Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…

\n\n for record in self:\n if record.email and not re.match(email_pattern, record.email):\n raise ValidationError(\n f\"Invalid email format: {record.email}\"\n )\n\[email protected]('phone')\ndef _check_phone_format(self):\n import re\n phone_pattern = r'^\\+?[\\d\\s-]{8,}

Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…

\n\n for record in self:\n if record.phone and not re.match(phone_pattern, record.phone):\n raise ValidationError(\n f\"Invalid phone format: {record.phone}\"\n )\n```\n\n### Validation with Context\n```python\[email protected]('quantity')\ndef _check_quantity_available(self):\n \"\"\"Skip validation during import.\"\"\"\n if self.env.context.get('import_mode'):\n return\n\n for record in self:\n available = record.product_id.qty_available\n if record.quantity > available:\n raise ValidationError(\n f\"Requested quantity ({record.quantity}) exceeds \"\n f\"available stock ({available}).\"\n )\n```\n\n---\n\n## Advanced Patterns\n\n### Hierarchical Validation\n```python\[email protected]('parent_id')\ndef _check_hierarchy(self):\n \"\"\"Prevent circular references.\"\"\"\n if not self._check_recursion():\n raise ValidationError(\n \"Error! You cannot create recursive categories.\"\n )\n```\n\n### State-Dependent Validation\n```python\[email protected]('state', 'partner_id', 'line_ids')\ndef _check_state_requirements(self):\n for record in self:\n if record.state == 'confirmed':\n if not record.partner_id:\n raise ValidationError(\n \"Partner is required for confirmed records.\"\n )\n if not record.line_ids:\n raise ValidationError(\n \"Lines are required for confirmed records.\"\n )\n```\n\n### Aggregate Validation\n```python\[email protected]('percentage')\ndef _check_total_percentage(self):\n \"\"\"Ensure percentages sum to 100%.\"\"\"\n for record in self:\n siblings = self.search([\n ('parent_id', '=', record.parent_id.id),\n ])\n total = sum(siblings.mapped('percentage'))\n\n if abs(total - 100) > 0.01: # Allow small rounding errors\n raise ValidationError(\n f\"Percentages must sum to 100%. Current total: {total}%\"\n )\n```\n\n### Business Period Validation\n```python\[email protected]('date', 'company_id')\ndef _check_fiscal_period(self):\n \"\"\"Ensure date is in open fiscal period.\"\"\"\n for record in self:\n period = self.env['account.fiscal.year'].search([\n ('company_id', '=', record.company_id.id),\n ('date_from', '\u003c=', record.date),\n ('date_to', '>=', record.date),\n ], limit=1)\n\n if not period:\n raise ValidationError(\n f\"No fiscal year defined for date {record.date}.\"\n )\n\n if period.state == 'closed':\n raise ValidationError(\n f\"Cannot post to closed fiscal period: {period.name}\"\n )\n```\n\n---\n\n## Constraint with Detailed Messages\n\n### Multiple Error Collection\n```python\[email protected]('name', 'code', 'amount', 'date_start', 'date_end')\ndef _check_all_fields(self):\n \"\"\"Validate all fields and collect errors.\"\"\"\n for record in self:\n errors = []\n\n if not record.name or len(record.name) \u003c 3:\n errors.append(\"Name must be at least 3 characters.\")\n\n if record.code and not record.code.isalnum():\n errors.append(\"Code must be alphanumeric only.\")\n\n if record.amount \u003c= 0:\n errors.append(\"Amount must be greater than zero.\")\n\n if record.date_start and record.date_end:\n if record.date_start > record.date_end:\n errors.append(\"Start date must be before end date.\")\n\n if errors:\n raise ValidationError(\"\\n\".join(errors))\n```\n\n### Field-Specific Error Messages\n```python\[email protected]('quantity', 'product_id')\ndef _check_quantity(self):\n for record in self:\n if record.quantity \u003c= 0:\n raise ValidationError(\n f\"Invalid quantity for product '{record.product_id.name}': \"\n f\"must be greater than zero.\"\n )\n\n min_qty = record.product_id.x_min_order_qty\n if min_qty and record.quantity \u003c min_qty:\n raise ValidationError(\n f\"Minimum order quantity for '{record.product_id.name}' \"\n f\"is {min_qty}. Requested: {record.quantity}\"\n )\n```\n\n---\n\n## When to Use Which\n\n### Use SQL Constraints For:\n- Simple uniqueness checks\n- Basic numeric checks (positive, range)\n- Database-level integrity\n- Performance-critical validations\n\n### Use Python Constraints For:\n- Complex business logic\n- Cross-record validation\n- External data validation\n- Conditional validation\n- Custom error messages\n- Validation that needs ORM features\n\n---\n\n## Best Practices\n\n1. **Prefer SQL for simple checks** - More efficient, database-level\n2. **Use Python for complex logic** - More flexibility\n3. **Clear error messages** - Tell user what's wrong and how to fix\n4. **Validate early** - Catch errors before processing\n5. **Consider context** - Skip validation during imports if appropriate\n6. **Test constraints** - Write tests for validation logic\n7. **Don't over-constrain** - Balance integrity vs usability\n8. **Document constraints** - Explain business rules\n9. **Handle upgrades** - New constraints may fail on existing data\n10. **Performance** - Avoid heavy queries in constraints\n\n---\n\n## Handling Existing Data\n\n### Adding Constraints to Existing Tables\n```python\n# In migration script\ndef migrate(cr, version):\n \"\"\"Fix data before adding constraint.\"\"\"\n # Fix invalid data first\n cr.execute(\"\"\"\n UPDATE my_model\n SET amount = 0\n WHERE amount \u003c 0\n \"\"\")\n\n # Remove duplicates\n cr.execute(\"\"\"\n DELETE FROM my_model a\n USING my_model b\n WHERE a.id > b.id\n AND a.code = b.code\n AND a.company_id = b.company_id\n \"\"\")\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13416,"content_sha256":"e53801f5939ccef6d2232dc9c3e53ebf9740d0c32ff87117513244e82dbfa483"},{"filename":"skills/context-environment-patterns.md","content":"# Context and Environment Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ CONTEXT & ENVIRONMENT PATTERNS ║\n║ Using context, environment, and recordset manipulation ║\n║ Use for passing data, changing behavior, and managing state ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Understanding the Environment\n\n### Environment Components\n```python\n# self.env contains:\n# - env.cr: Database cursor\n# - env.uid: Current user ID\n# - env.context: Context dictionary\n# - env.user: Current user record\n# - env.company: Current company\n# - env.companies: Accessible companies\n# - env.lang: Current language\n# - env.ref(): Get record by XML ID\n# - env['model.name']: Access model\n```\n\n### Accessing Environment\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def example_method(self):\n # Database cursor\n cr = self.env.cr\n\n # Current user\n user = self.env.user\n user_id = self.env.uid\n\n # Current company\n company = self.env.company\n companies = self.env.companies\n\n # Language\n lang = self.env.lang\n\n # Access other models\n partners = self.env['res.partner'].search([])\n\n # Get record by XML ID\n admin = self.env.ref('base.user_admin')\n\n # Context\n ctx = self.env.context\n```\n\n---\n\n## Context Usage\n\n### Reading Context Values\n```python\ndef my_method(self):\n # Get context value with default\n active_id = self.env.context.get('active_id')\n active_ids = self.env.context.get('active_ids', [])\n active_model = self.env.context.get('active_model')\n\n # Boolean context flags\n skip_validation = self.env.context.get('skip_validation', False)\n\n # With default\n limit = self.env.context.get('limit', 100)\n```\n\n### Passing Context\n```python\n# Using with_context()\ndef action_with_context(self):\n # Add to existing context\n record = self.with_context(my_flag=True)\n record.do_something()\n\n # Replace entire context\n record = self.with_context({'lang': 'en_US'})\n\n # Multiple values\n record = self.with_context(\n active_test=False,\n lang='fr_FR',\n custom_value=42,\n )\n```\n\n### Context in Fields\n```python\n# Default from context\npartner_id = fields.Many2one(\n 'res.partner',\n default=lambda self: self.env.context.get('default_partner_id'),\n)\n\n# In XML views\n\"\"\"\n\u003cfield name=\"partner_id\"\n context=\"{'default_company_id': company_id,\n 'show_archived': True}\"/>\n\"\"\"\n```\n\n### Context in Actions\n```python\ndef action_open_wizard(self):\n return {\n 'type': 'ir.actions.act_window',\n 'name': 'My Wizard',\n 'res_model': 'my.wizard',\n 'view_mode': 'form',\n 'target': 'new',\n 'context': {\n 'default_partner_id': self.partner_id.id,\n 'default_amount': self.amount_total,\n 'active_id': self.id,\n 'active_ids': self.ids,\n 'active_model': self._name,\n },\n }\n```\n\n---\n\n## Common Context Keys\n\n### Standard Keys\n```python\n# active_id/active_ids - Current record(s) from action\nactive_id = self.env.context.get('active_id')\nactive_ids = self.env.context.get('active_ids', [])\n\n# active_model - Model name\nactive_model = self.env.context.get('active_model')\n\n# default_* - Default values for fields\ndefault_name = self.env.context.get('default_name')\n\n# search_default_* - Default search filters\n# In action: context=\"{'search_default_my_filter': 1}\"\n\n# active_test - Include archived records\n# False = show archived, True/missing = hide archived\nrecords = self.with_context(active_test=False).search([])\n\n# lang - Language code\ntranslated = self.with_context(lang='fr_FR').name\n\n# tz - Timezone\n# Automatically used for datetime display\n\n# mail_create_nosubscribe - Don't auto-subscribe creator\n# mail_create_nolog - Don't create \"created\" message\n# mail_notrack - Don't track field changes\n# tracking_disable - Disable all tracking\n```\n\n### Custom Context Patterns\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def create(self, vals):\n # Check for context flags\n if self.env.context.get('import_mode'):\n # Skip validations during import\n pass\n\n if self.env.context.get('from_cron'):\n # Different behavior for scheduled actions\n pass\n\n return super().create(vals)\n\n def _compute_field(self):\n # Use context to modify computation\n if self.env.context.get('simplified_calculation'):\n # Simplified logic\n pass\n else:\n # Full calculation\n pass\n```\n\n---\n\n## Recordset Operations\n\n### Creating Records\n```python\n# Single record\nrecord = self.env['my.model'].create({\n 'name': 'Test',\n 'partner_id': partner.id,\n})\n\n# Multiple records (v17+)\nrecords = self.env['my.model'].create([\n {'name': 'Record 1'},\n {'name': 'Record 2'},\n])\n\n# With context\nrecord = self.env['my.model'].with_context(\n mail_create_nosubscribe=True\n).create(vals)\n```\n\n### Searching Records\n```python\n# Basic search\nrecords = self.env['my.model'].search([\n ('state', '=', 'draft'),\n])\n\n# With limit and order\nrecords = self.env['my.model'].search(\n [('state', '=', 'draft')],\n limit=10,\n order='create_date desc',\n)\n\n# Search and read in one call\ndata = self.env['my.model'].search_read(\n [('state', '=', 'draft')],\n ['name', 'state', 'amount'],\n limit=10,\n)\n\n# Count\ncount = self.env['my.model'].search_count([('state', '=', 'draft')])\n\n# Include archived\nall_records = self.env['my.model'].with_context(\n active_test=False\n).search([])\n```\n\n### Browsing Records\n```python\n# By ID\nrecord = self.env['my.model'].browse(record_id)\n\n# Multiple IDs\nrecords = self.env['my.model'].browse([1, 2, 3])\n\n# From context\nrecords = self.env['my.model'].browse(\n self.env.context.get('active_ids', [])\n)\n\n# Check existence\nif record.exists():\n # Record exists in database\n pass\n```\n\n### Recordset Manipulation\n```python\n# Combine recordsets (OR/union)\ncombined = records1 | records2\n\n# Intersection\ncommon = records1 & records2\n\n# Difference\ndiff = records1 - records2\n\n# Filter\ndraft_records = records.filtered(lambda r: r.state == 'draft')\ndraft_records = records.filtered('is_draft') # Boolean field\n\n# Map\nnames = records.mapped('name')\npartner_ids = records.mapped('partner_id.id')\npartners = records.mapped('partner_id') # Returns recordset\n\n# Sort\nsorted_records = records.sorted(key=lambda r: r.date)\nsorted_records = records.sorted('date', reverse=True)\n\n# Iterate\nfor record in records:\n record.do_something()\n\n# Check if recordset\nif records:\n # Has at least one record\n pass\n\n# Ensure single record\nrecord.ensure_one()\n```\n\n---\n\n## Changing User/Company Context\n\n### Change User\n```python\n# Execute as specific user\nadmin = self.env.ref('base.user_admin')\nrecord_as_admin = self.with_user(admin)\nrecord_as_admin.action_confirm()\n\n# Execute with sudo (superuser)\nself.sudo().write({'internal_field': value})\n\n# IMPORTANT: sudo() bypasses access rights\n# Use sparingly and carefully\n```\n\n### Change Company\n```python\n# Execute in different company context\nother_company = self.env['res.company'].browse(2)\nrecord_in_company = self.with_company(other_company)\nrecord_in_company.create_in_company()\n\n# Get company from context\ncompany = self.env.company # Current company\ncompanies = self.env.companies # All accessible\n```\n\n### Combining Context Changes\n```python\n# Change multiple aspects\nresult = self.sudo().with_company(company).with_context(\n skip_validation=True,\n lang='en_US',\n).create(vals)\n```\n\n---\n\n## Environment in Cron Jobs\n\n### Proper Cron Setup\n```python\[email protected]\ndef _cron_process(self):\n \"\"\"Cron method with proper environment handling.\"\"\"\n # Cron runs as OdooBot or specific user\n\n # Process records\n records = self.search([('state', '=', 'pending')])\n\n for record in records:\n try:\n # Use with_context for isolation\n record.with_context(from_cron=True)._process()\n\n # Commit after each to preserve progress\n self.env.cr.commit()\n\n except Exception as e:\n _logger.error(\"Failed to process %s: %s\", record.id, e)\n self.env.cr.rollback()\n```\n\n### Multi-Company Cron\n```python\[email protected]\ndef _cron_multi_company(self):\n \"\"\"Process for all companies.\"\"\"\n companies = self.env['res.company'].search([])\n\n for company in companies:\n self.with_company(company)._process_company()\n self.env.cr.commit()\n\ndef _process_company(self):\n \"\"\"Process for current company context.\"\"\"\n records = self.search([\n ('company_id', '=', self.env.company.id),\n ])\n # Process records\n```\n\n---\n\n## Cache and Invalidation\n\n### Cache Behavior\n```python\n# Records are cached in environment\nrecord = self.env['my.model'].browse(1)\nname1 = record.name # DB query\nname2 = record.name # From cache\n\n# Invalidate specific field\nself.env['my.model'].invalidate_model(['name'])\n\n# Invalidate all cache\nself.env.invalidate_all()\n\n# Clear all caches\nself.env.cache.clear()\n```\n\n### Refresh from Database\n```python\n# Invalidate and re-read\nrecord.invalidate_recordset()\nfresh_value = record.name\n\n# Or browse again\nrecord = self.env['my.model'].browse(record.id)\n```\n\n---\n\n## Best Practices\n\n### 1. Don't Modify Context Directly\n```python\n# Bad\nself.env.context['key'] = value\n\n# Good\nself = self.with_context(key=value)\n```\n\n### 2. Use sudo() Sparingly\n```python\n# Only when necessary, with minimal scope\npartner = self.sudo().partner_id # Just read related\npartner.sudo().write({'internal': True}) # Just this write\n```\n\n### 3. Preserve Context in Overrides\n```python\ndef create(self, vals):\n # Context is automatically preserved\n return super().create(vals)\n\n# If you need to modify:\ndef create(self, vals):\n self = self.with_context(creating=True)\n return super(MyModel, self).create(vals)\n```\n\n### 4. Use ensure_one() for Single Records\n```python\ndef action_confirm(self):\n self.ensure_one() # Raises if not exactly one record\n # Process single record\n```\n\n### 5. Handle Empty Recordsets\n```python\ndef get_partner_name(self):\n partner = self.partner_id\n # Bad - error if no partner\n return partner.name\n\n # Good\n return partner.name if partner else ''\n```\n\n### 6. Check Record Existence\n```python\nrecord = self.env['my.model'].browse(potentially_deleted_id)\nif record.exists():\n # Safe to use\n pass\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10969,"content_sha256":"5a8261005cfa1746cf321c28ee4af8b841d7f582aef0f52997ca08ccb235f8ea"},{"filename":"skills/controller-api-patterns.md","content":"# Controller and API Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ CONTROLLER & API PATTERNS ║\n║ HTTP controllers, REST endpoints, and web routes ║\n║ Use for APIs, webhooks, and custom web endpoints ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## File Structure\n\n```\nmy_module/\n├── controllers/\n│ ├── __init__.py\n│ └── main.py\n└── __manifest__.py\n```\n\n### __init__.py\n```python\nfrom . import main\n```\n\n---\n\n## Basic Controller\n\n```python\nfrom odoo import http\nfrom odoo.http import request\n\n\nclass MyController(http.Controller):\n\n @http.route('/my_module/hello', type='http', auth='public')\n def hello(self):\n \"\"\"Simple public endpoint.\"\"\"\n return \"Hello, World!\"\n\n @http.route('/my_module/data', type='json', auth='user')\n def get_data(self):\n \"\"\"JSON endpoint requiring authentication.\"\"\"\n records = request.env['my.model'].search([])\n return {\n 'status': 'success',\n 'count': len(records),\n 'data': records.read(['name', 'state']),\n }\n```\n\n---\n\n## Route Decorators\n\n### Basic Parameters\n```python\[email protected](\n route='/my_module/endpoint',\n type='http', # 'http' or 'json'\n auth='user', # 'public', 'user', 'none'\n methods=['GET', 'POST'],\n website=False, # True for website controllers\n csrf=True, # CSRF protection (default True)\n)\n```\n\n### Auth Types\n| Auth | Description |\n|------|-------------|\n| `public` | No login required, uses public user |\n| `user` | Login required |\n| `none` | No user context, manual handling |\n\n### Route Parameters\n```python\n# URL parameters\[email protected]('/my_module/record/\u003cint:record_id>')\ndef get_record(self, record_id):\n record = request.env['my.model'].browse(record_id)\n return record.name\n\n# Multiple parameters\[email protected]('/my_module/\u003cmodel>/\u003cint:id>/action')\ndef model_action(self, model, id):\n record = request.env[model].browse(id)\n return str(record)\n\n# Optional parameters\[email protected]('/my_module/search')\ndef search(self, query='', limit=10, **kw):\n records = request.env['my.model'].search(\n [('name', 'ilike', query)],\n limit=int(limit)\n )\n return str(records.ids)\n```\n\n---\n\n## HTTP Controllers\n\n### GET Endpoint\n```python\[email protected]('/api/v1/records', type='http', auth='user', methods=['GET'])\ndef list_records(self, **kw):\n \"\"\"List records with pagination.\"\"\"\n limit = int(kw.get('limit', 20))\n offset = int(kw.get('offset', 0))\n\n records = request.env['my.model'].search(\n [], limit=limit, offset=offset\n )\n\n data = []\n for record in records:\n data.append({\n 'id': record.id,\n 'name': record.name,\n 'state': record.state,\n })\n\n return request.make_response(\n json.dumps({'data': data}),\n headers=[('Content-Type', 'application/json')]\n )\n```\n\n### POST Endpoint\n```python\[email protected]('/api/v1/records', type='http', auth='user',\n methods=['POST'], csrf=False)\ndef create_record(self, **post):\n \"\"\"Create new record.\"\"\"\n try:\n record = request.env['my.model'].create({\n 'name': post.get('name'),\n 'description': post.get('description'),\n })\n\n return request.make_response(\n json.dumps({\n 'status': 'success',\n 'id': record.id,\n }),\n headers=[('Content-Type', 'application/json')]\n )\n except Exception as e:\n return request.make_response(\n json.dumps({\n 'status': 'error',\n 'message': str(e),\n }),\n status=400,\n headers=[('Content-Type', 'application/json')]\n )\n```\n\n---\n\n## JSON-RPC Controllers\n\n### Basic JSON Endpoint\n```python\[email protected]('/api/v1/json/records', type='json', auth='user')\ndef json_list_records(self, domain=None, fields=None, limit=100):\n \"\"\"JSON-RPC endpoint for listing records.\"\"\"\n domain = domain or []\n fields = fields or ['name', 'state']\n\n records = request.env['my.model'].search_read(\n domain, fields, limit=limit\n )\n\n return {\n 'status': 'success',\n 'count': len(records),\n 'records': records,\n }\n```\n\n### JSON with Validation\n```python\[email protected]('/api/v1/json/create', type='json', auth='user')\ndef json_create(self, name, **kwargs):\n \"\"\"Create record with validation.\"\"\"\n if not name:\n return {\n 'status': 'error',\n 'message': 'Name is required',\n }\n\n try:\n vals = {'name': name}\n if kwargs.get('partner_id'):\n vals['partner_id'] = int(kwargs['partner_id'])\n\n record = request.env['my.model'].create(vals)\n\n return {\n 'status': 'success',\n 'id': record.id,\n 'name': record.name,\n }\n except Exception as e:\n return {\n 'status': 'error',\n 'message': str(e),\n }\n```\n\n---\n\n## REST API Pattern\n\n### Complete CRUD Controller\n```python\nimport json\nfrom odoo import http\nfrom odoo.http import request, Response\n\n\nclass MyAPIController(http.Controller):\n \"\"\"REST API for my.model\"\"\"\n\n def _get_record(self, record_id):\n \"\"\"Helper to get record with error handling.\"\"\"\n record = request.env['my.model'].browse(record_id)\n if not record.exists():\n return None\n return record\n\n def _json_response(self, data, status=200):\n \"\"\"Helper to create JSON response.\"\"\"\n return Response(\n json.dumps(data),\n status=status,\n mimetype='application/json'\n )\n\n # LIST\n @http.route('/api/v1/mymodel', type='http', auth='user',\n methods=['GET'], csrf=False)\n def list(self, **kw):\n \"\"\"GET /api/v1/mymodel - List all records.\"\"\"\n domain = []\n if kw.get('state'):\n domain.append(('state', '=', kw['state']))\n\n records = request.env['my.model'].search_read(\n domain,\n ['id', 'name', 'state', 'create_date'],\n limit=int(kw.get('limit', 100)),\n offset=int(kw.get('offset', 0)),\n )\n\n return self._json_response({\n 'status': 'success',\n 'data': records,\n })\n\n # GET\n @http.route('/api/v1/mymodel/\u003cint:id>', type='http', auth='user',\n methods=['GET'], csrf=False)\n def get(self, id):\n \"\"\"GET /api/v1/mymodel/{id} - Get single record.\"\"\"\n record = self._get_record(id)\n if not record:\n return self._json_response(\n {'status': 'error', 'message': 'Not found'},\n status=404\n )\n\n return self._json_response({\n 'status': 'success',\n 'data': {\n 'id': record.id,\n 'name': record.name,\n 'state': record.state,\n 'partner_id': record.partner_id.id,\n 'partner_name': record.partner_id.name,\n },\n })\n\n # CREATE\n @http.route('/api/v1/mymodel', type='http', auth='user',\n methods=['POST'], csrf=False)\n def create(self, **post):\n \"\"\"POST /api/v1/mymodel - Create record.\"\"\"\n try:\n required = ['name']\n for field in required:\n if not post.get(field):\n return self._json_response(\n {'status': 'error', 'message': f'{field} is required'},\n status=400\n )\n\n vals = {\n 'name': post['name'],\n }\n if post.get('partner_id'):\n vals['partner_id'] = int(post['partner_id'])\n\n record = request.env['my.model'].create(vals)\n\n return self._json_response({\n 'status': 'success',\n 'id': record.id,\n }, status=201)\n\n except Exception as e:\n return self._json_response(\n {'status': 'error', 'message': str(e)},\n status=500\n )\n\n # UPDATE\n @http.route('/api/v1/mymodel/\u003cint:id>', type='http', auth='user',\n methods=['PUT', 'PATCH'], csrf=False)\n def update(self, id, **post):\n \"\"\"PUT/PATCH /api/v1/mymodel/{id} - Update record.\"\"\"\n record = self._get_record(id)\n if not record:\n return self._json_response(\n {'status': 'error', 'message': 'Not found'},\n status=404\n )\n\n try:\n vals = {}\n if 'name' in post:\n vals['name'] = post['name']\n if 'state' in post:\n vals['state'] = post['state']\n\n if vals:\n record.write(vals)\n\n return self._json_response({\n 'status': 'success',\n 'id': record.id,\n })\n\n except Exception as e:\n return self._json_response(\n {'status': 'error', 'message': str(e)},\n status=500\n )\n\n # DELETE\n @http.route('/api/v1/mymodel/\u003cint:id>', type='http', auth='user',\n methods=['DELETE'], csrf=False)\n def delete(self, id):\n \"\"\"DELETE /api/v1/mymodel/{id} - Delete record.\"\"\"\n record = self._get_record(id)\n if not record:\n return self._json_response(\n {'status': 'error', 'message': 'Not found'},\n status=404\n )\n\n try:\n record.unlink()\n return self._json_response({\n 'status': 'success',\n 'message': 'Deleted',\n })\n\n except Exception as e:\n return self._json_response(\n {'status': 'error', 'message': str(e)},\n status=500\n )\n```\n\n---\n\n## Webhook Endpoint\n\n```python\nimport hmac\nimport hashlib\n\nclass WebhookController(http.Controller):\n\n @http.route('/webhook/my_module', type='json', auth='none',\n methods=['POST'], csrf=False)\n def webhook_handler(self):\n \"\"\"Handle incoming webhook.\"\"\"\n # Get raw data\n data = request.jsonrequest\n\n # Verify signature (example)\n signature = request.httprequest.headers.get('X-Signature')\n secret = request.env['ir.config_parameter'].sudo().get_param(\n 'my_module.webhook_secret'\n )\n\n if not self._verify_signature(data, signature, secret):\n return {'status': 'error', 'message': 'Invalid signature'}\n\n # Process webhook\n try:\n event_type = data.get('event')\n payload = data.get('payload', {})\n\n if event_type == 'order.created':\n self._handle_order_created(payload)\n elif event_type == 'payment.received':\n self._handle_payment_received(payload)\n\n return {'status': 'success'}\n\n except Exception as e:\n return {'status': 'error', 'message': str(e)}\n\n def _verify_signature(self, data, signature, secret):\n \"\"\"Verify webhook signature.\"\"\"\n if not signature or not secret:\n return False\n expected = hmac.new(\n secret.encode(),\n json.dumps(data).encode(),\n hashlib.sha256\n ).hexdigest()\n return hmac.compare_digest(signature, expected)\n\n def _handle_order_created(self, payload):\n \"\"\"Handle order created event.\"\"\"\n request.env['my.model'].sudo().create({\n 'name': payload.get('order_id'),\n 'external_id': payload.get('id'),\n })\n```\n\n---\n\n## File Download/Upload\n\n### File Download\n```python\[email protected]('/my_module/download/\u003cint:id>', type='http', auth='user')\ndef download_file(self, id):\n \"\"\"Download file attachment.\"\"\"\n record = request.env['my.model'].browse(id)\n if not record.exists() or not record.file:\n return request.not_found()\n\n return request.make_response(\n base64.b64decode(record.file),\n headers=[\n ('Content-Type', 'application/octet-stream'),\n ('Content-Disposition', f'attachment; filename=\"{record.filename}\"'),\n ]\n )\n```\n\n### File Upload\n```python\[email protected]('/my_module/upload', type='http', auth='user',\n methods=['POST'], csrf=False)\ndef upload_file(self, **post):\n \"\"\"Upload file.\"\"\"\n file = post.get('file')\n if not file:\n return json.dumps({'error': 'No file provided'})\n\n try:\n content = base64.b64encode(file.read())\n filename = file.filename\n\n record = request.env['my.model'].create({\n 'name': filename,\n 'file': content,\n 'filename': filename,\n })\n\n return json.dumps({\n 'status': 'success',\n 'id': record.id,\n })\n\n except Exception as e:\n return json.dumps({'error': str(e)})\n```\n\n---\n\n## Authentication\n\n### API Key Authentication\n```python\nclass APIKeyController(http.Controller):\n\n def _check_api_key(self):\n \"\"\"Validate API key from header.\"\"\"\n api_key = request.httprequest.headers.get('X-API-Key')\n if not api_key:\n return False\n\n valid_key = request.env['ir.config_parameter'].sudo().get_param(\n 'my_module.api_key'\n )\n return api_key == valid_key\n\n @http.route('/api/secure/data', type='json', auth='none', csrf=False)\n def secure_endpoint(self):\n \"\"\"Endpoint with API key auth.\"\"\"\n if not self._check_api_key():\n return {'error': 'Invalid API key'}, 401\n\n # Process request with sudo (no user context)\n data = request.env['my.model'].sudo().search_read([], ['name'])\n return {'data': data}\n```\n\n---\n\n## Best Practices\n\n1. **Use appropriate auth** - `public` for public APIs, `user` for authenticated\n2. **Handle errors gracefully** - Return proper HTTP status codes\n3. **Validate input** - Check required fields and types\n4. **Use sudo carefully** - Only when necessary for public endpoints\n5. **CSRF protection** - Disable only for legitimate API endpoints\n6. **Rate limiting** - Implement for public APIs\n7. **Logging** - Log API requests for debugging\n8. **Documentation** - Document endpoints for consumers\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14805,"content_sha256":"1fe3d4721c40aeaeea1d07937550353a0462daa168a10552cf4ac6bbfd84048e"},{"filename":"skills/cron-automation-patterns.md","content":"# Scheduled Actions and Automation Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ CRON & AUTOMATION PATTERNS ║\n║ Scheduled actions, server actions, and automated rules ║\n║ Use for background jobs, triggers, and workflow automation ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Scheduled Actions (Cron Jobs)\n\n### Basic Cron Definition (XML)\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"ir_cron_process_pending\" model=\"ir.cron\">\n \u003cfield name=\"name\">My Module: Process Pending Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">model._cron_process_pending()\u003c/field>\n \u003cfield name=\"interval_number\">1\u003c/field>\n \u003cfield name=\"interval_type\">hours\u003c/field>\n \u003cfield name=\"numbercall\">-1\u003c/field>\n \u003cfield name=\"active\">True\u003c/field>\n \u003cfield name=\"doall\">False\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Interval Types\n| Type | Description |\n|------|-------------|\n| `minutes` | Run every X minutes |\n| `hours` | Run every X hours |\n| `days` | Run every X days |\n| `weeks` | Run every X weeks |\n| `months` | Run every X months |\n\n### Cron Attributes\n| Attribute | Description |\n|-----------|-------------|\n| `interval_number` | Number of intervals |\n| `interval_type` | Type of interval |\n| `numbercall` | -1 for infinite, or count |\n| `active` | Enable/disable cron |\n| `doall` | Run missed executions |\n| `nextcall` | Next execution datetime |\n| `priority` | Execution priority (lower = first) |\n\n---\n\n## Python Cron Methods (v18)\n\n### Basic Cron Method\n```python\nfrom odoo import api, models\nimport logging\n\n_logger = logging.getLogger(__name__)\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @api.model\n def _cron_process_pending(self) -> None:\n \"\"\"Process pending records - called by scheduled action.\"\"\"\n _logger.info(\"Starting cron: process pending records\")\n\n records = self.search([('state', '=', 'pending')], limit=100)\n _logger.info(f\"Found {len(records)} pending records\")\n\n for record in records:\n try:\n record._process_single()\n except Exception as e:\n _logger.error(f\"Error processing {record.id}: {e}\")\n\n _logger.info(\"Cron completed: process pending records\")\n```\n\n### Batch Processing Cron\n```python\[email protected]\ndef _cron_batch_process(self) -> None:\n \"\"\"Process records in batches with commits.\"\"\"\n batch_size = 100\n offset = 0\n processed = 0\n\n while True:\n # Fetch batch\n records = self.search(\n [('state', '=', 'pending')],\n limit=batch_size,\n offset=offset,\n )\n\n if not records:\n break\n\n for record in records:\n try:\n record.with_context(from_cron=True)._do_process()\n processed += 1\n except Exception as e:\n _logger.error(f\"Error on record {record.id}: {e}\")\n\n # Commit batch and clear cache\n self.env.cr.commit()\n self.env.invalidate_all()\n\n offset += batch_size\n _logger.info(f\"Processed {processed} records so far...\")\n\n _logger.info(f\"Batch process complete: {processed} records\")\n```\n\n### Time-Limited Cron\n```python\nimport time\n\[email protected]\ndef _cron_time_limited_process(self) -> None:\n \"\"\"Process with time limit to prevent long-running jobs.\"\"\"\n max_duration = 300 # 5 minutes\n start_time = time.time()\n processed = 0\n\n records = self.search([('needs_sync', '=', True)])\n\n for record in records:\n # Check time limit\n if time.time() - start_time > max_duration:\n _logger.warning(\n f\"Time limit reached after {processed} records. \"\n f\"Remaining: {len(records) - processed}\"\n )\n break\n\n try:\n record._sync_external()\n processed += 1\n except Exception as e:\n _logger.error(f\"Sync error for {record.id}: {e}\")\n\n # Periodic commit\n if processed % 50 == 0:\n self.env.cr.commit()\n\n _logger.info(f\"Processed {processed}/{len(records)} records\")\n```\n\n---\n\n## Server Actions\n\n### Python Code Action\n```xml\n\u003crecord id=\"action_mark_done\" model=\"ir.actions.server\">\n \u003cfield name=\"name\">Mark as Done\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"binding_model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"binding_view_types\">list,form\u003c/field>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\nif records:\n records.write({'state': 'done'})\n \u003c/field>\n\u003c/record>\n```\n\n### Multi-Record Action\n```xml\n\u003crecord id=\"action_batch_confirm\" model=\"ir.actions.server\">\n \u003cfield name=\"name\">Confirm Selected\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"binding_model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"binding_view_types\">list\u003c/field>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\nfor record in records:\n if record.state == 'draft':\n record.action_confirm()\n \u003c/field>\n\u003c/record>\n```\n\n### Action with Notification\n```xml\n\u003crecord id=\"action_notify_users\" model=\"ir.actions.server\">\n \u003cfield name=\"name\">Notify Users\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"binding_model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\ncount = len(records)\nrecords.action_send_notification()\naction = {\n 'type': 'ir.actions.client',\n 'tag': 'display_notification',\n 'params': {\n 'title': 'Success',\n 'message': f'Notified {count} users.',\n 'type': 'success',\n 'sticky': False,\n }\n}\n \u003c/field>\n\u003c/record>\n```\n\n---\n\n## Automated Actions (Base Automation)\n\n### On Create Trigger\n```xml\n\u003crecord id=\"automation_on_create\" model=\"base.automation\">\n \u003cfield name=\"name\">Auto-assign on Create\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"trigger\">on_create\u003c/field>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\nfor record in records:\n if not record.user_id:\n record.user_id = record.create_uid\n \u003c/field>\n\u003c/record>\n```\n\n### On Write Trigger\n```xml\n\u003crecord id=\"automation_on_state_change\" model=\"base.automation\">\n \u003cfield name=\"name\">Notify on State Change\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"trigger\">on_write\u003c/field>\n \u003cfield name=\"trigger_field_ids\" eval=\"[(6, 0, [ref('field_my_model__state')])]\"/>\n \u003cfield name=\"filter_domain\">[('state', '=', 'confirmed')]\u003c/field>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\nrecords.message_post(\n body=\"Record has been confirmed.\",\n message_type='notification',\n)\n \u003c/field>\n\u003c/record>\n```\n\n### Time-Based Trigger\n```xml\n\u003crecord id=\"automation_overdue_check\" model=\"base.automation\">\n \u003cfield name=\"name\">Mark Overdue Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"trigger\">on_time\u003c/field>\n \u003cfield name=\"trg_date_id\" ref=\"field_my_model__deadline\"/>\n \u003cfield name=\"trg_date_range\">1\u003c/field>\n \u003cfield name=\"trg_date_range_type\">day\u003c/field>\n \u003cfield name=\"filter_domain\">[('state', 'not in', ['done', 'cancel'])]\u003c/field>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\nrecords.write({'is_overdue': True})\nrecords.message_post(body=\"This record is now overdue!\")\n \u003c/field>\n\u003c/record>\n```\n\n### Trigger Types\n| Trigger | When Executed |\n|---------|---------------|\n| `on_create` | After record creation |\n| `on_write` | After record update |\n| `on_create_or_write` | After create or update |\n| `on_unlink` | Before record deletion |\n| `on_time` | Based on date field |\n\n---\n\n## Queue Jobs (for Heavy Processing)\n\n### Using ir.cron with Batching\n```python\[email protected]\ndef _cron_heavy_process(self) -> None:\n \"\"\"Heavy process with queue-like behavior.\"\"\"\n # Get unprocessed records\n to_process = self.search([\n ('processed', '=', False),\n ('attempts', '\u003c', 3), # Max retry attempts\n ], limit=50)\n\n for record in to_process:\n try:\n record.with_context(processing=True)._heavy_operation()\n record.processed = True\n record.processed_date = fields.Datetime.now()\n except Exception as e:\n record.attempts += 1\n record.last_error = str(e)\n _logger.error(f\"Processing failed for {record.id}: {e}\")\n\n # Commit after each to preserve progress\n self.env.cr.commit()\n```\n\n### Deferred Processing Pattern\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n process_state = fields.Selection([\n ('pending', 'Pending'),\n ('processing', 'Processing'),\n ('done', 'Done'),\n ('error', 'Error'),\n ], default='pending')\n process_error = fields.Text()\n\n def action_queue_for_processing(self) -> None:\n \"\"\"Queue records for cron processing.\"\"\"\n self.write({\n 'process_state': 'pending',\n 'process_error': False,\n })\n\n @api.model\n def _cron_process_queue(self) -> None:\n \"\"\"Process queued records.\"\"\"\n records = self.search([\n ('process_state', '=', 'pending')\n ], limit=20)\n\n for record in records:\n record.process_state = 'processing'\n self.env.cr.commit()\n\n try:\n record._do_heavy_work()\n record.process_state = 'done'\n except Exception as e:\n record.process_state = 'error'\n record.process_error = str(e)\n\n self.env.cr.commit()\n```\n\n---\n\n## Best Practices\n\n### 1. Logging\n```python\nimport logging\n_logger = logging.getLogger(__name__)\n\[email protected]\ndef _cron_task(self) -> None:\n _logger.info(\"Cron started: %s\", self._name)\n try:\n # Work here\n _logger.info(\"Cron completed successfully\")\n except Exception as e:\n _logger.exception(\"Cron failed: %s\", e)\n raise\n```\n\n### 2. Transaction Safety\n```python\[email protected]\ndef _cron_safe_process(self) -> None:\n \"\"\"Process with proper transaction handling.\"\"\"\n records = self.search([('pending', '=', True)])\n\n for record in records:\n # Use new cursor for isolation\n try:\n with self.env.cr.savepoint():\n record._process()\n except Exception as e:\n _logger.error(f\"Failed {record.id}: {e}\")\n # Savepoint rollback - continue with next\n continue\n```\n\n### 3. Idempotency\n```python\[email protected]\ndef _cron_idempotent_sync(self) -> None:\n \"\"\"Idempotent sync - safe to run multiple times.\"\"\"\n records = self.search([\n ('needs_sync', '=', True),\n ('last_sync_attempt', '\u003c', fields.Datetime.now() - timedelta(minutes=5)),\n ])\n\n for record in records:\n record.last_sync_attempt = fields.Datetime.now()\n self.env.cr.commit()\n\n try:\n record._sync()\n record.needs_sync = False\n except Exception:\n pass # Will retry on next run\n```\n\n### 4. Monitoring\n```python\[email protected]\ndef _cron_with_monitoring(self) -> None:\n \"\"\"Cron with execution tracking.\"\"\"\n start = fields.Datetime.now()\n\n try:\n count = self._do_work()\n status = 'success'\n error = False\n except Exception as e:\n count = 0\n status = 'error'\n error = str(e)\n\n # Log execution\n self.env['my.cron.log'].create({\n 'cron_name': 'process_pending',\n 'start_time': start,\n 'end_time': fields.Datetime.now(),\n 'records_processed': count,\n 'status': status,\n 'error_message': error,\n })\n```\n\n---\n\n## Cron Security\n\n### Manifest Declaration\n```python\n# Cron data file must be in 'data' section\n'data': [\n 'data/cron.xml',\n]\n```\n\n### Access Rights\nCrons run as the user who created them (usually admin). For specific user context:\n\n```python\[email protected]\ndef _cron_as_specific_user(self) -> None:\n \"\"\"Run as specific user for proper access rights.\"\"\"\n cron_user = self.env.ref('my_module.cron_service_user')\n self_as_user = self.with_user(cron_user)\n self_as_user._do_work()\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12698,"content_sha256":"a173d9580901b501c2e2106dd856f5d6becb9e7f09594c737b5d39ca8f8881bb"},{"filename":"skills/dashboard-kpi-patterns.md","content":"# Dashboard and KPI Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ DASHBOARD & KPI PATTERNS ║\n║ Analytics views, KPI displays, and business intelligence ║\n║ Use for data visualization and executive dashboards ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Dashboard Model (Pivot/Graph Base)\n\n### Analytics Model for Reporting\n```python\nfrom odoo import api, fields, models, tools\n\n\nclass SaleAnalysis(models.Model):\n _name = 'sale.analysis'\n _description = 'Sales Analysis'\n _auto = False # No table created - it's a database view\n _order = 'date desc'\n\n # Dimension fields\n date = fields.Date(string='Date', readonly=True)\n partner_id = fields.Many2one('res.partner', string='Customer', readonly=True)\n product_id = fields.Many2one('product.product', string='Product', readonly=True)\n categ_id = fields.Many2one('product.category', string='Category', readonly=True)\n user_id = fields.Many2one('res.users', string='Salesperson', readonly=True)\n company_id = fields.Many2one('res.company', string='Company', readonly=True)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('sale', 'Confirmed'),\n ('done', 'Done'),\n ('cancel', 'Cancelled'),\n ], string='Status', readonly=True)\n\n # Measure fields\n order_count = fields.Integer(string='# Orders', readonly=True)\n product_qty = fields.Float(string='Qty Sold', readonly=True)\n price_subtotal = fields.Float(string='Untaxed Total', readonly=True)\n price_total = fields.Float(string='Total', readonly=True)\n\n def init(self):\n \"\"\"Create database view for analysis.\"\"\"\n tools.drop_view_if_exists(self.env.cr, self._table)\n self.env.cr.execute(\"\"\"\n CREATE OR REPLACE VIEW %s AS (\n SELECT\n row_number() OVER () as id,\n so.date_order::date as date,\n so.partner_id,\n sol.product_id,\n pt.categ_id,\n so.user_id,\n so.company_id,\n so.state,\n COUNT(DISTINCT so.id) as order_count,\n SUM(sol.product_uom_qty) as product_qty,\n SUM(sol.price_subtotal) as price_subtotal,\n SUM(sol.price_total) as price_total\n FROM sale_order so\n JOIN sale_order_line sol ON sol.order_id = so.id\n JOIN product_product pp ON pp.id = sol.product_id\n JOIN product_template pt ON pt.id = pp.product_tmpl_id\n GROUP BY\n so.date_order::date,\n so.partner_id,\n sol.product_id,\n pt.categ_id,\n so.user_id,\n so.company_id,\n so.state\n )\n \"\"\" % self._table)\n```\n\n---\n\n## Dashboard Views\n\n### Pivot View\n```xml\n\u003crecord id=\"sale_analysis_view_pivot\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.analysis.pivot\u003c/field>\n \u003cfield name=\"model\">sale.analysis\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cpivot string=\"Sales Analysis\" display_quantity=\"true\">\n \u003cfield name=\"date\" type=\"row\" interval=\"month\"/>\n \u003cfield name=\"categ_id\" type=\"row\"/>\n \u003cfield name=\"user_id\" type=\"col\"/>\n \u003cfield name=\"price_total\" type=\"measure\"/>\n \u003cfield name=\"product_qty\" type=\"measure\"/>\n \u003cfield name=\"order_count\" type=\"measure\"/>\n \u003c/pivot>\n \u003c/field>\n\u003c/record>\n```\n\n### Graph View\n```xml\n\u003crecord id=\"sale_analysis_view_graph\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.analysis.graph\u003c/field>\n \u003cfield name=\"model\">sale.analysis\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cgraph string=\"Sales Analysis\" type=\"bar\" stacked=\"True\">\n \u003cfield name=\"date\" type=\"row\" interval=\"month\"/>\n \u003cfield name=\"price_total\" type=\"measure\"/>\n \u003c/graph>\n \u003c/field>\n\u003c/record>\n\n\u003c!-- Line Chart -->\n\u003crecord id=\"sale_analysis_view_graph_line\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.analysis.graph.line\u003c/field>\n \u003cfield name=\"model\">sale.analysis\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cgraph string=\"Sales Trend\" type=\"line\">\n \u003cfield name=\"date\" type=\"row\" interval=\"day\"/>\n \u003cfield name=\"price_total\" type=\"measure\"/>\n \u003c/graph>\n \u003c/field>\n\u003c/record>\n\n\u003c!-- Pie Chart -->\n\u003crecord id=\"sale_analysis_view_graph_pie\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.analysis.graph.pie\u003c/field>\n \u003cfield name=\"model\">sale.analysis\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cgraph string=\"Sales by Category\" type=\"pie\">\n \u003cfield name=\"categ_id\" type=\"row\"/>\n \u003cfield name=\"price_total\" type=\"measure\"/>\n \u003c/graph>\n \u003c/field>\n\u003c/record>\n```\n\n### Dashboard Action\n```xml\n\u003crecord id=\"action_sale_analysis\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">Sales Analysis\u003c/field>\n \u003cfield name=\"res_model\">sale.analysis\u003c/field>\n \u003cfield name=\"view_mode\">graph,pivot\u003c/field>\n \u003cfield name=\"context\">{\n 'search_default_current_month': 1,\n 'group_by': ['date:month'],\n }\u003c/field>\n \u003cfield name=\"help\" type=\"html\">\n \u003cp class=\"o_view_nocontent_smiling_face\">\n No data to display\n \u003c/p>\n \u003c/field>\n\u003c/record>\n```\n\n---\n\n## KPI Stat Buttons\n\n### Button Box Pattern\n```python\nclass Partner(models.Model):\n _inherit = 'res.partner'\n\n # KPI counters\n sale_order_count = fields.Integer(\n compute='_compute_sale_count',\n string='Sales',\n )\n sale_total = fields.Monetary(\n compute='_compute_sale_count',\n string='Total Sales',\n )\n invoice_count = fields.Integer(\n compute='_compute_invoice_count',\n string='Invoices',\n )\n open_invoice_amount = fields.Monetary(\n compute='_compute_invoice_count',\n string='Due Amount',\n )\n\n def _compute_sale_count(self):\n for partner in self:\n orders = self.env['sale.order'].search([\n ('partner_id', '=', partner.id),\n ('state', 'in', ['sale', 'done']),\n ])\n partner.sale_order_count = len(orders)\n partner.sale_total = sum(orders.mapped('amount_total'))\n\n def _compute_invoice_count(self):\n for partner in self:\n invoices = self.env['account.move'].search([\n ('partner_id', '=', partner.id),\n ('move_type', '=', 'out_invoice'),\n ])\n partner.invoice_count = len(invoices)\n partner.open_invoice_amount = sum(\n inv.amount_residual\n for inv in invoices\n if inv.payment_state != 'paid'\n )\n\n def action_view_sales(self):\n \"\"\"Open related sales.\"\"\"\n return {\n 'type': 'ir.actions.act_window',\n 'name': 'Sales',\n 'res_model': 'sale.order',\n 'view_mode': 'tree,form',\n 'domain': [('partner_id', '=', self.id)],\n }\n\n def action_view_invoices(self):\n \"\"\"Open related invoices.\"\"\"\n return {\n 'type': 'ir.actions.act_window',\n 'name': 'Invoices',\n 'res_model': 'account.move',\n 'view_mode': 'tree,form',\n 'domain': [\n ('partner_id', '=', self.id),\n ('move_type', '=', 'out_invoice'),\n ],\n }\n```\n\n### Button Box View\n```xml\n\u003cform>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\">\n \u003c!-- Sales stat button -->\n \u003cbutton name=\"action_view_sales\" type=\"object\"\n class=\"oe_stat_button\" icon=\"fa-dollar\">\n \u003cdiv class=\"o_field_widget o_stat_info\">\n \u003cspan class=\"o_stat_value\">\n \u003cfield name=\"sale_order_count\"/>\n \u003c/span>\n \u003cspan class=\"o_stat_text\">Sales\u003c/span>\n \u003c/div>\n \u003cdiv class=\"o_stat_info\" invisible=\"not sale_total\">\n \u003cspan class=\"o_stat_value\">\n \u003cfield name=\"sale_total\" widget=\"monetary\"/>\n \u003c/span>\n \u003c/div>\n \u003c/button>\n\n \u003c!-- Invoice stat button -->\n \u003cbutton name=\"action_view_invoices\" type=\"object\"\n class=\"oe_stat_button\" icon=\"fa-book\"\n invisible=\"invoice_count == 0\">\n \u003cdiv class=\"o_field_widget o_stat_info\">\n \u003cspan class=\"o_stat_value\">\n \u003cfield name=\"invoice_count\"/>\n \u003c/span>\n \u003cspan class=\"o_stat_text\">Invoices\u003c/span>\n \u003c/div>\n \u003c/button>\n\n \u003c!-- Alert indicator -->\n \u003cbutton name=\"action_view_open_invoices\" type=\"object\"\n class=\"oe_stat_button\" icon=\"fa-exclamation-triangle\"\n invisible=\"open_invoice_amount == 0\">\n \u003cdiv class=\"o_stat_info text-danger\">\n \u003cspan class=\"o_stat_value\">\n \u003cfield name=\"open_invoice_amount\" widget=\"monetary\"/>\n \u003c/span>\n \u003cspan class=\"o_stat_text\">Due\u003c/span>\n \u003c/div>\n \u003c/button>\n \u003c/div>\n \u003c/sheet>\n\u003c/form>\n```\n\n---\n\n## OWL Dashboard Component\n\n### Dashboard Action (v16+)\n```javascript\n/** @odoo-module **/\nimport { registry } from \"@web/core/registry\";\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\n\nclass MyDashboard extends Component {\n static template = \"my_module.Dashboard\";\n\n setup() {\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n\n this.state = useState({\n kpis: {},\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadKPIs();\n });\n }\n\n async loadKPIs() {\n this.state.loading = true;\n try {\n this.state.kpis = await this.orm.call(\n \"my.dashboard\",\n \"get_dashboard_data\",\n []\n );\n } finally {\n this.state.loading = false;\n }\n }\n\n openAction(action) {\n this.action.doAction(action);\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.dashboard\", MyDashboard);\n```\n\n### Dashboard Template\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.Dashboard\">\n \u003cdiv class=\"o_my_dashboard container-fluid\">\n \u003cdiv class=\"row mt-4\">\n \u003c!-- KPI Cards -->\n \u003cdiv class=\"col-lg-3 col-md-6 mb-4\">\n \u003cdiv class=\"card bg-primary text-white h-100\"\n t-on-click=\"() => this.openAction('sale.action_orders')\">\n \u003cdiv class=\"card-body\">\n \u003cdiv class=\"d-flex justify-content-between align-items-center\">\n \u003cdiv>\n \u003ch6 class=\"text-white-50\">Sales\u003c/h6>\n \u003ch2 t-esc=\"state.kpis.sale_count || 0\"/>\n \u003c/div>\n \u003ci class=\"fa fa-shopping-cart fa-3x opacity-50\"/>\n \u003c/div>\n \u003c/div>\n \u003cdiv class=\"card-footer bg-transparent border-0\">\n \u003csmall>This Month: \u003ct t-esc=\"state.kpis.sale_amount || 0\"/>\u003c/small>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n\n \u003cdiv class=\"col-lg-3 col-md-6 mb-4\">\n \u003cdiv class=\"card bg-success text-white h-100\">\n \u003cdiv class=\"card-body\">\n \u003cdiv class=\"d-flex justify-content-between align-items-center\">\n \u003cdiv>\n \u003ch6 class=\"text-white-50\">Revenue\u003c/h6>\n \u003ch2 t-esc=\"state.kpis.revenue || 0\"/>\n \u003c/div>\n \u003ci class=\"fa fa-dollar fa-3x opacity-50\"/>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n\n \u003cdiv class=\"col-lg-3 col-md-6 mb-4\">\n \u003cdiv class=\"card bg-warning text-dark h-100\">\n \u003cdiv class=\"card-body\">\n \u003cdiv class=\"d-flex justify-content-between align-items-center\">\n \u003cdiv>\n \u003ch6>Pending\u003c/h6>\n \u003ch2 t-esc=\"state.kpis.pending_count || 0\"/>\n \u003c/div>\n \u003ci class=\"fa fa-clock-o fa-3x opacity-50\"/>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n\n \u003cdiv class=\"col-lg-3 col-md-6 mb-4\">\n \u003cdiv class=\"card bg-danger text-white h-100\">\n \u003cdiv class=\"card-body\">\n \u003cdiv class=\"d-flex justify-content-between align-items-center\">\n \u003cdiv>\n \u003ch6 class=\"text-white-50\">Overdue\u003c/h6>\n \u003ch2 t-esc=\"state.kpis.overdue_count || 0\"/>\n \u003c/div>\n \u003ci class=\"fa fa-exclamation-triangle fa-3x opacity-50\"/>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n\n \u003c!-- Chart Area -->\n \u003cdiv class=\"row\">\n \u003cdiv class=\"col-lg-8 mb-4\">\n \u003cdiv class=\"card\">\n \u003cdiv class=\"card-header\">\n \u003ch5>Sales Trend\u003c/h5>\n \u003c/div>\n \u003cdiv class=\"card-body\">\n \u003c!-- Embed graph view or custom chart -->\n \u003cdiv id=\"sales_chart\" style=\"height: 300px;\"/>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003cdiv class=\"col-lg-4 mb-4\">\n \u003cdiv class=\"card\">\n \u003cdiv class=\"card-header\">\n \u003ch5>Top Products\u003c/h5>\n \u003c/div>\n \u003cdiv class=\"card-body\">\n \u003cul class=\"list-group list-group-flush\">\n \u003ct t-foreach=\"state.kpis.top_products || []\" t-as=\"product\">\n \u003cli class=\"list-group-item d-flex justify-content-between\">\n \u003cspan t-esc=\"product.name\"/>\n \u003cspan class=\"badge bg-primary rounded-pill\"\n t-esc=\"product.count\"/>\n \u003c/li>\n \u003c/t>\n \u003c/ul>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n### Dashboard Data Provider\n```python\nclass MyDashboard(models.TransientModel):\n _name = 'my.dashboard'\n _description = 'Dashboard Data Provider'\n\n @api.model\n def get_dashboard_data(self):\n \"\"\"Return KPI data for dashboard.\"\"\"\n today = fields.Date.today()\n month_start = today.replace(day=1)\n\n # Sales KPIs\n sales = self.env['sale.order'].search([\n ('date_order', '>=', month_start),\n ('state', 'in', ['sale', 'done']),\n ])\n\n # Pending orders\n pending = self.env['sale.order'].search_count([\n ('state', '=', 'draft'),\n ])\n\n # Overdue invoices\n overdue = self.env['account.move'].search_count([\n ('move_type', '=', 'out_invoice'),\n ('payment_state', '!=', 'paid'),\n ('invoice_date_due', '\u003c', today),\n ])\n\n # Top products\n top_products = self._get_top_products(limit=5)\n\n return {\n 'sale_count': len(sales),\n 'sale_amount': sum(sales.mapped('amount_total')),\n 'revenue': self._get_revenue(),\n 'pending_count': pending,\n 'overdue_count': overdue,\n 'top_products': top_products,\n }\n\n def _get_top_products(self, limit=5):\n \"\"\"Get top selling products.\"\"\"\n query = \"\"\"\n SELECT pp.id, pt.name, SUM(sol.product_uom_qty) as qty\n FROM sale_order_line sol\n JOIN product_product pp ON pp.id = sol.product_id\n JOIN product_template pt ON pt.id = pp.product_tmpl_id\n JOIN sale_order so ON so.id = sol.order_id\n WHERE so.state IN ('sale', 'done')\n AND so.date_order >= %s\n GROUP BY pp.id, pt.name\n ORDER BY qty DESC\n LIMIT %s\n \"\"\"\n month_start = fields.Date.today().replace(day=1)\n self.env.cr.execute(query, (month_start, limit))\n\n return [\n {'id': row[0], 'name': row[1], 'count': int(row[2])}\n for row in self.env.cr.fetchall()\n ]\n\n def _get_revenue(self):\n \"\"\"Calculate monthly revenue.\"\"\"\n month_start = fields.Date.today().replace(day=1)\n invoices = self.env['account.move'].search([\n ('move_type', '=', 'out_invoice'),\n ('state', '=', 'posted'),\n ('invoice_date', '>=', month_start),\n ])\n return sum(invoices.mapped('amount_total'))\n```\n\n---\n\n## Search Filters for Dashboard\n\n### Search View with Defaults\n```xml\n\u003crecord id=\"sale_analysis_view_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.analysis.search\u003c/field>\n \u003cfield name=\"model\">sale.analysis\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch>\n \u003c!-- Filters -->\n \u003cfilter name=\"current_month\" string=\"This Month\"\n domain=\"[('date', '>=', (context_today() - relativedelta(day=1)).strftime('%Y-%m-%d'))]\"/>\n \u003cfilter name=\"current_quarter\" string=\"This Quarter\"\n domain=\"[('date', '>=', (context_today() - relativedelta(months=(context_today().month - 1) % 3, day=1)).strftime('%Y-%m-%d'))]\"/>\n \u003cfilter name=\"current_year\" string=\"This Year\"\n domain=\"[('date', '>=', (context_today()).strftime('%Y-01-01'))]\"/>\n \u003cseparator/>\n \u003cfilter name=\"confirmed\" string=\"Confirmed\"\n domain=\"[('state', '=', 'sale')]\"/>\n\n \u003c!-- Group By -->\n \u003cgroup expand=\"1\" string=\"Group By\">\n \u003cfilter name=\"group_by_date\" string=\"Date\"\n context=\"{'group_by': 'date:month'}\"/>\n \u003cfilter name=\"group_by_partner\" string=\"Customer\"\n context=\"{'group_by': 'partner_id'}\"/>\n \u003cfilter name=\"group_by_product\" string=\"Product\"\n context=\"{'group_by': 'product_id'}\"/>\n \u003cfilter name=\"group_by_category\" string=\"Category\"\n context=\"{'group_by': 'categ_id'}\"/>\n \u003cfilter name=\"group_by_user\" string=\"Salesperson\"\n context=\"{'group_by': 'user_id'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n\u003c/record>\n```\n\n---\n\n## Cohort View (Enterprise)\n\n### Cohort Analysis\n```xml\n\u003crecord id=\"sale_analysis_view_cohort\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.analysis.cohort\u003c/field>\n \u003cfield name=\"model\">sale.analysis\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ccohort string=\"Sales Cohort\"\n date_start=\"date\"\n date_stop=\"date\"\n interval=\"month\"\n measure=\"price_total\"/>\n \u003c/field>\n\u003c/record>\n```\n\n---\n\n## Best Practices\n\n1. **Use database views** - _auto=False for aggregated models\n2. **Index key columns** - Add indexes to frequently filtered fields\n3. **Pre-aggregate** - Calculate totals in SQL, not Python\n4. **Cache expensive** - Use Redis/Memcached for heavy queries\n5. **Limit date ranges** - Default to current month/quarter\n6. **Add search filters** - Make it easy to drill down\n7. **Use measures wisely** - Choose meaningful KPIs\n8. **Refresh async** - Use background jobs for heavy data\n9. **Mobile friendly** - Design for responsive display\n10. **Test performance** - Verify with production data volumes\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":21014,"content_sha256":"fec80f6df6b2802c6b0bf7a2c5943f6381993358f0656a969bcd02b3c8123dd3"},{"filename":"skills/data-migration-patterns.md","content":"# Data Migration and Upgrade Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ DATA MIGRATION PATTERNS ║\n║ Version upgrades, data transformation, and migration scripts ║\n║ Use for module upgrades, data fixes, and version transitions ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Migration Script Structure\n\n### Directory Layout\n```\nmy_module/\n├── migrations/\n│ ├── 14.0.1.1/\n│ │ ├── pre-migrate.py\n│ │ └── post-migrate.py\n│ ├── 15.0.1.0/\n│ │ ├── pre-migrate.py\n│ │ └── post-migrate.py\n│ └── 16.0.2.0/\n│ └── post-migrate.py\n└── __manifest__.py\n```\n\n### Version Numbering\n```python\n# __manifest__.py\n{\n 'name': 'My Module',\n 'version': '16.0.2.0.1', # odoo_version.module_version\n # ^^ ^^ ^ ^\n # | | | └── patch\n # | | └──── minor\n # | └─────── major\n # └────────── Odoo version\n}\n```\n\n---\n\n## Pre-Migration Scripts\n\nPre-migrations run BEFORE the module is updated. Use for:\n- Renaming tables/columns\n- Preserving data before schema changes\n- Removing constraints that would block updates\n\n### Basic Pre-Migration\n```python\n# migrations/16.0.2.0/pre-migrate.py\nimport logging\nfrom odoo import SUPERUSER_ID\nfrom odoo.api import Environment\n\n_logger = logging.getLogger(__name__)\n\n\ndef migrate(cr, version):\n \"\"\"Pre-migration: prepare database for update.\"\"\"\n if not version:\n # Fresh install, no migration needed\n return\n\n _logger.info(\"Starting pre-migration from %s\", version)\n\n # Rename column before ORM sees it\n cr.execute(\"\"\"\n ALTER TABLE my_model\n RENAME COLUMN old_field TO x_old_field_backup\n \"\"\")\n\n _logger.info(\"Pre-migration completed\")\n```\n\n### Rename Table\n```python\ndef migrate(cr, version):\n \"\"\"Rename table before model rename.\"\"\"\n cr.execute(\"\"\"\n SELECT EXISTS (\n SELECT FROM information_schema.tables\n WHERE table_name = 'old_model_name'\n )\n \"\"\")\n if cr.fetchone()[0]:\n cr.execute(\"ALTER TABLE old_model_name RENAME TO new_model_name\")\n _logger.info(\"Renamed table old_model_name to new_model_name\")\n```\n\n### Preserve Data Before Removal\n```python\ndef migrate(cr, version):\n \"\"\"Backup data before field removal.\"\"\"\n cr.execute(\"\"\"\n SELECT EXISTS (\n SELECT FROM information_schema.columns\n WHERE table_name = 'my_model'\n AND column_name = 'deprecated_field'\n )\n \"\"\")\n if cr.fetchone()[0]:\n # Create backup table\n cr.execute(\"\"\"\n CREATE TABLE IF NOT EXISTS my_model_field_backup AS\n SELECT id, deprecated_field\n FROM my_model\n WHERE deprecated_field IS NOT NULL\n \"\"\")\n _logger.info(\"Backed up deprecated_field data\")\n```\n\n### Remove Constraints\n```python\ndef migrate(cr, version):\n \"\"\"Remove constraint before schema change.\"\"\"\n cr.execute(\"\"\"\n SELECT constraint_name\n FROM information_schema.table_constraints\n WHERE table_name = 'my_model'\n AND constraint_name LIKE '%_check'\n \"\"\")\n for (constraint_name,) in cr.fetchall():\n cr.execute(f\"ALTER TABLE my_model DROP CONSTRAINT {constraint_name}\")\n _logger.info(\"Dropped constraint %s\", constraint_name)\n```\n\n---\n\n## Post-Migration Scripts\n\nPost-migrations run AFTER the module is updated. Use for:\n- Data transformation\n- Setting default values\n- Migrating data between fields\n- Cleanup operations\n\n### Basic Post-Migration\n```python\n# migrations/16.0.2.0/post-migrate.py\nimport logging\nfrom odoo import SUPERUSER_ID, api\n\n_logger = logging.getLogger(__name__)\n\n\ndef migrate(cr, version):\n \"\"\"Post-migration: transform data after update.\"\"\"\n if not version:\n return\n\n _logger.info(\"Starting post-migration from %s\", version)\n\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n # Use ORM for data operations\n records = env['my.model'].search([('new_field', '=', False)])\n for record in records:\n record.new_field = record.old_field\n\n _logger.info(\"Migrated %d records\", len(records))\n```\n\n### Migrate Field Data\n```python\ndef migrate(cr, version):\n \"\"\"Migrate data from old field to new field.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n # Direct SQL for large datasets\n cr.execute(\"\"\"\n UPDATE my_model\n SET new_state = CASE state\n WHEN 'draft' THEN 'new'\n WHEN 'open' THEN 'in_progress'\n WHEN 'done' THEN 'completed'\n ELSE 'unknown'\n END\n WHERE new_state IS NULL\n \"\"\")\n _logger.info(\"Migrated %d state values\", cr.rowcount)\n```\n\n### Migrate Many2one to Many2many\n```python\ndef migrate(cr, version):\n \"\"\"Convert single relation to multiple.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n # Get records with old single value\n cr.execute(\"\"\"\n SELECT id, old_partner_id\n FROM my_model\n WHERE old_partner_id IS NOT NULL\n \"\"\")\n\n for record_id, partner_id in cr.fetchall():\n # Insert into relation table\n cr.execute(\"\"\"\n INSERT INTO my_model_partner_rel (my_model_id, partner_id)\n VALUES (%s, %s)\n ON CONFLICT DO NOTHING\n \"\"\", (record_id, partner_id))\n\n _logger.info(\"Migrated partner relations\")\n```\n\n### Set Computed Field Store\n```python\ndef migrate(cr, version):\n \"\"\"Initialize stored computed field.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n # Trigger recomputation\n records = env['my.model'].search([])\n records._compute_total_amount()\n\n # Or use SQL for performance\n cr.execute(\"\"\"\n UPDATE my_model m\n SET total_amount = (\n SELECT COALESCE(SUM(l.amount), 0)\n FROM my_model_line l\n WHERE l.model_id = m.id\n )\n \"\"\")\n```\n\n### Migrate XML IDs\n```python\ndef migrate(cr, version):\n \"\"\"Rename XML IDs after module rename.\"\"\"\n cr.execute(\"\"\"\n UPDATE ir_model_data\n SET module = 'new_module_name'\n WHERE module = 'old_module_name'\n \"\"\")\n\n # Update specific record references\n cr.execute(\"\"\"\n UPDATE ir_model_data\n SET name = REPLACE(name, 'old_prefix_', 'new_prefix_')\n WHERE module = 'my_module'\n AND name LIKE 'old_prefix_%'\n \"\"\")\n```\n\n---\n\n## openupgrade Patterns\n\nUsing OpenUpgrade library for complex migrations:\n\n### Rename Field\n```python\nfrom openupgradelib import openupgrade\n\n\ndef migrate(cr, version):\n openupgrade.rename_fields(\n cr,\n [\n ('my.model', 'my_model', 'old_field', 'new_field'),\n ]\n )\n```\n\n### Rename Model\n```python\ndef migrate(cr, version):\n openupgrade.rename_models(\n cr,\n [\n ('old.model', 'new.model'),\n ]\n )\n openupgrade.rename_tables(\n cr,\n [\n ('old_model', 'new_model'),\n ]\n )\n```\n\n### Merge Records\n```python\ndef migrate(cr, version):\n \"\"\"Merge duplicate records.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n duplicates = env['my.model'].search([])\n groups = {}\n for record in duplicates:\n key = record.name.lower().strip()\n if key not in groups:\n groups[key] = []\n groups[key].append(record)\n\n for key, records in groups.items():\n if len(records) > 1:\n main = records[0]\n for duplicate in records[1:]:\n openupgrade.merge_records(\n env,\n 'my.model',\n [duplicate.id],\n main.id,\n )\n```\n\n---\n\n## Batch Processing\n\n### Large Dataset Migration\n```python\ndef migrate(cr, version):\n \"\"\"Process large dataset in batches.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n batch_size = 1000\n offset = 0\n total = 0\n\n while True:\n cr.execute(\"\"\"\n SELECT id FROM my_model\n WHERE needs_migration = true\n ORDER BY id\n LIMIT %s OFFSET %s\n \"\"\", (batch_size, offset))\n\n ids = [row[0] for row in cr.fetchall()]\n if not ids:\n break\n\n records = env['my.model'].browse(ids)\n for record in records:\n record._migrate_data()\n total += 1\n\n # Commit batch\n cr.commit()\n env.invalidate_all()\n\n offset += batch_size\n _logger.info(\"Processed %d records...\", total)\n\n _logger.info(\"Migration complete: %d records\", total)\n```\n\n### Parallel Migration (Advanced)\n```python\ndef migrate(cr, version):\n \"\"\"Use SQL for parallel-safe operations.\"\"\"\n # Atomic update with RETURNING\n while True:\n cr.execute(\"\"\"\n WITH to_update AS (\n SELECT id FROM my_model\n WHERE migrated = false\n LIMIT 100\n FOR UPDATE SKIP LOCKED\n )\n UPDATE my_model m\n SET\n new_field = old_field * 1.1,\n migrated = true\n FROM to_update\n WHERE m.id = to_update.id\n RETURNING m.id\n \"\"\")\n\n updated = cr.fetchall()\n if not updated:\n break\n\n cr.commit()\n```\n\n---\n\n## Testing Migrations\n\n### Migration Test Pattern\n```python\n# tests/test_migration.py\nfrom odoo.tests import TransactionCase\n\n\nclass TestMigration(TransactionCase):\n\n def setUp(self):\n super().setUp()\n # Create test data with old structure\n self.test_record = self.env['my.model'].create({\n 'name': 'Test',\n 'old_field': 'value',\n })\n\n def test_field_migration(self):\n \"\"\"Test that old field migrates to new field.\"\"\"\n # Simulate migration\n self.test_record._migrate_field()\n\n self.assertEqual(\n self.test_record.new_field,\n 'value',\n \"Field value should be migrated\"\n )\n\n def test_state_migration(self):\n \"\"\"Test state value mapping.\"\"\"\n self.test_record.old_state = 'draft'\n self.test_record._migrate_state()\n\n self.assertEqual(\n self.test_record.state,\n 'new',\n \"State 'draft' should map to 'new'\"\n )\n```\n\n---\n\n## Common Migration Tasks\n\n### Add Default Value\n```python\ndef migrate(cr, version):\n \"\"\"Set default for new required field.\"\"\"\n cr.execute(\"\"\"\n UPDATE my_model\n SET new_required_field = 'default_value'\n WHERE new_required_field IS NULL\n \"\"\")\n```\n\n### Convert Data Type\n```python\ndef migrate(cr, version):\n \"\"\"Convert char to integer.\"\"\"\n # Add temporary column\n cr.execute(\"\"\"\n ALTER TABLE my_model\n ADD COLUMN IF NOT EXISTS numeric_code INTEGER\n \"\"\")\n\n # Convert with error handling\n cr.execute(\"\"\"\n UPDATE my_model\n SET numeric_code = CASE\n WHEN char_code ~ '^[0-9]+

Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…

THEN char_code::INTEGER\n ELSE 0\n END\n \"\"\")\n```\n\n### Migrate Attachments\n```python\ndef migrate(cr, version):\n \"\"\"Move attachments to new model.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n attachments = env['ir.attachment'].search([\n ('res_model', '=', 'old.model'),\n ])\n\n for attachment in attachments:\n # Find corresponding new record\n cr.execute(\"\"\"\n SELECT new_id FROM model_mapping\n WHERE old_id = %s\n \"\"\", (attachment.res_id,))\n result = cr.fetchone()\n\n if result:\n attachment.write({\n 'res_model': 'new.model',\n 'res_id': result[0],\n })\n```\n\n### Update Mail Followers\n```python\ndef migrate(cr, version):\n \"\"\"Update followers after model rename.\"\"\"\n cr.execute(\"\"\"\n UPDATE mail_followers\n SET res_model = 'new.model'\n WHERE res_model = 'old.model'\n \"\"\")\n\n cr.execute(\"\"\"\n UPDATE mail_message\n SET model = 'new.model'\n WHERE model = 'old.model'\n \"\"\")\n```\n\n---\n\n## Best Practices\n\n### 1. Always Check Version\n```python\ndef migrate(cr, version):\n if not version:\n return # Fresh install\n # Migration code\n```\n\n### 2. Use Logging\n```python\n_logger.info(\"Starting migration from %s\", version)\n_logger.info(\"Migrated %d records\", count)\n_logger.warning(\"Skipped %d invalid records\", skipped)\n```\n\n### 3. Handle Errors Gracefully\n```python\ndef migrate(cr, version):\n try:\n # Migration code\n except Exception as e:\n _logger.error(\"Migration failed: %s\", e)\n raise\n```\n\n### 4. Make Idempotent\n```python\n# Good - can run multiple times\ncr.execute(\"\"\"\n UPDATE my_model\n SET new_field = old_field\n WHERE new_field IS NULL -- Only unmigrated\n\"\"\")\n\n# Bad - breaks on re-run\ncr.execute(\"\"\"\n UPDATE my_model\n SET new_field = old_field\n\"\"\")\n```\n\n### 5. Commit Large Operations\n```python\nfor i, record in enumerate(records):\n record.migrate()\n if i % 1000 == 0:\n cr.commit()\n env.invalidate_all()\n```\n\n### 6. Test Before Production\n```python\n# Run on copy of production database\n# Verify data integrity after migration\n# Check performance with realistic data volumes\n```\n\n### 7. Installing Modules During Migration\n```python\n# Good - use button_install() during migration\ndef migrate(cr, version):\n \"\"\"Install dependency module during migration.\"\"\"\n env = api.Environment(cr, SUPERUSER_ID, {})\n\n module = env['ir.module.module'].search([\n ('name', '=', 'required_module'),\n ('state', '!=', 'installed'),\n ])\n\n if module:\n module.button_install()\n _logger.info(\"Queued installation of %s\", module.name)\n\n# Bad - causes UserError during migration\ndef migrate(cr, version):\n env = api.Environment(cr, SUPERUSER_ID, {})\n module = env['ir.module.module'].search([\n ('name', '=', 'required_module'),\n ])\n module._button_immediate_install() # ERROR: Cannot be called on non-loaded registries\n```\n\n**Why**: During migration, the registry is not fully loaded. The `_button_immediate_install()` method requires a complete registry and will raise:\n```\nodoo.exceptions.UserError: The method _button_immediate_install cannot be called on init or non loaded registries. Please use button_install instead.\n```\n\nUse `button_install()` which queues the installation to happen after the registry is properly initialized.\n\n---\n\n## Version-Specific Notes\n\n| Version | Migration Notes |\n|---------|-----------------|\n| 14→15 | `@api.multi` removed, update method signatures |\n| 15→16 | OWL 2.x, `Command` class for x2many |\n| 16→17 | `attrs` removed, use inline expressions |\n| 17→18 | `_check_company_auto`, `SQL()` builder |\n| 18→19 | Type hints required, `SQL()` mandatory |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15284,"content_sha256":"19e9905fb4312dae61379cb732e7bd7b19b86628023533278a4a43a051ed6c70"},{"filename":"skills/domain-filter-patterns.md","content":"# Domain and Filter Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ DOMAIN & FILTER PATTERNS ║\n║ Search domains, record filtering, and query optimization ║\n║ Use for search views, record rules, and programmatic filtering ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Domain Syntax\n\n### Basic Operators\n| Operator | Description | Example |\n|----------|-------------|---------|\n| `=` | Equal | `('state', '=', 'draft')` |\n| `!=` | Not equal | `('state', '!=', 'cancel')` |\n| `>` | Greater than | `('amount', '>', 100)` |\n| `>=` | Greater or equal | `('date', '>=', '2024-01-01')` |\n| `\u003c` | Less than | `('quantity', '\u003c', 10)` |\n| `\u003c=` | Less or equal | `('date', '\u003c=', today)` |\n| `like` | Pattern match (case sensitive) | `('name', 'like', 'Test%')` |\n| `ilike` | Pattern match (case insensitive) | `('name', 'ilike', '%test%')` |\n| `=like` | SQL LIKE | `('code', '=like', 'ABC%')` |\n| `=ilike` | SQL ILIKE | `('code', '=ilike', 'abc%')` |\n| `in` | In list | `('state', 'in', ['draft', 'sent'])` |\n| `not in` | Not in list | `('state', 'not in', ['cancel'])` |\n| `child_of` | Hierarchical child | `('category_id', 'child_of', parent_id)` |\n| `parent_of` | Hierarchical parent | `('category_id', 'parent_of', child_id)` |\n\n### Logical Operators\n```python\n# AND (implicit between tuples)\ndomain = [\n ('state', '=', 'confirmed'),\n ('date', '>=', '2024-01-01'),\n]\n\n# OR (prefix notation)\ndomain = [\n '|',\n ('state', '=', 'draft'),\n ('state', '=', 'sent'),\n]\n\n# NOT\ndomain = [\n '!',\n ('state', '=', 'cancel'),\n]\n\n# Complex: (A AND B) OR (C AND D)\ndomain = [\n '|',\n '&', ('state', '=', 'draft'), ('user_id', '=', uid),\n '&', ('state', '=', 'confirmed'), ('amount', '>', 1000),\n]\n```\n\n---\n\n## Common Domain Patterns\n\n### Date Ranges\n```python\nfrom datetime import date, datetime, timedelta\nfrom odoo import fields\n\n# Today\ntoday = fields.Date.today()\ndomain = [('date', '=', today)]\n\n# This week\nweek_start = today - timedelta(days=today.weekday())\nweek_end = week_start + timedelta(days=6)\ndomain = [\n ('date', '>=', week_start),\n ('date', '\u003c=', week_end),\n]\n\n# This month\nmonth_start = today.replace(day=1)\nnext_month = (month_start + timedelta(days=32)).replace(day=1)\ndomain = [\n ('date', '>=', month_start),\n ('date', '\u003c', next_month),\n]\n\n# Last 30 days\ndomain = [('date', '>=', today - timedelta(days=30))]\n\n# Between dates\ndomain = [\n ('date', '>=', date_from),\n ('date', '\u003c=', date_to),\n]\n```\n\n### Company Filtering\n```python\n# Current company only\ndomain = [('company_id', '=', self.env.company.id)]\n\n# User's companies\ndomain = [('company_id', 'in', self.env.user.company_ids.ids)]\n\n# Or no company (shared)\ndomain = [\n '|',\n ('company_id', '=', False),\n ('company_id', '=', self.env.company.id),\n]\n```\n\n### User/Partner Filtering\n```python\n# Current user\ndomain = [('user_id', '=', self.env.uid)]\n\n# Current user's partner\ndomain = [('partner_id', '=', self.env.user.partner_id.id)]\n\n# Team members\ndomain = [('user_id', 'in', self.env.user.team_id.member_ids.ids)]\n\n# No assigned user\ndomain = [('user_id', '=', False)]\n```\n\n### Related Field Filtering\n```python\n# Filter by related field\ndomain = [('partner_id.country_id', '=', country_id)]\n\n# Multiple levels\ndomain = [('order_id.partner_id.is_company', '=', True)]\n\n# Related Many2many\ndomain = [('tag_ids.name', 'ilike', 'important')]\n```\n\n### Null/Empty Checks\n```python\n# Field is empty\ndomain = [('name', '=', False)]\n\n# Field is not empty\ndomain = [('name', '!=', False)]\n\n# Empty string vs False\ndomain = [('name', 'in', [False, ''])]\n\n# Has related records\ndomain = [('line_ids', '!=', False)]\n```\n\n---\n\n## Dynamic Domains\n\n### In Python Methods\n```python\ndef _get_records_domain(self):\n \"\"\"Build domain dynamically.\"\"\"\n domain = [('active', '=', True)]\n\n if self.partner_id:\n domain.append(('partner_id', '=', self.partner_id.id))\n\n if self.date_from:\n domain.append(('date', '>=', self.date_from))\n\n if self.date_to:\n domain.append(('date', '\u003c=', self.date_to))\n\n if self.state_filter:\n domain.append(('state', '=', self.state_filter))\n\n return domain\n\ndef action_search(self):\n domain = self._get_records_domain()\n records = self.env['my.model'].search(domain)\n return records\n```\n\n### In Field Definitions\n```python\n# Static domain\npartner_id = fields.Many2one(\n 'res.partner',\n domain=[('is_company', '=', True)],\n)\n\n# Dynamic domain (string)\npartner_id = fields.Many2one(\n 'res.partner',\n domain=\"[('company_id', '=', company_id)]\",\n)\n\n# Complex dynamic domain\ndef _get_partner_domain(self):\n return [\n ('is_company', '=', True),\n ('country_id', '=', self.env.company.country_id.id),\n ]\n\npartner_id = fields.Many2one(\n 'res.partner',\n domain=lambda self: self._get_partner_domain(),\n)\n```\n\n### In Views (XML)\n```xml\n\u003c!-- Static domain -->\n\u003cfield name=\"partner_id\" domain=\"[('is_company', '=', True)]\"/>\n\n\u003c!-- Dynamic using other fields -->\n\u003cfield name=\"product_id\"\n domain=\"[('categ_id', '=', category_id)]\"/>\n\n\u003c!-- With context -->\n\u003cfield name=\"user_id\"\n domain=\"[('company_id', '=', company_id)]\"\n context=\"{'default_company_id': company_id}\"/>\n\n\u003c!-- Complex domain -->\n\u003cfield name=\"location_id\"\n domain=\"[\n ('usage', '=', 'internal'),\n '|',\n ('company_id', '=', company_id),\n ('company_id', '=', False)\n ]\"/>\n```\n\n---\n\n## Search Views\n\n### Basic Search View\n```xml\n\u003crecord id=\"my_model_view_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">my.model.search\u003c/field>\n \u003cfield name=\"model\">my.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch string=\"Search\">\n \u003c!-- Search fields -->\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"reference\" filter_domain=\"[\n '|',\n ('name', 'ilike', self),\n ('reference', 'ilike', self)\n ]\"/>\n\n \u003c!-- Filters -->\n \u003cfilter string=\"My Records\" name=\"my_records\"\n domain=\"[('user_id', '=', uid)]\"/>\n \u003cfilter string=\"Today\" name=\"today\"\n domain=\"[('date', '=', context_today().strftime('%Y-%m-%d'))]\"/>\n \u003cfilter string=\"This Month\" name=\"this_month\"\n domain=\"[\n ('date', '>=', (context_today() + relativedelta(day=1)).strftime('%Y-%m-%d')),\n ('date', '<', (context_today() + relativedelta(months=1, day=1)).strftime('%Y-%m-%d'))\n ]\"/>\n \u003cseparator/>\n \u003cfilter string=\"Draft\" name=\"draft\"\n domain=\"[('state', '=', 'draft')]\"/>\n \u003cfilter string=\"Confirmed\" name=\"confirmed\"\n domain=\"[('state', '=', 'confirmed')]\"/>\n \u003cseparator/>\n \u003cfilter string=\"Archived\" name=\"archived\"\n domain=\"[('active', '=', False)]\"/>\n\n \u003c!-- Group By -->\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter string=\"Partner\" name=\"group_partner\"\n context=\"{'group_by': 'partner_id'}\"/>\n \u003cfilter string=\"State\" name=\"group_state\"\n context=\"{'group_by': 'state'}\"/>\n \u003cfilter string=\"Date\" name=\"group_date\"\n context=\"{'group_by': 'date:month'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n\u003c/record>\n```\n\n### Advanced Search Features\n```xml\n\u003csearch>\n \u003c!-- Multi-field search -->\n \u003cfield name=\"name\" string=\"Name/Reference\"\n filter_domain=\"[\n '|', '|',\n ('name', 'ilike', self),\n ('reference', 'ilike', self),\n ('partner_id.name', 'ilike', self)\n ]\"/>\n\n \u003c!-- Search related fields -->\n \u003cfield name=\"partner_id\" operator=\"child_of\"/>\n\n \u003c!-- Date range filters -->\n \u003cfilter string=\"Last 7 Days\" name=\"last_7_days\"\n domain=\"[('create_date', '>=', (context_today() - relativedelta(days=7)).strftime('%Y-%m-%d'))]\"/>\n\n \u003c!-- Negative filter -->\n \u003cfilter string=\"Without Partner\" name=\"no_partner\"\n domain=\"[('partner_id', '=', False)]\"/>\n\n \u003c!-- Combined AND filter -->\n \u003cfilter string=\"Urgent Draft\" name=\"urgent_draft\"\n domain=\"[('state', '=', 'draft'), ('priority', '=', 'high')]\"/>\n\n \u003c!-- Dynamic context filter -->\n \u003cfilter string=\"My Team\" name=\"my_team\"\n domain=\"[('user_id.team_id', '=', %(sales_team.team_id)d)]\"/>\n\u003c/search>\n```\n\n---\n\n## Record Rules\n\n### Basic Record Rule\n```xml\n\u003c!-- Users see only their own records -->\n\u003crecord id=\"rule_my_model_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">My Model: User Rule\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('base.group_user'))]\"/>\n \u003cfield name=\"perm_read\" eval=\"True\"/>\n \u003cfield name=\"perm_write\" eval=\"True\"/>\n \u003cfield name=\"perm_create\" eval=\"True\"/>\n \u003cfield name=\"perm_unlink\" eval=\"True\"/>\n\u003c/record>\n```\n\n### Manager Rule (Override)\n```xml\n\u003c!-- Managers see all records -->\n\u003crecord id=\"rule_my_model_manager\" model=\"ir.rule\">\n \u003cfield name=\"name\">My Model: Manager Rule\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"domain_force\">[(1, '=', 1)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('my_module.group_manager'))]\"/>\n\u003c/record>\n```\n\n### Global Rule (No Groups)\n```xml\n\u003c!-- Global rule applying to everyone -->\n\u003crecord id=\"rule_my_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">My Model: Company Rule\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003cfield name=\"global\" eval=\"True\"/>\n\u003c/record>\n```\n\n---\n\n## Domain Helper Functions\n\n### Domain Utilities\n```python\nfrom odoo.osv import expression\n\n\nclass DomainHelper(models.AbstractModel):\n _name = 'domain.helper'\n\n def combine_domains(self, *domains):\n \"\"\"Combine multiple domains with AND.\"\"\"\n return expression.AND(list(domains))\n\n def combine_domains_or(self, *domains):\n \"\"\"Combine multiple domains with OR.\"\"\"\n return expression.OR(list(domains))\n\n def normalize_domain(self, domain):\n \"\"\"Normalize domain to standard form.\"\"\"\n return expression.normalize_domain(domain)\n\n def is_false_domain(self, domain):\n \"\"\"Check if domain is always false.\"\"\"\n return expression.is_false(self, domain)\n\n def distribute_not(self, domain):\n \"\"\"Push NOT operators down in domain.\"\"\"\n return expression.distribute_not(domain)\n\n\n# Usage\ndomain1 = [('state', '=', 'draft')]\ndomain2 = [('user_id', '=', self.env.uid)]\n\n# AND combination\ncombined = expression.AND([domain1, domain2])\n# Result: [('state', '=', 'draft'), ('user_id', '=', uid)]\n\n# OR combination\ncombined = expression.OR([domain1, domain2])\n# Result: ['|', ('state', '=', 'draft'), ('user_id', '=', uid)]\n```\n\n### Domain Parsing\n```python\nfrom odoo.osv.expression import DOMAIN_OPERATORS\n\ndef parse_domain(domain):\n \"\"\"Parse and analyze domain.\"\"\"\n result = {\n 'fields': set(),\n 'operators': [],\n }\n\n for element in domain:\n if isinstance(element, tuple):\n result['fields'].add(element[0])\n result['operators'].append(element[1])\n elif element in DOMAIN_OPERATORS:\n pass # Logical operator\n\n return result\n```\n\n---\n\n## Performance Tips\n\n### Indexed Fields\n```python\n# Add index for frequently filtered fields\nname = fields.Char(string='Name', index=True)\ndate = fields.Date(string='Date', index=True)\nstate = fields.Selection([...], index=True)\npartner_id = fields.Many2one('res.partner', index=True)\n```\n\n### Efficient Domains\n```python\n# Good - Uses index\ndomain = [('state', '=', 'confirmed')]\n\n# Bad - Function prevents index usage\ndomain = [('state', '=like', 'conf%')]\n\n# Good - Specific IDs\ndomain = [('id', 'in', record_ids)]\n\n# Bad - Too many OR conditions\ndomain = ['|'] * 99 + [('field', '=', v) for v in range(100)]\n# Better - Use 'in'\ndomain = [('field', 'in', list(range(100)))]\n```\n\n### Limit Results\n```python\n# Always limit when possible\nrecords = self.env['my.model'].search(domain, limit=100)\n\n# Use search_count for counts\ncount = self.env['my.model'].search_count(domain)\n```\n\n---\n\n## Best Practices\n\n1. **Use `in` operator** for multiple values instead of multiple OR\n2. **Index filtered fields** for better performance\n3. **Limit results** when full result set not needed\n4. **Use expression module** for combining domains\n5. **Test complex domains** with actual data\n6. **Document complex domains** with comments\n7. **Use `child_of`/`parent_of`** for hierarchical data\n8. **Prefer `ilike`** over `like` for user searches\n9. **Handle empty values** explicitly\n10. **Use record rules** for security, not business logic\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13505,"content_sha256":"8ca3d76fe43d60abf2f84d74b2d57b21fe3b1034b5cc5ff0c3b0d34a0d6d2e14"},{"filename":"skills/end-to-end-examples.md","content":"# End-to-End Generation Examples\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ END-TO-END GENERATION EXAMPLES ║\n║ Complete input → output examples for autonomous agent operation ║\n║ Copy these patterns for consistent module generation ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n---\n\n## Example 1: Equipment Tracking Module (v18)\n\n### User Request\n```\n\"Create a module to track company equipment assigned to employees.\nIt should have equipment items with serial numbers, assignment history,\nand maintenance schedules. Use Odoo 18 with multi-company support.\"\n```\n\n### Agent Analysis\n```json\n{\n \"identified_requirements\": {\n \"module_name\": \"equipment_tracking\",\n \"odoo_version\": \"18.0\",\n \"multi_company\": true,\n \"target_apps\": [\"hr\", \"maintenance\"],\n \"models_needed\": [\n \"equipment.item\",\n \"equipment.assignment\",\n \"equipment.maintenance\"\n ],\n \"features\": [\"workflow\", \"mail_thread\", \"tracking\"]\n }\n}\n```\n\n### Input to Workflow\n```json\n{\n \"module_name\": \"equipment_tracking\",\n \"module_description\": \"Track company equipment with employee assignments and maintenance schedules\",\n \"odoo_version\": \"18.0\",\n \"target_apps\": [\"hr\", \"maintenance\"],\n \"ui_stack\": \"classic\",\n \"multi_company\": true,\n \"multi_currency\": false,\n \"security_level\": \"advanced\",\n \"performance_critical\": false,\n \"include_tests\": true,\n \"include_demo\": true,\n \"models\": [\n {\n \"name\": \"equipment.item\",\n \"description\": \"Equipment Item\",\n \"inherit_mail\": true,\n \"has_workflow\": true,\n \"workflow_states\": [\n {\"value\": \"available\", \"label\": \"Available\"},\n {\"value\": \"assigned\", \"label\": \"Assigned\"},\n {\"value\": \"maintenance\", \"label\": \"In Maintenance\"},\n {\"value\": \"retired\", \"label\": \"Retired\"}\n ],\n \"fields\": [\n {\"name\": \"name\", \"type\": \"Char\", \"required\": true, \"tracking\": true},\n {\"name\": \"serial_number\", \"type\": \"Char\", \"index\": true},\n {\"name\": \"category_id\", \"type\": \"Many2one\", \"comodel_name\": \"equipment.category\"},\n {\"name\": \"employee_id\", \"type\": \"Many2one\", \"comodel_name\": \"hr.employee\", \"tracking\": true},\n {\"name\": \"purchase_date\", \"type\": \"Date\"},\n {\"name\": \"purchase_value\", \"type\": \"Monetary\"},\n {\"name\": \"assignment_ids\", \"type\": \"One2many\", \"comodel_name\": \"equipment.assignment\", \"inverse_name\": \"equipment_id\"}\n ]\n },\n {\n \"name\": \"equipment.category\",\n \"description\": \"Equipment Category\",\n \"fields\": [\n {\"name\": \"name\", \"type\": \"Char\", \"required\": true},\n {\"name\": \"code\", \"type\": \"Char\"},\n {\"name\": \"parent_id\", \"type\": \"Many2one\", \"comodel_name\": \"equipment.category\"}\n ]\n },\n {\n \"name\": \"equipment.assignment\",\n \"description\": \"Equipment Assignment History\",\n \"fields\": [\n {\"name\": \"equipment_id\", \"type\": \"Many2one\", \"comodel_name\": \"equipment.item\", \"required\": true},\n {\"name\": \"employee_id\", \"type\": \"Many2one\", \"comodel_name\": \"hr.employee\", \"required\": true},\n {\"name\": \"date_assigned\", \"type\": \"Date\", \"required\": true},\n {\"name\": \"date_returned\", \"type\": \"Date\"},\n {\"name\": \"notes\", \"type\": \"Text\"}\n ]\n }\n ]\n}\n```\n\n### Generated Output\n\n#### File Tree\n```\nequipment_tracking/\n├── __manifest__.py\n├── __init__.py\n├── models/\n│ ├── __init__.py\n│ ├── equipment_item.py\n│ ├── equipment_category.py\n│ └── equipment_assignment.py\n├── views/\n│ ├── equipment_item_views.xml\n│ ├── equipment_category_views.xml\n│ ├── equipment_assignment_views.xml\n│ └── menuitems.xml\n├── security/\n│ ├── equipment_tracking_security.xml\n│ └── ir.model.access.csv\n├── data/\n│ └── demo_data.xml\n└── tests/\n ├── __init__.py\n ├── common.py\n └── test_equipment_item.py\n```\n\n#### __manifest__.py\n```python\n{\n 'name': 'Equipment Tracking',\n 'version': '18.0.1.0.0',\n 'category': 'Human Resources',\n 'summary': 'Track company equipment with employee assignments and maintenance schedules',\n 'description': \"\"\"\nEquipment Tracking\n==================\n\nThis module provides:\n* Equipment item management with serial numbers\n* Employee assignment tracking with history\n* Maintenance scheduling\n* Multi-company support\n\nVersion Notes (v18):\n* Uses _check_company_auto for automatic company validation\n* Uses @api.model_create_multi for batch creation\n* Uses inline visibility expressions in views\n \"\"\",\n 'author': 'Your Company',\n 'website': 'https://www.yourcompany.com',\n 'license': 'LGPL-3',\n 'depends': ['hr', 'maintenance', 'mail'],\n 'data': [\n # Security (MUST be first)\n 'security/equipment_tracking_security.xml',\n 'security/ir.model.access.csv',\n # Views\n 'views/equipment_item_views.xml',\n 'views/equipment_category_views.xml',\n 'views/equipment_assignment_views.xml',\n 'views/menuitems.xml',\n ],\n 'demo': [\n 'data/demo_data.xml',\n ],\n 'installable': True,\n 'application': True,\n 'auto_install': False,\n}\n```\n\n#### models/equipment_item.py\n```python\nfrom odoo import api, fields, models\nfrom odoo.exceptions import UserError\n\n\nclass EquipmentItem(models.Model):\n _name = 'equipment.item'\n _description = 'Equipment Item'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _check_company_auto = True\n _order = 'name'\n\n name = fields.Char(\n string='Equipment Name',\n required=True,\n tracking=True,\n index=True,\n )\n serial_number = fields.Char(\n string='Serial Number',\n index='btree_not_null',\n copy=False,\n )\n state = fields.Selection(\n selection=[\n ('available', 'Available'),\n ('assigned', 'Assigned'),\n ('maintenance', 'In Maintenance'),\n ('retired', 'Retired'),\n ],\n string='Status',\n default='available',\n required=True,\n tracking=True,\n )\n category_id = fields.Many2one(\n comodel_name='equipment.category',\n string='Category',\n check_company=True,\n )\n employee_id = fields.Many2one(\n comodel_name='hr.employee',\n string='Assigned To',\n tracking=True,\n check_company=True,\n )\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n required=True,\n readonly=True,\n default=lambda self: self.env.company,\n index=True,\n )\n currency_id = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n related='company_id.currency_id',\n )\n purchase_date = fields.Date(string='Purchase Date')\n purchase_value = fields.Monetary(\n string='Purchase Value',\n currency_field='currency_id',\n )\n assignment_ids = fields.One2many(\n comodel_name='equipment.assignment',\n inverse_name='equipment_id',\n string='Assignment History',\n )\n assignment_count = fields.Integer(\n string='Assignments',\n compute='_compute_assignment_count',\n )\n\n def _compute_assignment_count(self) -> None:\n for record in self:\n record.assignment_count = len(record.assignment_ids)\n\n @api.model_create_multi\n def create(self, vals_list: list[dict]) -> 'EquipmentItem':\n return super().create(vals_list)\n\n def action_assign(self) -> bool:\n \"\"\"Assign equipment to employee.\"\"\"\n for record in self:\n if record.state != 'available':\n raise UserError(f\"Equipment '{record.name}' is not available for assignment.\")\n if not record.employee_id:\n raise UserError(\"Please select an employee before assigning.\")\n record.state = 'assigned'\n self.env['equipment.assignment'].create({\n 'equipment_id': record.id,\n 'employee_id': record.employee_id.id,\n 'date_assigned': fields.Date.today(),\n })\n return True\n\n def action_return(self) -> bool:\n \"\"\"Return equipment from employee.\"\"\"\n for record in self:\n if record.state != 'assigned':\n raise UserError(f\"Equipment '{record.name}' is not currently assigned.\")\n # Close current assignment\n current_assignment = record.assignment_ids.filtered(\n lambda a: not a.date_returned\n )\n current_assignment.write({'date_returned': fields.Date.today()})\n record.write({\n 'state': 'available',\n 'employee_id': False,\n })\n return True\n\n def action_retire(self) -> bool:\n \"\"\"Retire equipment.\"\"\"\n for record in self:\n if record.state == 'assigned':\n raise UserError(f\"Cannot retire assigned equipment '{record.name}'. Return it first.\")\n record.state = 'retired'\n return True\n\n def action_view_assignments(self) -> dict:\n \"\"\"Open assignment history.\"\"\"\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': 'Assignment History',\n 'res_model': 'equipment.assignment',\n 'view_mode': 'tree,form',\n 'domain': [('equipment_id', '=', self.id)],\n 'context': {'default_equipment_id': self.id},\n }\n```\n\n#### views/equipment_item_views.xml\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Form View -->\n \u003crecord id=\"equipment_item_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.item.form\u003c/field>\n \u003cfield name=\"model\">equipment.item\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"Equipment\">\n \u003cheader>\n \u003cbutton name=\"action_assign\"\n type=\"object\"\n string=\"Assign\"\n class=\"btn-primary\"\n invisible=\"state != 'available' or not employee_id\"/>\n \u003cbutton name=\"action_return\"\n type=\"object\"\n string=\"Return\"\n invisible=\"state != 'assigned'\"/>\n \u003cbutton name=\"action_retire\"\n type=\"object\"\n string=\"Retire\"\n invisible=\"state in ('assigned', 'retired')\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"available,assigned,maintenance,retired\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\">\n \u003cbutton name=\"action_view_assignments\"\n type=\"object\"\n class=\"oe_stat_button\"\n icon=\"fa-history\">\n \u003cfield name=\"assignment_count\" widget=\"statinfo\"\n string=\"Assignments\"/>\n \u003c/button>\n \u003c/div>\n \u003cwidget name=\"web_ribbon\" title=\"Retired\"\n bg_color=\"bg-danger\"\n invisible=\"state != 'retired'\"/>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\n \u003cfield name=\"name\" placeholder=\"Equipment Name\"/>\n \u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"category_id\"/>\n \u003cfield name=\"employee_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"purchase_date\"/>\n \u003cfield name=\"purchase_value\"/>\n \u003cfield name=\"currency_id\" invisible=\"1\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Assignment History\" name=\"assignments\">\n \u003cfield name=\"assignment_ids\" readonly=\"1\">\n \u003ctree>\n \u003cfield name=\"employee_id\"/>\n \u003cfield name=\"date_assigned\"/>\n \u003cfield name=\"date_returned\"/>\n \u003cfield name=\"notes\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Tree View -->\n \u003crecord id=\"equipment_item_view_tree\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.item.tree\u003c/field>\n \u003cfield name=\"model\">equipment.item\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ctree string=\"Equipment\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"category_id\"/>\n \u003cfield name=\"employee_id\"/>\n \u003cfield name=\"state\" widget=\"badge\"\n decoration-success=\"state == 'available'\"\n decoration-info=\"state == 'assigned'\"\n decoration-warning=\"state == 'maintenance'\"\n decoration-danger=\"state == 'retired'\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Search View -->\n \u003crecord id=\"equipment_item_view_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.item.search\u003c/field>\n \u003cfield name=\"model\">equipment.item\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch string=\"Search Equipment\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"employee_id\"/>\n \u003cseparator/>\n \u003cfilter name=\"available\" string=\"Available\"\n domain=\"[('state', '=', 'available')]\"/>\n \u003cfilter name=\"assigned\" string=\"Assigned\"\n domain=\"[('state', '=', 'assigned')]\"/>\n \u003cfilter name=\"maintenance\" string=\"In Maintenance\"\n domain=\"[('state', '=', 'maintenance')]\"/>\n \u003cseparator/>\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter name=\"group_category\" string=\"Category\"\n context=\"{'group_by': 'category_id'}\"/>\n \u003cfilter name=\"group_state\" string=\"Status\"\n context=\"{'group_by': 'state'}\"/>\n \u003cfilter name=\"group_employee\" string=\"Employee\"\n context=\"{'group_by': 'employee_id'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Action -->\n \u003crecord id=\"equipment_item_action\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">Equipment\u003c/field>\n \u003cfield name=\"res_model\">equipment.item\u003c/field>\n \u003cfield name=\"view_mode\">tree,form,kanban\u003c/field>\n \u003cfield name=\"context\">{'search_default_available': 1}\u003c/field>\n \u003cfield name=\"help\" type=\"html\">\n \u003cp class=\"o_view_nocontent_smiling_face\">\n Create your first equipment item\n \u003c/p>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n#### security/equipment_tracking_security.xml\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Category -->\n \u003crecord id=\"module_category_equipment\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Equipment\u003c/field>\n \u003cfield name=\"sequence\">30\u003c/field>\n \u003c/record>\n\n \u003c!-- Groups -->\n \u003crecord id=\"group_equipment_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_equipment\"/>\n \u003c/record>\n\n \u003crecord id=\"group_equipment_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_equipment\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_equipment_user'))]\"/>\n \u003c/record>\n\n \u003c!-- Record Rules -->\n \u003crecord id=\"equipment_item_rule_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Equipment Item: Multi-company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_equipment_item\"/>\n \u003cfield name=\"domain_force\">[('company_id', 'in', allowed_company_ids)]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n#### security/ir.model.access.csv\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_equipment_item_user,equipment.item.user,model_equipment_item,group_equipment_user,1,1,1,0\naccess_equipment_item_manager,equipment.item.manager,model_equipment_item,group_equipment_manager,1,1,1,1\naccess_equipment_category_user,equipment.category.user,model_equipment_category,group_equipment_user,1,0,0,0\naccess_equipment_category_manager,equipment.category.manager,model_equipment_category,group_equipment_manager,1,1,1,1\naccess_equipment_assignment_user,equipment.assignment.user,model_equipment_assignment,group_equipment_user,1,0,0,0\naccess_equipment_assignment_manager,equipment.assignment.manager,model_equipment_assignment,group_equipment_manager,1,1,1,1\n```\n\n#### tests/test_equipment_item.py\n```python\nfrom odoo.tests import tagged\nfrom odoo.exceptions import UserError\nfrom .common import TestEquipmentCommon\n\n\n@tagged('post_install', '-at_install')\nclass TestEquipmentItem(TestEquipmentCommon):\n \"\"\"Unit tests for equipment.item model\"\"\"\n\n def test_create_equipment(self):\n \"\"\"Test basic equipment creation\"\"\"\n equipment = self.env['equipment.item'].create({\n 'name': 'Test Laptop',\n 'serial_number': 'SN-TEST-001',\n })\n self.assertTrue(equipment.id)\n self.assertEqual(equipment.state, 'available')\n self.assertEqual(equipment.company_id, self.env.company)\n\n def test_assign_equipment(self):\n \"\"\"Test equipment assignment workflow\"\"\"\n self.equipment.employee_id = self.employee\n self.equipment.action_assign()\n\n self.assertEqual(self.equipment.state, 'assigned')\n self.assertEqual(len(self.equipment.assignment_ids), 1)\n self.assertEqual(\n self.equipment.assignment_ids[0].employee_id,\n self.employee\n )\n\n def test_assign_unavailable_fails(self):\n \"\"\"Test that assigning unavailable equipment fails\"\"\"\n self.equipment.state = 'maintenance'\n self.equipment.employee_id = self.employee\n\n with self.assertRaises(UserError):\n self.equipment.action_assign()\n\n def test_return_equipment(self):\n \"\"\"Test equipment return workflow\"\"\"\n self.equipment.employee_id = self.employee\n self.equipment.action_assign()\n self.equipment.action_return()\n\n self.assertEqual(self.equipment.state, 'available')\n self.assertFalse(self.equipment.employee_id)\n self.assertTrue(self.equipment.assignment_ids[0].date_returned)\n\n def test_retire_equipment(self):\n \"\"\"Test equipment retirement\"\"\"\n self.equipment.action_retire()\n self.assertEqual(self.equipment.state, 'retired')\n\n def test_retire_assigned_fails(self):\n \"\"\"Test that retiring assigned equipment fails\"\"\"\n self.equipment.employee_id = self.employee\n self.equipment.action_assign()\n\n with self.assertRaises(UserError):\n self.equipment.action_retire()\n\n def test_multi_company_isolation(self):\n \"\"\"Test multi-company record isolation\"\"\"\n # Create equipment in main company\n equipment_c1 = self.env['equipment.item'].create({\n 'name': 'Company 1 Equipment',\n 'company_id': self.env.company.id,\n })\n\n # User in company 2 should not see it\n visible = self.env['equipment.item'].with_user(\n self.user_company2\n ).search([])\n self.assertNotIn(equipment_c1.id, visible.ids)\n```\n\n### Version Notes Generated\n```json\n{\n \"version_notes\": [\n \"Using _check_company_auto = True for automatic multi-company validation (v18 pattern)\",\n \"Using @api.model_create_multi for batch record creation (required in v17+)\",\n \"Using inline visibility expressions in views (attrs removed in v17)\",\n \"Using check_company=True on relational fields for cross-company validation (v18 pattern)\",\n \"Using allowed_company_ids in record rules (v18 pattern)\",\n \"Using btree_not_null index type for serial_number (v16+ optimization)\"\n ]\n}\n```\n\n---\n\n## Example 2: Quick Module for v17 (Minimal)\n\n### User Request\n```\n\"Create a simple task priority module for Odoo 17\"\n```\n\n### Minimal Input\n```json\n{\n \"module_name\": \"task_priority\",\n \"module_description\": \"Add priority levels to project tasks\",\n \"odoo_version\": \"17.0\",\n \"target_apps\": [\"project\"],\n \"inherit_models\": [\n {\n \"model\": \"project.task\",\n \"fields\": [\n {\n \"name\": \"x_priority_level\",\n \"type\": \"Selection\",\n \"string\": \"Priority Level\",\n \"selection\": [\n [\"low\", \"Low\"],\n [\"medium\", \"Medium\"],\n [\"high\", \"High\"],\n [\"critical\", \"Critical\"]\n ],\n \"tracking\": true\n }\n ]\n }\n ]\n}\n```\n\n### Generated Output (v17 Pattern)\n\n#### models/project_task.py\n```python\nfrom odoo import fields, models\n\n\nclass ProjectTask(models.Model):\n _inherit = 'project.task'\n\n x_priority_level = fields.Selection(\n selection=[\n ('low', 'Low'),\n ('medium', 'Medium'),\n ('high', 'High'),\n ('critical', 'Critical'),\n ],\n string='Priority Level',\n tracking=True,\n )\n```\n\n#### views/project_task_views.xml\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"project_task_view_form_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">project.task.form.inherit.priority\u003c/field>\n \u003cfield name=\"model\">project.task\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"project.view_task_form2\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cxpath expr=\"//field[@name='user_ids']\" position=\"after\">\n \u003cfield name=\"x_priority_level\"/>\n \u003c/xpath>\n \u003c/field>\n \u003c/record>\n\n \u003crecord id=\"project_task_view_tree_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">project.task.tree.inherit.priority\u003c/field>\n \u003cfield name=\"model\">project.task\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"project.view_task_tree2\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cxpath expr=\"//field[@name='stage_id']\" position=\"after\">\n \u003cfield name=\"x_priority_level\" widget=\"badge\"\n decoration-success=\"x_priority_level == 'low'\"\n decoration-info=\"x_priority_level == 'medium'\"\n decoration-warning=\"x_priority_level == 'high'\"\n decoration-danger=\"x_priority_level == 'critical'\"\n optional=\"show\"/>\n \u003c/xpath>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n---\n\n## Example 3: Upgrade Example (v16 → v18)\n\n### Input\n```json\n{\n \"workflow\": \"upgrade_module\",\n \"source_version\": \"16.0\",\n \"target_version\": \"18.0\",\n \"module_path\": \"/path/to/my_module\"\n}\n```\n\n### Original Code (v16)\n```python\n# models/my_model.py (v16)\nfrom odoo import api, fields, models\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n company_id = fields.Many2one('res.company')\n partner_id = fields.Many2one('res.partner')\n\n @api.model\n def create(self, vals):\n return super().create(vals)\n```\n\n```xml\n\u003c!-- views/my_views.xml (v16) -->\n\u003cfield name=\"partner_id\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n```\n\n### Migration Output\n\n#### Updated Code (v18)\n```python\n# models/my_model.py (v18)\nfrom odoo import api, fields, models\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _check_company_auto = True # ADDED for v18\n\n company_id = fields.Many2one(\n 'res.company',\n required=True, # ADDED\n default=lambda self: self.env.company, # ADDED\n )\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True, # ADDED for v18\n )\n\n @api.model_create_multi # CHANGED from @api.model\n def create(self, vals_list: list[dict]) -> 'MyModel': # CHANGED signature + type hints\n return super().create(vals_list)\n```\n\n```xml\n\u003c!-- views/my_views.xml (v18) -->\n\u003cfield name=\"partner_id\"\n invisible=\"state == 'draft'\"/> \u003c!-- CHANGED from attrs -->\n```\n\n#### Migration Script\n```python\n# migrations/18.0.1.0.0/pre-migration.py\ndef migrate(cr, version):\n \"\"\"Pre-migration: Ensure company_id has values\"\"\"\n cr.execute(\"\"\"\n UPDATE my_model\n SET company_id = (SELECT id FROM res_company LIMIT 1)\n WHERE company_id IS NULL\n \"\"\")\n```\n\n---\n\n## Agent Execution Summary\n\n```\nWORKFLOW EXECUTION PATTERN:\n\n1. RECEIVE user request\n2. PARSE requirements → structured input\n3. VALIDATE input against schema\n4. DETERMINE version → load skills\n5. VERIFY patterns via GitHub (critical patterns only)\n6. GENERATE files in correct order\n7. VALIDATE generated code\n8. OUTPUT with version notes and file tree\n\nALWAYS REMEMBER:\n- Security files BEFORE views in manifest\n- Version-specific patterns are MANDATORY\n- Multi-company requires _check_company_auto (v18+)\n- Create uses @api.model_create_multi (v17+)\n- Views use inline expressions (v17+)\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":26480,"content_sha256":"379f8f0fdf2c8550bb68445163ea38b01559ff4a6c451c05edb181a4973445ff"},{"filename":"skills/error-handling-patterns.md","content":"# Error Handling Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ERROR HANDLING PATTERNS ║\n║ Exceptions, validation, and error recovery ║\n║ Use for robust error handling, user feedback, and data integrity ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Odoo Exception Types\n\n### Standard Exceptions\n```python\nfrom odoo.exceptions import (\n UserError, # User-facing errors (shown in dialog)\n ValidationError, # Constraint violations\n AccessError, # Permission denied\n MissingError, # Record doesn't exist\n AccessDenied, # Login/authentication failure\n RedirectWarning, # Error with action button\n)\n```\n\n### When to Use Each\n| Exception | Use Case |\n|-----------|----------|\n| `UserError` | Business logic errors, invalid operations |\n| `ValidationError` | Data validation failures in constraints |\n| `AccessError` | Permission/security violations |\n| `MissingError` | Record not found (browse deleted ID) |\n| `RedirectWarning` | Error with corrective action link |\n\n---\n\n## Raising Exceptions\n\n### UserError (Most Common)\n```python\nfrom odoo.exceptions import UserError\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def action_confirm(self):\n \"\"\"Confirm record with validation.\"\"\"\n self.ensure_one()\n\n if not self.line_ids:\n raise UserError(\"Cannot confirm without lines.\")\n\n if self.amount_total \u003c= 0:\n raise UserError(\n f\"Amount must be positive. Current: {self.amount_total}\"\n )\n\n if self.state != 'draft':\n raise UserError(\n f\"Only draft records can be confirmed. \"\n f\"Current state: {self.state}\"\n )\n\n self.write({'state': 'confirmed'})\n```\n\n### ValidationError (Constraints)\n```python\nfrom odoo.exceptions import ValidationError\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @api.constrains('date_start', 'date_end')\n def _check_dates(self):\n for record in self:\n if record.date_start and record.date_end:\n if record.date_start > record.date_end:\n raise ValidationError(\n \"Start date must be before end date.\"\n )\n\n @api.constrains('email')\n def _check_email(self):\n import re\n email_pattern = r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+

Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…

\n for record in self:\n if record.email and not re.match(email_pattern, record.email):\n raise ValidationError(\n f\"Invalid email format: {record.email}\"\n )\n\n @api.constrains('quantity')\n def _check_quantity(self):\n for record in self:\n if record.quantity \u003c 0:\n raise ValidationError(\"Quantity cannot be negative.\")\n```\n\n### RedirectWarning (With Action)\n```python\nfrom odoo.exceptions import RedirectWarning\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def action_process(self):\n if not self.env.company.x_config_complete:\n action = self.env.ref('my_module.action_config_wizard')\n raise RedirectWarning(\n \"Configuration is incomplete. Please complete setup first.\",\n action.id,\n \"Go to Configuration\",\n )\n```\n\n### AccessError (Security)\n```python\nfrom odoo.exceptions import AccessError\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def action_approve(self):\n if not self.env.user.has_group('my_module.group_approver'):\n raise AccessError(\n \"You do not have permission to approve records.\"\n )\n self.write({'state': 'approved'})\n```\n\n---\n\n## Try-Except Patterns\n\n### Basic Error Handling\n```python\ndef process_record(self):\n try:\n self._do_processing()\n except UserError:\n # Re-raise user errors (show to user)\n raise\n except Exception as e:\n _logger.error(\"Processing failed for %s: %s\", self.id, e)\n raise UserError(f\"Processing failed: {str(e)}\")\n```\n\n### Handling Specific Exceptions\n```python\ndef sync_external(self):\n import requests\n\n try:\n response = requests.get(self.api_url, timeout=30)\n response.raise_for_status()\n return response.json()\n\n except requests.Timeout:\n raise UserError(\n \"External service timed out. Please try again later.\"\n )\n except requests.ConnectionError:\n raise UserError(\n \"Cannot connect to external service. Check your connection.\"\n )\n except requests.HTTPError as e:\n if e.response.status_code == 401:\n raise UserError(\"Authentication failed. Check API credentials.\")\n elif e.response.status_code == 404:\n raise UserError(\"Resource not found on external service.\")\n else:\n raise UserError(f\"External service error: {e.response.status_code}\")\n except Exception as e:\n _logger.exception(\"Unexpected error in sync: %s\", e)\n raise UserError(f\"Sync failed: {str(e)}\")\n```\n\n### Transaction Safety\n```python\ndef process_batch(self):\n \"\"\"Process batch with transaction safety.\"\"\"\n for record in self:\n try:\n # Use savepoint for each record\n with self.env.cr.savepoint():\n record._process_single()\n\n except Exception as e:\n # Savepoint rolled back, continue with next\n _logger.error(\"Failed to process %s: %s\", record.id, e)\n record.message_post(body=f\"Processing failed: {e}\")\n continue\n```\n\n### Graceful Degradation\n```python\ndef get_external_data(self):\n \"\"\"Get data with fallback.\"\"\"\n try:\n # Try primary source\n return self._fetch_from_api()\n except Exception as e:\n _logger.warning(\"API fetch failed: %s, using cache\", e)\n try:\n # Fallback to cache\n return self._get_from_cache()\n except Exception:\n _logger.error(\"Cache also failed\")\n return None\n```\n\n---\n\n## Validation Patterns\n\n### Pre-Action Validation\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def action_confirm(self):\n \"\"\"Confirm with pre-validation.\"\"\"\n self._validate_confirm()\n self.write({'state': 'confirmed'})\n\n def _validate_confirm(self):\n \"\"\"Validate before confirmation.\"\"\"\n errors = []\n\n for record in self:\n if not record.partner_id:\n errors.append(f\"[{record.name}] Partner is required.\")\n if not record.line_ids:\n errors.append(f\"[{record.name}] At least one line required.\")\n if record.amount_total \u003c= 0:\n errors.append(f\"[{record.name}] Amount must be positive.\")\n\n if errors:\n raise UserError(\"\\n\".join(errors))\n```\n\n### Field Validation Decorator\n```python\ndef validate_required(field_name, message=None):\n \"\"\"Decorator to validate required field.\"\"\"\n def decorator(func):\n @wraps(func)\n def wrapper(self, *args, **kwargs):\n for record in self:\n if not getattr(record, field_name):\n raise UserError(\n message or f\"{field_name} is required.\"\n )\n return func(self, *args, **kwargs)\n return wrapper\n return decorator\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @validate_required('partner_id', 'Please select a partner first.')\n def action_send(self):\n # Validation passed\n self._send_to_partner()\n```\n\n### SQL Constraint Handling\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n _sql_constraints = [\n ('code_unique', 'UNIQUE(code, company_id)',\n 'Code must be unique per company.'),\n ('amount_positive', 'CHECK(amount >= 0)',\n 'Amount must be positive.'),\n ]\n\n @api.model_create_multi\n def create(self, vals_list):\n try:\n return super().create(vals_list)\n except IntegrityError as e:\n if 'code_unique' in str(e):\n raise UserError(\"A record with this code already exists.\")\n raise\n```\n\n---\n\n## Error Recovery\n\n### Retry Pattern\n```python\nimport time\n\n\ndef retry_on_error(max_retries=3, delay=1, exceptions=(Exception,)):\n \"\"\"Decorator for retry on error.\"\"\"\n def decorator(func):\n @wraps(func)\n def wrapper(*args, **kwargs):\n last_error = None\n for attempt in range(max_retries):\n try:\n return func(*args, **kwargs)\n except exceptions as e:\n last_error = e\n if attempt \u003c max_retries - 1:\n _logger.warning(\n \"Attempt %d failed: %s. Retrying...\",\n attempt + 1, e\n )\n time.sleep(delay * (attempt + 1))\n raise last_error\n return wrapper\n return decorator\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @retry_on_error(max_retries=3, delay=2)\n def call_external_api(self):\n # May fail temporarily\n pass\n```\n\n### Rollback and Notify\n```python\ndef process_with_rollback(self):\n \"\"\"Process with proper rollback handling.\"\"\"\n try:\n # Start work\n for record in self:\n record._process()\n\n # Commit if all successful\n self.env.cr.commit()\n\n except Exception as e:\n # Rollback transaction\n self.env.cr.rollback()\n\n # Notify about failure\n self.env['bus.bus']._sendone(\n self.env.user.partner_id,\n 'simple_notification',\n {\n 'title': 'Processing Failed',\n 'message': str(e),\n 'type': 'danger',\n }\n )\n raise\n```\n\n---\n\n## User Feedback\n\n### Progress Messages\n```python\ndef action_process_batch(self):\n \"\"\"Process with user feedback.\"\"\"\n total = len(self)\n processed = 0\n errors = []\n\n for record in self:\n try:\n record._process()\n processed += 1\n except Exception as e:\n errors.append(f\"{record.name}: {str(e)}\")\n\n # Return notification\n if errors:\n return {\n 'type': 'ir.actions.client',\n 'tag': 'display_notification',\n 'params': {\n 'title': 'Processing Complete',\n 'message': f'Processed {processed}/{total}. '\n f'{len(errors)} errors occurred.',\n 'type': 'warning',\n 'sticky': True,\n }\n }\n else:\n return {\n 'type': 'ir.actions.client',\n 'tag': 'display_notification',\n 'params': {\n 'title': 'Success',\n 'message': f'Successfully processed {total} records.',\n 'type': 'success',\n }\n }\n```\n\n### Error Details in Chatter\n```python\ndef process_with_logging(self):\n \"\"\"Process with error logging to chatter.\"\"\"\n try:\n result = self._do_work()\n self.message_post(\n body=f\"Processing completed successfully: {result}\",\n message_type='notification',\n )\n except Exception as e:\n self.message_post(\n body=f\"\u003cb>Processing Failed\u003c/b>\u003cbr/>{str(e)}\",\n message_type='notification',\n subtype_xmlid='mail.mt_note',\n )\n raise UserError(f\"Processing failed: {str(e)}\")\n```\n\n---\n\n## Best Practices\n\n1. **Use appropriate exception types** - UserError for business logic, ValidationError for constraints\n2. **Provide clear messages** - Include context and what to do\n3. **Log before raising** - Log technical details, show user-friendly message\n4. **Don't catch too broadly** - Be specific about what you catch\n5. **Always re-raise UserError** - Let it show to user\n6. **Use savepoints for batches** - Isolate failures\n7. **Validate early** - Check before doing work\n8. **Return feedback** - Use notifications for batch operations\n9. **Document expected errors** - In docstrings\n10. **Test error paths** - Write tests for error scenarios\n\n---\n\n## Anti-Patterns\n\n```python\n# Bad - Too broad\ntry:\n do_something()\nexcept:\n pass\n\n# Bad - Silent failure\ntry:\n do_something()\nexcept Exception:\n return False\n\n# Bad - Losing error context\ntry:\n do_something()\nexcept Exception:\n raise UserError(\"Something went wrong\") # Lost original error\n\n# Good - Preserve and log\ntry:\n do_something()\nexcept Exception as e:\n _logger.exception(\"Failed in do_something\")\n raise UserError(f\"Operation failed: {e}\")\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13176,"content_sha256":"72f0d4ce1d1aea038e1e2a62694c4f1b27a73fb67d4489cd973971cc7d468aed"},{"filename":"skills/external-api-patterns.md","content":"# External API Integration Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ EXTERNAL API INTEGRATION PATTERNS ║\n║ Connecting to third-party services, REST/SOAP APIs, and webhooks ║\n║ Use for payment gateways, shipping providers, CRM sync, etc. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Configuration Model\n\n### API Credentials Storage\n```python\nfrom odoo import api, fields, models\nfrom odoo.exceptions import ValidationError\nimport requests\n\n\nclass ExternalAPIConfig(models.Model):\n _name = 'external.api.config'\n _description = 'External API Configuration'\n\n name = fields.Char(string='Name', required=True)\n api_url = fields.Char(string='API URL', required=True)\n api_key = fields.Char(string='API Key', groups='base.group_system')\n api_secret = fields.Char(string='API Secret', groups='base.group_system')\n environment = fields.Selection(\n selection=[\n ('sandbox', 'Sandbox'),\n ('production', 'Production'),\n ],\n string='Environment',\n default='sandbox',\n required=True,\n )\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n )\n active = fields.Boolean(default=True)\n last_sync = fields.Datetime(string='Last Sync', readonly=True)\n\n @api.constrains('api_url')\n def _check_api_url(self):\n for config in self:\n if not config.api_url.startswith(('http://', 'https://')):\n raise ValidationError(\"API URL must start with http:// or https://\")\n\n def action_test_connection(self):\n \"\"\"Test API connection.\"\"\"\n self.ensure_one()\n try:\n response = self._make_request('GET', '/health')\n if response.status_code == 200:\n return {\n 'type': 'ir.actions.client',\n 'tag': 'display_notification',\n 'params': {\n 'title': 'Success',\n 'message': 'Connection successful!',\n 'type': 'success',\n }\n }\n except Exception as e:\n raise ValidationError(f\"Connection failed: {str(e)}\")\n```\n\n### System Parameters (Alternative)\n```python\n# Store in ir.config_parameter\ndef _get_api_key(self):\n \"\"\"Get API key from system parameters.\"\"\"\n return self.env['ir.config_parameter'].sudo().get_param(\n 'my_module.api_key', default=''\n )\n\ndef _set_api_key(self, value):\n \"\"\"Set API key in system parameters.\"\"\"\n self.env['ir.config_parameter'].sudo().set_param(\n 'my_module.api_key', value\n )\n```\n\n---\n\n## HTTP Client Mixin\n\n### Reusable API Client\n```python\nimport json\nimport logging\nimport requests\nfrom requests.adapters import HTTPAdapter\nfrom urllib3.util.retry import Retry\n\n_logger = logging.getLogger(__name__)\n\n\nclass APIClientMixin(models.AbstractModel):\n _name = 'api.client.mixin'\n _description = 'API Client Mixin'\n\n def _get_session(self):\n \"\"\"Get requests session with retry logic.\"\"\"\n session = requests.Session()\n\n retries = Retry(\n total=3,\n backoff_factor=0.5,\n status_forcelist=[500, 502, 503, 504],\n )\n adapter = HTTPAdapter(max_retries=retries)\n session.mount('http://', adapter)\n session.mount('https://', adapter)\n\n return session\n\n def _get_headers(self):\n \"\"\"Get default headers.\"\"\"\n config = self._get_api_config()\n return {\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {config.api_key}',\n 'X-API-Version': '2024-01',\n }\n\n def _get_api_config(self):\n \"\"\"Get API configuration for current company.\"\"\"\n config = self.env['external.api.config'].search([\n ('company_id', '=', self.env.company.id),\n ('active', '=', True),\n ], limit=1)\n\n if not config:\n raise ValidationError(\"No API configuration found for this company.\")\n\n return config\n\n def _make_request(self, method, endpoint, data=None, params=None):\n \"\"\"Make HTTP request to external API.\"\"\"\n config = self._get_api_config()\n url = f\"{config.api_url.rstrip('/')}/{endpoint.lstrip('/')}\"\n\n session = self._get_session()\n headers = self._get_headers()\n\n _logger.info(\"API Request: %s %s\", method, url)\n\n try:\n response = session.request(\n method=method,\n url=url,\n headers=headers,\n json=data,\n params=params,\n timeout=30,\n )\n\n _logger.info(\"API Response: %s\", response.status_code)\n\n if response.status_code >= 400:\n self._handle_error(response)\n\n return response\n\n except requests.Timeout:\n _logger.error(\"API Timeout: %s\", url)\n raise ValidationError(\"API request timed out. Please try again.\")\n\n except requests.RequestException as e:\n _logger.error(\"API Error: %s\", str(e))\n raise ValidationError(f\"API request failed: {str(e)}\")\n\n def _handle_error(self, response):\n \"\"\"Handle API error response.\"\"\"\n try:\n error_data = response.json()\n message = error_data.get('message', response.text)\n except json.JSONDecodeError:\n message = response.text\n\n _logger.error(\"API Error %s: %s\", response.status_code, message)\n\n if response.status_code == 401:\n raise ValidationError(\"Authentication failed. Check your API credentials.\")\n elif response.status_code == 403:\n raise ValidationError(\"Access forbidden. Check your API permissions.\")\n elif response.status_code == 404:\n raise ValidationError(\"Resource not found.\")\n elif response.status_code == 429:\n raise ValidationError(\"Rate limit exceeded. Please try again later.\")\n else:\n raise ValidationError(f\"API Error ({response.status_code}): {message}\")\n```\n\n---\n\n## Sync Patterns\n\n### Pull Sync (Import from External)\n```python\nclass ExternalProduct(models.Model):\n _name = 'external.product'\n _description = 'External Product Sync'\n _inherit = ['api.client.mixin']\n\n external_id = fields.Char(string='External ID', index=True)\n product_id = fields.Many2one('product.product', string='Odoo Product')\n sync_date = fields.Datetime(string='Last Sync')\n sync_status = fields.Selection([\n ('pending', 'Pending'),\n ('synced', 'Synced'),\n ('error', 'Error'),\n ], default='pending')\n sync_error = fields.Text(string='Sync Error')\n\n @api.model\n def _cron_sync_products(self):\n \"\"\"Cron job to sync products from external API.\"\"\"\n _logger.info(\"Starting product sync from external API\")\n\n try:\n response = self._make_request('GET', '/products', params={\n 'updated_since': self._get_last_sync_date(),\n 'limit': 100,\n })\n products = response.json().get('data', [])\n\n for product_data in products:\n self._sync_single_product(product_data)\n\n _logger.info(\"Synced %d products\", len(products))\n\n except Exception as e:\n _logger.error(\"Product sync failed: %s\", str(e))\n\n def _sync_single_product(self, data):\n \"\"\"Sync single product from external data.\"\"\"\n external_id = str(data['id'])\n\n # Find or create mapping\n mapping = self.search([('external_id', '=', external_id)], limit=1)\n if not mapping:\n mapping = self.create({'external_id': external_id})\n\n try:\n # Find or create Odoo product\n product = mapping.product_id\n if not product:\n product = self.env['product.product'].create({\n 'name': data['name'],\n 'default_code': data.get('sku'),\n 'list_price': data.get('price', 0),\n })\n mapping.product_id = product\n else:\n product.write({\n 'name': data['name'],\n 'list_price': data.get('price', 0),\n })\n\n mapping.write({\n 'sync_date': fields.Datetime.now(),\n 'sync_status': 'synced',\n 'sync_error': False,\n })\n\n except Exception as e:\n mapping.write({\n 'sync_status': 'error',\n 'sync_error': str(e),\n })\n _logger.error(\"Failed to sync product %s: %s\", external_id, str(e))\n```\n\n### Push Sync (Export to External)\n```python\nclass ResPartner(models.Model):\n _inherit = 'res.partner'\n\n external_customer_id = fields.Char(string='External Customer ID')\n sync_to_external = fields.Boolean(string='Sync to External', default=True)\n\n def write(self, vals):\n \"\"\"Override write to trigger external sync.\"\"\"\n result = super().write(vals)\n\n # Sync if relevant fields changed\n sync_fields = {'name', 'email', 'phone', 'street', 'city'}\n if self.sync_to_external and sync_fields & set(vals.keys()):\n self._sync_to_external_api()\n\n return result\n\n def _sync_to_external_api(self):\n \"\"\"Push customer data to external API.\"\"\"\n for partner in self:\n if not partner.sync_to_external:\n continue\n\n data = {\n 'name': partner.name,\n 'email': partner.email,\n 'phone': partner.phone,\n 'address': {\n 'street': partner.street,\n 'city': partner.city,\n 'zip': partner.zip,\n 'country': partner.country_id.code,\n },\n }\n\n try:\n api_client = self.env['api.client.mixin']\n\n if partner.external_customer_id:\n # Update existing\n response = api_client._make_request(\n 'PUT',\n f'/customers/{partner.external_customer_id}',\n data=data\n )\n else:\n # Create new\n response = api_client._make_request(\n 'POST', '/customers', data=data\n )\n result = response.json()\n partner.external_customer_id = result['id']\n\n except Exception as e:\n _logger.error(\"Failed to sync partner %s: %s\", partner.id, str(e))\n```\n\n### Bidirectional Sync\n```python\nclass SyncManager(models.Model):\n _name = 'sync.manager'\n _description = 'Bidirectional Sync Manager'\n _inherit = ['api.client.mixin']\n\n @api.model\n def _cron_full_sync(self):\n \"\"\"Full bidirectional sync.\"\"\"\n self._pull_changes()\n self._push_changes()\n\n def _pull_changes(self):\n \"\"\"Pull changes from external system.\"\"\"\n last_sync = self._get_last_sync_timestamp('pull')\n\n response = self._make_request('GET', '/changes', params={\n 'since': last_sync,\n 'types': 'customer,product,order',\n })\n\n for change in response.json().get('changes', []):\n self._process_incoming_change(change)\n\n self._set_last_sync_timestamp('pull')\n\n def _push_changes(self):\n \"\"\"Push local changes to external system.\"\"\"\n # Get records modified since last push\n last_sync = self._get_last_sync_timestamp('push')\n\n modified_partners = self.env['res.partner'].search([\n ('write_date', '>', last_sync),\n ('sync_to_external', '=', True),\n ])\n\n for partner in modified_partners:\n partner._sync_to_external_api()\n\n self._set_last_sync_timestamp('push')\n```\n\n---\n\n## Webhook Handling\n\n### Incoming Webhooks\n```python\nfrom odoo import http\nfrom odoo.http import request\nimport hmac\nimport hashlib\n\n\nclass WebhookController(http.Controller):\n\n @http.route('/webhook/external', type='json', auth='none',\n methods=['POST'], csrf=False)\n def handle_webhook(self):\n \"\"\"Handle incoming webhook from external service.\"\"\"\n # Verify signature\n signature = request.httprequest.headers.get('X-Signature')\n if not self._verify_signature(signature):\n return {'error': 'Invalid signature'}, 401\n\n data = request.jsonrequest\n event_type = data.get('event')\n\n _logger.info(\"Received webhook: %s\", event_type)\n\n try:\n if event_type == 'customer.created':\n self._handle_customer_created(data['payload'])\n elif event_type == 'customer.updated':\n self._handle_customer_updated(data['payload'])\n elif event_type == 'order.completed':\n self._handle_order_completed(data['payload'])\n else:\n _logger.warning(\"Unknown webhook event: %s\", event_type)\n\n return {'status': 'success'}\n\n except Exception as e:\n _logger.error(\"Webhook processing failed: %s\", str(e))\n return {'status': 'error', 'message': str(e)}\n\n def _verify_signature(self, signature):\n \"\"\"Verify webhook signature.\"\"\"\n if not signature:\n return False\n\n secret = request.env['ir.config_parameter'].sudo().get_param(\n 'my_module.webhook_secret'\n )\n if not secret:\n return False\n\n raw_body = request.httprequest.get_data()\n expected = hmac.new(\n secret.encode(),\n raw_body,\n hashlib.sha256\n ).hexdigest()\n\n return hmac.compare_digest(signature, expected)\n\n def _handle_customer_created(self, payload):\n \"\"\"Process customer creation webhook.\"\"\"\n partner = request.env['res.partner'].sudo().create({\n 'name': payload['name'],\n 'email': payload['email'],\n 'external_customer_id': payload['id'],\n })\n _logger.info(\"Created partner %s from webhook\", partner.id)\n```\n\n### Outgoing Webhooks\n```python\nclass WebhookSender(models.Model):\n _name = 'webhook.sender'\n _description = 'Outgoing Webhook Sender'\n\n @api.model\n def send_webhook(self, event_type, payload, url=None):\n \"\"\"Send webhook to external endpoint.\"\"\"\n if not url:\n url = self.env['ir.config_parameter'].sudo().get_param(\n 'my_module.webhook_url'\n )\n\n if not url:\n _logger.warning(\"No webhook URL configured\")\n return False\n\n data = {\n 'event': event_type,\n 'timestamp': fields.Datetime.now().isoformat(),\n 'payload': payload,\n }\n\n # Sign the payload\n secret = self.env['ir.config_parameter'].sudo().get_param(\n 'my_module.webhook_secret'\n )\n signature = hmac.new(\n secret.encode(),\n json.dumps(data).encode(),\n hashlib.sha256\n ).hexdigest()\n\n headers = {\n 'Content-Type': 'application/json',\n 'X-Signature': signature,\n }\n\n try:\n response = requests.post(\n url, json=data, headers=headers, timeout=10\n )\n response.raise_for_status()\n _logger.info(\"Webhook sent successfully: %s\", event_type)\n return True\n\n except Exception as e:\n _logger.error(\"Webhook failed: %s\", str(e))\n # Queue for retry\n self._queue_webhook_retry(event_type, payload, url)\n return False\n```\n\n---\n\n## OAuth2 Integration\n\n### OAuth2 Token Management\n```python\nfrom datetime import timedelta\n\n\nclass OAuth2Config(models.Model):\n _name = 'oauth2.config'\n _description = 'OAuth2 Configuration'\n\n name = fields.Char(string='Name', required=True)\n client_id = fields.Char(string='Client ID', required=True)\n client_secret = fields.Char(\n string='Client Secret',\n required=True,\n groups='base.group_system',\n )\n auth_url = fields.Char(string='Authorization URL')\n token_url = fields.Char(string='Token URL', required=True)\n scope = fields.Char(string='Scope')\n\n access_token = fields.Char(string='Access Token', groups='base.group_system')\n refresh_token = fields.Char(string='Refresh Token', groups='base.group_system')\n token_expiry = fields.Datetime(string='Token Expiry')\n\n def get_access_token(self):\n \"\"\"Get valid access token, refreshing if needed.\"\"\"\n self.ensure_one()\n\n if self.access_token and self.token_expiry:\n if fields.Datetime.now() \u003c self.token_expiry - timedelta(minutes=5):\n return self.access_token\n\n # Token expired or missing, refresh\n if self.refresh_token:\n self._refresh_token()\n else:\n self._get_new_token()\n\n return self.access_token\n\n def _refresh_token(self):\n \"\"\"Refresh the access token.\"\"\"\n response = requests.post(self.token_url, data={\n 'grant_type': 'refresh_token',\n 'refresh_token': self.refresh_token,\n 'client_id': self.client_id,\n 'client_secret': self.client_secret,\n })\n\n if response.status_code != 200:\n raise ValidationError(\"Token refresh failed\")\n\n self._process_token_response(response.json())\n\n def _get_new_token(self):\n \"\"\"Get new token using client credentials.\"\"\"\n response = requests.post(self.token_url, data={\n 'grant_type': 'client_credentials',\n 'client_id': self.client_id,\n 'client_secret': self.client_secret,\n 'scope': self.scope,\n })\n\n if response.status_code != 200:\n raise ValidationError(\"Token acquisition failed\")\n\n self._process_token_response(response.json())\n\n def _process_token_response(self, data):\n \"\"\"Process token response and store tokens.\"\"\"\n expires_in = data.get('expires_in', 3600)\n self.write({\n 'access_token': data['access_token'],\n 'refresh_token': data.get('refresh_token', self.refresh_token),\n 'token_expiry': fields.Datetime.now() + timedelta(seconds=expires_in),\n })\n```\n\n---\n\n## Rate Limiting\n\n### Rate Limiter\n```python\nimport time\nfrom collections import deque\n\n\nclass RateLimiter:\n \"\"\"Simple rate limiter for API calls.\"\"\"\n\n def __init__(self, max_calls, period):\n self.max_calls = max_calls\n self.period = period # seconds\n self.calls = deque()\n\n def wait_if_needed(self):\n \"\"\"Wait if rate limit would be exceeded.\"\"\"\n now = time.time()\n\n # Remove old calls outside the window\n while self.calls and self.calls[0] \u003c now - self.period:\n self.calls.popleft()\n\n if len(self.calls) >= self.max_calls:\n sleep_time = self.period - (now - self.calls[0])\n if sleep_time > 0:\n _logger.info(\"Rate limit reached, sleeping %.2fs\", sleep_time)\n time.sleep(sleep_time)\n\n self.calls.append(time.time())\n\n\n# Usage in API client\nclass APIClient(models.AbstractModel):\n _name = 'api.client'\n _rate_limiter = RateLimiter(max_calls=100, period=60)\n\n def _make_request(self, method, endpoint, **kwargs):\n self._rate_limiter.wait_if_needed()\n # ... rest of request logic\n```\n\n---\n\n## Error Handling & Retry\n\n### Retry with Exponential Backoff\n```python\nimport time\nfrom functools import wraps\n\n\ndef retry_on_failure(max_retries=3, backoff_factor=2):\n \"\"\"Decorator for retry with exponential backoff.\"\"\"\n def decorator(func):\n @wraps(func)\n def wrapper(*args, **kwargs):\n last_exception = None\n for attempt in range(max_retries):\n try:\n return func(*args, **kwargs)\n except (requests.Timeout, requests.ConnectionError) as e:\n last_exception = e\n if attempt \u003c max_retries - 1:\n sleep_time = backoff_factor ** attempt\n _logger.warning(\n \"Attempt %d failed, retrying in %ds: %s\",\n attempt + 1, sleep_time, str(e)\n )\n time.sleep(sleep_time)\n raise last_exception\n return wrapper\n return decorator\n\n\nclass APIClientWithRetry(models.AbstractModel):\n _name = 'api.client.retry'\n\n @retry_on_failure(max_retries=3, backoff_factor=2)\n def _make_request(self, method, endpoint, **kwargs):\n # Request implementation\n pass\n```\n\n---\n\n## Best Practices\n\n1. **Never hardcode credentials** - Use ir.config_parameter or dedicated config model\n2. **Use HTTPS** - Always use secure connections\n3. **Implement retry logic** - Handle transient failures\n4. **Log all API calls** - For debugging and audit\n5. **Handle rate limits** - Implement backoff strategies\n6. **Validate responses** - Don't trust external data\n7. **Use timeouts** - Prevent hanging requests\n8. **Queue heavy operations** - Don't block user actions\n9. **Test with sandbox** - Use environment switching\n10. **Secure webhooks** - Always verify signatures\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":21946,"content_sha256":"b7bf8db4fc5a07f9929325b37776b9e3078bddd71c49248546202db5c5d5dacb"},{"filename":"skills/field-type-reference.md","content":"# Odoo Field Type Reference\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ FIELD TYPE REFERENCE ║\n║ Complete reference for all Odoo field types with version-specific notes ║\n║ Use when defining model fields ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Field Types Overview\n\n| Type | Python Class | Storage | Use Case |\n|------|-------------|---------|----------|\n| Char | `fields.Char` | VARCHAR | Short text, names |\n| Text | `fields.Text` | TEXT | Long text, descriptions |\n| Html | `fields.Html` | TEXT | Rich formatted text |\n| Integer | `fields.Integer` | INTEGER | Whole numbers |\n| Float | `fields.Float` | FLOAT | Decimal numbers |\n| Monetary | `fields.Monetary` | NUMERIC | Currency amounts |\n| Boolean | `fields.Boolean` | BOOLEAN | True/False |\n| Date | `fields.Date` | DATE | Dates without time |\n| Datetime | `fields.Datetime` | TIMESTAMP | Dates with time |\n| Selection | `fields.Selection` | VARCHAR | Choice from list |\n| Binary | `fields.Binary` | BYTEA | Files, images |\n| Many2one | `fields.Many2one` | INTEGER (FK) | Single relation |\n| One2many | `fields.One2many` | Virtual | Reverse of Many2one |\n| Many2many | `fields.Many2many` | Junction table | Multiple relations |\n\n---\n\n## String Fields\n\n### Char\n```python\n# Basic\nname = fields.Char(string='Name')\n\n# With constraints\nname = fields.Char(\n string='Name',\n required=True,\n size=64, # Max length (rarely used)\n trim=True, # Strip whitespace (default True)\n translate=True, # Enable translations\n)\n\n# With tracking (v15+)\nname = fields.Char(\n string='Name',\n required=True,\n tracking=True, # Track in chatter\n index=True, # Create index\n)\n\n# With index types (v16+)\ncode = fields.Char(\n string='Code',\n index='btree_not_null', # Exclude NULL values from index\n)\nsearch_name = fields.Char(\n string='Search Name',\n index='trigram', # For ILIKE searches\n)\n```\n\n### Text\n```python\ndescription = fields.Text(\n string='Description',\n translate=True,\n tracking=True,\n)\n```\n\n### Html\n```python\ncontent = fields.Html(\n string='Content',\n sanitize=True, # Clean HTML (default True)\n sanitize_tags=True, # Remove unsafe tags\n sanitize_attributes=True, # Remove unsafe attributes\n sanitize_style=True, # Clean style attributes\n strip_style=False, # Remove all styles\n strip_classes=False, # Remove all classes\n)\n```\n\n---\n\n## Numeric Fields\n\n### Integer\n```python\nsequence = fields.Integer(\n string='Sequence',\n default=10,\n index=True,\n)\n\ncount = fields.Integer(\n string='Count',\n compute='_compute_count',\n store=True,\n)\n```\n\n### Float\n```python\n# Basic\nquantity = fields.Float(string='Quantity')\n\n# With precision\nquantity = fields.Float(\n string='Quantity',\n digits='Product Unit of Measure', # Named precision\n)\n\n# With explicit precision\namount = fields.Float(\n string='Amount',\n digits=(16, 2), # (total, decimal)\n)\n\n# Computed with aggregation\ntotal = fields.Float(\n string='Total',\n compute='_compute_total',\n store=True,\n group_operator='sum', # For group_by aggregation\n)\n```\n\n### Monetary\n```python\n# Requires currency field\ncurrency_id = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n default=lambda self: self.env.company.currency_id,\n)\n\namount = fields.Monetary(\n string='Amount',\n currency_field='currency_id', # REQUIRED: link to currency\n)\n\n# Related currency (common pattern)\ncurrency_id = fields.Many2one(\n comodel_name='res.currency',\n related='company_id.currency_id',\n store=True,\n)\n```\n\n---\n\n## Boolean Fields\n\n```python\nactive = fields.Boolean(\n string='Active',\n default=True,\n)\n\nis_done = fields.Boolean(\n string='Done',\n compute='_compute_is_done',\n store=True,\n)\n\n# Copy behavior\ncopy_this = fields.Boolean(default=True) # Copied by default\ndont_copy = fields.Boolean(default=True, copy=False) # Not copied\n```\n\n---\n\n## Date/Time Fields\n\n### Date\n```python\ndate = fields.Date(\n string='Date',\n default=fields.Date.today, # Today as default\n)\n\ndate = fields.Date(\n string='Date',\n default=fields.Date.context_today, # Today in user's timezone\n)\n\n# Computed date\ndeadline = fields.Date(\n string='Deadline',\n compute='_compute_deadline',\n store=True,\n index=True,\n)\n```\n\n### Datetime\n```python\ndatetime = fields.Datetime(\n string='Date Time',\n default=fields.Datetime.now, # Current datetime\n)\n\n# With copy behavior\ncreate_datetime = fields.Datetime(\n string='Created',\n default=fields.Datetime.now,\n copy=False,\n)\n```\n\n---\n\n## Selection Fields\n\n### Basic Selection\n```python\nstate = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n)\n```\n\n### Extending Selection (inheritance)\n```python\n# In inherited model\nstate = fields.Selection(\n selection_add=[\n ('approved', 'Approved'), # Add new option\n ('rejected', 'Rejected'),\n ],\n ondelete={ # Handle deletion (v14+)\n 'approved': 'set default',\n 'rejected': 'cascade',\n },\n)\n```\n\n### Dynamic Selection\n```python\ntype = fields.Selection(\n selection='_get_type_selection',\n string='Type',\n)\n\[email protected]\ndef _get_type_selection(self):\n return [\n ('type1', 'Type 1'),\n ('type2', 'Type 2'),\n ]\n```\n\n---\n\n## Binary Fields\n\n```python\n# File attachment\ndocument = fields.Binary(\n string='Document',\n attachment=True, # Store as attachment (recommended)\n)\ndocument_name = fields.Char(string='File Name')\n\n# Image with auto-resize\nimage = fields.Image(\n string='Image',\n max_width=1920,\n max_height=1920,\n)\n\n# Image variants (automatic)\nimage_128 = fields.Image(\n string='Image 128',\n related='image',\n max_width=128,\n max_height=128,\n store=True,\n)\n```\n\n---\n\n## Relational Fields\n\n### Many2one\n```python\n# Basic\npartner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n)\n\n# With constraints\npartner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n required=True,\n ondelete='cascade', # cascade, set null, restrict\n index=True,\n tracking=True,\n)\n\n# With domain\npartner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Customer',\n domain=[('customer_rank', '>', 0)],\n)\n\n# Dynamic domain\npartner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n domain=\"[('company_id', '=', company_id)]\",\n)\n\n# v18+ Multi-company\npartner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n check_company=True, # REQUIRED in v18+\n)\n```\n\n### One2many\n```python\n# Basic (REQUIRES inverse_name)\nline_ids = fields.One2many(\n comodel_name='my.model.line',\n inverse_name='model_id', # REQUIRED\n string='Lines',\n)\n\n# With copy behavior\nline_ids = fields.One2many(\n comodel_name='my.model.line',\n inverse_name='model_id',\n string='Lines',\n copy=True, # Copy lines when record copied\n)\n\n# With domain\nactive_line_ids = fields.One2many(\n comodel_name='my.model.line',\n inverse_name='model_id',\n string='Active Lines',\n domain=[('active', '=', True)],\n)\n```\n\n### Many2many\n```python\n# Basic (auto table name)\ntag_ids = fields.Many2many(\n comodel_name='my.model.tag',\n string='Tags',\n)\n\n# With explicit relation table\ntag_ids = fields.Many2many(\n comodel_name='my.model.tag',\n relation='my_model_tag_rel', # Junction table name\n column1='model_id', # This model's column\n column2='tag_id', # Related model's column\n string='Tags',\n)\n\n# With domain\nactive_tag_ids = fields.Many2many(\n comodel_name='my.model.tag',\n string='Active Tags',\n domain=[('active', '=', True)],\n)\n```\n\n---\n\n## Computed Fields\n\n### Basic Computed\n```python\nfull_name = fields.Char(\n string='Full Name',\n compute='_compute_full_name',\n)\n\[email protected]('first_name', 'last_name')\ndef _compute_full_name(self):\n for record in self:\n record.full_name = f\"{record.first_name or ''} {record.last_name or ''}\".strip()\n```\n\n### Stored Computed\n```python\ntotal = fields.Float(\n string='Total',\n compute='_compute_total',\n store=True, # Save to database\n)\n\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n```\n\n### Inverse (Editable Computed)\n```python\nfull_name = fields.Char(\n string='Full Name',\n compute='_compute_full_name',\n inverse='_inverse_full_name', # Makes field editable\n store=True,\n)\n\ndef _inverse_full_name(self):\n for record in self:\n if record.full_name:\n parts = record.full_name.split(' ', 1)\n record.first_name = parts[0]\n record.last_name = parts[1] if len(parts) > 1 else ''\n```\n\n### Search on Computed\n```python\ntotal = fields.Float(\n string='Total',\n compute='_compute_total',\n search='_search_total', # Enable searching\n)\n\ndef _search_total(self, operator, value):\n # Return domain that filters records\n if operator == '>':\n ids = self.search([]).filtered(lambda r: r.total > value).ids\n return [('id', 'in', ids)]\n return []\n```\n\n---\n\n## Related Fields\n\n```python\n# Simple related\npartner_name = fields.Char(\n string='Partner Name',\n related='partner_id.name',\n)\n\n# Stored related (for performance/search)\npartner_name = fields.Char(\n string='Partner Name',\n related='partner_id.name',\n store=True,\n index=True,\n)\n\n# Readonly related (can't edit through this field)\npartner_email = fields.Char(\n string='Partner Email',\n related='partner_id.email',\n readonly=True,\n)\n```\n\n---\n\n## Common Field Attributes\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `string` | str | Field label |\n| `help` | str | Tooltip text |\n| `required` | bool | Cannot be empty |\n| `readonly` | bool | Cannot be edited in UI |\n| `index` | bool/str | Create database index |\n| `default` | value/callable | Default value |\n| `copy` | bool | Copy when duplicating |\n| `groups` | str | Access groups (comma separated) |\n| `tracking` | bool | Track in chatter (v15+) |\n| `store` | bool | Store computed field |\n| `compute` | str | Compute method name |\n| `depends` | str | Dependency fields |\n| `inverse` | str | Inverse method name |\n\n---\n\n## Version-Specific Notes\n\n### v14\n```python\n# Use track_visibility (deprecated in v15)\nname = fields.Char(track_visibility='onchange')\n```\n\n### v15+\n```python\n# Use tracking\nname = fields.Char(tracking=True)\n```\n\n### v16+\n```python\n# Index types\ncode = fields.Char(index='btree_not_null')\nname = fields.Char(index='trigram')\n```\n\n### v18+\n```python\n# Multi-company fields\npartner_id = fields.Many2one('res.partner', check_company=True)\n\n# Type hints on model\nclass MyModel(models.Model):\n name: str = fields.Char(required=True)\n amount: float = fields.Float()\n```\n\n### v19+\n```python\n# Type hints required\nclass MyModel(models.Model):\n _name = 'my.model'\n\n name: str = fields.Char(required=True)\n active: bool = fields.Boolean(default=True)\n amount: float = fields.Monetary(currency_field='currency_id')\n```\n\n---\n\n## Field Naming Conventions\n\n| Suffix | Field Type | Example |\n|--------|-----------|---------|\n| `_id` | Many2one | `partner_id`, `company_id` |\n| `_ids` | One2many, Many2many | `line_ids`, `tag_ids` |\n| `_count` | Integer (computed) | `order_count`, `task_count` |\n| `_date` | Date | `create_date`, `due_date` |\n| `_datetime` | Datetime | `start_datetime` |\n| `is_` | Boolean | `is_done`, `is_locked` |\n| `has_` | Boolean | `has_children`, `has_invoice` |\n| `x_` | Custom field | `x_custom_field` (for studio/inheritance) |\n\n---\n\n## Security-Sensitive Fields\n\n```python\n# Hide from non-admin\nsalary = fields.Monetary(\n string='Salary',\n groups='hr.group_hr_manager', # Only HR managers\n)\n\n# Multiple groups\nsecret_field = fields.Char(\n string='Secret',\n groups='base.group_system,hr.group_hr_manager',\n)\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12945,"content_sha256":"25f380c020d49ac26da731410885bb8be707b496624bceed90137a85977203df"},{"filename":"skills/hr-employee-patterns.md","content":"# HR and Employee Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ HR & EMPLOYEE PATTERNS ║\n║ Employee management, contracts, attendance, and HR workflows ║\n║ Use for HR modules, time tracking, and workforce management ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Module Setup\n\n### Manifest Dependencies\n```python\n{\n 'name': 'My HR Module',\n 'version': '18.0.1.0.0',\n 'depends': ['hr'], # Core HR\n # Optional: 'hr_contract', 'hr_attendance', 'hr_holidays', 'hr_expense'\n 'data': [\n 'security/ir.model.access.csv',\n 'views/hr_views.xml',\n ],\n}\n```\n\n---\n\n## Extending Employee Model\n\n### Add Custom Fields\n```python\nfrom odoo import api, fields, models\nfrom datetime import date\nfrom dateutil.relativedelta import relativedelta\n\n\nclass HrEmployee(models.Model):\n _inherit = 'hr.employee'\n\n # Personal Info\n x_emergency_contact = fields.Char(string='Emergency Contact')\n x_emergency_phone = fields.Char(string='Emergency Phone')\n x_blood_type = fields.Selection([\n ('a+', 'A+'), ('a-', 'A-'),\n ('b+', 'B+'), ('b-', 'B-'),\n ('ab+', 'AB+'), ('ab-', 'AB-'),\n ('o+', 'O+'), ('o-', 'O-'),\n ], string='Blood Type')\n\n # Employment Info\n x_employee_number = fields.Char(\n string='Employee Number',\n copy=False,\n readonly=True,\n default='New',\n )\n x_hire_date = fields.Date(string='Hire Date')\n x_probation_end = fields.Date(\n string='Probation End Date',\n compute='_compute_probation_end',\n store=True,\n )\n x_years_of_service = fields.Float(\n string='Years of Service',\n compute='_compute_years_of_service',\n )\n x_employment_type = fields.Selection([\n ('full_time', 'Full Time'),\n ('part_time', 'Part Time'),\n ('contractor', 'Contractor'),\n ('intern', 'Intern'),\n ], string='Employment Type', default='full_time')\n\n # Skills & Certifications\n x_skill_ids = fields.Many2many(\n 'hr.skill',\n string='Skills',\n )\n x_certification_ids = fields.One2many(\n 'hr.employee.certification',\n 'employee_id',\n string='Certifications',\n )\n\n @api.model_create_multi\n def create(self, vals_list):\n for vals in vals_list:\n if vals.get('x_employee_number', 'New') == 'New':\n vals['x_employee_number'] = self.env['ir.sequence'].next_by_code(\n 'hr.employee.number'\n ) or 'New'\n return super().create(vals_list)\n\n @api.depends('x_hire_date')\n def _compute_probation_end(self):\n for employee in self:\n if employee.x_hire_date:\n employee.x_probation_end = employee.x_hire_date + relativedelta(months=3)\n else:\n employee.x_probation_end = False\n\n def _compute_years_of_service(self):\n today = date.today()\n for employee in self:\n if employee.x_hire_date:\n delta = relativedelta(today, employee.x_hire_date)\n employee.x_years_of_service = delta.years + (delta.months / 12)\n else:\n employee.x_years_of_service = 0.0\n```\n\n### Employee Certification Model\n```python\nclass HrEmployeeCertification(models.Model):\n _name = 'hr.employee.certification'\n _description = 'Employee Certification'\n\n employee_id = fields.Many2one(\n 'hr.employee',\n string='Employee',\n required=True,\n ondelete='cascade',\n )\n name = fields.Char(string='Certification Name', required=True)\n issuing_org = fields.Char(string='Issuing Organization')\n issue_date = fields.Date(string='Issue Date')\n expiry_date = fields.Date(string='Expiry Date')\n certificate_file = fields.Binary(string='Certificate File')\n certificate_filename = fields.Char(string='Filename')\n is_expired = fields.Boolean(\n string='Expired',\n compute='_compute_is_expired',\n )\n\n def _compute_is_expired(self):\n today = date.today()\n for cert in self:\n cert.is_expired = cert.expiry_date and cert.expiry_date \u003c today\n```\n\n---\n\n## Department Extensions\n\n### Custom Department Fields\n```python\nclass HrDepartment(models.Model):\n _inherit = 'hr.department'\n\n x_budget = fields.Monetary(\n string='Department Budget',\n currency_field='x_currency_id',\n )\n x_currency_id = fields.Many2one(\n 'res.currency',\n default=lambda self: self.env.company.currency_id,\n )\n x_cost_center = fields.Char(string='Cost Center')\n x_location_id = fields.Many2one('res.partner', string='Location')\n\n x_employee_count = fields.Integer(\n string='Employee Count',\n compute='_compute_employee_count',\n )\n\n def _compute_employee_count(self):\n for dept in self:\n dept.x_employee_count = self.env['hr.employee'].search_count([\n ('department_id', '=', dept.id),\n ('active', '=', True),\n ])\n```\n\n---\n\n## Job Positions\n\n### Extend Job Model\n```python\nclass HrJob(models.Model):\n _inherit = 'hr.job'\n\n x_min_salary = fields.Monetary(\n string='Minimum Salary',\n currency_field='x_currency_id',\n )\n x_max_salary = fields.Monetary(\n string='Maximum Salary',\n currency_field='x_currency_id',\n )\n x_currency_id = fields.Many2one(\n 'res.currency',\n default=lambda self: self.env.company.currency_id,\n )\n x_required_skills = fields.Many2many(\n 'hr.skill',\n string='Required Skills',\n )\n x_education_level = fields.Selection([\n ('high_school', 'High School'),\n ('bachelor', 'Bachelor\\'s Degree'),\n ('master', 'Master\\'s Degree'),\n ('phd', 'PhD'),\n ], string='Education Required')\n x_experience_years = fields.Integer(string='Experience Required (Years)')\n```\n\n---\n\n## Attendance Integration\n\n### Custom Attendance Logic\n```python\nclass HrAttendance(models.Model):\n _inherit = 'hr.attendance'\n\n x_location = fields.Char(string='Check-in Location')\n x_device_id = fields.Char(string='Device ID')\n x_is_remote = fields.Boolean(string='Remote Work')\n x_overtime_hours = fields.Float(\n string='Overtime Hours',\n compute='_compute_overtime',\n store=True,\n )\n\n @api.depends('check_in', 'check_out')\n def _compute_overtime(self):\n for attendance in self:\n if attendance.worked_hours > 8:\n attendance.x_overtime_hours = attendance.worked_hours - 8\n else:\n attendance.x_overtime_hours = 0.0\n\n\nclass HrEmployee(models.Model):\n _inherit = 'hr.employee'\n\n def action_check_in(self, location=None):\n \"\"\"Custom check-in with location.\"\"\"\n self.ensure_one()\n return self.env['hr.attendance'].create({\n 'employee_id': self.id,\n 'check_in': fields.Datetime.now(),\n 'x_location': location,\n })\n\n def action_check_out(self):\n \"\"\"Custom check-out.\"\"\"\n self.ensure_one()\n attendance = self.env['hr.attendance'].search([\n ('employee_id', '=', self.id),\n ('check_out', '=', False),\n ], limit=1)\n\n if attendance:\n attendance.write({'check_out': fields.Datetime.now()})\n return attendance\n return False\n```\n\n---\n\n## Leave Management\n\n### Custom Leave Types\n```python\nclass HrLeaveType(models.Model):\n _inherit = 'hr.leave.type'\n\n x_requires_approval = fields.Boolean(\n string='Requires Manager Approval',\n default=True,\n )\n x_max_days_per_request = fields.Integer(\n string='Max Days Per Request',\n default=0,\n help='0 = no limit',\n )\n x_requires_attachment = fields.Boolean(\n string='Requires Attachment',\n help='E.g., medical certificate for sick leave',\n )\n\n\nclass HrLeave(models.Model):\n _inherit = 'hr.leave'\n\n x_attachment_ids = fields.Many2many(\n 'ir.attachment',\n string='Attachments',\n )\n\n @api.constrains('holiday_status_id', 'number_of_days', 'x_attachment_ids')\n def _check_leave_requirements(self):\n for leave in self:\n leave_type = leave.holiday_status_id\n\n # Check max days\n if leave_type.x_max_days_per_request > 0:\n if leave.number_of_days > leave_type.x_max_days_per_request:\n raise ValidationError(\n f\"Maximum {leave_type.x_max_days_per_request} days allowed per request.\"\n )\n\n # Check attachment requirement\n if leave_type.x_requires_attachment and not leave.x_attachment_ids:\n raise ValidationError(\n f\"Attachment required for {leave_type.name}.\"\n )\n```\n\n---\n\n## Employee Onboarding\n\n### Onboarding Checklist\n```python\nclass HrOnboardingTask(models.Model):\n _name = 'hr.onboarding.task'\n _description = 'Onboarding Task'\n _order = 'sequence, id'\n\n name = fields.Char(string='Task', required=True)\n description = fields.Text(string='Description')\n sequence = fields.Integer(default=10)\n department_id = fields.Many2one('hr.department', string='Department')\n responsible_id = fields.Many2one('res.users', string='Responsible')\n days_after_hire = fields.Integer(\n string='Days After Hire',\n help='When task should be completed',\n )\n is_mandatory = fields.Boolean(string='Mandatory', default=True)\n\n\nclass HrEmployeeOnboarding(models.Model):\n _name = 'hr.employee.onboarding'\n _description = 'Employee Onboarding Progress'\n\n employee_id = fields.Many2one(\n 'hr.employee',\n string='Employee',\n required=True,\n ondelete='cascade',\n )\n task_id = fields.Many2one(\n 'hr.onboarding.task',\n string='Task',\n required=True,\n )\n state = fields.Selection([\n ('pending', 'Pending'),\n ('in_progress', 'In Progress'),\n ('done', 'Done'),\n ('skipped', 'Skipped'),\n ], string='Status', default='pending')\n completed_date = fields.Date(string='Completed Date')\n completed_by = fields.Many2one('res.users', string='Completed By')\n notes = fields.Text(string='Notes')\n\n def action_complete(self):\n self.write({\n 'state': 'done',\n 'completed_date': fields.Date.today(),\n 'completed_by': self.env.uid,\n })\n\n\nclass HrEmployee(models.Model):\n _inherit = 'hr.employee'\n\n x_onboarding_ids = fields.One2many(\n 'hr.employee.onboarding',\n 'employee_id',\n string='Onboarding Tasks',\n )\n x_onboarding_progress = fields.Float(\n string='Onboarding Progress',\n compute='_compute_onboarding_progress',\n )\n\n def _compute_onboarding_progress(self):\n for employee in self:\n total = len(employee.x_onboarding_ids)\n done = len(employee.x_onboarding_ids.filtered(\n lambda t: t.state == 'done'\n ))\n employee.x_onboarding_progress = (done / total * 100) if total else 0\n\n def action_create_onboarding(self):\n \"\"\"Create onboarding tasks for new employee.\"\"\"\n self.ensure_one()\n\n # Get tasks for employee's department or general\n tasks = self.env['hr.onboarding.task'].search([\n '|',\n ('department_id', '=', self.department_id.id),\n ('department_id', '=', False),\n ])\n\n for task in tasks:\n self.env['hr.employee.onboarding'].create({\n 'employee_id': self.id,\n 'task_id': task.id,\n })\n\n return True\n```\n\n---\n\n## Views\n\n### Employee Form Extension\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"view_employee_form_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">hr.employee.form.inherit\u003c/field>\n \u003cfield name=\"model\">hr.employee\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"hr.view_employee_form\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cfield name=\"job_id\" position=\"before\">\n \u003cfield name=\"x_employee_number\"/>\n \u003c/field>\n\n \u003cxpath expr=\"//page[@name='public']\" position=\"after\">\n \u003cpage string=\"Employment\" name=\"employment\">\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"x_hire_date\"/>\n \u003cfield name=\"x_probation_end\"/>\n \u003cfield name=\"x_years_of_service\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"x_employment_type\"/>\n \u003c/group>\n \u003c/group>\n \u003c/page>\n \u003cpage string=\"Emergency\" name=\"emergency\">\n \u003cgroup>\n \u003cfield name=\"x_emergency_contact\"/>\n \u003cfield name=\"x_emergency_phone\"/>\n \u003cfield name=\"x_blood_type\"/>\n \u003c/group>\n \u003c/page>\n \u003cpage string=\"Skills & Certifications\" name=\"skills\">\n \u003cfield name=\"x_skill_ids\" widget=\"many2many_tags\"/>\n \u003cfield name=\"x_certification_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"issuing_org\"/>\n \u003cfield name=\"issue_date\"/>\n \u003cfield name=\"expiry_date\"/>\n \u003cfield name=\"is_expired\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/xpath>\n\n \u003cdiv name=\"button_box\" position=\"inside\">\n \u003cbutton class=\"oe_stat_button\" type=\"object\"\n name=\"action_view_onboarding\"\n icon=\"fa-tasks\">\n \u003cfield string=\"Onboarding\" name=\"x_onboarding_progress\"\n widget=\"percentpie\"/>\n \u003c/button>\n \u003c/div>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n---\n\n## Scheduled Actions\n\n### Probation Reminder Cron\n```python\[email protected]\ndef _cron_probation_reminder(self):\n \"\"\"Send reminder for employees ending probation.\"\"\"\n in_7_days = date.today() + timedelta(days=7)\n\n employees = self.env['hr.employee'].search([\n ('x_probation_end', '=', in_7_days),\n ])\n\n template = self.env.ref('my_module.email_template_probation_reminder')\n for employee in employees:\n if employee.parent_id.work_email:\n template.send_mail(employee.id)\n```\n\n### Certification Expiry Alert\n```python\[email protected]\ndef _cron_certification_expiry(self):\n \"\"\"Alert for expiring certifications.\"\"\"\n in_30_days = date.today() + timedelta(days=30)\n\n expiring = self.env['hr.employee.certification'].search([\n ('expiry_date', '\u003c=', in_30_days),\n ('expiry_date', '>=', date.today()),\n ])\n\n for cert in expiring:\n cert.employee_id.message_post(\n body=f\"Certification '{cert.name}' expires on {cert.expiry_date}\",\n message_type='notification',\n )\n```\n\n---\n\n## Best Practices\n\n1. **Privacy** - Use `groups=\"hr.group_hr_user\"` for sensitive fields\n2. **Employee self-service** - Separate views for employees vs HR\n3. **Multi-company** - Filter employees by company\n4. **Manager hierarchy** - Use `parent_id` for reporting structure\n5. **Document management** - Attach contracts, certificates\n6. **Activity scheduling** - Use activities for HR tasks\n7. **Audit trail** - Track changes to sensitive data\n8. **Integration** - Connect with payroll, expense, timesheet\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":16169,"content_sha256":"3077bde6c3f656ec916be01505ab7496d2b15e95abcb70f289619cc787149268"},{"filename":"skills/import-export-patterns.md","content":"# Import/Export Data Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ IMPORT/EXPORT DATA PATTERNS ║\n║ CSV import, Excel export, data migration, and bulk operations ║\n║ Use for data loading, reporting exports, and system integration ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## CSV Import Basics\n\n### Standard Import Format\n```csv\nid,name,email,phone,country_id/id\npartner_001,Acme Corp,[email protected],+1-555-0100,base.us\npartner_002,Beta Inc,[email protected],+1-555-0200,base.us\n__import__.partner_003,Gamma LLC,[email protected],+1-555-0300,base.ca\n```\n\n### Import Column Patterns\n| Pattern | Description | Example |\n|---------|-------------|---------|\n| `field` | Direct field | `name`, `email` |\n| `field/id` | External ID reference | `country_id/id` |\n| `field.subfield` | Related field | `partner_id.name` |\n| `field/0/subfield` | One2many line | `line_ids/0/product_id` |\n\n---\n\n## Programmatic Import\n\n### Import CSV Data\n```python\nimport base64\nimport csv\nfrom io import StringIO\n\n\ndef import_partners_from_csv(self, csv_content):\n \"\"\"Import partners from CSV string.\"\"\"\n reader = csv.DictReader(StringIO(csv_content))\n\n created = []\n errors = []\n\n for row in reader:\n try:\n # Find country by code\n country = self.env['res.country'].search([\n ('code', '=', row.get('country_code', 'US'))\n ], limit=1)\n\n partner = self.env['res.partner'].create({\n 'name': row['name'],\n 'email': row.get('email'),\n 'phone': row.get('phone'),\n 'country_id': country.id,\n })\n created.append(partner.id)\n except Exception as e:\n errors.append({\n 'row': row,\n 'error': str(e),\n })\n\n return {\n 'created': created,\n 'errors': errors,\n 'total': len(created) + len(errors),\n }\n```\n\n### Using base_import\n```python\ndef import_with_base_import(self, model_name, csv_content, fields):\n \"\"\"Use Odoo's base import functionality.\"\"\"\n import_wizard = self.env['base_import.import'].create({\n 'res_model': model_name,\n 'file': base64.b64encode(csv_content.encode()),\n 'file_name': 'import.csv',\n 'file_type': 'text/csv',\n })\n\n result = import_wizard.execute_import(\n fields, # ['name', 'email', 'country_id/id']\n [], # columns (auto-detect)\n {'quoting': '\"', 'separator': ',', 'headers': True}\n )\n\n return result\n```\n\n---\n\n## Export Data\n\n### Export to CSV\n```python\nimport csv\nfrom io import StringIO\nimport base64\n\n\ndef export_partners_to_csv(self, domain=None):\n \"\"\"Export partners to CSV.\"\"\"\n partners = self.env['res.partner'].search(domain or [])\n\n output = StringIO()\n writer = csv.writer(output)\n\n # Header\n writer.writerow(['ID', 'Name', 'Email', 'Phone', 'Country'])\n\n # Data\n for partner in partners:\n writer.writerow([\n partner.id,\n partner.name,\n partner.email or '',\n partner.phone or '',\n partner.country_id.name or '',\n ])\n\n content = output.getvalue()\n output.close()\n\n return content\n```\n\n### Export to Excel\n```python\nimport xlsxwriter\nfrom io import BytesIO\nimport base64\n\n\ndef export_to_excel(self, records, fields, filename='export.xlsx'):\n \"\"\"Export records to Excel file.\"\"\"\n output = BytesIO()\n workbook = xlsxwriter.Workbook(output, {'in_memory': True})\n worksheet = workbook.add_worksheet('Data')\n\n # Styles\n header_format = workbook.add_format({\n 'bold': True,\n 'bg_color': '#4472C4',\n 'font_color': 'white',\n 'border': 1,\n })\n date_format = workbook.add_format({'num_format': 'yyyy-mm-dd'})\n money_format = workbook.add_format({'num_format': '#,##0.00'})\n\n # Write header\n for col, field in enumerate(fields):\n worksheet.write(0, col, field, header_format)\n\n # Write data\n for row, record in enumerate(records, start=1):\n for col, field in enumerate(fields):\n value = record[field]\n if isinstance(value, (int, float)):\n worksheet.write_number(row, col, value)\n elif hasattr(value, 'id'): # Many2one\n worksheet.write(row, col, value.display_name or '')\n else:\n worksheet.write(row, col, str(value) if value else '')\n\n workbook.close()\n output.seek(0)\n\n return base64.b64encode(output.read())\n```\n\n---\n\n## Import Wizard\n\n### Create Import Wizard\n```python\nclass ImportWizard(models.TransientModel):\n _name = 'import.wizard'\n _description = 'Import Wizard'\n\n file = fields.Binary(string='File', required=True)\n file_name = fields.Char(string='File Name')\n import_type = fields.Selection([\n ('create', 'Create Only'),\n ('update', 'Update Only'),\n ('create_update', 'Create or Update'),\n ], default='create', required=True)\n\n result_ids = fields.One2many(\n 'import.wizard.result',\n 'wizard_id',\n string='Results',\n )\n\n def action_import(self):\n \"\"\"Process the import.\"\"\"\n self.ensure_one()\n\n # Decode file\n content = base64.b64decode(self.file).decode('utf-8')\n reader = csv.DictReader(StringIO(content))\n\n results = []\n for row_num, row in enumerate(reader, start=2):\n try:\n record = self._process_row(row)\n results.append({\n 'wizard_id': self.id,\n 'row_number': row_num,\n 'status': 'success',\n 'record_id': record.id,\n 'message': f'Created: {record.display_name}',\n })\n except Exception as e:\n results.append({\n 'wizard_id': self.id,\n 'row_number': row_num,\n 'status': 'error',\n 'message': str(e),\n })\n\n self.env['import.wizard.result'].create(results)\n\n return {\n 'type': 'ir.actions.act_window',\n 'res_model': self._name,\n 'res_id': self.id,\n 'view_mode': 'form',\n 'target': 'new',\n }\n\n def _process_row(self, row):\n \"\"\"Process single row. Override in subclass.\"\"\"\n raise NotImplementedError()\n\n\nclass ImportWizardResult(models.TransientModel):\n _name = 'import.wizard.result'\n _description = 'Import Result'\n\n wizard_id = fields.Many2one('import.wizard')\n row_number = fields.Integer()\n status = fields.Selection([\n ('success', 'Success'),\n ('error', 'Error'),\n ('skipped', 'Skipped'),\n ])\n record_id = fields.Integer()\n message = fields.Char()\n```\n\n### Wizard View\n```xml\n\u003crecord id=\"import_wizard_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">import.wizard.form\u003c/field>\n \u003cfield name=\"model\">import.wizard\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform>\n \u003cgroup invisible=\"result_ids\">\n \u003cfield name=\"file\" filename=\"file_name\"/>\n \u003cfield name=\"file_name\" invisible=\"1\"/>\n \u003cfield name=\"import_type\"/>\n \u003c/group>\n \u003cgroup string=\"Results\" invisible=\"not result_ids\">\n \u003cfield name=\"result_ids\" nolabel=\"1\">\n \u003ctree>\n \u003cfield name=\"row_number\"/>\n \u003cfield name=\"status\" decoration-success=\"status == 'success'\"\n decoration-danger=\"status == 'error'\"/>\n \u003cfield name=\"message\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/group>\n \u003cfooter>\n \u003cbutton name=\"action_import\" string=\"Import\" type=\"object\"\n class=\"btn-primary\" invisible=\"result_ids\"/>\n \u003cbutton string=\"Close\" class=\"btn-secondary\" special=\"cancel\"/>\n \u003c/footer>\n \u003c/form>\n \u003c/field>\n\u003c/record>\n```\n\n---\n\n## External ID Handling\n\n### Create with External ID\n```python\ndef import_with_xmlid(self, model, xmlid, vals):\n \"\"\"Create/update record with external ID.\"\"\"\n # Check if record exists\n record = self.env.ref(xmlid, raise_if_not_found=False)\n\n if record:\n record.write(vals)\n else:\n # Create with external ID\n record = self.env[model].create(vals)\n\n # Create external ID\n module, name = xmlid.split('.')\n self.env['ir.model.data'].create({\n 'name': name,\n 'module': module,\n 'model': model,\n 'res_id': record.id,\n 'noupdate': False,\n })\n\n return record\n```\n\n### Lookup by External ID\n```python\ndef get_by_xmlid(self, xmlid, model=None):\n \"\"\"Get record by external ID.\"\"\"\n record = self.env.ref(xmlid, raise_if_not_found=False)\n if record and model:\n if record._name != model:\n return None\n return record\n\ndef get_xmlid(self, record):\n \"\"\"Get external ID for record.\"\"\"\n data = self.env['ir.model.data'].search([\n ('model', '=', record._name),\n ('res_id', '=', record.id),\n ], limit=1)\n return f'{data.module}.{data.name}' if data else None\n```\n\n---\n\n## Batch Processing\n\n### Import Large Files\n```python\ndef import_large_file(self, file_path, batch_size=1000):\n \"\"\"Import large file in batches.\"\"\"\n with open(file_path, 'r') as f:\n reader = csv.DictReader(f)\n batch = []\n total_created = 0\n\n for row in reader:\n batch.append(self._prepare_vals(row))\n\n if len(batch) >= batch_size:\n self.env['my.model'].create(batch)\n total_created += len(batch)\n batch = []\n self.env.cr.commit() # Commit batch\n\n # Process remaining\n if batch:\n self.env['my.model'].create(batch)\n total_created += len(batch)\n self.env.cr.commit()\n\n return total_created\n```\n\n### Background Import\n```python\ndef action_import_async(self):\n \"\"\"Queue import for background processing.\"\"\"\n self.ensure_one()\n\n # Create attachment for the file\n attachment = self.env['ir.attachment'].create({\n 'name': self.file_name,\n 'datas': self.file,\n 'res_model': self._name,\n 'res_id': self.id,\n })\n\n # Schedule cron job or use queue_job\n self.env['ir.cron'].create({\n 'name': f'Import: {self.file_name}',\n 'model_id': self.env.ref('my_module.model_import_wizard').id,\n 'state': 'code',\n 'code': f'model.browse({self.id})._process_import_async()',\n 'interval_number': 1,\n 'interval_type': 'minutes',\n 'numbercall': 1,\n 'doall': True,\n })\n\n return {'type': 'ir.actions.client', 'tag': 'reload'}\n```\n\n---\n\n## Export Reports\n\n### Excel Report with Multiple Sheets\n```python\ndef export_report_excel(self):\n \"\"\"Export multi-sheet Excel report.\"\"\"\n output = BytesIO()\n workbook = xlsxwriter.Workbook(output, {'in_memory': True})\n\n # Summary sheet\n summary = workbook.add_worksheet('Summary')\n summary.write(0, 0, 'Total Records')\n summary.write(0, 1, self.search_count([]))\n\n # Detail sheet\n detail = workbook.add_worksheet('Details')\n records = self.search([])\n\n # Headers\n headers = ['ID', 'Name', 'Date', 'Amount']\n for col, header in enumerate(headers):\n detail.write(0, col, header)\n\n # Data\n for row, rec in enumerate(records, start=1):\n detail.write(row, 0, rec.id)\n detail.write(row, 1, rec.name)\n detail.write(row, 2, str(rec.date) if rec.date else '')\n detail.write(row, 3, rec.amount)\n\n workbook.close()\n output.seek(0)\n\n # Create attachment\n attachment = self.env['ir.attachment'].create({\n 'name': 'report.xlsx',\n 'datas': base64.b64encode(output.read()),\n 'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n })\n\n return {\n 'type': 'ir.actions.act_url',\n 'url': f'/web/content/{attachment.id}?download=true',\n 'target': 'self',\n }\n```\n\n---\n\n## JSON Import/Export\n\n### Export to JSON\n```python\nimport json\n\n\ndef export_to_json(self, records, fields):\n \"\"\"Export records to JSON.\"\"\"\n data = []\n for record in records:\n row = {}\n for field in fields:\n value = record[field]\n if hasattr(value, 'id'): # Relational\n row[field] = {'id': value.id, 'name': value.display_name}\n elif hasattr(value, 'ids'): # X2many\n row[field] = [{'id': r.id, 'name': r.display_name} for r in value]\n elif isinstance(value, (date, datetime)):\n row[field] = value.isoformat()\n else:\n row[field] = value\n data.append(row)\n\n return json.dumps(data, indent=2, default=str)\n```\n\n### Import from JSON\n```python\ndef import_from_json(self, json_content):\n \"\"\"Import records from JSON.\"\"\"\n data = json.loads(json_content)\n\n created = []\n for row in data:\n # Process relational fields\n vals = {}\n for key, value in row.items():\n if isinstance(value, dict) and 'id' in value:\n vals[key] = value['id']\n elif isinstance(value, list) and value and isinstance(value[0], dict):\n vals[key] = [(6, 0, [v['id'] for v in value])]\n else:\n vals[key] = value\n\n record = self.create(vals)\n created.append(record.id)\n\n return created\n```\n\n---\n\n## Data Validation\n\n### Validate Import Data\n```python\ndef validate_import_row(self, row):\n \"\"\"Validate a single import row.\"\"\"\n errors = []\n\n # Required fields\n if not row.get('name'):\n errors.append('Name is required')\n\n # Email format\n if row.get('email'):\n import re\n if not re.match(r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+

Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…

, row['email']):\n errors.append(f\"Invalid email: {row['email']}\")\n\n # Reference lookup\n if row.get('country_code'):\n country = self.env['res.country'].search([\n ('code', '=', row['country_code'])\n ], limit=1)\n if not country:\n errors.append(f\"Unknown country: {row['country_code']}\")\n\n return errors\n```\n\n---\n\n## Best Practices\n\n1. **Validate first** - Check all rows before creating any\n2. **Use transactions** - Rollback on errors\n3. **Batch processing** - Commit in chunks for large imports\n4. **External IDs** - Use for data that may be re-imported\n5. **Error reporting** - Show row numbers and specific errors\n6. **Preview mode** - Let users review before committing\n7. **Template download** - Provide import template\n8. **Encoding** - Handle UTF-8 properly\n9. **Progress feedback** - Show import progress\n10. **Logging** - Log imports for audit trail\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15343,"content_sha256":"e7f0a92a4a6152ae97b7dcd5e1a5db7eca46059932fc2494095d7d6e76d2361e"},{"filename":"skills/inheritance-patterns.md","content":"# Model and View Inheritance Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ INHERITANCE PATTERNS ║\n║ Extending models, views, and controllers without modifying core code ║\n║ Use for customizations, extensions, and module integrations ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Inheritance Types Overview\n\n| Type | `_name` | `_inherit` | Use Case |\n|------|---------|-----------|----------|\n| Extension | None | `'model'` | Add fields/methods to existing model |\n| Delegation | `'new'` | `'model'` | Link new model to existing |\n| Prototype | `'new'` | `['model']` | Copy structure from existing |\n\n---\n\n## Model Extension (Most Common)\n\n### Add Fields to Existing Model\n```python\nfrom odoo import api, fields, models\n\n\nclass ResPartner(models.Model):\n _inherit = 'res.partner'\n\n # New fields\n x_loyalty_points = fields.Integer(\n string='Loyalty Points',\n default=0,\n )\n x_customer_tier = fields.Selection(\n selection=[\n ('bronze', 'Bronze'),\n ('silver', 'Silver'),\n ('gold', 'Gold'),\n ],\n string='Customer Tier',\n compute='_compute_customer_tier',\n store=True,\n )\n x_account_manager_id = fields.Many2one(\n comodel_name='res.users',\n string='Account Manager',\n )\n\n @api.depends('x_loyalty_points')\n def _compute_customer_tier(self):\n for partner in self:\n if partner.x_loyalty_points >= 1000:\n partner.x_customer_tier = 'gold'\n elif partner.x_loyalty_points >= 500:\n partner.x_customer_tier = 'silver'\n else:\n partner.x_customer_tier = 'bronze'\n```\n\n### Override Methods\n```python\nclass SaleOrder(models.Model):\n _inherit = 'sale.order'\n\n def action_confirm(self):\n \"\"\"Override confirm to add custom logic.\"\"\"\n # Pre-processing\n for order in self:\n order._check_credit_limit()\n\n # Call original method\n result = super().action_confirm()\n\n # Post-processing\n for order in self:\n order._send_confirmation_notification()\n\n return result\n\n def _check_credit_limit(self):\n \"\"\"Custom credit check before confirmation.\"\"\"\n if self.partner_id.credit_limit and \\\n self.amount_total > self.partner_id.credit_limit:\n raise UserError(\"Order exceeds credit limit.\")\n\n def _send_confirmation_notification(self):\n \"\"\"Send notification after confirmation.\"\"\"\n template = self.env.ref('my_module.email_template_order_confirm')\n template.send_mail(self.id)\n```\n\n### Extend Computed Fields\n```python\nclass SaleOrderLine(models.Model):\n _inherit = 'sale.order.line'\n\n @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id')\n def _compute_amount(self):\n \"\"\"Extend to add loyalty discount.\"\"\"\n super()._compute_amount()\n\n for line in self:\n if line.order_id.partner_id.x_customer_tier == 'gold':\n # Apply additional 5% discount for gold customers\n line.price_subtotal *= 0.95\n```\n\n### Add to Selection Field\n```python\nclass SaleOrder(models.Model):\n _inherit = 'sale.order'\n\n # Extend existing selection\n state = fields.Selection(\n selection_add=[\n ('pending_approval', 'Pending Approval'),\n ('approved', 'Approved'),\n ],\n ondelete={\n 'pending_approval': 'set default',\n 'approved': 'set default',\n },\n )\n```\n\n---\n\n## Delegation Inheritance\n\n### Link to Existing Model\n```python\nclass Employee(models.Model):\n _name = 'hr.employee'\n _inherits = {'res.partner': 'partner_id'}\n\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Related Partner',\n required=True,\n ondelete='cascade',\n )\n\n # Employee-specific fields\n department_id = fields.Many2one('hr.department')\n job_id = fields.Many2one('hr.job')\n\n # Inherited fields from res.partner are accessible directly\n # employee.name -> partner.name\n # employee.email -> partner.email\n```\n\n### Custom Delegation\n```python\nclass ProductVariant(models.Model):\n _name = 'product.product'\n _inherits = {'product.template': 'product_tmpl_id'}\n\n product_tmpl_id = fields.Many2one(\n comodel_name='product.template',\n string='Product Template',\n required=True,\n ondelete='cascade',\n )\n\n # Variant-specific fields\n barcode = fields.Char(string='Barcode')\n default_code = fields.Char(string='Internal Reference')\n```\n\n---\n\n## Abstract Models (Mixins)\n\n### Create Reusable Mixin\n```python\nclass TimestampMixin(models.AbstractModel):\n _name = 'timestamp.mixin'\n _description = 'Timestamp Mixin'\n\n created_at = fields.Datetime(\n string='Created At',\n default=fields.Datetime.now,\n readonly=True,\n )\n updated_at = fields.Datetime(\n string='Updated At',\n readonly=True,\n )\n\n def write(self, vals):\n vals['updated_at'] = fields.Datetime.now()\n return super().write(vals)\n\n\nclass ApprovalMixin(models.AbstractModel):\n _name = 'approval.mixin'\n _description = 'Approval Mixin'\n\n approval_state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('pending', 'Pending Approval'),\n ('approved', 'Approved'),\n ('rejected', 'Rejected'),\n ],\n string='Approval Status',\n default='draft',\n tracking=True,\n )\n approved_by = fields.Many2one(\n comodel_name='res.users',\n string='Approved By',\n readonly=True,\n )\n approved_date = fields.Datetime(\n string='Approval Date',\n readonly=True,\n )\n\n def action_submit_for_approval(self):\n self.write({'approval_state': 'pending'})\n\n def action_approve(self):\n self.write({\n 'approval_state': 'approved',\n 'approved_by': self.env.uid,\n 'approved_date': fields.Datetime.now(),\n })\n\n def action_reject(self):\n self.write({'approval_state': 'rejected'})\n```\n\n### Use Mixins\n```python\nclass PurchaseRequest(models.Model):\n _name = 'purchase.request'\n _description = 'Purchase Request'\n _inherit = ['mail.thread', 'timestamp.mixin', 'approval.mixin']\n\n name = fields.Char(string='Reference', required=True)\n amount = fields.Monetary(string='Amount')\n # Gets all fields and methods from mixins\n```\n\n---\n\n## View Inheritance\n\n### Extend Form View\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"view_partner_form_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.partner.form.inherit.my_module\u003c/field>\n \u003cfield name=\"model\">res.partner\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.view_partner_form\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003c!-- Add after existing field -->\n \u003cfield name=\"email\" position=\"after\">\n \u003cfield name=\"x_loyalty_points\"/>\n \u003cfield name=\"x_customer_tier\"/>\n \u003c/field>\n\n \u003c!-- Add new page to notebook -->\n \u003cxpath expr=\"//notebook\" position=\"inside\">\n \u003cpage string=\"Loyalty\" name=\"loyalty\">\n \u003cgroup>\n \u003cfield name=\"x_loyalty_points\"/>\n \u003cfield name=\"x_customer_tier\"/>\n \u003cfield name=\"x_account_manager_id\"/>\n \u003c/group>\n \u003c/page>\n \u003c/xpath>\n\n \u003c!-- Replace existing element -->\n \u003cfield name=\"title\" position=\"replace\">\n \u003cfield name=\"title\" placeholder=\"Select title...\"/>\n \u003c/field>\n\n \u003c!-- Add attributes -->\n \u003cfield name=\"phone\" position=\"attributes\">\n \u003cattribute name=\"required\">1\u003c/attribute>\n \u003c/field>\n\n \u003c!-- Hide field (v17+) -->\n \u003cfield name=\"fax\" position=\"attributes\">\n \u003cattribute name=\"invisible\">1\u003c/attribute>\n \u003c/field>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### XPath Expressions\n\n```xml\n\u003c!-- By field name -->\n\u003cfield name=\"partner_id\" position=\"after\">\n\n\u003c!-- By xpath -->\n\u003cxpath expr=\"//field[@name='partner_id']\" position=\"after\">\n\n\u003c!-- First field in group -->\n\u003cxpath expr=\"//group[1]/field[1]\" position=\"before\">\n\n\u003c!-- Field inside specific group -->\n\u003cxpath expr=\"//group[@name='sale_info']/field[@name='date_order']\" position=\"after\">\n\n\u003c!-- Page by name -->\n\u003cxpath expr=\"//page[@name='other_info']\" position=\"inside\">\n\n\u003c!-- Button by name -->\n\u003cxpath expr=\"//button[@name='action_confirm']\" position=\"before\">\n\n\u003c!-- Div by class -->\n\u003cxpath expr=\"//div[hasclass('oe_title')]\" position=\"inside\">\n\n\u003c!-- Last element -->\n\u003cxpath expr=\"//sheet/*[last()]\" position=\"after\">\n```\n\n### Position Types\n| Position | Effect |\n|----------|--------|\n| `inside` | Add as child (at end) |\n| `after` | Add as sibling after |\n| `before` | Add as sibling before |\n| `replace` | Replace entire element |\n| `attributes` | Modify attributes |\n\n### Extend Tree View\n```xml\n\u003crecord id=\"view_order_tree_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.order.tree.inherit\u003c/field>\n \u003cfield name=\"model\">sale.order\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"sale.view_order_tree\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cfield name=\"amount_total\" position=\"after\">\n \u003cfield name=\"x_margin\" optional=\"show\"/>\n \u003cfield name=\"x_priority\" decoration-danger=\"x_priority == 'high'\"/>\n \u003c/field>\n \u003c/field>\n\u003c/record>\n```\n\n### Extend Search View\n```xml\n\u003crecord id=\"view_order_search_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">sale.order.search.inherit\u003c/field>\n \u003cfield name=\"model\">sale.order\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"sale.view_sales_order_filter\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cfilter name=\"my_quotations\" position=\"after\">\n \u003cfilter string=\"High Priority\" name=\"high_priority\"\n domain=\"[('x_priority', '=', 'high')]\"/>\n \u003c/filter>\n\n \u003cxpath expr=\"//group\" position=\"inside\">\n \u003cfilter string=\"Priority\" name=\"group_priority\"\n context=\"{'group_by': 'x_priority'}\"/>\n \u003c/xpath>\n \u003c/field>\n\u003c/record>\n```\n\n---\n\n## Controller Inheritance\n\n### Extend HTTP Controller\n```python\nfrom odoo import http\nfrom odoo.http import request\nfrom odoo.addons.website_sale.controllers.main import WebsiteSale\n\n\nclass WebsiteSaleExtend(WebsiteSale):\n\n @http.route()\n def cart(self, **post):\n \"\"\"Extend cart to add custom data.\"\"\"\n response = super().cart(**post)\n\n # Add custom values to qcontext\n if hasattr(response, 'qcontext'):\n response.qcontext['x_loyalty_points'] = \\\n request.env.user.partner_id.x_loyalty_points\n\n return response\n\n @http.route('/shop/cart/update_loyalty', type='json', auth='user')\n def update_loyalty(self, points_to_use):\n \"\"\"New endpoint for loyalty point redemption.\"\"\"\n order = request.website.sale_get_order()\n if order:\n order.x_loyalty_points_used = points_to_use\n return {'success': True}\n```\n\n---\n\n## Report Inheritance\n\n### Extend Report Template\n```xml\n\u003ctemplate id=\"report_invoice_document_inherit\"\n inherit_id=\"account.report_invoice_document\">\n \u003c!-- Add custom section -->\n \u003cxpath expr=\"//div[@id='informations']\" position=\"after\">\n \u003cdiv class=\"row mt-3\">\n \u003cdiv class=\"col-6\">\n \u003cstrong>Customer Tier:\u003c/strong>\n \u003cspan t-field=\"o.partner_id.x_customer_tier\"/>\n \u003c/div>\n \u003cdiv class=\"col-6\">\n \u003cstrong>Loyalty Points Earned:\u003c/strong>\n \u003cspan t-esc=\"int(o.amount_total / 10)\"/>\n \u003c/div>\n \u003c/div>\n \u003c/xpath>\n\n \u003c!-- Modify existing content -->\n \u003cxpath expr=\"//span[@t-field='o.name']\" position=\"attributes\">\n \u003cattribute name=\"class\">h2 text-primary\u003c/attribute>\n \u003c/xpath>\n\u003c/template>\n```\n\n---\n\n## Security Inheritance\n\n### Extend Access Rights\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n# Extend existing model access\naccess_partner_loyalty_user,res.partner.loyalty.user,base.model_res_partner,base.group_user,1,1,0,0\naccess_partner_loyalty_manager,res.partner.loyalty.manager,base.model_res_partner,my_module.group_loyalty_manager,1,1,1,1\n```\n\n### Add Record Rules\n```xml\n\u003crecord id=\"rule_partner_loyalty_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">Partner: Loyalty User See Own\u003c/field>\n \u003cfield name=\"model_id\" ref=\"base.model_res_partner\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('x_account_manager_id', '=', user.id),\n ('x_account_manager_id', '=', False)\n ]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('my_module.group_loyalty_user'))]\"/>\n\u003c/record>\n```\n\n---\n\n## Best Practices\n\n### 1. Use Proper Naming\n```python\n# Fields: x_ prefix for custom fields\nx_custom_field = fields.Char()\n\n# Views: include module name\ninherit_id=\"base.view_partner_form\"\nname=\"res.partner.form.inherit.my_module\"\n```\n\n### 2. Call Super Properly\n```python\n# Good - always call super\ndef action_confirm(self):\n result = super().action_confirm()\n self._custom_logic()\n return result\n\n# Bad - skipping super breaks inheritance chain\ndef action_confirm(self):\n self._custom_logic()\n # Missing super() call!\n```\n\n### 3. Use Specific XPath\n```xml\n\u003c!-- Good - specific path -->\n\u003cxpath expr=\"//field[@name='partner_id']\" position=\"after\">\n\n\u003c!-- Bad - fragile, may break -->\n\u003cxpath expr=\"//field[3]\" position=\"after\">\n```\n\n### 4. Handle Dependencies\n```python\n# Manifest\n{\n 'depends': ['sale', 'account'], # Declare all inherited modules\n}\n```\n\n### 5. Preserve Original Behavior\n```python\n# Good - extend, don't replace\ndef _compute_amount(self):\n super()._compute_amount()\n # Add to computed value\n for line in self:\n line.price_subtotal += line.x_extra_fee\n\n# Bad - completely replaces original\ndef _compute_amount(self):\n for line in self:\n line.price_subtotal = line.quantity * line.price_unit\n```\n\n### 6. Never Use 'string' Attribute as Selector\n```xml\n\u003c!-- Good - use 'name' attribute (stable identifier) -->\n\u003cxpath expr=\"//page[@name='other_info']\" position=\"inside\">\n\u003cxpath expr=\"//field[@name='partner_id']\" position=\"after\">\n\u003cxpath expr=\"//button[@name='action_confirm']\" position=\"before\">\n\n\u003c!-- Bad - 'string' attribute is translated and may change -->\n\u003cxpath expr=\"//page[@string='Other Information']\" position=\"inside\">\n\u003cxpath expr=\"//button[@string='Confirm']\" position=\"before\">\n```\n\nThe `string` attribute should not be used as a selector in view inheritance because:\n- It contains translatable text that varies by language\n- Labels may be changed in future Odoo versions\n- Other modules may override the same string differently\n\nAlways prefer `name` attributes which are stable technical identifiers.\n\n### 7. Always Verify XML IDs and Views Before Extension\n\n**⚠️ CRITICAL RULE:** Do not trust your memory or make assumptions about XML IDs, views, records, or any other Odoo identifiers. Your memory is flawed by design. Always use the Odoo indexer or search tools to look them up. **Always read the actual Odoo source code** when in doubt.\n\n```python\n# Bad - trusting memory or assumptions about XML IDs\nview_id = self.env.ref('base.view_partner_form') # May not exist!\n\n# Good - verify existence before using\ntry:\n view_id = self.env.ref('base.view_partner_form', raise_if_not_found=False)\n if not view_id:\n raise ValueError(\"View not found\")\nexcept ValueError:\n # Handle missing view\n pass\n```\n\n```xml\n\u003c!-- Bad - assuming XML ID exists without verification -->\n\u003crecord id=\"view_partner_form_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.partner.form.inherit.my_module\u003c/field>\n \u003cfield name=\"model\">res.partner\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.view_partner_form\"/>\n \u003c!-- This will fail if base.view_partner_form doesn't exist -->\n\u003c/record>\n\n\u003c!-- Good - verify XML ID exists first using Odoo code/indexer -->\n\u003c!-- Before writing this inheritance, verify the XML ID exists:\n 1. Use grep/search to find the view definition in Odoo source\n 2. Check ir.ui.view records in database\n 3. Use Odoo indexer/IDE tools to look up the XML ID\n 4. READ THE ACTUAL ODOO SOURCE CODE - never rely on memory\n-->\n\u003crecord id=\"view_partner_form_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.partner.form.inherit.my_module\u003c/field>\n \u003cfield name=\"model\">res.partner\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.view_partner_form\"/>\n \u003c!-- Verified that base.view_partner_form exists by reading Odoo source -->\n\u003c/record>\n```\n\nWhy this matters:\n- XML IDs can change between Odoo versions\n- Views may be renamed, removed, or restructured\n- Some models may only have certain view types (e.g., only kanban, no form/tree)\n- Not all standard models have all view types defined\n- Runtime errors occur when referencing non-existent XML IDs\n- Memory of Odoo's structure is unreliable and prone to errors\n\n**Real-world example:**\nIn Odoo 19, the `res.users.api.keys` model only has a kanban view - no form or tree view exists. Attempting to extend a non-existent form view will cause runtime errors. Always verify what views actually exist before attempting to extend them.\n\n```python\n# Example: Check what views exist for a model\ndef check_available_views(self, model_name):\n \"\"\"Check what view types exist for a model.\"\"\"\n views = self.env['ir.ui.view'].search([\n ('model', '=', model_name),\n ('type', '!=', False)\n ])\n return {view.type for view in views}\n\n# Before extending: check if the view type exists\n# available_views = check_available_views('res.users.api.keys')\n# Result might be: {'kanban'} # Only kanban, no form or tree!\n```\n\n**Always verify XML IDs and view existence before use:**\n1. **READ THE ACTUAL ODOO SOURCE CODE** - this is the primary method, never rely on memory\n2. Use grep/ripgrep to search Odoo source code for the exact XML ID\n3. Check the database `ir.model.data` table for the record\n4. Use Odoo indexer or IDE integration tools to look up identifiers\n5. Verify which view types exist for a model before extending them\n6. Check the specific Odoo version's codebase (views differ between versions)\n7. Never assume an XML ID exists - always look it up using the tools above\n\n```python\n# Example: Verifying a view exists before inheritance\ndef _check_view_exists(self, xml_id):\n \"\"\"Check if XML ID exists before using it.\"\"\"\n try:\n return self.env.ref(xml_id, raise_if_not_found=False)\n except ValueError:\n return False\n\n# Check if specific view type exists for a model\ndef _check_view_type_exists(self, model_name, view_type):\n \"\"\"Check if a specific view type exists for a model.\"\"\"\n return self.env['ir.ui.view'].search([\n ('model', '=', model_name),\n ('type', '=', view_type)\n ], limit=1)\n\n# In data files, you can use noupdate=\"1\" with error handling\n# Or use module dependencies to ensure base modules are loaded\n```\n\n**Workflow when extending views:**\n1. Identify the model you want to extend\n2. Search Odoo source code to find available views for that model\n3. Verify the view type exists (form, tree, kanban, etc.)\n4. Look up the exact XML ID using search/indexer\n5. Read the actual view structure to understand the elements\n6. Test your XPath expressions against the actual view structure\n7. Only then write your view inheritance\n8. Never assume or guess - always verify\n\n### 8. Always Test XPath Expressions Before Use\n\n**⚠️ CRITICAL RULE:** After looking up the XML ID and before writing view inheritance, **read the actual view structure** and verify that your XPath expressions will match the correct elements. XPath errors are a common source of view inheritance failures.\n\n```xml\n\u003c!-- Bad - assuming structure without verification -->\n\u003crecord id=\"view_apikeys_kanban_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.users.apikeys.kanban.inherit\u003c/field>\n \u003cfield name=\"model\">res.users.apikeys\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.res_users_apikeys_view_kanban\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003c!-- Wrong XPath - this doesn't match actual structure! -->\n \u003cxpath expr=\"//div[hasclass('flex-row')]\" position=\"inside\">\n \u003cspan>My content\u003c/span>\n \u003c/xpath>\n \u003c/field>\n\u003c/record>\n```\n\n**Correct workflow:**\n1. **Read the actual view** to understand its structure\n2. **Verify the XPath** matches the actual elements\n3. Write the correct inheritance\n\n```xml\n\u003c!-- Good - verified XPath matches actual structure -->\n\u003crecord id=\"view_apikeys_kanban_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.users.apikeys.kanban.inherit\u003c/field>\n \u003cfield name=\"model\">res.users.apikeys\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.res_users_apikeys_view_kanban\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003c!-- Correct XPath after reading the view structure:\n The view has \u003ct t-name=\"card\"> not a div with flex-row class -->\n \u003cxpath expr=\"//t[@t-name='card']/div/div\" position=\"inside\">\n \u003cspan>My content\u003c/span>\n \u003c/xpath>\n \u003c/field>\n\u003c/record>\n```\n\n**Real-world example from Odoo 19:**\nWhen extending `res.users.apikeys` kanban view, developers might assume there's a `div` with `flex-row` class. However, reading the actual view reveals it uses `\u003ct t-name=\"card\">` structure instead. Using the wrong XPath will cause runtime errors.\n\n**How to test XPath expressions:**\n1. Read the source XML view file from Odoo codebase\n2. Identify the exact element structure and attributes\n3. Write your XPath to match the actual structure\n4. Test by upgrading your module and checking for errors\n5. If errors occur, re-read the view and adjust XPath\n\n```python\n# Example: Read a view to understand its structure\ndef read_view_structure(self, xml_id):\n \"\"\"Read view arch to understand structure before inheritance.\"\"\"\n view = self.env.ref(xml_id, raise_if_not_found=False)\n if view:\n # Print or log the arch to see actual structure\n print(view.arch)\n return view.arch\n return None\n\n# Before writing inheritance:\n# read_view_structure('base.res_users_apikeys_view_kanban')\n# Examine output to understand structure and write correct XPath\n```\n\n**Common XPath mistakes:**\n- Assuming class names that don't exist\n- Using wrong element names (div vs t vs field)\n- Not checking for QWeb template structures (t-name, t-if, etc.)\n- Copying XPath from different Odoo versions\n- Not accounting for nested structures\n\n**Best practices for XPath:**\n1. Always read the actual view first\n2. Use specific, precise selectors (element name + attributes)\n3. Prefer `name` attributes over classes or string values\n4. Test XPath against the actual structure\n5. Document why you chose that specific XPath\n6. Never copy-paste XPath without verification\n\n### 8. Always Test XPath Expressions Before Use\n\nWhen inheriting views, incorrect XPath expressions are a common source of errors. Always verify your XPath expressions match the actual view structure by reading the base view first.\n\n```xml\n\u003c!-- Bad - guessing the XPath without reading the view -->\n\u003crecord id=\"view_apikeys_kanban_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.users.apikeys.kanban.inherit\u003c/field>\n \u003cfield name=\"model\">res.users.apikeys\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.res_users_apikeys_view_kanban\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003c!-- This XPath is incorrect - assuming structure without verification -->\n \u003cxpath expr=\"//div[hasclass('flex-row')]\" position=\"inside\">\n \u003cspan t-if=\"record.is_readonly.raw_value\"\n class=\"badge text-bg-warning ms-2\">\n Read-Only\n \u003c/span>\n \u003c/xpath>\n \u003c/field>\n\u003c/record>\n\n\u003c!-- Good - verified XPath by reading the actual view structure -->\n\u003crecord id=\"view_apikeys_kanban_inherit\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">res.users.apikeys.kanban.inherit\u003c/field>\n \u003cfield name=\"model\">res.users.apikeys\u003c/field>\n \u003cfield name=\"inherit_id\" ref=\"base.res_users_apikeys_view_kanban\"/>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003c!-- Correct XPath after reading the view and finding \u003ct t-name=\"card\"> -->\n \u003cxpath expr=\"//t[@t-name='card']/div/div\" position=\"inside\">\n \u003cspan t-if=\"record.is_readonly.raw_value\"\n class=\"badge text-bg-warning ms-2\">\n Read-Only\n \u003c/span>\n \u003c/xpath>\n \u003c/field>\n\u003c/record>\n```\n\n**Best Practice Workflow for XPath Expressions:**\n\n1. **Read the base view first** - Never write XPath expressions from memory\n2. **Identify the exact element** - Find the precise structure in the view\n3. **Write the XPath expression** - Use the actual element names and attributes\n4. **Test the inheritance** - Verify the view renders correctly\n5. **Debug if needed** - If it fails, re-read the view and correct the XPath\n\n**Common XPath mistakes:**\n- Using `hasclass()` with incorrect class names\n- Assuming div structure without checking actual elements (could be `\u003ct>`, `\u003cspan>`, etc.)\n- Not accounting for QWeb-specific elements like `\u003ct t-name=\"...\">`\n- Guessing element hierarchy instead of reading the actual view\n\n**Real-world example from Odoo 19:**\n\nThe `res.users.apikeys` kanban view uses `\u003ct t-name=\"card\">` for the card template, not a simple `\u003cdiv>`. An incorrect XPath like `//div[hasclass('flex-row')]` will fail, while the correct XPath `//t[@t-name='card']/div/div` works because it matches the actual structure.\n\n**How to verify XPath expressions:**\n```python\n# Read the view to check structure\nview = self.env.ref('base.res_users_apikeys_view_kanban')\nprint(view.arch) # Examine the XML structure\n\n# Or use grep/search in Odoo source code\n# grep -r \"res_users_apikeys_view_kanban\" odoo/addons/base/\n```\n\nAlways read first, then write. Never trust your memory about view structures - they change between Odoo versions and even within the same version as views are refactored.\n\n---\n\n## Common Inheritance Patterns\n\n### Add Workflow State\n```python\nclass SaleOrder(models.Model):\n _inherit = 'sale.order'\n\n state = fields.Selection(\n selection_add=[\n ('waiting_approval', 'Waiting Approval'),\n ],\n ondelete={'waiting_approval': 'set default'},\n )\n\n def action_submit_approval(self):\n self.write({'state': 'waiting_approval'})\n\n def action_approve(self):\n self.action_confirm()\n```\n\n### Add Smart Button\n```xml\n\u003cxpath expr=\"//div[@name='button_box']\" position=\"inside\">\n \u003cbutton class=\"oe_stat_button\" type=\"object\"\n name=\"action_view_loyalty_history\"\n icon=\"fa-star\">\n \u003cfield string=\"Points\" name=\"x_loyalty_points\"\n widget=\"statinfo\"/>\n \u003c/button>\n\u003c/xpath>\n```\n\n### Conditional Field Display\n```xml\n\u003c!-- v17+ syntax -->\n\u003cfield name=\"x_approval_notes\"\n invisible=\"state not in ['waiting_approval', 'approved']\"/>\n\n\u003c!-- Pre-v17 syntax -->\n\u003cfield name=\"x_approval_notes\"\n attrs=\"{'invisible': [('state', 'not in', ['waiting_approval', 'approved'])]}\"/>\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":27683,"content_sha256":"d451ef42ee3acc6e9a28c165193fa7b00c189bb3094ec0fe58dd5d43d08d8e7f"},{"filename":"skills/logging-debugging-patterns.md","content":"# Logging and Debugging Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ LOGGING & DEBUGGING PATTERNS ║\n║ Proper logging, error tracking, and debugging techniques ║\n║ Use for troubleshooting, monitoring, and audit trails ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Logging Setup\n\n### Module Logger\n```python\nimport logging\n\n_logger = logging.getLogger(__name__)\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def process_record(self):\n \"\"\"Process with proper logging.\"\"\"\n _logger.info(\"Processing record %s\", self.id)\n\n try:\n result = self._do_work()\n _logger.debug(\"Work completed with result: %s\", result)\n return result\n\n except ValueError as e:\n _logger.warning(\"Invalid value for record %s: %s\", self.id, e)\n raise\n\n except Exception as e:\n _logger.error(\"Failed to process record %s: %s\", self.id, e)\n _logger.exception(\"Full traceback:\")\n raise\n```\n\n### Log Levels\n| Level | Use Case |\n|-------|----------|\n| `DEBUG` | Detailed diagnostic info (disabled in production) |\n| `INFO` | Normal operation events |\n| `WARNING` | Something unexpected but not breaking |\n| `ERROR` | Error that affects the operation |\n| `CRITICAL` | System-level failure |\n\n### Logging Best Practices\n```python\n# Good - Use lazy formatting\n_logger.info(\"Processing order %s for customer %s\", order.id, customer.name)\n\n# Bad - Eager string formatting (computed even if not logged)\n_logger.info(f\"Processing order {order.id} for customer {customer.name}\")\n\n# Good - Log exceptions with traceback\ntry:\n risky_operation()\nexcept Exception as e:\n _logger.exception(\"Operation failed: %s\", e)\n\n# Bad - Lose traceback information\ntry:\n risky_operation()\nexcept Exception as e:\n _logger.error(\"Operation failed: %s\", e)\n\n# Good - Structured context\n_logger.info(\n \"Order %s: state changed from %s to %s\",\n self.name, old_state, new_state\n)\n\n# Good - Performance-sensitive debug\nif _logger.isEnabledFor(logging.DEBUG):\n _logger.debug(\"Full data: %s\", expensive_data_format())\n```\n\n---\n\n## Audit Logging\n\n### Audit Trail Model\n```python\nclass AuditLog(models.Model):\n _name = 'audit.log'\n _description = 'Audit Log'\n _order = 'create_date desc'\n\n model_name = fields.Char(string='Model', required=True, index=True)\n record_id = fields.Integer(string='Record ID', index=True)\n action = fields.Selection([\n ('create', 'Create'),\n ('write', 'Update'),\n ('unlink', 'Delete'),\n ('action', 'Action'),\n ], string='Action', required=True)\n user_id = fields.Many2one(\n 'res.users', string='User',\n default=lambda self: self.env.user,\n )\n timestamp = fields.Datetime(\n string='Timestamp',\n default=fields.Datetime.now,\n )\n old_values = fields.Text(string='Old Values')\n new_values = fields.Text(string='New Values')\n ip_address = fields.Char(string='IP Address')\n description = fields.Text(string='Description')\n\n\nclass AuditMixin(models.AbstractModel):\n _name = 'audit.mixin'\n _description = 'Audit Mixin'\n\n def write(self, vals):\n \"\"\"Log changes with audit trail.\"\"\"\n for record in self:\n old_values = record._get_audit_values(vals.keys())\n result = super(AuditMixin, record).write(vals)\n new_values = record._get_audit_values(vals.keys())\n record._create_audit_log('write', old_values, new_values)\n return result\n\n @api.model_create_multi\n def create(self, vals_list):\n \"\"\"Log creation with audit trail.\"\"\"\n records = super().create(vals_list)\n for record in records:\n record._create_audit_log('create', {}, record._get_audit_values())\n return records\n\n def unlink(self):\n \"\"\"Log deletion with audit trail.\"\"\"\n for record in self:\n record._create_audit_log('unlink', record._get_audit_values(), {})\n return super().unlink()\n\n def _get_audit_values(self, field_names=None):\n \"\"\"Get values for audit logging.\"\"\"\n self.ensure_one()\n if field_names is None:\n field_names = self._get_audit_fields()\n return {\n field: getattr(self, field)\n for field in field_names\n if hasattr(self, field)\n }\n\n def _get_audit_fields(self):\n \"\"\"Override to specify fields to audit.\"\"\"\n return ['name', 'state']\n\n def _create_audit_log(self, action, old_values, new_values):\n \"\"\"Create audit log entry.\"\"\"\n self.env['audit.log'].sudo().create({\n 'model_name': self._name,\n 'record_id': self.id,\n 'action': action,\n 'old_values': json.dumps(old_values, default=str),\n 'new_values': json.dumps(new_values, default=str),\n 'ip_address': self._get_client_ip(),\n })\n\n def _get_client_ip(self):\n \"\"\"Get client IP from request.\"\"\"\n try:\n from odoo.http import request\n if request:\n return request.httprequest.remote_addr\n except Exception:\n pass\n return None\n```\n\n---\n\n## Performance Logging\n\n### Query Profiling\n```python\nimport time\n\n\nclass PerformanceLogger:\n \"\"\"Context manager for performance logging.\"\"\"\n\n def __init__(self, operation_name, logger=None):\n self.operation_name = operation_name\n self.logger = logger or _logger\n self.start_time = None\n\n def __enter__(self):\n self.start_time = time.time()\n return self\n\n def __exit__(self, exc_type, exc_val, exc_tb):\n duration = time.time() - self.start_time\n if duration > 1.0: # Log slow operations\n self.logger.warning(\n \"Slow operation: %s took %.2fs\",\n self.operation_name, duration\n )\n else:\n self.logger.debug(\n \"Operation %s completed in %.3fs\",\n self.operation_name, duration\n )\n\n\n# Usage\ndef compute_report(self):\n with PerformanceLogger(\"compute_report\"):\n # Heavy computation\n pass\n```\n\n### SQL Query Logging\n```python\ndef _log_query_count(self, operation_name):\n \"\"\"Log number of SQL queries in operation.\"\"\"\n cr = self.env.cr\n\n initial_count = cr.sql_log_count if hasattr(cr, 'sql_log_count') else 0\n\n yield\n\n final_count = cr.sql_log_count if hasattr(cr, 'sql_log_count') else 0\n query_count = final_count - initial_count\n\n if query_count > 100:\n _logger.warning(\n \"High query count in %s: %d queries\",\n operation_name, query_count\n )\n```\n\n### Memory Profiling (Development)\n```python\nimport tracemalloc\n\n\ndef profile_memory(func):\n \"\"\"Decorator for memory profiling.\"\"\"\n @wraps(func)\n def wrapper(*args, **kwargs):\n tracemalloc.start()\n try:\n result = func(*args, **kwargs)\n current, peak = tracemalloc.get_traced_memory()\n _logger.info(\n \"%s memory: current=%.1fMB, peak=%.1fMB\",\n func.__name__,\n current / 1024 / 1024,\n peak / 1024 / 1024\n )\n return result\n finally:\n tracemalloc.stop()\n return wrapper\n```\n\n---\n\n## Error Tracking\n\n### Error Log Model\n```python\nclass ErrorLog(models.Model):\n _name = 'error.log'\n _description = 'Error Log'\n _order = 'create_date desc'\n\n name = fields.Char(string='Error', required=True)\n model_name = fields.Char(string='Model')\n record_id = fields.Integer(string='Record ID')\n method_name = fields.Char(string='Method')\n error_type = fields.Char(string='Error Type')\n error_message = fields.Text(string='Error Message')\n traceback = fields.Text(string='Traceback')\n user_id = fields.Many2one('res.users', string='User')\n resolved = fields.Boolean(string='Resolved', default=False)\n occurrence_count = fields.Integer(string='Occurrences', default=1)\n\n @api.model\n def log_error(self, error, model_name=None, record_id=None, method_name=None):\n \"\"\"Log error with deduplication.\"\"\"\n import traceback as tb\n\n error_type = type(error).__name__\n error_message = str(error)\n traceback_str = tb.format_exc()\n\n # Check for existing similar error\n existing = self.search([\n ('error_type', '=', error_type),\n ('error_message', '=', error_message),\n ('resolved', '=', False),\n ], limit=1)\n\n if existing:\n existing.occurrence_count += 1\n return existing\n\n return self.create({\n 'name': f\"{error_type}: {error_message[:100]}\",\n 'model_name': model_name,\n 'record_id': record_id,\n 'method_name': method_name,\n 'error_type': error_type,\n 'error_message': error_message,\n 'traceback': traceback_str,\n 'user_id': self.env.uid,\n })\n```\n\n### Error Handling Decorator\n```python\ndef log_exceptions(model_name=None, method_name=None):\n \"\"\"Decorator to log exceptions to error.log.\"\"\"\n def decorator(func):\n @wraps(func)\n def wrapper(self, *args, **kwargs):\n try:\n return func(self, *args, **kwargs)\n except Exception as e:\n _logger.exception(\"Error in %s: %s\", func.__name__, e)\n self.env['error.log'].sudo().log_error(\n error=e,\n model_name=model_name or self._name,\n record_id=self.id if hasattr(self, 'id') else None,\n method_name=method_name or func.__name__,\n )\n raise\n return wrapper\n return decorator\n\n\n# Usage\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @log_exceptions()\n def risky_operation(self):\n # Code that might fail\n pass\n```\n\n---\n\n## Debug Helpers\n\n### Debug Mode Check\n```python\nfrom odoo.tools import config\n\n\ndef is_debug_mode():\n \"\"\"Check if server is in debug mode.\"\"\"\n return config.get('dev_mode') or config.get('debug')\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def process(self):\n if is_debug_mode():\n _logger.setLevel(logging.DEBUG)\n _logger.debug(\"Debug mode enabled - verbose logging\")\n\n # Processing logic\n```\n\n### Temporary Debug Output\n```python\ndef debug_record(self):\n \"\"\"Debug helper to print record details.\"\"\"\n self.ensure_one()\n\n info = {\n 'id': self.id,\n 'name': self.name,\n 'state': self.state,\n 'create_date': str(self.create_date),\n 'write_date': str(self.write_date),\n }\n\n _logger.info(\"=== DEBUG RECORD ===\")\n for key, value in info.items():\n _logger.info(\" %s: %s\", key, value)\n _logger.info(\"====================\")\n```\n\n### SQL Debug\n```python\ndef debug_sql(self, query):\n \"\"\"Execute and log SQL query for debugging.\"\"\"\n _logger.info(\"Executing SQL: %s\", query)\n self.env.cr.execute(query)\n result = self.env.cr.fetchall()\n _logger.info(\"Result: %s rows\", len(result))\n return result\n```\n\n---\n\n## Request Logging\n\n### HTTP Request Logger\n```python\nfrom odoo import http\nfrom odoo.http import request\nimport time\n\n\nclass RequestLogger(http.Controller):\n\n @http.route('/api/endpoint', type='json', auth='user')\n def my_endpoint(self, **kwargs):\n start_time = time.time()\n request_id = self._generate_request_id()\n\n _logger.info(\n \"[%s] Request: user=%s, params=%s\",\n request_id, request.env.user.login, kwargs\n )\n\n try:\n result = self._process_request(**kwargs)\n\n duration = time.time() - start_time\n _logger.info(\n \"[%s] Response: success, duration=%.3fs\",\n request_id, duration\n )\n\n return result\n\n except Exception as e:\n duration = time.time() - start_time\n _logger.error(\n \"[%s] Response: error=%s, duration=%.3fs\",\n request_id, str(e), duration\n )\n raise\n\n def _generate_request_id(self):\n import uuid\n return str(uuid.uuid4())[:8]\n```\n\n---\n\n## Configuration\n\n### Odoo Logging Configuration\n```ini\n# odoo.conf\n[options]\nlog_level = info\nlog_handler = :INFO,odoo.addons.my_module:DEBUG\nlog_db = True\nlog_db_level = warning\nlogfile = /var/log/odoo/odoo.log\n```\n\n### Per-Module Log Level\n```python\n# At module init, set specific log level\nimport logging\n\n# Set module-specific log level\nlogging.getLogger('odoo.addons.my_module').setLevel(logging.DEBUG)\n\n# Or conditionally\nif config.get('my_module_debug'):\n logging.getLogger('odoo.addons.my_module').setLevel(logging.DEBUG)\n```\n\n---\n\n## Best Practices\n\n1. **Use module logger** - `_logger = logging.getLogger(__name__)`\n2. **Lazy formatting** - `_logger.info(\"x=%s\", x)` not `f\"x={x}\"`\n3. **Include context** - Log record IDs, user, operation name\n4. **Use appropriate levels** - DEBUG for dev, INFO for operations\n5. **Log exceptions** - Use `_logger.exception()` to include traceback\n6. **Don't log sensitive data** - Mask passwords, tokens, PII\n7. **Performance aware** - Check log level before expensive formatting\n8. **Structured logging** - Consistent format for parsing\n9. **Audit critical operations** - Financial, security, compliance\n10. **Clean up debug logs** - Remove temporary logging before commit\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14021,"content_sha256":"b789dbf98de12d6bfa33f317d22e2b006e4d8fde2cddb006c90a9e8879734975"},{"filename":"skills/lot-serial-patterns.md","content":"# Lot and Serial Number Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ LOT & SERIAL NUMBER PATTERNS ║\n║ Product traceability, batch tracking, and serial number management ║\n║ Use for inventory tracking, recalls, and compliance requirements ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Tracking Types\n\n| Tracking | Description | Use Case |\n|----------|-------------|----------|\n| `none` | No tracking | Basic products |\n| `lot` | Batch/Lot tracking | Multiple items per lot |\n| `serial` | Serial number | One item per serial |\n\n---\n\n## Product Tracking Configuration\n\n### Enable Tracking on Product\n```python\nclass ProductTemplate(models.Model):\n _inherit = 'product.template'\n\n tracking = fields.Selection([\n ('serial', 'By Unique Serial Number'),\n ('lot', 'By Lots'),\n ('none', 'No Tracking'),\n ], string='Tracking', default='none', required=True)\n\n# Create product with lot tracking\nproduct = self.env['product.template'].create({\n 'name': 'Pharmaceutical Product',\n 'type': 'product',\n 'tracking': 'lot', # Batch tracking\n})\n\n# Create product with serial tracking\nproduct_serial = self.env['product.template'].create({\n 'name': 'Electronic Device',\n 'type': 'product',\n 'tracking': 'serial', # Unique serial per unit\n})\n```\n\n---\n\n## Creating Lots and Serials\n\n### Create Lot/Serial\n```python\n# Create a lot (batch)\nlot = self.env['stock.lot'].create({\n 'name': 'LOT-2024-001',\n 'product_id': product.id,\n 'company_id': self.env.company.id,\n})\n\n# With expiration date\nlot_with_expiry = self.env['stock.lot'].create({\n 'name': 'LOT-2024-002',\n 'product_id': product.id,\n 'company_id': self.env.company.id,\n 'expiration_date': fields.Datetime.now() + timedelta(days=365),\n 'use_date': fields.Datetime.now() + timedelta(days=300),\n 'removal_date': fields.Datetime.now() + timedelta(days=350),\n 'alert_date': fields.Datetime.now() + timedelta(days=280),\n})\n```\n\n### Auto-Generate Serial Numbers\n```python\ndef generate_serial_numbers(self, product, quantity, prefix='SN'):\n \"\"\"Generate unique serial numbers.\"\"\"\n serials = []\n for i in range(int(quantity)):\n serial = self.env['stock.lot'].create({\n 'name': f\"{prefix}-{fields.Date.today().strftime('%Y%m%d')}-{i+1:04d}\",\n 'product_id': product.id,\n 'company_id': self.env.company.id,\n })\n serials.append(serial)\n return serials\n```\n\n---\n\n## Stock Moves with Lots\n\n### Create Move with Lot\n```python\ndef create_move_with_lot(self, product, lot, qty, location_src, location_dest):\n \"\"\"Create stock move with lot tracking.\"\"\"\n move = self.env['stock.move'].create({\n 'name': f'Move {product.name}',\n 'product_id': product.id,\n 'product_uom_qty': qty,\n 'product_uom': product.uom_id.id,\n 'location_id': location_src.id,\n 'location_dest_id': location_dest.id,\n })\n\n # Create move line with lot\n self.env['stock.move.line'].create({\n 'move_id': move.id,\n 'product_id': product.id,\n 'lot_id': lot.id,\n 'quantity': qty,\n 'product_uom_id': product.uom_id.id,\n 'location_id': location_src.id,\n 'location_dest_id': location_dest.id,\n })\n\n move._action_confirm()\n move._action_assign()\n move._action_done()\n\n return move\n```\n\n### Receive with New Lot\n```python\ndef receive_with_lot(self, picking, product, qty, lot_name):\n \"\"\"Receive products creating new lot.\"\"\"\n # Find or create lot\n lot = self.env['stock.lot'].search([\n ('name', '=', lot_name),\n ('product_id', '=', product.id),\n ]) or self.env['stock.lot'].create({\n 'name': lot_name,\n 'product_id': product.id,\n 'company_id': picking.company_id.id,\n })\n\n # Find the move for this product\n move = picking.move_ids.filtered(lambda m: m.product_id == product)\n\n # Set lot on move line\n move.move_line_ids.write({\n 'lot_id': lot.id,\n 'quantity': qty,\n })\n\n return lot\n```\n\n---\n\n## Querying Lots\n\n### Find Lots for Product\n```python\ndef get_product_lots(self, product):\n \"\"\"Get all lots for a product.\"\"\"\n return self.env['stock.lot'].search([\n ('product_id', '=', product.id),\n ])\n\ndef get_available_lots(self, product, location=None):\n \"\"\"Get lots with available stock.\"\"\"\n domain = [('product_id', '=', product.id)]\n\n lots = self.env['stock.lot'].search(domain)\n\n available_lots = []\n for lot in lots:\n # Get quantity for this lot\n quants = self.env['stock.quant'].search([\n ('lot_id', '=', lot.id),\n ('location_id.usage', '=', 'internal'),\n ])\n if location:\n quants = quants.filtered(lambda q: q.location_id == location)\n\n qty = sum(quants.mapped('quantity'))\n if qty > 0:\n available_lots.append({\n 'lot': lot,\n 'quantity': qty,\n 'expiration_date': lot.expiration_date,\n })\n\n return available_lots\n```\n\n### Get Stock by Lot\n```python\ndef get_lot_stock(self, lot):\n \"\"\"Get stock quantity for a specific lot.\"\"\"\n quants = self.env['stock.quant'].search([\n ('lot_id', '=', lot.id),\n ('location_id.usage', '=', 'internal'),\n ])\n return sum(quants.mapped('quantity'))\n```\n\n---\n\n## Expiration Date Tracking\n\n### Product Expiration Fields\n```python\nclass ProductTemplate(models.Model):\n _inherit = 'product.template'\n\n use_expiration_date = fields.Boolean(\n string='Expiration Date',\n help='Track expiration dates on lots/serials',\n )\n\n # Default durations (in days)\n expiration_time = fields.Integer(string='Expiration Time')\n use_time = fields.Integer(string='Best Before Time')\n removal_time = fields.Integer(string='Removal Time')\n alert_time = fields.Integer(string='Alert Time')\n```\n\n### Auto-Calculate Expiration Dates\n```python\nclass StockLot(models.Model):\n _inherit = 'stock.lot'\n\n @api.model_create_multi\n def create(self, vals_list):\n \"\"\"Auto-set expiration dates from product.\"\"\"\n for vals in vals_list:\n if 'product_id' in vals:\n product = self.env['product.product'].browse(vals['product_id'])\n if product.use_expiration_date:\n now = fields.Datetime.now()\n if not vals.get('expiration_date') and product.expiration_time:\n vals['expiration_date'] = now + timedelta(days=product.expiration_time)\n if not vals.get('use_date') and product.use_time:\n vals['use_date'] = now + timedelta(days=product.use_time)\n if not vals.get('removal_date') and product.removal_time:\n vals['removal_date'] = now + timedelta(days=product.removal_time)\n if not vals.get('alert_date') and product.alert_time:\n vals['alert_date'] = now + timedelta(days=product.alert_time)\n\n return super().create(vals_list)\n```\n\n### Check Expiring Lots\n```python\ndef get_expiring_lots(self, days=30):\n \"\"\"Find lots expiring within specified days.\"\"\"\n deadline = fields.Datetime.now() + timedelta(days=days)\n return self.env['stock.lot'].search([\n ('expiration_date', '!=', False),\n ('expiration_date', '\u003c=', deadline),\n ('expiration_date', '>', fields.Datetime.now()),\n ])\n\ndef get_expired_lots(self):\n \"\"\"Find expired lots with stock.\"\"\"\n expired_lots = self.env['stock.lot'].search([\n ('expiration_date', '\u003c', fields.Datetime.now()),\n ])\n\n # Filter to only those with stock\n return expired_lots.filtered(\n lambda l: sum(l.quant_ids.filtered(\n lambda q: q.location_id.usage == 'internal'\n ).mapped('quantity')) > 0\n )\n```\n\n---\n\n## FIFO/FEFO Removal Strategy\n\n### Configure Removal Strategy\n```python\n# On location or category\nlocation = self.env['stock.location'].browse(location_id)\nlocation.write({\n 'removal_strategy_id': self.env.ref('stock.removal_fifo').id,\n})\n\n# FEFO (First Expiry, First Out)\nlocation.write({\n 'removal_strategy_id': self.env.ref('stock.removal_fefo').id,\n})\n```\n\n### Manual Lot Selection\n```python\ndef select_lot_for_delivery(self, product, quantity, strategy='fifo'):\n \"\"\"Select lots for delivery based on strategy.\"\"\"\n lots = self.get_available_lots(product)\n\n if strategy == 'fefo':\n # Sort by expiration date (earliest first)\n lots = sorted(lots, key=lambda l: l['expiration_date'] or datetime.max)\n elif strategy == 'fifo':\n # Sort by lot creation date (oldest first)\n lots = sorted(lots, key=lambda l: l['lot'].create_date)\n\n selected = []\n remaining = quantity\n for lot_data in lots:\n if remaining \u003c= 0:\n break\n take = min(lot_data['quantity'], remaining)\n selected.append({\n 'lot_id': lot_data['lot'].id,\n 'quantity': take,\n })\n remaining -= take\n\n return selected\n```\n\n---\n\n## Serial Number Validation\n\n### Unique Serial Check\n```python\nclass StockLot(models.Model):\n _inherit = 'stock.lot'\n\n @api.constrains('name', 'product_id', 'company_id')\n def _check_unique_serial(self):\n \"\"\"Ensure serial numbers are unique.\"\"\"\n for lot in self:\n if lot.product_id.tracking == 'serial':\n duplicates = self.search([\n ('id', '!=', lot.id),\n ('name', '=', lot.name),\n ('product_id', '=', lot.product_id.id),\n ('company_id', '=', lot.company_id.id),\n ])\n if duplicates:\n raise ValidationError(\n f\"Serial number {lot.name} already exists for this product.\"\n )\n```\n\n### Serial Format Validation\n```python\nimport re\n\[email protected]('name')\ndef _check_serial_format(self):\n \"\"\"Validate serial number format.\"\"\"\n pattern = r'^[A-Z]{2}-\\d{4}-\\d{6}

Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…

# e.g., SN-2024-000001\n for lot in self:\n if lot.product_id.tracking == 'serial':\n if not re.match(pattern, lot.name):\n raise ValidationError(\n f\"Invalid serial format: {lot.name}. \"\n f\"Expected format: XX-YYYY-NNNNNN\"\n )\n```\n\n---\n\n## Traceability Reports\n\n### Get Lot History\n```python\ndef get_lot_traceability(self, lot):\n \"\"\"Get complete history of a lot.\"\"\"\n moves = self.env['stock.move.line'].search([\n ('lot_id', '=', lot.id),\n ('state', '=', 'done'),\n ])\n\n history = []\n for move in moves.sorted('date'):\n history.append({\n 'date': move.date,\n 'reference': move.reference,\n 'from_location': move.location_id.complete_name,\n 'to_location': move.location_dest_id.complete_name,\n 'quantity': move.quantity,\n 'picking': move.picking_id.name if move.picking_id else None,\n })\n\n return history\n```\n\n### Upstream/Downstream Traceability\n```python\ndef get_upstream_lots(self, lot):\n \"\"\"Find source lots (for manufacturing).\"\"\"\n # Find production orders that consumed this lot\n consumed = self.env['stock.move.line'].search([\n ('lot_id', '=', lot.id),\n ('location_dest_id.usage', '=', 'production'),\n ])\n\n # Find resulting products\n productions = consumed.mapped('move_id.raw_material_production_id')\n return productions.mapped('lot_producing_id')\n\ndef get_downstream_lots(self, lot):\n \"\"\"Find destination lots (for recalls).\"\"\"\n # Find where this lot was consumed in production\n consumed = self.env['stock.move.line'].search([\n ('lot_id', '=', lot.id),\n ('move_id.raw_material_production_id', '!=', False),\n ])\n\n return consumed.mapped('move_id.raw_material_production_id.lot_producing_id')\n```\n\n---\n\n## Custom Fields on Lots\n\n### Extend Lot Model\n```python\nclass StockLot(models.Model):\n _inherit = 'stock.lot'\n\n supplier_lot = fields.Char(string='Supplier Lot Number')\n production_date = fields.Date(string='Production Date')\n certificate_ids = fields.Many2many(\n 'ir.attachment',\n string='Quality Certificates',\n )\n notes = fields.Text(string='Notes')\n\n # Quality control\n qc_status = fields.Selection([\n ('pending', 'Pending QC'),\n ('passed', 'Passed'),\n ('failed', 'Failed'),\n ('quarantine', 'Quarantine'),\n ], string='QC Status', default='pending')\n```\n\n---\n\n## XML Data for Lots\n\n### Pre-define Lots\n```xml\n\u003crecord id=\"lot_sample_001\" model=\"stock.lot\">\n \u003cfield name=\"name\">SAMPLE-LOT-001\u003c/field>\n \u003cfield name=\"product_id\" ref=\"product_sample\"/>\n \u003cfield name=\"company_id\" ref=\"base.main_company\"/>\n\u003c/record>\n```\n\n---\n\n## Best Practices\n\n1. **Choose tracking wisely** - Serial for unique items, lot for batches\n2. **Use expiration dates** - For perishables and regulated products\n3. **Validate formats** - Consistent naming conventions\n4. **FEFO for perishables** - First expiry, first out\n5. **Track QC status** - Before releasing to inventory\n6. **Maintain traceability** - For recalls and compliance\n7. **Supplier lot mapping** - Link to vendor's batch numbers\n8. **Automate numbering** - Use sequences for consistency\n9. **Regular audits** - Check expired/quarantined stock\n10. **Document certificates** - Attach quality documents to lots\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13896,"content_sha256":"1dd03a1c28b29eaa5377f513cae5a68b0a5580f231a76d63e7b60368889036e8"},{"filename":"skills/mail-notification-patterns.md","content":"# Mail and Notification Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MAIL & NOTIFICATION PATTERNS ║\n║ Email templates, chatter integration, and activity management ║\n║ Use for automated emails, discussions, and workflow notifications ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Mail Mixin Integration\n\n### Adding Chatter to Model\n```python\nfrom odoo import api, fields, models\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(string='Name', required=True, tracking=True)\n state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ],\n string='Status',\n default='draft',\n tracking=True,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Customer',\n tracking=True,\n )\n user_id = fields.Many2one(\n comodel_name='res.users',\n string='Assigned To',\n tracking=True,\n )\n description = fields.Html(string='Description')\n```\n\n### View with Chatter\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"my_model_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">my.model.form\u003c/field>\n \u003cfield name=\"model\">my.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"My Model\">\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"state\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Description\">\n \u003cfield name=\"description\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003c!-- Chatter -->\n \u003cchatter/>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n---\n\n## Email Templates\n\n### Template Syntax Evolution (Odoo 15+)\n\nStarting with Odoo 15, email templates migrated from Jinja2 (Mako-style) syntax to QWeb rendering. This brought significant syntax changes and improvements:\n\n**Before Odoo 15 (Jinja2/Mako syntax):**\n```xml\n\u003crecord id=\"email_template_example\" model=\"mail.template\">\n \u003cfield name=\"name\">Example Template\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"subject\">Order ${object.name} - ${object.state}\u003c/field>\n \u003cfield name=\"body_html\" type=\"html\">\n\u003c![CDATA[\n\u003cp>Dear ${object.student_id.name},\u003c/p>\n\u003cp>Your order ${object.name} is now ${object.state}.\u003c/p>\n\u003cp>Amount: ${object.amount_total}\u003c/p>\n]]>\n \u003c/field>\n\u003c/record>\n```\n\n**Odoo 15+ (QWeb syntax):**\n```xml\n\u003crecord id=\"email_template_example\" model=\"mail.template\">\n \u003cfield name=\"name\">Example Template\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"subject\">\u003ct t-out=\"object.name\"/> - \u003ct t-out=\"object.state\"/>\u003c/field>\n \u003cfield name=\"body_html\" type=\"html\">\n\u003c![CDATA[\n\u003cp>Dear \u003ct t-out=\"object.student_id.name\"/>,\u003c/p>\n\u003cp>Your order \u003ct t-out=\"object.name\"/> is now \u003ct t-out=\"object.state\"/>.\u003c/p>\n\u003cp>Amount: \u003ct t-out=\"object.amount_total\"/>\u003c/p>\n]]>\n \u003c/field>\n\u003c/record>\n```\n\n**Key Changes:**\n- **Syntax**: `${expression}` → `\u003ct t-out=\"expression\"/>`\n- **Default behavior**: `t-out` escapes HTML by default (like old `t-esc`)\n- **Raw HTML**: Use `t-out` with `Markup` objects for safe unescaped rendering\n- **Conditionals**: `% if` → `\u003ct t-if=\"condition\">`\n- **Loops**: `% for` → `\u003ct t-foreach=\"items\" t-as=\"item\">`\n\n**The t-out Directive:**\nThe `t-out` directive was introduced in Odoo 15 as a unified replacement for:\n- `t-esc` (HTML-escaped output) - deprecated but still works\n- `t-raw` (unescaped output) - deprecated but still works\n\n`t-out` escapes by default but accepts `Markup` objects for safe HTML rendering, providing better security while maintaining flexibility.\n\n**Migration Checklist:**\n- Replace `${object.field}` with `\u003ct t-out=\"object.field\"/>`\n- Replace `${object.field or ''}` with `\u003ct t-out=\"object.field or ''\"/>`\n- Convert `% if` blocks to `\u003ct t-if=\"condition\">`\n- Convert `% for` loops to `\u003ct t-foreach=\"items\" t-as=\"item\">`\n- Update any raw HTML rendering to use `Markup` objects with `t-out`\n\n### Basic Email Template\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"email_template_my_model_confirm\" model=\"mail.template\">\n \u003cfield name=\"name\">My Model: Confirmation\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"subject\">{{ object.name }} - Confirmed\u003c/field>\n \u003cfield name=\"email_from\">{{ (object.company_id.email or user.email) }}\u003c/field>\n \u003cfield name=\"email_to\">{{ object.partner_id.email }}\u003c/field>\n \u003cfield name=\"body_html\" type=\"html\">\n\u003c![CDATA[\n\u003cdiv style=\"margin: 0px; padding: 0px;\">\n \u003cp style=\"margin: 0px; padding: 0px; font-size: 13px;\">\n Dear {{ object.partner_id.name }},\n \u003c/p>\n \u003cbr/>\n \u003cp>\n Your request \u003cstrong>{{ object.name }}\u003c/strong> has been confirmed.\n \u003c/p>\n \u003cbr/>\n \u003cp>\n \u003cstrong>Details:\u003c/strong>\n \u003c/p>\n \u003cul>\n \u003cli>Reference: {{ object.name }}\u003c/li>\n \u003cli>Date: {{ object.create_date.strftime('%Y-%m-%d') }}\u003c/li>\n \u003cli>Status: {{ object.state }}\u003c/li>\n \u003c/ul>\n \u003cbr/>\n \u003cp>\n Best regards,\u003cbr/>\n {{ object.company_id.name }}\n \u003c/p>\n\u003c/div>\n]]>\n \u003c/field>\n \u003cfield name=\"auto_delete\" eval=\"True\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Template with Attachments\n```xml\n\u003crecord id=\"email_template_with_report\" model=\"mail.template\">\n \u003cfield name=\"name\">My Model: Send Report\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"subject\">Report: {{ object.name }}\u003c/field>\n \u003cfield name=\"email_from\">{{ user.email }}\u003c/field>\n \u003cfield name=\"email_to\">{{ object.partner_id.email }}\u003c/field>\n \u003cfield name=\"body_html\" type=\"html\">\n\u003c![CDATA[\n\u003cp>Please find attached the report for {{ object.name }}.\u003c/p>\n]]>\n \u003c/field>\n \u003c!-- Attach PDF report -->\n \u003cfield name=\"report_template_id\" ref=\"my_module.report_my_model\"/>\n \u003cfield name=\"report_name\">Report_{{ object.name }}\u003c/field>\n\u003c/record>\n```\n\n### Dynamic Template (Python)\n```python\ndef _get_email_template_body(self) -> str:\n \"\"\"Generate dynamic email body.\"\"\"\n lines_html = \"\"\n for line in self.line_ids:\n lines_html += f\"\"\"\n \u003ctr>\n \u003ctd>{line.name}\u003c/td>\n \u003ctd style=\"text-align: right;\">{line.quantity}\u003c/td>\n \u003ctd style=\"text-align: right;\">{line.price_unit:.2f}\u003c/td>\n \u003c/tr>\n \"\"\"\n\n return f\"\"\"\n \u003cp>Dear {self.partner_id.name},\u003c/p>\n \u003ctable border=\"1\" cellpadding=\"5\">\n \u003cthead>\n \u003ctr>\n \u003cth>Description\u003c/th>\n \u003cth>Qty\u003c/th>\n \u003cth>Price\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n {lines_html}\n \u003c/tbody>\n \u003c/table>\n \u003cp>Total: {self.amount_total:.2f}\u003c/p>\n \"\"\"\n```\n\n---\n\n## Sending Emails\n\n### Using Template\n```python\ndef action_send_email(self) -> dict:\n \"\"\"Send email using template.\"\"\"\n self.ensure_one()\n\n template = self.env.ref('my_module.email_template_my_model_confirm')\n template.send_mail(self.id, force_send=True)\n\n return True\n```\n\n### Open Email Composer\n```python\ndef action_send_email_wizard(self) -> dict:\n \"\"\"Open email composer with template.\"\"\"\n self.ensure_one()\n\n template = self.env.ref('my_module.email_template_my_model_confirm')\n\n return {\n 'type': 'ir.actions.act_window',\n 'name': 'Send Email',\n 'res_model': 'mail.compose.message',\n 'view_mode': 'form',\n 'target': 'new',\n 'context': {\n 'default_model': self._name,\n 'default_res_ids': self.ids,\n 'default_template_id': template.id,\n 'default_composition_mode': 'comment',\n 'force_email': True,\n },\n }\n```\n\n### Send Without Template\n```python\ndef action_notify_partner(self) -> None:\n \"\"\"Send email without template.\"\"\"\n self.ensure_one()\n\n mail_values = {\n 'subject': f'Update: {self.name}',\n 'body_html': f'\u003cp>Your record {self.name} has been updated.\u003c/p>',\n 'email_from': self.env.company.email or self.env.user.email,\n 'email_to': self.partner_id.email,\n 'model': self._name,\n 'res_id': self.id,\n }\n\n mail = self.env['mail.mail'].sudo().create(mail_values)\n mail.send()\n```\n\n### Batch Email\n```python\ndef action_send_batch_emails(self) -> None:\n \"\"\"Send emails to multiple records.\"\"\"\n template = self.env.ref('my_module.email_template_my_model_confirm')\n\n for record in self:\n if record.partner_id.email:\n template.send_mail(record.id, force_send=False)\n\n # Process mail queue\n self.env['mail.mail'].sudo().process_email_queue()\n```\n\n---\n\n## Message Posting\n\n### Post Simple Message\n```python\ndef action_post_note(self) -> None:\n \"\"\"Post internal note.\"\"\"\n self.ensure_one()\n\n self.message_post(\n body=\"This is an internal note.\",\n message_type='comment',\n subtype_xmlid='mail.mt_note',\n )\n```\n\n### Post with Tracking\n```python\ndef action_confirm(self) -> None:\n \"\"\"Confirm and post message.\"\"\"\n self.ensure_one()\n\n old_state = self.state\n self.write({'state': 'confirmed'})\n\n # Post message with details\n self.message_post(\n body=f\"Record confirmed. State changed from {old_state} to confirmed.\",\n message_type='notification',\n subtype_xmlid='mail.mt_comment',\n )\n```\n\n### Post with Attachments\n```python\ndef action_post_with_attachment(self) -> None:\n \"\"\"Post message with file attachment.\"\"\"\n self.ensure_one()\n\n attachment = self.env['ir.attachment'].create({\n 'name': 'document.pdf',\n 'type': 'binary',\n 'datas': self.document_file, # base64 encoded\n 'res_model': self._name,\n 'res_id': self.id,\n })\n\n self.message_post(\n body=\"Document attached for review.\",\n attachment_ids=[attachment.id],\n )\n```\n\n### Post from Template\n```python\ndef action_post_from_template(self) -> None:\n \"\"\"Post message using template.\"\"\"\n self.ensure_one()\n\n template = self.env.ref('my_module.email_template_my_model_confirm')\n\n self.message_post_with_source(\n source_ref=template,\n subtype_xmlid='mail.mt_comment',\n )\n```\n\n---\n\n## Followers and Subscriptions\n\n### Add Followers\n```python\ndef action_add_followers(self) -> None:\n \"\"\"Add partners as followers.\"\"\"\n self.ensure_one()\n\n partners_to_add = self.team_id.member_ids.mapped('partner_id')\n self.message_subscribe(partner_ids=partners_to_add.ids)\n```\n\n### Remove Followers\n```python\ndef action_remove_follower(self, partner_id: int) -> None:\n \"\"\"Remove specific follower.\"\"\"\n self.ensure_one()\n self.message_unsubscribe(partner_ids=[partner_id])\n```\n\n### Custom Subtypes\n```xml\n\u003c!-- data/mail_subtype.xml -->\n\u003codoo>\n \u003c!-- Subtype for confirmed notifications -->\n \u003crecord id=\"mt_my_model_confirmed\" model=\"mail.message.subtype\">\n \u003cfield name=\"name\">Confirmed\u003c/field>\n \u003cfield name=\"res_model\">my.model\u003c/field>\n \u003cfield name=\"default\" eval=\"True\"/>\n \u003cfield name=\"description\">Record has been confirmed\u003c/field>\n \u003c/record>\n\n \u003c!-- Subtype for assignment -->\n \u003crecord id=\"mt_my_model_assigned\" model=\"mail.message.subtype\">\n \u003cfield name=\"name\">Assigned\u003c/field>\n \u003cfield name=\"res_model\">my.model\u003c/field>\n \u003cfield name=\"default\" eval=\"False\"/>\n \u003cfield name=\"description\">Record has been assigned\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Use Custom Subtype\n```python\ndef action_confirm(self) -> None:\n \"\"\"Confirm with custom notification.\"\"\"\n self.ensure_one()\n self.write({'state': 'confirmed'})\n\n self.message_post(\n body=\"Record confirmed.\",\n subtype_xmlid='my_module.mt_my_model_confirmed',\n )\n```\n\n---\n\n## Activities\n\n### Schedule Activity\n```python\ndef action_schedule_followup(self) -> None:\n \"\"\"Schedule follow-up activity.\"\"\"\n self.ensure_one()\n\n self.activity_schedule(\n 'mail.mail_activity_data_todo',\n date_deadline=fields.Date.today() + timedelta(days=7),\n summary='Follow up with customer',\n note='Check if customer needs assistance.',\n user_id=self.user_id.id,\n )\n```\n\n### Schedule with Feedback\n```python\ndef action_request_approval(self) -> None:\n \"\"\"Request approval via activity.\"\"\"\n self.ensure_one()\n\n activity_type = self.env.ref('mail.mail_activity_data_todo')\n\n self.activity_schedule(\n activity_type_id=activity_type.id,\n date_deadline=fields.Date.today() + timedelta(days=3),\n summary='Approval Required',\n note=f'Please review and approve: {self.name}',\n user_id=self.env.ref('base.user_admin').id,\n )\n```\n\n### Mark Activity Done\n```python\ndef action_mark_activity_done(self) -> None:\n \"\"\"Mark all activities as done.\"\"\"\n self.ensure_one()\n\n activities = self.activity_ids.filtered(\n lambda a: a.activity_type_id.name == 'To Do'\n )\n activities.action_feedback(feedback='Completed by workflow.')\n```\n\n### Custom Activity Type\n```xml\n\u003c!-- data/mail_activity_type.xml -->\n\u003codoo>\n \u003crecord id=\"mail_activity_type_review\" model=\"mail.activity.type\">\n \u003cfield name=\"name\">Review Required\u003c/field>\n \u003cfield name=\"summary\">Review this record\u003c/field>\n \u003cfield name=\"res_model\">my.model\u003c/field>\n \u003cfield name=\"icon\">fa-check-square\u003c/field>\n \u003cfield name=\"delay_count\">3\u003c/field>\n \u003cfield name=\"delay_unit\">days\u003c/field>\n \u003cfield name=\"default_user_id\" ref=\"base.user_admin\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n---\n\n## Automated Notifications\n\n### On State Change (Automated Action)\n```xml\n\u003crecord id=\"automation_notify_on_confirm\" model=\"base.automation\">\n \u003cfield name=\"name\">Notify on Confirmation\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"trigger\">on_write\u003c/field>\n \u003cfield name=\"trigger_field_ids\" eval=\"[(6, 0, [ref('field_my_model__state')])]\"/>\n \u003cfield name=\"filter_domain\">[('state', '=', 'confirmed')]\u003c/field>\n \u003cfield name=\"state\">code\u003c/field>\n \u003cfield name=\"code\">\ntemplate = env.ref('my_module.email_template_my_model_confirm')\nfor record in records:\n if record.partner_id.email:\n template.send_mail(record.id)\n \u003c/field>\n\u003c/record>\n```\n\n### Override Tracking (Python)\n```python\ndef _track_subtype(self, init_values) -> str:\n \"\"\"Return subtype for tracking notifications.\"\"\"\n self.ensure_one()\n\n if 'state' in init_values:\n if self.state == 'confirmed':\n return self.env.ref('my_module.mt_my_model_confirmed')\n elif self.state == 'done':\n return self.env.ref('my_module.mt_my_model_done')\n\n return super()._track_subtype(init_values)\n```\n\n### Custom Notification Logic\n```python\ndef _notify_get_recipients(self, message, msg_vals, **kwargs):\n \"\"\"Override to customize notification recipients.\"\"\"\n recipients = super()._notify_get_recipients(message, msg_vals, **kwargs)\n\n # Add manager to important notifications\n if self.state == 'confirmed' and self.amount_total > 10000:\n manager = self.env.ref('my_module.group_manager').users\n for user in manager:\n if user.partner_id.id not in [r['id'] for r in recipients]:\n recipients.append({\n 'id': user.partner_id.id,\n 'active': True,\n 'share': False,\n 'notif': 'email',\n 'type': 'user',\n })\n\n return recipients\n```\n\n---\n\n## In-App Notifications\n\n### Display Notification\n```python\ndef action_with_notification(self) -> dict:\n \"\"\"Action with success notification.\"\"\"\n self.ensure_one()\n\n # Do something\n self.write({'state': 'done'})\n\n return {\n 'type': 'ir.actions.client',\n 'tag': 'display_notification',\n 'params': {\n 'title': 'Success',\n 'message': f'{self.name} has been processed.',\n 'type': 'success', # success, warning, danger, info\n 'sticky': False,\n 'next': {'type': 'ir.actions.act_window_close'},\n }\n }\n```\n\n### Notification with Link\n```python\ndef action_notify_with_link(self) -> dict:\n \"\"\"Notification with clickable link.\"\"\"\n return {\n 'type': 'ir.actions.client',\n 'tag': 'display_notification',\n 'params': {\n 'title': 'Record Created',\n 'message': 'Click to view the new record.',\n 'type': 'success',\n 'links': [{\n 'label': self.name,\n 'url': f'/web#id={self.id}&model={self._name}&view_type=form',\n }],\n }\n }\n```\n\n---\n\n## Bus Notifications (Real-time)\n\n### Send Bus Notification\n```python\ndef action_notify_users(self) -> None:\n \"\"\"Send real-time notification via bus.\"\"\"\n self.ensure_one()\n\n # Notify specific user\n self.env['bus.bus']._sendone(\n self.user_id.partner_id,\n 'simple_notification',\n {\n 'title': 'New Assignment',\n 'message': f'You have been assigned to {self.name}',\n 'type': 'info',\n 'sticky': False,\n }\n )\n```\n\n### Broadcast to Channel\n```python\ndef action_broadcast(self) -> None:\n \"\"\"Broadcast to all users in group.\"\"\"\n channel = f'my_module_{self.env.company.id}'\n\n self.env['bus.bus']._sendone(\n channel,\n 'my_module/notification',\n {\n 'record_id': self.id,\n 'message': f'Record {self.name} updated',\n }\n )\n```\n\n---\n\n## Best Practices\n\n1. **Use templates** - Define email templates in XML for maintainability\n2. **Handle missing emails** - Always check `partner_id.email` before sending\n3. **Use queues** - Set `force_send=False` for batch operations\n4. **Track important fields** - Add `tracking=True` to key fields\n5. **Custom subtypes** - Create subtypes for different notification types\n6. **Activity scheduling** - Use activities for task management\n7. **Follower management** - Auto-subscribe relevant parties\n8. **Test email rendering** - Verify templates render correctly\n\n---\n\n## Manifest Dependencies\n\n```python\n{\n 'depends': [\n 'mail', # Required for mail.thread\n ],\n 'data': [\n 'data/mail_template.xml',\n 'data/mail_subtype.xml',\n 'data/mail_activity_type.xml',\n 'views/my_model_views.xml',\n ],\n}\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":19118,"content_sha256":"4167a9206de6575ee8e6af257ebe439b31b49671f6757ab956688da1fa76df14"},{"filename":"skills/menu-navigation-patterns.md","content":"# Menu and Navigation Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MENU & NAVIGATION PATTERNS ║\n║ Menu structure, navigation, and application organization ║\n║ Use for module UI organization and user navigation ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Menu Structure Overview\n\n```\nRoot Menu (App)\n├── Category Menu 1\n│ ├── Submenu 1.1 → Action\n│ └── Submenu 1.2 → Action\n├── Category Menu 2\n│ ├── Submenu 2.1 → Action\n│ └── Submenu 2.2 → Action\n└── Configuration\n ├── Settings → Action\n └── Data → Action\n```\n\n---\n\n## Basic Menu Definition\n\n### Root Menu (Application)\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Root menu (appears in app switcher) -->\n \u003cmenuitem id=\"menu_my_module_root\"\n name=\"My Application\"\n web_icon=\"my_module,static/description/icon.png\"\n sequence=\"10\"/>\n\u003c/odoo>\n```\n\n### Category Menus\n```xml\n\u003c!-- Category under root -->\n\u003cmenuitem id=\"menu_my_module_main\"\n name=\"Records\"\n parent=\"menu_my_module_root\"\n sequence=\"10\"/>\n\n\u003cmenuitem id=\"menu_my_module_config\"\n name=\"Configuration\"\n parent=\"menu_my_module_root\"\n sequence=\"100\"\n groups=\"base.group_system\"/>\n```\n\n### Action Menus (Leaf Nodes)\n```xml\n\u003c!-- Menu with action -->\n\u003cmenuitem id=\"menu_my_model\"\n name=\"My Records\"\n parent=\"menu_my_module_main\"\n action=\"action_my_model\"\n sequence=\"10\"/>\n\n\u003cmenuitem id=\"menu_my_model_archived\"\n name=\"Archived\"\n parent=\"menu_my_module_main\"\n action=\"action_my_model_archived\"\n sequence=\"20\"/>\n```\n\n---\n\n## Complete Menu Example\n\n### Full Module Menu Structure\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- ============================================ -->\n \u003c!-- ROOT MENU (Application) -->\n \u003c!-- ============================================ -->\n \u003cmenuitem id=\"menu_my_module_root\"\n name=\"My Application\"\n web_icon=\"my_module,static/description/icon.png\"\n sequence=\"50\"/>\n\n \u003c!-- ============================================ -->\n \u003c!-- MAIN MENUS -->\n \u003c!-- ============================================ -->\n\n \u003c!-- Records Category -->\n \u003cmenuitem id=\"menu_records\"\n name=\"Records\"\n parent=\"menu_my_module_root\"\n sequence=\"10\"/>\n\n \u003cmenuitem id=\"menu_my_model\"\n name=\"All Records\"\n parent=\"menu_records\"\n action=\"action_my_model\"\n sequence=\"10\"/>\n\n \u003cmenuitem id=\"menu_my_model_draft\"\n name=\"Draft\"\n parent=\"menu_records\"\n action=\"action_my_model_draft\"\n sequence=\"20\"/>\n\n \u003cmenuitem id=\"menu_my_model_confirmed\"\n name=\"Confirmed\"\n parent=\"menu_records\"\n action=\"action_my_model_confirmed\"\n sequence=\"30\"/>\n\n \u003c!-- Reports Category -->\n \u003cmenuitem id=\"menu_reports\"\n name=\"Reports\"\n parent=\"menu_my_module_root\"\n sequence=\"50\"/>\n\n \u003cmenuitem id=\"menu_report_analysis\"\n name=\"Analysis\"\n parent=\"menu_reports\"\n action=\"action_report_analysis\"\n sequence=\"10\"/>\n\n \u003c!-- ============================================ -->\n \u003c!-- CONFIGURATION MENUS -->\n \u003c!-- ============================================ -->\n\n \u003cmenuitem id=\"menu_configuration\"\n name=\"Configuration\"\n parent=\"menu_my_module_root\"\n sequence=\"100\"\n groups=\"base.group_system\"/>\n\n \u003cmenuitem id=\"menu_config_settings\"\n name=\"Settings\"\n parent=\"menu_configuration\"\n action=\"action_my_module_config\"\n sequence=\"10\"/>\n\n \u003cmenuitem id=\"menu_config_categories\"\n name=\"Categories\"\n parent=\"menu_configuration\"\n action=\"action_my_category\"\n sequence=\"20\"/>\n\n \u003cmenuitem id=\"menu_config_tags\"\n name=\"Tags\"\n parent=\"menu_configuration\"\n action=\"action_my_tag\"\n sequence=\"30\"/>\n\u003c/odoo>\n```\n\n---\n\n## Menu Attributes\n\n### Common Attributes\n| Attribute | Description |\n|-----------|-------------|\n| `id` | Unique XML ID (required) |\n| `name` | Display name |\n| `parent` | Parent menu XML ID |\n| `action` | Action to execute |\n| `sequence` | Order (lower = first) |\n| `groups` | Security groups (comma-separated) |\n| `web_icon` | App icon (root menu only) |\n| `active` | Enable/disable menu |\n\n### Sequence Guidelines\n| Range | Use For |\n|-------|---------|\n| 1-20 | Primary menus (most used) |\n| 20-50 | Secondary menus |\n| 50-80 | Reports and analysis |\n| 80-100 | Configuration |\n\n---\n\n## Menu Security\n\n### Restrict by Group\n```xml\n\u003c!-- Only managers see this menu -->\n\u003cmenuitem id=\"menu_sensitive\"\n name=\"Sensitive Data\"\n parent=\"menu_my_module_main\"\n action=\"action_sensitive\"\n groups=\"my_module.group_manager\"/>\n\n\u003c!-- Multiple groups (OR) -->\n\u003cmenuitem id=\"menu_admin\"\n name=\"Admin\"\n parent=\"menu_my_module_config\"\n action=\"action_admin\"\n groups=\"base.group_system,my_module.group_admin\"/>\n```\n\n### Hide Menu Conditionally\n```xml\n\u003c!-- Menu visible based on settings -->\n\u003cmenuitem id=\"menu_optional_feature\"\n name=\"Optional Feature\"\n parent=\"menu_my_module_main\"\n action=\"action_optional\"\n groups=\"my_module.group_use_optional_feature\"/>\n```\n\n---\n\n## Extending Existing Menus\n\n### Add to Existing App\n```xml\n\u003c!-- Add menu under Sales app -->\n\u003cmenuitem id=\"menu_my_sale_extension\"\n name=\"My Extension\"\n parent=\"sale.sale_menu_root\"\n action=\"action_my_sale_extension\"\n sequence=\"50\"/>\n\n\u003c!-- Add under Sales > Configuration -->\n\u003cmenuitem id=\"menu_my_sale_config\"\n name=\"My Settings\"\n parent=\"sale.menu_sale_config\"\n action=\"action_my_sale_config\"\n sequence=\"100\"/>\n```\n\n### Common Parent Menus\n```xml\n\u003c!-- Sales -->\nparent=\"sale.sale_menu_root\"\nparent=\"sale.sale_order_menu\"\nparent=\"sale.menu_sale_config\"\n\n\u003c!-- Purchase -->\nparent=\"purchase.menu_purchase_root\"\nparent=\"purchase.menu_purchase_config\"\n\n\u003c!-- Inventory -->\nparent=\"stock.menu_stock_root\"\nparent=\"stock.menu_stock_config\"\n\n\u003c!-- Accounting -->\nparent=\"account.menu_finance\"\nparent=\"account.menu_finance_configuration\"\n\n\u003c!-- CRM -->\nparent=\"crm.crm_menu_root\"\nparent=\"crm.crm_menu_config\"\n\n\u003c!-- HR -->\nparent=\"hr.menu_hr_root\"\nparent=\"hr.menu_human_resources_configuration\"\n\n\u003c!-- Project -->\nparent=\"project.menu_main_pm\"\nparent=\"project.menu_project_config\"\n\n\u003c!-- Settings (general) -->\nparent=\"base.menu_administration\"\n```\n\n---\n\n## Menu with Filters\n\n### Pre-filtered Menus\n```xml\n\u003c!-- Action with domain -->\n\u003crecord id=\"action_my_model_draft\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">Draft Records\u003c/field>\n \u003cfield name=\"res_model\">my.model\u003c/field>\n \u003cfield name=\"view_mode\">tree,form\u003c/field>\n \u003cfield name=\"domain\">[('state', '=', 'draft')]\u003c/field>\n \u003cfield name=\"context\">{'default_state': 'draft'}\u003c/field>\n\u003c/record>\n\n\u003cmenuitem id=\"menu_my_model_draft\"\n name=\"Draft\"\n parent=\"menu_records\"\n action=\"action_my_model_draft\"/>\n\n\u003c!-- Action with search filter -->\n\u003crecord id=\"action_my_model_my_records\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">My Records\u003c/field>\n \u003cfield name=\"res_model\">my.model\u003c/field>\n \u003cfield name=\"view_mode\">tree,form\u003c/field>\n \u003cfield name=\"context\">{'search_default_my_records': 1}\u003c/field>\n\u003c/record>\n```\n\n---\n\n## Dynamic Menus\n\n### Create Menu from Python\n```python\ndef _create_dynamic_menu(self, name, parent_id, action_id):\n \"\"\"Create menu dynamically.\"\"\"\n return self.env['ir.ui.menu'].create({\n 'name': name,\n 'parent_id': parent_id,\n 'action': f'ir.actions.act_window,{action_id}',\n 'sequence': 100,\n })\n```\n\n### Update Menu Visibility\n```python\ndef _update_menu_visibility(self):\n \"\"\"Show/hide menu based on configuration.\"\"\"\n menu = self.env.ref('my_module.menu_optional_feature')\n config_param = self.env['ir.config_parameter'].sudo()\n show_menu = config_param.get_param('my_module.show_optional', 'False')\n menu.write({'active': show_menu == 'True'})\n```\n\n---\n\n## App Icon\n\n### Icon Requirements\n- Format: PNG\n- Size: 256x256 pixels (recommended)\n- Location: `static/description/icon.png`\n\n### Setting App Icon\n```xml\n\u003cmenuitem id=\"menu_my_module_root\"\n name=\"My Application\"\n web_icon=\"my_module,static/description/icon.png\"\n sequence=\"50\"/>\n```\n\n---\n\n## Menu Order Patterns\n\n### Standard App Layout\n```\n1. Main Operations (seq 10-20)\n - All Records\n - My Records\n - To Do / Pending\n\n2. Secondary Operations (seq 30-50)\n - By Category views\n - Filtered views\n\n3. Reports (seq 60-80)\n - Analysis\n - Dashboards\n\n4. Configuration (seq 90-100)\n - Settings\n - Master Data\n```\n\n### Example Implementation\n```xml\n\u003c!-- Main Operations -->\n\u003cmenuitem id=\"menu_operations\" name=\"Operations\"\n parent=\"menu_root\" sequence=\"10\"/>\n\n\u003cmenuitem id=\"menu_all\" name=\"All Records\"\n parent=\"menu_operations\" action=\"action_all\" sequence=\"10\"/>\n\n\u003cmenuitem id=\"menu_my\" name=\"My Records\"\n parent=\"menu_operations\" action=\"action_my\" sequence=\"20\"/>\n\n\u003c!-- Reports -->\n\u003cmenuitem id=\"menu_reporting\" name=\"Reporting\"\n parent=\"menu_root\" sequence=\"60\"/>\n\n\u003cmenuitem id=\"menu_analysis\" name=\"Analysis\"\n parent=\"menu_reporting\" action=\"action_analysis\" sequence=\"10\"/>\n\n\u003c!-- Configuration -->\n\u003cmenuitem id=\"menu_config\" name=\"Configuration\"\n parent=\"menu_root\" sequence=\"90\"\n groups=\"base.group_system\"/>\n```\n\n---\n\n## Best Practices\n\n1. **Consistent naming** - Use clear, action-oriented names\n2. **Logical grouping** - Group related menus together\n3. **Sequence numbers** - Leave gaps (10, 20, 30) for future insertions\n4. **Security groups** - Restrict sensitive menus\n5. **Configuration last** - Always sequence 90+\n6. **App icon** - Always provide for root menu\n7. **Extend, don't duplicate** - Add to existing apps when appropriate\n8. **Keep it shallow** - Max 3 levels of nesting\n9. **Use filters** - Pre-filtered views for common use cases\n10. **Test permissions** - Verify menu visibility for different users\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11038,"content_sha256":"53aa9c2512e6fc3023375c5b15c99babf08c1e2cd69047270d962688a66f322d"},{"filename":"skills/module-generation-example.md","content":"# Complete Module Generation Example\n\nThis document shows the complete output format for module generation, demonstrating how an AI agent should produce production-ready Odoo modules.\n\n## Example Request\n\n```json\n{\n \"module_name\": \"equipment_tracking\",\n \"module_description\": \"Track company equipment assets with maintenance scheduling\",\n \"odoo_version\": \"18.0\",\n \"target_apps\": [\"hr\", \"maintenance\"],\n \"ui_stack\": \"owl\",\n \"multi_company\": true,\n \"multi_currency\": false,\n \"security_level\": \"advanced\",\n \"performance_critical\": false,\n \"custom_models\": [\n {\n \"name\": \"equipment.asset\",\n \"description\": \"Equipment Asset\",\n \"inherit_mail\": true,\n \"fields\": [\n {\"name\": \"name\", \"type\": \"Char\", \"required\": true, \"tracking\": true},\n {\"name\": \"serial_number\", \"type\": \"Char\", \"index\": true},\n {\"name\": \"purchase_date\", \"type\": \"Date\"},\n {\"name\": \"status\", \"type\": \"Selection\", \"selection\": [[\"active\", \"Active\"], [\"maintenance\", \"In Maintenance\"], [\"retired\", \"Retired\"]], \"default\": \"active\"},\n {\"name\": \"value\", \"type\": \"Monetary\"},\n {\"name\": \"assigned_employee_id\", \"type\": \"Many2one\", \"comodel\": \"hr.employee\"}\n ]\n }\n ],\n \"include_tests\": true,\n \"include_demo\": true,\n \"author\": \"My Company\",\n \"website\": \"https://mycompany.com\",\n \"license\": \"LGPL-3\"\n}\n```\n\n## Generated Output\n\n### File Structure\n\n```\nequipment_tracking/\n├── __init__.py\n├── __manifest__.py\n├── models/\n│ ├── __init__.py\n│ └── equipment_asset.py\n├── views/\n│ ├── equipment_asset_views.xml\n│ └── menuitems.xml\n├── security/\n│ ├── equipment_tracking_security.xml\n│ └── ir.model.access.csv\n├── data/\n│ └── equipment_tracking_data.xml\n├── demo/\n│ └── equipment_tracking_demo.xml\n├── static/\n│ └── src/\n│ ├── components/\n│ │ └── equipment_dashboard/\n│ │ ├── equipment_dashboard.js\n│ │ └── equipment_dashboard.xml\n│ └── scss/\n│ └── equipment_tracking.scss\n├── tests/\n│ ├── __init__.py\n│ └── test_equipment_asset.py\n└── i18n/\n └── equipment_tracking.pot\n```\n\n### __manifest__.py\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': 'Equipment Tracking',\n 'version': '18.0.1.0.0',\n 'category': 'Human Resources/Equipment',\n 'summary': 'Track company equipment assets with maintenance scheduling',\n 'description': \"\"\"\nEquipment Tracking Module\n=========================\n\nThis module provides comprehensive equipment asset tracking with:\n* Asset management and lifecycle tracking\n* Employee assignment\n* Maintenance scheduling integration\n* Multi-company support\n\n**Version Notes:**\n- Odoo 18.0 compatible\n- Uses _check_company_auto for multi-company validation\n- Type hints on methods (v18 recommended pattern)\n\n**Upgrade Notes:**\n- From v17: No breaking changes\n- To v19: Add mandatory type hints\n \"\"\",\n 'author': 'My Company',\n 'website': 'https://mycompany.com',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail', 'hr', 'maintenance'],\n 'data': [\n # Security first (groups defined before referenced)\n 'security/equipment_tracking_security.xml',\n 'security/ir.model.access.csv',\n # Data files\n 'data/equipment_tracking_data.xml',\n # Views (may reference groups)\n 'views/equipment_asset_views.xml',\n # Menus last (reference actions from views)\n 'views/menuitems.xml',\n ],\n 'demo': [\n 'demo/equipment_tracking_demo.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n 'equipment_tracking/static/src/**/*.js',\n 'equipment_tracking/static/src/**/*.xml',\n 'equipment_tracking/static/src/**/*.scss',\n ],\n },\n 'installable': True,\n 'application': True,\n 'auto_install': False,\n}\n```\n\n### models/__init__.py\n\n```python\n# -*- coding: utf-8 -*-\nfrom . import equipment_asset\n```\n\n### models/equipment_asset.py\n\n```python\n# -*- coding: utf-8 -*-\nfrom typing import Optional, Any\n\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import UserError, ValidationError\nfrom odoo.tools import SQL\n\n\nclass EquipmentAsset(models.Model):\n _name = 'equipment.asset'\n _description = 'Equipment Asset'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'name'\n\n # v18: Enable automatic company validation\n _check_company_auto = True\n\n # === BASIC FIELDS === #\n name = fields.Char(\n string='Asset Name',\n required=True,\n tracking=True,\n )\n serial_number = fields.Char(\n string='Serial Number',\n index=True,\n tracking=True,\n )\n active = fields.Boolean(\n string='Active',\n default=True,\n )\n description = fields.Text(\n string='Description',\n )\n\n # === DATE FIELDS === #\n purchase_date = fields.Date(\n string='Purchase Date',\n tracking=True,\n )\n warranty_expiry = fields.Date(\n string='Warranty Expiry',\n )\n\n # === STATUS === #\n status = fields.Selection(\n selection=[\n ('active', 'Active'),\n ('maintenance', 'In Maintenance'),\n ('retired', 'Retired'),\n ],\n string='Status',\n default='active',\n required=True,\n tracking=True,\n )\n\n # === MONETARY FIELDS === #\n currency_id = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n default=lambda self: self.env.company.currency_id,\n required=True,\n )\n value = fields.Monetary(\n string='Asset Value',\n currency_field='currency_id',\n tracking=True,\n )\n\n # === RELATIONAL FIELDS === #\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n assigned_employee_id = fields.Many2one(\n comodel_name='hr.employee',\n string='Assigned Employee',\n tracking=True,\n check_company=True, # v18: Automatic company validation\n )\n maintenance_request_ids = fields.One2many(\n comodel_name='maintenance.request',\n inverse_name='equipment_id',\n string='Maintenance Requests',\n )\n\n # === COMPUTED FIELDS === #\n maintenance_count = fields.Integer(\n string='Maintenance Count',\n compute='_compute_maintenance_count',\n )\n is_under_warranty = fields.Boolean(\n string='Under Warranty',\n compute='_compute_is_under_warranty',\n )\n\n @api.depends('maintenance_request_ids')\n def _compute_maintenance_count(self) -> None:\n \"\"\"Compute number of maintenance requests.\"\"\"\n for record in self:\n record.maintenance_count = len(record.maintenance_request_ids)\n\n @api.depends('warranty_expiry')\n def _compute_is_under_warranty(self) -> None:\n \"\"\"Check if asset is under warranty.\"\"\"\n today = fields.Date.context_today(self)\n for record in self:\n record.is_under_warranty = (\n record.warranty_expiry and record.warranty_expiry >= today\n )\n\n # === CONSTRAINTS === #\n @api.constrains('value')\n def _check_value(self) -> None:\n \"\"\"Validate asset value is non-negative.\"\"\"\n for record in self:\n if record.value \u003c 0:\n raise ValidationError(_(\"Asset value cannot be negative.\"))\n\n _sql_constraints = [\n ('serial_uniq', 'unique(company_id, serial_number)',\n 'Serial number must be unique per company!'),\n ]\n\n # === CRUD METHODS === #\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> 'EquipmentAsset':\n \"\"\"Create assets with sequence generation.\"\"\"\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n 'equipment.asset'\n ) or _('New Asset')\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n \"\"\"Write with status change notification.\"\"\"\n if 'status' in vals:\n for record in self:\n record.message_post(\n body=_(\"Status changed to: %s\", vals['status']),\n message_type='notification',\n )\n return super().write(vals)\n\n def unlink(self) -> bool:\n \"\"\"Prevent deletion of assets with active maintenance.\"\"\"\n for record in self:\n active_maintenance = record.maintenance_request_ids.filtered(\n lambda r: r.stage_id.done is False\n )\n if active_maintenance:\n raise UserError(\n _(\"Cannot delete asset with active maintenance requests.\")\n )\n return super().unlink()\n\n # === ACTION METHODS === #\n def action_set_active(self) -> None:\n \"\"\"Set asset status to active.\"\"\"\n self.write({'status': 'active'})\n\n def action_set_maintenance(self) -> None:\n \"\"\"Set asset to maintenance status.\"\"\"\n self.write({'status': 'maintenance'})\n\n def action_retire(self) -> None:\n \"\"\"Retire the asset.\"\"\"\n self.write({'status': 'retired', 'active': False})\n\n def action_view_maintenance(self) -> dict[str, Any]:\n \"\"\"View maintenance requests for this asset.\"\"\"\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Maintenance Requests'),\n 'res_model': 'maintenance.request',\n 'view_mode': 'tree,form',\n 'domain': [('equipment_id', '=', self.id)],\n 'context': {'default_equipment_id': self.id},\n }\n\n # === SQL OPERATIONS (v18 pattern) === #\n def _get_asset_statistics(self) -> list[dict[str, Any]]:\n \"\"\"Get asset statistics using SQL builder.\"\"\"\n query = SQL(\n \"\"\"\n SELECT\n status,\n COUNT(*) as count,\n COALESCE(SUM(value), 0) as total_value\n FROM %s\n WHERE company_id IN %s\n AND active = %s\n GROUP BY status\n ORDER BY count DESC\n \"\"\",\n SQL.identifier(self._table),\n tuple(self.env.companies.ids),\n True,\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n### views/equipment_asset_views.xml\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Form View -->\n \u003crecord id=\"equipment_asset_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.asset.form\u003c/field>\n \u003cfield name=\"model\">equipment.asset\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"Equipment Asset\">\n \u003cheader>\n \u003cbutton name=\"action_set_active\"\n string=\"Set Active\"\n type=\"object\"\n class=\"btn-primary\"\n invisible=\"status == 'active'\"/>\n \u003cbutton name=\"action_set_maintenance\"\n string=\"Send to Maintenance\"\n type=\"object\"\n invisible=\"status == 'maintenance'\"/>\n \u003cbutton name=\"action_retire\"\n string=\"Retire\"\n type=\"object\"\n invisible=\"status == 'retired'\"\n confirm=\"Are you sure you want to retire this asset?\"/>\n \u003cfield name=\"status\" widget=\"statusbar\"\n statusbar_visible=\"active,maintenance,retired\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\">\n \u003cbutton name=\"action_view_maintenance\"\n type=\"object\"\n class=\"oe_stat_button\"\n icon=\"fa-wrench\">\n \u003cfield name=\"maintenance_count\" widget=\"statinfo\"\n string=\"Maintenance\"/>\n \u003c/button>\n \u003c/div>\n \u003cwidget name=\"web_ribbon\" title=\"Retired\" bg_color=\"bg-danger\"\n invisible=\"status != 'retired'\"/>\n \u003cwidget name=\"web_ribbon\" title=\"Under Warranty\" bg_color=\"bg-success\"\n invisible=\"not is_under_warranty\"/>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\n \u003cfield name=\"name\" placeholder=\"Asset Name...\"/>\n \u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup string=\"Asset Information\">\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"assigned_employee_id\"/>\n \u003cfield name=\"purchase_date\"/>\n \u003cfield name=\"warranty_expiry\"/>\n \u003c/group>\n \u003cgroup string=\"Value & Company\">\n \u003cfield name=\"value\"/>\n \u003cfield name=\"currency_id\" invisible=\"1\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Description\" name=\"description\">\n \u003cfield name=\"description\"\n placeholder=\"Add description...\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Tree View -->\n \u003crecord id=\"equipment_asset_view_tree\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.asset.tree\u003c/field>\n \u003cfield name=\"model\">equipment.asset\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ctree string=\"Equipment Assets\"\n decoration-muted=\"status == 'retired'\"\n decoration-warning=\"status == 'maintenance'\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"assigned_employee_id\"/>\n \u003cfield name=\"status\" widget=\"badge\"\n decoration-success=\"status == 'active'\"\n decoration-warning=\"status == 'maintenance'\"\n decoration-danger=\"status == 'retired'\"/>\n \u003cfield name=\"value\" sum=\"Total Value\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Search View -->\n \u003crecord id=\"equipment_asset_view_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.asset.search\u003c/field>\n \u003cfield name=\"model\">equipment.asset\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch string=\"Search Assets\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"assigned_employee_id\"/>\n \u003cseparator/>\n \u003cfilter name=\"filter_active\" string=\"Active\"\n domain=\"[('status', '=', 'active')]\"/>\n \u003cfilter name=\"filter_maintenance\" string=\"In Maintenance\"\n domain=\"[('status', '=', 'maintenance')]\"/>\n \u003cfilter name=\"filter_retired\" string=\"Retired\"\n domain=\"[('status', '=', 'retired')]\"/>\n \u003cseparator/>\n \u003cfilter name=\"filter_under_warranty\" string=\"Under Warranty\"\n domain=\"[('warranty_expiry', '>=', context_today().strftime('%Y-%m-%d'))]\"/>\n \u003cseparator/>\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter name=\"groupby_status\" string=\"Status\"\n context=\"{'group_by': 'status'}\"/>\n \u003cfilter name=\"groupby_employee\" string=\"Employee\"\n context=\"{'group_by': 'assigned_employee_id'}\"/>\n \u003cfilter name=\"groupby_company\" string=\"Company\"\n context=\"{'group_by': 'company_id'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Kanban View -->\n \u003crecord id=\"equipment_asset_view_kanban\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">equipment.asset.kanban\u003c/field>\n \u003cfield name=\"model\">equipment.asset\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ckanban class=\"o_kanban_mobile\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"serial_number\"/>\n \u003cfield name=\"status\"/>\n \u003cfield name=\"value\"/>\n \u003cfield name=\"assigned_employee_id\"/>\n \u003ctemplates>\n \u003ct t-name=\"kanban-box\">\n \u003cdiv class=\"oe_kanban_card oe_kanban_global_click\">\n \u003cdiv class=\"oe_kanban_content\">\n \u003cdiv class=\"o_kanban_record_top\">\n \u003cstrong class=\"o_kanban_record_title\">\n \u003cfield name=\"name\"/>\n \u003c/strong>\n \u003cfield name=\"status\" widget=\"badge\"/>\n \u003c/div>\n \u003cdiv class=\"o_kanban_record_bottom\">\n \u003cdiv class=\"oe_kanban_bottom_left\">\n \u003cfield name=\"serial_number\"/>\n \u003c/div>\n \u003cdiv class=\"oe_kanban_bottom_right\">\n \u003cfield name=\"value\" widget=\"monetary\"/>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n \u003c/templates>\n \u003c/kanban>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Action -->\n \u003crecord id=\"equipment_asset_action\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">Equipment Assets\u003c/field>\n \u003cfield name=\"res_model\">equipment.asset\u003c/field>\n \u003cfield name=\"view_mode\">tree,kanban,form\u003c/field>\n \u003cfield name=\"search_view_id\" ref=\"equipment_asset_view_search\"/>\n \u003cfield name=\"help\" type=\"html\">\n \u003cp class=\"o_view_nocontent_smiling_face\">\n Create your first equipment asset\n \u003c/p>\n \u003cp>\n Track and manage company equipment with maintenance scheduling.\n \u003c/p>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### security/equipment_tracking_security.xml\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Module Category -->\n \u003crecord id=\"module_category_equipment_tracking\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Equipment Tracking\u003c/field>\n \u003cfield name=\"sequence\">50\u003c/field>\n \u003c/record>\n\n \u003c!-- User Group -->\n \u003crecord id=\"group_equipment_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_equipment_tracking\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('base.group_user'))]\"/>\n \u003c/record>\n\n \u003c!-- Manager Group -->\n \u003crecord id=\"group_equipment_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_equipment_tracking\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_equipment_user'))]\"/>\n \u003cfield name=\"users\" eval=\"[(4, ref('base.user_root')), (4, ref('base.user_admin'))]\"/>\n \u003c/record>\n\n \u003c!-- Multi-Company Rule (v18: uses allowed_company_ids) -->\n \u003crecord id=\"rule_equipment_asset_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Equipment Asset: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_equipment_asset\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### security/ir.model.access.csv\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_equipment_asset_user,equipment.asset.user,model_equipment_asset,group_equipment_user,1,1,1,0\naccess_equipment_asset_manager,equipment.asset.manager,model_equipment_asset,group_equipment_manager,1,1,1,1\n```\n\n### static/src/components/equipment_dashboard/equipment_dashboard.js\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class EquipmentDashboard extends Component {\n static template = \"equipment_tracking.EquipmentDashboard\";\n static props = {};\n\n setup() {\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n\n this.state = useState({\n stats: null,\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadStats();\n });\n }\n\n async loadStats() {\n try {\n const stats = await this.orm.call(\n \"equipment.asset\",\n \"_get_asset_statistics\",\n []\n );\n this.state.stats = stats;\n } finally {\n this.state.loading = false;\n }\n }\n\n async onViewAssets(status) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n name: \"Equipment Assets\",\n res_model: \"equipment.asset\",\n view_mode: \"tree,form\",\n domain: status ? [[\"status\", \"=\", status]] : [],\n });\n }\n}\n\nregistry.category(\"actions\").add(\"equipment_tracking.dashboard\", EquipmentDashboard);\n```\n\n### tests/test_equipment_asset.py\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo.tests import TransactionCase, tagged\nfrom odoo.exceptions import UserError, ValidationError\n\n\n@tagged('post_install', '-at_install')\nclass TestEquipmentAsset(TransactionCase):\n \"\"\"Test cases for Equipment Asset model.\"\"\"\n\n @classmethod\n def setUpClass(cls):\n super().setUpClass()\n cls.Asset = cls.env['equipment.asset']\n cls.employee = cls.env['hr.employee'].create({\n 'name': 'Test Employee',\n })\n\n def test_create_asset(self):\n \"\"\"Test basic asset creation.\"\"\"\n asset = self.Asset.create({\n 'name': 'Test Laptop',\n 'serial_number': 'SN001',\n })\n self.assertEqual(asset.status, 'active')\n self.assertTrue(asset.active)\n\n def test_asset_value_constraint(self):\n \"\"\"Test that negative values are rejected.\"\"\"\n with self.assertRaises(ValidationError):\n self.Asset.create({\n 'name': 'Test Asset',\n 'value': -100,\n })\n\n def test_retire_asset(self):\n \"\"\"Test asset retirement.\"\"\"\n asset = self.Asset.create({\n 'name': 'Test Asset',\n })\n asset.action_retire()\n self.assertEqual(asset.status, 'retired')\n self.assertFalse(asset.active)\n\n def test_unique_serial_number(self):\n \"\"\"Test serial number uniqueness per company.\"\"\"\n self.Asset.create({\n 'name': 'Asset 1',\n 'serial_number': 'SN001',\n })\n with self.assertRaises(Exception):\n self.Asset.create({\n 'name': 'Asset 2',\n 'serial_number': 'SN001',\n })\n```\n\n## Structured JSON Output\n\n```json\n{\n \"module_skeleton\": {\n \"name\": \"equipment_tracking\",\n \"version\": \"18.0.1.0.0\",\n \"odoo_version\": \"18.0\",\n \"file_count\": 15,\n \"files\": [\n \"__init__.py\",\n \"__manifest__.py\",\n \"models/__init__.py\",\n \"models/equipment_asset.py\",\n \"views/equipment_asset_views.xml\",\n \"views/menuitems.xml\",\n \"security/equipment_tracking_security.xml\",\n \"security/ir.model.access.csv\",\n \"data/equipment_tracking_data.xml\",\n \"demo/equipment_tracking_demo.xml\",\n \"static/src/components/equipment_dashboard/equipment_dashboard.js\",\n \"static/src/components/equipment_dashboard/equipment_dashboard.xml\",\n \"static/src/scss/equipment_tracking.scss\",\n \"tests/__init__.py\",\n \"tests/test_equipment_asset.py\"\n ]\n },\n \"version_compliance\": {\n \"version\": \"18.0\",\n \"patterns_used\": [\n \"_check_company_auto = True\",\n \"@api.model_create_multi\",\n \"check_company=True on fields\",\n \"SQL() builder for raw SQL\",\n \"Type hints on methods\",\n \"Direct invisible/readonly in views\",\n \"allowed_company_ids in record rules\",\n \"OWL 2.x components\"\n ]\n },\n \"dependencies\": [\"base\", \"mail\", \"hr\", \"maintenance\"],\n \"security_groups\": [\n \"group_equipment_user\",\n \"group_equipment_manager\"\n ],\n \"github_verified\": true,\n \"generation_date\": \"2026-01-16\"\n}\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":25467,"content_sha256":"dfc99eb6e1315204b09cce90335b00bb884aa38dba8dbb1f36e7eb5de33e5ba1"},{"filename":"skills/multi-company-patterns.md","content":"# Multi-Company and Multi-Currency Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MULTI-COMPANY & MULTI-CURRENCY PATTERNS ║\n║ Company-aware models, cross-company rules, and currency handling ║\n║ Use for enterprise deployments with multiple business units ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Multi-Company Model Setup\n\n### Basic Company-Aware Model (v18+)\n```python\nfrom odoo import api, fields, models\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _check_company_auto = True # v18+ automatic company checking\n\n name = fields.Char(string='Name', required=True)\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n required=True,\n default=lambda self: self.env.company,\n index=True,\n )\n\n # Related records with company check\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n check_company=True, # Enforces same company\n )\n warehouse_id = fields.Many2one(\n comodel_name='stock.warehouse',\n string='Warehouse',\n check_company=True,\n )\n```\n\n### v17 and Earlier Pattern\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n required=True,\n default=lambda self: self.env.company,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n )\n\n @api.constrains('partner_id', 'company_id')\n def _check_company(self):\n for record in self:\n if record.partner_id.company_id and \\\n record.partner_id.company_id != record.company_id:\n raise ValidationError(\n \"Partner company must match record company.\"\n )\n```\n\n---\n\n## Company-Dependent Fields\n\n### Company-Specific Values\n```python\nclass ProductTemplate(models.Model):\n _inherit = 'product.template'\n\n # Different value per company\n x_internal_code = fields.Char(\n string='Internal Code',\n company_dependent=True,\n )\n x_local_price = fields.Float(\n string='Local Price',\n company_dependent=True,\n digits='Product Price',\n )\n x_local_supplier_id = fields.Many2one(\n comodel_name='res.partner',\n string='Local Supplier',\n company_dependent=True,\n )\n```\n\n### Accessing Company-Dependent Values\n```python\ndef get_local_data(self):\n \"\"\"Get company-specific values.\"\"\"\n # Automatically returns value for current company\n code = self.x_internal_code\n\n # Get for specific company\n other_company = self.env['res.company'].browse(2)\n code_other = self.with_company(other_company).x_internal_code\n```\n\n---\n\n## Record Rules for Multi-Company\n\n### Basic Company Rule\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Users see only their company's records -->\n \u003crecord id=\"my_model_company_rule\" model=\"ir.rule\">\n \u003cfield name=\"name\">My Model: Company Rule\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Multi-Company Access Patterns\n```xml\n\u003c!-- Strict: Only own company -->\n\u003cfield name=\"domain_force\">[('company_id', '=', company_id)]\u003c/field>\n\n\u003c!-- Flexible: Own companies or no company -->\n\u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n]\u003c/field>\n\n\u003c!-- Child companies included -->\n\u003cfield name=\"domain_force\">[\n ('company_id', 'child_of', company_id)\n]\u003c/field>\n```\n\n---\n\n## Cross-Company Operations\n\n### Switch Company Context\n```python\ndef action_process_all_companies(self):\n \"\"\"Process records across all user's companies.\"\"\"\n for company in self.env.user.company_ids:\n records = self.with_company(company).search([\n ('state', '=', 'pending'),\n ('company_id', '=', company.id),\n ])\n for record in records:\n record._process()\n```\n\n### Create in Specific Company\n```python\ndef action_create_in_company(self, company_id: int) -> 'my.model':\n \"\"\"Create record in specific company.\"\"\"\n company = self.env['res.company'].browse(company_id)\n\n return self.with_company(company).create({\n 'name': 'New Record',\n 'company_id': company.id,\n })\n```\n\n### Inter-Company Transactions\n```python\nclass InterCompanyTransfer(models.Model):\n _name = 'inter.company.transfer'\n _description = 'Inter-Company Transfer'\n\n source_company_id = fields.Many2one(\n comodel_name='res.company',\n string='Source Company',\n required=True,\n )\n dest_company_id = fields.Many2one(\n comodel_name='res.company',\n string='Destination Company',\n required=True,\n )\n\n def action_transfer(self):\n \"\"\"Execute inter-company transfer.\"\"\"\n self.ensure_one()\n\n # Create in source company\n source_record = self.with_company(self.source_company_id).sudo().create({\n 'name': f'Transfer to {self.dest_company_id.name}',\n 'type': 'outgoing',\n 'company_id': self.source_company_id.id,\n })\n\n # Create in destination company\n dest_record = self.with_company(self.dest_company_id).sudo().create({\n 'name': f'Transfer from {self.source_company_id.name}',\n 'type': 'incoming',\n 'company_id': self.dest_company_id.id,\n 'source_ref': source_record.id,\n })\n\n return source_record, dest_record\n```\n\n---\n\n## Multi-Currency Support\n\n### Currency Fields\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n\n company_id = fields.Many2one(\n comodel_name='res.company',\n default=lambda self: self.env.company,\n )\n currency_id = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n default=lambda self: self.env.company.currency_id,\n required=True,\n )\n\n # Monetary fields\n amount = fields.Monetary(\n string='Amount',\n currency_field='currency_id',\n )\n amount_tax = fields.Monetary(\n string='Tax Amount',\n currency_field='currency_id',\n )\n amount_total = fields.Monetary(\n string='Total',\n currency_field='currency_id',\n compute='_compute_amount_total',\n store=True,\n )\n\n # Company currency equivalent\n company_currency_id = fields.Many2one(\n related='company_id.currency_id',\n string='Company Currency',\n )\n amount_company_currency = fields.Monetary(\n string='Amount (Company Currency)',\n currency_field='company_currency_id',\n compute='_compute_amount_company_currency',\n store=True,\n )\n\n @api.depends('amount', 'amount_tax')\n def _compute_amount_total(self):\n for record in self:\n record.amount_total = record.amount + record.amount_tax\n\n @api.depends('amount_total', 'currency_id', 'company_id', 'date')\n def _compute_amount_company_currency(self):\n for record in self:\n if record.currency_id != record.company_currency_id:\n record.amount_company_currency = record.currency_id._convert(\n record.amount_total,\n record.company_currency_id,\n record.company_id,\n record.date or fields.Date.today(),\n )\n else:\n record.amount_company_currency = record.amount_total\n```\n\n### Currency Conversion\n```python\ndef convert_to_currency(self, amount: float, target_currency) -> float:\n \"\"\"Convert amount to target currency.\"\"\"\n if self.currency_id == target_currency:\n return amount\n\n return self.currency_id._convert(\n amount,\n target_currency,\n self.company_id,\n self.date or fields.Date.today(),\n )\n\ndef get_rate(self) -> float:\n \"\"\"Get exchange rate to company currency.\"\"\"\n return self.currency_id._get_conversion_rate(\n self.currency_id,\n self.company_currency_id,\n self.company_id,\n self.date or fields.Date.today(),\n )\n```\n\n### Multi-Currency Reporting\n```python\ndef _get_amounts_by_currency(self) -> dict:\n \"\"\"Group amounts by currency for reporting.\"\"\"\n result = {}\n for record in self:\n currency = record.currency_id\n if currency not in result:\n result[currency] = {\n 'amount': 0.0,\n 'amount_company': 0.0,\n }\n result[currency]['amount'] += record.amount_total\n result[currency]['amount_company'] += record.amount_company_currency\n return result\n```\n\n---\n\n## Views for Multi-Company\n\n### Form View with Company\n```xml\n\u003cform string=\"My Model\">\n \u003csheet>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"\n context=\"{'default_company_id': company_id}\"\n domain=\"[('company_id', 'in', [company_id, False])]\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\"\n groups=\"base.group_multi_company\"\n options=\"{'no_create': True}\"/>\n \u003cfield name=\"currency_id\"\n groups=\"base.group_multi_currency\"/>\n \u003c/group>\n \u003c/group>\n \u003cgroup string=\"Amounts\">\n \u003cfield name=\"amount\"/>\n \u003cfield name=\"amount_total\"/>\n \u003cfield name=\"amount_company_currency\"\n groups=\"base.group_multi_currency\"\n invisible=\"currency_id == company_currency_id\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Search View with Company Filter\n```xml\n\u003csearch string=\"My Model\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfilter string=\"My Company\" name=\"my_company\"\n domain=\"[('company_id', '=', company_id)]\"/>\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter string=\"Company\" name=\"group_company\"\n context=\"{'group_by': 'company_id'}\"\n groups=\"base.group_multi_company\"/>\n \u003cfilter string=\"Currency\" name=\"group_currency\"\n context=\"{'group_by': 'currency_id'}\"\n groups=\"base.group_multi_currency\"/>\n \u003c/group>\n\u003c/search>\n```\n\n---\n\n## Scheduled Actions (Multi-Company)\n\n### Process Each Company\n```python\[email protected]\ndef _cron_process_all_companies(self) -> None:\n \"\"\"Cron that processes each company separately.\"\"\"\n companies = self.env['res.company'].search([])\n\n for company in companies:\n self.with_company(company)._process_company_records()\n\ndef _process_company_records(self) -> None:\n \"\"\"Process records for current company context.\"\"\"\n records = self.search([\n ('company_id', '=', self.env.company.id),\n ('state', '=', 'pending'),\n ])\n\n for record in records:\n try:\n record._do_process()\n except Exception as e:\n _logger.error(f\"Error processing {record.id}: {e}\")\n```\n\n---\n\n## Best Practices\n\n### 1. Always Include company_id\n```python\n# Good - explicit company\ncompany_id = fields.Many2one(\n 'res.company',\n required=True,\n default=lambda self: self.env.company,\n)\n\n# Bad - no company field on business model\n```\n\n### 2. Use check_company (v18+)\n```python\n# Good - automatic validation\npartner_id = fields.Many2one('res.partner', check_company=True)\n\n# Manual (older versions)\[email protected]('partner_id', 'company_id')\ndef _check_company(self):\n ...\n```\n\n### 3. Use with_company() for Context\n```python\n# Good - explicit company context\nrecord.with_company(company)._process()\n\n# Avoid - changing env.company directly\n```\n\n### 4. Handle Shared Records\n```python\n# Allow records without company (shared)\ndomain = [\n '|',\n ('company_id', '=', False),\n ('company_id', '=', self.env.company.id),\n]\n```\n\n### 5. Currency Conversions\n```python\n# Always specify date for conversions\nconverted = currency._convert(\n amount,\n target_currency,\n company,\n date, # Required for correct rate\n)\n```\n\n---\n\n## Version Differences\n\n| Feature | v14-16 | v17 | v18+ |\n|---------|--------|-----|------|\n| Company check | Manual `@api.constrains` | Manual | `_check_company_auto = True` |\n| Field validation | `domain=` | `domain=` | `check_company=True` |\n| Company switch | `with_context(force_company=)` | `with_company()` | `with_company()` |\n| Multi-company views | `groups=\"base.group_multi_company\"` | Same | Same |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13322,"content_sha256":"35e20be7fc431d062c2894ab0d7d2bf66797618b275864ffad3e1bf5bfe22762"},{"filename":"skills/odoo-editions.md","content":"# Odoo Editions: Community vs Enterprise\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO EDITIONS REFERENCE ║\n║ Understanding differences between Community and Enterprise editions ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Edition Overview\n\n| Aspect | Community | Enterprise |\n|--------|-----------|------------|\n| License | LGPL-3 | Odoo Enterprise License |\n| Source | Open source | Proprietary |\n| Repository | github.com/odoo/odoo | github.com/odoo/enterprise |\n| Cost | Free | Subscription-based |\n| Apps | Core apps | Core + Enterprise apps |\n\n## GitHub Repositories\n\n### Community Edition\n```\nhttps://github.com/odoo/odoo\n```\nContains all core functionality and Community apps.\n\n### Enterprise Edition\n```\nhttps://github.com/odoo/enterprise\n```\nRequires Enterprise license to access. Contains additional apps and features.\n\n## App Availability by Edition\n\n### Community Apps (Free)\n| Category | Apps |\n|----------|------|\n| Sales | CRM, Sales, Point of Sale |\n| Inventory | Inventory, Purchase |\n| Accounting | Invoicing (basic) |\n| Website | Website Builder, eCommerce (basic) |\n| HR | Employees, Recruitment, Time Off |\n| Manufacturing | MRP (basic) |\n| Project | Project, Timesheet |\n| Marketing | Email Marketing, Events |\n\n### Enterprise-Only Apps\n| Category | Apps |\n|----------|------|\n| Accounting | Full Accounting, Assets, Consolidation |\n| Sales | Subscriptions, Rental |\n| HR | Appraisals, Referrals, Payroll |\n| Manufacturing | PLM, Quality, MRP II |\n| Inventory | Barcode, IoT |\n| Field Service | Field Service, Planning |\n| Marketing | Marketing Automation, Social Marketing |\n| Studio | Odoo Studio (customization tool) |\n| Documents | Document Management |\n| Sign | Electronic Signatures |\n| Helpdesk | Helpdesk |\n\n## Development Considerations\n\n### Developing for Community Edition\n\n```python\n# Your module depends only on Community apps\n{\n 'depends': ['base', 'mail', 'sale', 'stock'],\n # All these are Community apps\n}\n```\n\nYour module can be:\n- Open source (LGPL-3)\n- Sold commercially\n- Used by anyone\n\n### Developing for Enterprise Edition\n\n```python\n# If your module depends on Enterprise apps\n{\n 'depends': ['helpdesk', 'planning', 'documents'],\n # These are Enterprise-only apps\n}\n```\n\nYour module:\n- Requires Enterprise license to use\n- Cannot be open source (incompatible license)\n- Can only be used by Enterprise customers\n\n### Hybrid Modules (Both Editions)\n\n```python\n# Main module (Community-compatible)\n{\n 'name': 'My Module',\n 'depends': ['base', 'sale'],\n}\n\n# Optional Enterprise extension\n# my_module_enterprise/__manifest__.py\n{\n 'name': 'My Module - Enterprise',\n 'depends': ['my_module', 'helpdesk'],\n 'auto_install': True, # Auto-install if helpdesk is available\n}\n```\n\n## Feature Detection Pattern\n\n```python\n# Check if Enterprise module is installed\ndef _has_enterprise_feature(self):\n return 'helpdesk' in self.env.registry._init_modules\n\n# Conditional import\ntry:\n from odoo.addons.helpdesk.models import helpdesk_ticket\n HAS_HELPDESK = True\nexcept ImportError:\n HAS_HELPDESK = False\n\n# Feature flag in model\nclass MyModel(models.Model):\n _name = 'my.model'\n\n # Only add field if Enterprise module available\n helpdesk_ticket_id = fields.Many2one(\n 'helpdesk.ticket',\n string='Helpdesk Ticket',\n ) if 'helpdesk' in dir() else None\n```\n\n## Views with Edition-Specific Elements\n\n```xml\n\u003c!-- Use groups to hide Enterprise features -->\n\u003cfield name=\"helpdesk_ticket_id\"\n groups=\"helpdesk.group_helpdesk_user\"/>\n\n\u003c!-- The field will be hidden if helpdesk module is not installed -->\n```\n\n## Key Technical Differences\n\n### Studio Module (Enterprise)\n- Allows non-developers to customize\n- Creates custom models and fields\n- Generates XML views automatically\n- Stored in `ir.model.data` with special prefixes\n\n### Barcode Module (Enterprise)\n- Hardware barcode scanner support\n- Inventory operations via barcode\n- Dedicated mobile interface\n\n### IoT Module (Enterprise)\n- Internet of Things integration\n- Hardware device support\n- Real-time data collection\n\n## Module Licensing\n\n### Community-Compatible (LGPL-3)\n```python\n{\n 'license': 'LGPL-3',\n 'depends': ['base', 'sale', 'stock'], # Community only\n}\n```\n\n### Enterprise-Dependent (OPL-1)\n```python\n{\n 'license': 'OPL-1', # Odoo Proprietary License\n 'depends': ['helpdesk'], # Enterprise app\n}\n```\n\n## Best Practices for Edition Compatibility\n\n### 1. Prefer Community Dependencies\nIf possible, depend only on Community apps for wider compatibility.\n\n### 2. Use Conditional Features\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def _get_available_integrations(self):\n integrations = ['email', 'sms']\n if self.env['ir.module.module'].search([\n ('name', '=', 'helpdesk'),\n ('state', '=', 'installed')\n ]):\n integrations.append('helpdesk')\n return integrations\n```\n\n### 3. Separate Enterprise Extensions\n```\nmy_module/ # Community core\nmy_module_helpdesk/ # Enterprise integration\nmy_module_planning/ # Enterprise integration\n```\n\n### 4. Document Edition Requirements\n```python\n{\n 'description': \"\"\"\nMy Module\n=========\n\n**Community Features:**\n- Feature A\n- Feature B\n\n**Enterprise Features (requires Enterprise apps):**\n- Helpdesk integration (requires helpdesk)\n- Planning integration (requires planning)\n \"\"\",\n}\n```\n\n## AI Agent Instructions\n\nWhen generating modules:\n\n1. **ASK** which edition the user targets\n2. **DEFAULT** to Community-compatible unless specified\n3. **WARN** if depending on Enterprise-only apps\n4. **SEPARATE** Enterprise features into optional modules\n5. **DOCUMENT** edition requirements clearly\n6. **USE** appropriate license (LGPL-3 or OPL-1)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6244,"content_sha256":"42985a8223c7d03bc9dd4f4ee12439b18a2845280c9b09f40eb1303fba13e2e1"},{"filename":"skills/odoo-model-patterns-14-15.md","content":"# Odoo Model Patterns Migration: 14.0 → 15.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODEL PATTERNS MIGRATION: 14.0 → 15.0 ║\n║ @api.multi REMOVED, tracking=True standardized ║\n║ VERIFY: https://github.com/odoo/odoo/tree/15.0/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes\n\n### 1. @api.multi REMOVED\n\nThis is the **most critical** breaking change. All methods using `@api.multi` will fail in v15.\n\n```python\n# v14 (BREAKS IN v15):\nfrom odoo import models, api\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @api.multi\n def action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n return True\n\n @api.multi\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n\n# v15 (REQUIRED - remove @api.multi):\nfrom odoo import models\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n return True\n\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n```\n\n### 2. track_visibility → tracking\n\n```python\n# v14 (deprecated, still works):\nname = fields.Char(track_visibility='always')\nstate = fields.Selection([...], track_visibility='onchange')\n\n# v15 (RECOMMENDED):\nname = fields.Char(tracking=True)\nstate = fields.Selection([...], tracking=True)\n```\n\n### 3. super() Syntax (Best Practice)\n\n```python\n# v14 (Python 2 style - works but outdated):\nreturn super(MyModel, self).create(vals)\n\n# v15 (Python 3 style - RECOMMENDED):\nreturn super().create(vals)\n```\n\n## Field Changes\n\n### Tracking Fields\n\n| v14 Syntax | v15 Syntax |\n|------------|------------|\n| `track_visibility='always'` | `tracking=True` |\n| `track_visibility='onchange'` | `tracking=True` |\n| `track_visibility=True` | `tracking=True` |\n\n```python\n# v14:\nclass MyModel(models.Model):\n _inherit = 'mail.thread'\n\n name = fields.Char(track_visibility='always')\n state = fields.Selection([\n ('draft', 'Draft'),\n ('done', 'Done'),\n ], track_visibility='onchange')\n partner_id = fields.Many2one('res.partner', track_visibility='onchange')\n amount = fields.Float(track_visibility='always')\n\n# v15:\nclass MyModel(models.Model):\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(tracking=True)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('done', 'Done'),\n ], tracking=True)\n partner_id = fields.Many2one('res.partner', tracking=True)\n amount = fields.Float(tracking=True)\n```\n\n## CRUD Methods Migration\n\n### Create Method\n\n```python\n# v14:\[email protected]\ndef create(self, vals):\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super(MyModel, self).create(vals)\n\n# v15 (single record - still valid):\[email protected]\ndef create(self, vals):\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals)\n\n# v15 (batch - RECOMMENDED for performance):\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n```\n\n### Write Method\n\n```python\n# v14:\[email protected]\ndef write(self, vals):\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Add at least one line.\"))\n return super(MyModel, self).write(vals)\n\n# v15:\ndef write(self, vals):\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Add at least one line.\"))\n return super().write(vals)\n```\n\n### Unlink Method\n\n```python\n# v14:\[email protected]\ndef unlink(self):\n for record in self:\n if record.state == 'done':\n raise UserError(_(\"Cannot delete done records.\"))\n return super(MyModel, self).unlink()\n\n# v15:\ndef unlink(self):\n for record in self:\n if record.state == 'done':\n raise UserError(_(\"Cannot delete done records.\"))\n return super().unlink()\n```\n\n### Copy Method\n\n```python\n# v14:\[email protected]\ndef copy(self, default=None):\n self.ensure_one()\n default = dict(default or {})\n default['name'] = _(\"%s (copy)\") % self.name\n return super(MyModel, self).copy(default)\n\n# v15:\ndef copy(self, default=None):\n self.ensure_one()\n default = dict(default or {})\n default['name'] = _(\"%s (copy)\") % self.name\n return super().copy(default)\n```\n\n## Action Methods Migration\n\n```python\n# v14:\[email protected]\ndef action_confirm(self):\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n record.state = 'confirmed'\n return True\n\[email protected]\ndef action_view_partner(self):\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Partner'),\n 'res_model': 'res.partner',\n 'res_id': self.partner_id.id,\n 'view_mode': 'form',\n }\n\n# v15:\ndef action_confirm(self):\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n record.state = 'confirmed'\n return True\n\ndef action_view_partner(self):\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Partner'),\n 'res_model': 'res.partner',\n 'res_id': self.partner_id.id,\n 'view_mode': 'form',\n }\n```\n\n## Computed Fields (No Change)\n\nComputed fields work the same way in both versions:\n\n```python\n# v14 and v15 (same):\ntotal = fields.Float(compute='_compute_total', store=True)\n\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n```\n\n## Constraints (No Change)\n\n```python\n# v14 and v15 (same):\[email protected]('date_start', 'date_end')\ndef _check_dates(self):\n for record in self:\n if record.date_start and record.date_end:\n if record.date_start > record.date_end:\n raise ValidationError(_(\"End date must be after start date.\"))\n```\n\n## Migration Script\n\nUse this script to find and fix v14 patterns:\n\n```bash\n#!/bin/bash\n# find_v14_patterns.sh\n\necho \"=== Finding @api.multi decorators ===\"\ngrep -rn \"@api.multi\" --include=\"*.py\"\n\necho \"\"\necho \"=== Finding track_visibility ===\"\ngrep -rn \"track_visibility\" --include=\"*.py\"\n\necho \"\"\necho \"=== Finding old super() patterns ===\"\ngrep -rn \"super(.*self)\" --include=\"*.py\"\n```\n\n## Search and Replace Patterns\n\n| Find | Replace With |\n|------|--------------|\n| `@api.multi\\n def` | `def` |\n| `track_visibility='always'` | `tracking=True` |\n| `track_visibility='onchange'` | `tracking=True` |\n| `track_visibility=True` | `tracking=True` |\n| `super(ClassName, self)` | `super()` |\n\n## Migration Checklist\n\n- [ ] Remove ALL `@api.multi` decorators\n- [ ] Replace ALL `track_visibility` with `tracking=True`\n- [ ] Update `super()` calls to Python 3 style\n- [ ] Add `mail.activity.mixin` where appropriate\n- [ ] Consider `@api.model_create_multi` for batch creates\n- [ ] Test all action methods\n- [ ] Test all CRUD operations\n- [ ] Verify mail tracking works\n\n## Common Errors After Migration\n\n### AttributeError: 'api' object has no attribute 'multi'\n**Cause**: @api.multi still in code\n**Solution**: Remove the decorator, keep the method\n\n### DeprecationWarning: track_visibility is deprecated\n**Cause**: track_visibility used\n**Solution**: Replace with tracking=True\n\n### TypeError: create() got multiple values for argument 'vals'\n**Cause**: Mixing @api.model and @api.model_create_multi\n**Solution**: Choose one pattern consistently\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8332,"content_sha256":"1b2281b506e0de1a6eb3d6306e99b36a624bfbab6c9a91fd90a4af2ec210970b"},{"filename":"skills/odoo-model-patterns-14.md","content":"# Odoo 14.0 Model Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 14.0 ORM PATTERNS ║\n║ Last version with @api.multi and track_visibility ║\n║ VERIFY: https://github.com/odoo/odoo/tree/14.0/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Patterns\n\n### Key Characteristics\n| Feature | Odoo 14.0 Pattern |\n|---------|-------------------|\n| Multi-record decorator | `@api.multi` (deprecated but works) |\n| Change tracking | `track_visibility='onchange'` |\n| X2many commands | Tuple syntax `(0, 0, vals)` |\n| attrs in views | Full support |\n| Python version | 3.6+ |\n\n## Model Definition\n\n```python\nfrom odoo import models, fields, api, _\nfrom odoo.exceptions import UserError, ValidationError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'sequence, name'\n _rec_name = 'name'\n\n # Basic fields\n name = fields.Char(\n string='Name',\n required=True,\n index=True,\n tracking=True, # New way (works in 14.0)\n )\n\n # Old tracking syntax (deprecated but works)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', track_visibility='onchange') # Old way\n\n sequence = fields.Integer(default=10)\n active = fields.Boolean(default=True)\n\n # Relational fields\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n required=True,\n default=lambda self: self.env.company,\n )\n\n partner_id = fields.Many2one(\n 'res.partner',\n string='Partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n )\n\n line_ids = fields.One2many(\n 'my.model.line',\n 'model_id',\n string='Lines',\n )\n\n tag_ids = fields.Many2many(\n 'my.model.tag',\n 'my_model_tag_rel',\n 'model_id',\n 'tag_id',\n string='Tags',\n )\n```\n\n## @api.multi Pattern (Deprecated)\n\n```python\n# v14: @api.multi still works but is deprecated\n# It's the implicit default for methods\n\[email protected] # Deprecated - remove this decorator\ndef action_confirm(self):\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n record.state = 'confirmed'\n return True\n\n# Correct v14 pattern (no decorator needed)\ndef action_confirm(self):\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n record.state = 'confirmed'\n return True\n```\n\n## X2many Command Syntax\n\n```python\n# v14: Tuple syntax (still works in all versions)\ndef create_with_lines(self):\n self.env['my.model'].create({\n 'name': 'Test',\n 'line_ids': [\n (0, 0, {'name': 'Line 1', 'quantity': 1}), # Create\n (1, line_id, {'quantity': 2}), # Update\n (2, line_id, 0), # Delete\n (3, line_id, 0), # Unlink\n (4, line_id, 0), # Link\n (5, 0, 0), # Clear all\n (6, 0, [id1, id2]), # Replace all\n ],\n })\n```\n\n### Command Reference\n| Code | Description | Arguments |\n|------|-------------|-----------|\n| (0, 0, vals) | Create new record | vals dict |\n| (1, id, vals) | Update existing | id, vals dict |\n| (2, id, 0) | Delete record | id |\n| (3, id, 0) | Unlink (M2M only) | id |\n| (4, id, 0) | Link existing | id |\n| (5, 0, 0) | Clear all | none |\n| (6, 0, ids) | Replace all | list of ids |\n\n## CRUD Methods\n\n```python\[email protected]\ndef create(self, vals):\n \"\"\"Override create - note: not create_multi yet\"\"\"\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super(MyModel, self).create(vals)\n\ndef write(self, vals):\n \"\"\"Override write\"\"\"\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Cannot complete without lines.\"))\n return super(MyModel, self).write(vals)\n\ndef unlink(self):\n \"\"\"Override unlink\"\"\"\n for record in self:\n if record.state == 'done':\n raise UserError(_(\"Cannot delete completed records.\"))\n return super(MyModel, self).unlink()\n\ndef copy(self, default=None):\n \"\"\"Override copy - single record\"\"\"\n self.ensure_one()\n default = dict(default or {})\n default['name'] = _(\"%s (copy)\") % self.name\n return super(MyModel, self).copy(default)\n```\n\n## Computed Fields\n\n```python\n# v14 computed field patterns\ntotal = fields.Float(\n string='Total',\n compute='_compute_total',\n store=True,\n)\n\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n\n# Inverse method\npartner_name = fields.Char(\n compute='_compute_partner_name',\n inverse='_inverse_partner_name',\n store=False,\n)\n\ndef _compute_partner_name(self):\n for record in self:\n record.partner_name = record.partner_id.name or ''\n\ndef _inverse_partner_name(self):\n for record in self:\n if record.partner_id:\n record.partner_id.name = record.partner_name\n```\n\n## Constraints\n\n```python\n# SQL Constraint\n_sql_constraints = [\n ('code_uniq', 'unique(code, company_id)',\n 'Code must be unique per company!'),\n ('positive_amount', 'CHECK(amount >= 0)',\n 'Amount must be positive!'),\n]\n\n# Python Constraint\[email protected]('date_start', 'date_end')\ndef _check_dates(self):\n for record in self:\n if record.date_start and record.date_end:\n if record.date_start > record.date_end:\n raise ValidationError(\n _(\"End date must be after start date.\")\n )\n```\n\n## Search and Domain\n\n```python\ndef action_find_partners(self):\n # Domain operators\n domain = [\n ('customer_rank', '>', 0),\n ('company_id', 'in', [self.company_id.id, False]),\n '|',\n ('name', 'ilike', 'test'),\n ('email', 'ilike', 'test'),\n ]\n\n partners = self.env['res.partner'].search(\n domain,\n limit=100,\n order='name',\n )\n return partners\n\n# search_read for efficiency\ndef get_partner_data(self):\n return self.env['res.partner'].search_read(\n [('customer_rank', '>', 0)],\n ['name', 'email', 'phone'],\n limit=50,\n )\n```\n\n## XML Views with attrs\n\n```xml\n\u003c!-- v14: attrs fully supported -->\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" type=\"object\" string=\"Done\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"\n attrs=\"{'required': [('state', '=', 'confirmed')]}\"/>\n \u003cfield name=\"amount\"\n attrs=\"{'readonly': [('state', '=', 'done')]}\"/>\n \u003c/group>\n\n \u003cnotebook>\n \u003cpage string=\"Lines\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price\"\n attrs=\"{'readonly': [('parent.state', '=', 'done')]}\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n\u003c/form>\n```\n\n## Security Configuration\n\n```python\n# ir.model.access.csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_my_model_user,my.model.user,model_my_model,base.group_user,1,1,1,0\naccess_my_model_manager,my.model.manager,model_my_model,my_module.group_manager,1,1,1,1\n```\n\n```xml\n\u003c!-- Record Rules -->\n\u003crecord id=\"rule_my_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">My Model: Multi-company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n## Common Patterns\n\n### Action Return\n```python\ndef action_view_partner(self):\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Partner'),\n 'res_model': 'res.partner',\n 'res_id': self.partner_id.id,\n 'view_mode': 'form',\n 'target': 'current',\n }\n\ndef action_view_lines(self):\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Lines'),\n 'res_model': 'my.model.line',\n 'view_mode': 'tree,form',\n 'domain': [('model_id', '=', self.id)],\n 'context': {'default_model_id': self.id},\n }\n```\n\n### Wizard Pattern\n```python\nclass MyWizard(models.TransientModel):\n _name = 'my.wizard'\n _description = 'My Wizard'\n\n partner_id = fields.Many2one('res.partner', required=True)\n note = fields.Text()\n\n def action_confirm(self):\n self.ensure_one()\n active_ids = self.env.context.get('active_ids', [])\n records = self.env['my.model'].browse(active_ids)\n for record in records:\n record.partner_id = self.partner_id\n return {'type': 'ir.actions.act_window_close'}\n```\n\n## Migration Notes to v15\n\nWhen upgrading from v14 to v15:\n\n1. **Remove @api.multi** - No longer needed\n2. **Replace track_visibility** - Use `tracking=True` instead\n3. **Update create()** - Consider `@api.model_create_multi` (optional in v15)\n\n```python\n# v14\[email protected]\ndef action_test(self):\n pass\n\nstate = fields.Selection(..., track_visibility='onchange')\n\n# v15\ndef action_test(self):\n pass\n\nstate = fields.Selection(..., tracking=True)\n```\n\n## Important v14 Notes\n\n1. **@api.multi is implicit** - Don't use it explicitly\n2. **track_visibility works** - But tracking=True is preferred\n3. **Tuple commands work** - Standard x2many syntax\n4. **attrs fully supported** - Use for conditional visibility\n5. **Python 3.6+** - f-strings and type hints available\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10922,"content_sha256":"0afb091027592cc62e816ebb4bf88e1030e4040a07613d29d45acbc8a51d9330"},{"filename":"skills/odoo-model-patterns-15-16.md","content":"# Odoo Model Patterns Migration: 15.0 → 16.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODEL PATTERNS MIGRATION: 15.0 → 16.0 ║\n║ Command class introduced, attrs deprecated (still works) ║\n║ VERIFY: https://github.com/odoo/odoo/tree/16.0/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview\n\nMigration from v15 to v16 is **non-breaking** for model code. The main changes are:\n- `Command` class for x2many operations (recommended)\n- `attrs` deprecation in views (start migrating)\n- `@api.model_create_multi` recommended\n\n## New: Command Class\n\nThe `Command` class provides a cleaner API for x2many field operations.\n\n### Import\n\n```python\nfrom odoo.fields import Command\n```\n\n### Command Methods\n\n| Command | Tuple Equivalent | Description |\n|---------|------------------|-------------|\n| `Command.create(vals)` | `(0, 0, vals)` | Create new record |\n| `Command.update(id, vals)` | `(1, id, vals)` | Update existing |\n| `Command.delete(id)` | `(2, id, 0)` | Delete record |\n| `Command.unlink(id)` | `(3, id, 0)` | Unlink (M2M only) |\n| `Command.link(id)` | `(4, id, 0)` | Link existing |\n| `Command.clear()` | `(5, 0, 0)` | Clear all |\n| `Command.set(ids)` | `(6, 0, ids)` | Replace all |\n\n### Migration Examples\n\n```python\n# v15 Tuple syntax (still works in v16):\ndef create_with_lines(self):\n return self.env['sale.order'].create({\n 'partner_id': partner.id,\n 'order_line': [\n (0, 0, {'product_id': product1.id, 'product_uom_qty': 1}),\n (0, 0, {'product_id': product2.id, 'product_uom_qty': 2}),\n ],\n })\n\ndef update_lines(self):\n self.write({\n 'order_line': [\n (1, line_id, {'product_uom_qty': 5}), # Update\n (0, 0, {'product_id': product.id}), # Create\n (2, old_line_id, 0), # Delete\n ],\n })\n\n# v16 Command class (RECOMMENDED):\nfrom odoo.fields import Command\n\ndef create_with_lines(self):\n return self.env['sale.order'].create({\n 'partner_id': partner.id,\n 'order_line': [\n Command.create({'product_id': product1.id, 'product_uom_qty': 1}),\n Command.create({'product_id': product2.id, 'product_uom_qty': 2}),\n ],\n })\n\ndef update_lines(self):\n self.write({\n 'order_line': [\n Command.update(line_id, {'product_uom_qty': 5}),\n Command.create({'product_id': product.id}),\n Command.delete(old_line_id),\n ],\n })\n```\n\n### Many2many Operations\n\n```python\n# v15:\ndef manage_tags_v15(self):\n # Link\n self.write({'tag_ids': [(4, tag.id, 0)]})\n # Unlink\n self.write({'tag_ids': [(3, tag.id, 0)]})\n # Replace\n self.write({'tag_ids': [(6, 0, [tag1.id, tag2.id])]})\n # Clear\n self.write({'tag_ids': [(5, 0, 0)]})\n\n# v16:\nfrom odoo.fields import Command\n\ndef manage_tags_v16(self):\n # Link\n self.write({'tag_ids': [Command.link(tag.id)]})\n # Unlink\n self.write({'tag_ids': [Command.unlink(tag.id)]})\n # Replace\n self.write({'tag_ids': [Command.set([tag1.id, tag2.id])]})\n # Clear\n self.write({'tag_ids': [Command.clear()]})\n```\n\n## Create Method: model_create_multi\n\nWhile `@api.model` still works, `@api.model_create_multi` is now **strongly recommended**.\n\n```python\n# v15 (still works in v16):\[email protected]\ndef create(self, vals):\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals)\n\n# v16 (RECOMMENDED - prepare for v17 where it's mandatory):\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n```\n\n### Benefits of model_create_multi\n\n1. **Better Performance**: Batch operations are more efficient\n2. **Future Compatibility**: Mandatory in v17\n3. **Cleaner API**: Consistent handling of single/batch creates\n\n## Field Indexing (Best Practice)\n\nv16 encourages more explicit indexing for better performance:\n\n```python\n# v15:\nname = fields.Char(required=True, index=True)\n\n# v16 (enhanced indexing options):\nname = fields.Char(required=True, index='trigram') # For LIKE searches\ncode = fields.Char(index=True) # Standard B-tree\nstate = fields.Selection([...], index=True) # For filtering\n```\n\n### Index Types\n\n| Type | Use Case |\n|------|----------|\n| `index=True` | Standard B-tree index |\n| `index='trigram'` | For ILIKE/pattern searches |\n| `index='btree_not_null'` | B-tree excluding NULL |\n\n## Complete Model Migration Example\n\n```python\n# v15 Model:\nfrom odoo import models, fields, api, _\nfrom odoo.exceptions import UserError\n\nclass SaleOrderLine(models.Model):\n _name = 'sale.order.line'\n\n order_id = fields.Many2one('sale.order', required=True)\n product_id = fields.Many2one('product.product', required=True)\n quantity = fields.Float(default=1.0)\n price_unit = fields.Float()\n subtotal = fields.Float(compute='_compute_subtotal', store=True)\n\n @api.model\n def create(self, vals):\n if not vals.get('price_unit') and vals.get('product_id'):\n product = self.env['product.product'].browse(vals['product_id'])\n vals['price_unit'] = product.list_price\n return super().create(vals)\n\n @api.depends('quantity', 'price_unit')\n def _compute_subtotal(self):\n for line in self:\n line.subtotal = line.quantity * line.price_unit\n\n\nclass SaleOrder(models.Model):\n _name = 'sale.order'\n\n name = fields.Char(required=True, index=True)\n partner_id = fields.Many2one('res.partner', required=True)\n line_ids = fields.One2many('sale.order.line', 'order_id')\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ], default='draft')\n\n def create_sample_lines(self, products):\n self.write({\n 'line_ids': [\n (0, 0, {'product_id': p.id, 'quantity': 1})\n for p in products\n ],\n })\n\n\n# v16 Model (RECOMMENDED):\nfrom odoo import models, fields, api, _\nfrom odoo.fields import Command\nfrom odoo.exceptions import UserError\n\nclass SaleOrderLine(models.Model):\n _name = 'sale.order.line'\n\n order_id = fields.Many2one('sale.order', required=True, index=True)\n product_id = fields.Many2one('product.product', required=True)\n quantity = fields.Float(default=1.0)\n price_unit = fields.Float()\n subtotal = fields.Float(compute='_compute_subtotal', store=True)\n\n @api.model_create_multi\n def create(self, vals_list):\n for vals in vals_list:\n if not vals.get('price_unit') and vals.get('product_id'):\n product = self.env['product.product'].browse(vals['product_id'])\n vals['price_unit'] = product.list_price\n return super().create(vals_list)\n\n @api.depends('quantity', 'price_unit')\n def _compute_subtotal(self):\n for line in self:\n line.subtotal = line.quantity * line.price_unit\n\n\nclass SaleOrder(models.Model):\n _name = 'sale.order'\n\n name = fields.Char(required=True, index='trigram') # For search\n partner_id = fields.Many2one('res.partner', required=True, index=True)\n line_ids = fields.One2many('sale.order.line', 'order_id')\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ], default='draft', index=True) # For filtering\n\n def create_sample_lines(self, products):\n self.write({\n 'line_ids': [\n Command.create({'product_id': p.id, 'quantity': 1})\n for p in products\n ],\n })\n```\n\n## View Changes (Start Migrating)\n\nWhile `attrs` still works in v16, start migrating to prepare for v17:\n\n```xml\n\u003c!-- v15 (works in v16 but deprecated): -->\n\u003cfield name=\"partner_id\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n\n\u003c!-- v16 (RECOMMENDED - required in v17): -->\n\u003cfield name=\"partner_id\"\n invisible=\"state == 'draft'\"/>\n```\n\n## Migration Checklist\n\n### Recommended (Non-Breaking)\n- [ ] Add `from odoo.fields import Command` to files using x2many\n- [ ] Replace tuple x2many syntax with Command class\n- [ ] Update `@api.model` create to `@api.model_create_multi`\n- [ ] Add `index=True` to frequently filtered fields\n- [ ] Consider `index='trigram'` for searchable text fields\n\n### Views (Start Now for v17)\n- [ ] Identify all `attrs=` usage in XML views\n- [ ] Start converting to `invisible=`, `readonly=`, `required=`\n- [ ] Test conditional visibility with new syntax\n\n## Search and Replace Patterns\n\n| Pattern | Replacement |\n|---------|-------------|\n| `(0, 0, vals)` | `Command.create(vals)` |\n| `(1, id, vals)` | `Command.update(id, vals)` |\n| `(2, id, 0)` | `Command.delete(id)` |\n| `(3, id, 0)` | `Command.unlink(id)` |\n| `(4, id, 0)` | `Command.link(id)` |\n| `(5, 0, 0)` | `Command.clear()` |\n| `(6, 0, ids)` | `Command.set(ids)` |\n\n## Testing\n\n1. **Test x2many operations** - Verify Command class works correctly\n2. **Test batch creates** - Ensure model_create_multi handles single and batch\n3. **Test view conditionals** - If migrating attrs, verify visibility logic\n4. **Performance testing** - Verify indexing improvements\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9733,"content_sha256":"1da24e1d62abb50ce1f41a114e1eb1ff26c821bc014f2538e674bd570c4bed20"},{"filename":"skills/odoo-model-patterns-15.md","content":"# Odoo 15.0 Model Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 15.0 ORM PATTERNS ║\n║ @api.multi removed, tracking=True standardized, OWL 1.x introduced ║\n║ VERIFY: https://github.com/odoo/odoo/tree/15.0/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Patterns\n\n### Key Characteristics\n| Feature | Odoo 15.0 Pattern |\n|---------|-------------------|\n| Multi-record decorator | REMOVED - methods iterate by default |\n| Change tracking | `tracking=True` (track_visibility deprecated) |\n| X2many commands | Tuple syntax `(0, 0, vals)` |\n| attrs in views | Full support |\n| OWL | Version 1.x introduced |\n| Python version | 3.7+ |\n\n## Breaking Changes from v14\n\n```python\n# REMOVED in v15 - @api.multi\n# v14 (deprecated):\[email protected]\ndef action_test(self):\n pass\n\n# v15 (correct):\ndef action_test(self):\n for record in self:\n pass\n\n# DEPRECATED in v15 - track_visibility\n# v14:\nstate = fields.Selection(..., track_visibility='onchange')\n\n# v15:\nstate = fields.Selection(..., tracking=True)\n```\n\n## Model Definition\n\n```python\nfrom odoo import models, fields, api, _\nfrom odoo.exceptions import UserError, ValidationError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'sequence, name'\n\n # Basic fields with tracking\n name = fields.Char(\n string='Name',\n required=True,\n index=True,\n tracking=True, # v15 standard\n )\n\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', tracking=True) # Use tracking, not track_visibility\n\n sequence = fields.Integer(default=10)\n active = fields.Boolean(default=True)\n\n # Date fields\n date = fields.Date(default=fields.Date.context_today)\n datetime = fields.Datetime(default=fields.Datetime.now)\n\n # Relational fields\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n required=True,\n default=lambda self: self.env.company,\n )\n\n partner_id = fields.Many2one(\n 'res.partner',\n string='Partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n )\n\n line_ids = fields.One2many(\n 'my.model.line',\n 'model_id',\n string='Lines',\n copy=True,\n )\n\n tag_ids = fields.Many2many(\n 'my.model.tag',\n string='Tags',\n )\n\n # Computed fields\n line_count = fields.Integer(\n compute='_compute_line_count',\n string='Line Count',\n )\n\n total_amount = fields.Monetary(\n compute='_compute_total',\n store=True,\n currency_field='currency_id',\n )\n\n currency_id = fields.Many2one(\n 'res.currency',\n related='company_id.currency_id',\n )\n```\n\n## CRUD Methods\n\n```python\[email protected]\ndef create(self, vals):\n \"\"\"Single record create - standard in v15\"\"\"\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals)\n\n# Optional: model_create_multi for batch operations\[email protected]_create_multi\ndef create(self, vals_list):\n \"\"\"Batch create - optional in v15, recommended for performance\"\"\"\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n\ndef write(self, vals):\n \"\"\"Multi-record write\"\"\"\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Cannot complete without lines.\"))\n return super().write(vals)\n\ndef unlink(self):\n \"\"\"Multi-record delete\"\"\"\n if any(record.state == 'done' for record in self):\n raise UserError(_(\"Cannot delete completed records.\"))\n return super().unlink()\n\ndef copy(self, default=None):\n \"\"\"Copy single record\"\"\"\n self.ensure_one()\n default = dict(default or {})\n default['name'] = _(\"%s (copy)\") % self.name\n return super().copy(default)\n```\n\n## Computed Fields\n\n```python\[email protected]('line_ids', 'line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\[email protected]('line_ids')\ndef _compute_line_count(self):\n for record in self:\n record.line_count = len(record.line_ids)\n\n# Search computed field\nis_overdue = fields.Boolean(\n compute='_compute_is_overdue',\n search='_search_is_overdue',\n)\n\ndef _compute_is_overdue(self):\n today = fields.Date.context_today(self)\n for record in self:\n record.is_overdue = record.date_due and record.date_due \u003c today\n\ndef _search_is_overdue(self, operator, value):\n today = fields.Date.context_today(self)\n if (operator == '=' and value) or (operator == '!=' and not value):\n return [('date_due', '\u003c', today)]\n return [('date_due', '>=', today)]\n```\n\n## Onchange and Constraints\n\n```python\[email protected]('partner_id')\ndef _onchange_partner_id(self):\n \"\"\"Update fields when partner changes\"\"\"\n if self.partner_id:\n self.delivery_address_id = self.partner_id.address_get(['delivery'])['delivery']\n\[email protected]('date_start', 'date_end')\ndef _check_dates(self):\n for record in self:\n if record.date_start and record.date_end:\n if record.date_start > record.date_end:\n raise ValidationError(\n _(\"End date must be after start date.\")\n )\n\n_sql_constraints = [\n ('code_uniq', 'unique(code, company_id)',\n 'Code must be unique per company!'),\n ('positive_amount', 'CHECK(amount >= 0)',\n 'Amount must be positive!'),\n]\n```\n\n## X2many Operations\n\n```python\ndef create_with_lines(self):\n \"\"\"Create record with one2many lines using tuple syntax\"\"\"\n return self.env['my.model'].create({\n 'name': 'New Record',\n 'line_ids': [\n (0, 0, {'name': 'Line 1', 'quantity': 1, 'price': 10.0}),\n (0, 0, {'name': 'Line 2', 'quantity': 2, 'price': 20.0}),\n ],\n })\n\ndef update_lines(self):\n \"\"\"Update one2many lines\"\"\"\n self.write({\n 'line_ids': [\n (1, self.line_ids[0].id, {'quantity': 5}), # Update first line\n (0, 0, {'name': 'New Line', 'quantity': 1}), # Add new line\n (2, self.line_ids[-1].id, 0), # Delete last line\n ],\n })\n\ndef replace_lines(self):\n \"\"\"Replace all lines\"\"\"\n new_line_ids = self.env['my.model.line'].create([\n {'name': 'A', 'quantity': 1},\n {'name': 'B', 'quantity': 2},\n ])\n self.write({\n 'line_ids': [(6, 0, new_line_ids.ids)],\n })\n```\n\n## XML Views with attrs\n\n```xml\n\u003c!-- v15: attrs fully supported -->\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n class=\"btn-primary\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" type=\"object\" string=\"Mark Done\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cbutton name=\"action_cancel\" type=\"object\" string=\"Cancel\"\n attrs=\"{'invisible': [('state', '=', 'done')]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\n \u003cfield name=\"name\" placeholder=\"Name\"/>\n \u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"date\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"total_amount\"/>\n \u003cfield name=\"currency_id\" invisible=\"1\"/>\n \u003c/group>\n \u003c/group>\n\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\"\n attrs=\"{'readonly': [('state', '=', 'done')]}\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price\"/>\n \u003cfield name=\"amount\" sum=\"Total\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003cpage string=\"Notes\" name=\"notes\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\">\n \u003cfield name=\"note\" placeholder=\"Internal notes...\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n\u003c/form>\n```\n\n## Search and Actions\n\n```python\ndef action_search_partners(self):\n \"\"\"Search with domain\"\"\"\n domain = [\n ('customer_rank', '>', 0),\n ('company_id', 'in', [self.company_id.id, False]),\n ]\n return self.env['res.partner'].search(domain, limit=100)\n\ndef action_view_record(self):\n \"\"\"Return action to view single record\"\"\"\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': self.name,\n 'res_model': self._name,\n 'res_id': self.id,\n 'view_mode': 'form',\n 'target': 'current',\n }\n\ndef action_view_lines(self):\n \"\"\"Return action to view related records\"\"\"\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Lines'),\n 'res_model': 'my.model.line',\n 'view_mode': 'tree,form',\n 'domain': [('model_id', '=', self.id)],\n 'context': {\n 'default_model_id': self.id,\n 'create': self.state != 'done',\n },\n }\n```\n\n## Cron Jobs\n\n```python\[email protected]\ndef _cron_process_records(self):\n \"\"\"Scheduled action - process pending records\"\"\"\n records = self.search([\n ('state', '=', 'confirmed'),\n ('date', '\u003c=', fields.Date.today()),\n ])\n for record in records:\n try:\n record.action_done()\n except Exception as e:\n _logger.error(\"Failed to process %s: %s\", record.name, e)\n```\n\n## Mail Integration\n\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n def action_send_notification(self):\n \"\"\"Post message to chatter\"\"\"\n self.message_post(\n body=_(\"Record has been confirmed.\"),\n message_type='notification',\n subtype_xmlid='mail.mt_note',\n )\n\n def action_schedule_activity(self):\n \"\"\"Schedule follow-up activity\"\"\"\n self.activity_schedule(\n 'mail.mail_activity_data_todo',\n date_deadline=fields.Date.add(fields.Date.today(), days=7),\n summary=_(\"Follow up on %s\") % self.name,\n )\n```\n\n## v15 Best Practices\n\n1. **Use tracking=True** instead of track_visibility\n2. **Remove @api.multi** - methods iterate by default\n3. **Consider @api.model_create_multi** for batch creates\n4. **Use super() without arguments** - Python 3 style\n5. **Leverage OWL 1.x** for custom frontend components\n\n## Migration Notes to v16\n\nWhen upgrading from v15 to v16:\n\n1. **Command class available** - Can use `Command.create()` instead of tuples\n2. **attrs deprecation begins** - Start planning migration\n3. **OWL 2.x** - Major frontend update\n\n```python\n# v15: Tuple syntax\nline_ids = [(0, 0, {'name': 'Test'})]\n\n# v16: Command class (preferred)\nfrom odoo.fields import Command\nline_ids = [Command.create({'name': 'Test'})]\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12164,"content_sha256":"1e7e27365bffee2d2db28b8e88c9fb5bc602e76b88cc368ddf01d319abce4059"},{"filename":"skills/odoo-model-patterns-16-17.md","content":"# Odoo Model Patterns Migration Guide: 16.0 → 17.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODEL MIGRATION GUIDE: Odoo 16.0 → 17.0 ║\n║ Focus: Python models, decorators, CRUD methods ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Pattern | v16 | v17 | Action |\n|---------|-----|-----|--------|\n| `@api.model_create_multi` | Recommended | **Mandatory** | Must add |\n| `attrs` in views | Deprecated | Removed | Must migrate |\n| `states` in views | Deprecated | Removed | Must migrate |\n\n## MANDATORY: @api.model_create_multi\n\n### Before (v16)\n```python\[email protected]\ndef create(self, vals):\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals)\n```\n\n### After (v17)\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n```\n\n## View Visibility Changes (Affects Model Logic)\n\nModels that reference view visibility in their logic need updates:\n\n### Before (v16)\n```python\ndef get_view_attrs(self):\n return {\n 'invisible': [('state', '!=', 'draft')],\n 'readonly': [('locked', '=', True)],\n }\n```\n\n### After (v17)\n```python\ndef get_view_visibility(self):\n # Return Python expressions instead of domains\n return {\n 'invisible': \"state != 'draft'\",\n 'readonly': \"locked\",\n }\n```\n\n## Command Class (Already Required in v16)\n\nEnsure all x2many operations use Command class:\n\n```python\nfrom odoo import Command\n\n# Correct for both v16 and v17\nself.write({\n 'line_ids': [\n Command.create({'name': 'New'}),\n Command.update(1, {'name': 'Updated'}),\n Command.delete(2),\n Command.link(3),\n Command.unlink(4),\n Command.clear(),\n Command.set([5, 6, 7]),\n ]\n})\n```\n\n## Python Version Updates\n\n- v16: Python 3.8+\n- v17: Python 3.10+\n\n### New Python Features Available\n\n```python\n# Match statement (Python 3.10+)\nmatch self.state:\n case 'draft':\n self.action_confirm()\n case 'confirmed':\n self.action_done()\n case _:\n pass\n\n# Improved type hints\ndef process(self, data: list[dict]) -> bool:\n return True\n```\n\n## Migration Checklist\n\n### For Each Model\n- [ ] Update `create()` to use `@api.model_create_multi`\n- [ ] Change signature from `create(vals)` to `create(vals_list)`\n- [ ] Iterate over `vals_list` in create logic\n- [ ] Verify all x2many use `Command` class\n- [ ] Update any view visibility logic for Python expressions\n\n### Testing\n- [ ] Test bulk create operations\n- [ ] Verify single record creation still works\n- [ ] Test all form views for visibility\n- [ ] Test all buttons for state visibility\n\n## Common Errors and Fixes\n\n### Error: create() expects vals_list\n```\nTypeError: create() got an unexpected keyword argument 'vals'\n```\n**Fix**: Update method signature to accept `vals_list`.\n\n### Error: Missing return in create\n```\nTypeError: create() should return recordset\n```\n**Fix**: Ensure super().create() is called with `vals_list`.\n\n## Automated Migration Pattern\n\n```python\n# Find and replace pattern\n# Before:\[email protected]\ndef create(self, vals):\n # ... logic with vals ...\n return super().create(vals)\n\n# After:\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n # ... logic with vals ...\n return super().create(vals_list)\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3979,"content_sha256":"64c1c66a6b872e16d194ed7d3e476dba2b5420b158c98d5d76e9c85879ada75a"},{"filename":"skills/odoo-model-patterns-16.md","content":"# Odoo 16.0 Model Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 16.0 ORM PATTERNS ║\n║ Command class introduced, attrs deprecated, OWL 2.x ║\n║ VERIFY: https://github.com/odoo/odoo/tree/16.0/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Patterns\n\n### Key Characteristics\n| Feature | Odoo 16.0 Pattern |\n|---------|-------------------|\n| X2many commands | `Command` class (tuple syntax still works) |\n| attrs in views | DEPRECATED - use conditional attributes |\n| Change tracking | `tracking=True` |\n| OWL | Version 2.x |\n| Python version | 3.8+ |\n\n## New in v16: Command Class\n\n```python\nfrom odoo.fields import Command\n\n# Command class methods\nCommand.create(values) # Replaces (0, 0, values)\nCommand.update(id, values) # Replaces (1, id, values)\nCommand.delete(id) # Replaces (2, id, 0)\nCommand.unlink(id) # Replaces (3, id, 0) - M2M only\nCommand.link(id) # Replaces (4, id, 0)\nCommand.clear() # Replaces (5, 0, 0)\nCommand.set(ids) # Replaces (6, 0, ids)\n```\n\n## Model Definition\n\n```python\nfrom odoo import models, fields, api, _\nfrom odoo.fields import Command\nfrom odoo.exceptions import UserError, ValidationError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'sequence, name'\n\n name = fields.Char(\n string='Name',\n required=True,\n index=True,\n tracking=True,\n )\n\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ], default='draft', tracking=True, index=True)\n\n sequence = fields.Integer(default=10)\n active = fields.Boolean(default=True)\n\n # Date fields\n date = fields.Date(default=fields.Date.context_today)\n date_deadline = fields.Date()\n\n # Relational fields\n company_id = fields.Many2one(\n 'res.company',\n required=True,\n default=lambda self: self.env.company,\n )\n\n partner_id = fields.Many2one(\n 'res.partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n tracking=True,\n )\n\n line_ids = fields.One2many(\n 'my.model.line',\n 'model_id',\n copy=True,\n )\n\n tag_ids = fields.Many2many('my.model.tag')\n\n # Computed fields\n total_amount = fields.Monetary(\n compute='_compute_total',\n store=True,\n currency_field='currency_id',\n )\n\n currency_id = fields.Many2one(\n 'res.currency',\n related='company_id.currency_id',\n )\n```\n\n## Using Command Class\n\n```python\ndef create_with_lines(self):\n \"\"\"Create record with lines using Command class\"\"\"\n return self.env['my.model'].create({\n 'name': 'New Record',\n 'line_ids': [\n Command.create({'name': 'Line 1', 'quantity': 1, 'price': 10.0}),\n Command.create({'name': 'Line 2', 'quantity': 2, 'price': 20.0}),\n ],\n })\n\ndef update_lines(self):\n \"\"\"Update one2many lines\"\"\"\n self.write({\n 'line_ids': [\n Command.update(self.line_ids[0].id, {'quantity': 5}),\n Command.create({'name': 'New Line', 'quantity': 1}),\n Command.delete(self.line_ids[-1].id),\n ],\n })\n\ndef manage_tags(self):\n \"\"\"Manage many2many using Command class\"\"\"\n tag = self.env['my.model.tag'].create({'name': 'Important'})\n\n # Link existing tag\n self.write({'tag_ids': [Command.link(tag.id)]})\n\n # Replace all tags\n self.write({'tag_ids': [Command.set([tag.id])]})\n\n # Clear all tags\n self.write({'tag_ids': [Command.clear()]})\n\n # Unlink specific tag\n self.write({'tag_ids': [Command.unlink(tag.id)]})\n```\n\n## CRUD Methods\n\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n \"\"\"Batch create - recommended in v16\"\"\"\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n\ndef write(self, vals):\n \"\"\"Multi-record write with Command awareness\"\"\"\n result = super().write(vals)\n if 'state' in vals and vals['state'] == 'confirmed':\n for record in self:\n record.message_post(body=_(\"Record confirmed.\"))\n return result\n\ndef unlink(self):\n \"\"\"Delete with validation\"\"\"\n if any(r.state not in ('draft', 'cancelled') for r in self):\n raise UserError(_(\"Only draft or cancelled records can be deleted.\"))\n return super().unlink()\n```\n\n## XML Views: attrs Deprecation\n\n```xml\n\u003c!--\n v16: attrs is DEPRECATED\n Start migrating to conditional attributes\n-->\n\n\u003c!-- OLD (deprecated but still works in v16) -->\n\u003cfield name=\"partner_id\"\n attrs=\"{'invisible': [('state', '=', 'draft')],\n 'required': [('state', '=', 'confirmed')],\n 'readonly': [('state', '=', 'done')]}\"/>\n\n\u003c!-- NEW (v16+ recommended) -->\n\u003cfield name=\"partner_id\"\n invisible=\"state == 'draft'\"\n required=\"state == 'confirmed'\"\n readonly=\"state == 'done'\"/>\n\n\u003c!-- For buttons -->\n\u003c!-- OLD -->\n\u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n\n\u003c!-- NEW -->\n\u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n invisible=\"state != 'draft'\"/>\n```\n\n### Complete Form Example\n\n```xml\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n class=\"btn-primary\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" type=\"object\" string=\"Mark Done\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_cancel\" type=\"object\" string=\"Cancel\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cbutton name=\"action_draft\" type=\"object\" string=\"Set to Draft\"\n invisible=\"state != 'cancelled'\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\n \u003cfield name=\"name\" placeholder=\"Name\"\n readonly=\"state == 'done'\"/>\n \u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"\n readonly=\"state == 'done'\"/>\n \u003cfield name=\"date\"/>\n \u003cfield name=\"date_deadline\"\n invisible=\"state == 'draft'\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"total_amount\"/>\n \u003cfield name=\"company_id\"\n groups=\"base.group_multi_company\"\n readonly=\"state != 'draft'\"/>\n \u003c/group>\n \u003c/group>\n\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\"\n readonly=\"state == 'done'\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price\"/>\n \u003cfield name=\"amount\" sum=\"Total\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003cpage string=\"Tags\" name=\"tags\">\n \u003cfield name=\"tag_ids\" widget=\"many2many_tags\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n\u003c/form>\n```\n\n## Computed Fields\n\n```python\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n# With currency\[email protected]('line_ids.price_subtotal', 'currency_id')\ndef _compute_amount_total(self):\n for record in self:\n record.amount_total = sum(record.line_ids.mapped('price_subtotal'))\n```\n\n## Search Methods\n\n```python\ndef action_search_overdue(self):\n \"\"\"Search with complex domain\"\"\"\n today = fields.Date.today()\n domain = [\n ('state', '=', 'confirmed'),\n ('date_deadline', '\u003c', today),\n ('company_id', '=', self.env.company.id),\n ]\n return self.search(domain, order='date_deadline')\n\ndef name_search(self, name='', args=None, operator='ilike', limit=100):\n \"\"\"Enhanced name search\"\"\"\n args = args or []\n if name:\n domain = ['|', ('name', operator, name), ('code', operator, name)]\n return self.search(domain + args, limit=limit).name_get()\n return super().name_search(name, args, operator, limit)\n```\n\n## Action Methods\n\n```python\ndef action_confirm(self):\n \"\"\"Confirm records\"\"\"\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n if not record.line_ids:\n raise UserError(_(\"Please add at least one line.\"))\n self.write({'state': 'confirmed'})\n\ndef action_done(self):\n \"\"\"Mark as done\"\"\"\n self.filtered(lambda r: r.state == 'confirmed').write({'state': 'done'})\n\ndef action_cancel(self):\n \"\"\"Cancel records\"\"\"\n self.filtered(lambda r: r.state != 'done').write({'state': 'cancelled'})\n\ndef action_draft(self):\n \"\"\"Reset to draft\"\"\"\n self.filtered(lambda r: r.state == 'cancelled').write({'state': 'draft'})\n```\n\n## v16 Best Practices\n\n1. **Use Command class** for x2many operations\n2. **Migrate from attrs** to conditional attributes\n3. **Use @api.model_create_multi** for create methods\n4. **Leverage OWL 2.x** for frontend components\n5. **Use index=True** on frequently searched fields\n\n## Migration Notes to v17\n\nWhen upgrading from v16 to v17:\n\n1. **attrs REMOVED** - Must use conditional attributes\n2. **@api.model_create_multi mandatory** - No longer optional\n3. **OWL 2.x enhanced** - Minor updates\n\n```python\n# v16: attrs still works (deprecated)\n# v17: attrs REMOVED - this will break\n\n# Must update all XML files before v17 upgrade\n# invisible=\"domain\" instead of attrs=\"{'invisible': [domain]}\"\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10692,"content_sha256":"b494f6624d57af4da1dabbe9a212a41376a4ed52f8887f40daa2ba905c61e528"},{"filename":"skills/odoo-model-patterns-17-18.md","content":"# Odoo Model Patterns Migration Guide: 17.0 → 18.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODEL MIGRATION GUIDE: Odoo 17.0 → 18.0 ║\n║ Focus: Company checks, SQL builder, type hints ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## New Features Summary\n\n| Feature | v17 | v18 | Recommendation |\n|---------|-----|-----|----------------|\n| `_check_company_auto` | N/A | Available | Add to multi-company models |\n| `check_company` on fields | N/A | Available | Add to cross-company fields |\n| `SQL()` builder | N/A | Recommended | Use for raw SQL |\n| Type hints | Optional | Recommended | Add where possible |\n\n## NEW: Automatic Company Validation\n\n### Before (v17 - Manual)\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n company_id = fields.Many2one('res.company')\n partner_id = fields.Many2one('res.partner')\n\n @api.constrains('partner_id', 'company_id')\n def _check_partner_company(self):\n for record in self:\n if record.partner_id.company_id and record.partner_id.company_id != record.company_id:\n raise ValidationError(_(\"Partner company must match record company.\"))\n```\n\n### After (v18 - Automatic)\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _check_company_auto = True # Enable automatic checking\n\n company_id = fields.Many2one('res.company', required=True)\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True, # Framework handles validation\n )\n # No manual constraint needed!\n```\n\n## NEW: SQL Builder\n\n### Before (v17 - Raw SQL)\n```python\ndef _get_statistics(self):\n query = \"\"\"\n SELECT partner_id, SUM(amount) as total\n FROM %s\n WHERE company_id = %%s AND state = %%s\n GROUP BY partner_id\n \"\"\" % self._table\n self.env.cr.execute(query, (self.env.company.id, 'confirmed'))\n return self.env.cr.dictfetchall()\n```\n\n### After (v18 - SQL Builder)\n```python\nfrom odoo.tools import SQL\n\ndef _get_statistics(self):\n query = SQL(\n \"\"\"\n SELECT partner_id, SUM(amount) as total\n FROM %s\n WHERE company_id = %s AND state = %s\n GROUP BY partner_id\n \"\"\",\n SQL.identifier(self._table),\n self.env.company.id,\n 'confirmed',\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n### SQL Builder Benefits\n- Automatic SQL injection prevention\n- Type-safe identifiers\n- Clear parameter binding\n- Better debugging\n\n## RECOMMENDED: Type Hints\n\n### Before (v17)\n```python\ndef calculate_total(self, include_tax=True, discount=None):\n discount = discount or 0\n total = sum(self.mapped('amount'))\n if include_tax:\n total *= 1.21\n return total - discount\n```\n\n### After (v18)\n```python\nfrom typing import Optional\n\ndef calculate_total(\n self,\n include_tax: bool = True,\n discount: Optional[float] = None,\n) -> float:\n discount = discount or 0.0\n total = sum(self.mapped('amount'))\n if include_tax:\n total *= 1.21\n return total - discount\n```\n\n## Security Rule Updates\n\n### Before (v17)\n```xml\n\u003cfield name=\"domain_force\">[\n ('company_id', 'in', company_ids)\n]\u003c/field>\n```\n\n### After (v18)\n```xml\n\u003cfield name=\"domain_force\">[\n ('company_id', 'in', allowed_company_ids)\n]\u003c/field>\n```\n\n## Migration Checklist\n\n### For Multi-Company Models\n- [ ] Add `_check_company_auto = True` to model definition\n- [ ] Add `check_company=True` to relevant Many2one fields\n- [ ] Remove manual company validation constraints\n- [ ] Update record rules to use `allowed_company_ids`\n\n### For SQL Queries\n- [ ] Import `from odoo.tools import SQL`\n- [ ] Replace raw SQL strings with `SQL()` builder\n- [ ] Use `SQL.identifier()` for table/column names\n- [ ] Test all queries work correctly\n\n### For Methods (Recommended)\n- [ ] Add type hints to method parameters\n- [ ] Add return type annotations\n- [ ] Import `from typing import Optional, Any` as needed\n\n### Testing\n- [ ] Test multi-company scenarios\n- [ ] Test company switching\n- [ ] Verify SQL queries execute correctly\n- [ ] Test with Python type checker (optional)\n\n## Backward Compatibility\n\nIf supporting both v17 and v18:\n\n```python\n# Check for SQL builder availability\ntry:\n from odoo.tools import SQL\n HAS_SQL = True\nexcept ImportError:\n HAS_SQL = False\n\ndef _execute_query(self):\n if HAS_SQL:\n query = SQL(\"SELECT * FROM %s WHERE id = %s\",\n SQL.identifier(self._table), self.id)\n else:\n query = \"SELECT * FROM %s WHERE id = %%s\" % self._table\n query = query, (self.id,)\n # Handle execution...\n```\n\n## Common Migration Issues\n\n### Issue: company_id required for _check_company_auto\n```\nValidationError: company_id is required when _check_company_auto is True\n```\n**Fix**: Ensure `company_id` field has `required=True`.\n\n### Issue: SQL identifier error\n```\nAttributeError: 'str' object has no attribute 'as_string'\n```\n**Fix**: Use `SQL.identifier()` for table names, not raw strings.\n\n### Issue: Type hint import error\n```\nImportError: cannot import name 'Optional' from 'typing'\n```\n**Fix**: Ensure Python 3.10+ is used, or use `from typing import Optional`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5631,"content_sha256":"488c8cf3176b2251cadea3bd0bec4d8e6e37109ab4d0af6727b286bb22cc9afa"},{"filename":"skills/odoo-model-patterns-17.md","content":"# Odoo 17.0 Model Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 17.0 ORM PATTERNS ║\n║ attrs REMOVED, @api.model_create_multi mandatory ║\n║ VERIFY: https://github.com/odoo/odoo/tree/17.0/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Patterns\n\n### Key Characteristics\n| Feature | Odoo 17.0 Pattern |\n|---------|-------------------|\n| attrs in views | **REMOVED** - must use conditional attributes |\n| Create method | `@api.model_create_multi` **MANDATORY** |\n| X2many commands | `Command` class (recommended) |\n| Change tracking | `tracking=True` |\n| OWL | Version 2.x (enhanced) |\n| Python version | 3.10+ |\n\n## BREAKING: attrs Removed\n\n```xml\n\u003c!-- THIS WILL BREAK IN v17 -->\n\u003cfield name=\"partner_id\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n\n\u003c!-- v17 REQUIRED syntax -->\n\u003cfield name=\"partner_id\"\n invisible=\"state == 'draft'\"/>\n```\n\n## BREAKING: create_multi Mandatory\n\n```python\n# v16 (optional):\[email protected]\ndef create(self, vals):\n return super().create(vals)\n\n# v17 (MANDATORY):\[email protected]_create_multi\ndef create(self, vals_list):\n return super().create(vals_list)\n```\n\n## Model Definition\n\n```python\nfrom odoo import models, fields, api, _\nfrom odoo.fields import Command\nfrom odoo.exceptions import UserError, ValidationError\nimport logging\n\n_logger = logging.getLogger(__name__)\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'sequence, id desc'\n\n name = fields.Char(\n string='Name',\n required=True,\n index='trigram', # v17: index type specification\n tracking=True,\n )\n\n code = fields.Char(\n index=True,\n copy=False,\n )\n\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('in_progress', 'In Progress'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ], default='draft', tracking=True, index=True)\n\n priority = fields.Selection([\n ('0', 'Normal'),\n ('1', 'Low'),\n ('2', 'High'),\n ('3', 'Very High'),\n ], default='0', index=True)\n\n sequence = fields.Integer(default=10)\n active = fields.Boolean(default=True)\n\n # Date fields\n date = fields.Date(default=fields.Date.context_today)\n date_deadline = fields.Date(index=True)\n\n # Relational fields\n company_id = fields.Many2one(\n 'res.company',\n required=True,\n default=lambda self: self.env.company,\n index=True,\n )\n\n user_id = fields.Many2one(\n 'res.users',\n default=lambda self: self.env.user,\n tracking=True,\n )\n\n partner_id = fields.Many2one(\n 'res.partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n tracking=True,\n )\n\n line_ids = fields.One2many(\n 'my.model.line',\n 'model_id',\n copy=True,\n )\n\n tag_ids = fields.Many2many('my.model.tag')\n\n # Computed fields\n total_amount = fields.Monetary(\n compute='_compute_total',\n store=True,\n currency_field='currency_id',\n )\n\n line_count = fields.Integer(\n compute='_compute_line_count',\n )\n\n currency_id = fields.Many2one(\n 'res.currency',\n related='company_id.currency_id',\n )\n\n # Binary and HTML fields\n attachment = fields.Binary(attachment=True)\n description = fields.Html(sanitize=True)\n note = fields.Text()\n```\n\n## CRUD Methods (v17 Patterns)\n\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n \"\"\"MANDATORY: @api.model_create_multi in v17\"\"\"\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n if 'company_id' not in vals:\n vals['company_id'] = self.env.company.id\n\n records = super().create(vals_list)\n\n # Post-creation logic\n for record in records:\n record.message_post(body=_(\"Record created.\"))\n\n return records\n\ndef write(self, vals):\n \"\"\"Write with state validation\"\"\"\n if 'state' in vals:\n if vals['state'] == 'confirmed':\n for record in self:\n if not record.line_ids:\n raise UserError(\n _(\"Record '%s' must have at least one line.\") % record.name\n )\n\n result = super().write(vals)\n\n # Track specific field changes\n if 'partner_id' in vals:\n self._log_partner_change()\n\n return result\n\ndef unlink(self):\n \"\"\"Delete with state check\"\"\"\n for record in self:\n if record.state not in ('draft', 'cancelled'):\n raise UserError(\n _(\"Cannot delete '%s' (state: %s). Only draft or cancelled records can be deleted.\")\n % (record.name, record.state)\n )\n return super().unlink()\n\ndef copy(self, default=None):\n \"\"\"Copy with name suffix\"\"\"\n self.ensure_one()\n default = dict(default or {})\n if 'name' not in default:\n default['name'] = _(\"%s (copy)\", self.name)\n default['state'] = 'draft'\n return super().copy(default)\n```\n\n## XML Views (v17 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Form View -->\n \u003crecord id=\"view_my_model_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">my.model.form\u003c/field>\n \u003cfield name=\"model\">my.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform>\n \u003cheader>\n \u003c!-- v17: Use Python expressions, NOT attrs -->\n \u003cbutton name=\"action_confirm\" type=\"object\"\n string=\"Confirm\" class=\"btn-primary\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_start\" type=\"object\"\n string=\"Start\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_done\" type=\"object\"\n string=\"Mark Done\"\n invisible=\"state != 'in_progress'\"/>\n \u003cbutton name=\"action_cancel\" type=\"object\"\n string=\"Cancel\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cbutton name=\"action_draft\" type=\"object\"\n string=\"Reset to Draft\"\n invisible=\"state != 'cancelled'\"/>\n\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,in_progress,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\">\n \u003cbutton name=\"action_view_lines\" type=\"object\"\n class=\"oe_stat_button\" icon=\"fa-list\"\n invisible=\"line_count == 0\">\n \u003cfield name=\"line_count\" widget=\"statinfo\"\n string=\"Lines\"/>\n \u003c/button>\n \u003c/div>\n\n \u003cwidget name=\"web_ribbon\" title=\"Archived\"\n invisible=\"active\"/>\n\n \u003cdiv class=\"oe_title\">\n \u003clabel for=\"name\"/>\n \u003ch1>\n \u003cfield name=\"name\" placeholder=\"Enter name...\"\n readonly=\"state == 'done'\"/>\n \u003c/h1>\n \u003c/div>\n\n \u003cgroup>\n \u003cgroup string=\"General\">\n \u003cfield name=\"code\" readonly=\"state != 'draft'\"/>\n \u003cfield name=\"partner_id\"\n readonly=\"state == 'done'\"\n required=\"state == 'confirmed'\"/>\n \u003cfield name=\"user_id\"/>\n \u003cfield name=\"priority\" widget=\"priority\"/>\n \u003c/group>\n \u003cgroup string=\"Dates\">\n \u003cfield name=\"date\"/>\n \u003cfield name=\"date_deadline\"\n invisible=\"state == 'draft'\"/>\n \u003cfield name=\"company_id\"\n groups=\"base.group_multi_company\"\n readonly=\"state != 'draft'\"/>\n \u003c/group>\n \u003c/group>\n\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\"\n readonly=\"state == 'done'\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"product_id\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\" sum=\"Total\"/>\n \u003c/tree>\n \u003cform>\n \u003cgroup>\n \u003cfield name=\"product_id\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/group>\n \u003c/form>\n \u003c/field>\n \u003cgroup class=\"oe_subtotal_footer\">\n \u003cfield name=\"total_amount\" class=\"oe_subtotal_footer_separator\"/>\n \u003c/group>\n \u003c/page>\n \u003cpage string=\"Tags\" name=\"tags\">\n \u003cfield name=\"tag_ids\" widget=\"many2many_tags\"\n options=\"{'color_field': 'color'}\"/>\n \u003c/page>\n \u003cpage string=\"Notes\" name=\"notes\"\n invisible=\"state == 'draft'\">\n \u003cfield name=\"description\"\n placeholder=\"Description...\"\n readonly=\"state == 'done'\"/>\n \u003cfield name=\"note\"\n placeholder=\"Internal notes...\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Tree View -->\n \u003crecord id=\"view_my_model_tree\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">my.model.tree\u003c/field>\n \u003cfield name=\"model\">my.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ctree decoration-danger=\"state == 'cancelled'\"\n decoration-success=\"state == 'done'\"\n decoration-warning=\"date_deadline and date_deadline < current_date and state not in ('done', 'cancelled')\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"code\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"date\"/>\n \u003cfield name=\"date_deadline\" optional=\"show\"/>\n \u003cfield name=\"total_amount\" sum=\"Total\"/>\n \u003cfield name=\"state\" widget=\"badge\"\n decoration-success=\"state == 'done'\"\n decoration-warning=\"state == 'in_progress'\"\n decoration-info=\"state == 'confirmed'\"\n decoration-danger=\"state == 'cancelled'\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\" optional=\"hide\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/record>\n\n \u003c!-- Search View -->\n \u003crecord id=\"view_my_model_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">my.model.search\u003c/field>\n \u003cfield name=\"model\">my.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch>\n \u003cfield name=\"name\" filter_domain=\"['|', ('name', 'ilike', self), ('code', 'ilike', self)]\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003cseparator/>\n \u003cfilter name=\"filter_my\" string=\"My Records\"\n domain=\"[('user_id', '=', uid)]\"/>\n \u003cfilter name=\"filter_draft\" string=\"Draft\"\n domain=\"[('state', '=', 'draft')]\"/>\n \u003cfilter name=\"filter_confirmed\" string=\"Confirmed\"\n domain=\"[('state', '=', 'confirmed')]\"/>\n \u003cfilter name=\"filter_done\" string=\"Done\"\n domain=\"[('state', '=', 'done')]\"/>\n \u003cseparator/>\n \u003cfilter name=\"filter_overdue\" string=\"Overdue\"\n domain=\"[('date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('state', 'not in', ('done', 'cancelled'))]\"/>\n \u003cseparator/>\n \u003cfilter name=\"filter_archived\" string=\"Archived\"\n domain=\"[('active', '=', False)]\"/>\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter name=\"groupby_state\" string=\"State\"\n context=\"{'group_by': 'state'}\"/>\n \u003cfilter name=\"groupby_partner\" string=\"Partner\"\n context=\"{'group_by': 'partner_id'}\"/>\n \u003cfilter name=\"groupby_user\" string=\"Responsible\"\n context=\"{'group_by': 'user_id'}\"/>\n \u003cfilter name=\"groupby_date\" string=\"Date\"\n context=\"{'group_by': 'date:month'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Computed Fields\n\n```python\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\[email protected]('line_ids')\ndef _compute_line_count(self):\n for record in self:\n record.line_count = len(record.line_ids)\n\n# Computed with conditional logic\nis_overdue = fields.Boolean(\n compute='_compute_is_overdue',\n search='_search_is_overdue',\n store=True,\n)\n\[email protected]('date_deadline', 'state')\ndef _compute_is_overdue(self):\n today = fields.Date.context_today(self)\n for record in self:\n record.is_overdue = (\n record.date_deadline\n and record.date_deadline \u003c today\n and record.state not in ('done', 'cancelled')\n )\n\ndef _search_is_overdue(self, operator, value):\n today = fields.Date.context_today(self)\n if (operator == '=' and value) or (operator == '!=' and not value):\n return [\n ('date_deadline', '\u003c', today),\n ('state', 'not in', ('done', 'cancelled')),\n ]\n return ['|', ('date_deadline', '>=', today), ('date_deadline', '=', False)]\n```\n\n## Action Methods\n\n```python\ndef action_confirm(self):\n \"\"\"Confirm records - validate before state change\"\"\"\n for record in self.filtered(lambda r: r.state == 'draft'):\n if not record.line_ids:\n raise UserError(_(\"Add at least one line to confirm '%s'.\") % record.name)\n self.filtered(lambda r: r.state == 'draft').write({'state': 'confirmed'})\n\ndef action_start(self):\n \"\"\"Start work on records\"\"\"\n self.filtered(lambda r: r.state == 'confirmed').write({'state': 'in_progress'})\n\ndef action_done(self):\n \"\"\"Mark records as done\"\"\"\n self.filtered(lambda r: r.state == 'in_progress').write({'state': 'done'})\n\ndef action_cancel(self):\n \"\"\"Cancel records\"\"\"\n self.filtered(lambda r: r.state not in ('done', 'cancelled')).write({'state': 'cancelled'})\n\ndef action_draft(self):\n \"\"\"Reset to draft\"\"\"\n self.filtered(lambda r: r.state == 'cancelled').write({'state': 'draft'})\n\ndef action_view_lines(self):\n \"\"\"View lines action\"\"\"\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Lines'),\n 'res_model': 'my.model.line',\n 'view_mode': 'tree,form',\n 'domain': [('model_id', '=', self.id)],\n 'context': {'default_model_id': self.id},\n }\n```\n\n## v17 Best Practices\n\n1. **NO attrs** - Always use Python expression attributes\n2. **@api.model_create_multi** - Always use for create methods\n3. **Use Command class** - For all x2many operations\n4. **Index important fields** - Use `index=True` or `index='trigram'`\n5. **Type-aware filtering** - Use `filtered()` with lambdas\n\n## Migration Notes to v18\n\nWhen upgrading from v17 to v18:\n\n1. **_check_company_auto** - New pattern for multi-company\n2. **check_company=True** - On relational fields\n3. **SQL() builder** - For raw SQL queries\n4. **Type hints recommended** - Python 3.10+ features\n\n```python\n# v18 additions\nclass MyModel(models.Model):\n _name = 'my.model'\n _check_company_auto = True # v18 pattern\n\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True, # v18 pattern\n )\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":17926,"content_sha256":"72c4daeded426bc9542642e8e6db13bd8b203fcfe472aa93ddb91deb94a7ef26"},{"filename":"skills/odoo-model-patterns-18-19.md","content":"# Odoo Model Patterns Migration Guide: 18.0 → 19.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODEL MIGRATION GUIDE: Odoo 18.0 → 19.0 ║\n║ Focus: Mandatory type hints, mandatory SQL builder ║\n║ Note: v19 is in development - patterns may change. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Feature | v18 Status | v19 Status | Action |\n|---------|------------|------------|--------|\n| Type hints | Recommended | **Mandatory** | Must add |\n| `SQL()` builder | Recommended | **Mandatory** | Must use |\n| Raw SQL strings | Deprecated | **Removed** | Must migrate |\n\n## MANDATORY: Type Hints\n\n### Before (v18 - Optional)\n```python\ndef calculate_totals(self, options=None):\n options = options or {}\n results = []\n for record in self:\n total = sum(record.line_ids.mapped('amount'))\n if options.get('include_tax'):\n total *= 1.21\n results.append({'id': record.id, 'total': total})\n return results\n\ndef get_partner_data(self):\n return {\n 'id': self.partner_id.id,\n 'name': self.partner_id.name,\n 'email': self.partner_id.email,\n }\n\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = 'New'\n return super().create(vals_list)\n```\n\n### After (v19 - Mandatory)\n```python\nfrom __future__ import annotations\n\nfrom typing import Any, Optional\nfrom collections.abc import Sequence\n\ndef calculate_totals(\n self,\n options: Optional[dict[str, Any]] = None,\n) -> list[dict[str, Any]]:\n options = options or {}\n results: list[dict[str, Any]] = []\n for record in self:\n total: float = sum(record.line_ids.mapped('amount'))\n if options.get('include_tax'):\n total *= 1.21\n results.append({'id': record.id, 'total': total})\n return results\n\ndef get_partner_data(self) -> dict[str, Any]:\n return {\n 'id': self.partner_id.id,\n 'name': self.partner_id.name,\n 'email': self.partner_id.email,\n }\n\[email protected]_create_multi\ndef create(self, vals_list: list[dict[str, Any]]) -> 'MyModel':\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = 'New'\n return super().create(vals_list)\n```\n\n## MANDATORY: SQL Builder\n\n### Before (v18 - Allowed but deprecated)\n```python\ndef _get_report_data(self):\n # This will FAIL in v19\n query = \"\"\"\n SELECT id, name, amount\n FROM %s\n WHERE company_id = %%s\n ORDER BY create_date DESC\n \"\"\" % self._table\n self.env.cr.execute(query, (self.env.company.id,))\n return self.env.cr.dictfetchall()\n```\n\n### After (v19 - Required)\n```python\nfrom odoo.tools import SQL\n\ndef _get_report_data(self) -> list[dict[str, Any]]:\n query = SQL(\n \"\"\"\n SELECT id, name, amount\n FROM %s\n WHERE company_id = %s\n ORDER BY %s\n \"\"\",\n SQL.identifier(self._table),\n self.env.company.id,\n SQL('create_date DESC'),\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n## Type Hint Reference\n\n### Common Patterns\n\n```python\nfrom __future__ import annotations\n\nfrom typing import Any, Optional, Union, Literal\nfrom collections.abc import Sequence, Mapping, Iterable\n\n# Model class\nclass MyModel(models.Model):\n _name = 'my.model'\n\n # CRUD methods\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> 'MyModel':\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n return super().write(vals)\n\n def unlink(self) -> bool:\n return super().unlink()\n\n def copy(self, default: Optional[dict[str, Any]] = None) -> 'MyModel':\n return super().copy(default)\n\n # Compute methods\n @api.depends('line_ids.amount')\n def _compute_total(self) -> None:\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n\n # Constraint methods\n @api.constrains('amount')\n def _check_amount(self) -> None:\n for record in self:\n if record.amount \u003c 0:\n raise ValidationError(_(\"Amount must be positive\"))\n\n # Action methods\n def action_confirm(self) -> None:\n self.write({'state': 'confirmed'})\n\n def action_view_records(self) -> dict[str, Any]:\n return {\n 'type': 'ir.actions.act_window',\n 'res_model': 'my.model',\n 'view_mode': 'tree,form',\n }\n\n # Search methods\n @api.model\n def _name_search(\n self,\n name: str = '',\n domain: Optional[list[tuple[str, str, Any]]] = None,\n operator: str = 'ilike',\n limit: int = 100,\n order: Optional[str] = None,\n ) -> Sequence[int]:\n return self._search(domain or [], limit=limit, order=order)\n\n # Custom methods with various types\n def process_data(\n self,\n partner_ids: list[int],\n options: Optional[dict[str, Any]] = None,\n mode: Literal['create', 'update', 'delete'] = 'create',\n ) -> tuple[int, list[str]]:\n options = options or {}\n count = 0\n errors: list[str] = []\n # ...\n return count, errors\n```\n\n## SQL Builder Complete Reference\n\n```python\nfrom odoo.tools import SQL\n\n# Basic query\nquery = SQL(\n \"SELECT * FROM %s WHERE id = %s\",\n SQL.identifier('my_table'),\n record_id,\n)\n\n# Complex query with joins\nquery = SQL(\n \"\"\"\n SELECT\n m.id,\n m.name,\n p.name as partner_name,\n COALESCE(SUM(l.amount), 0) as total\n FROM %s m\n LEFT JOIN %s p ON p.id = m.partner_id\n LEFT JOIN %s l ON l.parent_id = m.id\n WHERE m.company_id IN %s\n AND m.state = %s\n AND m.active = %s\n GROUP BY m.id, m.name, p.name\n HAVING SUM(l.amount) > %s\n ORDER BY %s\n LIMIT %s OFFSET %s\n \"\"\",\n SQL.identifier('my_model'),\n SQL.identifier('res_partner'),\n SQL.identifier('my_model_line'),\n tuple(company_ids),\n 'confirmed',\n True,\n 100.0,\n SQL('total DESC'),\n limit,\n offset,\n)\n\n# Dynamic conditions\nconditions = [SQL(\"company_id = %s\", company_id)]\nif partner_id:\n conditions.append(SQL(\"partner_id = %s\", partner_id))\nif state:\n conditions.append(SQL(\"state = %s\", state))\n\nwhere_clause = SQL(\" AND \").join(conditions)\nquery = SQL(\n \"SELECT * FROM %s WHERE %s\",\n SQL.identifier(self._table),\n where_clause,\n)\n```\n\n## Migration Checklist\n\n### For All Models\n- [ ] Add `from __future__ import annotations` at file top\n- [ ] Add type hints to ALL method parameters\n- [ ] Add return type annotations to ALL methods\n- [ ] Import types from `typing` and `collections.abc`\n\n### For SQL Queries\n- [ ] Replace ALL raw SQL strings with `SQL()` builder\n- [ ] Verify all queries work correctly\n- [ ] Test with various input parameters\n\n### Python Version\n- [ ] Ensure Python 3.12+ is installed\n- [ ] Use new Python features where beneficial\n\n### Testing\n- [ ] Run type checker (mypy, pyright) on code\n- [ ] Test all SQL queries\n- [ ] Verify all methods work correctly\n\n## Type Checker Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"Check for missing type hints in Odoo models.\"\"\"\nimport ast\nimport sys\nfrom pathlib import Path\n\ndef check_file(filepath: Path) -> list[str]:\n issues = []\n with open(filepath) as f:\n tree = ast.parse(f.read())\n\n for node in ast.walk(tree):\n if isinstance(node, ast.FunctionDef):\n if node.name.startswith('_') and node.name not in ('__init__', '__new__'):\n continue\n\n # Check return annotation\n if node.returns is None and node.name != '__init__':\n issues.append(f\"{filepath}:{node.lineno}: {node.name}() missing return type\")\n\n # Check parameter annotations\n for arg in node.args.args:\n if arg.arg != 'self' and arg.annotation is None:\n issues.append(f\"{filepath}:{node.lineno}: {node.name}({arg.arg}) missing type hint\")\n\n return issues\n\nif __name__ == '__main__':\n for path in Path('models').rglob('*.py'):\n for issue in check_file(path):\n print(issue)\n```\n\n## Common Migration Errors\n\n### Error: Missing type hints\n```\nTypeError: Missing type annotation for parameter 'vals'\n```\n**Fix**: Add type hints to all method parameters.\n\n### Error: Raw SQL not allowed\n```\nSecurityError: Raw SQL strings are deprecated. Use SQL() builder.\n```\n**Fix**: Convert all raw SQL to use `SQL()` builder.\n\n### Error: Invalid type hint syntax\n```\nSyntaxError: invalid syntax (type hint)\n```\n**Fix**: Ensure Python 3.12+ is used and correct syntax.\n\n### Error: Circular import with type hints\n```\nImportError: cannot import name 'MyModel' from partially initialized module\n```\n**Fix**: Use `from __future__ import annotations` for forward references.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9329,"content_sha256":"3dca826bbe4ee62e88722a3061a7d2ba7f62859cb0fb72edbf9485016d42d22d"},{"filename":"skills/odoo-model-patterns-18.md","content":"# Odoo Model Patterns - Version 18.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 18.0 MODEL PATTERNS ║\n║ This file contains ONLY Odoo 18.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 18.0 Requirements\n\n- **Python**: 3.10+ required, 3.12 recommended\n- **Type Hints**: Recommended (will be mandatory in v19)\n- **SQL Builder**: Use `SQL()` for raw SQL (mandatory in v19)\n- **Company Check**: Use `_check_company_auto = True`\n- **Decorators**: `@api.model_create_multi` mandatory\n\n## Model Definition (v18)\n\n```python\n# -*- coding: utf-8 -*-\nfrom typing import Optional\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import UserError, ValidationError\nfrom odoo.tools import SQL\n\n\nclass MyModel(models.Model):\n _name = 'my_module.my_model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n\n # v18: Enable automatic company check\n _check_company_auto = True\n\n # === BASIC FIELDS === #\n name = fields.Char(\n string='Name',\n required=True,\n tracking=True,\n )\n active = fields.Boolean(default=True)\n sequence = fields.Integer(default=10)\n description = fields.Text(string='Description')\n\n # === RELATIONAL FIELDS === #\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n tracking=True,\n check_company=True, # v18: Check company consistency\n )\n user_id = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n check_company=True,\n )\n line_ids = fields.One2many(\n comodel_name='my_module.my_model.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n tag_ids = fields.Many2many(\n comodel_name='my_module.tag',\n string='Tags',\n )\n\n # === SELECTION FIELDS === #\n state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n copy=False,\n )\n\n # === MONETARY FIELDS === #\n currency_id = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n default=lambda self: self.env.company.currency_id,\n required=True,\n )\n amount = fields.Monetary(\n string='Amount',\n currency_field='currency_id',\n )\n\n # === COMPUTED FIELDS === #\n total_amount = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n )\n display_name = fields.Char(\n compute='_compute_display_name',\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self) -> None:\n \"\"\"Compute total from lines.\"\"\"\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n @api.depends('name', 'sequence')\n def _compute_display_name(self) -> None:\n \"\"\"Compute display name.\"\"\"\n for record in self:\n record.display_name = f\"[{record.sequence}] {record.name or ''}\"\n\n # === CONSTRAINTS === #\n @api.constrains('amount')\n def _check_amount(self) -> None:\n \"\"\"Validate amount is positive.\"\"\"\n for record in self:\n if record.amount \u003c 0:\n raise ValidationError(_(\"Amount must be positive.\"))\n\n _sql_constraints = [\n ('name_uniq', 'unique(company_id, name)', 'Name must be unique per company!'),\n ]\n\n # === CRUD METHODS === #\n @api.model_create_multi\n def create(self, vals_list: list[dict]) -> 'MyModel':\n \"\"\"Override create to add sequence.\"\"\"\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n 'my_module.my_model'\n ) or _('New')\n return super().create(vals_list)\n\n def write(self, vals: dict) -> bool:\n \"\"\"Override write with validation.\"\"\"\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Cannot complete without lines.\"))\n return super().write(vals)\n\n def unlink(self) -> bool:\n \"\"\"Prevent deletion of confirmed records.\"\"\"\n if any(rec.state != 'draft' for rec in self):\n raise UserError(_(\"Cannot delete confirmed records.\"))\n return super().unlink()\n\n def copy(self, default: Optional[dict] = None) -> 'MyModel':\n \"\"\"Custom copy with name suffix.\"\"\"\n default = dict(default or {})\n default.setdefault('name', _(\"%s (Copy)\", self.name))\n return super().copy(default)\n\n # === x2many OPERATIONS - Use Command class === #\n def action_add_line(self) -> None:\n \"\"\"Add a new line using Command.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New Line', 'amount': 0}),\n ]\n })\n\n def action_update_lines(self) -> None:\n \"\"\"Command class operations reference.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New'}), # 0: Create new\n Command.update(1, {'name': 'Updated'}), # 1: Update existing\n Command.delete(2), # 2: Delete from DB\n Command.unlink(3), # 3: Remove relation\n Command.link(4), # 4: Link existing\n Command.clear(), # 5: Clear all\n Command.set([5, 6, 7]), # 6: Replace with IDs\n ]\n })\n\n # === ACTION METHODS === #\n def action_confirm(self) -> None:\n \"\"\"Confirm records.\"\"\"\n self.write({'state': 'confirmed'})\n\n def action_done(self) -> None:\n \"\"\"Mark as done.\"\"\"\n self.write({'state': 'done'})\n\n def action_cancel(self) -> None:\n \"\"\"Cancel records.\"\"\"\n self.write({'state': 'cancelled'})\n\n def action_draft(self) -> None:\n \"\"\"Reset to draft.\"\"\"\n self.write({'state': 'draft'})\n\n # === SEARCH METHODS === #\n @api.model\n def _name_search(\n self,\n name: str = '',\n domain: Optional[list] = None,\n operator: str = 'ilike',\n limit: int = 100,\n order: Optional[str] = None,\n ):\n \"\"\"Extended name search.\"\"\"\n domain = domain or []\n if name:\n domain = ['|', ('name', operator, name), ('sequence', operator, name)] + domain\n return self._search(domain, limit=limit, order=order)\n\n # === SQL OPERATIONS (v18 pattern) === #\n def _get_report_data(self) -> list[dict]:\n \"\"\"Use SQL builder for complex queries.\"\"\"\n query = SQL(\n \"\"\"\n SELECT\n m.id,\n m.name,\n m.state,\n SUM(l.amount) as total\n FROM %s m\n LEFT JOIN %s l ON l.parent_id = m.id\n WHERE m.company_id IN %s\n GROUP BY m.id, m.name, m.state\n ORDER BY m.create_date DESC\n \"\"\",\n SQL.identifier(self._table),\n SQL.identifier('my_module_my_model_line'),\n tuple(self.env.companies.ids),\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n\n # === ONCHANGE METHODS === #\n @api.onchange('partner_id')\n def _onchange_partner_id(self) -> None:\n \"\"\"Update user when partner changes.\"\"\"\n if self.partner_id and self.partner_id.user_id:\n self.user_id = self.partner_id.user_id\n```\n\n## Line Model (v18)\n\n```python\nclass MyModelLine(models.Model):\n _name = 'my_module.my_model.line'\n _description = 'My Model Line'\n _order = 'sequence, id'\n\n _check_company_auto = True\n\n parent_id = fields.Many2one(\n comodel_name='my_module.my_model',\n string='Parent',\n required=True,\n ondelete='cascade',\n index=True,\n )\n company_id = fields.Many2one(\n related='parent_id.company_id',\n store=True,\n )\n sequence = fields.Integer(default=10)\n name = fields.Char(string='Description', required=True)\n product_id = fields.Many2one(\n comodel_name='product.product',\n string='Product',\n check_company=True,\n )\n quantity = fields.Float(string='Quantity', default=1.0)\n price_unit = fields.Float(string='Unit Price')\n amount = fields.Float(\n string='Amount',\n compute='_compute_amount',\n store=True,\n )\n\n @api.depends('quantity', 'price_unit')\n def _compute_amount(self) -> None:\n for line in self:\n line.amount = line.quantity * line.price_unit\n\n @api.onchange('product_id')\n def _onchange_product_id(self) -> None:\n if self.product_id:\n self.name = self.product_id.display_name\n self.price_unit = self.product_id.lst_price\n```\n\n## Inheritance Patterns (v18)\n\n### Classical Inheritance (Extension)\n\n```python\nclass ResPartner(models.Model):\n _inherit = 'res.partner'\n\n custom_field = fields.Char(string='Custom Field')\n my_model_ids = fields.One2many(\n comodel_name='my_module.my_model',\n inverse_name='partner_id',\n string='Related Records',\n )\n\n @api.model_create_multi\n def create(self, vals_list: list[dict]) -> 'ResPartner':\n \"\"\"Extend create with custom logic.\"\"\"\n records = super().create(vals_list)\n for record in records:\n if record.custom_field:\n record._process_custom_field()\n return records\n\n def _process_custom_field(self) -> None:\n \"\"\"Custom processing logic.\"\"\"\n pass\n```\n\n### Delegation Inheritance\n\n```python\nclass ExtendedModel(models.Model):\n _name = 'my_module.extended_model'\n _inherits = {'my_module.my_model': 'base_id'}\n _description = 'Extended Model'\n\n base_id = fields.Many2one(\n comodel_name='my_module.my_model',\n required=True,\n ondelete='cascade',\n auto_join=True,\n )\n extra_field = fields.Char(string='Extra Field')\n```\n\n### Abstract Model (Mixin)\n\n```python\nclass ApprovalMixin(models.AbstractModel):\n _name = 'my_module.approval.mixin'\n _description = 'Approval Mixin'\n\n approval_state = fields.Selection(\n selection=[\n ('pending', 'Pending'),\n ('approved', 'Approved'),\n ('rejected', 'Rejected'),\n ],\n default='pending',\n tracking=True,\n )\n approved_by = fields.Many2one(\n comodel_name='res.users',\n string='Approved By',\n )\n approval_date = fields.Datetime(string='Approval Date')\n\n def action_approve(self) -> None:\n self.write({\n 'approval_state': 'approved',\n 'approved_by': self.env.user.id,\n 'approval_date': fields.Datetime.now(),\n })\n\n def action_reject(self) -> None:\n self.write({\n 'approval_state': 'rejected',\n 'approved_by': self.env.user.id,\n 'approval_date': fields.Datetime.now(),\n })\n```\n\n## Transient Model (Wizard) - v18\n\n```python\nclass MyWizard(models.TransientModel):\n _name = 'my_module.wizard'\n _description = 'My Wizard'\n\n def _default_record_ids(self) -> 'MyModel':\n return self.env['my_module.my_model'].browse(\n self._context.get('active_ids', [])\n )\n\n record_ids = fields.Many2many(\n comodel_name='my_module.my_model',\n string='Records',\n default=_default_record_ids,\n )\n date = fields.Date(\n string='Date',\n default=fields.Date.context_today,\n required=True,\n )\n note = fields.Text(string='Note')\n\n def action_confirm(self) -> dict:\n \"\"\"Execute wizard action.\"\"\"\n self.ensure_one()\n self.record_ids.write({\n 'state': 'confirmed',\n })\n return {'type': 'ir.actions.act_window_close'}\n\n def action_confirm_and_view(self) -> dict:\n \"\"\"Execute and return action to view records.\"\"\"\n self.action_confirm()\n return {\n 'type': 'ir.actions.act_window',\n 'res_model': 'my_module.my_model',\n 'view_mode': 'tree,form',\n 'domain': [('id', 'in', self.record_ids.ids)],\n 'target': 'current',\n }\n```\n\n## v18 Specific Features\n\n### Type Hints (Recommended)\n\n```python\nfrom typing import Optional, Any\n\ndef custom_method(\n self,\n partner_id: int,\n options: Optional[dict[str, Any]] = None,\n) -> list[dict[str, Any]]:\n \"\"\"Method with type hints.\"\"\"\n options = options or {}\n # ...\n return []\n```\n\n### SQL Builder Pattern\n\n```python\nfrom odoo.tools import SQL\n\ndef _execute_query(self) -> list[dict]:\n \"\"\"Use SQL() for safe query building.\"\"\"\n query = SQL(\n \"\"\"\n SELECT id, name, amount\n FROM %s\n WHERE company_id = %s\n AND state = %s\n ORDER BY %s\n \"\"\",\n SQL.identifier(self._table),\n self.env.company.id,\n 'confirmed',\n SQL('create_date DESC'),\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n### Company Check Auto\n\n```python\nclass MyModel(models.Model):\n _name = 'my_module.my_model'\n _check_company_auto = True # v18: Enable automatic checks\n\n company_id = fields.Many2one('res.company', required=True)\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True, # Will be auto-validated\n )\n```\n\n## v18 Decorator Reference\n\n| Decorator | Usage |\n|-----------|-------|\n| `@api.model` | Class-level method, no recordset |\n| `@api.model_create_multi` | Create method (mandatory) |\n| `@api.depends(*fields)` | Compute dependency |\n| `@api.constrains(*fields)` | Validation constraint |\n| `@api.onchange(*fields)` | UI change handler |\n| `@api.depends_context(*keys)` | Context-dependent compute |\n| `@api.autovacuum` | Scheduled cleanup |\n\n## v18 Checklist\n\n- [ ] Use `_check_company_auto = True` for multi-company models\n- [ ] Add `check_company=True` to relational fields\n- [ ] Use `@api.model_create_multi` for create methods\n- [ ] Use `SQL()` builder for raw SQL queries\n- [ ] Add type hints (recommended, mandatory in v19)\n- [ ] Use `Command` class for x2many operations\n- [ ] Use direct `invisible`/`readonly` in views (not `attrs`)\n\n## AI Agent Instructions (v18)\n\nWhen generating Odoo 18.0 models:\n\n1. **ALWAYS** use `_check_company_auto = True` for multi-company\n2. **ALWAYS** use `@api.model_create_multi` for create\n3. **USE** `check_company=True` on relational fields\n4. **USE** `SQL()` builder for raw SQL (prepare for v19)\n5. **ADD** type hints where appropriate (prepare for v19)\n6. **USE** `Command` class for x2many operations\n7. **USE** `tracking=True` for audited fields\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15799,"content_sha256":"43d83e501d80c3dcbcc837f40246099225e10901ad0b0e39d9410ee25bbf3cfc"},{"filename":"skills/odoo-model-patterns-19.md","content":"# Odoo 19.0 Model Patterns\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 19.0 ORM PATTERNS ║\n║ Type hints mandatory, SQL() required, OWL 3.x ║\n║ WARNING: v19 is in development - patterns may change ║\n║ VERIFY: https://github.com/odoo/odoo/tree/master/odoo/models.py ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Patterns\n\n### Key Characteristics\n| Feature | Odoo 19.0 Pattern |\n|---------|-------------------|\n| Type hints | **MANDATORY** for public methods |\n| Raw SQL | **SQL() required** - string queries deprecated |\n| X2many commands | `Command` class |\n| Multi-company | `_check_company_auto` + `check_company=True` |\n| OWL | Version 3.x |\n| Python version | 3.12+ |\n\n## BREAKING: Type Hints Mandatory\n\n```python\n# v18 (optional):\ndef action_confirm(self):\n pass\n\n# v19 (MANDATORY):\ndef action_confirm(self) -> bool:\n return True\n\n# Full type hints example\ndef create_record(self, name: str, partner_id: int | None = None) -> 'MyModel':\n return self.create({'name': name, 'partner_id': partner_id})\n```\n\n## BREAKING: SQL() Builder Required\n\n```python\nfrom odoo.tools import SQL\n\n# v18 (deprecated string queries):\nself.env.cr.execute(\"SELECT id FROM my_model WHERE state = %s\", ['draft'])\n\n# v19 (REQUIRED SQL() builder):\nself.env.cr.execute(SQL(\n \"SELECT id FROM my_model WHERE state = %s\",\n 'draft'\n))\n\n# Complex query\nself.env.cr.execute(SQL(\n \"\"\"\n SELECT m.id, m.name, COUNT(l.id) as line_count\n FROM my_model m\n LEFT JOIN my_model_line l ON l.model_id = m.id\n WHERE m.state = %s AND m.company_id = %s\n GROUP BY m.id, m.name\n HAVING COUNT(l.id) > %s\n \"\"\",\n 'confirmed', self.env.company.id, 0\n))\n```\n\n## Model Definition\n\n```python\nfrom __future__ import annotations\nfrom typing import TYPE_CHECKING\n\nfrom odoo import models, fields, api, _\nfrom odoo.fields import Command\nfrom odoo.exceptions import UserError, ValidationError\nfrom odoo.tools import SQL\nimport logging\n\nif TYPE_CHECKING:\n from odoo.addons.base.models.res_partner import Partner\n from odoo.addons.base.models.res_company import Company\n\n_logger = logging.getLogger(__name__)\n\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'sequence, id desc'\n _check_company_auto = True # v19: Auto company check\n\n # String fields\n name: str = fields.Char(\n string='Name',\n required=True,\n index='trigram',\n tracking=True,\n )\n\n code: str = fields.Char(\n index=True,\n copy=False,\n )\n\n # Selection field\n state: str = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('in_progress', 'In Progress'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ], default='draft', tracking=True, index=True)\n\n priority: str = fields.Selection([\n ('0', 'Normal'),\n ('1', 'Low'),\n ('2', 'High'),\n ('3', 'Very High'),\n ], default='0', index=True)\n\n # Integer/Float fields\n sequence: int = fields.Integer(default=10)\n quantity: float = fields.Float(digits='Product Unit of Measure')\n\n # Boolean\n active: bool = fields.Boolean(default=True)\n\n # Date fields\n date: fields.Date = fields.Date(default=fields.Date.context_today)\n date_deadline: fields.Date = fields.Date(index=True)\n\n # Relational fields with company check\n company_id: Company = fields.Many2one(\n 'res.company',\n required=True,\n default=lambda self: self.env.company,\n index=True,\n )\n\n user_id: models.Model = fields.Many2one(\n 'res.users',\n default=lambda self: self.env.user,\n tracking=True,\n check_company=True, # v19: Enforce company\n )\n\n partner_id: Partner = fields.Many2one(\n 'res.partner',\n tracking=True,\n check_company=True, # v19: Enforce company\n )\n\n line_ids: models.Model = fields.One2many(\n 'my.model.line',\n 'model_id',\n copy=True,\n )\n\n tag_ids: models.Model = fields.Many2many('my.model.tag')\n\n # Monetary fields\n total_amount: float = fields.Monetary(\n compute='_compute_total',\n store=True,\n currency_field='currency_id',\n )\n\n currency_id: models.Model = fields.Many2one(\n 'res.currency',\n related='company_id.currency_id',\n )\n\n # Computed count\n line_count: int = fields.Integer(\n compute='_compute_line_count',\n )\n\n # HTML/Text\n description: str = fields.Html(sanitize=True)\n note: str = fields.Text()\n```\n\n## CRUD Methods with Type Hints\n\n```python\[email protected]_create_multi\ndef create(self, vals_list: list[dict]) -> 'MyModel':\n \"\"\"Create records with auto-generated code.\n\n Args:\n vals_list: List of value dictionaries\n\n Returns:\n Created records\n \"\"\"\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n if 'company_id' not in vals:\n vals['company_id'] = self.env.company.id\n\n records = super().create(vals_list)\n\n for record in records:\n record.message_post(body=_(\"Record created.\"))\n\n return records\n\ndef write(self, vals: dict) -> bool:\n \"\"\"Update records with validation.\n\n Args:\n vals: Values to update\n\n Returns:\n True on success\n \"\"\"\n if 'state' in vals and vals['state'] == 'confirmed':\n for record in self:\n if not record.line_ids:\n raise UserError(\n _(\"Record '%s' must have at least one line.\") % record.name\n )\n\n return super().write(vals)\n\ndef unlink(self) -> bool:\n \"\"\"Delete records with state check.\n\n Returns:\n True on success\n\n Raises:\n UserError: If record is not draft or cancelled\n \"\"\"\n for record in self:\n if record.state not in ('draft', 'cancelled'):\n raise UserError(\n _(\"Cannot delete '%s'. Only draft or cancelled records can be deleted.\")\n % record.name\n )\n return super().unlink()\n\ndef copy(self, default: dict | None = None) -> 'MyModel':\n \"\"\"Copy record with name suffix.\n\n Args:\n default: Override values for copy\n\n Returns:\n New copied record\n \"\"\"\n self.ensure_one()\n default = dict(default or {})\n if 'name' not in default:\n default['name'] = _(\"%s (copy)\", self.name)\n default['state'] = 'draft'\n return super().copy(default)\n```\n\n## Computed Fields with Types\n\n```python\[email protected]('line_ids.amount')\ndef _compute_total(self) -> None:\n \"\"\"Compute total amount from lines.\"\"\"\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\[email protected]('line_ids')\ndef _compute_line_count(self) -> None:\n \"\"\"Compute number of lines.\"\"\"\n for record in self:\n record.line_count = len(record.line_ids)\n\n# Computed with search\nis_overdue: bool = fields.Boolean(\n compute='_compute_is_overdue',\n search='_search_is_overdue',\n store=True,\n)\n\[email protected]('date_deadline', 'state')\ndef _compute_is_overdue(self) -> None:\n \"\"\"Compute overdue status.\"\"\"\n today = fields.Date.context_today(self)\n for record in self:\n record.is_overdue = (\n record.date_deadline\n and record.date_deadline \u003c today\n and record.state not in ('done', 'cancelled')\n )\n\ndef _search_is_overdue(self, operator: str, value: bool) -> list:\n \"\"\"Search implementation for is_overdue.\n\n Args:\n operator: Search operator\n value: Search value\n\n Returns:\n Search domain\n \"\"\"\n today = fields.Date.context_today(self)\n if (operator == '=' and value) or (operator == '!=' and not value):\n return [\n ('date_deadline', '\u003c', today),\n ('state', 'not in', ('done', 'cancelled')),\n ]\n return ['|', ('date_deadline', '>=', today), ('date_deadline', '=', False)]\n```\n\n## SQL Queries with SQL() Builder\n\n```python\ndef get_summary_data(self) -> list[dict]:\n \"\"\"Get summary data using SQL() builder.\n\n Returns:\n List of summary dictionaries\n \"\"\"\n self.env.cr.execute(SQL(\n \"\"\"\n SELECT\n state,\n COUNT(*) as count,\n SUM(total_amount) as total\n FROM my_model\n WHERE company_id = %s\n GROUP BY state\n ORDER BY count DESC\n \"\"\",\n self.env.company.id\n ))\n return self.env.cr.dictfetchall()\n\ndef find_duplicates(self, name: str) -> list[int]:\n \"\"\"Find records with similar names.\n\n Args:\n name: Name to search\n\n Returns:\n List of record IDs\n \"\"\"\n self.env.cr.execute(SQL(\n \"\"\"\n SELECT id FROM my_model\n WHERE company_id = %s\n AND name ILIKE %s\n AND id != %s\n \"\"\",\n self.env.company.id,\n f'%{name}%',\n self.id or 0\n ))\n return [row[0] for row in self.env.cr.fetchall()]\n\ndef bulk_update_state(self, state: str, record_ids: list[int]) -> int:\n \"\"\"Bulk update state using raw SQL.\n\n Args:\n state: New state value\n record_ids: Record IDs to update\n\n Returns:\n Number of updated records\n \"\"\"\n if not record_ids:\n return 0\n\n self.env.cr.execute(SQL(\n \"\"\"\n UPDATE my_model\n SET state = %s, write_date = NOW(), write_uid = %s\n WHERE id = ANY(%s)\n AND company_id = %s\n \"\"\",\n state,\n self.env.uid,\n record_ids,\n self.env.company.id\n ))\n\n # Invalidate cache for updated records\n self.browse(record_ids).invalidate_recordset()\n\n return self.env.cr.rowcount\n```\n\n## Action Methods with Types\n\n```python\ndef action_confirm(self) -> bool:\n \"\"\"Confirm records.\n\n Returns:\n True on success\n\n Raises:\n UserError: If validation fails\n \"\"\"\n for record in self.filtered(lambda r: r.state == 'draft'):\n if not record.line_ids:\n raise UserError(_(\"Add at least one line to confirm '%s'.\") % record.name)\n\n self.filtered(lambda r: r.state == 'draft').write({'state': 'confirmed'})\n return True\n\ndef action_start(self) -> bool:\n \"\"\"Start work on records.\"\"\"\n self.filtered(lambda r: r.state == 'confirmed').write({'state': 'in_progress'})\n return True\n\ndef action_done(self) -> bool:\n \"\"\"Mark records as done.\"\"\"\n self.filtered(lambda r: r.state == 'in_progress').write({'state': 'done'})\n return True\n\ndef action_cancel(self) -> bool:\n \"\"\"Cancel records.\"\"\"\n self.filtered(lambda r: r.state not in ('done', 'cancelled')).write({'state': 'cancelled'})\n return True\n\ndef action_draft(self) -> bool:\n \"\"\"Reset to draft.\"\"\"\n self.filtered(lambda r: r.state == 'cancelled').write({'state': 'draft'})\n return True\n\ndef action_view_lines(self) -> dict:\n \"\"\"Return action to view lines.\n\n Returns:\n Action dictionary\n \"\"\"\n self.ensure_one()\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Lines'),\n 'res_model': 'my.model.line',\n 'view_mode': 'tree,form',\n 'domain': [('model_id', '=', self.id)],\n 'context': {'default_model_id': self.id},\n }\n```\n\n## Constraints with Types\n\n```python\n_sql_constraints = [\n ('code_company_uniq', 'unique(code, company_id)',\n 'Code must be unique per company!'),\n ('positive_quantity', 'CHECK(quantity >= 0)',\n 'Quantity must be positive!'),\n]\n\[email protected]('date', 'date_deadline')\ndef _check_dates(self) -> None:\n \"\"\"Validate date order.\n\n Raises:\n ValidationError: If dates are invalid\n \"\"\"\n for record in self:\n if record.date and record.date_deadline:\n if record.date > record.date_deadline:\n raise ValidationError(\n _(\"Deadline must be after start date for '%s'.\") % record.name\n )\n\[email protected]('line_ids')\ndef _check_lines(self) -> None:\n \"\"\"Validate lines have positive amounts.\n\n Raises:\n ValidationError: If any line has invalid amount\n \"\"\"\n for record in self:\n for line in record.line_ids:\n if line.amount \u003c 0:\n raise ValidationError(\n _(\"Line '%s' cannot have negative amount.\") % line.name\n )\n```\n\n## XML Views (v19)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"view_my_model_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">my.model.form\u003c/field>\n \u003cfield name=\"model\">my.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\"\n string=\"Confirm\" class=\"btn-primary\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_start\" type=\"object\"\n string=\"Start\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_done\" type=\"object\"\n string=\"Done\"\n invisible=\"state != 'in_progress'\"/>\n \u003cbutton name=\"action_cancel\" type=\"object\"\n string=\"Cancel\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\n \u003cfield name=\"name\" readonly=\"state == 'done'\"/>\n \u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"\n readonly=\"state == 'done'\"\n required=\"state == 'confirmed'\"/>\n \u003cfield name=\"date\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"total_amount\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\">\n \u003cfield name=\"line_ids\" readonly=\"state == 'done'\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v19 Best Practices\n\n1. **Type hints on all public methods** - Mandatory for code quality\n2. **Use SQL() builder** - Never use string SQL queries\n3. **_check_company_auto = True** - On multi-company models\n4. **check_company=True** - On relational fields\n5. **Use OWL 3.x patterns** - For frontend components\n6. **from __future__ import annotations** - For forward references\n\n## Type Hint Reference\n\n```python\n# Import types\nfrom __future__ import annotations\nfrom typing import TYPE_CHECKING, Any\n\nif TYPE_CHECKING:\n from odoo.addons.base.models.res_partner import Partner\n\n# Method signatures\ndef method_name(self, param: str, optional: int | None = None) -> bool:\n pass\n\n# Return types\ndef returns_recordset(self) -> 'MyModel':\n pass\n\ndef returns_list(self) -> list[dict[str, Any]]:\n pass\n\ndef returns_dict(self) -> dict[str, str | int]:\n pass\n```\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":16176,"content_sha256":"8003d301ada33b535116d410ff053e075933dd8803691118f5ece4e94d65598d"},{"filename":"skills/odoo-model-patterns-all.md","content":"# Odoo Model Patterns - Core Concepts (All Versions)\n\nThis document covers ORM model concepts that are consistent across all Odoo versions. For version-specific implementation details, see the version-specific files.\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ CRITICAL: Always use version-specific patterns! ║\n║ ║\n║ Version-specific files: odoo-model-patterns-{14|15|16|17|18|19}.md ║\n║ Migration guides: odoo-model-patterns-{from}-{to}.md ║\n║ ║\n║ Key differences between versions: ║\n║ • v14: @api.multi still used (deprecated) ║\n║ • v15: @api.multi removed, use multi-record methods ║\n║ • v16: Command class introduced, attrs deprecated ║\n║ • v17: @api.model_create_multi mandatory, attrs removed ║\n║ • v18: _check_company_auto, SQL() builder, type hints recommended ║\n║ • v19: Type hints mandatory, SQL() mandatory ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Model Types\n\n### models.Model\nPersistent business data with database table.\n\n```python\nclass MyModel(models.Model):\n _name = 'my_module.my_model'\n _description = 'My Model'\n```\n\nUse for:\n- Business entities (partners, products, orders)\n- Configuration records\n- Master data\n\n### models.TransientModel\nTemporary data that is automatically cleaned up.\n\n```python\nclass MyWizard(models.TransientModel):\n _name = 'my_module.wizard'\n _description = 'My Wizard'\n```\n\nUse for:\n- Wizards and dialogs\n- Batch operations\n- Temporary input forms\n\n### models.AbstractModel\nNo database table, provides shared functionality.\n\n```python\nclass MyMixin(models.AbstractModel):\n _name = 'my_module.mixin'\n _description = 'My Mixin'\n```\n\nUse for:\n- Mixins (mail.thread, portal.mixin)\n- Shared field/method definitions\n- Reusable behaviors\n\n## Model Attributes\n\n| Attribute | Description | Example |\n|-----------|-------------|---------|\n| `_name` | Technical name | `'my_module.model'` |\n| `_description` | Human name | `'My Model'` |\n| `_inherit` | Extend models | `['mail.thread']` |\n| `_inherits` | Delegation | `{'res.partner': 'partner_id'}` |\n| `_table` | Custom table name | `'my_custom_table'` |\n| `_order` | Default sort | `'sequence, name'` |\n| `_rec_name` | Display field | `'name'` |\n| `_parent_name` | Parent field | `'parent_id'` |\n| `_parent_store` | Hierarchical | `True` |\n| `_log_access` | Track access | `True` (default) |\n| `_auto` | Auto create table | `True` (default) |\n\n## Field Types\n\n### Basic Fields\n\n| Type | Description | Common Attributes |\n|------|-------------|-------------------|\n| `Char` | Short text | `size`, `trim` |\n| `Text` | Long text | - |\n| `Html` | Rich HTML | `sanitize` |\n| `Boolean` | True/False | - |\n| `Integer` | Whole number | - |\n| `Float` | Decimal | `digits` |\n| `Monetary` | Currency | `currency_field` |\n| `Date` | Date only | - |\n| `Datetime` | Date + time | - |\n| `Binary` | File data | `attachment` |\n| `Image` | Image binary | `max_width`, `max_height` |\n| `Selection` | Dropdown | `selection` |\n\n### Relational Fields\n\n| Type | Description | Key Attributes |\n|------|-------------|----------------|\n| `Many2one` | FK reference | `comodel_name`, `ondelete` |\n| `One2many` | Reverse FK | `comodel_name`, `inverse_name` |\n| `Many2many` | Junction table | `comodel_name`, `relation` |\n\n### Special Fields\n\n| Type | Description |\n|------|-------------|\n| `Reference` | Dynamic model reference |\n| `Monetary` | Currency-aware amount |\n| `Properties` | Dynamic key-value |\n| `PropertiesDefinition` | Define properties schema |\n\n## Common Field Attributes\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `string` | str | User-facing label |\n| `required` | bool | Must have value |\n| `readonly` | bool | Cannot edit |\n| `default` | value/callable | Default value |\n| `index` | bool | Database index |\n| `copy` | bool | Copy on duplicate |\n| `groups` | str | Access groups |\n| `tracking` | bool | Track changes |\n| `compute` | str | Compute method |\n| `inverse` | str | Inverse method |\n| `store` | bool | Store computed |\n| `help` | str | Tooltip text |\n| `translate` | bool | Translatable |\n\n## Compute Patterns\n\n### Basic Compute\n\n```python\ntotal = fields.Float(compute='_compute_total', store=True)\n\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n```\n\n### Inverse Method\n\n```python\nfull_name = fields.Char(compute='_compute_full', inverse='_inverse_full')\n\[email protected]('first_name', 'last_name')\ndef _compute_full(self):\n for rec in self:\n rec.full_name = f\"{rec.first_name} {rec.last_name}\"\n\ndef _inverse_full(self):\n for rec in self:\n parts = (rec.full_name or '').split(' ', 1)\n rec.first_name = parts[0]\n rec.last_name = parts[1] if len(parts) > 1 else ''\n```\n\n### Context-Dependent\n\n```python\nis_manager = fields.Boolean(compute='_compute_is_manager')\n\[email protected]_context('uid')\ndef _compute_is_manager(self):\n manager_group = self.env.ref('module.group_manager')\n is_mgr = manager_group in self.env.user.groups_id\n for rec in self:\n rec.is_manager = is_mgr\n```\n\n## Constraint Patterns\n\n### Python Constraints\n\n```python\[email protected]('start_date', 'end_date')\ndef _check_dates(self):\n for record in self:\n if record.start_date > record.end_date:\n raise ValidationError(_(\"End date must be after start date.\"))\n```\n\n### SQL Constraints\n\n```python\n_sql_constraints = [\n ('name_uniq', 'unique(company_id, name)', 'Name must be unique!'),\n ('positive_amount', 'CHECK(amount >= 0)', 'Amount must be positive!'),\n]\n```\n\n## Inheritance Types\n\n### Extension (Classical)\nExtends existing model with new fields/methods.\n\n```python\nclass ResPartner(models.Model):\n _inherit = 'res.partner'\n\n custom_field = fields.Char()\n```\n\n### Prototype (New Model)\nCreates new model based on existing one.\n\n```python\nclass MyPartner(models.Model):\n _name = 'my.partner'\n _inherit = 'res.partner'\n```\n\n### Delegation\nOne model contains another.\n\n```python\nclass ExtendedProduct(models.Model):\n _name = 'extended.product'\n _inherits = {'product.product': 'product_id'}\n\n product_id = fields.Many2one('product.product', required=True)\n extra_field = fields.Char()\n```\n\n## CRUD Method Patterns\n\n### Create\n\n```python\[email protected]_create_multi # Required in v17+\ndef create(self, vals_list):\n for vals in vals_list:\n # Pre-processing\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code('my.model')\n records = super().create(vals_list)\n # Post-processing\n for record in records:\n record._after_create()\n return records\n```\n\n### Write\n\n```python\ndef write(self, vals):\n # Pre-validation\n if 'state' in vals:\n self._check_state_transition(vals['state'])\n result = super().write(vals)\n # Post-processing\n if 'important_field' in vals:\n self._notify_change()\n return result\n```\n\n### Unlink\n\n```python\ndef unlink(self):\n # Validation\n if any(rec.state != 'draft' for rec in self):\n raise UserError(_(\"Cannot delete non-draft records.\"))\n return super().unlink()\n```\n\n### Copy\n\n```python\ndef copy(self, default=None):\n default = dict(default or {})\n default.setdefault('name', _(\"%s (Copy)\", self.name))\n default.setdefault('state', 'draft')\n return super().copy(default)\n```\n\n## Search Patterns\n\n### Domain Operators\n\n| Operator | Description |\n|----------|-------------|\n| `=`, `!=` | Equals, not equals |\n| `\u003c`, `>`, `\u003c=`, `>=` | Comparisons |\n| `like`, `ilike` | Pattern match |\n| `=like`, `=ilike` | SQL pattern |\n| `in`, `not in` | List membership |\n| `child_of`, `parent_of` | Hierarchy |\n| `=?` | Unset or equals |\n\n### Domain Combinations\n\n```python\n# AND (implicit)\ndomain = [('state', '=', 'confirmed'), ('amount', '>', 0)]\n\n# OR\ndomain = ['|', ('state', '=', 'draft'), ('state', '=', 'confirmed')]\n\n# NOT\ndomain = ['!', ('active', '=', False)]\n\n# Complex\ndomain = [\n '|',\n '&', ('state', '=', 'draft'), ('user_id', '=', False),\n '&', ('state', '=', 'confirmed'), ('user_id', '!=', False),\n]\n```\n\n### Name Search\n\n```python\[email protected]\ndef _name_search(self, name='', domain=None, operator='ilike', limit=100, order=None):\n domain = domain or []\n if name:\n domain = ['|', '|',\n ('name', operator, name),\n ('code', operator, name),\n ('reference', operator, name),\n ] + domain\n return self._search(domain, limit=limit, order=order)\n```\n\n## Action Return Patterns\n\n### Open Form View\n\n```python\ndef action_view_form(self):\n return {\n 'type': 'ir.actions.act_window',\n 'res_model': 'my.model',\n 'res_id': self.id,\n 'view_mode': 'form',\n 'target': 'current',\n }\n```\n\n### Open List View\n\n```python\ndef action_view_list(self):\n return {\n 'type': 'ir.actions.act_window',\n 'name': _('Records'),\n 'res_model': 'my.model',\n 'view_mode': 'tree,form',\n 'domain': [('partner_id', '=', self.partner_id.id)],\n 'context': {'default_partner_id': self.partner_id.id},\n }\n```\n\n### Open Wizard\n\n```python\ndef action_open_wizard(self):\n return {\n 'type': 'ir.actions.act_window',\n 'res_model': 'my.wizard',\n 'view_mode': 'form',\n 'target': 'new',\n 'context': {'active_ids': self.ids},\n }\n```\n\n### URL Action\n\n```python\ndef action_open_url(self):\n return {\n 'type': 'ir.actions.act_url',\n 'url': f'/my/portal/{self.id}',\n 'target': 'self',\n }\n```\n\n## Environment Patterns\n\n### User and Company\n\n```python\ncurrent_user = self.env.user\ncurrent_company = self.env.company\nall_companies = self.env.companies\n\n# Sudo for elevated access\nadmin_user = self.env.ref('base.user_admin').sudo()\n```\n\n### Context Manipulation\n\n```python\n# Add to context\nrecords = self.with_context(skip_validation=True)\n\n# Replace context\nrecords = self.with_context({'lang': 'fr_FR'})\n\n# Read context\nlang = self.env.context.get('lang', 'en_US')\n```\n\n### Company Switch\n\n```python\n# Work in different company\nother_company = self.env['res.company'].browse(2)\nrecords = self.with_company(other_company)\n```\n\n## Performance Best Practices\n\n1. **Batch Operations**: Process records in batches\n2. **Prefetch**: Use `prefetch_fields` for related data\n3. **Store Computed**: Store frequently accessed computes\n4. **Indexes**: Add indexes to searched fields\n5. **Avoid N+1**: Use `mapped()` instead of loops\n6. **Limit Reads**: Only read needed fields\n\n```python\n# Good: Batch read\npartners = self.env['res.partner'].search([])\nnames = partners.mapped('name') # Single query\n\n# Bad: N+1 queries\nfor partner in partners:\n print(partner.name) # Query per record\n```\n\n## Error Handling\n\n```python\nfrom odoo.exceptions import (\n UserError, # User-facing error\n ValidationError, # Constraint violation\n AccessError, # Permission denied\n MissingError, # Record not found\n RedirectWarning, # Error with action button\n)\n\n# User error\nif not self.partner_id:\n raise UserError(_(\"Partner is required.\"))\n\n# Validation error (in @api.constrains)\nif self.amount \u003c 0:\n raise ValidationError(_(\"Amount must be positive.\"))\n\n# Redirect warning\naction = self.env.ref('module.action_id')\nraise RedirectWarning(\n _(\"Configuration required.\"),\n action.id,\n _(\"Go to Settings\"),\n)\n```\n\n---\n\n**Note**: This document covers concepts that apply to all versions. For version-specific syntax and patterns, refer to the appropriate version-specific file.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12379,"content_sha256":"1f31c4c0328e3d176f17d2edd89c88cdf04eb228543c95213ea86fd003d462e4"},{"filename":"skills/odoo-model-patterns.md","content":"# Odoo Model Patterns - Version Dispatcher\n\n## CRITICAL: VERSION-SPECIFIC REQUIREMENTS\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ║\n║ ⚠️ MANDATORY VERSION MATCHING ⚠️ ║\n║ ║\n║ You MUST use the version-specific model patterns that match your ║\n║ target Odoo version. Using patterns from the wrong version WILL ║\n║ cause errors or deprecated code warnings. ║\n║ ║\n║ BEFORE implementing ANY model code, identify your target Odoo version ║\n║ and load the corresponding file. This is NOT optional. ║\n║ ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Files\n\n| Target Version | File to Use | Status |\n|----------------|-------------|--------|\n| Odoo 14.0 | `odoo-model-patterns-14.md` | Legacy |\n| Odoo 15.0 | `odoo-model-patterns-15.md` | Legacy |\n| Odoo 16.0 | `odoo-model-patterns-16.md` | Supported |\n| Odoo 17.0 | `odoo-model-patterns-17.md` | Supported |\n| Odoo 18.0 | `odoo-model-patterns-18.md` | Current |\n| Odoo 19.0 | `odoo-model-patterns-19.md` | Development |\n| All versions | `odoo-model-patterns-all.md` | Core concepts |\n\n## Migration Guides\n\n| Migration Path | File |\n|----------------|------|\n| 14.0 → 15.0 | `odoo-model-patterns-14-15.md` |\n| 15.0 → 16.0 | `odoo-model-patterns-15-16.md` |\n| 16.0 → 17.0 | `odoo-model-patterns-16-17.md` |\n| 17.0 → 18.0 | `odoo-model-patterns-17-18.md` |\n| 18.0 → 19.0 | `odoo-model-patterns-18-19.md` |\n\n## Quick Reference: Major Model Pattern Changes\n\n### v14 Patterns\n- `@api.multi` deprecated (still works)\n- `track_visibility='onchange'`\n- Single record `create(vals)`\n\n### v15 Patterns\n- `@api.multi` removed\n- `tracking=True` replaces `track_visibility`\n- Simplified chatter\n\n### v16 Patterns\n- `Command` class for x2many\n- `@api.model_create_multi` recommended\n\n### v17 Patterns\n- `@api.model_create_multi` mandatory\n- Enhanced ORM methods\n\n### v18 Patterns\n- `_check_company_auto = True`\n- `check_company=True` on fields\n- Type hints recommended\n- `SQL()` builder recommended\n\n### v19 Patterns\n- Type hints mandatory\n- `SQL()` builder mandatory\n\n## Version Detection in Existing Code\n\n| Indicator | Version |\n|-----------|---------|\n| `@api.multi` decorator | 14.0 |\n| `track_visibility` | 14.0 |\n| `tracking=True` | 15.0+ |\n| Tuple syntax for x2many | 14.0-15.0 |\n| `Command` class | 16.0+ |\n| `_check_company_auto` | 18.0+ |\n| Type hints on fields | 18.0+ |\n| Full type annotations | 19.0+ |\n\n---\n\n**REMINDER**: Always load the version-specific file before implementing model patterns.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3364,"content_sha256":"e720009c3ec1e2cc6a8e2b402cf00f867f49a1fa407f8215a20388ba4472d9a8"},{"filename":"skills/odoo-module-generator-14-15.md","content":"# Odoo Module Migration Guide: 14.0 → 15.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODULE MIGRATION: 14.0 → 15.0 ║\n║ @api.multi removed, tracking=True standardized, OWL 1.x introduced ║\n║ VERIFY: https://github.com/odoo/odoo/tree/15.0 ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Migration Overview\n\n| Aspect | v14 | v15 |\n|--------|-----|-----|\n| @api.multi | Deprecated (works) | REMOVED |\n| track_visibility | Deprecated (works) | Deprecated (warns) |\n| tracking=True | Supported | Standard |\n| OWL | Not available | OWL 1.x |\n| Python | 3.6-3.8 | 3.7-3.9 |\n\n## Breaking Changes\n\n### 1. @api.multi REMOVED\n\n```python\n# v14 (works but deprecated):\nfrom odoo import models, api\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @api.multi\n def action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n return True\n\n# v15 (REQUIRED):\nfrom odoo import models\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n def action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n return True\n```\n\n### 2. track_visibility → tracking\n\n```python\n# v14 (works):\nstate = fields.Selection([\n ('draft', 'Draft'),\n ('done', 'Done'),\n], track_visibility='onchange')\n\nname = fields.Char(track_visibility='always')\n\n# v15 (RECOMMENDED):\nstate = fields.Selection([\n ('draft', 'Draft'),\n ('done', 'Done'),\n], tracking=True)\n\nname = fields.Char(tracking=True)\n```\n\n## Manifest Changes\n\n```python\n# v14\n{\n 'name': 'My Module',\n 'version': '14.0.1.0.0',\n 'depends': ['base', 'mail'],\n 'data': [\n 'security/ir.model.access.csv',\n 'views/my_model_views.xml',\n ],\n}\n\n# v15 - Add assets bundle\n{\n 'name': 'My Module',\n 'version': '15.0.1.0.0',\n 'depends': ['base', 'mail', 'web'], # Add 'web' for OWL\n 'data': [\n 'security/ir.model.access.csv',\n 'views/my_model_views.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n 'my_module/static/src/js/**/*',\n 'my_module/static/src/xml/**/*',\n 'my_module/static/src/scss/**/*',\n ],\n },\n}\n```\n\n## Model Migration\n\n### Complete Model Example\n\n```python\n# v14 Model:\nfrom odoo import models, fields, api, _\nfrom odoo.exceptions import UserError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread']\n\n name = fields.Char(required=True, track_visibility='always')\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', track_visibility='onchange')\n\n partner_id = fields.Many2one('res.partner')\n line_ids = fields.One2many('my.model.line', 'model_id')\n\n @api.multi\n def action_confirm(self):\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Add at least one line.\"))\n record.state = 'confirmed'\n return True\n\n @api.multi\n def action_done(self):\n self.write({'state': 'done'})\n return True\n\n @api.model\n def create(self, vals):\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super(MyModel, self).create(vals)\n\n# v15 Model:\nfrom odoo import models, fields, api, _\nfrom odoo.exceptions import UserError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(required=True, tracking=True)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', tracking=True)\n\n partner_id = fields.Many2one('res.partner')\n line_ids = fields.One2many('my.model.line', 'model_id')\n\n def action_confirm(self): # No @api.multi\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Add at least one line.\"))\n record.state = 'confirmed'\n return True\n\n def action_done(self): # No @api.multi\n self.write({'state': 'done'})\n return True\n\n @api.model\n def create(self, vals):\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals) # Python 3 style super()\n```\n\n## View Changes\n\nViews remain largely compatible. No mandatory changes required.\n\n```xml\n\u003c!-- v14/v15: Views are compatible -->\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n\u003c/form>\n```\n\n## Frontend Migration\n\n### Legacy JavaScript (v14) → OWL 1.x (v15)\n\n```javascript\n// v14 Legacy Widget:\nodoo.define('my_module.MyWidget', function (require) {\n \"use strict\";\n\n var Widget = require('web.Widget');\n var core = require('web.core');\n\n var MyWidget = Widget.extend({\n template: 'my_module.MyWidget',\n events: {\n 'click .btn-action': '_onClickAction',\n },\n\n init: function (parent, options) {\n this._super(parent);\n this.data = [];\n },\n\n start: function () {\n var self = this;\n return this._super.apply(this, arguments).then(function () {\n return self._loadData();\n });\n },\n\n _loadData: function () {\n var self = this;\n return this._rpc({\n model: 'my.model',\n method: 'search_read',\n args: [[], ['name', 'state']],\n }).then(function (result) {\n self.data = result;\n self.renderElement();\n });\n },\n\n _onClickAction: function (ev) {\n ev.preventDefault();\n // Handle action\n },\n });\n\n core.action_registry.add('my_module.my_action', MyWidget);\n return MyWidget;\n});\n\n// v15 OWL 1.x Component:\n/** @odoo-module **/\n\nconst { Component, useState, onMounted } = owl;\nconst { useService } = require('@web/core/utils/hooks');\nconst { registry } = require('@web/core/registry');\n\nclass MyComponent extends Component {\n setup() {\n this.state = useState({\n data: [],\n loading: true,\n });\n this.orm = useService('orm');\n\n onMounted(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n this.state.data = await this.orm.searchRead(\n 'my.model',\n [],\n ['name', 'state']\n );\n this.state.loading = false;\n }\n\n onClickAction(item) {\n // Handle action\n }\n}\n\nMyComponent.template = 'my_module.MyComponent';\n\nregistry.category('actions').add('my_module.my_action', MyComponent);\n```\n\n### QWeb Template Migration\n\n```xml\n\u003c!-- v14 Legacy QWeb -->\n\u003ct t-name=\"my_module.MyWidget\">\n \u003cdiv class=\"my-widget\">\n \u003ct t-foreach=\"widget.data\" t-as=\"item\">\n \u003cdiv class=\"item\" t-att-data-id=\"item.id\">\n \u003cspan t-esc=\"item.name\"/>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n\u003c/t>\n\n\u003c!-- v15 OWL QWeb -->\n\u003ct t-name=\"my_module.MyComponent\" owl=\"1\">\n \u003cdiv class=\"my-component\">\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"loading\">Loading...\u003c/div>\n \u003c/t>\n \u003ct t-else=\"\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv class=\"item\" t-on-click=\"() => this.onClickAction(item)\">\n \u003cspan t-esc=\"item.name\"/>\n \u003c/div>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n\u003c/t>\n```\n\n## Migration Checklist\n\n### Code Changes\n- [ ] Remove all `@api.multi` decorators\n- [ ] Replace `track_visibility='onchange'` with `tracking=True`\n- [ ] Replace `track_visibility='always'` with `tracking=True`\n- [ ] Update `super()` calls to Python 3 style (remove class/self)\n- [ ] Update Python version to 3.7+ if needed\n\n### Manifest Changes\n- [ ] Update version to `15.0.x.x.x`\n- [ ] Add `assets` bundle for JS/CSS/XML\n- [ ] Add `'web'` to depends if using OWL\n\n### Optional Improvements\n- [ ] Consider `@api.model_create_multi` for batch creates\n- [ ] Consider migrating legacy JS widgets to OWL\n- [ ] Add `mail.activity.mixin` for activity support\n\n## Search and Replace Patterns\n\n```bash\n# Find @api.multi decorators\ngrep -r \"@api.multi\" --include=\"*.py\"\n\n# Find track_visibility usage\ngrep -r \"track_visibility\" --include=\"*.py\"\n\n# Find old super() patterns\ngrep -r \"super(.*self)\" --include=\"*.py\"\n```\n\n## Testing\n\n1. **Test all methods** - Ensure no @api.multi errors\n2. **Test mail tracking** - Verify tracking=True works\n3. **Test create operations** - Ensure create methods work\n4. **Test frontend** - If migrating to OWL, test components\n\n## Common Issues\n\n### AttributeError: 'api' object has no attribute 'multi'\n**Cause**: @api.multi used in v15\n**Solution**: Remove all @api.multi decorators\n\n### DeprecationWarning: track_visibility is deprecated\n**Cause**: track_visibility still used\n**Solution**: Replace with tracking=True\n\n### ImportError: cannot import name 'Component' from 'owl'\n**Cause**: Wrong OWL import for v15\n**Solution**: Use `const { Component } = owl;`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10409,"content_sha256":"a87f9c6ab94e820afd7d892966f71907acac2a26ba7e495fb9bec9a98704db55"},{"filename":"skills/odoo-module-generator-14.md","content":"# Odoo Module Generator - Version 14.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 14.0 MODULE GENERATION PATTERNS ║\n║ This file contains ONLY Odoo 14.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n║ Note: v14 is LEGACY - consider upgrading to v17+ for new projects. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 14.0 Requirements\n\n- **Python**: 3.6+ required, 3.8 recommended\n- **Key Features**: Last version with `@api.multi`, `track_visibility`\n- **View syntax**: `attrs` for visibility/readonly\n- **OWL**: Not available (legacy widgets only)\n\n## IMPORTANT: Legacy Version Notes\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ v14 is END OF LIFE (October 2023) ║\n║ Only use for maintaining existing modules. ║\n║ For new projects, use v17+ with modern patterns. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## __manifest__.py Template (v14)\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': '{Module Title}',\n 'version': '14.0.1.0.0',\n 'category': '{Category}',\n 'summary': '{Short description}',\n 'description': \"\"\"\n{Detailed description}\n \"\"\",\n 'author': '{Author}',\n 'website': '{Website}',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail'],\n 'data': [\n # ORDER IS CRITICAL\n 'security/{module_name}_security.xml',\n 'security/ir.model.access.csv',\n 'views/{model_name}_views.xml',\n 'views/menuitems.xml',\n ],\n 'demo': [],\n 'installable': True,\n 'application': False,\n 'auto_install': False,\n}\n```\n\n## Model Template (v14)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, _\nfrom odoo.exceptions import UserError, ValidationError\n\n\nclass {ModelName}(models.Model):\n _name = '{module_name}.{model_name}'\n _description = '{Model Description}'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n\n # === BASIC FIELDS === #\n name = fields.Char(\n string='Name',\n required=True,\n track_visibility='onchange', # v14 syntax\n )\n active = fields.Boolean(default=True)\n sequence = fields.Integer(default=10)\n\n # === RELATIONAL FIELDS === #\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n track_visibility='onchange',\n )\n user_id = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n track_visibility='onchange',\n )\n line_ids = fields.One2many(\n comodel_name='{module_name}.{model_name}.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n\n # === SELECTION FIELDS === #\n state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n track_visibility='onchange',\n copy=False,\n )\n\n # === COMPUTED FIELDS === #\n total_amount = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n # === CRUD METHODS (v14 style) === #\n @api.model\n def create(self, vals):\n \"\"\"Single record create - v14 style.\"\"\"\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n '{module_name}.{model_name}'\n ) or _('New')\n return super().create(vals)\n\n # === x2many OPERATIONS - v14: Use tuple syntax === #\n def action_add_line(self):\n \"\"\"Use tuple syntax for x2many operations in v14.\"\"\"\n self.write({\n 'line_ids': [\n (0, 0, {'name': 'New Line', 'amount': 0}), # Create new\n ]\n })\n\n def action_update_lines(self):\n \"\"\"Tuple command reference for v14.\"\"\"\n self.write({\n 'line_ids': [\n (0, 0, {'name': 'New'}), # Create new record\n (1, line_id, {'name': 'Upd'}), # Update existing\n (2, line_id), # Delete from DB\n (3, line_id), # Remove from relation\n (4, line_id), # Link existing\n (5, 0, 0), # Clear all\n (6, 0, [id1, id2]), # Replace with these\n ]\n })\n\n # === ACTION METHODS === #\n def action_confirm(self):\n self.write({'state': 'confirmed'})\n\n def action_done(self):\n self.write({'state': 'done'})\n\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n```\n\n## View Templates (v14 - attrs syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"{model_name}_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.form\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"{Model Title}\">\n \u003cheader>\n \u003c!-- v14: Use attrs for visibility -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cbutton name=\"action_cancel\" string=\"Cancel\" type=\"object\"\n attrs=\"{'invisible': [('state', 'in', ('done', 'cancelled'))]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\"/>\n \u003cwidget name=\"web_ribbon\" title=\"Archived\" bg_color=\"bg-danger\"\n attrs=\"{'invisible': [('active', '=', True)]}\"/>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\u003cfield name=\"name\" placeholder=\"Name...\"/>\u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"total_amount\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\" widget=\"mail_followers\"/>\n \u003cfield name=\"activity_ids\" widget=\"mail_activity\"/>\n \u003cfield name=\"message_ids\" widget=\"mail_thread\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Security Templates (v14)\n\n### ir.model.access.csv\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_{model_name}_user,{model_name}.user,model_{module_name}_{model_name},{module_name}.group_{module_name}_user,1,1,1,0\naccess_{model_name}_manager,{model_name}.manager,model_{module_name}_{model_name},{module_name}.group_{module_name}_manager,1,1,1,1\n```\n\n### Record Rules (v14 - uses company_ids)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Multi-Company Rule - v14 syntax -->\n \u003crecord id=\"rule_{model_name}_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">{Model Name}: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_{module_name}_{model_name}\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v14 Patterns Reference\n\n### Field Tracking (v14 syntax)\n```python\n# v14: Use track_visibility\nname = fields.Char(track_visibility='onchange')\nstate = fields.Selection([...], track_visibility='always')\n```\n\n### x2many Commands (v14 tuple syntax)\n```python\n# v14: Tuple syntax\n(0, 0, values) # Create\n(1, id, values) # Update\n(2, id) # Delete\n(3, id) # Unlink\n(4, id) # Link\n(5, 0, 0) # Clear\n(6, 0, [ids]) # Set\n```\n\n### View Visibility (v14 attrs)\n```xml\n\u003c!-- v14: attrs with domain syntax -->\n\u003cfield name=\"x\" attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n\u003cfield name=\"y\" attrs=\"{'readonly': [('state', '!=', 'draft')],\n 'required': [('type', '=', 'invoice')]}\"/>\n```\n\n## v14 Checklist\n\nWhen generating a v14 module:\n\n- [ ] Use `track_visibility` for field tracking (NOT `tracking`)\n- [ ] Use tuple syntax for x2many operations (NOT `Command`)\n- [ ] Use `attrs` for view visibility\n- [ ] Use single-record `create(vals)` method\n- [ ] Use `company_ids` in record rules (NOT `allowed_company_ids`)\n- [ ] No OWL components (legacy widgets only)\n- [ ] Data files in correct order in manifest\n\n## Migration Note\n\nTo upgrade this module to v15+:\n1. Replace `track_visibility` with `tracking`\n2. Read `odoo-module-generator-14-15.md` for full migration guide\n\n## AI Agent Instructions (v14)\n\nWhen generating an Odoo 14.0 module:\n\n1. **USE** `track_visibility='onchange'` for tracked fields\n2. **USE** tuple syntax `(0, 0, {...})` for x2many\n3. **USE** `attrs` in views for visibility/readonly\n4. **USE** `company_ids` in record rules\n5. **DO NOT** use `Command` class (v16+)\n6. **DO NOT** use `tracking=True` (v15+)\n7. **DO NOT** use direct `invisible`/`readonly` (v17+)\n8. **DO NOT** create OWL components (use legacy widgets)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11890,"content_sha256":"d2627203ebce81edd29ef1f241a2b82633530b859db8c4551c16b28dceb42058"},{"filename":"skills/odoo-module-generator-15-16.md","content":"# Odoo Module Migration Guide: 15.0 → 16.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MODULE MIGRATION: 15.0 → 16.0 ║\n║ Command class introduced, attrs deprecated, OWL 2.x ║\n║ VERIFY: https://github.com/odoo/odoo/tree/16.0 ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Migration Overview\n\n| Aspect | v15 | v16 |\n|--------|-----|-----|\n| X2many commands | Tuple syntax | Command class (recommended) |\n| attrs in views | Full support | DEPRECATED (warns) |\n| OWL | 1.x | 2.x |\n| Python | 3.7-3.9 | 3.8-3.10 |\n| create method | @api.model | @api.model_create_multi recommended |\n\n## Key Changes\n\n### 1. Command Class for X2many\n\n```python\n# v15 Tuple syntax (still works in v16):\nline_ids = [\n (0, 0, {'name': 'Line 1'}), # Create\n (1, line_id, {'name': 'Updated'}), # Update\n (2, line_id, 0), # Delete\n (4, line_id, 0), # Link\n (5, 0, 0), # Clear\n (6, 0, [id1, id2]), # Replace\n]\n\n# v16 Command class (RECOMMENDED):\nfrom odoo.fields import Command\n\nline_ids = [\n Command.create({'name': 'Line 1'}), # Create\n Command.update(line_id, {'name': 'Updated'}), # Update\n Command.delete(line_id), # Delete\n Command.link(line_id), # Link\n Command.clear(), # Clear\n Command.set([id1, id2]), # Replace\n]\n```\n\n### 2. attrs DEPRECATED\n\n```xml\n\u003c!-- v15 (full support): -->\n\u003cfield name=\"partner_id\"\n attrs=\"{'invisible': [('state', '=', 'draft')],\n 'required': [('state', '=', 'confirmed')],\n 'readonly': [('state', '=', 'done')]}\"/>\n\n\u003c!-- v16 (RECOMMENDED - prepare for v17): -->\n\u003cfield name=\"partner_id\"\n invisible=\"state == 'draft'\"\n required=\"state == 'confirmed'\"\n readonly=\"state == 'done'\"/>\n```\n\n### 3. OWL 1.x → 2.x\n\n```javascript\n// v15 OWL 1.x:\n/** @odoo-module **/\nconst { Component, useState, onMounted } = owl;\nconst { useService } = require('@web/core/utils/hooks');\nconst { registry } = require('@web/core/registry');\n\nclass MyComponent extends Component {\n setup() {\n this.state = useState({ data: [] });\n this.orm = useService('orm');\n onMounted(() => this.loadData());\n }\n}\nMyComponent.template = 'my_module.MyComponent';\n\n// v16 OWL 2.x:\n/** @odoo-module **/\nimport { Component, useState, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n\n setup() {\n this.state = useState({ data: [] });\n this.orm = useService(\"orm\");\n onMounted(() => this.loadData());\n }\n}\n```\n\n## Manifest Changes\n\n```python\n# v15\n{\n 'name': 'My Module',\n 'version': '15.0.1.0.0',\n 'depends': ['base', 'mail', 'web'],\n 'data': [\n 'security/ir.model.access.csv',\n 'views/my_model_views.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n 'my_module/static/src/js/**/*',\n 'my_module/static/src/xml/**/*',\n ],\n },\n}\n\n# v16 - Same structure, update version\n{\n 'name': 'My Module',\n 'version': '16.0.1.0.0',\n 'depends': ['base', 'mail', 'web'],\n 'data': [\n 'security/ir.model.access.csv',\n 'views/my_model_views.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n 'my_module/static/src/**/*.js',\n 'my_module/static/src/**/*.xml',\n 'my_module/static/src/**/*.scss',\n ],\n },\n}\n```\n\n## Model Migration\n\n### Complete Model Example\n\n```python\n# v15 Model:\nfrom odoo import models, fields, api, _\nfrom odoo.exceptions import UserError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(required=True, tracking=True)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', tracking=True)\n\n partner_id = fields.Many2one('res.partner')\n line_ids = fields.One2many('my.model.line', 'model_id')\n\n @api.model\n def create(self, vals):\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals)\n\n def create_with_lines(self):\n return self.env['my.model'].create({\n 'name': 'Test',\n 'line_ids': [\n (0, 0, {'name': 'Line 1'}),\n (0, 0, {'name': 'Line 2'}),\n ],\n })\n\n# v16 Model:\nfrom odoo import models, fields, api, _\nfrom odoo.fields import Command\nfrom odoo.exceptions import UserError\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(required=True, tracking=True)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', tracking=True, index=True) # Add index\n\n partner_id = fields.Many2one('res.partner')\n line_ids = fields.One2many('my.model.line', 'model_id')\n\n @api.model_create_multi # Recommended in v16\n def create(self, vals_list):\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n\n def create_with_lines(self):\n return self.env['my.model'].create({\n 'name': 'Test',\n 'line_ids': [\n Command.create({'name': 'Line 1'}),\n Command.create({'name': 'Line 2'}),\n ],\n })\n```\n\n## View Migration\n\n### Form View\n\n```xml\n\u003c!-- v15 (with attrs): -->\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" type=\"object\" string=\"Done\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"\n attrs=\"{'required': [('state', '=', 'confirmed')],\n 'readonly': [('state', '=', 'done')]}\"/>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\">\n \u003cfield name=\"line_ids\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n\u003c/form>\n\n\u003c!-- v16 (with Python expressions - RECOMMENDED): -->\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" type=\"object\" string=\"Confirm\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" type=\"object\" string=\"Done\"\n invisible=\"state != 'confirmed'\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"\n required=\"state == 'confirmed'\"\n readonly=\"state == 'done'\"/>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\"\n invisible=\"state == 'draft'\">\n \u003cfield name=\"line_ids\"/>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Tree View with Decorations\n\n```xml\n\u003c!-- v16: Enhanced tree decorations -->\n\u003ctree decoration-danger=\"state == 'cancelled'\"\n decoration-success=\"state == 'done'\"\n decoration-warning=\"is_overdue\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"state\" widget=\"badge\"\n decoration-success=\"state == 'done'\"\n decoration-info=\"state == 'confirmed'\"/>\n\u003c/tree>\n```\n\n## OWL Migration (1.x → 2.x)\n\n### Component Migration\n\n```javascript\n// v15 OWL 1.x:\n/** @odoo-module **/\n\nconst { Component, useState, onWillStart, onMounted } = owl;\nconst { useService } = require('@web/core/utils/hooks');\nconst { registry } = require('@web/core/registry');\n\nclass MyComponent extends Component {\n setup() {\n this.state = useState({\n data: [],\n loading: true,\n });\n this.orm = useService('orm');\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n this.state.data = await this.orm.searchRead(\n 'my.model', [], ['name', 'state']\n );\n this.state.loading = false;\n }\n}\n\nMyComponent.template = 'my_module.MyComponent';\nregistry.category('actions').add('my_module.my_action', MyComponent);\n\n// v16 OWL 2.x:\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n };\n\n setup() {\n this.state = useState({\n data: [],\n loading: true,\n });\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"my.model\", [], [\"name\", \"state\"],\n { limit: 100 }\n );\n } catch (error) {\n this.notification.add(\"Failed to load data\", { type: \"danger\" });\n } finally {\n this.state.loading = false;\n }\n }\n\n async onItemClick(item) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"my.model\",\n res_id: item.id,\n views: [[false, \"form\"]],\n });\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_action\", MyComponent);\n```\n\n### Template Changes\n\n```xml\n\u003c!-- v15 OWL 1.x template: -->\n\u003ct t-name=\"my_module.MyComponent\" owl=\"1\">\n \u003cdiv class=\"my-component\">\n \u003ct t-if=\"state.loading\">\n \u003cdiv>Loading...\u003c/div>\n \u003c/t>\n \u003ct t-else=\"\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv t-on-click=\"onItemClick(item)\" t-esc=\"item.name\"/>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n\u003c/t>\n\n\u003c!-- v16 OWL 2.x template: -->\n\u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv class=\"my-component\">\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"text-center p-4\">\n \u003ci class=\"fa fa-spinner fa-spin\"/>\n \u003c/div>\n \u003c/t>\n \u003ct t-else=\"\">\n \u003cdiv class=\"list-group\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003ca class=\"list-group-item list-group-item-action\"\n t-on-click=\"() => this.onItemClick(item)\">\n \u003cspan t-esc=\"item.name\"/>\n \u003c/a>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n\u003c/t>\n```\n\n## Migration Checklist\n\n### Code Changes\n- [ ] Add `from odoo.fields import Command` where needed\n- [ ] Replace tuple x2many commands with Command class\n- [ ] Consider `@api.model_create_multi` for create methods\n- [ ] Add `index=True` to frequently searched fields\n\n### View Changes\n- [ ] Replace `attrs=` with Python expression attributes\n- [ ] Convert invisible/required/readonly domains to expressions\n- [ ] Update page visibility conditions\n\n### OWL Changes\n- [ ] Change `require()` to ES `import`\n- [ ] Update OWL imports from `@odoo/owl`\n- [ ] Add `static template` and `static props`\n- [ ] Use arrow functions in t-on-click: `t-on-click=\"() => this.method(arg)\"`\n\n### Manifest Changes\n- [ ] Update version to `16.0.x.x.x`\n- [ ] Update asset glob patterns if needed\n\n## attrs Conversion Reference\n\n| attrs | Python Expression |\n|-------|-------------------|\n| `[('state', '=', 'draft')]` | `state == 'draft'` |\n| `[('state', '!=', 'draft')]` | `state != 'draft'` |\n| `[('state', 'in', ['a', 'b'])]` | `state in ('a', 'b')` |\n| `[('state', 'not in', ['a', 'b'])]` | `state not in ('a', 'b')` |\n| `[('count', '>', 0)]` | `count > 0` |\n| `[('count', '>=', 5)]` | `count >= 5` |\n| `[('active', '=', True)]` | `active` |\n| `[('active', '=', False)]` | `not active` |\n| `['|', ('a', '=', 1), ('b', '=', 2)]` | `a == 1 or b == 2` |\n| `['&', ('a', '=', 1), ('b', '=', 2)]` | `a == 1 and b == 2` |\n\n## Common Issues\n\n### DeprecationWarning: attrs is deprecated\n**Cause**: Using attrs in v16\n**Solution**: Migrate to Python expression attributes\n\n### ImportError: cannot import 'Command'\n**Cause**: Using Command in v15\n**Solution**: Ensure you're on v16; use tuple syntax for v15\n\n### OWL Import Errors\n**Cause**: Using old require() syntax in v16\n**Solution**: Use ES `import` statements\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13489,"content_sha256":"083fcdd512735eb4e19860401321c32eae2a0c1c27c4af877a36b0d919c413da"},{"filename":"skills/odoo-module-generator-15.md","content":"# Odoo Module Generator - Version 15.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 15.0 MODULE GENERATION PATTERNS ║\n║ This file contains ONLY Odoo 15.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 15.0 Requirements\n\n- **Python**: 3.8+ required\n- **Key Changes**: `@api.multi` removed, `tracking` replaces `track_visibility`\n- **View syntax**: `attrs` for visibility (standard)\n- **OWL**: 1.x introduced for new components\n\n## Breaking Changes from v14\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ REMOVED in v15: ║\n║ • @api.multi decorator - REMOVED (methods work on recordsets by default) ║\n║ • @api.one decorator - REMOVED ║\n║ • track_visibility - DEPRECATED (use tracking=True) ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## __manifest__.py Template (v15)\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': '{Module Title}',\n 'version': '15.0.1.0.0',\n 'category': '{Category}',\n 'summary': '{Short description}',\n 'description': \"\"\"\n{Detailed description}\n \"\"\",\n 'author': '{Author}',\n 'website': '{Website}',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail'],\n 'data': [\n # ORDER IS CRITICAL\n 'security/{module_name}_security.xml',\n 'security/ir.model.access.csv',\n 'views/{model_name}_views.xml',\n 'views/menuitems.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/**/*.js',\n '{module_name}/static/src/**/*.xml',\n '{module_name}/static/src/**/*.scss',\n ],\n },\n 'demo': [],\n 'installable': True,\n 'application': False,\n 'auto_install': False,\n}\n```\n\n## Model Template (v15)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, _\nfrom odoo.exceptions import UserError, ValidationError\n\n\nclass {ModelName}(models.Model):\n _name = '{module_name}.{model_name}'\n _description = '{Model Description}'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n\n # === BASIC FIELDS === #\n name = fields.Char(\n string='Name',\n required=True,\n tracking=True, # v15: New tracking syntax\n )\n active = fields.Boolean(default=True)\n sequence = fields.Integer(default=10)\n\n # === RELATIONAL FIELDS === #\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n tracking=True,\n )\n user_id = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n )\n line_ids = fields.One2many(\n comodel_name='{module_name}.{model_name}.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n\n # === SELECTION FIELDS === #\n state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n copy=False,\n )\n\n # === COMPUTED FIELDS === #\n total_amount = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n # === CRUD METHODS (v15 - multi-record aware) === #\n @api.model\n def create(self, vals):\n \"\"\"Create method - no @api.multi needed.\"\"\"\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n '{module_name}.{model_name}'\n ) or _('New')\n return super().create(vals)\n\n # === x2many OPERATIONS - v15: Still tuple syntax === #\n def action_add_line(self):\n \"\"\"Use tuple syntax for x2many operations.\"\"\"\n self.write({\n 'line_ids': [\n (0, 0, {'name': 'New Line', 'amount': 0}),\n ]\n })\n\n # === ACTION METHODS === #\n def action_confirm(self):\n # v15: Methods work on recordsets, no @api.multi needed\n self.write({'state': 'confirmed'})\n\n def action_done(self):\n self.write({'state': 'done'})\n\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n```\n\n## View Templates (v15 - attrs syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"{model_name}_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.form\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"{Model Title}\">\n \u003cheader>\n \u003c!-- v15: attrs syntax still standard -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cbutton name=\"action_cancel\" string=\"Cancel\" type=\"object\"\n attrs=\"{'invisible': [('state', 'in', ('done', 'cancelled'))]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\"/>\n \u003cwidget name=\"web_ribbon\" title=\"Archived\" bg_color=\"bg-danger\"\n attrs=\"{'invisible': [('active', '=', True)]}\"/>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\u003cfield name=\"name\" placeholder=\"Name...\"/>\u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"total_amount\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## OWL 1.x Component (v15)\n\n```javascript\nodoo.define('{module_name}.{ComponentName}', function (require) {\n \"use strict\";\n\n const { Component } = owl;\n const { useState, onWillStart } = owl.hooks;\n const AbstractAction = require('web.AbstractAction');\n const core = require('web.core');\n\n class {ComponentName} extends Component {\n setup() {\n this.state = useState({\n data: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n const rpc = this.env.services.rpc;\n try {\n const data = await rpc({\n model: '{module_name}.{model_name}',\n method: 'search_read',\n args: [[], ['name', 'state']],\n });\n this.state.data = data;\n } finally {\n this.state.loading = false;\n }\n }\n }\n\n {ComponentName}.template = '{module_name}.{ComponentName}';\n\n core.action_registry.add('{module_name}.{component_name}', {ComponentName});\n\n return {ComponentName};\n});\n```\n\n## OWL 1.x Template (v15)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"{module_name}.{ComponentName}\" owl=\"1\">\n \u003cdiv class=\"o_action o_{component_name}\">\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"o_loading\">Loading...\u003c/div>\n \u003c/t>\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_content\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv class=\"o_item\">\n \u003cspan t-esc=\"item.name\"/>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## v15 Patterns Reference\n\n### Field Tracking (v15 syntax)\n```python\n# v15: Use tracking=True (not track_visibility)\nname = fields.Char(tracking=True)\nstate = fields.Selection([...], tracking=True)\n```\n\n### x2many Commands (v15 - still tuple syntax)\n```python\n# v15: Tuple syntax (Command class comes in v16)\n(0, 0, values) # Create\n(1, id, values) # Update\n(2, id) # Delete\n(3, id) # Unlink\n(4, id) # Link\n(5, 0, 0) # Clear\n(6, 0, [ids]) # Set\n```\n\n### No More @api.multi\n```python\n# v14 (OLD):\[email protected]\ndef action_confirm(self):\n ...\n\n# v15 (NEW):\ndef action_confirm(self):\n # Works on recordset by default\n ...\n```\n\n## v15 Checklist\n\nWhen generating a v15 module:\n\n- [ ] Use `tracking=True` for field tracking (NOT `track_visibility`)\n- [ ] Remove any `@api.multi` decorators\n- [ ] Remove any `@api.one` decorators\n- [ ] Use tuple syntax for x2many operations\n- [ ] Use `attrs` for view visibility\n- [ ] Use OWL 1.x if adding new components\n- [ ] Include `assets` in manifest for JS/CSS\n- [ ] Data files in correct order in manifest\n\n## AI Agent Instructions (v15)\n\nWhen generating an Odoo 15.0 module:\n\n1. **USE** `tracking=True` for tracked fields\n2. **REMOVE** any `@api.multi` or `@api.one` decorators\n3. **USE** tuple syntax for x2many (Command class is v16+)\n4. **USE** `attrs` in views for visibility/readonly\n5. **USE** OWL 1.x patterns with `odoo.define()`\n6. **INCLUDE** `assets` key in manifest\n7. **DO NOT** use `Command` class (v16+)\n8. **DO NOT** use direct `invisible`/`readonly` (v17+)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12143,"content_sha256":"1dbc3a1f712db5d819c04a5297969032000197171c3d64bf2516dcdf9f0f697f"},{"filename":"skills/odoo-module-generator-16-17.md","content":"# Odoo Module Migration Guide: 16.0 → 17.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: Odoo 16.0 → 17.0 ║\n║ This document covers ONLY changes between these specific versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Component | v16 Status | v17 Status | Action Required |\n|-----------|------------|------------|-----------------|\n| `attrs` attribute | Deprecated | **REMOVED** | Must migrate |\n| `states` attribute | Deprecated | **REMOVED** | Must migrate |\n| `@api.model_create_multi` | Recommended | **Mandatory** | Must add |\n| Direct `invisible`/`readonly` | Supported | Required | Use exclusively |\n| OWL | 2.x | 2.x (enhanced) | Minor updates |\n\n## CRITICAL: attrs Removal\n\n### Before (v16 - Deprecated)\n```xml\n\u003cbutton name=\"action_confirm\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n\n\u003cfield name=\"partner_id\"\n attrs=\"{'readonly': [('state', '!=', 'draft')],\n 'required': [('type', '=', 'invoice')]}\"/>\n```\n\n### After (v17 - Required)\n```xml\n\u003cbutton name=\"action_confirm\"\n invisible=\"state != 'draft'\"/>\n\n\u003cfield name=\"partner_id\"\n readonly=\"state != 'draft'\"\n required=\"type == 'invoice'\"/>\n```\n\n### Migration Pattern\n\n| v16 `attrs` Pattern | v17 Replacement |\n|---------------------|-----------------|\n| `[('field', '=', value)]` | `field == value` |\n| `[('field', '!=', value)]` | `field != value` |\n| `[('field', 'in', [a, b])]` | `field in [a, b]` or `field in (a, b)` |\n| `[('field', 'not in', [a, b])]` | `field not in [a, b]` |\n| `[('field', '=', True)]` | `field` |\n| `[('field', '=', False)]` | `not field` |\n| Multiple conditions (AND) | `cond1 and cond2` |\n| Multiple conditions (OR) | `cond1 or cond2` |\n\n### Complex Example\n\n```xml\n\u003c!-- v16 -->\n\u003cfield name=\"amount\"\n attrs=\"{'invisible': [('state', '=', 'draft'), ('amount', '=', 0)],\n 'readonly': ['|', ('state', '!=', 'draft'), ('locked', '=', True)]}\"/>\n\n\u003c!-- v17 -->\n\u003cfield name=\"amount\"\n invisible=\"state == 'draft' and amount == 0\"\n readonly=\"state != 'draft' or locked\"/>\n```\n\n## CRITICAL: states Removal\n\n### Before (v16 - Deprecated)\n```xml\n\u003cfield name=\"partner_id\" states=\"draft,sent\"/>\n```\n\n### After (v17 - Required)\n```xml\n\u003cfield name=\"partner_id\" invisible=\"state not in ('draft', 'sent')\"/>\n```\n\n## MANDATORY: @api.model_create_multi\n\n### Before (v16 - Optional)\n```python\[email protected]\ndef create(self, vals):\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals)\n```\n\n### After (v17 - Required)\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n```\n\n## Manifest Version Update\n\n```python\n# v16\n'version': '16.0.1.0.0',\n\n# v17\n'version': '17.0.1.0.0',\n```\n\n## Python Changes\n\n### Minimum Python Version\n- v16: Python 3.8+\n- v17: Python 3.10+\n\n### Type Hints (Recommended)\n```python\n# v17 encourages type hints\ndef process_data(self, partner_id: int, options: dict = None) -> bool:\n options = options or {}\n return True\n```\n\n## OWL 2.x Updates\n\n### Minor Template Changes\nOWL remains at 2.x but with enhancements. No major breaking changes.\n\n### Component Registration\n```javascript\n// Unchanged in v17\nregistry.category(\"actions\").add(\"my_module.component\", MyComponent);\n```\n\n## Migration Checklist\n\n### Views (XML)\n- [ ] Replace all `attrs=\"{'invisible': ...}\"` with `invisible=\"...\"`\n- [ ] Replace all `attrs=\"{'readonly': ...}\"` with `readonly=\"...\"`\n- [ ] Replace all `attrs=\"{'required': ...}\"` with `required=\"...\"`\n- [ ] Replace all `states=\"...\"` with `invisible=\"state not in (...)\"`\n- [ ] Convert domain syntax to Python expression syntax\n\n### Models (Python)\n- [ ] Add `@api.model_create_multi` to all `create()` methods\n- [ ] Update `create(vals)` to `create(vals_list)` signature\n- [ ] Update Python version compatibility to 3.10+\n- [ ] Add type hints where appropriate\n\n### Manifest\n- [ ] Update version from `16.0.x.x.x` to `17.0.x.x.x`\n- [ ] Verify all dependencies are v17 compatible\n\n### Testing\n- [ ] Run all tests with v17\n- [ ] Test all form views for visibility rules\n- [ ] Test all buttons for state-based visibility\n- [ ] Verify computed field readonly behavior\n\n## Common Migration Errors\n\n### Error: attrs not supported\n```\nError: attrs=\"...\" is no longer supported in Odoo 17\n```\n**Solution**: Convert to direct Python expression syntax.\n\n### Error: create() missing vals_list\n```\nTypeError: create() got an unexpected keyword argument 'vals'\n```\n**Solution**: Update method signature to use `vals_list`.\n\n### Error: Invalid expression syntax\n```\nError: Invalid expression: field = value\n```\n**Solution**: Use `==` for comparison, not `=`.\n\n## Automated Migration Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nBasic migration helper for attrs to direct expressions.\nRun manually and verify results!\n\"\"\"\nimport re\nimport sys\n\ndef convert_domain_to_expr(domain_str):\n \"\"\"Convert domain list to Python expression.\"\"\"\n # This is a simplified converter - verify results manually\n domain_str = domain_str.strip()\n if domain_str.startswith('[') and domain_str.endswith(']'):\n domain_str = domain_str[1:-1]\n\n # Handle simple cases\n patterns = [\n (r\"\\('(\\w+)',\\s*'=',\\s*'([^']+)'\\)\", r\"\\1 == '\\2'\"),\n (r\"\\('(\\w+)',\\s*'=',\\s*(\\d+)\\)\", r\"\\1 == \\2\"),\n (r\"\\('(\\w+)',\\s*'=',\\s*True\\)\", r\"\\1\"),\n (r\"\\('(\\w+)',\\s*'=',\\s*False\\)\", r\"not \\1\"),\n (r\"\\('(\\w+)',\\s*'!=',\\s*'([^']+)'\\)\", r\"\\1 != '\\2'\"),\n ]\n\n for pattern, replacement in patterns:\n domain_str = re.sub(pattern, replacement, domain_str)\n\n return domain_str\n\nif __name__ == '__main__':\n print(\"Manual verification required for all conversions!\")\n```\n\n## GitHub Reference\n\nFor official migration notes, consult:\n- https://github.com/odoo/odoo/tree/17.0\n- Odoo 17.0 release notes\n- Community upgrade scripts\n\n---\n\n**IMPORTANT**: Always test thoroughly after migration. The automated patterns cover common cases but complex domains may require manual adjustment.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6673,"content_sha256":"e080e4f16686a43e34001610567e1dc1cce1871e31c397a9a0f08be0d7657d73"},{"filename":"skills/odoo-module-generator-16.md","content":"# Odoo Module Generator - Version 16.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 16.0 MODULE GENERATION PATTERNS ║\n║ This file contains ONLY Odoo 16.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 16.0 Requirements\n\n- **Python**: 3.8+ required\n- **Key Features**: `Command` class introduced, `attrs` deprecated, OWL 2.x\n- **View syntax**: `attrs` still works but deprecated, prefer direct attributes\n\n## IMPORTANT: Transition Version\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ v16 is a TRANSITION version: ║\n║ - `attrs` works but is DEPRECATED ║\n║ - `@api.model_create_multi` recommended but not mandatory ║\n║ - Start using direct invisible/readonly for v17 compatibility ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## IMPORTANT: Data File Ordering\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ The ORDER of files in the 'data' list is CRITICAL. ║\n║ A resource can ONLY be referenced AFTER it has been defined. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## __manifest__.py Template (v16)\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': '{Module Title}',\n 'version': '16.0.1.0.0',\n 'category': '{Category}',\n 'summary': '{Short description}',\n 'description': \"\"\"\n{Detailed description}\n \"\"\",\n 'author': '{Author}',\n 'website': '{Website}',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail'],\n 'data': [\n # ORDER IS CRITICAL\n 'security/{module_name}_security.xml',\n 'security/ir.model.access.csv',\n 'views/{model_name}_views.xml',\n 'views/menuitems.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/**/*.js',\n '{module_name}/static/src/**/*.xml',\n '{module_name}/static/src/**/*.scss',\n ],\n },\n 'demo': [],\n 'installable': True,\n 'application': False,\n 'auto_install': False,\n}\n```\n\n## Model Template (v16)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import UserError, ValidationError\n\nclass {ModelName}(models.Model):\n _name = '{module_name}.{model_name}'\n _description = '{Model Description}'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n\n # === BASIC FIELDS === #\n name = fields.Char(\n string='Name',\n required=True,\n tracking=True,\n )\n active = fields.Boolean(default=True)\n sequence = fields.Integer(default=10)\n\n # === RELATIONAL FIELDS === #\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n tracking=True,\n )\n user_id = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n )\n line_ids = fields.One2many(\n comodel_name='{module_name}.{model_name}.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n\n # === SELECTION FIELDS === #\n state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n copy=False,\n )\n\n # === COMPUTED FIELDS === #\n total_amount = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n # === CRUD METHODS === #\n # v16: @api.model_create_multi recommended\n @api.model_create_multi\n def create(self, vals_list):\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n '{module_name}.{model_name}'\n ) or _('New')\n return super().create(vals_list)\n\n # === x2many OPERATIONS - v16: Use Command class === #\n def action_add_line(self):\n \"\"\"Use Command class for x2many operations.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New Line', 'amount': 0}),\n ]\n })\n\n def action_update_lines(self):\n \"\"\"Command class examples.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New'}), # Create new record\n Command.update(1, {'name': 'Updated'}), # Update existing\n Command.delete(2), # Delete from DB\n Command.unlink(3), # Remove from relation\n Command.link(4), # Link existing\n Command.clear(), # Clear all\n Command.set([5, 6, 7]), # Replace with these\n ]\n })\n\n # === ACTION METHODS === #\n def action_confirm(self):\n self.write({'state': 'confirmed'})\n\n def action_done(self):\n self.write({'state': 'done'})\n\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n```\n\n## View Templates (v16 - attrs deprecated, prefer direct)\n\n### Recommended Pattern (v17-ready)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"{model_name}_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.form\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"{Model Title}\">\n \u003cheader>\n \u003c!-- v16: PREFER direct invisible (v17-ready) -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_cancel\" string=\"Cancel\" type=\"object\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\"/>\n \u003cwidget name=\"web_ribbon\" title=\"Archived\" bg_color=\"bg-danger\"\n invisible=\"active\"/>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\u003cfield name=\"name\" placeholder=\"Name...\"/>\u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"total_amount\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Legacy Pattern (still works in v16, will break in v17)\n\n```xml\n\u003c!-- DEPRECATED: Will break in v17 -->\n\u003cbutton name=\"action_confirm\" string=\"Confirm\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n\n\u003c!-- PREFER THIS INSTEAD (works in v16 AND v17) -->\n\u003cbutton name=\"action_confirm\" string=\"Confirm\"\n invisible=\"state != 'draft'\"/>\n```\n\n## OWL Component Template (v16 - OWL 2.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class {ComponentName} extends Component {\n static template = \"{module_name}.{ComponentName}\";\n\n setup() {\n this.orm = useService(\"orm\");\n this.notification = useService(\"notification\");\n\n this.state = useState({\n data: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"{module_name}.{model_name}\",\n [],\n [\"name\", \"state\"]\n );\n } finally {\n this.state.loading = false;\n }\n }\n}\n\nregistry.category(\"actions\").add(\"{module_name}.{component_name}\", {ComponentName});\n```\n\n## Security Templates (v16)\n\n### ir.model.access.csv\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_{model_name}_user,{model_name}.user,model_{module_name}_{model_name},{module_name}.group_{module_name}_user,1,1,1,0\naccess_{model_name}_manager,{model_name}.manager,model_{module_name}_{model_name},{module_name}.group_{module_name}_manager,1,1,1,1\n```\n\n### Security Groups XML\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_{module_name}\" model=\"ir.module.category\">\n \u003cfield name=\"name\">{Module Title}\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_{module_name}_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_{module_name}\"/>\n \u003c/record>\n\n \u003crecord id=\"group_{module_name}_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_{module_name}\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_{module_name}_user'))]\"/>\n \u003c/record>\n\n \u003c!-- Multi-Company Rule -->\n \u003crecord id=\"rule_{model_name}_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">{Model Name}: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_{module_name}_{model_name}\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v16 Checklist\n\nWhen generating a v16 module:\n\n- [ ] Use `Command` class for x2many operations\n- [ ] Prefer direct `invisible`/`readonly` over `attrs` (v17 preparation)\n- [ ] Use `@api.model_create_multi` for create methods (recommended)\n- [ ] Use `tracking=True` for tracked fields\n- [ ] Use OWL 2.x syntax for frontend components\n- [ ] Include assets in manifest\n- [ ] Data files in correct order\n\n## AI Agent Instructions (v16)\n\nWhen generating an Odoo 16.0 module:\n\n1. **USE** `Command` class for x2many operations (mandatory)\n2. **PREFER** direct `invisible`/`readonly` attributes (v17 preparation)\n3. **USE** `@api.model_create_multi` for create (recommended)\n4. **USE** `tracking=True` for field tracking\n5. **NOTE**: `attrs` still works but is deprecated\n6. **USE** OWL 2.x with `/** @odoo-module **/`\n7. **INCLUDE** assets in manifest\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13684,"content_sha256":"b1dd20a3f3fa6618dd316381bd9c7e5c24c4182dd002c00a510e4f807801638f"},{"filename":"skills/odoo-module-generator-17-18.md","content":"# Odoo Module Migration Guide: 17.0 → 18.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: Odoo 17.0 → 18.0 ║\n║ This document covers ONLY changes between these specific versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Component | v17 Status | v18 Status | Action Required |\n|-----------|------------|------------|-----------------|\n| `_check_company_auto` | Optional | Recommended | Add to models |\n| `check_company` on fields | Optional | Recommended | Add to relations |\n| `SQL()` builder | New | Recommended | Use for raw SQL |\n| Type hints | Optional | Recommended | Add where possible |\n| Raw SQL strings | Allowed | Deprecated | Migrate to SQL() |\n| Python 3.10 | Required | 3.10+, 3.12 recommended | Verify compatibility |\n\n## NEW: Company Check Automation\n\n### Before (v17)\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n company_id = fields.Many2one('res.company')\n partner_id = fields.Many2one('res.partner')\n\n def write(self, vals):\n # Manual company validation\n if 'partner_id' in vals:\n partner = self.env['res.partner'].browse(vals['partner_id'])\n if partner.company_id and partner.company_id != self.company_id:\n raise UserError(_(\"Partner company mismatch.\"))\n return super().write(vals)\n```\n\n### After (v18)\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _check_company_auto = True # v18: Enable automatic checks\n\n company_id = fields.Many2one('res.company', required=True)\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True, # v18: Automatic company validation\n )\n # No manual validation needed - framework handles it\n```\n\n## NEW: SQL Builder Pattern\n\n### Before (v17 - Raw SQL)\n```python\ndef _get_statistics(self):\n # Vulnerable to SQL injection if not careful\n query = \"\"\"\n SELECT partner_id, SUM(amount) as total\n FROM %s\n WHERE company_id = %s AND state = '%s'\n GROUP BY partner_id\n \"\"\" % (self._table, self.env.company.id, 'confirmed')\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n### After (v18 - SQL Builder)\n```python\nfrom odoo.tools import SQL\n\ndef _get_statistics(self):\n # Safe, parameterized queries\n query = SQL(\n \"\"\"\n SELECT partner_id, SUM(amount) as total\n FROM %s\n WHERE company_id = %s AND state = %s\n GROUP BY partner_id\n \"\"\",\n SQL.identifier(self._table),\n self.env.company.id,\n 'confirmed',\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n### SQL Builder Reference\n\n```python\nfrom odoo.tools import SQL\n\n# Table/column identifiers (prevents injection)\nSQL.identifier('my_table')\nSQL.identifier('my_table', 'column_name')\n\n# Raw SQL fragment (use carefully)\nSQL('ORDER BY create_date DESC')\n\n# Combining queries\nquery = SQL(\n \"%s UNION %s\",\n SQL(\"SELECT * FROM table1 WHERE id = %s\", 1),\n SQL(\"SELECT * FROM table2 WHERE id = %s\", 2),\n)\n\n# IN clause with tuple\nids = (1, 2, 3)\nquery = SQL(\"SELECT * FROM table WHERE id IN %s\", ids)\n```\n\n## RECOMMENDED: Type Hints\n\n### Before (v17)\n```python\ndef process_partner(self, partner_id, options=None):\n partner = self.env['res.partner'].browse(partner_id)\n options = options or {}\n return partner.name\n```\n\n### After (v18)\n```python\nfrom typing import Optional, Any\n\ndef process_partner(\n self,\n partner_id: int,\n options: Optional[dict[str, Any]] = None,\n) -> str:\n partner = self.env['res.partner'].browse(partner_id)\n options = options or {}\n return partner.name\n```\n\n### Common Type Hints\n\n```python\nfrom typing import Optional, Any, Union\nfrom collections.abc import Iterable\n\n# Model methods\ndef create(self, vals_list: list[dict]) -> 'MyModel': ...\ndef write(self, vals: dict) -> bool: ...\ndef unlink(self) -> bool: ...\ndef copy(self, default: Optional[dict] = None) -> 'MyModel': ...\n\n# Search methods\ndef search(self, domain: list, limit: Optional[int] = None) -> 'MyModel': ...\n\n# Custom methods\ndef calculate_total(self, include_tax: bool = True) -> float: ...\ndef get_partner_data(self) -> dict[str, Any]: ...\n```\n\n## Multi-Company Updates\n\n### v18 Multi-Company Pattern\n\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _check_company_auto = True\n\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n\n # Related fields - auto-check company\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True,\n )\n product_id = fields.Many2one(\n 'product.product',\n check_company=True,\n )\n warehouse_id = fields.Many2one(\n 'stock.warehouse',\n check_company=True,\n )\n```\n\n### Security Rule Update\n\n```xml\n\u003c!-- v18: Use allowed_company_ids -->\n\u003crecord id=\"rule_my_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">My Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_my_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n## OWL Updates (2.x Enhanced)\n\n### Service Access Pattern\n```javascript\n// v18: Consistent service usage\nsetup() {\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n this.user = useService(\"user\");\n this.company = useService(\"company\");\n}\n```\n\n### Enhanced RPC\n```javascript\n// v18: Enhanced ORM service\nconst records = await this.orm.searchRead(\n \"res.partner\",\n [[\"is_company\", \"=\", true]],\n [\"name\", \"email\", \"phone\"],\n { limit: 100, order: \"name ASC\" }\n);\n```\n\n## Manifest Version Update\n\n```python\n# v17\n'version': '17.0.1.0.0',\n\n# v18\n'version': '18.0.1.0.0',\n```\n\n## Migration Checklist\n\n### Models (Python)\n- [ ] Add `_check_company_auto = True` to multi-company models\n- [ ] Add `check_company=True` to relational fields referencing company-specific data\n- [ ] Replace raw SQL with `SQL()` builder\n- [ ] Add type hints to method signatures\n- [ ] Update Python compatibility to 3.10+ (3.12 recommended)\n- [ ] Review deprecated method usage\n\n### Views (XML)\n- [ ] Verify all views work with new validation\n- [ ] Test company switching behavior\n- [ ] Verify field visibility with company rules\n\n### Security (XML)\n- [ ] Update record rules to use `allowed_company_ids`\n- [ ] Test multi-company access scenarios\n- [ ] Verify cross-company data isolation\n\n### OWL (JavaScript)\n- [ ] Verify service usage patterns\n- [ ] Test all client actions\n- [ ] Verify RPC calls work correctly\n\n### Manifest\n- [ ] Update version from `17.0.x.x.x` to `18.0.x.x.x`\n- [ ] Verify all dependencies are v18 compatible\n- [ ] Check asset declarations\n\n### Testing\n- [ ] Run all tests with v18\n- [ ] Test multi-company scenarios\n- [ ] Test company switching\n- [ ] Verify SQL queries work correctly\n- [ ] Performance testing (SQL builder may affect)\n\n## Common Migration Issues\n\n### Issue: Company validation errors\n```\nValidationError: Partner's company must match document company.\n```\n**Solution**: Ensure all relational fields have proper company relationships or set `check_company=False` for cross-company fields.\n\n### Issue: SQL syntax errors\n```\nProgrammingError: syntax error at or near \"SQL\"\n```\n**Solution**: Ensure SQL() is properly imported from `odoo.tools`.\n\n### Issue: Type hint import errors\n```\nImportError: cannot import name 'Optional' from 'typing'\n```\n**Solution**: Use Python 3.10+ which has built-in support, or import from `typing`.\n\n## Performance Considerations\n\n### SQL Builder Overhead\nThe SQL() builder adds minimal overhead but provides:\n- Automatic SQL injection prevention\n- Better query debugging\n- Consistent query formatting\n\n### Company Check Performance\n`_check_company_auto` adds validation overhead:\n- Enable only on models that need it\n- Consider disabling for high-volume transient models\n- Use `with_context(check_company=False)` for batch operations\n\n## Backward Compatibility Notes\n\n### Keeping v17 Compatibility (Dual Version)\n```python\n# Works in both v17 and v18\ntry:\n from odoo.tools import SQL\n HAS_SQL_BUILDER = True\nexcept ImportError:\n HAS_SQL_BUILDER = False\n\ndef _execute_query(self, ...):\n if HAS_SQL_BUILDER:\n query = SQL(...)\n else:\n query = \"...\" % (...)\n self.env.cr.execute(query)\n```\n\n**Note**: This is only recommended for modules that must support multiple versions simultaneously.\n\n## GitHub Reference\n\nFor official migration notes, consult:\n- https://github.com/odoo/odoo/tree/18.0\n- Odoo 18.0 release notes\n- Community upgrade scripts\n\n---\n\n**IMPORTANT**: v18 changes are mostly additive. Focus on adopting new patterns for better security and maintainability.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9443,"content_sha256":"a589c21e07c78504c00830ac0c98244f2bd33990edee4b84df4dc18e0f4dc1f1"},{"filename":"skills/odoo-module-generator-17.md","content":"# Odoo Module Generator - Version 17.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 17.0 MODULE GENERATION PATTERNS ║\n║ This file contains ONLY Odoo 17.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 17.0 Requirements\n\n- **Python**: 3.10+ required\n- **Key Changes**: `attrs` REMOVED from views, `@api.model_create_multi` mandatory\n- **OWL**: 2.x (enhanced)\n- **View syntax**: Direct `invisible`/`readonly`/`required` attributes with Python expressions\n\n## IMPORTANT: Breaking Changes from v16\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ CRITICAL: The `attrs` attribute is COMPLETELY REMOVED in v17. ║\n║ All views using attrs=\"{'invisible': [...]}\" WILL FAIL. ║\n║ You MUST use direct attributes: invisible=\"expression\" ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## IMPORTANT: Data File Ordering\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ The ORDER of files in the 'data' list is CRITICAL. ║\n║ A resource can ONLY be referenced AFTER it has been defined. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Input Parameters\n\n### Required Parameters\n\n| Parameter | Type | Description | Example |\n|-----------|------|-------------|---------|\n| `module_name` | string | Technical name (lowercase, underscores) | `custom_inventory` |\n| `module_description` | string | Human-readable description | `Custom inventory tracking` |\n\n### Optional Parameters\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `target_apps` | list | `[]` | Apps to extend |\n| `ui_stack` | string | `owl` | UI technology: `classic`, `owl`, `hybrid` |\n| `multi_company` | boolean | `false` | Enable multi-company support |\n| `multi_currency` | boolean | `false` | Enable multi-currency support |\n| `security_level` | string | `basic` | Security level: `basic`, `advanced`, `audit` |\n| `performance_critical` | boolean | `false` | Enable performance optimizations |\n| `custom_models` | list | `[]` | List of custom models to create |\n| `include_tests` | boolean | `true` | Generate test files |\n\n## Generated Module Structure\n\n```\n{module_name}/\n├── __init__.py\n├── __manifest__.py\n├── models/\n│ ├── __init__.py\n│ └── {model_name}.py\n├── views/\n│ ├── {model_name}_views.xml\n│ └── menuitems.xml\n├── security/\n│ ├── ir.model.access.csv\n│ └── {module_name}_security.xml\n├── static/\n│ └── src/\n│ ├── js/\n│ ├── xml/\n│ └── scss/\n├── tests/\n│ ├── __init__.py\n│ └── test_{model_name}.py\n└── i18n/\n```\n\n## __manifest__.py Template (v17)\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': '{Module Title}',\n 'version': '17.0.1.0.0',\n 'category': '{Category}',\n 'summary': '{Short description}',\n 'description': \"\"\"\n{Detailed description}\n \"\"\",\n 'author': '{Author}',\n 'website': '{Website}',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail'],\n 'data': [\n # ORDER IS CRITICAL - define before reference\n 'security/{module_name}_security.xml', # Groups first\n 'security/ir.model.access.csv', # Access rights\n 'views/{model_name}_views.xml', # Views\n 'views/menuitems.xml', # Menus last\n ],\n 'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/**/*.js',\n '{module_name}/static/src/**/*.xml',\n '{module_name}/static/src/**/*.scss',\n ],\n },\n 'demo': [],\n 'installable': True,\n 'application': False,\n 'auto_install': False,\n}\n```\n\n## Model Template (v17)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import UserError, ValidationError\n\nclass {ModelName}(models.Model):\n _name = '{module_name}.{model_name}'\n _description = '{Model Description}'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n\n # === BASIC FIELDS === #\n name = fields.Char(\n string='Name',\n required=True,\n tracking=True,\n index='btree', # v17: index type specification\n )\n active = fields.Boolean(default=True)\n sequence = fields.Integer(default=10)\n\n # === RELATIONAL FIELDS === #\n company_id = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n tracking=True,\n domain=\"[('company_id', 'in', [company_id, False])]\",\n )\n user_id = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n )\n line_ids = fields.One2many(\n comodel_name='{module_name}.{model_name}.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n\n # === SELECTION FIELDS === #\n state = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n copy=False,\n )\n\n # === COMPUTED FIELDS === #\n total_amount = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n readonly=True,\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n # === CONSTRAINTS === #\n @api.constrains('name')\n def _check_name(self):\n for record in self:\n if record.name and len(record.name) \u003c 3:\n raise ValidationError(_(\"Name must be at least 3 characters.\"))\n\n _sql_constraints = [\n ('name_company_uniq', 'UNIQUE(name, company_id)',\n 'Name must be unique per company!'),\n ]\n\n # === CRUD METHODS === #\n # v17: @api.model_create_multi is MANDATORY\n @api.model_create_multi\n def create(self, vals_list):\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n '{module_name}.{model_name}'\n ) or _('New')\n return super().create(vals_list)\n\n def write(self, vals):\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Cannot complete without lines.\"))\n return super().write(vals)\n\n def unlink(self):\n for record in self:\n if record.state not in ('draft', 'cancelled'):\n raise UserError(\n _(\"Cannot delete record in state '%s'.\") % record.state\n )\n return super().unlink()\n\n def copy(self, default=None):\n default = dict(default or {})\n default.update({\n 'name': _(\"%s (Copy)\") % self.name,\n 'state': 'draft',\n })\n return super().copy(default)\n\n # === ACTION METHODS === #\n def action_confirm(self):\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n self.write({'state': 'confirmed'})\n\n def action_done(self):\n for record in self:\n if record.state != 'confirmed':\n raise UserError(_(\"Only confirmed records can be marked done.\"))\n self.write({'state': 'done'})\n\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n\n def action_draft(self):\n self.write({'state': 'draft'})\n\n # === x2many OPERATIONS === #\n def action_add_line(self):\n \"\"\"v17: Use Command class for x2many operations.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New Line', 'amount': 0}),\n ]\n })\n\n\nclass {ModelName}Line(models.Model):\n _name = '{module_name}.{model_name}.line'\n _description = '{Model Name} Line'\n _order = 'sequence, id'\n\n parent_id = fields.Many2one(\n comodel_name='{module_name}.{model_name}',\n string='Parent',\n required=True,\n ondelete='cascade',\n index=True,\n )\n company_id = fields.Many2one(\n related='parent_id.company_id',\n store=True,\n )\n sequence = fields.Integer(default=10)\n name = fields.Char(string='Description', required=True)\n quantity = fields.Float(string='Quantity', default=1.0)\n price_unit = fields.Float(string='Unit Price')\n amount = fields.Float(\n string='Amount',\n compute='_compute_amount',\n store=True,\n )\n\n @api.depends('quantity', 'price_unit')\n def _compute_amount(self):\n for line in self:\n line.amount = line.quantity * line.price_unit\n```\n\n## View Templates (v17 - NO attrs!)\n\n### Form View\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"{model_name}_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.form\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"{Model Title}\">\n \u003cheader>\n \u003c!-- v17: Direct invisible with Python expression -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_cancel\" string=\"Cancel\" type=\"object\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cbutton name=\"action_draft\" string=\"Reset to Draft\" type=\"object\"\n invisible=\"state != 'cancelled'\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\"/>\n \u003c!-- v17: invisible takes Python expression -->\n \u003cwidget name=\"web_ribbon\" title=\"Archived\" bg_color=\"bg-danger\"\n invisible=\"active\"/>\n \u003cdiv class=\"oe_title\">\n \u003clabel for=\"name\"/>\n \u003ch1>\u003cfield name=\"name\" placeholder=\"Name...\"/>\u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"total_amount\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### v17 Visibility Syntax Examples\n\n```xml\n\u003c!-- Simple condition -->\n\u003cfield name=\"notes\" invisible=\"state == 'draft'\"/>\n\n\u003c!-- Multiple conditions with and -->\n\u003cfield name=\"amount\" invisible=\"state == 'draft' and not is_manager\"/>\n\n\u003c!-- Multiple conditions with or -->\n\u003cfield name=\"secret\" invisible=\"state == 'draft' or state == 'cancelled'\"/>\n\n\u003c!-- Using in operator -->\n\u003cfield name=\"field\" invisible=\"state in ('draft', 'cancelled')\"/>\n\n\u003c!-- Not in -->\n\u003cfield name=\"field\" invisible=\"state not in ('confirmed', 'done')\"/>\n\n\u003c!-- Group check -->\n\u003cfield name=\"admin_field\" invisible=\"not user_has_groups('base.group_system')\"/>\n\n\u003c!-- Combined with readonly and required -->\n\u003cfield name=\"amount\"\n readonly=\"state != 'draft'\"\n required=\"state == 'confirmed'\"\n invisible=\"state == 'cancelled'\"/>\n```\n\n### Tree View\n\n```xml\n\u003crecord id=\"{model_name}_view_tree\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.tree\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ctree string=\"{Model Title}\" multi_edit=\"1\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\" widget=\"many2one_avatar_user\"/>\n \u003cfield name=\"total_amount\" sum=\"Total\"/>\n \u003cfield name=\"state\" widget=\"badge\"\n decoration-success=\"state == 'done'\"\n decoration-info=\"state == 'confirmed'\"\n decoration-warning=\"state == 'draft'\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\" optional=\"hide\"/>\n \u003c/tree>\n \u003c/field>\n\u003c/record>\n```\n\n### Search View\n\n```xml\n\u003crecord id=\"{model_name}_view_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.search\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch string=\"{Model Title}\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003cseparator/>\n \u003cfilter string=\"My Records\" name=\"my_records\"\n domain=\"[('user_id', '=', uid)]\"/>\n \u003cfilter string=\"Draft\" name=\"draft\"\n domain=\"[('state', '=', 'draft')]\"/>\n \u003cfilter string=\"Archived\" name=\"inactive\"\n domain=\"[('active', '=', False)]\"/>\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter string=\"Partner\" name=\"group_partner\"\n context=\"{'group_by': 'partner_id'}\"/>\n \u003cfilter string=\"Status\" name=\"group_state\"\n context=\"{'group_by': 'state'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n\u003c/record>\n```\n\n## Security Templates (v17)\n\n### ir.model.access.csv\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_{model_name}_user,{model_name}.user,model_{module_name}_{model_name},{module_name}.group_{module_name}_user,1,1,1,0\naccess_{model_name}_manager,{model_name}.manager,model_{module_name}_{model_name},{module_name}.group_{module_name}_manager,1,1,1,1\naccess_{model_name}_line_user,{model_name}.line.user,model_{module_name}_{model_name}_line,{module_name}.group_{module_name}_user,1,1,1,0\naccess_{model_name}_line_manager,{model_name}.line.manager,model_{module_name}_{model_name}_line,{module_name}.group_{module_name}_manager,1,1,1,1\n```\n\n### Security Groups XML\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_{module_name}\" model=\"ir.module.category\">\n \u003cfield name=\"name\">{Module Title}\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_{module_name}_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_{module_name}\"/>\n \u003c/record>\n\n \u003crecord id=\"group_{module_name}_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_{module_name}\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_{module_name}_user'))]\"/>\n \u003cfield name=\"users\" eval=\"[(4, ref('base.user_admin'))]\"/>\n \u003c/record>\n\n \u003c!-- Multi-Company Record Rule -->\n \u003crecord id=\"rule_{model_name}_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">{Model Name}: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_{module_name}_{model_name}\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## OWL Component Template (v17 - OWL 2.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class {ComponentName} extends Component {\n static template = \"{module_name}.{ComponentName}\";\n static props = {};\n\n setup() {\n this.orm = useService(\"orm\");\n this.notification = useService(\"notification\");\n this.action = useService(\"action\");\n\n this.state = useState({\n data: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"{module_name}.{model_name}\",\n [],\n [\"name\", \"state\", \"total_amount\"]\n );\n } finally {\n this.state.loading = false;\n }\n }\n}\n\nregistry.category(\"actions\").add(\"{module_name}.{component_name}\", {ComponentName});\n```\n\n## Test Template (v17)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo.tests import TransactionCase, tagged\nfrom odoo.exceptions import UserError, ValidationError\n\n@tagged('post_install', '-at_install')\nclass Test{ModelName}(TransactionCase):\n\n @classmethod\n def setUpClass(cls):\n super().setUpClass()\n cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))\n cls.Model = cls.env['{module_name}.{model_name}']\n\n def test_create_record(self):\n \"\"\"Test basic record creation.\"\"\"\n record = self.Model.create({'name': 'Test'})\n self.assertTrue(record.id)\n self.assertEqual(record.state, 'draft')\n\n def test_workflow(self):\n \"\"\"Test state workflow.\"\"\"\n record = self.Model.create({'name': 'Test'})\n record.action_confirm()\n self.assertEqual(record.state, 'confirmed')\n\n def test_model_create_multi(self):\n \"\"\"Test batch creation with @api.model_create_multi.\"\"\"\n records = self.Model.create([\n {'name': 'Test 1'},\n {'name': 'Test 2'},\n ])\n self.assertEqual(len(records), 2)\n```\n\n## v17 Checklist\n\nWhen generating a v17 module, ensure:\n\n- [ ] **NO `attrs` in any view** - use direct `invisible`/`readonly`/`required`\n- [ ] `@api.model_create_multi` for ALL create methods\n- [ ] `Command` class for x2many operations\n- [ ] `tracking=True` for tracked fields (not `track_visibility`)\n- [ ] Data files in correct order in manifest\n- [ ] Python 3.10+ compatibility\n- [ ] `company_ids` in multi-company record rules\n\n## AI Agent Instructions (v17)\n\nWhen generating an Odoo 17.0 module:\n\n1. **NEVER use** `attrs` attribute in views (removed in v17)\n2. **ALWAYS use** direct `invisible`, `readonly`, `required` with Python expressions\n3. **ALWAYS use** `@api.model_create_multi` for create methods\n4. **USE** `user_has_groups()` for group checks in visibility\n5. **USE** `Command` class for x2many operations\n6. **USE** `company_ids` in multi-company record rules\n7. **VERIFY** data file order in manifest\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":21532,"content_sha256":"c98816e7242a88b487983d329147896a7f91ddce3782bc41c8f664e1fe19f355"},{"filename":"skills/odoo-module-generator-18-19.md","content":"# Odoo Module Migration Guide: 18.0 → 19.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: Odoo 18.0 → 19.0 ║\n║ This document covers ONLY changes between these specific versions. ║\n║ Note: v19 is in development - patterns may change. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Component | v18 Status | v19 Status | Action Required |\n|-----------|------------|------------|-----------------|\n| Type hints | Recommended | **Mandatory** | Must add |\n| `SQL()` builder | Recommended | **Mandatory** | Must migrate |\n| Raw SQL strings | Deprecated | **Removed** | Must migrate |\n| OWL | 2.x | **3.x** | Must update |\n| Python 3.10 | Required | 3.12+ required | Upgrade Python |\n| `_check_company_auto` | Recommended | Standard | Already adopted |\n\n## MANDATORY: Type Hints\n\n### Before (v18 - Recommended)\n```python\ndef calculate_total(self, include_tax=True, discount=None):\n discount = discount or 0\n total = sum(self.mapped('amount'))\n if include_tax:\n total *= 1.21\n return total - discount\n```\n\n### After (v19 - Required)\n```python\nfrom typing import Optional\n\ndef calculate_total(\n self,\n include_tax: bool = True,\n discount: Optional[float] = None,\n) -> float:\n discount = discount or 0.0\n total = sum(self.mapped('amount'))\n if include_tax:\n total *= 1.21\n return total - discount\n```\n\n### Complete Type Hint Examples\n\n```python\nfrom typing import Optional, Any, Union\nfrom collections.abc import Iterable, Mapping\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> 'MyModel':\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n return super().write(vals)\n\n def unlink(self) -> bool:\n return super().unlink()\n\n def copy(self, default: Optional[dict[str, Any]] = None) -> 'MyModel':\n return super().copy(default)\n\n def action_confirm(self) -> None:\n self.write({'state': 'confirmed'})\n\n def get_partner_data(self) -> dict[str, Any]:\n return {\n 'id': self.partner_id.id,\n 'name': self.partner_id.name,\n }\n\n @api.model\n def search_by_criteria(\n self,\n domain: list[tuple[str, str, Any]],\n limit: Optional[int] = None,\n offset: int = 0,\n ) -> 'MyModel':\n return self.search(domain, limit=limit, offset=offset)\n```\n\n## MANDATORY: SQL Builder\n\n### Before (v18 - Allowed)\n```python\n# This will FAIL in v19\nquery = \"\"\"\n SELECT id, name FROM %s WHERE company_id = %s\n\"\"\" % (self._table, self.env.company.id)\nself.env.cr.execute(query)\n```\n\n### After (v19 - Required)\n```python\nfrom odoo.tools import SQL\n\nquery = SQL(\n \"\"\"\n SELECT id, name FROM %s WHERE company_id = %s\n \"\"\",\n SQL.identifier(self._table),\n self.env.company.id,\n)\nself.env.cr.execute(query)\n```\n\n### SQL Builder Complete Reference\n\n```python\nfrom odoo.tools import SQL\n\n# Basic query with parameters\nquery = SQL(\n \"SELECT * FROM %s WHERE id = %s AND active = %s\",\n SQL.identifier('res_partner'),\n 123,\n True,\n)\n\n# Table and column identifiers\ntable = SQL.identifier('my_table')\ncolumn = SQL.identifier('my_table', 'my_column')\n\n# Dynamic ORDER BY\norder_sql = SQL('ORDER BY %s %s', SQL.identifier('create_date'), SQL('DESC'))\n\n# Combining queries\nunion_query = SQL(\n \"%s UNION ALL %s\",\n SQL(\"SELECT id, name FROM table1 WHERE type = %s\", 'a'),\n SQL(\"SELECT id, name FROM table2 WHERE type = %s\", 'b'),\n)\n\n# Complex query\nreport_query = SQL(\n \"\"\"\n SELECT\n p.id,\n p.name,\n COALESCE(SUM(o.amount_total), 0) as total_sales\n FROM %s p\n LEFT JOIN %s o ON o.partner_id = p.id\n WHERE p.company_id = %s\n AND p.active = %s\n AND o.state IN %s\n GROUP BY p.id, p.name\n HAVING SUM(o.amount_total) > %s\n ORDER BY %s\n LIMIT %s\n \"\"\",\n SQL.identifier('res_partner'),\n SQL.identifier('sale_order'),\n self.env.company.id,\n True,\n ('sale', 'done'),\n 1000.0,\n SQL('total_sales DESC'),\n 100,\n)\n```\n\n## OWL 3.x Migration\n\n### Major OWL Changes\n\n| Feature | OWL 2.x (v18) | OWL 3.x (v19) |\n|---------|---------------|---------------|\n| Reactivity | `useState` | Enhanced reactivity |\n| Component class | `Component` | Updated patterns |\n| Lifecycle | Hooks-based | Refined hooks |\n| Templates | QWeb | Enhanced QWeb |\n\n### Component Structure Changes\n\n```javascript\n// v19: OWL 3.x patterns\n/** @odoo-module **/\n\nimport { Component, useState, useRef, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n onSelect: { type: Function, optional: true },\n };\n\n setup() {\n // Services\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n\n // Reactive state\n this.state = useState({\n data: [],\n loading: true,\n });\n\n // Refs\n this.containerRef = useRef(\"container\");\n\n // Lifecycle\n onMounted(() => {\n this.loadData();\n });\n }\n\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\"]\n );\n } finally {\n this.state.loading = false;\n }\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_component\", MyComponent);\n```\n\n### Template Updates\n\n```xml\n\u003c!-- v19: Enhanced QWeb -->\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv t-ref=\"container\" class=\"o_my_component\">\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"o_loading\">Loading...\u003c/div>\n \u003c/t>\n \u003ct t-else=\"\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv class=\"o_item\" t-on-click=\"() => this.onItemClick(item)\">\n \u003cspan t-esc=\"item.name\"/>\n \u003c/div>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## Python 3.12+ Requirements\n\n### New Python Features Available\n\n```python\n# Type parameter syntax (PEP 695)\ndef process_items[T](items: list[T]) -> list[T]:\n return [item for item in items if item]\n\n# Match statements (enhanced)\nmatch self.state:\n case 'draft':\n self.action_confirm()\n case 'confirmed':\n self.action_done()\n case _:\n raise UserError(_(\"Invalid state\"))\n\n# Exception groups (if needed)\ntry:\n self.validate_all()\nexcept* ValidationError as eg:\n for e in eg.exceptions:\n self.notification.add(str(e), type='warning')\n```\n\n## Manifest Version Update\n\n```python\n# v18\n'version': '18.0.1.0.0',\n\n# v19\n'version': '19.0.1.0.0',\n```\n\n## Migration Checklist\n\n### Models (Python) - CRITICAL\n- [ ] Add type hints to ALL method signatures\n- [ ] Add type hints to ALL method return types\n- [ ] Replace ALL raw SQL with `SQL()` builder\n- [ ] Verify `from odoo.tools import SQL` is imported\n- [ ] Update to Python 3.12+ syntax where beneficial\n- [ ] Review all `cr.execute()` calls\n\n### OWL Components (JavaScript) - CRITICAL\n- [ ] Update to OWL 3.x patterns\n- [ ] Review all component lifecycle hooks\n- [ ] Update reactivity patterns\n- [ ] Test all UI components thoroughly\n\n### Views (XML)\n- [ ] Verify all views work with v19\n- [ ] Test all dynamic visibility rules\n- [ ] Verify template inheritance\n\n### Security\n- [ ] Verify record rules with new patterns\n- [ ] Test multi-company scenarios\n- [ ] Review group assignments\n\n### Manifest\n- [ ] Update version from `18.0.x.x.x` to `19.0.x.x.x`\n- [ ] Verify all dependencies are v19 compatible\n- [ ] Update asset declarations for OWL 3.x\n\n### Testing\n- [ ] Run all tests with Python 3.12+\n- [ ] Test all OWL components\n- [ ] Verify all SQL queries execute correctly\n- [ ] Performance testing\n\n## Common Migration Errors\n\n### Error: Missing type hints\n```\nTypeError: Missing type annotation for parameter 'vals'\n```\n**Solution**: Add type hints to all method parameters and return types.\n\n### Error: Raw SQL not allowed\n```\nSecurityError: Raw SQL strings are not allowed. Use SQL() builder.\n```\n**Solution**: Convert all raw SQL to use `SQL()` builder.\n\n### Error: OWL component failure\n```\nError: Component lifecycle hook not found\n```\n**Solution**: Update component to OWL 3.x patterns.\n\n### Error: Python version incompatibility\n```\nSyntaxError: invalid syntax (requires Python 3.12+)\n```\n**Solution**: Upgrade Python to 3.12 or later.\n\n## Type Hint Migration Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nHelper to identify methods missing type hints.\nRun on your Python files to find what needs updating.\n\"\"\"\nimport ast\nimport sys\nfrom pathlib import Path\n\ndef check_type_hints(filepath: Path) -> list[str]:\n \"\"\"Check for missing type hints in a Python file.\"\"\"\n issues = []\n with open(filepath) as f:\n tree = ast.parse(f.read())\n\n for node in ast.walk(tree):\n if isinstance(node, ast.FunctionDef):\n # Check return type\n if node.returns is None and node.name != '__init__':\n issues.append(f\"{filepath}:{node.lineno} - {node.name}() missing return type\")\n\n # Check parameters\n for arg in node.args.args:\n if arg.arg != 'self' and arg.annotation is None:\n issues.append(f\"{filepath}:{node.lineno} - {node.name}({arg.arg}) missing type\")\n\n return issues\n\nif __name__ == '__main__':\n for path in Path('.').rglob('*.py'):\n issues = check_type_hints(path)\n for issue in issues:\n print(issue)\n```\n\n## GitHub Reference\n\nFor official migration notes, consult:\n- https://github.com/odoo/odoo/tree/19.0 (when available)\n- Odoo 19.0 release notes\n- Community upgrade scripts\n\n---\n\n**IMPORTANT**: v19 is currently in development. These patterns are based on announced changes and may be refined. Always verify against official documentation when available.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10778,"content_sha256":"098da74887e2e942f78e6a13c371b3f2e91725dda069a5305e08fcf5307e750d"},{"filename":"skills/odoo-module-generator-18.md","content":"# Odoo Module Generator - Version 18.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 18.0 MODULE GENERATION PATTERNS ║\n║ This file contains ONLY Odoo 18.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 18.0 Requirements\n\n- **Python**: 3.11+ required\n- **Key Features**: `_check_company_auto`, `check_company`, type hints, `SQL()` builder\n- **OWL**: 2.x\n- **View syntax**: Direct `invisible`/`readonly` attributes\n\n## Input Parameters\n\n### Required Parameters\n\n| Parameter | Type | Description | Example |\n|-----------|------|-------------|---------|\n| `module_name` | string | Technical name (lowercase, underscores) | `custom_inventory` |\n| `module_description` | string | Human-readable description | `Custom inventory tracking` |\n\n### Optional Parameters\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `target_apps` | list | `[]` | Apps to extend: `crm`, `sale`, `purchase`, `account`, `hr`, `website`, `stock`, `mrp`, `project` |\n| `ui_stack` | string | `owl` | UI technology: `classic`, `owl`, `hybrid` |\n| `multi_company` | boolean | `false` | Enable multi-company support |\n| `multi_currency` | boolean | `false` | Enable multi-currency support |\n| `security_level` | string | `basic` | Security level: `basic`, `advanced`, `audit` |\n| `performance_critical` | boolean | `false` | Enable performance optimizations |\n| `custom_models` | list | `[]` | List of custom models to create |\n| `custom_fields` | list | `[]` | Fields to add to existing models |\n| `include_tests` | boolean | `true` | Generate test files |\n| `author` | string | `\"\"` | Module author name |\n| `license` | string | `LGPL-3` | Module license |\n\n## Generated Module Structure\n\n```\n{module_name}/\n├── __init__.py\n├── __manifest__.py\n├── models/\n│ ├── __init__.py\n│ └── {model_name}.py\n├── views/\n│ ├── {model_name}_views.xml\n│ └── menuitems.xml\n├── security/\n│ ├── ir.model.access.csv\n│ └── {module_name}_security.xml\n├── static/\n│ ├── description/\n│ │ └── icon.png\n│ └── src/\n│ ├── js/\n│ │ └── {component_name}.js\n│ ├── xml/\n│ │ └── {component_name}.xml\n│ └── scss/\n│ └── {module_name}.scss\n├── data/\n│ └── {module_name}_data.xml\n├── wizard/\n│ ├── __init__.py\n│ └── {wizard_name}.py\n├── report/\n│ ├── __init__.py\n│ ├── {report_name}.py\n│ └── {report_name}_template.xml\n├── tests/\n│ ├── __init__.py\n│ └── test_{model_name}.py\n└── i18n/\n └── {module_name}.pot\n```\n\n## __manifest__.py Template (v18)\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': '{Module Title}',\n 'version': '18.0.1.0.0',\n 'category': '{Category}',\n 'summary': '{Short description}',\n 'description': \"\"\"\n{Detailed description}\n \"\"\",\n 'author': '{Author}',\n 'website': '{Website}',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail'],\n 'data': [\n 'security/{module_name}_security.xml',\n 'security/ir.model.access.csv',\n 'views/{model_name}_views.xml',\n 'views/menuitems.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/**/*.js',\n '{module_name}/static/src/**/*.xml',\n '{module_name}/static/src/**/*.scss',\n ],\n },\n 'demo': [],\n 'installable': True,\n 'application': False,\n 'auto_install': False,\n}\n```\n\n## Model Template (v18)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import UserError, ValidationError\nfrom odoo.tools import SQL\n\nclass {ModelName}(models.Model):\n _name = '{module_name}.{model_name}'\n _description = '{Model Description}'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n _check_company_auto = True # v18: Automatic company validation\n\n # === BASIC FIELDS === #\n name: str = fields.Char(\n string='Name',\n required=True,\n tracking=True,\n index='btree',\n )\n active: bool = fields.Boolean(default=True)\n sequence: int = fields.Integer(default=10)\n\n # === RELATIONAL FIELDS === #\n company_id: int = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id: int = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n check_company=True, # v18: Company validation\n tracking=True,\n )\n user_id: int = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n check_company=True,\n tracking=True,\n )\n line_ids: list = fields.One2many(\n comodel_name='{module_name}.{model_name}.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n\n # === SELECTION FIELDS === #\n state: str = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n copy=False,\n )\n\n # === COMPUTED FIELDS === #\n total_amount: float = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n readonly=True,\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self):\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n # === CONSTRAINTS === #\n @api.constrains('name')\n def _check_name(self):\n for record in self:\n if record.name and len(record.name) \u003c 3:\n raise ValidationError(_(\"Name must be at least 3 characters.\"))\n\n _sql_constraints = [\n ('name_company_uniq', 'UNIQUE(name, company_id)',\n 'Name must be unique per company!'),\n ]\n\n # === CRUD METHODS === #\n @api.model_create_multi\n def create(self, vals_list):\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n '{module_name}.{model_name}'\n ) or _('New')\n return super().create(vals_list)\n\n def write(self, vals):\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Cannot complete without lines.\"))\n return super().write(vals)\n\n def unlink(self):\n for record in self:\n if record.state not in ('draft', 'cancelled'):\n raise UserError(_(\"Cannot delete record in state '%s'.\") % record.state)\n return super().unlink()\n\n def copy(self, default=None):\n default = dict(default or {})\n default.update({\n 'name': _(\"%s (Copy)\") % self.name,\n 'state': 'draft',\n })\n return super().copy(default)\n\n # === ACTION METHODS === #\n def action_confirm(self):\n for record in self:\n if record.state != 'draft':\n raise UserError(_(\"Only draft records can be confirmed.\"))\n self.write({'state': 'confirmed'})\n\n def action_done(self):\n for record in self:\n if record.state != 'confirmed':\n raise UserError(_(\"Only confirmed records can be marked done.\"))\n self.write({'state': 'done'})\n\n def action_cancel(self):\n self.write({'state': 'cancelled'})\n\n def action_draft(self):\n self.write({'state': 'draft'})\n\n # === BUSINESS METHODS === #\n def _get_report_data(self):\n \"\"\"v18: Use SQL builder for complex queries.\"\"\"\n query = SQL(\n \"\"\"\n SELECT id, name, total_amount\n FROM %(table)s\n WHERE company_id = %(company_id)s\n AND state = %(state)s\n ORDER BY total_amount DESC\n \"\"\",\n table=SQL.identifier(self._table),\n company_id=self.env.company.id,\n state='done',\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n\n\nclass {ModelName}Line(models.Model):\n _name = '{module_name}.{model_name}.line'\n _description = '{Model Name} Line'\n _order = 'sequence, id'\n\n parent_id: int = fields.Many2one(\n comodel_name='{module_name}.{model_name}',\n string='Parent',\n required=True,\n ondelete='cascade',\n index=True,\n )\n company_id: int = fields.Many2one(\n related='parent_id.company_id',\n store=True,\n )\n sequence: int = fields.Integer(default=10)\n name: str = fields.Char(string='Description', required=True)\n quantity: float = fields.Float(string='Quantity', default=1.0)\n price_unit: float = fields.Float(string='Unit Price')\n amount: float = fields.Float(\n string='Amount',\n compute='_compute_amount',\n store=True,\n )\n\n @api.depends('quantity', 'price_unit')\n def _compute_amount(self):\n for line in self:\n line.amount = line.quantity * line.price_unit\n```\n\n## View Templates (v18)\n\n### Form View\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"{model_name}_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.form\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"{Model Title}\">\n \u003cheader>\n \u003c!-- v18: Direct invisible attribute -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\" invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_cancel\" string=\"Cancel\" type=\"object\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cbutton name=\"action_draft\" string=\"Reset to Draft\" type=\"object\"\n invisible=\"state not in ('cancelled',)\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\"/>\n \u003cwidget name=\"web_ribbon\" title=\"Archived\" bg_color=\"bg-danger\"\n invisible=\"active\"/>\n \u003cdiv class=\"oe_title\">\n \u003clabel for=\"name\"/>\n \u003ch1>\u003cfield name=\"name\" placeholder=\"Name...\"/>\u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"total_amount\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Tree View\n\n```xml\n\u003crecord id=\"{model_name}_view_tree\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.tree\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003ctree string=\"{Model Title}\" multi_edit=\"1\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\" widget=\"many2one_avatar_user\"/>\n \u003cfield name=\"total_amount\" sum=\"Total\"/>\n \u003cfield name=\"state\" widget=\"badge\"\n decoration-success=\"state == 'done'\"\n decoration-info=\"state == 'confirmed'\"\n decoration-warning=\"state == 'draft'\"/>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\" optional=\"hide\"/>\n \u003c/tree>\n \u003c/field>\n\u003c/record>\n```\n\n### Search View\n\n```xml\n\u003crecord id=\"{model_name}_view_search\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.search\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003csearch string=\"{Model Title}\">\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003cseparator/>\n \u003cfilter string=\"My Records\" name=\"my_records\"\n domain=\"[('user_id', '=', uid)]\"/>\n \u003cfilter string=\"Draft\" name=\"draft\"\n domain=\"[('state', '=', 'draft')]\"/>\n \u003cfilter string=\"Confirmed\" name=\"confirmed\"\n domain=\"[('state', '=', 'confirmed')]\"/>\n \u003cfilter string=\"Done\" name=\"done\"\n domain=\"[('state', '=', 'done')]\"/>\n \u003cseparator/>\n \u003cfilter string=\"Archived\" name=\"inactive\"\n domain=\"[('active', '=', False)]\"/>\n \u003cgroup expand=\"0\" string=\"Group By\">\n \u003cfilter string=\"Partner\" name=\"group_partner\"\n context=\"{'group_by': 'partner_id'}\"/>\n \u003cfilter string=\"Status\" name=\"group_state\"\n context=\"{'group_by': 'state'}\"/>\n \u003cfilter string=\"Responsible\" name=\"group_user\"\n context=\"{'group_by': 'user_id'}\"/>\n \u003c/group>\n \u003c/search>\n \u003c/field>\n\u003c/record>\n```\n\n### Action and Menu\n\n```xml\n\u003crecord id=\"{model_name}_action\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">{Model Title}\u003c/field>\n \u003cfield name=\"res_model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"view_mode\">tree,form\u003c/field>\n \u003cfield name=\"context\">{'search_default_my_records': 1}\u003c/field>\n \u003cfield name=\"help\" type=\"html\">\n \u003cp class=\"o_view_nocontent_smiling_face\">\n Create your first {Model Title}\n \u003c/p>\n \u003c/field>\n\u003c/record>\n\n\u003cmenuitem id=\"menu_{module_name}_root\"\n name=\"{Module Title}\"\n sequence=\"100\"/>\n\n\u003cmenuitem id=\"menu_{model_name}\"\n name=\"{Model Title}\"\n parent=\"menu_{module_name}_root\"\n action=\"{model_name}_action\"\n sequence=\"10\"/>\n```\n\n## Security Templates (v18)\n\n### ir.model.access.csv\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_{model_name}_user,{model_name}.user,model_{module_name}_{model_name},{module_name}.group_{module_name}_user,1,1,1,0\naccess_{model_name}_manager,{model_name}.manager,model_{module_name}_{model_name},{module_name}.group_{module_name}_manager,1,1,1,1\naccess_{model_name}_line_user,{model_name}.line.user,model_{module_name}_{model_name}_line,{module_name}.group_{module_name}_user,1,1,1,0\naccess_{model_name}_line_manager,{model_name}.line.manager,model_{module_name}_{model_name}_line,{module_name}.group_{module_name}_manager,1,1,1,1\n```\n\n### Security Groups XML\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_{module_name}\" model=\"ir.module.category\">\n \u003cfield name=\"name\">{Module Title}\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_{module_name}_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_{module_name}\"/>\n \u003c/record>\n\n \u003crecord id=\"group_{module_name}_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_{module_name}\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_{module_name}_user'))]\"/>\n \u003cfield name=\"users\" eval=\"[(4, ref('base.user_admin'))]\"/>\n \u003c/record>\n\n \u003c!-- Multi-Company Record Rule (v18 pattern) -->\n \u003crecord id=\"rule_{model_name}_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">{Model Name}: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_{module_name}_{model_name}\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## OWL Component Template (v18 - OWL 2.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class {ComponentName} extends Component {\n static template = \"{module_name}.{ComponentName}\";\n static props = {\n recordId: { type: Number, optional: true },\n };\n\n setup() {\n this.orm = useService(\"orm\");\n this.notification = useService(\"notification\");\n this.action = useService(\"action\");\n\n this.state = useState({\n data: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"{module_name}.{model_name}\",\n [[\"state\", \"=\", \"confirmed\"]],\n [\"name\", \"total_amount\", \"partner_id\"]\n );\n } catch (error) {\n this.notification.add(\"Error loading data\", { type: \"danger\" });\n } finally {\n this.state.loading = false;\n }\n }\n\n async onRecordClick(recordId) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"{module_name}.{model_name}\",\n res_id: recordId,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n}\n\nregistry.category(\"actions\").add(\"{module_name}.{component_name}\", {ComponentName});\n```\n\n## Test Template (v18)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo.tests import TransactionCase, tagged\nfrom odoo.exceptions import UserError, ValidationError\n\n@tagged('post_install', '-at_install')\nclass Test{ModelName}(TransactionCase):\n\n @classmethod\n def setUpClass(cls):\n super().setUpClass()\n cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))\n\n cls.company = cls.env.company\n cls.partner = cls.env['res.partner'].create({\n 'name': 'Test Partner',\n 'company_id': cls.company.id,\n })\n cls.user = cls.env['res.users'].create({\n 'name': 'Test User',\n 'login': 'test_user',\n 'company_id': cls.company.id,\n 'company_ids': [(6, 0, [cls.company.id])],\n })\n\n def test_create_record(self):\n \"\"\"Test basic record creation.\"\"\"\n record = self.env['{module_name}.{model_name}'].create({\n 'name': 'Test Record',\n 'partner_id': self.partner.id,\n })\n self.assertTrue(record.id)\n self.assertEqual(record.state, 'draft')\n\n def test_confirm_record(self):\n \"\"\"Test record confirmation workflow.\"\"\"\n record = self.env['{module_name}.{model_name}'].create({\n 'name': 'Test Record',\n 'partner_id': self.partner.id,\n })\n record.action_confirm()\n self.assertEqual(record.state, 'confirmed')\n\n def test_cannot_delete_confirmed(self):\n \"\"\"Test that confirmed records cannot be deleted.\"\"\"\n record = self.env['{module_name}.{model_name}'].create({\n 'name': 'Test Record',\n })\n record.action_confirm()\n with self.assertRaises(UserError):\n record.unlink()\n\n def test_multi_company(self):\n \"\"\"Test multi-company record rules.\"\"\"\n company_2 = self.env['res.company'].create({'name': 'Company 2'})\n record = self.env['{module_name}.{model_name}'].create({\n 'name': 'Test Record',\n 'company_id': company_2.id,\n })\n # User should not see record from other company\n records = self.env['{module_name}.{model_name}'].with_user(self.user).search([])\n self.assertNotIn(record.id, records.ids)\n```\n\n## v18 Checklist\n\nWhen generating a v18 module, ensure:\n\n- [ ] `_check_company_auto = True` on multi-company models\n- [ ] `check_company=True` on relational fields\n- [ ] Type hints on all fields: `name: str = fields.Char(...)`\n- [ ] `@api.model_create_multi` for create methods\n- [ ] Direct `invisible`/`readonly` in views (no `attrs`)\n- [ ] `allowed_company_ids` in record rules\n- [ ] `SQL()` builder for raw SQL queries\n- [ ] `tracking=True` for tracked fields\n- [ ] Proper OWL 2.x component syntax\n- [ ] Tests with `@tagged` decorator\n\n## AI Agent Instructions\n\nWhen generating an Odoo 18.0 module:\n\n1. **Always add** `_check_company_auto = True` to models with `company_id`\n2. **Always add** `check_company=True` to relational fields\n3. **Always add** type hints to fields\n4. **Always use** `@api.model_create_multi` for create\n5. **Never use** `attrs` in views\n6. **Use** `allowed_company_ids` in multi-company rules\n7. **Use** `SQL()` builder for any raw SQL\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":22867,"content_sha256":"901e1c0c6e14ee0e2ae63bfb6038a732b4c09a50ed665fa3ef6bc637d65b5d3e"},{"filename":"skills/odoo-module-generator-19.md","content":"# Odoo Module Generator - Version 19.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 19.0 MODULE GENERATION PATTERNS ║\n║ This file contains ONLY Odoo 19.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n║ Note: v19 is in DEVELOPMENT - patterns may change before release. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 19.0 Requirements\n\n- **Python**: 3.12+ required\n- **Type Hints**: MANDATORY on all methods\n- **SQL Builder**: MANDATORY for all raw SQL\n- **OWL**: 3.x (new patterns)\n- **View syntax**: Direct `invisible`/`readonly` with Python expressions\n\n## MANDATORY Features in v19\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ v19 MANDATORY REQUIREMENTS: ║\n║ • Type hints on ALL method parameters and return types ║\n║ • SQL() builder for ALL raw SQL queries ║\n║ • OWL 3.x patterns (OWL 2.x will not work) ║\n║ • Python 3.12+ syntax ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## __manifest__.py Template (v19)\n\n```python\n# -*- coding: utf-8 -*-\n{\n 'name': '{Module Title}',\n 'version': '19.0.1.0.0',\n 'category': '{Category}',\n 'summary': '{Short description}',\n 'description': \"\"\"\n{Detailed description}\n \"\"\",\n 'author': '{Author}',\n 'website': '{Website}',\n 'license': 'LGPL-3',\n 'depends': ['base', 'mail'],\n 'data': [\n # ORDER IS CRITICAL\n 'security/{module_name}_security.xml',\n 'security/ir.model.access.csv',\n 'views/{model_name}_views.xml',\n 'views/menuitems.xml',\n ],\n 'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/**/*.js',\n '{module_name}/static/src/**/*.xml',\n '{module_name}/static/src/**/*.scss',\n ],\n },\n 'demo': [],\n 'installable': True,\n 'application': False,\n 'auto_install': False,\n}\n```\n\n## Model Template (v19 - Full Type Hints)\n\n```python\n# -*- coding: utf-8 -*-\nfrom __future__ import annotations\n\nfrom typing import Any, Optional\nfrom collections.abc import Sequence\n\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import UserError, ValidationError\nfrom odoo.tools import SQL\n\n\nclass {ModelName}(models.Model):\n _name = '{module_name}.{model_name}'\n _description = '{Model Description}'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _order = 'create_date desc'\n _check_company_auto = True\n\n # === BASIC FIELDS === #\n name: str = fields.Char(\n string='Name',\n required=True,\n tracking=True,\n )\n active: bool = fields.Boolean(default=True)\n sequence: int = fields.Integer(default=10)\n description: str = fields.Text(string='Description')\n\n # === RELATIONAL FIELDS === #\n company_id: int = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id: int = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n tracking=True,\n check_company=True,\n )\n user_id: int = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n check_company=True,\n )\n line_ids: list = fields.One2many(\n comodel_name='{module_name}.{model_name}.line',\n inverse_name='parent_id',\n string='Lines',\n copy=True,\n )\n\n # === SELECTION FIELDS === #\n state: str = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ('cancelled', 'Cancelled'),\n ],\n string='Status',\n default='draft',\n required=True,\n tracking=True,\n copy=False,\n )\n\n # === MONETARY FIELDS === #\n currency_id: int = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n default=lambda self: self.env.company.currency_id,\n required=True,\n )\n amount: float = fields.Monetary(\n string='Amount',\n currency_field='currency_id',\n )\n\n # === COMPUTED FIELDS === #\n total_amount: float = fields.Float(\n string='Total Amount',\n compute='_compute_total_amount',\n store=True,\n )\n\n @api.depends('line_ids.amount')\n def _compute_total_amount(self) -> None:\n \"\"\"Compute total from lines.\"\"\"\n for record in self:\n record.total_amount = sum(record.line_ids.mapped('amount'))\n\n # === CONSTRAINTS === #\n @api.constrains('amount')\n def _check_amount(self) -> None:\n \"\"\"Validate amount is positive.\"\"\"\n for record in self:\n if record.amount \u003c 0:\n raise ValidationError(_(\"Amount must be positive.\"))\n\n _sql_constraints = [\n ('name_uniq', 'unique(company_id, name)', 'Name must be unique per company!'),\n ]\n\n # === CRUD METHODS (v19 - Type hints mandatory) === #\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> '{ModelName}':\n \"\"\"Create records with sequence generation.\"\"\"\n for vals in vals_list:\n if not vals.get('name'):\n vals['name'] = self.env['ir.sequence'].next_by_code(\n '{module_name}.{model_name}'\n ) or _('New')\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n \"\"\"Write with validation.\"\"\"\n if 'state' in vals and vals['state'] == 'done':\n for record in self:\n if not record.line_ids:\n raise UserError(_(\"Cannot complete without lines.\"))\n return super().write(vals)\n\n def unlink(self) -> bool:\n \"\"\"Prevent deletion of non-draft records.\"\"\"\n if any(rec.state != 'draft' for rec in self):\n raise UserError(_(\"Cannot delete non-draft records.\"))\n return super().unlink()\n\n def copy(self, default: Optional[dict[str, Any]] = None) -> '{ModelName}':\n \"\"\"Custom copy with name suffix.\"\"\"\n default = dict(default or {})\n default.setdefault('name', _(\"%s (Copy)\", self.name))\n return super().copy(default)\n\n # === x2many OPERATIONS === #\n def action_add_line(self) -> None:\n \"\"\"Add a new line using Command.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New Line', 'amount': 0}),\n ]\n })\n\n # === ACTION METHODS === #\n def action_confirm(self) -> None:\n \"\"\"Confirm records.\"\"\"\n self.write({'state': 'confirmed'})\n\n def action_done(self) -> None:\n \"\"\"Mark as done.\"\"\"\n self.write({'state': 'done'})\n\n def action_cancel(self) -> None:\n \"\"\"Cancel records.\"\"\"\n self.write({'state': 'cancelled'})\n\n def action_draft(self) -> None:\n \"\"\"Reset to draft.\"\"\"\n self.write({'state': 'draft'})\n\n # === SQL OPERATIONS (v19 - SQL() MANDATORY) === #\n def _get_report_data(self) -> list[dict[str, Any]]:\n \"\"\"Use SQL builder for all raw SQL queries.\"\"\"\n query = SQL(\n \"\"\"\n SELECT\n m.id,\n m.name,\n m.state,\n COALESCE(SUM(l.amount), 0) as total\n FROM %s m\n LEFT JOIN %s l ON l.parent_id = m.id\n WHERE m.company_id IN %s\n GROUP BY m.id, m.name, m.state\n ORDER BY m.create_date DESC\n \"\"\",\n SQL.identifier(self._table),\n SQL.identifier('{module_name}_{model_name}_line'),\n tuple(self.env.companies.ids),\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n\n # === SEARCH METHODS === #\n @api.model\n def _name_search(\n self,\n name: str = '',\n domain: Optional[list[tuple[str, str, Any]]] = None,\n operator: str = 'ilike',\n limit: int = 100,\n order: Optional[str] = None,\n ) -> Sequence[int]:\n \"\"\"Extended name search with type hints.\"\"\"\n domain = domain or []\n if name:\n domain = [\n '|',\n ('name', operator, name),\n ('sequence', operator, name),\n ] + domain\n return self._search(domain, limit=limit, order=order)\n\n # === RETURN ACTION METHODS === #\n def action_view_records(self) -> dict[str, Any]:\n \"\"\"Return action to view records.\"\"\"\n return {\n 'type': 'ir.actions.act_window',\n 'res_model': '{module_name}.{model_name}',\n 'view_mode': 'tree,form',\n 'domain': [('partner_id', '=', self.partner_id.id)],\n 'context': {'default_partner_id': self.partner_id.id},\n }\n```\n\n## View Templates (v19)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"{model_name}_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">{module_name}.{model_name}.form\u003c/field>\n \u003cfield name=\"model\">{module_name}.{model_name}\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"{Model Title}\">\n \u003cheader>\n \u003c!-- v19: Direct Python expressions -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_cancel\" string=\"Cancel\" type=\"object\"\n invisible=\"state in ('done', 'cancelled')\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"\n statusbar_visible=\"draft,confirmed,done\"/>\n \u003c/header>\n \u003csheet>\n \u003cdiv class=\"oe_button_box\" name=\"button_box\"/>\n \u003cwidget name=\"web_ribbon\" title=\"Archived\" bg_color=\"bg-danger\"\n invisible=\"active\"/>\n \u003cdiv class=\"oe_title\">\n \u003ch1>\u003cfield name=\"name\" placeholder=\"Name...\"/>\u003c/h1>\n \u003c/div>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"partner_id\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"total_amount\"/>\n \u003c/group>\n \u003c/group>\n \u003cnotebook>\n \u003cpage string=\"Lines\" name=\"lines\">\n \u003cfield name=\"line_ids\">\n \u003ctree editable=\"bottom\">\n \u003cfield name=\"sequence\" widget=\"handle\"/>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"quantity\"/>\n \u003cfield name=\"price_unit\"/>\n \u003cfield name=\"amount\"/>\n \u003c/tree>\n \u003c/field>\n \u003c/page>\n \u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## OWL 3.x Component (v19)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, useRef, onMounted, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class {ComponentName} extends Component {\n static template = \"{module_name}.{ComponentName}\";\n static props = {\n recordId: { type: Number, optional: true },\n mode: { type: String, optional: true },\n onSelect: { type: Function, optional: true },\n };\n\n setup() {\n // Services\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n this.user = useService(\"user\");\n\n // State\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n selectedId: null,\n });\n\n // Refs\n this.containerRef = useRef(\"container\");\n\n // Lifecycle\n onWillStart(async () => {\n await this.loadData();\n });\n\n onMounted(() => {\n console.log(\"Component mounted\");\n });\n }\n\n async loadData() {\n try {\n const data = await this.orm.searchRead(\n \"{module_name}.{model_name}\",\n [],\n [\"name\", \"state\", \"amount\"],\n { limit: 100, order: \"create_date DESC\" }\n );\n this.state.data = data;\n } catch (error) {\n this.state.error = error.message;\n this.notification.add(\"Failed to load data\", { type: \"danger\" });\n } finally {\n this.state.loading = false;\n }\n }\n\n async onItemClick(item) {\n this.state.selectedId = item.id;\n if (this.props.onSelect) {\n this.props.onSelect(item.id);\n }\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"{module_name}.{model_name}\",\n res_id: item.id,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n\n onRefresh() {\n this.state.loading = true;\n this.loadData();\n }\n}\n\nregistry.category(\"actions\").add(\"{module_name}.{component_name}\", {ComponentName});\n```\n\n## OWL 3.x Template (v19)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"{module_name}.{ComponentName}\">\n \u003cdiv t-ref=\"container\" class=\"o_{component_name} p-3\">\n \u003c!-- Loading state -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"o_loading d-flex justify-content-center align-items-center p-5\">\n \u003ci class=\"fa fa-spinner fa-spin fa-2x me-2\"/>\n \u003cspan>Loading...\u003c/span>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error state -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger\">\n \u003ci class=\"fa fa-exclamation-triangle me-2\"/>\n \u003ct t-esc=\"state.error\"/>\n \u003cbutton class=\"btn btn-link\" t-on-click=\"onRefresh\">Retry\u003c/button>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Content -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_header d-flex justify-content-between align-items-center mb-3\">\n \u003ch2>Records\u003c/h2>\n \u003cbutton class=\"btn btn-primary\" t-on-click=\"onRefresh\">\n \u003ci class=\"fa fa-refresh me-1\"/>\n Refresh\n \u003c/button>\n \u003c/div>\n\n \u003ct t-if=\"state.data.length\">\n \u003cdiv class=\"o_list\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv t-att-class=\"{'o_item p-3 mb-2 border rounded': true, 'bg-primary-subtle': item.id === state.selectedId}\"\n t-on-click=\"() => this.onItemClick(item)\"\n style=\"cursor: pointer;\">\n \u003cdiv class=\"d-flex justify-content-between\">\n \u003cstrong t-esc=\"item.name\"/>\n \u003cspan t-attf-class=\"badge bg-{{ item.state === 'done' ? 'success' : item.state === 'cancelled' ? 'danger' : 'secondary' }}\">\n \u003ct t-esc=\"item.state\"/>\n \u003c/span>\n \u003c/div>\n \u003cdiv class=\"text-muted\">\n Amount: \u003ct t-esc=\"item.amount\"/>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_nocontent_help text-center p-5\">\n \u003cp class=\"o_view_nocontent_smiling_face\">No records found\u003c/p>\n \u003cp>Create your first record to get started.\u003c/p>\n \u003c/div>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## Security (v19)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- v19: Uses allowed_company_ids -->\n \u003crecord id=\"rule_{model_name}_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">{Model Name}: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_{module_name}_{model_name}\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v19 Checklist\n\nWhen generating a v19 module:\n\n- [ ] Add `from __future__ import annotations`\n- [ ] Type hints on ALL method parameters\n- [ ] Type hints on ALL return types\n- [ ] Use `SQL()` builder for ALL raw SQL\n- [ ] Use `_check_company_auto = True`\n- [ ] Use `check_company=True` on relational fields\n- [ ] Use `@api.model_create_multi` for create\n- [ ] Use `Command` class for x2many\n- [ ] Use direct `invisible`/`readonly` in views\n- [ ] Use `allowed_company_ids` in record rules\n- [ ] Use OWL 3.x patterns\n- [ ] Python 3.12+ compatible code\n\n## AI Agent Instructions (v19)\n\nWhen generating an Odoo 19.0 module:\n\n1. **MANDATORY**: Add type hints to ALL methods\n2. **MANDATORY**: Use `SQL()` for ALL raw SQL queries\n3. **USE** `from __future__ import annotations`\n4. **USE** `_check_company_auto = True`\n5. **USE** `@api.model_create_multi` for create\n6. **USE** OWL 3.x patterns\n7. **USE** Python 3.12+ syntax (match statements, etc.)\n8. **DO NOT** use raw SQL strings\n9. **DO NOT** use OWL 2.x patterns\n10. **DO NOT** use methods without type hints\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":19489,"content_sha256":"706b34353342ba85ecac48dc412dfd7d5e1998c15b9d840fa7ac8a1ab8a57718"},{"filename":"skills/odoo-module-generator-all.md","content":"# Odoo Module Generator - Core Concepts (All Versions)\n\nThis document covers module generation concepts that are consistent across all Odoo versions. For version-specific implementation details, see the version-specific files.\n\n## Module Architecture Overview\n\nEvery Odoo module follows a standard structure:\n\n```\nmodule_name/\n├── __init__.py # Python package initialization\n├── __manifest__.py # Module metadata and dependencies\n├── models/ # Business logic (Python models)\n├── views/ # UI definitions (XML)\n├── security/ # Access control\n├── data/ # Default data\n├── demo/ # Demo data\n├── static/ # Web assets (JS, CSS, images)\n├── wizard/ # Transient models for wizards\n├── report/ # Report definitions\n├── tests/ # Unit tests\n└── i18n/ # Translations\n```\n\n## Module Naming Conventions\n\n### Technical Name (module_name)\n- Lowercase letters and underscores only\n- No spaces or special characters\n- Descriptive and unique\n- Examples: `custom_inventory`, `hr_attendance_extension`\n\n### Human-Readable Name\n- Title case\n- Spaces allowed\n- Shown in Apps menu\n- Examples: `Custom Inventory`, `HR Attendance Extension`\n\n## __manifest__.py Structure\n\nThe manifest file is required for every module:\n\n```python\n{\n 'name': 'Module Title', # Required: Display name\n 'version': 'X.Y.Z.W.V', # Required: Version number\n 'category': 'Category', # Module category\n 'summary': 'Short description', # One-line summary\n 'description': \"\"\"Long description\"\"\",\n 'author': 'Author Name',\n 'website': 'https://example.com',\n 'license': 'LGPL-3', # License type\n 'depends': ['base'], # Required dependencies\n 'data': [], # Data files to load\n 'demo': [], # Demo data files\n 'assets': {}, # Web assets (v15+)\n 'installable': True, # Can be installed\n 'application': False, # Is a full app\n 'auto_install': False, # Auto-install with dependencies\n}\n```\n\n## IMPORTANT: Data File Ordering\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ The ORDER of files in the 'data' list is CRITICAL. ║\n║ A resource can ONLY be referenced AFTER it has been defined. ║\n║ ║\n║ Incorrect order will cause installation errors! ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n### Correct Order in Manifest\n\n```python\n'data': [\n # 1. Security groups FIRST (define groups before referencing them)\n 'security/custom_module_security.xml',\n\n # 2. Access rights (reference the groups defined above)\n 'security/ir.model.access.csv',\n\n # 3. Data files (sequences, configuration)\n 'data/custom_module_data.xml',\n\n # 4. Views (may reference groups for visibility)\n 'views/model_views.xml',\n\n # 5. Menu items LAST (reference actions defined in views)\n 'views/menuitems.xml',\n],\n```\n\n### Why Order Matters\n\nWhen Odoo loads a module:\n1. Files are processed in the order listed in `data`\n2. XML IDs become available only after the file is loaded\n3. Referencing an undefined ID causes an error\n\n**Example Error (wrong order):**\n```\nValueError: External ID not found in the system: custom_module.group_manager\n```\nThis happens when a view references a group that's defined in a file listed AFTER the view file.\n\n### Order Within XML Files\n\nThe same rule applies inside XML files:\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- CORRECT: Define group BEFORE using it -->\n \u003crecord id=\"group_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003c/record>\n\n \u003c!-- This can now reference group_manager -->\n \u003crecord id=\"rule_manager\" model=\"ir.rule\">\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_manager'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n### Version Numbering\n\nFormat: `ODOO_VERSION.MAJOR.MINOR.PATCH`\n\n- `18.0.1.0.0` - Odoo 18.0, version 1.0.0\n- `17.0.2.1.3` - Odoo 17.0, version 2.1.3\n\n## Model Types\n\n### models.Model\n- Persistent data stored in database\n- Has database table\n- Used for business entities\n\n### models.TransientModel\n- Temporary data (wizards)\n- Auto-cleaned by scheduler\n- Used for user interactions\n\n### models.AbstractModel\n- No database table\n- Used for mixins\n- Provides shared functionality\n\n## Essential Mixins\n\n### mail.thread\nEnables chatter (messages, followers):\n```python\n_inherit = ['mail.thread']\n```\n\n### mail.activity.mixin\nEnables scheduled activities:\n```python\n_inherit = ['mail.activity.mixin']\n```\n\n### portal.mixin\nEnables portal access:\n```python\n_inherit = ['portal.mixin']\n```\n\n## Field Types\n\n| Type | Description | Example |\n|------|-------------|---------|\n| Char | Short text | `fields.Char()` |\n| Text | Long text | `fields.Text()` |\n| Boolean | True/False | `fields.Boolean()` |\n| Integer | Whole number | `fields.Integer()` |\n| Float | Decimal number | `fields.Float()` |\n| Monetary | Currency amount | `fields.Monetary()` |\n| Date | Date only | `fields.Date()` |\n| Datetime | Date and time | `fields.Datetime()` |\n| Selection | Dropdown | `fields.Selection()` |\n| Many2one | Single relation | `fields.Many2one()` |\n| One2many | Multiple relations | `fields.One2many()` |\n| Many2many | Multiple relations | `fields.Many2many()` |\n| Binary | File/image | `fields.Binary()` |\n| Html | Rich text | `fields.Html()` |\n\n## View Types\n\n| Type | Purpose |\n|------|---------|\n| form | Single record editing |\n| tree | List of records |\n| kanban | Card-based view |\n| search | Filters and grouping |\n| calendar | Date-based view |\n| graph | Charts and graphs |\n| pivot | Pivot tables |\n| gantt | Timeline view |\n\n## Security Layers\n\n1. **Access Rights** (ir.model.access.csv)\n - Model-level CRUD permissions\n\n2. **Record Rules** (ir.rule)\n - Row-level filtering\n\n3. **Field Groups**\n - Field-level visibility\n\n4. **Menu/Action Groups**\n - UI access control\n\n## Input Parameters Reference\n\n### Required Parameters\n\n| Parameter | Type | Description |\n|-----------|------|-------------|\n| module_name | string | Technical name (snake_case) |\n| module_description | string | Human-readable description |\n| odoo_version | string | Target Odoo version |\n\n### Optional Parameters\n\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| target_apps | list | [] | Apps to extend |\n| ui_stack | string | varies | UI technology |\n| multi_company | boolean | false | Multi-company support |\n| multi_currency | boolean | false | Multi-currency support |\n| security_level | string | basic | Security level |\n| performance_critical | boolean | false | Performance optimizations |\n| custom_models | list | [] | Custom models to create |\n| custom_fields | list | [] | Fields for existing models |\n| include_tests | boolean | true | Generate tests |\n| include_demo | boolean | false | Generate demo data |\n| author | string | \"\" | Module author |\n| website | string | \"\" | Author website |\n| license | string | LGPL-3 | Module license |\n| category | string | Uncategorized | Module category |\n\n## Custom Model Definition\n\n```json\n{\n \"name\": \"equipment.asset\",\n \"description\": \"Equipment Asset\",\n \"inherit_mail\": true,\n \"fields\": [\n {\n \"name\": \"name\",\n \"type\": \"Char\",\n \"required\": true,\n \"tracking\": true\n },\n {\n \"name\": \"serial_number\",\n \"type\": \"Char\",\n \"index\": true\n },\n {\n \"name\": \"status\",\n \"type\": \"Selection\",\n \"selection\": [\n [\"active\", \"Active\"],\n [\"maintenance\", \"Maintenance\"],\n [\"retired\", \"Retired\"]\n ],\n \"default\": \"active\"\n },\n {\n \"name\": \"purchase_date\",\n \"type\": \"Date\"\n },\n {\n \"name\": \"value\",\n \"type\": \"Monetary\"\n }\n ]\n}\n```\n\n## Best Practices (All Versions)\n\n### Code Organization\n- One model per file\n- Organize fields by type (basic, relational, computed)\n- Use section comments for clarity\n\n### Naming Conventions\n- Models: `module_name.model_name` (dots)\n- Tables: `module_name_model_name` (underscores)\n- Fields: `snake_case`\n- Classes: `PascalCase`\n- XML IDs: `module_name_description`\n\n### Security First\n- Always define access rights\n- Use record rules for multi-tenant\n- Never expose sensitive data\n\n### Performance\n- Index frequently searched fields\n- Store computed fields when appropriate\n- Use SQL for complex reports\n\n### Internationalization\n- Mark strings with `_()` for translation\n- Use translatable field attributes\n- Generate .pot files\n\n## Common Categories\n\n- Accounting/Finance\n- Human Resources\n- Sales\n- Purchase\n- Inventory/MRP\n- Project\n- Website\n- Marketing\n- Productivity\n- Technical\n\n## License Types\n\n| License | Commercial Use | Source Required |\n|---------|---------------|-----------------|\n| LGPL-3 | Yes | Modifications only |\n| AGPL-3 | Yes | All source |\n| OPL-1 | Paid | No |\n\n---\n\n**Note**: This document covers concepts that apply to all versions. For version-specific syntax and patterns, refer to the appropriate version-specific file.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9711,"content_sha256":"a43d711799b03a2d2655a1edbd6ec5ba222909a95df392320728f981c682aa00"},{"filename":"skills/odoo-module-generator.md","content":"# Odoo Module Generator - Version Dispatcher\n\n## CRITICAL: VERSION-SPECIFIC REQUIREMENTS\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ║\n║ ⚠️ MANDATORY VERSION MATCHING ⚠️ ║\n║ ║\n║ You MUST use the version-specific module generator that matches your ║\n║ target Odoo version. Using patterns from the wrong version WILL ║\n║ cause errors, deprecated code, or security vulnerabilities. ║\n║ ║\n║ BEFORE generating ANY module code, identify your target Odoo version ║\n║ and load the corresponding file. This is NOT optional. ║\n║ ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Files\n\n| Target Version | File to Use | Status |\n|----------------|-------------|--------|\n| Odoo 14.0 | `odoo-module-generator-14.md` | Legacy |\n| Odoo 15.0 | `odoo-module-generator-15.md` | Legacy |\n| Odoo 16.0 | `odoo-module-generator-16.md` | Supported |\n| Odoo 17.0 | `odoo-module-generator-17.md` | Supported |\n| Odoo 18.0 | `odoo-module-generator-18.md` | Current |\n| Odoo 19.0 | `odoo-module-generator-19.md` | Development |\n| All versions | `odoo-module-generator-all.md` | Core concepts |\n\n## Migration Guides\n\nWhen upgrading modules between versions:\n\n| Migration Path | File |\n|----------------|------|\n| 14.0 → 15.0 | `odoo-module-generator-14-15.md` |\n| 15.0 → 16.0 | `odoo-module-generator-15-16.md` |\n| 16.0 → 17.0 | `odoo-module-generator-16-17.md` |\n| 17.0 → 18.0 | `odoo-module-generator-17-18.md` |\n| 18.0 → 19.0 | `odoo-module-generator-18-19.md` |\n\n## How to Use This Skill\n\n### Step 1: Identify Target Version\n\n```\nQUESTION: What Odoo version are you generating a module for?\n\nIf the user doesn't specify, ASK before proceeding:\n\"What Odoo version should I target? (14.0, 15.0, 16.0, 17.0, 18.0, 19.0)\"\n```\n\n### Step 2: Load the Correct File\n\n**For AI Agents**: You MUST read the version-specific file before generating any module code:\n\n```\n# Example for Odoo 18.0 project\nRead: skills/odoo-module-generator-18.md\n\n# Example for upgrading from 17.0 to 18.0\nRead: skills/odoo-module-generator-17-18.md\n```\n\n### Step 3: Gather Input Parameters\n\nRequired parameters for module generation:\n\n| Parameter | Type | Required | Example |\n|-----------|------|----------|---------|\n| `module_name` | string | Yes | `custom_inventory` |\n| `module_description` | string | Yes | `Custom inventory tracking` |\n| `odoo_version` | string | Yes | `18.0` |\n| `target_apps` | list | No | `['stock', 'sale']` |\n| `ui_stack` | string | No | `owl`, `classic`, `hybrid` |\n| `multi_company` | boolean | No | `true` |\n| `multi_currency` | boolean | No | `false` |\n| `security_level` | string | No | `basic`, `advanced`, `audit` |\n| `performance_critical` | boolean | No | `false` |\n| `custom_models` | list | No | List of model definitions |\n| `custom_fields` | list | No | Fields to add to existing models |\n\n### Step 4: Apply Version-Specific Patterns\n\nOnly use patterns from the loaded version-specific file. Never mix patterns from different versions.\n\n## Version Detection Hints\n\nIf the version is not explicitly stated, look for these clues in existing code:\n\n| Indicator | Version |\n|-----------|---------|\n| `@api.multi` decorator | 14.0 (removed in 15.0+) |\n| `track_visibility` parameter | 14.0 |\n| `tracking` parameter | 15.0+ |\n| Tuple syntax for x2many | 14.0-15.0 |\n| `Command` class usage | 16.0+ |\n| `attrs` in views | 14.0-16.0 |\n| Direct `invisible`/`readonly` | 17.0+ |\n| `_check_company_auto` | 18.0+ |\n| Type hints on fields | 18.0+ |\n| `SQL()` builder | 18.0+ |\n| Full type annotations | 19.0+ |\n\n## Quick Reference: Major Changes by Version\n\n### v14 Key Patterns\n- Single record `create(vals)`\n- `track_visibility='onchange'`\n- `attrs` in views\n- Legacy widgets\n\n### v15 Key Patterns\n- `@api.multi` removed\n- `tracking=True` replaces `track_visibility`\n- OWL 1.x introduced\n\n### v16 Key Patterns\n- `Command` class for x2many\n- `attrs` deprecated (still works)\n- OWL 2.x\n- `@api.model_create_multi` recommended\n\n### v17 Key Patterns\n- `attrs` removed from views\n- Direct `invisible`/`readonly` attributes\n- `@api.model_create_multi` mandatory\n- Python expressions in visibility\n\n### v18 Key Patterns\n- `_check_company_auto = True`\n- `check_company=True` on fields\n- Type hints recommended\n- `SQL()` builder recommended\n- `allowed_company_ids` in rules\n\n### v19 Key Patterns\n- Type hints mandatory\n- `SQL()` builder mandatory\n- OWL 3.x\n- Python 3.12+\n\n## Structured Output Format\n\nAI agents should produce structured output for programmatic consumption:\n\n```json\n{\n \"module_skeleton\": {\n \"name\": \"module_name\",\n \"version\": \"18.0.1.0.0\",\n \"odoo_version\": \"18.0\",\n \"files\": {\n \"__manifest__.py\": \"...\",\n \"__init__.py\": \"...\",\n \"models/__init__.py\": \"...\",\n \"models/model_name.py\": \"...\",\n \"views/model_name_views.xml\": \"...\",\n \"views/menuitems.xml\": \"...\",\n \"security/ir.model.access.csv\": \"...\",\n \"security/module_name_security.xml\": \"...\"\n }\n },\n \"version_instructions\": [\n \"Instruction 1 specific to version\",\n \"Instruction 2 specific to version\"\n ],\n \"warnings\": [\n \"Warning about potential issues\"\n ],\n \"dependencies\": [\"base\", \"mail\"],\n \"github_verified\": true,\n \"verification_date\": \"2025-01-16\"\n}\n```\n\n## GitHub Repository Verification\n\nBefore generating modules, agents SHOULD verify patterns against:\n\n**Official Odoo Repository**: https://github.com/odoo/odoo\n\n| Version | Branch |\n|---------|--------|\n| 14.0 | `14.0` |\n| 15.0 | `15.0` |\n| 16.0 | `16.0` |\n| 17.0 | `17.0` |\n| 18.0 | `18.0` |\n| 19.0 | `master` |\n\n## Example Module Generation Requests\n\n### Example 1: Basic Inventory Module (v18)\n\n**User Request**:\n```\nCreate an Odoo 18.0 module for tracking equipment assets with:\n- Equipment model with name, serial number, purchase date, status\n- Assignment to employees\n- Maintenance scheduling\n- Multi-company support\n```\n\n**Agent Workflow**:\n1. Identify version: `18.0`\n2. Load: `skills/odoo-module-generator-18.md`\n3. Load: `skills/odoo-model-patterns-18.md`\n4. Generate with v18 patterns:\n - `_check_company_auto = True`\n - `@api.model_create_multi`\n - `check_company=True` on employee relation\n - Direct `invisible`/`readonly` in views\n\n### Example 2: Sales Extension (v17)\n\n**User Request**:\n```\nAdd custom discount approval workflow to Odoo 17.0 sales module\n```\n\n**Agent Workflow**:\n1. Identify version: `17.0`\n2. Load: `skills/odoo-module-generator-17.md`\n3. Load: `skills/odoo-model-patterns-17.md`\n4. Generate with v17 patterns:\n - Extend `sale.order`\n - `@api.model_create_multi`\n - Python expressions for visibility (`invisible=\"discount > 20\"`)\n - NO `attrs` usage\n\n### Example 3: Dashboard Component (v18)\n\n**User Request**:\n```\nCreate a KPI dashboard for Odoo 18.0 with charts and real-time data\n```\n\n**Agent Workflow**:\n1. Identify version: `18.0`\n2. Load: `skills/odoo-owl-components-18.md`\n3. Load: `skills/odoo-module-generator-18.md`\n4. Generate with v18 OWL 2.x patterns:\n - `/** @odoo-module **/`\n - `import { Component } from \"@odoo/owl\"`\n - `useService(\"orm\")` for data\n - Register as client action\n\n### Example 4: Migration Project (v16 → v17)\n\n**User Request**:\n```\nUpgrade our custom CRM module from Odoo 16.0 to 17.0\n```\n\n**Agent Workflow**:\n1. Load migration guide: `skills/odoo-module-generator-16-17.md`\n2. Key changes to apply:\n - Remove ALL `attrs` from views\n - Add `@api.model_create_multi` to all `create()` methods\n - Convert domain syntax to Python expressions\n - Update manifest version to `17.0.x.x.x`\n\n### Example 5: Multi-Company HR Module (v18)\n\n**User Request**:\n```json\n{\n \"module_name\": \"hr_custom_leave\",\n \"module_description\": \"Custom leave management with approval workflow\",\n \"odoo_version\": \"18.0\",\n \"target_apps\": [\"hr\", \"hr_holidays\"],\n \"multi_company\": true,\n \"security_level\": \"advanced\",\n \"custom_models\": [\n {\n \"name\": \"hr.leave.type.custom\",\n \"description\": \"Custom Leave Type\",\n \"fields\": [\n {\"name\": \"name\", \"type\": \"Char\", \"required\": true},\n {\"name\": \"requires_approval\", \"type\": \"Boolean\"},\n {\"name\": \"max_days\", \"type\": \"Integer\"}\n ]\n }\n ]\n}\n```\n\n**Agent Workflow**:\n1. Parse structured input\n2. Load v18 patterns\n3. Generate complete module skeleton with:\n - Multi-company record rules using `allowed_company_ids`\n - `_check_company_auto = True` on models\n - Advanced security groups\n - Type hints on methods\n\n## Structured Input Schema\n\nFor programmatic module generation, use this JSON schema:\n\n```json\n{\n \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n \"type\": \"object\",\n \"required\": [\"module_name\", \"module_description\", \"odoo_version\"],\n \"properties\": {\n \"module_name\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z][a-z0-9_]*$\",\n \"description\": \"Technical module name (snake_case)\"\n },\n \"module_description\": {\n \"type\": \"string\",\n \"description\": \"Human-readable description\"\n },\n \"odoo_version\": {\n \"type\": \"string\",\n \"enum\": [\"14.0\", \"15.0\", \"16.0\", \"17.0\", \"18.0\", \"19.0\"]\n },\n \"target_apps\": {\n \"type\": \"array\",\n \"items\": {\"type\": \"string\"},\n \"description\": \"Odoo apps to extend\"\n },\n \"multi_company\": {\n \"type\": \"boolean\",\n \"default\": false\n },\n \"multi_currency\": {\n \"type\": \"boolean\",\n \"default\": false\n },\n \"security_level\": {\n \"type\": \"string\",\n \"enum\": [\"basic\", \"advanced\", \"audit\"],\n \"default\": \"basic\"\n },\n \"custom_models\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"inherit_mail\": {\"type\": \"boolean\"},\n \"fields\": {\"type\": \"array\"}\n }\n }\n },\n \"custom_fields\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"model\": {\"type\": \"string\"},\n \"name\": {\"type\": \"string\"},\n \"type\": {\"type\": \"string\"}\n }\n }\n },\n \"include_tests\": {\n \"type\": \"boolean\",\n \"default\": true\n },\n \"include_demo\": {\n \"type\": \"boolean\",\n \"default\": false\n }\n }\n}\n```\n\n---\n\n**REMINDER**: Do not use this dispatcher file for actual module generation. Always load and follow the version-specific file for your target Odoo version.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11175,"content_sha256":"506034ecc10eec389827a15ea633648f7d83a1576d8b45c723fbabb18374e4ac"},{"filename":"skills/odoo-owl-components-15-16.md","content":"# Odoo OWL Migration Guide: 15.0 → 16.0 (OWL 1.x → 2.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ OWL MIGRATION GUIDE: 1.x → 2.x ║\n║ This is a MAJOR breaking change migration. ║\n║ All OWL components must be rewritten. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Feature | OWL 1.x (v15) | OWL 2.x (v16) |\n|---------|---------------|---------------|\n| Module system | `odoo.define()` | ES modules `/** @odoo-module **/` |\n| Imports | `require()` | `import` statements |\n| Hooks location | `owl.hooks` | Direct from `@odoo/owl` |\n| Template declaration | Property on class | `static template` |\n| RPC | `require('web.rpc')` | `useService(\"orm\")` |\n| Action registration | `core.action_registry` | `registry.category(\"actions\")` |\n\n## Complete Migration Example\n\n### Before (OWL 1.x - v15)\n\n```javascript\nodoo.define('my_module.MyComponent', function (require) {\n \"use strict\";\n\n const { Component, useState } = owl;\n const { onWillStart, onMounted } = owl.hooks;\n const AbstractAction = require('web.AbstractAction');\n const core = require('web.core');\n const rpc = require('web.rpc');\n\n class MyComponent extends Component {\n setup() {\n this.state = useState({\n records: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadRecords();\n });\n\n onMounted(() => {\n console.log(\"Component mounted\");\n });\n }\n\n async loadRecords() {\n const records = await rpc.query({\n model: 'my.model',\n method: 'search_read',\n args: [[], ['name', 'state']],\n });\n this.state.records = records;\n this.state.loading = false;\n }\n\n async onRecordClick(id) {\n await this.do_action({\n type: 'ir.actions.act_window',\n res_model: 'my.model',\n res_id: id,\n views: [[false, 'form']],\n target: 'current',\n });\n }\n }\n\n MyComponent.template = 'my_module.MyComponent';\n\n core.action_registry.add('my_module.my_action', MyComponent);\n\n return MyComponent;\n});\n```\n\n### After (OWL 2.x - v16)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n\n setup() {\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n\n this.state = useState({\n records: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadRecords();\n });\n\n onMounted(() => {\n console.log(\"Component mounted\");\n });\n }\n\n async loadRecords() {\n const records = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\"]\n );\n this.state.records = records;\n this.state.loading = false;\n }\n\n async onRecordClick(id) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"my.model\",\n res_id: id,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_action\", MyComponent);\n```\n\n## Template Migration\n\n### Before (OWL 1.x)\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\" owl=\"1\">\n \u003cdiv class=\"o_my_component\">\n \u003ct t-if=\"state.loading\">Loading...\u003c/t>\n \u003ct t-else=\"\">\n \u003ct t-foreach=\"state.records\" t-as=\"record\" t-key=\"record.id\">\n \u003cdiv t-on-click=\"() => this.onRecordClick(record.id)\">\n \u003ct t-esc=\"record.name\"/>\n \u003c/div>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n### After (OWL 2.x)\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv class=\"o_my_component\">\n \u003ct t-if=\"state.loading\">Loading...\u003c/t>\n \u003ct t-else=\"\">\n \u003ct t-foreach=\"state.records\" t-as=\"record\" t-key=\"record.id\">\n \u003cdiv t-on-click=\"() => this.onRecordClick(record.id)\">\n \u003ct t-esc=\"record.name\"/>\n \u003c/div>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n**Note**: Remove `owl=\"1\"` attribute from template root.\n\n## Import Mapping\n\n| OWL 1.x | OWL 2.x |\n|---------|---------|\n| `const { Component } = owl;` | `import { Component } from \"@odoo/owl\";` |\n| `const { useState } = owl;` | `import { useState } from \"@odoo/owl\";` |\n| `const { onWillStart } = owl.hooks;` | `import { onWillStart } from \"@odoo/owl\";` |\n| `const rpc = require('web.rpc');` | `this.orm = useService(\"orm\");` |\n| `const core = require('web.core');` | `import { registry } from \"@web/core/registry\";` |\n\n## RPC Method Migration\n\n| OWL 1.x RPC | OWL 2.x ORM Service |\n|-------------|---------------------|\n| `rpc.query({ model, method: 'search_read', args })` | `this.orm.searchRead(model, domain, fields)` |\n| `rpc.query({ model, method: 'read', args })` | `this.orm.read(model, ids, fields)` |\n| `rpc.query({ model, method: 'create', args })` | `this.orm.create(model, vals)` |\n| `rpc.query({ model, method: 'write', args })` | `this.orm.write(model, ids, vals)` |\n| `rpc.query({ model, method: 'unlink', args })` | `this.orm.unlink(model, ids)` |\n| `rpc.query({ model, method, args, kwargs })` | `this.orm.call(model, method, args, kwargs)` |\n\n## Registration Migration\n\n| OWL 1.x | OWL 2.x |\n|---------|---------|\n| `core.action_registry.add(key, Component)` | `registry.category(\"actions\").add(key, Component)` |\n| `fieldRegistry.add(key, Component)` | `registry.category(\"fields\").add(key, {...})` |\n| `widgetRegistry.add(key, Component)` | Depends on widget type |\n\n## Migration Checklist\n\n- [ ] Replace `odoo.define()` with `/** @odoo-module **/`\n- [ ] Convert `require()` to `import` statements\n- [ ] Import hooks directly from `@odoo/owl` (not `owl.hooks`)\n- [ ] Add `static template` to class\n- [ ] Replace `rpc.query()` with `useService(\"orm\")` methods\n- [ ] Replace `core.action_registry` with `registry.category(\"actions\")`\n- [ ] Remove `owl=\"1\"` from templates\n- [ ] Update manifest assets to use glob patterns\n- [ ] Test all component functionality\n\n## Common Migration Errors\n\n### Error: odoo.define is not defined\n**Cause**: Using old module syntax\n**Fix**: Replace with ES module syntax\n\n### Error: owl is not defined\n**Cause**: Importing from global `owl`\n**Fix**: Import from `@odoo/owl`\n\n### Error: rpc is not defined\n**Cause**: Using `require('web.rpc')`\n**Fix**: Use `useService(\"orm\")` instead\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7570,"content_sha256":"1c95d2a65dfa12fa9eb1d2dc872f9860109b8a8881dfe11a96820e133fbb6f81"},{"filename":"skills/odoo-owl-components-15.md","content":"# Odoo OWL Components - Version 15.0 (OWL 1.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 15.0 OWL 1.x COMPONENT PATTERNS ║\n║ This file contains ONLY Odoo 15.0 OWL patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 15.0 OWL Requirements\n\n- **OWL Version**: 1.x\n- **Module System**: `odoo.define()` with require\n- **Syntax**: Different from OWL 2.x\n\n## Basic Component Structure (OWL 1.x)\n\n```javascript\nodoo.define('{module_name}.{ComponentName}', function (require) {\n \"use strict\";\n\n const { Component, useState, useRef } = owl;\n const { onWillStart, onMounted, onWillUnmount } = owl.hooks;\n const AbstractAction = require('web.AbstractAction');\n const core = require('web.core');\n const rpc = require('web.rpc');\n\n class {ComponentName} extends Component {\n setup() {\n // State\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n });\n\n // Refs\n this.inputRef = useRef(\"input\");\n\n // Lifecycle\n onWillStart(async () => {\n await this.loadData();\n });\n\n onMounted(() => {\n if (this.inputRef.el) {\n this.inputRef.el.focus();\n }\n });\n }\n\n async loadData() {\n try {\n const data = await rpc.query({\n model: '{module_name}.{model_name}',\n method: 'search_read',\n args: [[], ['name', 'state', 'amount']],\n });\n this.state.data = data;\n } catch (error) {\n this.state.error = error.message;\n } finally {\n this.state.loading = false;\n }\n }\n\n onButtonClick() {\n console.log(\"Button clicked\");\n }\n\n async onRecordSelect(recordId) {\n await this.do_action({\n type: 'ir.actions.act_window',\n res_model: '{module_name}.{model_name}',\n res_id: recordId,\n views: [[false, 'form']],\n target: 'current',\n });\n }\n }\n\n {ComponentName}.template = '{module_name}.{ComponentName}';\n\n // Register as action\n core.action_registry.add('{module_name}.{component_name}', {ComponentName});\n\n return {ComponentName};\n});\n```\n\n## Template Structure (OWL 1.x)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"{module_name}.{ComponentName}\" owl=\"1\">\n \u003cdiv class=\"o_{component_name}\">\n \u003c!-- Loading state -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"o_loading text-center p-4\">\n \u003ci class=\"fa fa-spinner fa-spin fa-2x\"/>\n \u003cp>Loading...\u003c/p>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error state -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger\">\n \u003ct t-esc=\"state.error\"/>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Content -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_content\">\n \u003cdiv class=\"o_header d-flex justify-content-between mb-3\">\n \u003ch2>My Component\u003c/h2>\n \u003cbutton class=\"btn btn-primary\" t-on-click=\"onButtonClick\">\n Action\n \u003c/button>\n \u003c/div>\n\n \u003ct t-if=\"state.data.length\">\n \u003ctable class=\"table table-hover\">\n \u003cthead>\n \u003ctr>\n \u003cth>Name\u003c/th>\n \u003cth>State\u003c/th>\n \u003cth>Amount\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003ctr t-on-click=\"() => this.onRecordSelect(item.id)\"\n class=\"cursor-pointer\">\n \u003ctd t-esc=\"item.name\"/>\n \u003ctd t-esc=\"item.state\"/>\n \u003ctd t-esc=\"item.amount\"/>\n \u003c/tr>\n \u003c/t>\n \u003c/tbody>\n \u003c/table>\n \u003c/t>\n\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_nocontent_help text-center p-4\">\n \u003cp>No records found\u003c/p>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n\n \u003cinput t-ref=\"input\" type=\"text\" class=\"form-control mt-3\"/>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## OWL 1.x Hooks\n\n```javascript\nconst { Component, useState, useRef } = owl;\nconst {\n onWillStart, // Before first render (async)\n onMounted, // After mounted to DOM\n onWillUpdateProps, // Before props update\n onWillUnmount, // Before unmount\n onWillPatch, // Before DOM patch\n onPatched, // After DOM patch\n} = owl.hooks;\n\nsetup() {\n onWillStart(async () => {\n // Async initialization\n await this.loadData();\n });\n\n onMounted(() => {\n // DOM is available\n console.log(\"Component mounted\");\n });\n\n onWillUnmount(() => {\n // Cleanup\n console.log(\"Component will unmount\");\n });\n}\n```\n\n## RPC Service (OWL 1.x)\n\n```javascript\nconst rpc = require('web.rpc');\n\n// Search and read\nconst records = await rpc.query({\n model: 'res.partner',\n method: 'search_read',\n args: [[['is_company', '=', true]], ['name', 'email']],\n});\n\n// Call custom method\nconst result = await rpc.query({\n model: 'res.partner',\n method: 'custom_method',\n args: [[1, 2, 3]],\n kwargs: { arg: 'value' },\n});\n\n// Create record\nconst id = await rpc.query({\n model: 'res.partner',\n method: 'create',\n args: [{ name: 'New Partner' }],\n});\n```\n\n## Field Widget (OWL 1.x)\n\n```javascript\nodoo.define('{module_name}.{WidgetName}', function (require) {\n \"use strict\";\n\n const { Component } = owl;\n const fieldRegistry = require('web.field_registry');\n const AbstractField = require('web.AbstractField');\n const fieldUtils = require('web.field_utils');\n\n class {WidgetName} extends Component {\n get value() {\n return this.props.value || '';\n }\n\n onChange(ev) {\n this.trigger('update-value', ev.target.value);\n }\n }\n\n {WidgetName}.template = '{module_name}.{WidgetName}';\n\n // Register widget\n fieldRegistry.add('{widget_name}', {WidgetName});\n\n return {WidgetName};\n});\n```\n\n## Manifest Assets (v15)\n\n```python\n'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/js/*.js',\n '{module_name}/static/src/xml/*.xml',\n '{module_name}/static/src/scss/*.scss',\n ],\n},\n```\n\n## v15 OWL 1.x Checklist\n\n- [ ] Use `odoo.define()` module syntax\n- [ ] Import from `owl` and `owl.hooks`\n- [ ] Use `require('web.rpc')` for RPC calls\n- [ ] Use `core.action_registry.add()` for actions\n- [ ] Add `owl=\"1\"` to template root\n- [ ] Include in manifest assets\n\n## AI Agent Instructions (v15 OWL)\n\nWhen generating Odoo 15.0 OWL components:\n\n1. **USE** `odoo.define()` module pattern\n2. **USE** `owl.hooks` for lifecycle\n3. **USE** `require('web.rpc')` for RPC\n4. **USE** `core.action_registry.add()` for registration\n5. **ADD** `owl=\"1\"` to template\n6. **DO NOT** use ES module imports\n7. **DO NOT** use OWL 2.x patterns\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8308,"content_sha256":"8050863d8510940ceb398d91a76fc67b6bebef595c6832663b1f2b061c559d85"},{"filename":"skills/odoo-owl-components-16-17.md","content":"# Odoo OWL Migration Guide: 16.0 → 17.0 (OWL 2.x Continued)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ OWL MIGRATION GUIDE: 16.0 → 17.0 ║\n║ OWL 2.x continues with enhancements and best practice refinements ║\n║ VERIFY: https://github.com/odoo/odoo/tree/17.0/addons/web/static/src ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview\n\nOdoo 17.0 continues using OWL 2.x with refinements. The main changes are in best practices, service usage, and component patterns rather than framework version changes.\n\n## Changes Summary\n\n| Feature | v16 (OWL 2.x) | v17 (OWL 2.x enhanced) |\n|---------|---------------|------------------------|\n| OWL Version | 2.x | 2.x (same) |\n| Props validation | Recommended | Strongly recommended |\n| Services | Standard | Enhanced patterns |\n| Error handling | Basic | Improved patterns |\n| TypeScript-like JSDoc | Optional | Recommended |\n\n## No Breaking Changes\n\nOWL components written for v16 will work in v17 without modification. The changes are additive best practices.\n\n## Enhanced Patterns for v17\n\n### Props Validation (Strongly Recommended)\n\n```javascript\n// v16: Props optional but recommended\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n };\n}\n\n// v17: Enhanced props with validation\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n mode: {\n type: String,\n optional: true,\n validate: (value) => [\"view\", \"edit\", \"create\"].includes(value),\n },\n onConfirm: { type: Function, optional: true },\n config: { type: Object, optional: true },\n };\n\n static defaultProps = {\n mode: \"view\",\n config: {},\n };\n}\n```\n\n### JSDoc Type Annotations (Recommended)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\n/**\n * @typedef {Object} MyComponentProps\n * @property {number} [recordId] - Optional record ID\n * @property {'view' | 'edit' | 'create'} [mode] - Component mode\n * @property {Function} [onConfirm] - Callback on confirm\n */\n\n/**\n * @typedef {Object} MyComponentState\n * @property {Array\u003cObject>} data - Loaded records\n * @property {boolean} loading - Loading state\n * @property {string|null} error - Error message\n */\n\nexport class MyComponent extends Component {\n /** @type {string} */\n static template = \"my_module.MyComponent\";\n\n /** @type {MyComponentProps} */\n static props = {\n recordId: { type: Number, optional: true },\n mode: { type: String, optional: true },\n onConfirm: { type: Function, optional: true },\n };\n\n static defaultProps = {\n mode: \"view\",\n };\n\n setup() {\n /** @type {import(\"@web/core/orm_service\").ORM} */\n this.orm = useService(\"orm\");\n\n /** @type {MyComponentState} */\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n });\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n /**\n * Load data from server\n * @returns {Promise\u003cvoid>}\n */\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\"],\n { order: \"create_date DESC\" }\n );\n this.state.error = null;\n } catch (error) {\n this.state.error = error.message;\n } finally {\n this.state.loading = false;\n }\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_action\", MyComponent);\n```\n\n### Enhanced Service Usage\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart, onWillUnmount } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n\n setup() {\n // Core services\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n this.user = useService(\"user\");\n this.company = useService(\"company\"); // v17: Enhanced company service\n\n // State management\n this.state = useState({\n data: [],\n loading: true,\n selectedIds: new Set(),\n });\n\n // Cleanup tracking\n this._cleanup = [];\n\n onWillStart(async () => {\n await this.loadData();\n });\n\n onWillUnmount(() => {\n this._cleanup.forEach(fn => fn());\n });\n }\n\n async loadData() {\n try {\n // v17: Enhanced ORM options\n this.state.data = await this.orm.searchRead(\n \"my.model\",\n [[\"company_id\", \"=\", this.company.currentCompany.id]],\n [\"name\", \"state\", \"partner_id\"],\n {\n limit: 100,\n offset: 0,\n order: \"create_date DESC\",\n context: { ...this.user.context },\n }\n );\n } catch (error) {\n this.notification.add(error.message || \"Failed to load data\", {\n type: \"danger\",\n sticky: false,\n });\n } finally {\n this.state.loading = false;\n }\n }\n\n async onConfirm(recordId) {\n try {\n await this.orm.call(\"my.model\", \"action_confirm\", [[recordId]]);\n this.notification.add(\"Record confirmed successfully\", {\n type: \"success\",\n });\n await this.loadData();\n } catch (error) {\n this.notification.add(error.message, { type: \"danger\" });\n }\n }\n\n async openRecord(recordId) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"my.model\",\n res_id: recordId,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n}\n```\n\n### Error Handling Patterns\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n\n setup() {\n this.orm = useService(\"orm\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n });\n\n onWillStart(async () => {\n await this.loadDataWithRetry();\n });\n }\n\n /**\n * Load data with retry logic\n * @param {number} retries - Number of retries\n */\n async loadDataWithRetry(retries = 3) {\n for (let i = 0; i \u003c retries; i++) {\n try {\n this.state.data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\"]\n );\n this.state.error = null;\n return;\n } catch (error) {\n if (i === retries - 1) {\n this.state.error = `Failed after ${retries} attempts: ${error.message}`;\n this.notification.add(this.state.error, { type: \"danger\" });\n } else {\n // Wait before retry\n await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));\n }\n }\n }\n this.state.loading = false;\n }\n\n /**\n * Confirm action with dialog\n * @param {number} recordId\n */\n async confirmWithDialog(recordId) {\n const confirmed = await new Promise((resolve) => {\n this.dialog.add(ConfirmationDialog, {\n title: \"Confirm Action\",\n body: \"Are you sure you want to confirm this record?\",\n confirm: () => resolve(true),\n cancel: () => resolve(false),\n });\n });\n\n if (confirmed) {\n await this.doConfirm(recordId);\n }\n }\n}\n```\n\n## Component Lifecycle Best Practices\n\n```javascript\n/** @odoo-module **/\n\nimport {\n Component,\n useState,\n onWillStart,\n onMounted,\n onWillUpdateProps,\n onWillUnmount,\n} from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n };\n\n setup() {\n this.orm = useService(\"orm\");\n this.state = useState({ data: null, loading: true });\n\n // Cleanup functions\n this._eventListeners = [];\n\n // Before first render\n onWillStart(async () => {\n if (this.props.recordId) {\n await this.loadRecord(this.props.recordId);\n }\n });\n\n // After DOM mounted\n onMounted(() => {\n this.setupEventListeners();\n });\n\n // When props change\n onWillUpdateProps(async (nextProps) => {\n if (nextProps.recordId !== this.props.recordId) {\n this.state.loading = true;\n await this.loadRecord(nextProps.recordId);\n }\n });\n\n // Cleanup before unmount\n onWillUnmount(() => {\n this.cleanupEventListeners();\n });\n }\n\n setupEventListeners() {\n const handleKeydown = (e) => {\n if (e.key === \"Escape\") {\n this.onCancel();\n }\n };\n document.addEventListener(\"keydown\", handleKeydown);\n this._eventListeners.push(() => {\n document.removeEventListener(\"keydown\", handleKeydown);\n });\n }\n\n cleanupEventListeners() {\n this._eventListeners.forEach(cleanup => cleanup());\n this._eventListeners = [];\n }\n\n async loadRecord(recordId) {\n try {\n const [record] = await this.orm.read(\"my.model\", [recordId]);\n this.state.data = record;\n } finally {\n this.state.loading = false;\n }\n }\n}\n```\n\n## Template Patterns for v17\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv class=\"my-component h-100 d-flex flex-column\">\n \u003c!-- Loading State -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"d-flex justify-content-center align-items-center h-100\">\n \u003cdiv class=\"spinner-border text-primary\" role=\"status\">\n \u003cspan class=\"visually-hidden\">Loading...\u003c/span>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error State -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger m-3\" role=\"alert\">\n \u003ci class=\"fa fa-exclamation-triangle me-2\"/>\n \u003cspan t-esc=\"state.error\"/>\n \u003cbutton class=\"btn btn-sm btn-outline-danger ms-3\"\n t-on-click=\"() => this.loadDataWithRetry()\">\n Retry\n \u003c/button>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Content -->\n \u003ct t-else=\"\">\n \u003c!-- Toolbar -->\n \u003cdiv class=\"d-flex p-2 border-bottom bg-light\">\n \u003cbutton class=\"btn btn-primary btn-sm me-2\"\n t-on-click=\"onCreateNew\">\n \u003ci class=\"fa fa-plus me-1\"/>\n New\n \u003c/button>\n \u003cbutton class=\"btn btn-secondary btn-sm\"\n t-att-disabled=\"state.selectedIds.size === 0\"\n t-on-click=\"onBulkAction\">\n Bulk Action\n \u003cspan t-if=\"state.selectedIds.size > 0\"\n class=\"badge bg-primary ms-1\"\n t-esc=\"state.selectedIds.size\"/>\n \u003c/button>\n \u003c/div>\n\n \u003c!-- List -->\n \u003cdiv class=\"flex-grow-1 overflow-auto\">\n \u003ct t-if=\"state.data.length === 0\">\n \u003cdiv class=\"text-center text-muted p-5\">\n \u003ci class=\"fa fa-inbox fa-3x mb-3 d-block\"/>\n No records found\n \u003c/div>\n \u003c/t>\n \u003ct t-else=\"\">\n \u003cdiv class=\"list-group list-group-flush\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv class=\"list-group-item list-group-item-action d-flex align-items-center\"\n t-att-class=\"{ 'active': state.selectedIds.has(item.id) }\"\n t-on-click=\"() => this.onItemClick(item)\">\n \u003cinput type=\"checkbox\"\n class=\"form-check-input me-3\"\n t-att-checked=\"state.selectedIds.has(item.id)\"\n t-on-click.stop=\"() => this.toggleSelection(item.id)\"/>\n \u003cdiv class=\"flex-grow-1\">\n \u003cdiv class=\"fw-bold\" t-esc=\"item.name\"/>\n \u003csmall class=\"text-muted\" t-esc=\"item.state\"/>\n \u003c/div>\n \u003cspan t-att-class=\"'badge ' + this.getStateBadgeClass(item.state)\"\n t-esc=\"item.state\"/>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## Migration Checklist (v16 → v17)\n\nSince there are no breaking changes, focus on improvements:\n\n- [ ] Add comprehensive JSDoc type annotations\n- [ ] Add props validation with validate functions\n- [ ] Add static defaultProps where applicable\n- [ ] Implement proper cleanup in onWillUnmount\n- [ ] Use enhanced service patterns\n- [ ] Add error handling with retry logic\n- [ ] Update templates with Bootstrap 5 classes\n\n## Best Practices Summary\n\n1. **Always validate props** - Use static props with types and validators\n2. **Document with JSDoc** - Add type annotations for better IDE support\n3. **Handle errors gracefully** - Show user-friendly messages\n4. **Clean up resources** - Remove event listeners in onWillUnmount\n5. **Use services correctly** - Leverage company, user, and other services\n6. **Follow lifecycle hooks** - Use appropriate hooks for each task\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15611,"content_sha256":"77f522b41bc9d20974c6022a49c063fccaf5e05b7a33c93de1b7f4f636cd01d1"},{"filename":"skills/odoo-owl-components-16.md","content":"# Odoo OWL Components - Version 16.0 (OWL 2.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 16.0 OWL 2.x COMPONENT PATTERNS ║\n║ This file contains ONLY Odoo 16.0 OWL patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 16.0 OWL Requirements\n\n- **OWL Version**: 2.x (initial)\n- **Module System**: ES modules with `/** @odoo-module **/`\n- **Breaking**: Complete rewrite from OWL 1.x\n\n## IMPORTANT: OWL 1.x → 2.x Changes\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ BREAKING CHANGES from v15: ║\n║ • No more odoo.define() - use ES modules ║\n║ • No more require() - use import ║\n║ • Hooks imported directly from @odoo/owl ║\n║ • Services accessed via useService() ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Basic Component Structure (OWL 2.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, useRef, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class {ComponentName} extends Component {\n static template = \"{module_name}.{ComponentName}\";\n static props = {\n recordId: { type: Number, optional: true },\n mode: { type: String, optional: true },\n };\n\n setup() {\n // Services\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n\n // State\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n });\n\n // Refs\n this.inputRef = useRef(\"input\");\n\n // Lifecycle\n onWillStart(async () => {\n await this.loadData();\n });\n\n onMounted(() => {\n this.inputRef.el?.focus();\n });\n }\n\n async loadData() {\n try {\n const data = await this.orm.searchRead(\n \"{module_name}.{model_name}\",\n [],\n [\"name\", \"state\", \"amount\"]\n );\n this.state.data = data;\n } catch (error) {\n this.state.error = error.message;\n this.notification.add(\"Failed to load data\", { type: \"danger\" });\n } finally {\n this.state.loading = false;\n }\n }\n\n onButtonClick() {\n this.notification.add(\"Button clicked!\", { type: \"success\" });\n }\n\n async onRecordSelect(recordId) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"{module_name}.{model_name}\",\n res_id: recordId,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n}\n\n// Register as client action\nregistry.category(\"actions\").add(\"{module_name}.{component_name}\", {ComponentName});\n```\n\n## Template Structure (OWL 2.x)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"{module_name}.{ComponentName}\">\n \u003cdiv class=\"o_{component_name}\">\n \u003c!-- Loading state -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"o_loading text-center p-4\">\n \u003ci class=\"fa fa-spinner fa-spin fa-2x\"/>\n \u003cp>Loading...\u003c/p>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error state -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger\">\n \u003ct t-esc=\"state.error\"/>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Content -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_content\">\n \u003cdiv class=\"o_header d-flex justify-content-between mb-3\">\n \u003ch2>My Component\u003c/h2>\n \u003cbutton class=\"btn btn-primary\" t-on-click=\"onButtonClick\">\n Action\n \u003c/button>\n \u003c/div>\n\n \u003ct t-if=\"state.data.length\">\n \u003ctable class=\"table table-hover\">\n \u003cthead>\n \u003ctr>\n \u003cth>Name\u003c/th>\n \u003cth>State\u003c/th>\n \u003cth>Amount\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003ctr t-on-click=\"() => this.onRecordSelect(item.id)\"\n class=\"cursor-pointer\">\n \u003ctd t-esc=\"item.name\"/>\n \u003ctd t-esc=\"item.state\"/>\n \u003ctd t-esc=\"item.amount\"/>\n \u003c/tr>\n \u003c/t>\n \u003c/tbody>\n \u003c/table>\n \u003c/t>\n\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_nocontent_help text-center p-4\">\n \u003cp>No records found\u003c/p>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n\n \u003cinput t-ref=\"input\" type=\"text\" class=\"form-control mt-3\"/>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## ORM Service (OWL 2.x)\n\n```javascript\nconst orm = useService(\"orm\");\n\n// Search and read\nconst records = await orm.searchRead(\n \"res.partner\",\n [[\"is_company\", \"=\", true]],\n [\"name\", \"email\"]\n);\n\n// Read\nconst record = await orm.read(\"res.partner\", [1], [\"name\"]);\n\n// Create\nconst id = await orm.create(\"res.partner\", { name: \"New Partner\" });\n\n// Write\nawait orm.write(\"res.partner\", [1], { name: \"Updated\" });\n\n// Unlink\nawait orm.unlink(\"res.partner\", [1]);\n\n// Call method\nconst result = await orm.call(\n \"res.partner\",\n \"custom_method\",\n [[1, 2]],\n { arg: \"value\" }\n);\n\n// Search count\nconst count = await orm.searchCount(\"res.partner\", []);\n```\n\n## Field Widget (OWL 2.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\nimport { standardFieldProps } from \"@web/views/fields/standard_field_props\";\n\nexport class {WidgetName} extends Component {\n static template = \"{module_name}.{WidgetName}\";\n static props = {\n ...standardFieldProps,\n };\n\n get formattedValue() {\n return this.props.record.data[this.props.name] || \"\";\n }\n\n onChange(ev) {\n this.props.record.update({ [this.props.name]: ev.target.value });\n }\n}\n\nregistry.category(\"fields\").add(\"{widget_name}\", {\n component: {WidgetName},\n supportedTypes: [\"char\"],\n});\n```\n\n## Systray Item (OWL 2.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\nimport { useService } from \"@web/core/utils/hooks\";\n\nexport class {SystrayName} extends Component {\n static template = \"{module_name}.{SystrayName}\";\n\n setup() {\n this.notification = useService(\"notification\");\n this.state = useState({ count: 0 });\n }\n\n onClick() {\n this.notification.add(\"Systray clicked!\", { type: \"info\" });\n }\n}\n\nexport const systrayItem = {\n Component: {SystrayName},\n};\n\nregistry.category(\"systray\").add(\"{module_name}.{SystrayName}\", systrayItem, { sequence: 100 });\n```\n\n## Manifest Assets (v16)\n\n```python\n'assets': {\n 'web.assets_backend': [\n '{module_name}/static/src/**/*.js',\n '{module_name}/static/src/**/*.xml',\n '{module_name}/static/src/**/*.scss',\n ],\n},\n```\n\n## v16 OWL 2.x Checklist\n\n- [ ] Use `/** @odoo-module **/` directive\n- [ ] Import from `@odoo/owl`\n- [ ] Use `useService()` for services\n- [ ] Use `registry.category().add()` for registration\n- [ ] Define `static template` and `static props`\n- [ ] Use direct lifecycle hooks (not from `owl.hooks`)\n- [ ] Include in manifest assets\n\n## AI Agent Instructions (v16 OWL)\n\nWhen generating Odoo 16.0 OWL components:\n\n1. **START** with `/** @odoo-module **/`\n2. **IMPORT** from `@odoo/owl` directly\n3. **USE** `useService()` for orm, action, notification\n4. **USE** `registry.category().add()` for registration\n5. **DEFINE** `static template` and `static props`\n6. **DO NOT** use `odoo.define()`\n7. **DO NOT** use `require()`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9478,"content_sha256":"1130ebc6824ff82b3f648490efd6532b49b77b5c16110184df8ebd4d1cf601ab"},{"filename":"skills/odoo-owl-components-17-18.md","content":"# Odoo OWL Migration Guide: 17.0 → 18.0 (OWL 2.x Enhanced)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ OWL MIGRATION GUIDE: 17.0 → 18.0 ║\n║ Minor updates - OWL 2.x remains the same with enhancements. ║\n║ Focus on service improvements and best practices. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview\n\nThe OWL framework in Odoo 18.0 is the same version (2.x) as in Odoo 17.0, with minor enhancements and new services.\n\n## Changes Summary\n\n| Feature | v17 | v18 |\n|---------|-----|-----|\n| OWL Version | 2.x | 2.x (enhanced) |\n| Services | Standard | Additional services |\n| Props validation | Recommended | Recommended |\n| Type hints (JSDoc) | Optional | Recommended |\n\n## New/Enhanced Services in v18\n\n### Company Service\n```javascript\n// v18: New company service\nsetup() {\n this.company = useService(\"company\");\n // Access current company info\n const currentCompany = this.company.currentCompany;\n const allowedCompanies = this.company.allowedCompanies;\n}\n```\n\n### Enhanced ORM Service\n```javascript\n// v18: Enhanced ORM with better options\nconst records = await this.orm.searchRead(\n \"my.model\",\n domain,\n fields,\n {\n limit: 100,\n offset: 0,\n order: \"create_date DESC\",\n context: { ...this.user.context },\n }\n);\n```\n\n### Enhanced Notification Service\n```javascript\n// v18: More notification options\nthis.notification.add(\"Operation successful\", {\n type: \"success\",\n sticky: false,\n buttons: [\n {\n name: \"Undo\",\n onClick: () => this.undoAction(),\n primary: true,\n },\n {\n name: \"View\",\n onClick: () => this.viewRecord(),\n },\n ],\n});\n```\n\n## Best Practices for v18\n\n### Add JSDoc Type Annotations\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\n\n/**\n * @typedef {Object} MyComponentProps\n * @property {number} [recordId]\n * @property {Function} [onConfirm]\n */\n\nexport class MyComponent extends Component {\n /** @type {string} */\n static template = \"my_module.MyComponent\";\n\n /** @type {MyComponentProps} */\n static props = {\n recordId: { type: Number, optional: true },\n onConfirm: { type: Function, optional: true },\n };\n\n setup() {\n /** @type {import(\"@web/core/orm_service\").ORM} */\n this.orm = useService(\"orm\");\n }\n}\n```\n\n### Use Static Props Validation\n```javascript\nstatic props = {\n // Required props\n recordId: { type: Number },\n\n // Optional props with defaults\n mode: { type: String, optional: true },\n\n // Function props\n onConfirm: { type: Function, optional: true },\n\n // Array/Object props\n items: { type: Array, optional: true },\n config: { type: Object, optional: true },\n\n // Union types\n value: { type: [String, Number], optional: true },\n};\n\nstatic defaultProps = {\n mode: \"view\",\n items: [],\n};\n```\n\n### Cleanup in onWillUnmount\n```javascript\nsetup() {\n this._cleanup = null;\n\n onMounted(() => {\n const handler = (e) => this.handleKeydown(e);\n document.addEventListener(\"keydown\", handler);\n this._cleanup = () => document.removeEventListener(\"keydown\", handler);\n });\n\n onWillUnmount(() => {\n this._cleanup?.();\n });\n}\n```\n\n## Migration Checklist\n\n- [ ] Add JSDoc type annotations (recommended)\n- [ ] Add comprehensive `static props` validation\n- [ ] Add `static defaultProps` where needed\n- [ ] Use new company service if needed\n- [ ] Add cleanup logic in `onWillUnmount`\n- [ ] Test with new view Python expressions\n\n## No Breaking Changes\n\nv17 OWL components work in v18 without modification. The changes are additive and follow enhanced best practices.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4270,"content_sha256":"379d36c6aa8f11f1040ab67f88bc0c74b092d304ee3d69247b1a84b1727d19f7"},{"filename":"skills/odoo-owl-components-17.md","content":"# Odoo OWL Components - Version 17.0 (OWL 2.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 17.0 OWL 2.x COMPONENT PATTERNS ║\n║ This file contains ONLY Odoo 17.0 OWL patterns. ║\n║ Same as v16 patterns with minor enhancements. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 17.0 OWL Requirements\n\n- **OWL Version**: 2.x (same as v16)\n- **Module System**: ES modules with `/** @odoo-module **/`\n- **View Integration**: Works with new Python expression visibility\n\n## v17 OWL Enhancements\n\nv17 OWL is essentially the same as v16 with improved:\n- Better error handling\n- Enhanced service interfaces\n- Improved TypeScript definitions\n\n## Basic Component Structure\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, useRef, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n mode: { type: String, optional: true },\n onConfirm: { type: Function, optional: true },\n };\n\n setup() {\n // Services\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n this.user = useService(\"user\");\n\n // State\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n selectedIds: new Set(),\n });\n\n // Refs\n this.containerRef = useRef(\"container\");\n\n // Lifecycle\n onWillStart(async () => {\n await this.loadData();\n });\n\n onMounted(() => {\n console.log(\"Component mounted\");\n });\n }\n\n async loadData() {\n try {\n const data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\", \"amount\"],\n { order: \"create_date DESC\", limit: 100 }\n );\n this.state.data = data;\n } catch (error) {\n this.state.error = error.message;\n this.notification.add(\"Failed to load data\", { type: \"danger\" });\n } finally {\n this.state.loading = false;\n }\n }\n\n toggleSelect(id) {\n if (this.state.selectedIds.has(id)) {\n this.state.selectedIds.delete(id);\n } else {\n this.state.selectedIds.add(id);\n }\n // Force reactivity update\n this.state.selectedIds = new Set(this.state.selectedIds);\n }\n\n async onBulkAction() {\n const ids = Array.from(this.state.selectedIds);\n if (ids.length === 0) {\n this.notification.add(\"No records selected\", { type: \"warning\" });\n return;\n }\n\n await this.orm.call(\"my.model\", \"action_confirm\", [ids]);\n this.notification.add(`${ids.length} records confirmed`, { type: \"success\" });\n await this.loadData();\n }\n\n async onRecordClick(record) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"my.model\",\n res_id: record.id,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_action\", MyComponent);\n```\n\n## Template Structure\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv t-ref=\"container\" class=\"o_my_component p-3\">\n \u003c!-- Loading -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"d-flex justify-content-center p-5\">\n \u003cdiv class=\"spinner-border text-primary\" role=\"status\">\n \u003cspan class=\"visually-hidden\">Loading...\u003c/span>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger d-flex align-items-center\">\n \u003ci class=\"fa fa-exclamation-triangle me-2\"/>\n \u003cspan t-esc=\"state.error\"/>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Content -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"d-flex justify-content-between align-items-center mb-3\">\n \u003ch2>Records\u003c/h2>\n \u003cdiv>\n \u003cbutton class=\"btn btn-outline-secondary me-2\"\n t-on-click=\"() => this.loadData()\">\n \u003ci class=\"fa fa-refresh\"/>\n \u003c/button>\n \u003cbutton class=\"btn btn-primary\"\n t-att-disabled=\"state.selectedIds.size === 0\"\n t-on-click=\"onBulkAction\">\n Confirm Selected (\u003ct t-esc=\"state.selectedIds.size\"/>)\n \u003c/button>\n \u003c/div>\n \u003c/div>\n\n \u003ct t-if=\"state.data.length\">\n \u003cdiv class=\"list-group\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv t-attf-class=\"list-group-item list-group-item-action d-flex justify-content-between align-items-center {{ state.selectedIds.has(item.id) ? 'active' : '' }}\">\n \u003cdiv class=\"form-check\">\n \u003cinput type=\"checkbox\" class=\"form-check-input\"\n t-att-checked=\"state.selectedIds.has(item.id)\"\n t-on-change=\"() => this.toggleSelect(item.id)\"/>\n \u003c/div>\n \u003cdiv class=\"flex-grow-1 ms-3\"\n t-on-click=\"() => this.onRecordClick(item)\"\n style=\"cursor: pointer;\">\n \u003cstrong t-esc=\"item.name\"/>\n \u003cdiv class=\"small text-muted\">\n Amount: \u003ct t-esc=\"item.amount\"/>\n \u003c/div>\n \u003c/div>\n \u003cspan t-attf-class=\"badge bg-{{ item.state === 'done' ? 'success' : 'secondary' }}\">\n \u003ct t-esc=\"item.state\"/>\n \u003c/span>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\n \u003ct t-else=\"\">\n \u003cdiv class=\"text-center p-5 text-muted\">\n \u003ci class=\"fa fa-inbox fa-3x mb-3\"/>\n \u003cp>No records found\u003c/p>\n \u003c/div>\n \u003c/t>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## Dialog Component (v17)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState } from \"@odoo/owl\";\nimport { Dialog } from \"@web/core/dialog/dialog\";\n\nexport class ConfirmDialog extends Component {\n static template = \"my_module.ConfirmDialog\";\n static components = { Dialog };\n static props = {\n close: Function,\n title: { type: String, optional: true },\n message: { type: String, optional: true },\n onConfirm: { type: Function, optional: true },\n };\n\n setup() {\n this.state = useState({ processing: false });\n }\n\n async onConfirm() {\n this.state.processing = true;\n try {\n if (this.props.onConfirm) {\n await this.props.onConfirm();\n }\n this.props.close();\n } finally {\n this.state.processing = false;\n }\n }\n}\n```\n\n## v17 Checklist\n\n- [ ] Use `/** @odoo-module **/` directive\n- [ ] Import from `@odoo/owl`\n- [ ] Use `useService()` for all services\n- [ ] Define `static props` for validation\n- [ ] Use `registry.category().add()` for registration\n- [ ] Include in manifest assets\n\n## AI Agent Instructions (v17 OWL)\n\nWhen generating Odoo 17.0 OWL components:\n\n1. **USE** same patterns as v16 (OWL 2.x)\n2. **START** with `/** @odoo-module **/`\n3. **IMPORT** from `@odoo/owl`\n4. **USE** `useService()` for services\n5. **DEFINE** `static props` for type validation\n6. Components work with new view Python expressions\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8900,"content_sha256":"b3609f0a9fd5e8b5829bc19df25f1453403a0b38032356239f00cec78671eb30"},{"filename":"skills/odoo-owl-components-18-19.md","content":"# Odoo OWL Migration Guide: 18.0 → 19.0 (OWL 2.x → 3.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ OWL MIGRATION GUIDE: 2.x → 3.x ║\n║ This is a significant update with enhanced patterns. ║\n║ Note: v19 is in development - patterns may change. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Changes Summary\n\n| Feature | OWL 2.x (v18) | OWL 3.x (v19) |\n|---------|---------------|---------------|\n| Reactivity | Standard | Enhanced |\n| Props validation | Runtime | Enhanced + TypeScript-like |\n| Type annotations | JSDoc optional | JSDoc recommended |\n| Error boundaries | Basic | Enhanced |\n| Performance | Good | Improved |\n\n## Enhanced Reactivity in OWL 3.x\n\n### Before (OWL 2.x)\n```javascript\nsetup() {\n this.state = useState({\n items: [],\n selectedIds: new Set(),\n });\n}\n\ntoggleSelect(id) {\n if (this.state.selectedIds.has(id)) {\n this.state.selectedIds.delete(id);\n } else {\n this.state.selectedIds.add(id);\n }\n // Force reactivity update for Set\n this.state.selectedIds = new Set(this.state.selectedIds);\n}\n```\n\n### After (OWL 3.x)\n```javascript\nsetup() {\n this.state = useState({\n items: [],\n selectedIds: new Set(), // Sets are now fully reactive\n });\n}\n\ntoggleSelect(id) {\n // Direct mutation works with enhanced reactivity\n if (this.state.selectedIds.has(id)) {\n this.state.selectedIds.delete(id);\n } else {\n this.state.selectedIds.add(id);\n }\n // No need to recreate Set\n}\n```\n\n## Enhanced Props Validation\n\n### Before (OWL 2.x)\n```javascript\nstatic props = {\n recordId: { type: Number, optional: true },\n onConfirm: { type: Function, optional: true },\n};\n```\n\n### After (OWL 3.x)\n```javascript\n/**\n * @typedef {Object} MyComponentProps\n * @property {number} [recordId]\n * @property {(id: number) => void} [onConfirm]\n * @property {'view' | 'edit'} [mode]\n */\n\nstatic props = {\n recordId: { type: Number, optional: true },\n onConfirm: { type: Function, optional: true },\n mode: {\n type: String,\n optional: true,\n validate: (value) => ['view', 'edit'].includes(value),\n },\n};\n\nstatic defaultProps = {\n mode: 'view',\n};\n```\n\n## Complete Migration Example\n\n### Before (OWL 2.x - v18)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n };\n\n setup() {\n this.orm = useService(\"orm\");\n this.state = useState({\n data: [],\n loading: true,\n });\n\n onWillStart(async () => {\n await this.loadData();\n });\n }\n\n async loadData() {\n try {\n this.state.data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\"]\n );\n } finally {\n this.state.loading = false;\n }\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_action\", MyComponent);\n```\n\n### After (OWL 3.x - v19)\n\n```javascript\n/** @odoo-module **/\n\nimport {\n Component,\n useState,\n onWillStart,\n onMounted,\n onWillUnmount,\n} from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\n/**\n * @typedef {Object} MyComponentProps\n * @property {number} [recordId] - Optional record ID to load\n * @property {(data: Array) => void} [onDataLoad] - Callback when data loads\n */\n\n/**\n * @typedef {Object} MyComponentState\n * @property {Array\u003cObject>} data\n * @property {boolean} loading\n * @property {string|null} error\n */\n\nexport class MyComponent extends Component {\n /** @type {string} */\n static template = \"my_module.MyComponent\";\n\n /** @type {MyComponentProps} */\n static props = {\n recordId: { type: Number, optional: true },\n onDataLoad: { type: Function, optional: true },\n };\n\n setup() {\n /** @type {import(\"@web/core/orm_service\").ORM} */\n this.orm = useService(\"orm\");\n this.notification = useService(\"notification\");\n\n /** @type {MyComponentState} */\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n });\n\n // Cleanup function reference\n this._abortController = null;\n\n onWillStart(async () => {\n await this.loadData();\n });\n\n onWillUnmount(() => {\n // Cancel pending requests\n this._abortController?.abort();\n });\n }\n\n /**\n * Load data from server\n * @returns {Promise\u003cvoid>}\n */\n async loadData() {\n this._abortController = new AbortController();\n\n try {\n const data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\"],\n { order: \"create_date DESC\" }\n );\n\n this.state.data = data;\n this.state.error = null;\n\n // Call callback if provided\n this.props.onDataLoad?.(data);\n } catch (error) {\n if (error.name !== 'AbortError') {\n this.state.error = error.message;\n this.notification.add(\"Failed to load data\", { type: \"danger\" });\n }\n } finally {\n this.state.loading = false;\n }\n }\n\n /**\n * Handle item click\n * @param {Object} item\n */\n onItemClick(item) {\n console.log(\"Item clicked:\", item.id);\n }\n}\n\nregistry.category(\"actions\").add(\"my_module.my_action\", MyComponent);\n```\n\n## Key Differences\n\n### 1. Enhanced Type Annotations\n- More comprehensive JSDoc\n- Function parameter types\n- Return type annotations\n\n### 2. Better Error Handling\n- AbortController for cancellation\n- Proper cleanup in onWillUnmount\n- Error state management\n\n### 3. Props Callback Pattern\n```javascript\n// v19: Better callback props pattern\nstatic props = {\n onSelect: {\n type: Function,\n optional: true,\n },\n};\n\n// Usage with null-safe call\nthis.props.onSelect?.(selectedId);\n```\n\n### 4. State Type Definition\n```javascript\n/**\n * @typedef {Object} ComponentState\n * @property {Array\u003cObject>} items\n * @property {boolean} loading\n * @property {number|null} selectedId\n */\n\n/** @type {ComponentState} */\nthis.state = useState({...});\n```\n\n## Migration Checklist\n\n- [ ] Add comprehensive JSDoc type annotations\n- [ ] Define type for component props (`@typedef`)\n- [ ] Define type for component state (`@typedef`)\n- [ ] Add return types to all methods\n- [ ] Add parameter types to all methods\n- [ ] Implement proper cleanup in `onWillUnmount`\n- [ ] Use AbortController for cancellable requests\n- [ ] Update Set/Map usage (now fully reactive)\n- [ ] Add validation functions to props where needed\n- [ ] Use `static defaultProps` for optional props\n\n## Common Migration Issues\n\n### Issue: Set/Map not updating UI\n**v18**: Required recreating Set/Map for reactivity\n**v19**: Direct mutations work, no workaround needed\n\n### Issue: Missing type annotations\n**Fix**: Add JSDoc for all methods, state, and props\n\n### Issue: Uncancelled requests on unmount\n**Fix**: Use AbortController and cleanup in onWillUnmount\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":7898,"content_sha256":"3e471cf2fc97bb813ea93a29fad8e544cb08794dc884e1cd6a053b883f34f27e"},{"filename":"skills/odoo-owl-components-18.md","content":"# Odoo OWL Components - Version 18.0 (OWL 2.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 18.0 OWL 2.x COMPONENT PATTERNS ║\n║ This file contains ONLY Odoo 18.0 OWL patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 18.0 OWL Requirements\n\n- **OWL Version**: 2.x (enhanced)\n- **Module System**: ES modules with `/** @odoo-module **/`\n- **Props**: Optional but recommended\n\n## Basic Component Structure\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, useRef, onWillStart, onMounted } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n recordId: { type: Number, optional: true },\n mode: { type: String, optional: true },\n };\n\n setup() {\n // Services\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n this.user = useService(\"user\");\n\n // State\n this.state = useState({\n data: [],\n loading: true,\n error: null,\n });\n\n // Refs\n this.inputRef = useRef(\"input\");\n\n // Lifecycle\n onWillStart(async () => {\n await this.loadData();\n });\n\n onMounted(() => {\n this.inputRef.el?.focus();\n });\n }\n\n async loadData() {\n try {\n const data = await this.orm.searchRead(\n \"my.model\",\n [],\n [\"name\", \"state\", \"amount\"]\n );\n this.state.data = data;\n } catch (error) {\n this.state.error = error.message;\n this.notification.add(\"Failed to load data\", { type: \"danger\" });\n } finally {\n this.state.loading = false;\n }\n }\n\n onButtonClick() {\n this.notification.add(\"Button clicked!\", { type: \"success\" });\n }\n\n async onRecordSelect(recordId) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"my.model\",\n res_id: recordId,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n}\n\n// Register as client action\nregistry.category(\"actions\").add(\"my_module.my_component\", MyComponent);\n```\n\n## Template Structure (XML)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv class=\"o_my_component\">\n \u003c!-- Loading state -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"o_loading text-center p-4\">\n \u003ci class=\"fa fa-spinner fa-spin fa-2x\"/>\n \u003cp>Loading...\u003c/p>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error state -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger\">\n \u003ct t-esc=\"state.error\"/>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Data loaded -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_content\">\n \u003c!-- Header -->\n \u003cdiv class=\"o_header d-flex justify-content-between mb-3\">\n \u003ch2>My Component\u003c/h2>\n \u003cbutton class=\"btn btn-primary\"\n t-on-click=\"onButtonClick\">\n Action\n \u003c/button>\n \u003c/div>\n\n \u003c!-- List -->\n \u003ct t-if=\"state.data.length\">\n \u003ctable class=\"table table-hover\">\n \u003cthead>\n \u003ctr>\n \u003cth>Name\u003c/th>\n \u003cth>State\u003c/th>\n \u003cth>Amount\u003c/th>\n \u003c/tr>\n \u003c/thead>\n \u003ctbody>\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003ctr t-on-click=\"() => this.onRecordSelect(item.id)\"\n class=\"cursor-pointer\">\n \u003ctd t-esc=\"item.name\"/>\n \u003ctd>\n \u003cspan t-attf-class=\"badge bg-{{ item.state === 'done' ? 'success' : 'secondary' }}\">\n \u003ct t-esc=\"item.state\"/>\n \u003c/span>\n \u003c/td>\n \u003ctd t-esc=\"item.amount\"/>\n \u003c/tr>\n \u003c/t>\n \u003c/tbody>\n \u003c/table>\n \u003c/t>\n\n \u003c!-- Empty state -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"o_nocontent_help text-center p-4\">\n \u003cp class=\"o_view_nocontent_smiling_face\">\n No records found\n \u003c/p>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n\n \u003c!-- Input with ref -->\n \u003cinput t-ref=\"input\" type=\"text\" class=\"form-control\"/>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## Component Types\n\n### Client Action\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState, onWillStart } from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\nexport class DashboardAction extends Component {\n static template = \"my_module.DashboardAction\";\n\n setup() {\n this.orm = useService(\"orm\");\n this.state = useState({ stats: {} });\n\n onWillStart(async () => {\n this.state.stats = await this.orm.call(\n \"my.model\",\n \"get_dashboard_stats\",\n []\n );\n });\n }\n}\n\n// Register as action\nregistry.category(\"actions\").add(\"my_module.dashboard\", DashboardAction);\n```\n\nAction XML:\n```xml\n\u003crecord id=\"action_dashboard\" model=\"ir.actions.client\">\n \u003cfield name=\"name\">Dashboard\u003c/field>\n \u003cfield name=\"tag\">my_module.dashboard\u003c/field>\n\u003c/record>\n```\n\n### Field Widget\n\n```javascript\n/** @odoo-module **/\n\nimport { Component } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\nimport { standardFieldProps } from \"@web/views/fields/standard_field_props\";\n\nexport class MyFieldWidget extends Component {\n static template = \"my_module.MyFieldWidget\";\n static props = {\n ...standardFieldProps,\n };\n\n get formattedValue() {\n return this.props.record.data[this.props.name] || \"\";\n }\n\n onChange(ev) {\n this.props.record.update({ [this.props.name]: ev.target.value });\n }\n}\n\nregistry.category(\"fields\").add(\"my_field_widget\", {\n component: MyFieldWidget,\n supportedTypes: [\"char\"],\n});\n```\n\n### Systray Item\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\nimport { useService } from \"@web/core/utils/hooks\";\n\nexport class MySystrayItem extends Component {\n static template = \"my_module.MySystrayItem\";\n\n setup() {\n this.notification = useService(\"notification\");\n this.state = useState({ count: 0 });\n }\n\n onClick() {\n this.notification.add(\"Systray clicked!\", { type: \"info\" });\n }\n}\n\nexport const systrayItem = {\n Component: MySystrayItem,\n};\n\nregistry.category(\"systray\").add(\"my_module.MySystrayItem\", systrayItem, { sequence: 100 });\n```\n\n### Dialog Component\n\n```javascript\n/** @odoo-module **/\n\nimport { Component, useState } from \"@odoo/owl\";\nimport { Dialog } from \"@web/core/dialog/dialog\";\nimport { useService } from \"@web/core/utils/hooks\";\n\nexport class MyDialog extends Component {\n static template = \"my_module.MyDialog\";\n static components = { Dialog };\n static props = {\n close: Function,\n title: { type: String, optional: true },\n onConfirm: { type: Function, optional: true },\n };\n\n setup() {\n this.state = useState({ value: \"\" });\n }\n\n onConfirm() {\n if (this.props.onConfirm) {\n this.props.onConfirm(this.state.value);\n }\n this.props.close();\n }\n}\n```\n\nDialog template:\n```xml\n\u003ct t-name=\"my_module.MyDialog\">\n \u003cDialog title=\"props.title or 'My Dialog'\">\n \u003cdiv class=\"p-3\">\n \u003cinput type=\"text\" class=\"form-control\"\n t-model=\"state.value\"\n placeholder=\"Enter value...\"/>\n \u003c/div>\n \u003ct t-set-slot=\"footer\">\n \u003cbutton class=\"btn btn-secondary\" t-on-click=\"props.close\">\n Cancel\n \u003c/button>\n \u003cbutton class=\"btn btn-primary\" t-on-click=\"onConfirm\">\n Confirm\n \u003c/button>\n \u003c/t>\n \u003c/Dialog>\n\u003c/t>\n```\n\n## OWL Hooks Reference\n\n### Lifecycle Hooks\n\n```javascript\nimport {\n onWillStart, // Before first render (async)\n onMounted, // After mounted to DOM\n onWillUpdateProps, // Before props update\n onWillRender, // Before each render\n onRendered, // After each render\n onWillUnmount, // Before unmount\n onWillDestroy, // Before destroy\n onError, // Error handling\n} from \"@odoo/owl\";\n\nsetup() {\n onWillStart(async () => {\n // Async initialization\n await this.loadData();\n });\n\n onMounted(() => {\n // DOM is available\n console.log(\"Component mounted\");\n });\n\n onWillUnmount(() => {\n // Cleanup\n console.log(\"Component will unmount\");\n });\n\n onError((error) => {\n console.error(\"Component error:\", error);\n });\n}\n```\n\n### State and Reactivity\n\n```javascript\nimport { useState, useRef, reactive } from \"@odoo/owl\";\n\nsetup() {\n // Reactive state\n this.state = useState({\n count: 0,\n items: [],\n });\n\n // DOM refs\n this.inputRef = useRef(\"myInput\");\n\n // Reactive object\n this.data = reactive({ value: 0 });\n}\n\n// Access ref in methods\nonButtonClick() {\n const input = this.inputRef.el;\n input.focus();\n}\n```\n\n## Services Reference\n\n### ORM Service\n\n```javascript\nconst orm = useService(\"orm\");\n\n// Search and read\nconst records = await orm.searchRead(\"res.partner\", [[\"is_company\", \"=\", true]], [\"name\", \"email\"]);\n\n// Read single record\nconst record = await orm.read(\"res.partner\", [1], [\"name\"]);\n\n// Create\nconst id = await orm.create(\"res.partner\", { name: \"New Partner\" });\n\n// Write\nawait orm.write(\"res.partner\", [1], { name: \"Updated\" });\n\n// Unlink\nawait orm.unlink(\"res.partner\", [1]);\n\n// Call method\nconst result = await orm.call(\"res.partner\", \"custom_method\", [[1, 2]], { arg: \"value\" });\n\n// Search count\nconst count = await orm.searchCount(\"res.partner\", []);\n```\n\n### Action Service\n\n```javascript\nconst action = useService(\"action\");\n\n// Open form view\nawait action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"res.partner\",\n res_id: 1,\n views: [[false, \"form\"]],\n target: \"current\",\n});\n\n// Open list view\nawait action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"res.partner\",\n views: [[false, \"list\"], [false, \"form\"]],\n target: \"current\",\n});\n\n// Execute server action\nawait action.doAction(actionId);\n```\n\n### Notification Service\n\n```javascript\nconst notification = useService(\"notification\");\n\n// Simple notification\nnotification.add(\"Operation successful\", { type: \"success\" });\n\n// With title and options\nnotification.add(\"Record created\", {\n title: \"Success\",\n type: \"success\",\n sticky: false,\n buttons: [\n {\n name: \"View\",\n onClick: () => this.viewRecord(),\n },\n ],\n});\n\n// Types: success, warning, danger, info\n```\n\n### Dialog Service\n\n```javascript\nconst dialog = useService(\"dialog\");\n\n// Confirmation dialog\ndialog.add(ConfirmationDialog, {\n title: \"Confirm Action\",\n body: \"Are you sure?\",\n confirm: () => this.doAction(),\n cancel: () => {},\n});\n```\n\n## Manifest Assets (v18)\n\n```python\n'assets': {\n 'web.assets_backend': [\n 'my_module/static/src/**/*.js',\n 'my_module/static/src/**/*.xml',\n 'my_module/static/src/**/*.scss',\n ],\n 'web.assets_frontend': [\n # For website components\n ],\n},\n```\n\n## v18 OWL Checklist\n\n- [ ] Use `/** @odoo-module **/` directive\n- [ ] Import from `@odoo/owl`\n- [ ] Use `useService()` for services\n- [ ] Register in appropriate registry\n- [ ] Static props definition (optional but recommended)\n- [ ] Proper lifecycle hooks usage\n- [ ] Include in manifest assets\n\n## AI Agent Instructions (v18 OWL)\n\nWhen generating Odoo 18.0 OWL components:\n\n1. **ALWAYS** start with `/** @odoo-module **/`\n2. **USE** ES module imports from `@odoo/owl`\n3. **USE** `useService()` hook for services\n4. **REGISTER** components in appropriate registry\n5. **DEFINE** static props (recommended)\n6. **USE** proper lifecycle hooks\n7. **INCLUDE** in manifest assets\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":13758,"content_sha256":"83dee2a62991732a6a2f079dd7528842a43572b79f5c0dc947405ff483ab17db"},{"filename":"skills/odoo-owl-components-19.md","content":"# Odoo OWL Components - Version 19.0 (OWL 3.x)\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 19.0 OWL 3.x COMPONENT PATTERNS ║\n║ This file contains ONLY Odoo 19.0 OWL patterns. ║\n║ DO NOT use these patterns for other versions. ║\n║ Note: v19 is in development - patterns may change. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 19.0 OWL Requirements\n\n- **OWL Version**: 3.x (new version)\n- **Module System**: ES modules with `/** @odoo-module **/`\n- **Breaking**: Some changes from OWL 2.x\n\n## OWL 2.x → 3.x Changes\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ KEY CHANGES in OWL 3.x: ║\n║ • Enhanced reactivity system ║\n║ • Improved props validation ║\n║ • Better TypeScript support ║\n║ • Refined lifecycle hooks ║\n║ • Performance improvements ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Basic Component Structure (OWL 3.x)\n\n```javascript\n/** @odoo-module **/\n\nimport {\n Component,\n useState,\n useRef,\n onWillStart,\n onMounted,\n onWillUnmount,\n} from \"@odoo/owl\";\nimport { useService } from \"@web/core/utils/hooks\";\nimport { registry } from \"@web/core/registry\";\n\n/**\n * @typedef {Object} MyComponentProps\n * @property {number} [recordId]\n * @property {string} [mode]\n * @property {Function} [onSelect]\n */\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n\n /** @type {MyComponentProps} */\n static props = {\n recordId: { type: Number, optional: true },\n mode: { type: String, optional: true },\n onSelect: { type: Function, optional: true },\n };\n\n static defaultProps = {\n mode: \"view\",\n };\n\n setup() {\n // Services with typed access\n /** @type {import(\"@web/core/orm_service\").ORM} */\n this.orm = useService(\"orm\");\n this.action = useService(\"action\");\n this.notification = useService(\"notification\");\n this.dialog = useService(\"dialog\");\n this.user = useService(\"user\");\n this.company = useService(\"company\");\n\n // Reactive state\n this.state = useState({\n /** @type {Array\u003cObject>} */\n data: [],\n /** @type {boolean} */\n loading: true,\n /** @type {string|null} */\n error: null,\n /** @type {number|null} */\n selectedId: null,\n /** @type {Object} */\n filters: {\n state: null,\n search: \"\",\n },\n });\n\n // Refs\n this.containerRef = useRef(\"container\");\n this.searchRef = useRef(\"search\");\n\n // Cleanup function\n this._cleanup = null;\n\n // Lifecycle\n onWillStart(async () => {\n await this.loadData();\n });\n\n onMounted(() => {\n this._setupEventListeners();\n this.searchRef.el?.focus();\n });\n\n onWillUnmount(() => {\n this._cleanup?.();\n });\n }\n\n _setupEventListeners() {\n const handler = (e) => {\n if (e.key === \"Escape\") {\n this.state.selectedId = null;\n }\n };\n document.addEventListener(\"keydown\", handler);\n this._cleanup = () => document.removeEventListener(\"keydown\", handler);\n }\n\n /**\n * Load data from server\n * @returns {Promise\u003cvoid>}\n */\n async loadData() {\n this.state.loading = true;\n this.state.error = null;\n\n try {\n const domain = this._buildDomain();\n const data = await this.orm.searchRead(\n \"my.model\",\n domain,\n [\"name\", \"state\", \"amount\", \"partner_id\"],\n {\n order: \"create_date DESC\",\n limit: 100,\n }\n );\n this.state.data = data;\n } catch (error) {\n this.state.error = error.message || \"Failed to load data\";\n this.notification.add(this.state.error, {\n type: \"danger\",\n sticky: true,\n });\n } finally {\n this.state.loading = false;\n }\n }\n\n /**\n * Build search domain from filters\n * @returns {Array}\n */\n _buildDomain() {\n const domain = [];\n if (this.state.filters.state) {\n domain.push([\"state\", \"=\", this.state.filters.state]);\n }\n if (this.state.filters.search) {\n domain.push([\"name\", \"ilike\", this.state.filters.search]);\n }\n return domain;\n }\n\n /**\n * Handle item selection\n * @param {Object} item\n */\n onItemSelect(item) {\n this.state.selectedId = item.id;\n if (this.props.onSelect) {\n this.props.onSelect(item.id);\n }\n }\n\n /**\n * Open record in form view\n * @param {number} id\n */\n async openRecord(id) {\n await this.action.doAction({\n type: \"ir.actions.act_window\",\n res_model: \"my.model\",\n res_id: id,\n views: [[false, \"form\"]],\n target: \"current\",\n });\n }\n\n /**\n * Handle search input\n * @param {Event} ev\n */\n onSearchInput(ev) {\n this.state.filters.search = ev.target.value;\n // Debounced reload would be better in production\n this.loadData();\n }\n\n /**\n * Handle filter change\n * @param {string|null} state\n */\n onFilterChange(state) {\n this.state.filters.state = state;\n this.loadData();\n }\n\n /**\n * Refresh data\n */\n async onRefresh() {\n await this.loadData();\n this.notification.add(\"Data refreshed\", { type: \"success\" });\n }\n}\n\n// Register as client action\nregistry.category(\"actions\").add(\"my_module.my_component\", MyComponent);\n```\n\n## Template Structure (OWL 3.x)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\u003ctemplates xml:space=\"preserve\">\n \u003ct t-name=\"my_module.MyComponent\">\n \u003cdiv t-ref=\"container\" class=\"o_my_component d-flex flex-column h-100\">\n \u003c!-- Header -->\n \u003cdiv class=\"o_cp_top p-3 border-bottom bg-light\">\n \u003cdiv class=\"d-flex justify-content-between align-items-center\">\n \u003ch4 class=\"mb-0\">Records\u003c/h4>\n \u003cdiv class=\"d-flex gap-2\">\n \u003cbutton class=\"btn btn-outline-secondary btn-sm\"\n t-on-click=\"onRefresh\"\n t-att-disabled=\"state.loading\">\n \u003ci t-attf-class=\"fa fa-refresh {{ state.loading ? 'fa-spin' : '' }}\"/>\n \u003c/button>\n \u003c/div>\n \u003c/div>\n\n \u003c!-- Filters -->\n \u003cdiv class=\"d-flex gap-3 mt-3\">\n \u003cdiv class=\"flex-grow-1\">\n \u003cinput t-ref=\"search\"\n type=\"search\"\n class=\"form-control form-control-sm\"\n placeholder=\"Search...\"\n t-att-value=\"state.filters.search\"\n t-on-input=\"onSearchInput\"/>\n \u003c/div>\n \u003cdiv class=\"btn-group btn-group-sm\">\n \u003cbutton t-attf-class=\"btn {{ !state.filters.state ? 'btn-primary' : 'btn-outline-primary' }}\"\n t-on-click=\"() => this.onFilterChange(null)\">All\u003c/button>\n \u003cbutton t-attf-class=\"btn {{ state.filters.state === 'draft' ? 'btn-primary' : 'btn-outline-primary' }}\"\n t-on-click=\"() => this.onFilterChange('draft')\">Draft\u003c/button>\n \u003cbutton t-attf-class=\"btn {{ state.filters.state === 'confirmed' ? 'btn-primary' : 'btn-outline-primary' }}\"\n t-on-click=\"() => this.onFilterChange('confirmed')\">Confirmed\u003c/button>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n\n \u003c!-- Content -->\n \u003cdiv class=\"flex-grow-1 overflow-auto p-3\">\n \u003c!-- Loading -->\n \u003ct t-if=\"state.loading\">\n \u003cdiv class=\"d-flex justify-content-center align-items-center h-100\">\n \u003cdiv class=\"spinner-border text-primary\" role=\"status\"/>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Error -->\n \u003ct t-elif=\"state.error\">\n \u003cdiv class=\"alert alert-danger m-3\">\n \u003cdiv class=\"d-flex align-items-center\">\n \u003ci class=\"fa fa-exclamation-circle fa-2x me-3\"/>\n \u003cdiv>\n \u003cstrong>Error loading data\u003c/strong>\n \u003cp class=\"mb-0 mt-1\" t-esc=\"state.error\"/>\n \u003c/div>\n \u003c/div>\n \u003cbutton class=\"btn btn-outline-danger btn-sm mt-3\"\n t-on-click=\"onRefresh\">\n Try Again\n \u003c/button>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Data -->\n \u003ct t-elif=\"state.data.length\">\n \u003cdiv class=\"row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3\">\n \u003ct t-foreach=\"state.data\" t-as=\"item\" t-key=\"item.id\">\n \u003cdiv class=\"col\">\n \u003cdiv t-attf-class=\"card h-100 {{ item.id === state.selectedId ? 'border-primary shadow' : '' }}\"\n t-on-click=\"() => this.onItemSelect(item)\"\n style=\"cursor: pointer;\">\n \u003cdiv class=\"card-body\">\n \u003cdiv class=\"d-flex justify-content-between align-items-start\">\n \u003ch5 class=\"card-title mb-1\" t-esc=\"item.name\"/>\n \u003cspan t-attf-class=\"badge bg-{{ item.state === 'done' ? 'success' : item.state === 'cancelled' ? 'danger' : 'secondary' }}\">\n \u003ct t-esc=\"item.state\"/>\n \u003c/span>\n \u003c/div>\n \u003cp class=\"card-text text-muted small\">\n Amount: \u003cstrong t-esc=\"item.amount\"/>\n \u003c/p>\n \u003ct t-if=\"item.partner_id\">\n \u003cp class=\"card-text small\">\n Partner: \u003ct t-esc=\"item.partner_id[1]\"/>\n \u003c/p>\n \u003c/t>\n \u003c/div>\n \u003cdiv class=\"card-footer bg-transparent\">\n \u003cbutton class=\"btn btn-sm btn-outline-primary w-100\"\n t-on-click.stop=\"() => this.openRecord(item.id)\">\n \u003ci class=\"fa fa-external-link me-1\"/>\n Open\n \u003c/button>\n \u003c/div>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/t>\n\n \u003c!-- Empty -->\n \u003ct t-else=\"\">\n \u003cdiv class=\"d-flex flex-column align-items-center justify-content-center h-100 text-muted\">\n \u003ci class=\"fa fa-inbox fa-4x mb-3\"/>\n \u003ch5>No Records Found\u003c/h5>\n \u003cp>Try adjusting your search or filters\u003c/p>\n \u003c/div>\n \u003c/t>\n \u003c/div>\n \u003c/div>\n \u003c/t>\n\u003c/templates>\n```\n\n## Field Widget (OWL 3.x)\n\n```javascript\n/** @odoo-module **/\n\nimport { Component } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\nimport { standardFieldProps } from \"@web/views/fields/standard_field_props\";\n\nexport class CustomFieldWidget extends Component {\n static template = \"my_module.CustomFieldWidget\";\n static props = {\n ...standardFieldProps,\n customOption: { type: String, optional: true },\n };\n\n /** @returns {string} */\n get formattedValue() {\n const value = this.props.record.data[this.props.name];\n if (!value) return \"\";\n // Custom formatting\n return `★ ${value}`;\n }\n\n /** @returns {boolean} */\n get isReadonly() {\n return this.props.readonly || !this.props.record.isEditable;\n }\n\n /**\n * @param {Event} ev\n */\n onChange(ev) {\n if (this.isReadonly) return;\n this.props.record.update({ [this.props.name]: ev.target.value });\n }\n}\n\nregistry.category(\"fields\").add(\"custom_widget\", {\n component: CustomFieldWidget,\n supportedTypes: [\"char\", \"text\"],\n extractProps: ({ attrs }) => ({\n customOption: attrs.custom_option,\n }),\n});\n```\n\n## v19 OWL 3.x Checklist\n\n- [ ] Use `/** @odoo-module **/` directive\n- [ ] Import from `@odoo/owl`\n- [ ] Add JSDoc type annotations\n- [ ] Define comprehensive `static props`\n- [ ] Use `static defaultProps` where needed\n- [ ] Handle cleanup in `onWillUnmount`\n- [ ] Use proper TypeScript-compatible patterns\n- [ ] Include in manifest assets\n\n## AI Agent Instructions (v19 OWL)\n\nWhen generating Odoo 19.0 OWL components:\n\n1. **USE** `/** @odoo-module **/`\n2. **IMPORT** from `@odoo/owl`\n3. **ADD** JSDoc type annotations\n4. **DEFINE** comprehensive `static props`\n5. **HANDLE** cleanup in `onWillUnmount`\n6. **USE** OWL 3.x enhanced patterns\n7. **DO NOT** use OWL 2.x deprecated patterns\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15050,"content_sha256":"9e68dc0e91a2fb282893b2f3ae04c8d1f22878b0ccad2786cf29bda6a74ed655"},{"filename":"skills/odoo-owl-components-all.md","content":"# Odoo OWL Components - Core Concepts (All Versions)\n\nThis document covers OWL component concepts that are consistent across all Odoo versions. For version-specific implementation details, see the version-specific files.\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ OWL VERSION MAPPING ║\n║ • Odoo 15.0: OWL 1.x ║\n║ • Odoo 16.0-18.0: OWL 2.x ║\n║ • Odoo 19.0+: OWL 3.x ║\n║ ALWAYS use the correct OWL version patterns for your target Odoo version! ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## OWL Component Architecture\n\n### Component Lifecycle\n\nAll OWL versions follow a similar lifecycle pattern:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ 1. Construction → 2. willStart → 3. Render → 4. Mounted │\n│ │\n│ Updates: willUpdateProps → willRender → Rendered → willPatch │\n│ │\n│ Cleanup: willUnmount → willDestroy │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n### Core Concepts\n\n1. **Components**: Self-contained UI units with template, logic, and state\n2. **Templates**: QWeb XML templates defining the UI structure\n3. **State**: Reactive data that triggers re-renders when changed\n4. **Props**: Data passed from parent to child components\n5. **Hooks**: Lifecycle callbacks and service access\n6. **Services**: Shared functionality (ORM, notifications, actions)\n\n## Component Types in Odoo\n\n### Client Actions\nFull-page components triggered by menu actions or programmatic navigation.\n\nUse cases:\n- Dashboards\n- Custom wizards\n- Standalone applications\n- Configuration pages\n\n### Field Widgets\nComponents that render and edit field values in forms and lists.\n\nUse cases:\n- Custom field displays\n- Complex input components\n- Field-specific interactions\n\n### Systray Items\nSmall components in the top navigation bar.\n\nUse cases:\n- Notifications\n- Quick actions\n- Status indicators\n- Menu dropdowns\n\n### View Components\nComponents that render entire view types (form, list, kanban).\n\nUse cases:\n- Custom view types\n- View extensions\n- Specialized displays\n\n### Dialog Components\nModal components for user interactions.\n\nUse cases:\n- Confirmations\n- Form popups\n- Wizards\n- Alerts\n\n## Odoo Services Reference\n\n### ORM Service\nAccess to database operations:\n- `searchRead`: Search and read records\n- `read`: Read specific records\n- `create`: Create new records\n- `write`: Update records\n- `unlink`: Delete records\n- `call`: Call model methods\n\n### Action Service\nNavigation and action execution:\n- `doAction`: Execute window/server/client actions\n- `switchView`: Change current view\n- Navigate between records\n\n### Notification Service\nUser feedback:\n- Success messages\n- Warning messages\n- Error messages\n- Sticky notifications\n- Button notifications\n\n### Dialog Service\nModal interactions:\n- Confirmation dialogs\n- Custom dialogs\n- Alert dialogs\n\n### RPC Service\nLow-level server communication:\n- Direct controller calls\n- Custom endpoints\n- File uploads\n\n## Template Concepts\n\n### QWeb Directives\n\n| Directive | Purpose |\n|-----------|---------|\n| `t-if` / `t-elif` / `t-else` | Conditional rendering |\n| `t-foreach` / `t-as` / `t-key` | Iteration |\n| `t-esc` | Text output (escaped) |\n| `t-out` | Raw HTML output |\n| `t-att-*` | Dynamic attribute |\n| `t-attf-*` | Formatted attribute |\n| `t-on-*` | Event handlers |\n| `t-ref` | Element reference |\n| `t-slot` | Slot definition |\n| `t-set-slot` | Slot content |\n| `t-component` | Dynamic component |\n| `t-props` | Props spreading |\n\n### Event Handling\n\n```xml\n\u003c!-- Click event -->\n\u003cbutton t-on-click=\"onButtonClick\">Click\u003c/button>\n\n\u003c!-- With parameter -->\n\u003cbutton t-on-click=\"() => this.onSelect(item.id)\">Select\u003c/button>\n\n\u003c!-- Prevent default -->\n\u003ca t-on-click.prevent=\"onLinkClick\">Link\u003c/a>\n\n\u003c!-- Stop propagation -->\n\u003cdiv t-on-click.stop=\"onDivClick\">Div\u003c/div>\n```\n\n### Conditional Classes\n\n```xml\n\u003c!-- Dynamic class -->\n\u003cdiv t-att-class=\"state.active ? 'active' : ''\"/>\n\n\u003c!-- Multiple conditions -->\n\u003cdiv t-attf-class=\"base-class {{ state.type }} {{ state.active ? 'active' : '' }}\"/>\n\n\u003c!-- Object syntax (OWL 2+) -->\n\u003cdiv t-att-class=\"{ active: state.active, hidden: !state.visible }\"/>\n```\n\n## Registry System\n\nOdoo uses registries to organize components:\n\n| Registry | Purpose |\n|----------|---------|\n| `actions` | Client action components |\n| `fields` | Field widget components |\n| `systray` | Systray item components |\n| `views` | View type components |\n| `services` | Service providers |\n| `main_components` | Root-level components |\n\n## State Management\n\n### Local State\nComponent-specific reactive state:\n- Use for UI-only state\n- Triggers re-render on change\n- Not shared between components\n\n### Props\nParent-to-child data flow:\n- Immutable in child\n- Triggers update on change\n- Define type validation\n\n### Services\nShared application state:\n- Global accessibility\n- Business logic encapsulation\n- Singleton pattern\n\n## Common Patterns\n\n### Loading States\n```\n1. Initialize with loading: true\n2. Fetch data in willStart/onMounted\n3. Set loading: false when complete\n4. Show spinner while loading\n5. Show content when loaded\n```\n\n### Error Handling\n```\n1. Wrap async operations in try/catch\n2. Update error state on failure\n3. Show error message to user\n4. Provide retry option\n5. Log errors for debugging\n```\n\n### Form Handling\n```\n1. Initialize form state\n2. Bind inputs to state\n3. Validate on change/blur\n4. Submit to server\n5. Handle response/errors\n```\n\n## Asset Organization\n\n### Static File Structure\n\n```\nmodule_name/\n└── static/\n └── src/\n ├── components/\n │ ├── component_name/\n │ │ ├── component_name.js\n │ │ ├── component_name.xml\n │ │ └── component_name.scss\n │ └── ...\n ├── fields/\n ├── views/\n └── ...\n```\n\n### Manifest Assets\n\n```python\n'assets': {\n 'web.assets_backend': [\n # JavaScript\n 'module_name/static/src/**/*.js',\n # Templates\n 'module_name/static/src/**/*.xml',\n # Styles\n 'module_name/static/src/**/*.scss',\n ],\n 'web.assets_frontend': [\n # Website components\n ],\n},\n```\n\n## Debugging Tips\n\n1. **Use browser devtools**: Inspect component tree\n2. **Console logging**: Log state changes\n3. **OWL devtools**: Browser extension for OWL debugging\n4. **Network tab**: Monitor RPC calls\n5. **Error boundaries**: Catch and display errors\n\n## Performance Best Practices\n\n1. **Minimize re-renders**: Use shouldUpdate when needed\n2. **Lazy loading**: Load data on demand\n3. **Pagination**: Limit data fetching\n4. **Debouncing**: Delay rapid user inputs\n5. **Memoization**: Cache computed values\n\n## Testing Components\n\n### Unit Testing\n- Test component logic in isolation\n- Mock services and dependencies\n- Verify state changes\n\n### Integration Testing\n- Test component interactions\n- Verify RPC calls\n- Check DOM updates\n\n### Tour Testing\n- End-to-end user flows\n- Automated UI testing\n- Regression testing\n\n---\n\n**Note**: This document covers concepts that apply to all versions. For version-specific syntax and patterns, refer to the appropriate version-specific file (e.g., `odoo-owl-components-18.md`).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8289,"content_sha256":"0529162bb3396916ca8b2b8048cee45c3db09634de942ec3461fccaafe1af20d"},{"filename":"skills/odoo-owl-components.md","content":"# Odoo OWL Components - Version Dispatcher\n\n## CRITICAL: VERSION-SPECIFIC REQUIREMENTS\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ║\n║ ⚠️ MANDATORY VERSION MATCHING ⚠️ ║\n║ ║\n║ OWL versions are COMPLETELY DIFFERENT between Odoo versions. ║\n║ Using wrong OWL patterns WILL cause JavaScript errors. ║\n║ ║\n║ - Odoo 14: No OWL (legacy JavaScript) ║\n║ - Odoo 15: OWL 1.x ║\n║ - Odoo 16-18: OWL 2.x ║\n║ - Odoo 19+: OWL 3.x ║\n║ ║\n║ BEFORE writing ANY OWL component, identify your Odoo version ║\n║ and load the corresponding file. This is NOT optional. ║\n║ ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Files\n\n| Target Version | OWL Version | File to Use |\n|----------------|-------------|-------------|\n| Odoo 14.0 | No OWL | `odoo-owl-components-14.md` (legacy JS) |\n| Odoo 15.0 | OWL 1.x | `odoo-owl-components-15.md` |\n| Odoo 16.0 | OWL 2.x | `odoo-owl-components-16.md` |\n| Odoo 17.0 | OWL 2.x | `odoo-owl-components-17.md` |\n| Odoo 18.0 | OWL 2.x | `odoo-owl-components-18.md` |\n| Odoo 19.0 | OWL 3.x | `odoo-owl-components-19.md` |\n| All versions | Concepts | `odoo-owl-components-all.md` |\n\n## Migration Guides\n\n| Migration Path | File |\n|----------------|------|\n| 14.0 → 15.0 | `odoo-owl-components-14-15.md` (Legacy to OWL 1.x) |\n| 15.0 → 16.0 | `odoo-owl-components-15-16.md` (OWL 1.x to 2.x) |\n| 16.0 → 17.0 | `odoo-owl-components-16-17.md` (OWL 2.x refinements) |\n| 17.0 → 18.0 | `odoo-owl-components-17-18.md` (OWL 2.x refinements) |\n| 18.0 → 19.0 | `odoo-owl-components-18-19.md` (OWL 2.x to 3.x) |\n\n## Quick Reference: OWL Changes by Version\n\n### Odoo 14 (No OWL)\n```javascript\n// Legacy jQuery-based\nodoo.define('module.widget', function (require) {\n var Widget = require('web.Widget');\n var MyWidget = Widget.extend({\n template: 'MyTemplate',\n start: function() {\n return this._super.apply(this, arguments);\n },\n });\n return MyWidget;\n});\n```\n\n### Odoo 15 (OWL 1.x)\n```javascript\nodoo.define('module.Component', function (require) {\n const { Component } = owl;\n const { useState } = owl.hooks;\n\n class MyComponent extends Component {\n setup() {\n this.state = useState({ count: 0 });\n }\n }\n MyComponent.template = 'module.MyComponent';\n return MyComponent;\n});\n```\n\n### Odoo 16-18 (OWL 2.x)\n```javascript\n/** @odoo-module **/\nimport { Component, useState } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"module.MyComponent\";\n setup() {\n this.state = useState({ count: 0 });\n }\n}\nregistry.category(\"actions\").add(\"my_action\", MyComponent);\n```\n\n### Odoo 19+ (OWL 3.x)\n```javascript\n/** @odoo-module **/\nimport { Component, useState } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n static template = \"module.MyComponent\";\n static props = {\n // Explicit prop types required\n };\n setup() {\n this.state = useState({ count: 0 });\n }\n}\n```\n\n## Key Differences\n\n| Feature | OWL 1.x | OWL 2.x | OWL 3.x |\n|---------|---------|---------|---------|\n| Module system | `odoo.define` | ES modules | ES modules |\n| Import syntax | `require()` | `import` | `import` |\n| Hooks | `owl.hooks` | Direct import | Direct import |\n| Template | Property | Static property | Static property |\n| Props | Implicit | Optional | Required |\n\n## OWL Detection in Existing Code\n\n| Indicator | Version |\n|-----------|---------|\n| `odoo.define()` | 14 (legacy) or 15 (OWL 1.x) |\n| `require('web.Widget')` | 14 (legacy) |\n| `const { Component } = owl` | 15 (OWL 1.x) |\n| `/** @odoo-module **/` | 16+ (OWL 2.x+) |\n| `import { Component }` | 16+ (OWL 2.x+) |\n| `static props = {}` required | 19+ (OWL 3.x) |\n\n## Common OWL Patterns\n\n### Registries\n- `actions` - Client actions\n- `fields` - Field widgets\n- `views` - View types\n- `systray` - Systray items\n- `main_components` - Main UI components\n\n### Services\n- `orm` - Database operations\n- `action` - Navigation\n- `notification` - User notifications\n- `dialog` - Modal dialogs\n- `user` - Current user info\n- `company` - Current company\n\n---\n\n**REMINDER**: OWL versions are NOT backwards compatible. Always verify your Odoo version before implementing OWL components.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5485,"content_sha256":"de46b54cfd870a6a2411fd3fe36001bc3238a0ee778f49c7c4f670e41b8af74d"},{"filename":"skills/odoo-performance-guide.md","content":"# Odoo Performance Optimization Guide\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ PERFORMANCE OPTIMIZATION GUIDE ║\n║ Best practices for high-performance Odoo modules across all versions ║\n║ Critical for modules marked as \"performance_critical\": true ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Performance Principles\n\n1. **Minimize database queries** - Batch operations, prefetch, avoid N+1\n2. **Use stored computed fields** - When values don't change frequently\n3. **Index search fields** - For frequently filtered/searched fields\n4. **Avoid sudo() in loops** - Cache environment when needed\n5. **Use SQL for bulk operations** - When ORM overhead is prohibitive\n\n## Database Query Optimization\n\n### N+1 Query Problem\n\n```python\n# BAD: N+1 queries (1 for orders, N for partners)\nfor order in orders:\n print(order.partner_id.name) # Query per iteration\n\n# GOOD: Prefetch in single query\norders = self.env['sale.order'].search([])\norders.mapped('partner_id') # Prefetch all partners\nfor order in orders:\n print(order.partner_id.name) # No additional queries\n\n# BETTER: Use search_read when you only need specific fields\ndata = self.env['sale.order'].search_read(\n [('state', '=', 'sale')],\n ['name', 'partner_id', 'amount_total'],\n limit=100,\n)\n```\n\n### Batch Operations\n\n```python\n# BAD: Individual creates\nfor data in data_list:\n self.env['my.model'].create(data)\n\n# GOOD: Batch create (v15+)\nself.env['my.model'].create(data_list)\n\n# BAD: Individual writes\nfor record in records:\n record.write({'state': 'done'})\n\n# GOOD: Batch write\nrecords.write({'state': 'done'})\n\n# BAD: Individual unlinks\nfor record in records:\n record.unlink()\n\n# GOOD: Batch unlink\nrecords.unlink()\n```\n\n### Efficient Searching\n\n```python\n# BAD: Search then count\ncount = len(self.env['my.model'].search([('state', '=', 'draft')]))\n\n# GOOD: Use search_count\ncount = self.env['my.model'].search_count([('state', '=', 'draft')])\n\n# BAD: Search all then filter in Python\nrecords = self.env['my.model'].search([])\ndraft_records = [r for r in records if r.state == 'draft']\n\n# GOOD: Filter in domain\ndraft_records = self.env['my.model'].search([('state', '=', 'draft')])\n\n# BAD: Multiple searches for related data\npartners = self.env['res.partner'].search([('customer_rank', '>', 0)])\norders = self.env['sale.order'].search([('partner_id', 'in', partners.ids)])\n\n# GOOD: Single search with join\norders = self.env['sale.order'].search([\n ('partner_id.customer_rank', '>', 0)\n])\n```\n\n## Field Indexing\n\n### When to Index\n\n```python\n# Index fields that are:\n# 1. Frequently used in search domains\n# 2. Used in record rules\n# 3. Used in ORDER BY clauses\n\n# Standard B-tree index\nstate = fields.Selection([...], index=True)\ncompany_id = fields.Many2one('res.company', index=True)\ndate = fields.Date(index=True)\n\n# Trigram index for ILIKE searches (v16+)\nname = fields.Char(index='trigram') # For pattern searches\n\n# Index types (v16+)\ncode = fields.Char(index='btree_not_null') # Exclude NULL values\n```\n\n### Index Guidelines\n\n| Field Type | When to Index |\n|------------|---------------|\n| Selection | If used in filters/domains |\n| Many2one | If used in search or rules |\n| Date/Datetime | If used in date range queries |\n| Char | If used with `=` operator |\n| Char (pattern) | Use `index='trigram'` for ILIKE |\n\n## Computed Fields\n\n### Stored vs Non-Stored\n\n```python\n# STORED: Computed once, updated on dependency change\n# Use when: Value rarely changes, frequently read\ntotal = fields.Float(\n compute='_compute_total',\n store=True, # Stored in database\n)\n\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n\n# NON-STORED: Computed on every read\n# Use when: Value changes frequently, rarely displayed\ndays_until_deadline = fields.Integer(\n compute='_compute_days_until_deadline',\n store=False, # Computed on read\n)\n\ndef _compute_days_until_deadline(self):\n today = fields.Date.today()\n for record in self:\n if record.deadline:\n record.days_until_deadline = (record.deadline - today).days\n else:\n record.days_until_deadline = 0\n```\n\n### Optimizing Computed Fields\n\n```python\n# BAD: Individual queries in compute\[email protected]('partner_id')\ndef _compute_partner_orders(self):\n for record in self:\n record.order_count = self.env['sale.order'].search_count([\n ('partner_id', '=', record.partner_id.id)\n ])\n\n# GOOD: Batch query with read_group\[email protected]('partner_id')\ndef _compute_partner_orders(self):\n if not self:\n return\n\n partner_ids = self.mapped('partner_id').ids\n order_data = self.env['sale.order'].read_group(\n [('partner_id', 'in', partner_ids)],\n ['partner_id'],\n ['partner_id'],\n )\n counts = {d['partner_id'][0]: d['partner_id_count'] for d in order_data}\n\n for record in self:\n record.order_count = counts.get(record.partner_id.id, 0)\n```\n\n## SQL Optimization\n\n### When to Use Raw SQL\n\nUse raw SQL for:\n- Bulk updates/deletes\n- Complex aggregations\n- Performance-critical read operations\n- Operations on millions of records\n\n### Version-Specific SQL Patterns\n\n```python\n# v14-v17: String SQL (works but deprecated in v18+)\nself.env.cr.execute(\"\"\"\n UPDATE sale_order\n SET state = %s\n WHERE id IN %s\n\"\"\", ('done', tuple(order_ids)))\n\n# v18+: SQL() builder (REQUIRED in v19)\nfrom odoo.tools import SQL\n\nself.env.cr.execute(SQL(\n \"\"\"\n UPDATE sale_order\n SET state = %s\n WHERE id IN %s\n \"\"\",\n 'done', tuple(order_ids)\n))\n\n# Complex query with SQL builder\nself.env.cr.execute(SQL(\n \"\"\"\n SELECT partner_id, COUNT(*) as order_count, SUM(amount_total) as total\n FROM sale_order\n WHERE state = %s AND company_id = %s\n GROUP BY partner_id\n HAVING SUM(amount_total) > %s\n ORDER BY total DESC\n LIMIT %s\n \"\"\",\n 'sale', self.env.company.id, 10000, 100\n))\nresults = self.env.cr.dictfetchall()\n```\n\n### Cache Invalidation After SQL\n\n```python\n# After raw SQL updates, invalidate ORM cache\nself.env.cr.execute(SQL(...))\n\n# Invalidate specific records\nself.browse(updated_ids).invalidate_recordset()\n\n# Or invalidate entire model cache\nself.invalidate_model()\n```\n\n## Prefetching\n\n### Understanding Prefetch\n\n```python\n# Odoo automatically prefetches in batches of 1000\n# When you access a field on one record, it fetches for all in recordset\n\norders = self.env['sale.order'].search([], limit=500)\n\n# First access triggers prefetch for all 500 orders\nfor order in orders:\n print(order.name) # First iteration: 1 query for all names\n print(order.partner_id.name) # First iteration: 1 query for all partners\n\n# Manual prefetch for related records\norders.mapped('order_line_ids.product_id') # Prefetch all products\n```\n\n### Prefetch Groups\n\n```python\n# Efficient related record access\ndef process_orders(self, orders):\n # Prefetch all related data upfront\n orders.mapped('partner_id')\n orders.mapped('order_line_ids')\n orders.mapped('order_line_ids.product_id')\n\n # Now process without additional queries\n for order in orders:\n for line in order.order_line_ids:\n print(line.product_id.name) # No additional queries\n```\n\n## ORM Performance Tips\n\n### Use filtered() Efficiently\n\n```python\n# filtered() is in-memory, not a database query\n# Good for small recordsets already loaded\nconfirmed_orders = orders.filtered(lambda o: o.state == 'sale')\n\n# For large datasets, use search() instead\nconfirmed_orders = self.env['sale.order'].search([\n ('id', 'in', orders.ids),\n ('state', '=', 'sale'),\n])\n```\n\n### Use mapped() for Collections\n\n```python\n# Get all partner IDs efficiently\npartner_ids = orders.mapped('partner_id.id')\n\n# Get unique values\npartner_ids = orders.mapped('partner_id').ids\n\n# Sum values\ntotal = sum(orders.mapped('amount_total'))\n\n# Or use Python's sum with generator\ntotal = sum(o.amount_total for o in orders)\n```\n\n### Avoid Repeated Environment Access\n\n```python\n# BAD: Repeated env access in loop\nfor partner_id in partner_ids:\n partner = self.env['res.partner'].browse(partner_id)\n print(partner.name)\n\n# GOOD: Single browse for all\npartners = self.env['res.partner'].browse(partner_ids)\nfor partner in partners:\n print(partner.name)\n```\n\n## Cron Job Optimization\n\n```python\[email protected]\ndef _cron_process_large_dataset(self):\n \"\"\"Process records in batches to avoid memory issues\"\"\"\n batch_size = 1000\n offset = 0\n\n while True:\n records = self.search(\n [('state', '=', 'pending')],\n limit=batch_size,\n offset=offset,\n )\n\n if not records:\n break\n\n for record in records:\n try:\n record._process_single()\n except Exception as e:\n _logger.error(\"Failed to process %s: %s\", record.id, e)\n\n # Commit batch and clear cache\n self.env.cr.commit()\n self.env.invalidate_all()\n\n offset += batch_size\n```\n\n## Memory Optimization\n\n### Clear Cache in Long Operations\n\n```python\ndef process_many_records(self):\n \"\"\"Process large dataset with memory management\"\"\"\n count = 0\n batch_size = 500\n\n for record in self:\n record._do_processing()\n count += 1\n\n # Clear cache periodically\n if count % batch_size == 0:\n self.env.invalidate_all()\n self.env.cr.commit() # Optional: commit in batches\n```\n\n### Use Generators for Large Data\n\n```python\n# BAD: Load all into memory\nall_data = self.env['large.model'].search_read([], ['name', 'value'])\nfor item in all_data:\n process(item)\n\n# GOOD: Process in batches\ndef _iter_records(self, domain, batch_size=1000):\n offset = 0\n while True:\n records = self.search(domain, limit=batch_size, offset=offset)\n if not records:\n break\n yield from records\n offset += batch_size\n\nfor record in self._iter_records([('state', '=', 'pending')]):\n process(record)\n```\n\n## Version-Specific Optimizations\n\n### v16+ Command Class Performance\n\n```python\n# Command class is slightly more efficient than tuples\nfrom odoo.fields import Command\n\n# Efficient batch line creation\nself.write({\n 'line_ids': [Command.create(vals) for vals in vals_list]\n})\n```\n\n### v18+ Multi-Company Optimization\n\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _check_company_auto = True # Automatic company checking\n\n company_id = fields.Many2one('res.company', index=True)\n partner_id = fields.Many2one('res.partner', check_company=True)\n\n # Company-aware search (v18 pattern)\n def _search_company_records(self):\n # Uses allowed_company_ids automatically\n return self.search([('state', '=', 'active')])\n```\n\n## Performance Monitoring\n\n### Using Logging\n\n```python\nimport logging\nimport time\n\n_logger = logging.getLogger(__name__)\n\ndef _performance_critical_method(self):\n start_time = time.time()\n\n # Your code here\n\n elapsed = time.time() - start_time\n _logger.info(\"Method completed in %.2f seconds\", elapsed)\n\n if elapsed > 5.0:\n _logger.warning(\"Slow operation detected: %.2f seconds\", elapsed)\n```\n\n### Query Count Debugging\n\n```python\n# In development, enable query logging\n# In odoo.conf: log_level = debug_sql\n\n# Or use the profiler\nfrom odoo.tools.profiler import profile\n\n@profile\ndef slow_method(self):\n # This will log timing and query count\n pass\n```\n\n## Performance Checklist\n\n### For New Modules\n- [ ] Index all fields used in search domains\n- [ ] Use stored computed fields for frequently read values\n- [ ] Implement batch operations (`@api.model_create_multi`)\n- [ ] Avoid N+1 patterns in computed fields\n- [ ] Use `search_count()` instead of `len(search())`\n- [ ] Prefetch related records before loops\n\n### For Performance-Critical Code\n- [ ] Profile before optimizing\n- [ ] Consider raw SQL for bulk operations\n- [ ] Use read_group for aggregations\n- [ ] Implement batch processing for large datasets\n- [ ] Clear cache in long-running operations\n- [ ] Use generators for memory efficiency\n\n### For Cron Jobs\n- [ ] Process in batches\n- [ ] Commit periodically\n- [ ] Handle errors gracefully (don't stop entire job)\n- [ ] Clear cache between batches\n- [ ] Log performance metrics\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":12782,"content_sha256":"d86db795e0b3a2116da84c31137608a57d3f40ed125949547421ca5dbbe60692"},{"filename":"skills/odoo-security-guide-14-15.md","content":"# Odoo Security Guide - Migration 14.0 → 15.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: ODOO 14.0 → 15.0 SECURITY ║\n║ Use this guide when upgrading security code from v14 to v15. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview of Security Changes\n\n| Component | v14 | v15 | Migration Required |\n|-----------|-----|-----|-------------------|\n| @api.multi | Deprecated | **Removed** | **REQUIRED** |\n| Field tracking | `track_visibility` | `tracking` | **REQUIRED** |\n| Python | 3.6+ | 3.8+ | Check compatibility |\n| Chatter widgets | Legacy | Simplified | Recommended |\n\n## Breaking Changes\n\n### 1. @api.multi Decorator REMOVED\n\n**v14 (deprecated but works):**\n```python\[email protected]\ndef action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n```\n\n**v15 (no decorator needed):**\n```python\ndef action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n```\n\n### 2. track_visibility → tracking\n\n**v14:**\n```python\nname = fields.Char(string='Name', track_visibility='onchange')\nstate = fields.Selection([...], track_visibility='always')\npartner_id = fields.Many2one('res.partner', track_visibility='onchange')\n```\n\n**v15:**\n```python\nname = fields.Char(string='Name', tracking=True)\nstate = fields.Selection([...], tracking=True)\npartner_id = fields.Many2one('res.partner', tracking=True)\n```\n\n**Note:** `tracking=True` replaces both `track_visibility='onchange'` and `track_visibility='always'`. The distinction is no longer needed.\n\n## Migration Script\n\n```python\nimport re\n\ndef migrate_track_visibility(python_content):\n \"\"\"Convert track_visibility to tracking.\"\"\"\n # Replace track_visibility='onchange' with tracking=True\n content = re.sub(\n r\"track_visibility=['\\\"]onchange['\\\"]\",\n \"tracking=True\",\n python_content\n )\n # Replace track_visibility='always' with tracking=True\n content = re.sub(\n r\"track_visibility=['\\\"]always['\\\"]\",\n \"tracking=True\",\n content\n )\n return content\n\ndef remove_api_multi(python_content):\n \"\"\"Remove @api.multi decorators.\"\"\"\n # Remove @api.multi line\n content = re.sub(r\"^\\s*@api\\.multi\\s*\\n\", \"\", python_content, flags=re.MULTILINE)\n return content\n```\n\n## Detailed Migration Examples\n\n### Model with Tracking\n\n**v14:**\n```python\nfrom odoo import api, fields, models\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(required=True, track_visibility='onchange')\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ], default='draft', track_visibility='always')\n partner_id = fields.Many2one('res.partner', track_visibility='onchange')\n amount = fields.Float(track_visibility='onchange')\n\n @api.multi\n def action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n\n @api.multi\n def action_send_email(self):\n for record in self:\n record._send_notification()\n```\n\n**v15:**\n```python\nfrom odoo import api, fields, models\n\nclass MyModel(models.Model):\n _name = 'my.model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(required=True, tracking=True)\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ], default='draft', tracking=True)\n partner_id = fields.Many2one('res.partner', tracking=True)\n amount = fields.Float(tracking=True)\n\n def action_confirm(self):\n for record in self:\n record.state = 'confirmed'\n\n def action_send_email(self):\n for record in self:\n record._send_notification()\n```\n\n### Chatter Widget Updates\n\n**v14:**\n```xml\n\u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\" widget=\"mail_followers\"/>\n \u003cfield name=\"activity_ids\" widget=\"mail_activity\"/>\n \u003cfield name=\"message_ids\" widget=\"mail_thread\"/>\n\u003c/div>\n```\n\n**v15:**\n```xml\n\u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n\u003c/div>\n```\n\n**Note:** The widget attributes are optional in v15 as Odoo auto-detects the correct widget.\n\n## No Change Required\n\n### Security Groups\n```xml\n\u003c!-- Same in v14 and v15 -->\n\u003crecord id=\"group_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('other_group'))]\"/>\n\u003c/record>\n```\n\n### Access Rights\n```csv\n# Same format in v14 and v15\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n```\n\n### Record Rules\n```xml\n\u003c!-- Same syntax in v14 and v15 -->\n\u003crecord id=\"rule_company\" model=\"ir.rule\">\n \u003cfield name=\"domain_force\">[('company_id', 'in', company_ids)]\u003c/field>\n\u003c/record>\n```\n\n### View attrs Syntax\n```xml\n\u003c!-- Same in v14 and v15 -->\n\u003cfield name=\"notes\" attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n```\n\n### Field Groups\n```python\n# Same in v14 and v15\nnotes = fields.Text(groups='my_module.group_manager')\n```\n\n## Migration Checklist\n\n- [ ] **CRITICAL**: Remove ALL `@api.multi` decorators\n- [ ] **CRITICAL**: Replace `track_visibility` with `tracking=True`\n- [ ] Update Python version to 3.8+\n- [ ] Update chatter widgets (optional but recommended)\n- [ ] Test all methods that had `@api.multi`\n- [ ] Verify tracking works on mail.thread models\n\n## Common Mistakes\n\n### 1. Leaving @api.multi\n\n**Wrong (will cause errors in v15):**\n```python\[email protected]\ndef my_method(self):\n pass\n```\n\n**Correct:**\n```python\ndef my_method(self):\n pass\n```\n\n### 2. Using Old track_visibility Values\n\n**Wrong:**\n```python\n# These won't work in v15\nname = fields.Char(track_visibility='onchange')\nstate = fields.Selection([...], track_visibility='always')\n```\n\n**Correct:**\n```python\nname = fields.Char(tracking=True)\nstate = fields.Selection([...], tracking=True)\n```\n\n## Testing After Migration\n\n```python\ndef test_tracking(self):\n \"\"\"Test that field tracking works after migration.\"\"\"\n record = self.env['my.model'].create({'name': 'Test'})\n\n # Update tracked field\n record.write({'name': 'Updated'})\n\n # Check that message was created\n messages = record.message_ids.filtered(\n lambda m: m.tracking_value_ids\n )\n self.assertTrue(messages, \"Tracking message should be created\")\n```\n\n## GitHub Reference\n\n- `odoo/api.py` - Decorator changes\n- `odoo/models.py` - Field tracking implementation\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6885,"content_sha256":"07919e8b9fb733035e0598cda1a5348279edb003e47ce68abd4c4b22f3de3dd0"},{"filename":"skills/odoo-security-guide-14.md","content":"# Odoo Security Guide - Version 14.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 14.0 SECURITY PATTERNS ║\n║ This file contains ONLY Odoo 14.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n║ NOTE: Odoo 14.0 is LEGACY - consider upgrading to a supported version. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 14.0 Requirements\n\n- **Python**: 3.6+ required\n- **Key Features**: `@api.multi` deprecated, `attrs` in views, legacy widget system\n\n## Security Groups (v14 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Access Rights (v14)\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_custom_model_user,custom.model.user,model_custom_model,custom_module.group_custom_user,1,1,1,0\naccess_custom_model_manager,custom.model.manager,model_custom_model,custom_module.group_custom_manager,1,1,1,1\n```\n\n## Record Rules (v14 Syntax)\n\n```xml\n\u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n\u003c/record>\n\n\u003crecord id=\"rule_custom_model_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n\u003c/record>\n```\n\n## Model Security (v14 Patterns)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, _\nfrom odoo.exceptions import AccessError, UserError, ValidationError\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(\n string='Name',\n required=True,\n track_visibility='onchange', # v14: track_visibility (deprecated in v15+)\n )\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n 'res.partner',\n string='Partner',\n )\n user_id = fields.Many2one(\n 'res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n track_visibility='onchange',\n )\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', track_visibility='onchange')\n\n # v14: Single record create (vals, not vals_list)\n @api.model\n def create(self, vals):\n return super(SecureModel, self).create(vals)\n\n def write(self, vals):\n return super(SecureModel, self).write(vals)\n\n # v14: @api.multi is deprecated but may still appear\n # Do NOT use @api.multi in new v14 code\n def action_sensitive_operation(self):\n \"\"\"Check permissions before sensitive operations.\"\"\"\n if not self.env.user.has_group('custom_module.group_manager'):\n raise AccessError(_(\"Only managers can perform this action.\"))\n for record in self:\n record._do_sensitive_work()\n```\n\n## View Security (v14 Syntax - attrs)\n\n### Using attrs for Visibility\n\n```xml\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n\n \u003c!-- v14: Use attrs for conditional visibility -->\n \u003cfield name=\"internal_notes\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n\n \u003c!-- v14: Combine attrs with groups -->\n \u003cfield name=\"secret_field\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"\n groups=\"custom_module.group_manager\"/>\n\n \u003c!-- v14: Multiple conditions in attrs -->\n \u003cfield name=\"amount\"\n attrs=\"{'readonly': [('state', '!=', 'draft')], 'required': [('type', '=', 'invoice')]}\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Button Security (v14)\n\n```xml\n\u003cbutton name=\"action_approve\"\n string=\"Approve\"\n type=\"object\"\n groups=\"custom_module.group_manager\"\n attrs=\"{'invisible': [('state', '!=', 'pending')]}\"/>\n\n\u003cbutton name=\"action_confirm\"\n string=\"Confirm\"\n type=\"object\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n```\n\n### Complete Form Example (v14)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"custom_model_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">custom.model.form\u003c/field>\n \u003cfield name=\"model\">custom.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"Custom Model\">\n \u003cheader>\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cbutton name=\"action_approve\" string=\"Approve\" type=\"object\"\n groups=\"custom_module.group_manager\"\n attrs=\"{'invisible': [('state', '!=', 'pending')]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003c/group>\n \u003cgroup string=\"Internal\" groups=\"custom_module.group_manager\">\n \u003cfield name=\"internal_notes\"/>\n \u003c/group>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\" widget=\"mail_followers\"/>\n \u003cfield name=\"activity_ids\" widget=\"mail_activity\"/>\n \u003cfield name=\"message_ids\" widget=\"mail_thread\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Field-Level Security (v14)\n\n```python\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n name = fields.Char(required=True, track_visibility='onchange')\n\n internal_notes = fields.Text(\n string='Internal Notes',\n groups='custom_module.group_manager',\n )\n\n cost_price = fields.Float(\n string='Cost Price',\n groups='account.group_account_user',\n )\n```\n\n## v14 Security Checklist\n\n- [ ] All models have `ir.model.access.csv` entries\n- [ ] Use `attrs` for view visibility conditions\n- [ ] Use `track_visibility` for field tracking\n- [ ] Single record create method signature\n- [ ] Do NOT use `@api.multi` (deprecated)\n- [ ] Use legacy chatter widgets\n\n## Key v14 Patterns\n\n| Feature | v14 Pattern |\n|---------|-------------|\n| Field tracking | `track_visibility='onchange'` |\n| View visibility | `attrs=\"{'invisible': [...]}\"` |\n| Create method | `def create(self, vals):` (single dict) |\n| Chatter | `widget=\"mail_followers\"`, `widget=\"mail_thread\"` |\n\n## AI Agent Instructions (v14)\n\nWhen generating Odoo 14.0 security code:\n\n1. **Use** `attrs` for view visibility: `attrs=\"{'invisible': [...]}\"`\n2. **Use** `track_visibility='onchange'` for field tracking\n3. **Use** single record create: `def create(self, vals):`\n4. **Do NOT** use `@api.multi` (deprecated)\n5. **Use** legacy chatter widgets: `widget=\"mail_followers\"`\n6. **Note**: v14 is legacy - recommend upgrading\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9139,"content_sha256":"6053df1d58662740dee5a80bf4a8116e67869245a096bd115a4cb70f4ab01ba4"},{"filename":"skills/odoo-security-guide-15-16.md","content":"# Odoo Security Guide - Migration 15.0 → 16.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: ODOO 15.0 → 16.0 SECURITY ║\n║ Use this guide when upgrading security code from v15 to v16. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview of Security Changes\n\n| Component | v15 | v16 | Migration Required |\n|-----------|-----|-----|-------------------|\n| x2many operations | Tuple syntax | `Command` class | Recommended |\n| View visibility | `attrs` | `attrs` (deprecated) | Prepare for v17 |\n| OWL | 1.x | 2.x | For OWL components |\n| @api.model_create_multi | Optional | Recommended | Recommended |\n\n## Key Changes\n\n### 1. Command Class for x2many Operations\n\nThe `Command` class provides a cleaner API for x2many field operations with security implications.\n\n**v15 (tuple syntax):**\n```python\ndef action_update_lines(self):\n self.write({\n 'line_ids': [\n (0, 0, {'name': 'New Line'}), # Create\n (1, line_id, {'name': 'Updated'}), # Update\n (2, line_id, 0), # Delete\n (3, line_id, 0), # Unlink\n (4, line_id, 0), # Link\n (5, 0, 0), # Clear\n (6, 0, [id1, id2]), # Set\n ]\n })\n```\n\n**v16 (Command class):**\n```python\nfrom odoo import Command\n\ndef action_update_lines(self):\n self.write({\n 'line_ids': [\n Command.create({'name': 'New Line'}),\n Command.update(line_id, {'name': 'Updated'}),\n Command.delete(line_id),\n Command.unlink(line_id),\n Command.link(line_id),\n Command.clear(),\n Command.set([id1, id2]),\n ]\n })\n```\n\n### 2. Prepare for attrs Deprecation\n\nWhile `attrs` still works in v16, it's deprecated. Start migrating to direct attributes.\n\n**v15/v16 (still works but deprecated):**\n```xml\n\u003cfield name=\"notes\" attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n```\n\n**v16 (recommended, ready for v17):**\n```xml\n\u003cfield name=\"notes\" invisible=\"state == 'draft'\"/>\n```\n\n### 3. @api.model_create_multi Recommendation\n\n**v15:**\n```python\[email protected]\ndef create(self, vals):\n return super().create(vals)\n```\n\n**v16 (recommended):**\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n return super().create(vals_list)\n```\n\n## Command Class Reference\n\n```python\nfrom odoo import Command\n\n# Create a new related record\nCommand.create(values) # (0, 0, values)\n\n# Update an existing related record\nCommand.update(id, values) # (1, id, values)\n\n# Delete a related record (removes from DB)\nCommand.delete(id) # (2, id, 0)\n\n# Unlink a related record (removes relation only)\nCommand.unlink(id) # (3, id, 0)\n\n# Link an existing record\nCommand.link(id) # (4, id, 0)\n\n# Clear all related records (unlink all)\nCommand.clear() # (5, 0, 0)\n\n# Replace all with specific records\nCommand.set(ids) # (6, 0, ids)\n```\n\n## Security Implications of Command Class\n\nThe `Command` class improves security by:\n1. Making operations more explicit and readable\n2. Reducing errors from wrong tuple indices\n3. Better type checking\n\n**Example: Secure Line Creation**\n```python\ndef action_create_secure_lines(self):\n \"\"\"Create lines with proper security context.\"\"\"\n self.ensure_one()\n\n # Check permission first\n if not self.env.user.has_group('my_module.group_manager'):\n raise AccessError(_(\"Only managers can create lines.\"))\n\n # Use Command for clear, secure operations\n new_lines = [\n Command.create({\n 'name': 'Line 1',\n 'amount': 100.0,\n 'user_id': self.env.user.id, # Track who created\n }),\n Command.create({\n 'name': 'Line 2',\n 'amount': 200.0,\n 'user_id': self.env.user.id,\n }),\n ]\n self.write({'line_ids': new_lines})\n```\n\n## OWL 2.x Security Changes\n\n### Component Registration\n\n**v15 (OWL 1.x):**\n```javascript\nodoo.define('my_module.MyComponent', function (require) {\n const { Component } = owl;\n const { registry } = require('@web/core/registry');\n\n class MyComponent extends Component {\n // ...\n }\n\n registry.category('actions').add('my_action', MyComponent);\n});\n```\n\n**v16 (OWL 2.x):**\n```javascript\n/** @odoo-module **/\n\nimport { Component } from \"@odoo/owl\";\nimport { registry } from \"@web/core/registry\";\n\nexport class MyComponent extends Component {\n // ...\n}\n\nregistry.category('actions').add('my_action', MyComponent);\n```\n\n## No Change Required\n\n### Security Groups\n```xml\n\u003c!-- Same in v15 and v16 -->\n\u003crecord id=\"group_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n\u003c/record>\n```\n\n### Access Rights\n```csv\n# Same format\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n```\n\n### Record Rules\n```xml\n\u003c!-- Same syntax -->\n\u003crecord id=\"rule_company\" model=\"ir.rule\">\n \u003cfield name=\"domain_force\">[('company_id', 'in', company_ids)]\u003c/field>\n\u003c/record>\n```\n\n### Field Groups\n```python\n# Same\nnotes = fields.Text(groups='my_module.group_manager')\n```\n\n## Migration Checklist\n\n- [ ] Import `Command` from `odoo` and use for x2many operations\n- [ ] Start migrating `attrs` to direct attributes (preparation for v17)\n- [ ] Update to `@api.model_create_multi` for create methods\n- [ ] Migrate OWL 1.x components to OWL 2.x\n- [ ] Test x2many operations with new Command syntax\n- [ ] Update JavaScript module syntax\n\n## Dual Compatibility (v15/v16)\n\nIf you need to support both versions:\n\n```python\ntry:\n from odoo import Command\nexcept ImportError:\n # Fallback for v15\n class Command:\n @staticmethod\n def create(values):\n return (0, 0, values)\n @staticmethod\n def update(id, values):\n return (1, id, values)\n @staticmethod\n def delete(id):\n return (2, id, 0)\n @staticmethod\n def unlink(id):\n return (3, id, 0)\n @staticmethod\n def link(id):\n return (4, id, 0)\n @staticmethod\n def clear():\n return (5, 0, 0)\n @staticmethod\n def set(ids):\n return (6, 0, ids)\n```\n\n## GitHub Reference\n\n- `odoo/__init__.py` - `Command` class export\n- `odoo/fields.py` - x2many field handling\n- `addons/web/static/src/` - OWL 2.x components\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":6791,"content_sha256":"83154b504ab4e3bf9a546a656a3d687d2184bb4adfae6289ee9c774737db0967"},{"filename":"skills/odoo-security-guide-15.md","content":"# Odoo Security Guide - Version 15.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 15.0 SECURITY PATTERNS ║\n║ This file contains ONLY Odoo 15.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n║ NOTE: Odoo 15.0 is LEGACY - consider upgrading to a supported version. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 15.0 Requirements\n\n- **Python**: 3.8+ required\n- **Key Changes**: `@api.multi` removed, `tracking` replaces `track_visibility`, OWL 1.x introduced\n\n## Security Groups (v15 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Access Rights (v15)\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_custom_model_user,custom.model.user,model_custom_model,custom_module.group_custom_user,1,1,1,0\naccess_custom_model_manager,custom.model.manager,model_custom_model,custom_module.group_custom_manager,1,1,1,1\n```\n\n## Record Rules (v15 Syntax)\n\n```xml\n\u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n\u003c/record>\n\n\u003crecord id=\"rule_custom_model_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n\u003c/record>\n```\n\n## Model Security (v15 Patterns)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, _\nfrom odoo.exceptions import AccessError, UserError, ValidationError\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(\n string='Name',\n required=True,\n tracking=True, # v15: tracking replaces track_visibility\n )\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n 'res.partner',\n string='Partner',\n )\n user_id = fields.Many2one(\n 'res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n )\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', tracking=True)\n\n # v15: @api.model for single record create\n @api.model\n def create(self, vals):\n return super().create(vals)\n\n # v15: @api.multi is REMOVED - do not use\n def action_sensitive_operation(self):\n \"\"\"Check permissions before sensitive operations.\"\"\"\n if not self.env.user.has_group('custom_module.group_manager'):\n raise AccessError(_(\"Only managers can perform this action.\"))\n for record in self:\n record._do_sensitive_work()\n```\n\n## View Security (v15 Syntax - attrs)\n\n### Using attrs for Visibility\n\n```xml\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n\n \u003c!-- v15: Use attrs for conditional visibility -->\n \u003cfield name=\"internal_notes\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n\n \u003c!-- v15: Combine attrs with groups -->\n \u003cfield name=\"secret_field\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"\n groups=\"custom_module.group_manager\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Button Security (v15)\n\n```xml\n\u003cbutton name=\"action_approve\"\n string=\"Approve\"\n type=\"object\"\n groups=\"custom_module.group_manager\"\n attrs=\"{'invisible': [('state', '!=', 'pending')]}\"/>\n```\n\n### Complete Form Example (v15)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"custom_model_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">custom.model.form\u003c/field>\n \u003cfield name=\"model\">custom.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"Custom Model\">\n \u003cheader>\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n attrs=\"{'invisible': [('state', '!=', 'confirmed')]}\"/>\n \u003cbutton name=\"action_approve\" string=\"Approve\" type=\"object\"\n groups=\"custom_module.group_manager\"\n attrs=\"{'invisible': [('state', '!=', 'pending')]}\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003c/group>\n \u003cgroup string=\"Internal\" groups=\"custom_module.group_manager\">\n \u003cfield name=\"internal_notes\"/>\n \u003c/group>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Field-Level Security (v15)\n\n```python\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n name = fields.Char(required=True, tracking=True)\n\n internal_notes = fields.Text(\n string='Internal Notes',\n groups='custom_module.group_manager',\n )\n\n cost_price = fields.Float(\n string='Cost Price',\n groups='account.group_account_user',\n )\n```\n\n## v15 Security Checklist\n\n- [ ] All models have `ir.model.access.csv` entries\n- [ ] Use `attrs` for view visibility conditions\n- [ ] Use `tracking=True` (NOT `track_visibility`)\n- [ ] Do NOT use `@api.multi` (removed)\n- [ ] Update chatter widgets (simplified)\n\n## Key Differences from v14\n\n| Feature | v14 | v15 |\n|---------|-----|-----|\n| Field tracking | `track_visibility='onchange'` | `tracking=True` |\n| @api.multi | Deprecated | Removed |\n| OWL | Not available | OWL 1.x introduced |\n| Chatter widgets | Legacy widgets | Simplified |\n\n## AI Agent Instructions (v15)\n\nWhen generating Odoo 15.0 security code:\n\n1. **Use** `attrs` for view visibility: `attrs=\"{'invisible': [...]}\"`\n2. **Use** `tracking=True` for field tracking (NOT `track_visibility`)\n3. **Do NOT** use `@api.multi` (removed in v15)\n4. **Use** updated chatter syntax (no widget attribute needed)\n5. **Note**: v15 is legacy - recommend upgrading\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8433,"content_sha256":"9564bcbe08e5cbcf2533e6cc8a8d673117280dc120f4b823ecd1aee0663f2702"},{"filename":"skills/odoo-security-guide-16-17.md","content":"# Odoo Security Guide - Migration 16.0 → 17.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: ODOO 16.0 → 17.0 SECURITY ║\n║ Use this guide when upgrading security code from v16 to v17. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview of Security Changes\n\n| Component | v16 | v17 | Migration Required |\n|-----------|-----|-----|-------------------|\n| View visibility | `attrs` (deprecated) | Direct attributes | **REQUIRED** |\n| Create method | `@api.model_create_multi` optional | Mandatory | **REQUIRED** |\n| Python | 3.8+ | 3.10+ | Check compatibility |\n| Record rules | `company_ids` | `company_ids` | No change |\n\n## Breaking Changes\n\n### 1. attrs Attribute REMOVED from Views\n\nThis is the most significant breaking change. The `attrs` attribute is completely removed in v17.\n\n**v16 Pattern (will break in v17):**\n```xml\n\u003cfield name=\"secret_field\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n\n\u003cfield name=\"amount\"\n attrs=\"{'readonly': [('state', '!=', 'draft')], 'required': [('type', '=', 'invoice')]}\"/>\n\n\u003cbutton name=\"action_approve\"\n attrs=\"{'invisible': [('state', '!=', 'pending')]}\"/>\n```\n\n**v17 Pattern:**\n```xml\n\u003cfield name=\"secret_field\"\n invisible=\"state == 'draft'\"/>\n\n\u003cfield name=\"amount\"\n readonly=\"state != 'draft'\"\n required=\"type == 'invoice'\"/>\n\n\u003cbutton name=\"action_approve\"\n invisible=\"state != 'pending'\"/>\n```\n\n### 2. Domain Syntax in Visibility Changed\n\n**v16 (Odoo domain format):**\n```xml\nattrs=\"{'invisible': [('state', '=', 'draft'), ('type', '!=', 'invoice')]}\"\n\u003c!-- This means: invisible if state == 'draft' AND type != 'invoice' -->\n\nattrs=\"{'invisible': ['|', ('state', '=', 'draft'), ('type', '=', 'invoice')]}\"\n\u003c!-- This means: invisible if state == 'draft' OR type == 'invoice' -->\n```\n\n**v17 (Python expression format):**\n```xml\ninvisible=\"state == 'draft' and type != 'invoice'\"\n\u003c!-- Same logic as v16 AND condition -->\n\ninvisible=\"state == 'draft' or type == 'invoice'\"\n\u003c!-- Same logic as v16 OR condition -->\n```\n\n### 3. Group Checks in Visibility\n\n**v16:**\n```xml\n\u003c!-- No direct way to check groups in attrs -->\n\u003cfield name=\"admin_field\" groups=\"base.group_system\"/>\n\u003c!-- Or use a computed boolean field -->\n```\n\n**v17:**\n```xml\n\u003cfield name=\"admin_field\"\n invisible=\"not user_has_groups('base.group_system')\"/>\n```\n\n## Migration Script for attrs\n\nHere's a Python script to help migrate `attrs` to direct attributes:\n\n```python\nimport re\nimport ast\n\ndef convert_domain_to_expression(domain_str):\n \"\"\"Convert Odoo domain to Python expression.\"\"\"\n try:\n domain = ast.literal_eval(domain_str)\n except:\n return domain_str # Can't parse, return as-is\n\n def convert_condition(cond):\n if isinstance(cond, str):\n return cond # '|' or '&'\n field, op, value = cond\n if op == '=':\n return f\"{field} == {repr(value)}\"\n elif op == '!=':\n return f\"{field} != {repr(value)}\"\n elif op == 'in':\n return f\"{field} in {repr(value)}\"\n elif op == 'not in':\n return f\"{field} not in {repr(value)}\"\n elif op in ('\u003c', '>', '\u003c=', '>='):\n return f\"{field} {op} {repr(value)}\"\n return f\"{field} {op} {repr(value)}\"\n\n # Simple case: single condition or AND conditions\n if not any(c in ('|', '&') for c in domain if isinstance(c, str)):\n conditions = [convert_condition(c) for c in domain]\n return ' and '.join(conditions)\n\n # Handle OR/AND operators (simplified)\n # For complex domains, manual conversion may be needed\n return str(domain) # Return original for manual review\n\n\ndef migrate_attrs_in_xml(xml_content):\n \"\"\"Migrate attrs to direct attributes in XML content.\"\"\"\n # Pattern to match attrs=\"{'invisible': [...], ...}\"\n attrs_pattern = r'attrs=\"(\\{[^\"]+\\})\"'\n\n def replace_attrs(match):\n attrs_str = match.group(1)\n try:\n attrs = ast.literal_eval(attrs_str)\n result_parts = []\n for attr, domain in attrs.items():\n expr = convert_domain_to_expression(str(domain))\n result_parts.append(f'{attr}=\"{expr}\"')\n return ' '.join(result_parts)\n except:\n return match.group(0) # Keep original if can't parse\n\n return re.sub(attrs_pattern, replace_attrs, xml_content)\n```\n\n## Manual Migration Examples\n\n### Simple Visibility\n\n**v16:**\n```xml\n\u003cfield name=\"notes\" attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n```\n\n**v17:**\n```xml\n\u003cfield name=\"notes\" invisible=\"state == 'draft'\"/>\n```\n\n### Multiple Conditions (AND)\n\n**v16:**\n```xml\n\u003cfield name=\"amount\" attrs=\"{'invisible': [('state', '=', 'draft'), ('type', '=', 'draft')]}\"/>\n```\n\n**v17:**\n```xml\n\u003cfield name=\"amount\" invisible=\"state == 'draft' and type == 'draft'\"/>\n```\n\n### OR Conditions\n\n**v16:**\n```xml\n\u003cfield name=\"field\" attrs=\"{'invisible': ['|', ('state', '=', 'draft'), ('state', '=', 'cancelled')]}\"/>\n```\n\n**v17:**\n```xml\n\u003cfield name=\"field\" invisible=\"state == 'draft' or state == 'cancelled'\"/>\n\u003c!-- Or more elegantly: -->\n\u003cfield name=\"field\" invisible=\"state in ('draft', 'cancelled')\"/>\n```\n\n### Complex Conditions\n\n**v16:**\n```xml\n\u003cfield name=\"field\" attrs=\"{'invisible': ['|', '&', ('state', '=', 'draft'), ('type', '=', 'a'), ('active', '=', False)]}\"/>\n```\n\n**v17:**\n```xml\n\u003cfield name=\"field\" invisible=\"(state == 'draft' and type == 'a') or not active\"/>\n```\n\n### Multiple Attributes\n\n**v16:**\n```xml\n\u003cfield name=\"amount\"\n attrs=\"{'readonly': [('state', '!=', 'draft')], 'required': [('type', '=', 'invoice')], 'invisible': [('show_amount', '=', False)]}\"/>\n```\n\n**v17:**\n```xml\n\u003cfield name=\"amount\"\n readonly=\"state != 'draft'\"\n required=\"type == 'invoice'\"\n invisible=\"not show_amount\"/>\n```\n\n### Button Visibility\n\n**v16:**\n```xml\n\u003cbutton name=\"action_confirm\"\n string=\"Confirm\"\n type=\"object\"\n attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n```\n\n**v17:**\n```xml\n\u003cbutton name=\"action_confirm\"\n string=\"Confirm\"\n type=\"object\"\n invisible=\"state != 'draft'\"/>\n```\n\n## Create Method Migration\n\n**v16 (optional @api.model_create_multi):**\n```python\[email protected]\ndef create(self, vals):\n return super().create(vals)\n\n# Or\[email protected]_create_multi\ndef create(self, vals_list):\n return super().create(vals_list)\n```\n\n**v17 (mandatory @api.model_create_multi):**\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n return super().create(vals_list)\n```\n\n## No Change Required\n\n### Security Groups\n```xml\n\u003c!-- Same in v16 and v17 -->\n\u003crecord id=\"group_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n\u003c/record>\n```\n\n### Access Rights\n```csv\n# Same format in v16 and v17\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\n```\n\n### Record Rules\n```xml\n\u003c!-- Same syntax in v16 and v17 -->\n\u003crecord id=\"rule_company\" model=\"ir.rule\">\n \u003cfield name=\"domain_force\">[('company_id', 'in', company_ids)]\u003c/field>\n\u003c/record>\n```\n\n### Field Groups\n```python\n# Same in v16 and v17\nnotes = fields.Text(groups='my_module.group_manager')\n```\n\n## Migration Checklist\n\n- [ ] **CRITICAL**: Replace ALL `attrs` with direct attributes in views\n- [ ] Convert domain syntax to Python expressions\n- [ ] Update create methods to use `@api.model_create_multi`\n- [ ] Update Python version to 3.10+\n- [ ] Test all view visibility conditions\n- [ ] Test button visibility with different states\n- [ ] Verify groups-based visibility using `user_has_groups()`\n\n## Common Mistakes to Avoid\n\n### 1. Forgetting to Convert Domain Operators\n\n**Wrong:**\n```xml\n\u003c!-- Still using domain syntax -->\n\u003cfield name=\"f\" invisible=\"('state', '=', 'draft')\"/>\n```\n\n**Correct:**\n```xml\n\u003cfield name=\"f\" invisible=\"state == 'draft'\"/>\n```\n\n### 2. Using Wrong Boolean Syntax\n\n**Wrong:**\n```xml\n\u003cfield name=\"f\" invisible=\"active = False\"/>\n```\n\n**Correct:**\n```xml\n\u003cfield name=\"f\" invisible=\"not active\"/>\n\u003c!-- or -->\n\u003cfield name=\"f\" invisible=\"active == False\"/>\n```\n\n### 3. Forgetting Quotes Around String Values\n\n**Wrong:**\n```xml\n\u003cfield name=\"f\" invisible=\"state == draft\"/>\n```\n\n**Correct:**\n```xml\n\u003cfield name=\"f\" invisible=\"state == 'draft'\"/>\n```\n\n## Testing After Migration\n\n```python\ndef test_view_visibility(self):\n \"\"\"Test that visibility conditions work correctly after migration.\"\"\"\n # Create record in draft state\n record = self.env['my.model'].create({'name': 'Test', 'state': 'draft'})\n\n # Get form view\n view = self.env.ref('my_module.view_form')\n\n # Verify invisible fields are not shown for draft records\n # (This would need UI testing or view parsing)\n```\n\n## Rollback Considerations\n\nYou cannot support both v16 and v17 with the same view files. If you need dual support:\n\n1. Create version-specific view files\n2. Use conditional loading in `__manifest__.py` (not recommended)\n3. Maintain separate branches\n\n## GitHub Reference\n\nCheck these for v17 view parsing:\n- `odoo/tools/view_validation.py` - View validation rules\n- `addons/web/static/src/views/` - View rendering\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9527,"content_sha256":"9522c036b1696e8a0ae869de355513750186a29137d941e9241b086772c1a632"},{"filename":"skills/odoo-security-guide-16.md","content":"# Odoo Security Guide - Version 16.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 16.0 SECURITY PATTERNS ║\n║ This file contains ONLY Odoo 16.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 16.0 Requirements\n\n- **Python**: 3.8+ required\n- **Key Features**: `Command` class, `attrs` still supported (deprecated), OWL 2.x\n\n## Security Groups (v16 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Access Rights (v16)\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_custom_model_user,custom.model.user,model_custom_model,custom_module.group_custom_user,1,1,1,0\naccess_custom_model_manager,custom.model.manager,model_custom_model,custom_module.group_custom_manager,1,1,1,1\n```\n\n## Record Rules (v16 Syntax)\n\n### Multi-Company Rule\n\n```xml\n\u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n## Model Security (v16 Patterns)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import AccessError, UserError, ValidationError\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(string='Name', required=True, tracking=True)\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n 'res.partner',\n string='Partner',\n )\n user_id = fields.Many2one(\n 'res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n )\n line_ids = fields.One2many('custom.secure.line', 'parent_id')\n\n # v16: @api.model_create_multi recommended but not mandatory\n @api.model_create_multi\n def create(self, vals_list):\n return super().create(vals_list)\n\n def action_update_lines(self):\n \"\"\"v16: Use Command class for x2many operations.\"\"\"\n self.write({\n 'line_ids': [\n Command.create({'name': 'New Line'}),\n Command.update(1, {'name': 'Updated'}),\n Command.delete(2),\n Command.link(3),\n Command.unlink(4),\n Command.clear(),\n Command.set([5, 6]),\n ]\n })\n```\n\n## View Security (v16 Syntax - attrs DEPRECATED)\n\n### Using attrs (Deprecated but supported)\n\n```xml\n\u003c!-- v16: attrs still works but is DEPRECATED -->\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n\n \u003c!-- DEPRECATED: Using attrs -->\n \u003cfield name=\"internal_notes\"\n attrs=\"{'invisible': [('state', '=', 'draft')]}\"/>\n\n \u003c!-- RECOMMENDED: Using direct invisible (v16 supports both) -->\n \u003cfield name=\"secret_field\"\n invisible=\"state == 'draft'\"\n groups=\"custom_module.group_manager\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Recommended v16 Pattern (Prepare for v17)\n\n```xml\n\u003c!-- v16: Prefer direct attributes for future compatibility -->\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n\n \u003c!-- Direct invisible attribute -->\n \u003cfield name=\"manager_notes\"\n invisible=\"state == 'draft'\"\n groups=\"custom_module.group_manager\"/>\n\n \u003c!-- Direct readonly attribute -->\n \u003cfield name=\"amount\"\n readonly=\"state != 'draft'\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Button Security (v16)\n\n```xml\n\u003c!-- v16: attrs still works -->\n\u003cbutton name=\"action_approve\"\n string=\"Approve\"\n type=\"object\"\n groups=\"custom_module.group_manager\"\n attrs=\"{'invisible': [('state', '!=', 'pending')]}\"/>\n\n\u003c!-- v16: Direct invisible also works (preferred) -->\n\u003cbutton name=\"action_confirm\"\n string=\"Confirm\"\n type=\"object\"\n invisible=\"state != 'draft'\"/>\n```\n\n## Field-Level Security (v16)\n\n```python\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n name = fields.Char(required=True, tracking=True)\n\n internal_notes = fields.Text(\n string='Internal Notes',\n groups='custom_module.group_manager',\n )\n\n cost_price = fields.Float(\n string='Cost Price',\n groups='account.group_account_user',\n )\n```\n\n## Complete Security Template (v16)\n\n### security/custom_module_security.xml\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003cfield name=\"users\" eval=\"[(4, ref('base.user_admin'))]\"/>\n \u003c/record>\n\n \u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"rule_custom_model_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\n \u003crecord id=\"rule_custom_model_manager\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Manager All Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[(1, '=', 1)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_manager'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v16 Security Checklist\n\n- [ ] All models have `ir.model.access.csv` entries\n- [ ] Record rules use `company_ids` for multi-company\n- [ ] Prefer direct `invisible` attribute over `attrs`\n- [ ] Use `Command` class for x2many security operations\n- [ ] Use `tracking=True` instead of `track_visibility`\n- [ ] Prepare for `attrs` removal by using direct attributes\n\n## Key Differences\n\n| Feature | v15 | v16 |\n|---------|-----|-----|\n| x2many commands | Tuple syntax | `Command` class |\n| View visibility | `attrs` | `attrs` (deprecated) or direct |\n| Tracking | `tracking=True` | `tracking=True` |\n\n## AI Agent Instructions (v16)\n\nWhen generating Odoo 16.0 security code:\n\n1. **Prefer** direct `invisible`, `readonly`, `required` attributes over `attrs`\n2. **Use** `Command` class for x2many operations\n3. **Use** `tracking=True` for field tracking\n4. **Use** `company_ids` in multi-company record rules\n5. **Note**: `attrs` still works but is deprecated - avoid for new code\n6. **Use** `@api.model_create_multi` (recommended but not mandatory yet)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8851,"content_sha256":"3eb158d511d181f9ed2eeb096faaeeb37a7929fca664c0b508faf4927f7e4854"},{"filename":"skills/odoo-security-guide-17-18.md","content":"# Odoo Security Guide - Migration 17.0 → 18.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: ODOO 17.0 → 18.0 SECURITY ║\n║ Use this guide when upgrading security code from v17 to v18. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview of Security Changes\n\n| Component | v17 | v18 | Migration Required |\n|-----------|-----|-----|-------------------|\n| Multi-company | `company_ids` | `allowed_company_ids` | Recommended |\n| Company check | Manual domain | `_check_company_auto` | Recommended |\n| Field company | Manual validation | `check_company=True` | Recommended |\n| Type hints | Optional | Recommended | Optional |\n| SQL queries | Parameterized | `SQL()` builder | Recommended |\n| View syntax | Direct attributes | Direct attributes | No change |\n\n## Breaking Changes\n\n### None - v17 to v18 is mostly additive\n\nThe v17 to v18 migration for security is relatively smooth. Most changes are new features/improvements rather than breaking changes.\n\n## Recommended Migrations\n\n### 1. Multi-Company Record Rules\n\n**v17 Pattern:**\n```xml\n\u003crecord id=\"rule_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n**v18 Pattern:**\n```xml\n\u003crecord id=\"rule_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n**Migration Script:**\n```python\n# Find and replace in XML files\n# company_ids → allowed_company_ids (in record rule domains)\n```\n\n### 2. Model Company Validation\n\n**v17 Pattern:**\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n\n company_id = fields.Many2one(\n 'res.company',\n default=lambda self: self.env.company,\n required=True,\n )\n partner_id = fields.Many2one(\n 'res.partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n )\n\n @api.constrains('partner_id', 'company_id')\n def _check_company(self):\n for record in self:\n if record.partner_id.company_id and record.partner_id.company_id != record.company_id:\n raise ValidationError(_(\"Partner company mismatch\"))\n```\n\n**v18 Pattern:**\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n _check_company_auto = True # NEW in v18\n\n company_id = fields.Many2one(\n 'res.company',\n default=lambda self: self.env.company,\n required=True,\n )\n partner_id = fields.Many2one(\n 'res.partner',\n check_company=True, # NEW in v18 - replaces manual constraint\n )\n # No need for manual _check_company constraint\n```\n\n**Migration Steps:**\n1. Add `_check_company_auto = True` to model class\n2. Add `check_company=True` to relational fields\n3. Remove manual `_check_company` constraint methods\n4. Remove company domain filters (optional, `check_company` handles it)\n\n### 3. Type Hints on Fields\n\n**v17 Pattern:**\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n\n name = fields.Char(required=True)\n partner_id = fields.Many2one('res.partner')\n line_ids = fields.One2many('my.model.line', 'parent_id')\n amount = fields.Float()\n```\n\n**v18 Pattern:**\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n _description = 'My Model'\n\n name: str = fields.Char(required=True)\n partner_id: int = fields.Many2one('res.partner')\n line_ids: list = fields.One2many('my.model.line', 'parent_id')\n amount: float = fields.Float()\n```\n\n**Migration Steps:**\n1. Add type hints to all relational fields: `partner_id: int = fields.Many2one(...)`\n2. Optionally add to scalar fields: `name: str = fields.Char(...)`\n\n### 4. SQL Builder for Raw Queries\n\n**v17 Pattern:**\n```python\ndef _get_data(self):\n self.env.cr.execute(\n \"\"\"\n SELECT id, name FROM my_table\n WHERE company_id = %s AND active = %s\n \"\"\",\n [self.env.company.id, True]\n )\n return self.env.cr.dictfetchall()\n```\n\n**v18 Pattern:**\n```python\nfrom odoo.tools import SQL\n\ndef _get_data(self):\n query = SQL(\n \"\"\"\n SELECT id, name FROM %(table)s\n WHERE company_id = %(company_id)s AND active = %(active)s\n \"\"\",\n table=SQL.identifier('my_table'),\n company_id=self.env.company.id,\n active=True,\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n**Migration Steps:**\n1. Import `SQL` from `odoo.tools`\n2. Convert parameterized queries to `SQL()` builder\n3. Use `SQL.identifier()` for table/column names\n\n## No Change Required\n\nThe following remain the same between v17 and v18:\n\n### Security Groups\n```xml\n\u003c!-- Same syntax in v17 and v18 -->\n\u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n\u003c/record>\n```\n\n### Access Rights (ir.model.access.csv)\n```csv\n# Same format in v17 and v18\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_model_user,model.user,model_my_model,my_module.group_user,1,1,1,0\n```\n\n### View Security Syntax\n```xml\n\u003c!-- Same syntax in v17 and v18 -->\n\u003cfield name=\"secret\" invisible=\"not user_has_groups('my_module.group_manager')\"/>\n\u003cbutton name=\"action\" invisible=\"state != 'draft'\" groups=\"my_module.group_manager\"/>\n```\n\n### Field Groups\n```python\n# Same syntax in v17 and v18\ninternal_notes = fields.Text(groups='my_module.group_manager')\n```\n\n## Migration Checklist\n\n- [ ] Update record rules: `company_ids` → `allowed_company_ids`\n- [ ] Add `_check_company_auto = True` to multi-company models\n- [ ] Add `check_company=True` to relational fields\n- [ ] Remove manual company validation constraints\n- [ ] Add type hints to relational fields (recommended)\n- [ ] Convert raw SQL to `SQL()` builder (recommended)\n- [ ] Test all security rules with different user roles\n- [ ] Verify multi-company access works correctly\n\n## Testing After Migration\n\n```python\n# Test company validation\ndef test_company_check(self):\n \"\"\"Test that _check_company_auto works.\"\"\"\n company_a = self.env['res.company'].create({'name': 'Company A'})\n company_b = self.env['res.company'].create({'name': 'Company B'})\n\n partner_a = self.env['res.partner'].create({\n 'name': 'Partner A',\n 'company_id': company_a.id,\n })\n\n # This should raise ValidationError with check_company=True\n with self.assertRaises(ValidationError):\n self.env['my.model'].create({\n 'name': 'Test',\n 'company_id': company_b.id,\n 'partner_id': partner_a.id, # Wrong company\n })\n```\n\n## Rollback Considerations\n\nIf you need to support both v17 and v18:\n\n```python\n# Dual-version compatible code\nclass MyModel(models.Model):\n _name = 'my.model'\n\n # _check_company_auto is ignored in v17, works in v18\n _check_company_auto = True\n\n company_id = fields.Many2one('res.company', required=True)\n\n # check_company is ignored in v17, works in v18\n partner_id = fields.Many2one('res.partner', check_company=True)\n\n # Keep manual constraint for v17 compatibility\n @api.constrains('partner_id', 'company_id')\n def _check_company_compat(self):\n # This runs in both versions, but v18 also has automatic check\n for record in self:\n if record.partner_id.company_id:\n if record.partner_id.company_id != record.company_id:\n raise ValidationError(_(\"Company mismatch\"))\n```\n\n## GitHub Reference\n\nCheck these files in the Odoo repository for v18 patterns:\n\n- `odoo/models.py` - `_check_company_auto` implementation\n- `odoo/fields.py` - `check_company` parameter\n- `odoo/tools/sql.py` - `SQL` builder class\n- `addons/base/models/ir_rule.py` - `allowed_company_ids` usage\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8677,"content_sha256":"af153b30a0f845b927a1520291371871b2039637fbcaa066340c89206dd21c6c"},{"filename":"skills/odoo-security-guide-17.md","content":"# Odoo Security Guide - Version 17.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 17.0 SECURITY PATTERNS ║\n║ This file contains ONLY Odoo 17.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 17.0 Requirements\n\n- **Python**: 3.10+ required\n- **Key Changes**: `attrs` removed from views, `@api.model_create_multi` mandatory\n\n## Security Groups (v17 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Access Rights (v17)\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_custom_model_user,custom.model.user,model_custom_model,custom_module.group_custom_user,1,1,1,0\naccess_custom_model_manager,custom.model.manager,model_custom_model,custom_module.group_custom_manager,1,1,1,1\n```\n\n## Record Rules (v17 Syntax)\n\n### Multi-Company Rule (v17)\n\n```xml\n\u003c!-- v17: Use company_ids for multi-company -->\n\u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n### User Own Records Rule\n\n```xml\n\u003crecord id=\"rule_custom_model_user_own\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n\u003c/record>\n```\n\n## Model Security (v17 Patterns)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import AccessError, UserError, ValidationError\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n\n name = fields.Char(string='Name', required=True, tracking=True)\n company_id = fields.Many2one(\n 'res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id = fields.Many2one(\n 'res.partner',\n string='Partner',\n domain=\"[('company_id', 'in', [company_id, False])]\",\n )\n user_id = fields.Many2one(\n 'res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n )\n state = fields.Selection([\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ], default='draft', tracking=True)\n\n @api.model_create_multi\n def create(self, vals_list):\n # v17: @api.model_create_multi is mandatory\n return super().create(vals_list)\n\n def action_sensitive_operation(self):\n \"\"\"Check permissions before sensitive operations.\"\"\"\n if not self.env.user.has_group('custom_module.group_manager'):\n raise AccessError(_(\"Only managers can perform this action.\"))\n self._do_sensitive_work()\n```\n\n### Secure SQL Queries (v17 - Parameterized)\n\n```python\ndef _get_secure_data(self):\n \"\"\"Use parameterized queries (v17 pattern).\"\"\"\n # SAFE: Parameterized query\n self.env.cr.execute(\n \"\"\"\n SELECT id, name, amount\n FROM %s\n WHERE company_id = %%s\n AND active = %%s\n AND create_uid = %%s\n \"\"\" % self._table,\n [self.env.company.id, True, self.env.user.id]\n )\n return self.env.cr.dictfetchall()\n```\n\n## View Security (v17 Syntax - NO attrs)\n\n### Field Visibility\n\n```xml\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n\n \u003c!-- v17: Direct invisible attribute (NOT attrs) -->\n \u003cfield name=\"internal_notes\" groups=\"custom_module.group_custom_manager\"/>\n\n \u003c!-- v17: Python expression in invisible -->\n \u003cfield name=\"secret_field\" invisible=\"not user_has_groups('custom_module.group_custom_manager')\"/>\n\n \u003c!-- v17: Conditional based on field value -->\n \u003cfield name=\"manager_notes\" invisible=\"state == 'draft'\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Button Security (v17 Syntax)\n\n```xml\n\u003c!-- v17: Direct invisible attribute with Python expression -->\n\u003cbutton name=\"action_approve\"\n string=\"Approve\"\n type=\"object\"\n groups=\"custom_module.group_custom_manager\"\n invisible=\"state != 'pending'\"\n class=\"btn-primary\"/>\n\n\u003c!-- v17: Group check in invisible -->\n\u003cbutton name=\"action_admin\"\n string=\"Admin Action\"\n type=\"object\"\n invisible=\"not user_has_groups('base.group_system')\"/>\n```\n\n### Complete Form Example (v17)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"custom_model_view_form\" model=\"ir.ui.view\">\n \u003cfield name=\"name\">custom.model.form\u003c/field>\n \u003cfield name=\"model\">custom.model\u003c/field>\n \u003cfield name=\"arch\" type=\"xml\">\n \u003cform string=\"Custom Model\">\n \u003cheader>\n \u003c!-- v17: invisible takes Python expression -->\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\"\n class=\"btn-primary\" invisible=\"state != 'draft'\"/>\n \u003cbutton name=\"action_done\" string=\"Done\" type=\"object\"\n invisible=\"state != 'confirmed'\"/>\n \u003cbutton name=\"action_approve\" string=\"Approve\" type=\"object\"\n groups=\"custom_module.group_manager\"\n invisible=\"state != 'pending'\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n \u003cfield name=\"partner_id\"/>\n \u003c/group>\n \u003cgroup>\n \u003cfield name=\"company_id\" groups=\"base.group_multi_company\"/>\n \u003cfield name=\"user_id\"/>\n \u003c/group>\n \u003c/group>\n \u003c!-- Manager-only section -->\n \u003cgroup string=\"Internal\" groups=\"custom_module.group_manager\">\n \u003cfield name=\"internal_notes\"/>\n \u003cfield name=\"cost_price\"/>\n \u003c/group>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\n \u003cfield name=\"message_follower_ids\"/>\n \u003cfield name=\"activity_ids\"/>\n \u003cfield name=\"message_ids\"/>\n \u003c/div>\n \u003c/form>\n \u003c/field>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Field-Level Security (v17)\n\n```python\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n name = fields.Char(required=True)\n\n # Manager-only field\n internal_notes = fields.Text(\n string='Internal Notes',\n groups='custom_module.group_custom_manager',\n )\n\n # Finance-only field\n cost_price = fields.Float(\n string='Cost Price',\n groups='account.group_account_user',\n )\n```\n\n## Complete Security Template (v17)\n\n### security/custom_module_security.xml\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003cfield name=\"users\" eval=\"[(4, ref('base.user_admin'))]\"/>\n \u003c/record>\n\n \u003c!-- Multi-Company Rule -->\n \u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n ]\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"rule_custom_model_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\n \u003crecord id=\"rule_custom_model_manager\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Manager All Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[(1, '=', 1)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_manager'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v17 Security Checklist\n\n- [ ] All models have `ir.model.access.csv` entries\n- [ ] Record rules use `company_ids` for multi-company\n- [ ] Views use direct `invisible` attribute (NOT `attrs`)\n- [ ] Use `@api.model_create_multi` for create methods\n- [ ] SQL queries use parameterized syntax\n- [ ] Button visibility uses `invisible` with Python expression\n- [ ] No `attrs` attribute anywhere in views\n\n## Key Differences from v16\n\n| Feature | v16 | v17 |\n|---------|-----|-----|\n| View visibility | `attrs=\"{'invisible': [...]}\"` | `invisible=\"expression\"` |\n| Create method | `@api.model_create_multi` optional | `@api.model_create_multi` mandatory |\n| Domain syntax | Still supports `attrs` | `attrs` removed |\n\n## AI Agent Instructions (v17)\n\nWhen generating Odoo 17.0 security code:\n\n1. **Never use** `attrs` attribute in views (removed in v17)\n2. **Always use** direct `invisible`, `readonly`, `required` attributes\n3. **Use** Python expressions for visibility: `invisible=\"state != 'draft'\"`\n4. **Use** `user_has_groups()` for group checks: `invisible=\"not user_has_groups('base.group_manager')\"`\n5. **Use** `@api.model_create_multi` for create methods (mandatory)\n6. **Use** parameterized SQL queries\n7. **Use** `company_ids` in multi-company record rules\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11544,"content_sha256":"68105e1ad55b40418f7807dc02987efd3bdb7cb957bd12af64f7dfef314c2a95"},{"filename":"skills/odoo-security-guide-18-19.md","content":"# Odoo Security Guide - Migration 18.0 → 19.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ MIGRATION GUIDE: ODOO 18.0 → 19.0 SECURITY ║\n║ Use this guide when upgrading security code from v18 to v19. ║\n║ NOTE: v19 is in development - patterns may change. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Overview of Security Changes\n\n| Component | v18 | v19 | Migration Required |\n|-----------|-----|-----|-------------------|\n| Type hints | Recommended | **Mandatory** | **REQUIRED** |\n| SQL builder | Recommended | **Mandatory** | **REQUIRED** |\n| Python | 3.11+ | 3.12+ | Check compatibility |\n| OWL | 2.x | 3.x | For OWL components |\n\n## Breaking Changes\n\n### 1. Type Hints Now Mandatory\n\n**v18 (recommended):**\n```python\nclass MyModel(models.Model):\n _name = 'my.model'\n\n name = fields.Char(required=True)\n partner_id = fields.Many2one('res.partner')\n line_ids = fields.One2many('my.line', 'parent_id')\n```\n\n**v19 (mandatory):**\n```python\nfrom __future__ import annotations\n\nclass MyModel(models.Model):\n _name = 'my.model'\n\n name: str = fields.Char(required=True)\n partner_id: int = fields.Many2one('res.partner')\n line_ids: list[int] = fields.One2many('my.line', 'parent_id')\n```\n\n### 2. SQL Builder Now Mandatory\n\n**v18 (recommended):**\n```python\n# Still allowed in v18\nself.env.cr.execute(\n \"SELECT id FROM my_table WHERE company_id = %s\",\n [self.env.company.id]\n)\n```\n\n**v19 (mandatory):**\n```python\nfrom odoo.tools import SQL\n\nquery = SQL(\n \"SELECT id FROM %(table)s WHERE company_id = %(company_id)s\",\n table=SQL.identifier('my_table'),\n company_id=self.env.company.id,\n)\nself.env.cr.execute(query)\n```\n\n## Migration Script\n\n```python\nimport re\n\ndef add_type_hints(python_content):\n \"\"\"Add type hints to field definitions.\"\"\"\n\n # Pattern for field definitions without type hints\n field_patterns = {\n r\"(\\w+)\\s*=\\s*fields\\.Char\\(\": r\"\\1: str = fields.Char(\",\n r\"(\\w+)\\s*=\\s*fields\\.Text\\(\": r\"\\1: str = fields.Text(\",\n r\"(\\w+)\\s*=\\s*fields\\.Boolean\\(\": r\"\\1: bool = fields.Boolean(\",\n r\"(\\w+)\\s*=\\s*fields\\.Integer\\(\": r\"\\1: int = fields.Integer(\",\n r\"(\\w+)\\s*=\\s*fields\\.Float\\(\": r\"\\1: float = fields.Float(\",\n r\"(\\w+)\\s*=\\s*fields\\.Monetary\\(\": r\"\\1: float = fields.Monetary(\",\n r\"(\\w+)\\s*=\\s*fields\\.Date\\(\": r\"\\1: date = fields.Date(\",\n r\"(\\w+)\\s*=\\s*fields\\.Datetime\\(\": r\"\\1: datetime = fields.Datetime(\",\n r\"(\\w+)\\s*=\\s*fields\\.Selection\\(\": r\"\\1: str = fields.Selection(\",\n r\"(\\w+)\\s*=\\s*fields\\.Many2one\\(\": r\"\\1: int = fields.Many2one(\",\n r\"(\\w+)\\s*=\\s*fields\\.One2many\\(\": r\"\\1: list[int] = fields.One2many(\",\n r\"(\\w+)\\s*=\\s*fields\\.Many2many\\(\": r\"\\1: list[int] = fields.Many2many(\",\n }\n\n content = python_content\n for pattern, replacement in field_patterns.items():\n content = re.sub(pattern, replacement, content)\n\n # Add future annotations import if not present\n if \"from __future__ import annotations\" not in content:\n content = \"from __future__ import annotations\\n\" + content\n\n return content\n\n\ndef convert_to_sql_builder(python_content):\n \"\"\"Convert raw SQL to SQL builder pattern.\"\"\"\n # This is a complex transformation that often requires manual review\n # Basic pattern detection\n if \"self.env.cr.execute\" in python_content and \"SQL(\" not in python_content:\n print(\"WARNING: Found raw SQL execution. Manual migration to SQL() required.\")\n return python_content\n```\n\n## Detailed Type Hint Examples\n\n### Field Type Hints\n\n```python\nfrom __future__ import annotations\nfrom datetime import date, datetime\nfrom typing import Any\n\nfrom odoo import api, fields, models\n\nclass SecureModel(models.Model):\n _name = 'secure.model'\n _description = 'Secure Model'\n\n # Scalar fields\n name: str = fields.Char(required=True)\n description: str = fields.Text()\n active: bool = fields.Boolean(default=True)\n sequence: int = fields.Integer(default=10)\n amount: float = fields.Float()\n percentage: float = fields.Float(digits=(16, 2))\n\n # Date fields\n date_start: date = fields.Date()\n datetime_action: datetime = fields.Datetime()\n\n # Selection\n state: str = fields.Selection([\n ('draft', 'Draft'),\n ('done', 'Done'),\n ], default='draft')\n\n # Relational fields\n company_id: int = fields.Many2one('res.company')\n partner_id: int = fields.Many2one('res.partner')\n user_id: int = fields.Many2one('res.users')\n line_ids: list[int] = fields.One2many('secure.model.line', 'parent_id')\n tag_ids: list[int] = fields.Many2many('secure.model.tag')\n\n # Related fields\n partner_name: str = fields.Char(related='partner_id.name')\n company_currency_id: int = fields.Many2one(related='company_id.currency_id')\n```\n\n### Method Type Hints\n\n```python\nfrom __future__ import annotations\nfrom typing import Any\n\nclass SecureModel(models.Model):\n _name = 'secure.model'\n\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> SecureModel:\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n return super().write(vals)\n\n def unlink(self) -> bool:\n return super().unlink()\n\n def copy(self, default: dict[str, Any] | None = None) -> SecureModel:\n return super().copy(default)\n\n def action_confirm(self) -> dict[str, Any] | bool:\n \"\"\"Returns action dict or True.\"\"\"\n self.write({'state': 'confirmed'})\n return True\n\n def _compute_total(self) -> None:\n for record in self:\n record.total = sum(record.line_ids.mapped('amount'))\n```\n\n## SQL Builder Migration Examples\n\n### Simple Query\n\n**v18:**\n```python\ndef _get_records(self):\n self.env.cr.execute(\n \"SELECT id, name FROM my_table WHERE active = %s\",\n [True]\n )\n return self.env.cr.fetchall()\n```\n\n**v19:**\n```python\nfrom odoo.tools import SQL\n\ndef _get_records(self) -> list[tuple[int, str]]:\n query = SQL(\n \"SELECT id, name FROM %(table)s WHERE active = %(active)s\",\n table=SQL.identifier('my_table'),\n active=True,\n )\n self.env.cr.execute(query)\n return self.env.cr.fetchall()\n```\n\n### Query with Dynamic Table\n\n**v18:**\n```python\ndef _get_model_records(self):\n self.env.cr.execute(\n f\"SELECT id FROM {self._table} WHERE company_id = %s\",\n [self.env.company.id]\n )\n```\n\n**v19:**\n```python\ndef _get_model_records(self) -> list[tuple[int]]:\n query = SQL(\n \"SELECT id FROM %(table)s WHERE company_id = %(company_id)s\",\n table=SQL.identifier(self._table),\n company_id=self.env.company.id,\n )\n self.env.cr.execute(query)\n return self.env.cr.fetchall()\n```\n\n### Complex Query with Joins\n\n**v19 Pattern:**\n```python\ndef _get_report_data(self) -> list[dict[str, Any]]:\n query = SQL(\n \"\"\"\n SELECT\n m.id,\n m.name,\n p.name AS partner_name,\n COALESCE(SUM(l.amount), 0) AS total_amount\n FROM %(main_table)s m\n LEFT JOIN %(partner_table)s p ON m.partner_id = p.id\n LEFT JOIN %(line_table)s l ON l.parent_id = m.id\n WHERE m.company_id IN %(company_ids)s\n AND m.state = %(state)s\n AND m.date >= %(date_from)s\n GROUP BY m.id, m.name, p.name\n ORDER BY %(order_field)s %(order_dir)s\n \"\"\",\n main_table=SQL.identifier(self._table),\n partner_table=SQL.identifier('res_partner'),\n line_table=SQL.identifier('secure_model_line'),\n company_ids=tuple(self.env.companies.ids) or (0,),\n state='confirmed',\n date_from=fields.Date.today(),\n order_field=SQL.identifier('total_amount'),\n order_dir=SQL('DESC'),\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n## OWL 3.x Migration\n\n### Component Structure\n\n**v18 (OWL 2.x):**\n```javascript\nimport { Component, useState } from \"@odoo/owl\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n\n setup() {\n this.state = useState({ count: 0 });\n }\n}\n```\n\n**v19 (OWL 3.x):**\n```javascript\nimport { Component, useState } from \"@odoo/owl\";\n\nexport class MyComponent extends Component {\n static template = \"my_module.MyComponent\";\n static props = {\n // Explicit prop definitions\n };\n\n setup() {\n this.state = useState({ count: 0 });\n }\n}\n```\n\n## Migration Checklist\n\n- [ ] **CRITICAL**: Add type hints to ALL field definitions\n- [ ] **CRITICAL**: Add type hints to ALL method signatures\n- [ ] **CRITICAL**: Convert ALL raw SQL to `SQL()` builder\n- [ ] Add `from __future__ import annotations` to all Python files\n- [ ] Update Python to 3.12+\n- [ ] Migrate OWL 2.x to OWL 3.x components\n- [ ] Test all SQL queries work correctly\n- [ ] Verify type hints don't cause runtime errors\n\n## No Change Required\n\n### Security Groups, Access Rights, Record Rules\n\nThese remain unchanged from v18 to v19:\n- Group definitions\n- ir.model.access.csv format\n- Record rule syntax\n- Field groups attribute\n- View visibility syntax\n\n## GitHub Reference\n\nCheck the `master` branch for v19 patterns:\n- `odoo/fields.py` - Type hint support\n- `odoo/tools/sql.py` - SQL builder enhancements\n- `odoo/models.py` - Model type annotations\n- `addons/web/static/src/` - OWL 3.x patterns\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9830,"content_sha256":"20bf2196f4091853dfb3c3a1ad83fbfb1a118d07b9c632f47f996563752128d5"},{"filename":"skills/odoo-security-guide-18.md","content":"# Odoo Security Guide - Version 18.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 18.0 SECURITY PATTERNS ║\n║ This file contains ONLY Odoo 18.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 18.0 Requirements\n\n- **Python**: 3.11+ required\n- **Key Features**: `_check_company_auto`, `check_company`, type hints, `SQL()` builder\n\n## Security Groups (v18 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Category -->\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003c!-- User Group -->\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003c!-- Manager Group (inherits User) -->\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Access Rights (v18)\n\n### ir.model.access.csv\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_custom_model_user,custom.model.user,model_custom_model,custom_module.group_custom_user,1,1,1,0\naccess_custom_model_manager,custom.model.manager,model_custom_model,custom_module.group_custom_manager,1,1,1,1\n```\n\n## Record Rules (v18 Syntax)\n\n### Multi-Company Rule (v18 Pattern)\n\n```xml\n\u003c!-- v18: Use allowed_company_ids for multi-company -->\n\u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n### User Own Records Rule\n\n```xml\n\u003crecord id=\"rule_custom_model_user_own\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n\u003c/record>\n```\n\n## Model Security (v18 Patterns)\n\n### _check_company_auto (v18 Feature)\n\n```python\n# -*- coding: utf-8 -*-\nfrom odoo import api, fields, models\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n _check_company_auto = True # v18: Automatic company validation\n\n # Type hints (v18 recommended)\n name: str = fields.Char(string='Name', required=True)\n company_id: int = fields.Many2one(\n 'res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n # check_company validates company matches (v18)\n partner_id: int = fields.Many2one(\n 'res.partner',\n string='Partner',\n check_company=True,\n )\n warehouse_id: int = fields.Many2one(\n 'stock.warehouse',\n string='Warehouse',\n check_company=True,\n )\n```\n\n### Secure Method Implementation (v18)\n\n```python\nfrom odoo import api, models, _\nfrom odoo.exceptions import AccessError, UserError\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n def action_sensitive_operation(self):\n \"\"\"Method with security checks.\"\"\"\n # Check group membership\n if not self.env.user.has_group('custom_module.group_manager'):\n raise AccessError(_(\"Only managers can perform this action.\"))\n\n # Check record-level access\n self.check_access_rights('write')\n self.check_access_rule('write')\n\n # Perform operation\n self._execute_sensitive_operation()\n\n def _execute_sensitive_operation(self):\n \"\"\"Internal method - never call directly from external code.\"\"\"\n # Sensitive logic here\n pass\n```\n\n### Secure SQL Queries (v18 - SQL Builder)\n\n```python\nfrom odoo import models\nfrom odoo.tools import SQL\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n def _get_secure_data(self):\n \"\"\"Use SQL builder for secure raw SQL (v18 pattern).\"\"\"\n query = SQL(\n \"\"\"\n SELECT id, name, amount\n FROM %(table)s\n WHERE company_id = %(company_id)s\n AND active = %(active)s\n AND create_uid = %(user_id)s\n \"\"\",\n table=SQL.identifier(self._table),\n company_id=self.env.company.id,\n active=True,\n user_id=self.env.user.id,\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n## View Security (v18 Syntax)\n\n### Field Visibility with Groups\n\n```xml\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003c!-- Field visible to all -->\n \u003cfield name=\"name\"/>\n\n \u003c!-- Field visible only to managers -->\n \u003cfield name=\"internal_notes\" groups=\"custom_module.group_custom_manager\"/>\n\n \u003c!-- Field with conditional visibility (v18 syntax) -->\n \u003cfield name=\"secret_field\" invisible=\"not user_has_groups('custom_module.group_custom_manager')\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Button Security (v18 Syntax)\n\n```xml\n\u003c!-- v18: Direct invisible attribute with Python expression -->\n\u003cbutton name=\"action_approve\"\n string=\"Approve\"\n type=\"object\"\n groups=\"custom_module.group_custom_manager\"\n invisible=\"state != 'pending'\"\n class=\"btn-primary\"/>\n\n\u003c!-- Conditional on group -->\n\u003cbutton name=\"action_admin\"\n string=\"Admin Action\"\n type=\"object\"\n invisible=\"not user_has_groups('base.group_system')\"/>\n```\n\n### Menu Security\n\n```xml\n\u003cmenuitem id=\"menu_custom_root\"\n name=\"Custom Module\"\n groups=\"custom_module.group_custom_user\"/>\n\n\u003cmenuitem id=\"menu_custom_config\"\n name=\"Configuration\"\n parent=\"menu_custom_root\"\n groups=\"custom_module.group_custom_manager\"/>\n```\n\n## Field-Level Security (v18)\n\n```python\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n # Public field\n name: str = fields.Char(required=True)\n\n # Manager-only field\n internal_notes: str = fields.Text(\n string='Internal Notes',\n groups='custom_module.group_custom_manager',\n )\n\n # Finance-only field\n cost_price: float = fields.Float(\n string='Cost Price',\n groups='account.group_account_user',\n digits='Product Price',\n )\n\n # Multiple groups (any of them can access)\n sensitive_data: str = fields.Char(\n string='Sensitive Data',\n groups='custom_module.group_custom_manager,base.group_system',\n )\n```\n\n## Audit Trail (v18 Pattern)\n\n```python\nimport json\nfrom odoo import api, fields, models\n\nclass AuditLog(models.Model):\n _name = 'custom.audit.log'\n _description = 'Audit Log'\n _order = 'create_date desc'\n _rec_name = 'display_name'\n\n model: str = fields.Char(required=True, index=True)\n res_id: int = fields.Integer(required=True, index=True)\n action: str = fields.Selection([\n ('create', 'Created'),\n ('write', 'Updated'),\n ('unlink', 'Deleted'),\n ], required=True)\n user_id: int = fields.Many2one('res.users', required=True, index=True)\n timestamp: fields.Datetime = fields.Datetime(default=fields.Datetime.now)\n old_values: str = fields.Text()\n new_values: str = fields.Text()\n display_name: str = fields.Char(compute='_compute_display_name')\n\n @api.depends('model', 'res_id', 'action')\n def _compute_display_name(self):\n for record in self:\n record.display_name = f\"{record.model}/{record.res_id} - {record.action}\"\n\n\nclass AuditedModel(models.Model):\n _name = 'custom.audited'\n _description = 'Audited Model'\n _audit_fields = ['name', 'state', 'amount']\n\n name: str = fields.Char(required=True)\n state: str = fields.Selection([('draft', 'Draft'), ('done', 'Done')])\n amount: float = fields.Float()\n\n def _create_audit_log(self, action, old_values=None, new_values=None):\n self.env['custom.audit.log'].sudo().create({\n 'model': self._name,\n 'res_id': self.id,\n 'action': action,\n 'user_id': self.env.user.id,\n 'old_values': json.dumps(old_values) if old_values else False,\n 'new_values': json.dumps(new_values) if new_values else False,\n })\n\n @api.model_create_multi\n def create(self, vals_list):\n records = super().create(vals_list)\n for record, vals in zip(records, vals_list):\n audit_vals = {k: v for k, v in vals.items() if k in self._audit_fields}\n record._create_audit_log('create', new_values=audit_vals)\n return records\n\n def write(self, vals):\n audit_fields = [f for f in vals.keys() if f in self._audit_fields]\n old_values = {}\n if audit_fields:\n for record in self:\n old_values[record.id] = {f: getattr(record, f) for f in audit_fields}\n\n result = super().write(vals)\n\n if audit_fields:\n for record in self:\n record._create_audit_log(\n 'write',\n old_values=old_values.get(record.id),\n new_values={k: vals[k] for k in audit_fields}\n )\n return result\n\n def unlink(self):\n for record in self:\n record._create_audit_log('unlink', old_values={\n f: getattr(record, f) for f in self._audit_fields\n })\n return super().unlink()\n```\n\n## Security Levels Implementation (v18)\n\n### Basic Security\n\n```csv\n# ir.model.access.csv - Basic\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_model_user,model.user,model_custom_model,custom_module.group_user,1,1,1,0\naccess_model_manager,model.manager,model_custom_model,custom_module.group_manager,1,1,1,1\n```\n\n### Advanced Security\n\n```csv\n# ir.model.access.csv - Advanced (viewer/editor/manager)\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_model_viewer,model.viewer,model_custom_model,custom_module.group_viewer,1,0,0,0\naccess_model_editor,model.editor,model_custom_model,custom_module.group_editor,1,1,1,0\naccess_model_manager,model.manager,model_custom_model,custom_module.group_manager,1,1,1,1\n```\n\n### Audit-Grade Security\n\n```csv\n# ir.model.access.csv - Audit-grade (full separation)\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_model_reader,model.reader,model_custom_model,custom_module.group_reader,1,0,0,0\naccess_model_creator,model.creator,model_custom_model,custom_module.group_creator,1,0,1,0\naccess_model_editor,model.editor,model_custom_model,custom_module.group_editor,1,1,0,0\naccess_model_deleter,model.deleter,model_custom_model,custom_module.group_deleter,1,0,0,1\naccess_audit_auditor,audit.auditor,model_custom_audit_log,custom_module.group_auditor,1,0,0,0\n```\n\n## Complete Security File Template (v18)\n\n### security/custom_module_security.xml\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003c!-- Category -->\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003c!-- Groups -->\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003cfield name=\"users\" eval=\"[(4, ref('base.user_admin'))]\"/>\n \u003c/record>\n\n \u003c!-- Multi-Company Rule -->\n \u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n \u003c/record>\n\n \u003c!-- User sees own records -->\n \u003crecord id=\"rule_custom_model_user\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: User Own Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[('user_id', '=', user.id)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\n \u003c!-- Manager sees all -->\n \u003crecord id=\"rule_custom_model_manager\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Manager All Records\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"domain_force\">[(1, '=', 1)]\u003c/field>\n \u003cfield name=\"groups\" eval=\"[(4, ref('group_custom_manager'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## v18 Security Checklist\n\n- [ ] All models have `ir.model.access.csv` entries\n- [ ] Multi-company models use `_check_company_auto = True`\n- [ ] Relational fields use `check_company=True` where appropriate\n- [ ] Record rules use `allowed_company_ids` for multi-company\n- [ ] Views use direct `invisible` attribute (not `attrs`)\n- [ ] SQL queries use `SQL()` builder\n- [ ] Type hints on all relational fields\n- [ ] Audit logging for sensitive operations\n- [ ] sudo() usage is minimal and justified\n- [ ] No hardcoded IDs\n\n## AI Agent Instructions (v18)\n\nWhen generating Odoo 18.0 security code:\n\n1. **Always use** `_check_company_auto = True` for multi-company models\n2. **Always use** `check_company=True` on relational fields referencing company-scoped models\n3. **Use** `allowed_company_ids` in record rule domains\n4. **Use** direct `invisible` attribute in views, not `attrs`\n5. **Use** `SQL()` builder for any raw SQL queries\n6. **Add** type hints to all relational fields\n7. **Never** use `attrs` attribute (removed in v17)\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14893,"content_sha256":"19ef5d589fb737cb5217a32c6d7b8357570a5e849495b567df38fa34312ee207"},{"filename":"skills/odoo-security-guide-19.md","content":"# Odoo Security Guide - Version 19.0\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ODOO 19.0 SECURITY PATTERNS ║\n║ This file contains ONLY Odoo 19.0 specific patterns. ║\n║ DO NOT use these patterns for other versions. ║\n║ NOTE: Odoo 19.0 is in DEVELOPMENT - patterns may change. ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version 19.0 Requirements\n\n- **Python**: 3.12+ required\n- **Key Features**: Full type annotations, mandatory SQL builder, OWL 3.x, enhanced security\n\n## Security Groups (v19 Syntax)\n\n```xml\n\u003c?xml version=\"1.0\" encoding=\"utf-8\"?>\n\u003codoo>\n \u003crecord id=\"module_category_custom\" model=\"ir.module.category\">\n \u003cfield name=\"name\">Custom Module\u003c/field>\n \u003cfield name=\"sequence\">100\u003c/field>\n \u003c/record>\n\n \u003crecord id=\"group_custom_user\" model=\"res.groups\">\n \u003cfield name=\"name\">User\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003c/record>\n\n \u003crecord id=\"group_custom_manager\" model=\"res.groups\">\n \u003cfield name=\"name\">Manager\u003c/field>\n \u003cfield name=\"category_id\" ref=\"module_category_custom\"/>\n \u003cfield name=\"implied_ids\" eval=\"[(4, ref('group_custom_user'))]\"/>\n \u003c/record>\n\u003c/odoo>\n```\n\n## Access Rights (v19)\n\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_custom_model_user,custom.model.user,model_custom_model,custom_module.group_custom_user,1,1,1,0\naccess_custom_model_manager,custom.model.manager,model_custom_model,custom_module.group_custom_manager,1,1,1,1\n```\n\n## Record Rules (v19 Syntax)\n\n### Multi-Company Rule (v19)\n\n```xml\n\u003c!-- v19: Enhanced multi-company with allowed_company_ids -->\n\u003crecord id=\"rule_custom_model_company\" model=\"ir.rule\">\n \u003cfield name=\"name\">Custom Model: Multi-Company\u003c/field>\n \u003cfield name=\"model_id\" ref=\"model_custom_model\"/>\n \u003cfield name=\"global\" eval=\"True\"/>\n \u003cfield name=\"domain_force\">[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', allowed_company_ids)\n ]\u003c/field>\n\u003c/record>\n```\n\n## Model Security (v19 Patterns - Full Type Annotations)\n\n```python\n# -*- coding: utf-8 -*-\nfrom __future__ import annotations\nfrom typing import Any\nfrom odoo import api, fields, models, Command, _\nfrom odoo.exceptions import AccessError, UserError, ValidationError\nfrom odoo.tools import SQL\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n _inherit = ['mail.thread', 'mail.activity.mixin']\n _check_company_auto = True\n\n # v19: Full type annotations on ALL fields\n name: str = fields.Char(\n string='Name',\n required=True,\n tracking=True,\n index='btree',\n )\n active: bool = fields.Boolean(default=True)\n sequence: int = fields.Integer(default=10)\n company_id: int = fields.Many2one(\n comodel_name='res.company',\n string='Company',\n default=lambda self: self.env.company,\n required=True,\n index=True,\n )\n partner_id: int = fields.Many2one(\n comodel_name='res.partner',\n string='Partner',\n check_company=True,\n index=True,\n )\n user_id: int = fields.Many2one(\n comodel_name='res.users',\n string='Responsible',\n default=lambda self: self.env.user,\n tracking=True,\n check_company=True,\n )\n amount: float = fields.Float(\n string='Amount',\n digits='Product Price',\n )\n currency_id: int = fields.Many2one(\n comodel_name='res.currency',\n string='Currency',\n default=lambda self: self.env.company.currency_id,\n )\n state: str = fields.Selection(\n selection=[\n ('draft', 'Draft'),\n ('confirmed', 'Confirmed'),\n ('done', 'Done'),\n ],\n string='Status',\n default='draft',\n tracking=True,\n copy=False,\n )\n line_ids: list[int] = fields.One2many(\n comodel_name='custom.secure.line',\n inverse_name='parent_id',\n string='Lines',\n )\n tag_ids: list[int] = fields.Many2many(\n comodel_name='custom.tag',\n string='Tags',\n )\n\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> SecureModel:\n \"\"\"v19: Full type annotations on method signatures.\"\"\"\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n \"\"\"v19: Typed write method.\"\"\"\n return super().write(vals)\n\n def action_sensitive_operation(self) -> None:\n \"\"\"Method with security checks and type hints.\"\"\"\n if not self.env.user.has_group('custom_module.group_manager'):\n raise AccessError(_(\"Only managers can perform this action.\"))\n self.check_access_rights('write')\n self.check_access_rule('write')\n self._execute_sensitive_operation()\n\n def _execute_sensitive_operation(self) -> None:\n \"\"\"Internal method with type hints.\"\"\"\n pass\n```\n\n### Mandatory SQL Builder (v19)\n\n```python\nfrom odoo import models\nfrom odoo.tools import SQL\n\nclass SecureModel(models.Model):\n _name = 'custom.secure'\n _description = 'Secure Model'\n\n def _get_secure_data(self) -> list[dict[str, Any]]:\n \"\"\"v19: SQL builder is MANDATORY for raw SQL.\"\"\"\n query = SQL(\n \"\"\"\n SELECT id, name, amount\n FROM %(table)s\n WHERE company_id = %(company_id)s\n AND active = %(active)s\n AND create_uid = %(user_id)s\n ORDER BY %(order)s\n \"\"\",\n table=SQL.identifier(self._table),\n company_id=self.env.company.id,\n active=True,\n user_id=self.env.user.id,\n order=SQL.identifier('create_date') + SQL(' DESC'),\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n\n def _execute_complex_query(self) -> list[dict[str, Any]]:\n \"\"\"v19: Complex query with SQL builder.\"\"\"\n query = SQL(\n \"\"\"\n SELECT\n m.id,\n m.name,\n p.name AS partner_name,\n COALESCE(SUM(l.amount), 0) AS total_amount\n FROM %(main_table)s m\n LEFT JOIN %(partner_table)s p ON m.partner_id = p.id\n LEFT JOIN %(line_table)s l ON l.parent_id = m.id\n WHERE m.company_id IN %(company_ids)s\n AND m.state = %(state)s\n GROUP BY m.id, m.name, p.name\n HAVING COALESCE(SUM(l.amount), 0) > %(min_amount)s\n \"\"\",\n main_table=SQL.identifier(self._table),\n partner_table=SQL.identifier('res_partner'),\n line_table=SQL.identifier('custom_secure_line'),\n company_ids=tuple(self.env.companies.ids),\n state='confirmed',\n min_amount=0,\n )\n self.env.cr.execute(query)\n return self.env.cr.dictfetchall()\n```\n\n## View Security (v19 Syntax)\n\n### Field Visibility\n\n```xml\n\u003cform>\n \u003csheet>\n \u003cgroup>\n \u003cfield name=\"name\"/>\n\n \u003c!-- v19: Direct invisible with Python expression -->\n \u003cfield name=\"internal_notes\"\n invisible=\"not user_has_groups('custom_module.group_manager')\"/>\n\n \u003c!-- v19: Conditional visibility -->\n \u003cfield name=\"secret_field\"\n invisible=\"state == 'draft' or not user_has_groups('custom_module.group_manager')\"/>\n\n \u003c!-- v19: Combined conditions -->\n \u003cfield name=\"amount\"\n readonly=\"state != 'draft'\"\n required=\"state == 'confirmed'\"/>\n \u003c/group>\n \u003c/sheet>\n\u003c/form>\n```\n\n### Button Security (v19)\n\n```xml\n\u003cbutton name=\"action_approve\"\n string=\"Approve\"\n type=\"object\"\n groups=\"custom_module.group_manager\"\n invisible=\"state != 'pending'\"\n class=\"btn-primary\"/>\n\n\u003cbutton name=\"action_admin\"\n string=\"Admin Action\"\n type=\"object\"\n invisible=\"not user_has_groups('base.group_system')\"/>\n```\n\n## Enhanced Audit Trail (v19)\n\n```python\nfrom __future__ import annotations\nimport json\nfrom typing import Any\nfrom odoo import api, fields, models\n\nclass AuditLog(models.Model):\n _name = 'custom.audit.log'\n _description = 'Audit Log'\n _order = 'create_date desc'\n\n model: str = fields.Char(required=True, index=True)\n res_id: int = fields.Integer(required=True, index=True)\n action: str = fields.Selection([\n ('create', 'Created'),\n ('write', 'Updated'),\n ('unlink', 'Deleted'),\n ('access', 'Accessed'),\n ('export', 'Exported'),\n ], required=True, index=True)\n user_id: int = fields.Many2one('res.users', required=True, index=True)\n timestamp: fields.Datetime = fields.Datetime(\n default=fields.Datetime.now,\n required=True,\n index=True,\n )\n old_values: str = fields.Text()\n new_values: str = fields.Text()\n ip_address: str = fields.Char(index=True)\n user_agent: str = fields.Char()\n\n @api.model_create_multi\n def create(self, vals_list: list[dict[str, Any]]) -> AuditLog:\n # Audit logs should be immutable\n return super().create(vals_list)\n\n def write(self, vals: dict[str, Any]) -> bool:\n raise UserError(_(\"Audit logs cannot be modified.\"))\n\n def unlink(self) -> bool:\n raise UserError(_(\"Audit logs cannot be deleted.\"))\n```\n\n## v19 Security Checklist\n\n- [ ] All models have `ir.model.access.csv` entries\n- [ ] Use `_check_company_auto = True` for multi-company models\n- [ ] Use `check_company=True` on relational fields\n- [ ] Use `allowed_company_ids` in record rules\n- [ ] Full type annotations on ALL fields\n- [ ] Full type annotations on ALL method signatures\n- [ ] Use `SQL()` builder for ALL raw SQL (mandatory)\n- [ ] Views use direct `invisible` attribute\n- [ ] No `attrs` attribute in views\n\n## Key Differences from v18\n\n| Feature | v18 | v19 |\n|---------|-----|-----|\n| Type hints | Recommended | Mandatory |\n| SQL builder | Recommended | Mandatory |\n| Python | 3.11+ | 3.12+ |\n| OWL | 2.x | 3.x |\n\n## AI Agent Instructions (v19)\n\nWhen generating Odoo 19.0 security code:\n\n1. **ALWAYS** add type hints to ALL fields\n2. **ALWAYS** add type hints to ALL method signatures\n3. **ALWAYS** use `SQL()` builder for raw SQL (mandatory)\n4. **ALWAYS** use `_check_company_auto = True` for multi-company\n5. **ALWAYS** use `check_company=True` on relational fields\n6. **Use** `allowed_company_ids` in record rules\n7. **Use** direct `invisible` attribute (no `attrs`)\n8. **Use** Python 3.12+ features where appropriate\n9. **Verify** patterns against `master` branch of odoo/odoo GitHub\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11144,"content_sha256":"070567e8f523192164002eefc6c82da3c5b52c0eae8ac1277dc20b63fb29829d"},{"filename":"skills/odoo-security-guide-all.md","content":"# Odoo Security Guide - Core Concepts (All Versions)\n\nThis document covers security concepts that are consistent across all Odoo versions (14-19+). For version-specific implementation details, see the version-specific files.\n\n## Security Architecture Overview\n\nOdoo implements a multi-layered security model:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ User Request │\n└─────────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ Layer 1: Authentication │\n│ - User login verification │\n│ - Session management │\n│ - 2FA (if enabled) │\n└─────────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ Layer 2: Menu/Action Access │\n│ - Menu visibility (groups attribute) │\n│ - Action access (groups attribute) │\n└─────────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ Layer 3: Model Access Rights │\n│ - ir.model.access.csv │\n│ - CRUD permissions per group │\n└─────────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ Layer 4: Record Rules │\n│ - ir.rule records │\n│ - Row-level filtering │\n│ - Domain-based access control │\n└─────────────────────────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ Layer 5: Field-Level Security │\n│ - groups attribute on fields │\n│ - Visibility in views │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## Core Security Components\n\n### 1. Security Groups (res.groups)\n\nGroups are the foundation of Odoo's permission system. Users are assigned to groups, and permissions are granted to groups.\n\n**Key Concepts:**\n- **Category**: Groups can be organized into categories for the UI\n- **Implied Groups**: Group inheritance - a manager group implies user group\n- **Users**: Direct assignment of users to groups\n\n**Group Hierarchy Pattern:**\n```\nAdministrator\n └── Manager (implies Administrator permissions)\n └── User (implies Manager permissions)\n```\n\n### 2. Access Rights (ir.model.access)\n\nAccess rights define CRUD (Create, Read, Update, Delete) permissions at the model level.\n\n**Key Concepts:**\n- One record per model/group combination\n- Permissions are additive (if any group grants access, user has access)\n- Missing access rights = no access (secure by default)\n\n**Permission Matrix:**\n| Permission | Meaning |\n|------------|---------|\n| perm_read | Can view records |\n| perm_write | Can modify existing records |\n| perm_create | Can create new records |\n| perm_unlink | Can delete records |\n\n### 3. Record Rules (ir.rule)\n\nRecord rules filter which records a user can access within a model they have access rights to.\n\n**Key Concepts:**\n- **Domain-based**: Uses Odoo domain syntax\n- **Global Rules**: Apply to all users (no group specified)\n- **Group Rules**: Apply only to specific groups\n- **Combination**: Multiple rules are OR'd within a group, AND'd across groups\n\n**Rule Evaluation:**\n```\nFinal Access = (Global Rules AND'd) AND (Group Rules OR'd per group)\n```\n\n### 4. Field-Level Security\n\nIndividual fields can be restricted to specific groups using the `groups` attribute.\n\n**Key Concepts:**\n- Field is invisible and inaccessible to users not in the group\n- Applies to both UI and API access\n- Can specify multiple groups (comma-separated)\n\n## Security Best Practices (All Versions)\n\n### Principle of Least Privilege\n\nAlways grant the minimum permissions necessary:\n\n```\n✓ GOOD: User can only read and create, manager can also edit and delete\n✗ BAD: Everyone has full access \"for convenience\"\n```\n\n### Defense in Depth\n\nUse multiple security layers:\n\n```\n✓ GOOD: Access rights + Record rules + Field groups\n✗ BAD: Relying only on UI hiding for security\n```\n\n### Secure by Default\n\nWhen no permissions are defined, access should be denied:\n\n```\n✓ GOOD: New model has no access until explicitly granted\n✗ BAD: New model accessible to everyone by default\n```\n\n### Avoid sudo() Abuse\n\nThe `sudo()` method bypasses security checks. Use it sparingly:\n\n```python\n# ✓ GOOD: sudo() for system operations only\nsequence = self.env['ir.sequence'].sudo().next_by_code('my.model')\n\n# ✗ BAD: sudo() to bypass permission checks\nrecord.sudo().write({'sensitive_field': value}) # DANGEROUS\n```\n\n### Never Hardcode IDs\n\nUse XML IDs for references:\n\n```python\n# ✓ GOOD: Using XML ID\nmanager_group = self.env.ref('my_module.group_manager')\n\n# ✗ BAD: Hardcoded database ID\nmanager_group = self.env['res.groups'].browse(7)\n```\n\n## Multi-Company Security\n\nMulti-company is a critical security concern in Odoo.\n\n### Core Concepts\n\n- **company_id**: Field linking record to a company\n- **company_ids**: User's allowed companies\n- **Record Rules**: Filter records by company\n\n### Standard Pattern\n\n```\nUser has access to companies: [1, 3]\nRecord has company_id: 1\n→ User CAN access (1 is in [1, 3])\n\nRecord has company_id: 2\n→ User CANNOT access (2 is not in [1, 3])\n\nRecord has company_id: False (no company)\n→ User CAN access (shared record)\n```\n\n### Multi-Company Domain Pattern\n\n```python\n# Standard multi-company domain (all versions)\n[\n '|',\n ('company_id', '=', False),\n ('company_id', 'in', company_ids)\n]\n```\n\n## Portal and Public Access\n\n### Portal Users\n\nPortal users are external users (customers, vendors) with limited access:\n\n- Belong to `base.group_portal`\n- Cannot access internal data\n- Access controlled via specific record rules\n\n### Public Users\n\nPublic users are anonymous website visitors:\n\n- Belong to `base.group_public`\n- Very restricted access\n- Used for public website pages\n\n### Security Pattern for Portal\n\n```\nInternal Users → Full access to their scope\nPortal Users → Access only to their own records\nPublic Users → Access only to published/public records\n```\n\n## Audit and Compliance\n\n### Tracking Changes\n\nOdoo provides built-in change tracking via `mail.thread`:\n\n- Records who changed what and when\n- Visible in the chatter\n- Configurable per field with `tracking=True`\n\n### Audit Log Considerations\n\nFor compliance requirements:\n- Track all CRUD operations\n- Record user, timestamp, IP address\n- Store old and new values\n- Make logs immutable\n\n## Common Security Vulnerabilities\n\n### SQL Injection\n\n**Risk**: Raw SQL without proper escaping\n\n```python\n# ✗ DANGEROUS\nself.env.cr.execute(f\"SELECT * FROM res_partner WHERE name = '{user_input}'\")\n\n# ✓ SAFE: Use parameters\nself.env.cr.execute(\"SELECT * FROM res_partner WHERE name = %s\", [user_input])\n\n# ✓ SAFER: Use ORM\nself.env['res.partner'].search([('name', '=', user_input)])\n```\n\n### Cross-Site Scripting (XSS)\n\n**Risk**: Unescaped user input in views\n\n```xml\n\u003c!-- ✗ DANGEROUS: Using t-raw with user input -->\n\u003ct t-raw=\"record.user_input\"/>\n\n\u003c!-- ✓ SAFE: Using t-esc (default escaping) -->\n\u003ct t-esc=\"record.user_input\"/>\n```\n\n### Insecure Direct Object Reference (IDOR)\n\n**Risk**: Accessing records without proper permission checks\n\n```python\n# ✗ DANGEROUS: No access check\nrecord = self.env['my.model'].browse(user_provided_id)\nreturn record.sensitive_data\n\n# ✓ SAFE: Access check performed\nrecord = self.env['my.model'].browse(user_provided_id)\nrecord.check_access_rights('read')\nrecord.check_access_rule('read')\nreturn record.sensitive_data\n```\n\n## Security Testing Checklist\n\n### Before Deployment\n\n- [ ] All models have access rights defined\n- [ ] Sensitive models have record rules\n- [ ] Multi-company rules are in place\n- [ ] Portal access is properly restricted\n- [ ] No sudo() usage without justification\n- [ ] No hardcoded IDs or credentials\n- [ ] SQL queries use parameters or ORM\n- [ ] User input is escaped in views\n- [ ] Field groups restrict sensitive data\n- [ ] Audit logging for compliance\n\n### Testing Approach\n\n1. Test as each user role (admin, manager, user, portal)\n2. Verify each security layer independently\n3. Test edge cases (no company, inactive records)\n4. Attempt unauthorized access deliberately\n5. Review sudo() usage in code\n\n## Glossary\n\n| Term | Definition |\n|------|------------|\n| Access Rights | Model-level CRUD permissions |\n| Record Rules | Row-level domain filters |\n| Security Groups | Collections of permissions assigned to users |\n| sudo() | Method to bypass security checks |\n| Domain | List of tuples for filtering records |\n| Multi-company | Support for multiple legal entities |\n| Portal | External user access (customers, vendors) |\n\n---\n\n**Note**: This document covers concepts that apply to all Odoo versions. For version-specific implementation syntax and patterns, refer to the appropriate version-specific file (e.g., `odoo-security-guide-18.md`).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":11534,"content_sha256":"e038211f4603838093dc2e088e91bef7dfdafba2855279c6490a0821ce46d883"},{"filename":"skills/odoo-security-guide.md","content":"# Odoo Security Guide - Version Dispatcher\n\n## CRITICAL: VERSION-SPECIFIC REQUIREMENTS\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ ║\n║ ⚠️ MANDATORY VERSION MATCHING ⚠️ ║\n║ ║\n║ You MUST use the version-specific security guide that matches your ║\n║ target Odoo version. Using patterns from the wrong version WILL ║\n║ cause errors, security vulnerabilities, or deprecated code. ║\n║ ║\n║ BEFORE proceeding, identify your target Odoo version and load the ║\n║ corresponding file. This is NOT optional. ║\n║ ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Version-Specific Files\n\n| Target Version | File to Use | Status |\n|----------------|-------------|--------|\n| Odoo 14.0 | `odoo-security-guide-14.md` | Legacy |\n| Odoo 15.0 | `odoo-security-guide-15.md` | Legacy |\n| Odoo 16.0 | `odoo-security-guide-16.md` | Supported |\n| Odoo 17.0 | `odoo-security-guide-17.md` | Supported |\n| Odoo 18.0 | `odoo-security-guide-18.md` | Current |\n| Odoo 19.0 | `odoo-security-guide-19.md` | Development |\n| All versions | `odoo-security-guide-all.md` | Core concepts |\n\n## Migration Guides\n\nWhen upgrading modules between versions, use the migration guides:\n\n| Migration Path | File |\n|----------------|------|\n| 14.0 → 15.0 | `odoo-security-guide-14-15.md` |\n| 15.0 → 16.0 | `odoo-security-guide-15-16.md` |\n| 16.0 → 17.0 | `odoo-security-guide-16-17.md` |\n| 17.0 → 18.0 | `odoo-security-guide-17-18.md` |\n| 18.0 → 19.0 | `odoo-security-guide-18-19.md` |\n\n## How to Use This Skill\n\n### Step 1: Identify Target Version\n\n```\nQUESTION: What Odoo version are you targeting?\n- If generating NEW code → Use single version file (e.g., odoo-security-guide-18.md)\n- If UPGRADING code → Use migration file (e.g., odoo-security-guide-17-18.md)\n- If learning CONCEPTS → Use odoo-security-guide-all.md\n```\n\n### Step 2: Load the Correct File\n\n**For AI Agents**: You MUST read the version-specific file before generating any security code:\n\n```\n# Example for Odoo 18.0 project\nRead: skills/odoo-security-guide-18.md\n\n# Example for upgrading from 17.0 to 18.0\nRead: skills/odoo-security-guide-17-18.md\n```\n\n### Step 3: Apply Patterns\n\nOnly use patterns from the loaded version-specific file. Never mix patterns from different versions.\n\n## Version Detection Hints\n\nIf the version is not explicitly stated, look for these clues:\n\n| Indicator | Version |\n|-----------|---------|\n| `@api.multi` decorator | 14.0 (deprecated in 15.0+) |\n| `track_visibility` parameter | 14.0-15.0 |\n| `tracking` parameter | 15.0+ |\n| `Command` class usage | 16.0+ |\n| `attrs` in views | 14.0-16.0 |\n| Direct `invisible`/`readonly` | 17.0+ |\n| `_check_company_auto` | 18.0+ |\n| Type hints on fields | 18.0+ |\n| `SQL()` builder | 18.0+ |\n\n## Quick Reference: Major Security Changes by Version\n\n### v14 → v15\n- No major security API changes\n\n### v15 → v16\n- `Command` class introduced for x2many security patterns\n- Enhanced record rule evaluation\n\n### v16 → v17\n- `attrs` removed from views - security visibility changed\n- `invisible` attribute now takes Python expressions\n\n### v17 → v18\n- `_check_company_auto` for automatic company validation\n- `check_company` parameter on Many2one fields\n- Enhanced field-level security\n\n### v18 → v19\n- Stricter type checking\n- Enhanced audit capabilities\n\n---\n\n**REMINDER**: Do not use this dispatcher file for actual security implementation. Always load and follow the version-specific file for your target Odoo version.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4323,"content_sha256":"9383449f4d54e4258712dd4379613112279d2b1318306ef48be8379826a12cb7"},{"filename":"skills/odoo-test-patterns.md","content":"# Odoo Test Patterns Guide\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ TEST PATTERNS GUIDE ║\n║ Comprehensive testing patterns for Odoo modules across all versions ║\n║ Generate when include_tests: true is specified ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Test File Structure\n\n```\n{module_name}/\n├── tests/\n│ ├── __init__.py\n│ ├── common.py # Shared test data and base classes\n│ ├── test_{model_name}.py # Model unit tests\n│ ├── test_security.py # Security/access tests\n│ └── test_integration.py # Integration tests\n```\n\n## Test Class Hierarchy\n\n```python\n# tests/__init__.py\nfrom . import test_my_model\nfrom . import test_security\nfrom . import test_integration\n```\n\n## Base Test Classes\n\n### Common Test Setup\n\n```python\n# tests/common.py\nfrom odoo.tests import TransactionCase, tagged\n\nclass TestMyModuleCommon(TransactionCase):\n \"\"\"Common setup for all tests in this module\"\"\"\n\n @classmethod\n def setUpClass(cls):\n super().setUpClass()\n\n # Create shared test data\n cls.company = cls.env.ref('base.main_company')\n cls.user_admin = cls.env.ref('base.user_admin')\n\n # Create test partner\n cls.partner = cls.env['res.partner'].create({\n 'name': 'Test Partner',\n 'email': '[email protected]',\n })\n\n # Create test user with specific groups\n cls.user_manager = cls.env['res.users'].create({\n 'name': 'Test Manager',\n 'login': 'test_manager',\n 'email': '[email protected]',\n 'groups_id': [(6, 0, [\n cls.env.ref('base.group_user').id,\n cls.env.ref('my_module.group_manager').id,\n ])],\n })\n\n cls.user_basic = cls.env['res.users'].create({\n 'name': 'Test User',\n 'login': 'test_user',\n 'email': '[email protected]',\n 'groups_id': [(6, 0, [\n cls.env.ref('base.group_user').id,\n ])],\n })\n```\n\n## Unit Tests\n\n### Basic Model Tests\n\n```python\n# tests/test_my_model.py\nfrom odoo.tests import tagged\nfrom odoo.exceptions import ValidationError, UserError\nfrom .common import TestMyModuleCommon\n\n\n@tagged('post_install', '-at_install')\nclass TestMyModel(TestMyModuleCommon):\n \"\"\"Unit tests for my.model\"\"\"\n\n def test_create_record(self):\n \"\"\"Test basic record creation\"\"\"\n record = self.env['my.model'].create({\n 'name': 'Test Record',\n 'partner_id': self.partner.id,\n })\n self.assertTrue(record.id)\n self.assertEqual(record.name, 'Test Record')\n self.assertEqual(record.state, 'draft')\n\n def test_create_with_defaults(self):\n \"\"\"Test creation with default values\"\"\"\n record = self.env['my.model'].create({\n 'name': 'Test',\n })\n # Check default company\n self.assertEqual(record.company_id, self.env.company)\n # Check default state\n self.assertEqual(record.state, 'draft')\n\n def test_name_required(self):\n \"\"\"Test that name is required\"\"\"\n with self.assertRaises(Exception):\n self.env['my.model'].create({})\n\n def test_state_workflow(self):\n \"\"\"Test state transitions\"\"\"\n record = self.env['my.model'].create({\n 'name': 'Workflow Test',\n })\n\n # Add required line for confirmation\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Line 1',\n 'quantity': 1,\n })\n\n # Test confirm\n self.assertEqual(record.state, 'draft')\n record.action_confirm()\n self.assertEqual(record.state, 'confirmed')\n\n # Test done\n record.action_done()\n self.assertEqual(record.state, 'done')\n\n def test_confirm_without_lines_fails(self):\n \"\"\"Test that confirmation requires lines\"\"\"\n record = self.env['my.model'].create({\n 'name': 'No Lines Test',\n })\n\n with self.assertRaises(UserError):\n record.action_confirm()\n```\n\n### Computed Field Tests\n\n```python\n@tagged('post_install', '-at_install')\nclass TestMyModelComputed(TestMyModuleCommon):\n \"\"\"Test computed fields\"\"\"\n\n def test_compute_total(self):\n \"\"\"Test total computation\"\"\"\n record = self.env['my.model'].create({\n 'name': 'Computed Test',\n })\n\n # Create lines\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Line 1',\n 'quantity': 2,\n 'price_unit': 10.0,\n })\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Line 2',\n 'quantity': 3,\n 'price_unit': 20.0,\n })\n\n # Total should be (2*10) + (3*20) = 80\n self.assertEqual(record.total_amount, 80.0)\n\n def test_compute_line_count(self):\n \"\"\"Test line count computation\"\"\"\n record = self.env['my.model'].create({'name': 'Count Test'})\n self.assertEqual(record.line_count, 0)\n\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Line 1',\n })\n self.assertEqual(record.line_count, 1)\n```\n\n### Constraint Tests\n\n```python\n@tagged('post_install', '-at_install')\nclass TestMyModelConstraints(TestMyModuleCommon):\n \"\"\"Test model constraints\"\"\"\n\n def test_date_constraint(self):\n \"\"\"Test date_start must be before date_end\"\"\"\n with self.assertRaises(ValidationError):\n self.env['my.model'].create({\n 'name': 'Date Test',\n 'date_start': '2024-12-31',\n 'date_end': '2024-01-01', # Before start\n })\n\n def test_unique_code_per_company(self):\n \"\"\"Test unique code constraint\"\"\"\n self.env['my.model'].create({\n 'name': 'First',\n 'code': 'TEST001',\n })\n\n with self.assertRaises(Exception): # IntegrityError wrapped\n self.env['my.model'].create({\n 'name': 'Duplicate',\n 'code': 'TEST001', # Same code\n })\n\n def test_positive_quantity(self):\n \"\"\"Test quantity must be positive\"\"\"\n record = self.env['my.model'].create({'name': 'Qty Test'})\n\n with self.assertRaises(ValidationError):\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Negative',\n 'quantity': -1,\n })\n```\n\n## Security Tests\n\n```python\n# tests/test_security.py\nfrom odoo.tests import tagged\nfrom odoo.exceptions import AccessError\nfrom .common import TestMyModuleCommon\n\n\n@tagged('post_install', '-at_install')\nclass TestMyModelSecurity(TestMyModuleCommon):\n \"\"\"Security and access rights tests\"\"\"\n\n def test_user_can_read_own_records(self):\n \"\"\"Test basic user can read their own records\"\"\"\n record = self.env['my.model'].with_user(self.user_basic).create({\n 'name': 'User Record',\n })\n\n # Should be able to read\n record.with_user(self.user_basic).read(['name'])\n\n def test_user_cannot_delete(self):\n \"\"\"Test basic user cannot delete records\"\"\"\n record = self.env['my.model'].create({'name': 'Test'})\n\n with self.assertRaises(AccessError):\n record.with_user(self.user_basic).unlink()\n\n def test_manager_can_delete(self):\n \"\"\"Test manager can delete records\"\"\"\n record = self.env['my.model'].create({'name': 'Test'})\n record.with_user(self.user_manager).unlink()\n self.assertFalse(record.exists())\n\n def test_multi_company_isolation(self):\n \"\"\"Test records are isolated by company\"\"\"\n # Create second company\n company2 = self.env['res.company'].create({\n 'name': 'Company 2',\n })\n\n # Create user in company2\n user_company2 = self.env['res.users'].create({\n 'name': 'User Company 2',\n 'login': 'user_c2',\n 'company_id': company2.id,\n 'company_ids': [(6, 0, [company2.id])],\n })\n\n # Create record in main company\n record = self.env['my.model'].create({\n 'name': 'Main Company Record',\n 'company_id': self.company.id,\n })\n\n # User in company2 should not see it\n records = self.env['my.model'].with_user(user_company2).search([])\n self.assertNotIn(record, records)\n\n def test_sudo_bypasses_rules(self):\n \"\"\"Test sudo() bypasses record rules\"\"\"\n record = self.env['my.model'].create({'name': 'Test'})\n\n # Admin can access via sudo\n record_sudo = record.sudo()\n self.assertTrue(record_sudo.exists())\n```\n\n## Integration Tests\n\n```python\n# tests/test_integration.py\nfrom odoo.tests import tagged, HttpCase\nfrom .common import TestMyModuleCommon\n\n\n@tagged('post_install', '-at_install')\nclass TestMyModelIntegration(TestMyModuleCommon):\n \"\"\"Integration tests\"\"\"\n\n def test_full_workflow(self):\n \"\"\"Test complete record lifecycle\"\"\"\n # Create\n record = self.env['my.model'].create({\n 'name': 'Full Workflow Test',\n 'partner_id': self.partner.id,\n })\n self.assertEqual(record.state, 'draft')\n\n # Add lines\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Line 1',\n 'quantity': 1,\n 'price_unit': 100,\n })\n\n # Confirm\n record.action_confirm()\n self.assertEqual(record.state, 'confirmed')\n\n # Check partner was notified (if mail integration)\n if hasattr(record, 'message_ids'):\n self.assertTrue(len(record.message_ids) > 0)\n\n # Complete\n record.action_done()\n self.assertEqual(record.state, 'done')\n\n # Cannot delete done records\n from odoo.exceptions import UserError\n with self.assertRaises(UserError):\n record.unlink()\n\n def test_copy_record(self):\n \"\"\"Test record duplication\"\"\"\n record = self.env['my.model'].create({\n 'name': 'Original',\n 'state': 'confirmed',\n })\n\n # Add line\n self.env['my.model.line'].create({\n 'model_id': record.id,\n 'name': 'Line',\n })\n\n # Copy\n copy = record.copy()\n\n self.assertNotEqual(copy.id, record.id)\n self.assertIn('(copy)', copy.name)\n self.assertEqual(copy.state, 'draft') # Reset to draft\n self.assertEqual(len(copy.line_ids), len(record.line_ids)) # Lines copied\n\n def test_batch_operations(self):\n \"\"\"Test batch create and write\"\"\"\n # Batch create\n records = self.env['my.model'].create([\n {'name': 'Batch 1'},\n {'name': 'Batch 2'},\n {'name': 'Batch 3'},\n ])\n self.assertEqual(len(records), 3)\n\n # Batch write\n records.write({'active': False})\n for record in records:\n self.assertFalse(record.active)\n```\n\n## HTTP/Tour Tests\n\n```python\n# tests/test_ui.py\nfrom odoo.tests import tagged, HttpCase\n\n\n@tagged('post_install', '-at_install')\nclass TestMyModelUI(HttpCase):\n \"\"\"UI/Tour tests\"\"\"\n\n def test_ui_create_record(self):\n \"\"\"Test creating record via UI\"\"\"\n self.start_tour(\n '/web',\n 'my_module_create_tour',\n login='admin',\n )\n```\n\n## Test Tags Reference\n\n| Tag | Meaning |\n|-----|---------|\n| `post_install` | Run after module installation |\n| `-at_install` | Don't run during installation |\n| `standard` | Standard test (default) |\n| `external` | Requires external services |\n\n## Version-Specific Test Patterns\n\n### v16+ with Command Class\n\n```python\ndef test_create_with_command(self):\n \"\"\"Test creation with Command class (v16+)\"\"\"\n from odoo.fields import Command\n\n record = self.env['my.model'].create({\n 'name': 'Command Test',\n 'line_ids': [\n Command.create({'name': 'Line 1', 'quantity': 1}),\n Command.create({'name': 'Line 2', 'quantity': 2}),\n ],\n })\n self.assertEqual(len(record.line_ids), 2)\n```\n\n### v17+ Visibility Testing\n\n```python\ndef test_view_visibility(self):\n \"\"\"Test view visibility conditions (v17+)\"\"\"\n record = self.env['my.model'].create({\n 'name': 'Visibility Test',\n 'state': 'draft',\n })\n\n # Get form view\n view = self.env['ir.ui.view'].search([\n ('model', '=', 'my.model'),\n ('type', '=', 'form'),\n ], limit=1)\n\n # In v17+, visibility uses Python expressions\n # Test that the view renders correctly\n fields_view = self.env['my.model'].get_views(\n [(view.id, 'form')]\n )['views']['form']\n self.assertIn('invisible', str(fields_view))\n```\n\n### v18+ Multi-Company Testing\n\n```python\ndef test_check_company_auto(self):\n \"\"\"Test automatic company checking (v18+)\"\"\"\n # Create partner in different company\n company2 = self.env['res.company'].create({'name': 'Company 2'})\n partner_c2 = self.env['res.partner'].create({\n 'name': 'Partner C2',\n 'company_id': company2.id,\n })\n\n # Should raise if check_company=True\n with self.assertRaises(Exception):\n self.env['my.model'].create({\n 'name': 'Cross Company',\n 'company_id': self.company.id,\n 'partner_id': partner_c2.id, # Different company\n })\n```\n\n## Running Tests\n\n```bash\n# Run all tests for a module\n./odoo-bin -d testdb -i my_module --test-enable --stop-after-init\n\n# Run specific test class\n./odoo-bin -d testdb --test-tags my_module.TestMyModel\n\n# Run with coverage\ncoverage run ./odoo-bin -d testdb -i my_module --test-enable --stop-after-init\ncoverage report\n```\n\n## Test Generation Checklist\n\nFor each model, generate tests for:\n\n- [ ] Basic CRUD operations (create, read, update, delete)\n- [ ] All computed fields\n- [ ] All constraints (Python and SQL)\n- [ ] State workflow transitions\n- [ ] Access rights by user group\n- [ ] Record rules (multi-company, ownership)\n- [ ] Onchange methods\n- [ ] Action methods (buttons)\n- [ ] Copy behavior\n- [ ] Batch operations\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":14715,"content_sha256":"dfc4d4f7f4e27b6b0981f828c501fcc2647bc443c3153ed7de01f39ca31fdc9c"},{"filename":"skills/odoo-version-knowledge-14-15.md","content":"# Odoo Version Knowledge: 14 to 15 Migration\n\n```\n╔══════════════════════════════════════════════════════════════════════════════╗\n║ VERSION MIGRATION: 14.0 → 15.0 ║\n║ Critical changes, breaking changes, and migration patterns ║\n╚══════════════════════════════════════════════════════════════════════════════╝\n```\n\n## Breaking Changes Summary\n\n| Category | Change | Impact |\n|----------|--------|--------|\n| API | `@api.multi` REMOVED | High - Update all methods |\n| Fields | `track_visibility` deprecated | Medium - Use `tracking` |\n| OWL | OWL 1.x introduced | High - New frontend framework |\n| Assets | `assets_backend` pattern change | Medium - Update manifests |\n| Python | Python 3.8+ required | Low - Check compatibility |\n\n## Critical Migration: @api.multi Removal\n\n**The most significant breaking change in v15**\n\n### Before (v14)\n```python\nfrom odoo import api, models\n\nclass SaleOrder(models.Model):\n _inherit = 'sale.order'\n\n @api.multi\n def action_confirm(self):\n for order in self:\n order.state = 'sale'\n return True\n\n @api.multi\n def action_cancel(self):\n return self.write({'state': 'cancel'})\n```\n\n### After (v15)\n```python\nfrom odoo import api, models\n\nclass SaleOrder(models.Model):\n _inherit = 'sale.order'\n\n def action_confirm(self):\n for order in self:\n order.state = 'sale'\n return True\n\n def action_cancel(self):\n return self.write({'state': 'cancel'})\n```\n\n### Migration Script\n```python\n# Find and remove @api.multi\nimport re\n\ndef migrate_api_multi(content):\n # Remove @api.multi decorator lines\n content = re.sub(r'\\n\\s*@api\\.multi\\n', '\\n', content)\n return content\n```\n\n## Field Tracking Migration\n\n### Before (v14)\n```python\nname = fields.Char(track_visibility='onchange')\nstate = fields.Selection([...], track_visibility='always')\npartner_id = fields.Many2one('res.partner', track_visibility='onchange')\n```\n\n### After (v15)\n```python\nname = fields.Char(tracking=True)\nstate = fields.Selection([...], tracking=True)\npartner_id = fields.Many2one('res.partner', tracking=True)\n```\n\n### Migration Notes\n- `track_visibility='onchange'` → `tracking=True`\n- `track_visibility='always'` → `tracking=True`\n- `track_visibility=False` → Remove or `tracking=False`\n\n## OWL Introduction (v15)\n\nv15 introduces OWL 1.x as the new frontend framework alongside legacy JS.\n\n### Legacy Widget (v14)\n```javascript\nodoo.define('my_module.MyWidget', function (require) {\n var Widget = require('web.Widget');\n\n var MyWidget = Widget.extend({\n template: 'my_module.MyTemplate',\n events: {\n 'click .my-button': '_onClick',\n },\n start: function () {\n return this._super.apply(this, arguments);\n },\n _onClick: function () {\n this.do_action({type: 'ir.actions.act_window'});\n },\n });\n\n return MyWidget;\n});\n```\n\n### OWL 1.x Component (v15)\n```javascript\n/** @odoo-module **/\n\nconst { Component } = owl;\nconst { xml } = owl.tags;\n\nclass MyComponent extends Component {\n static template = xml`\n \u003cdiv class=\"my-component\">\n \u003cbutton t-on-click=\"onClick\">Click Me\u003c/button>\n \u003c/div>\n `;\n\n onClick() {\n this.env.services.action.doAction({type: 'ir.actions.act_window'});\n }\n}\n```\n\n## Asset Bundle Changes\n\n### Before (v14 manifest)\n```python\n'qweb': [\n 'static/src/xml/my_templates.xml',\n],\n```\n\n### After (v15 manifest)\n```python\n'assets': {\n 'web.assets_backend': [\n 'my_module/static/src/js/my_component.js',\n 'my_module/static/src/scss/my_styles.scss',\n ],\n 'web.assets_qweb': [\n 'my_module/static/src/xml/my_templates.xml',\n ],\n},\n```\n\n## Removed/Deprecated Features\n\n| Feature | Status | Replacement |\n|---------|--------|-------------|\n| `@api.multi` | REMOVED | No decorator needed |\n| `@api.one` | REMOVED | Use loop in method |\n| `track_visibility` | Deprecated | `tracking=True` |\n| `qweb` in manifest | Deprecated | Use `assets` |\n| `website_published` | Deprecated | `is_published` |\n\n## GitHub Verification URLs\n\n```\n# v14 reference\nhttps://raw.githubusercontent.com/odoo/odoo/14.0/odoo/api.py\n\n# v15 reference (note: @api.multi removed)\nhttps://raw.githubusercontent.com/odoo/odoo/15.0/odoo/api.py\n\n# Compare sale.order between versions\nhttps://raw.githubusercontent.com/odoo/odoo/14.0/addons/sale/models/sale_order.py\nhttps://raw.githubusercontent.com/odoo/odoo/15.0/addons/sale/models/sale_order.py\n```\n\n## Migration Checklist\n\n- [ ] Remove all `@api.multi` decorators\n- [ ] Remove all `@api.one` decorators (rewrite logic)\n- [ ] Replace `track_visibility` with `tracking`\n- [ ] Update manifest `qweb` to `assets`\n- [ ] Test all button actions work without decorators\n- [ ] Verify field tracking still works\n- [ ] Update Python compatibility (3.8+)\n- [ ] Consider OWL for new frontend components\n\n## Common Migration Errors\n\n### Error: `AttributeError: module 'odoo.api' has no attribute 'multi'`\n**Fix**: Remove `@api.multi` decorator\n\n### Error: `Unknown field 'track_visibility'`\n**Fix**: Replace with `tracking=True`\n\n### Error: `qweb key not supported in manifest`\n**Fix**: Move to `assets.web.assets_qweb`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5596,"content_sha256":"2b41be47f2257f5bc847242c69e552a35f431723c34b868e94843f69e4c309e4"},{"filename":"skills/quick-patterns.md","content":"# Quick Patterns - 80% of Common Tasks\n\n> Copy-paste ready. For complex patterns, use `Read` on specific skill files.\n\n## Model\n```python\nfrom odoo import api, fields, models\n\nclass MyModel(models.Model):\n _name = 'my.module.model'\n _description = 'My Model'\n _inherit = ['mail.thread']\n _order = 'sequence, id'\n```\n\n## Fields\n```python\n# Basic\nname = fields.Char(required=True)\nactive = fields.Boolean(default=True)\nsequence = fields.Integer(default=10)\namount = fields.Float(digits=(16, 2))\ndescription = fields.Text()\ndate = fields.Date(default=fields.Date.today)\nstate = fields.Selection([('draft', 'Draft'), ('done', 'Done')], default='draft')\n\n# Relational\npartner_id = fields.Many2one('res.partner', ondelete='cascade')\ntag_ids = fields.Many2many('my.tag')\nline_ids = fields.One2many('my.line', 'parent_id')\ncompany_id = fields.Many2one('res.company', default=lambda self: self.env.company)\n```\n\n## Computed\n```python\ntotal = fields.Float(compute='_compute_total', store=True)\n\[email protected]('line_ids.amount')\ndef _compute_total(self):\n for rec in self:\n rec.total = sum(rec.line_ids.mapped('amount'))\n```\n\n## Onchange\n```python\[email protected]('partner_id')\ndef _onchange_partner_id(self):\n if self.partner_id:\n self.phone = self.partner_id.phone\n```\n\n## Constraints\n```python\n# SQL\n_sql_constraints = [('name_unique', 'UNIQUE(name)', 'Name must be unique')]\n\n# Python\[email protected]('amount')\ndef _check_amount(self):\n for rec in self:\n if rec.amount \u003c 0:\n raise ValidationError(\"Amount cannot be negative\")\n```\n\n## CRUD Override\n```python\[email protected]_create_multi\ndef create(self, vals_list):\n for vals in vals_list:\n if not vals.get('code'):\n vals['code'] = self.env['ir.sequence'].next_by_code('my.model')\n return super().create(vals_list)\n\ndef write(self, vals):\n if 'state' in vals:\n self._check_state_transition(vals['state'])\n return super().write(vals)\n\ndef unlink(self):\n if any(rec.state == 'done' for rec in self):\n raise UserError(\"Cannot delete done records\")\n return super().unlink()\n```\n\n## Form View\n```xml\n\u003cform>\n \u003cheader>\n \u003cbutton name=\"action_confirm\" string=\"Confirm\" type=\"object\" class=\"oe_highlight\" invisible=\"state != 'draft'\"/>\n \u003cfield name=\"state\" widget=\"statusbar\"/>\n \u003c/header>\n \u003csheet>\n \u003cgroup>\u003cgroup>\u003cfield name=\"name\"/>\u003cfield name=\"partner_id\"/>\u003c/group>\n \u003cgroup>\u003cfield name=\"date\"/>\u003cfield name=\"amount\"/>\u003c/group>\u003c/group>\n \u003cnotebook>\u003cpage string=\"Lines\">\u003cfield name=\"line_ids\"/>\u003c/page>\u003c/notebook>\n \u003c/sheet>\n \u003cdiv class=\"oe_chatter\">\u003cfield name=\"message_ids\"/>\u003c/div>\n\u003c/form>\n```\n\n## Tree View\n```xml\n\u003ctree>\u003cfield name=\"name\"/>\u003cfield name=\"partner_id\"/>\u003cfield name=\"amount\"/>\u003cfield name=\"state\" widget=\"badge\"/>\u003c/tree>\n```\n\n## Search View\n```xml\n\u003csearch>\u003cfield name=\"name\"/>\u003cfield name=\"partner_id\"/>\n\u003cfilter name=\"draft\" string=\"Draft\" domain=\"[('state', '=', 'draft')]\"/>\n\u003cgroup expand=\"0\" string=\"Group By\">\u003cfilter name=\"group_partner\" string=\"Partner\" context=\"{'group_by': 'partner_id'}\"/>\u003c/group>\n\u003c/search>\n```\n\n## Action\n```xml\n\u003crecord id=\"action_my_model\" model=\"ir.actions.act_window\">\n \u003cfield name=\"name\">My Records\u003c/field>\n \u003cfield name=\"res_model\">my.module.model\u003c/field>\n \u003cfield name=\"view_mode\">tree,form\u003c/field>\n\u003c/record>\n```\n\n## Menu\n```xml\n\u003cmenuitem id=\"menu_root\" name=\"My App\" web_icon=\"my_module,static/description/icon.png\"/>\n\u003cmenuitem id=\"menu_main\" name=\"Records\" parent=\"menu_root\" action=\"action_my_model\"/>\n```\n\n## Security (ir.model.access.csv)\n```csv\nid,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink\naccess_my_model_user,my.model.user,model_my_module_model,base.group_user,1,1,1,0\naccess_my_model_manager,my.model.manager,model_my_module_model,my_module.group_manager,1,1,1,1\n```\n\n## Manifest\n```python\n{\n 'name': 'My Module',\n 'version': '17.0.1.0.0',\n 'category': 'Tools',\n 'summary': 'Short description',\n 'depends': ['base', 'mail'],\n 'data': ['security/ir.model.access.csv', 'views/my_model_views.xml'],\n 'installable': True,\n 'license': 'LGPL-3',\n}\n```\n\n## Version Visibility\n```xml\n\u003c!-- v17+: direct --> \u003cfield name=\"x\" invisible=\"state != 'draft'\"/>\n\u003c!-- v14-16: attrs --> \u003cfield name=\"x\" attrs=\"{'invisible': [('state', '!=', 'draft')]}\"/>\n```\n\n---\n**Need more?** Read specific skill file from `SKILL.md`\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4391,"content_sha256":"10cda46d0d4deba744f18a78640b91191eaa5244e095998e993eba48bae36846"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Odoo Development Skill (Universal)","type":"text"}]},{"type":"paragraph","content":[{"text":"You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1. DETECT ODOO VERSION","type":"text"}]},{"type":"paragraph","content":[{"text":"Identify target version BEFORE applying any pattern:","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"__manifest__.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the current directory and extract the version (","type":"text"},{"text":"X.0.Y.Z","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The first number represents the Odoo version (14, 15, 16, 17, 18, 19).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"2. DON'T REINVENT THE WHEEL ⚡","type":"text"}]},{"type":"paragraph","content":[{"text":"BEFORE developing ANY new functionality, perform an exhaustive search in this order:","type":"text","marks":[{"type":"strong"}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"a) Odoo Official Source (Community)","type":"text"}]},{"type":"paragraph","content":[{"text":"Search locally first, then verify against the official GitHub:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Local: ","type":"text"},{"text":"\u003cYOUR_ODOO_SRC_PATH>/addons/","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitHub (by version):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"v14: https://github.com/odoo/odoo/tree/14.0/addons","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"v15: https://github.com/odoo/odoo/tree/15.0/addons","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"v16: https://github.com/odoo/odoo/tree/16.0/addons","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"v17: https://github.com/odoo/odoo/tree/17.0/addons","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"v18: https://github.com/odoo/odoo/tree/18.0/addons","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"v19: https://github.com/odoo/odoo/tree/19.0/addons","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"b) Odoo Enterprise","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Local: ","type":"text"},{"text":"\u003cYOUR_ENTERPRISE_SRC_PATH>/","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"GitHub: https://github.com/odoo/enterprise (requires access)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"c) OCA (Odoo Community Association) — CRITICAL","type":"text"}]},{"type":"paragraph","content":[{"text":"Perform an ","type":"text"},{"text":"exhaustive search","type":"text","marks":[{"type":"strong"}]},{"text":" across OCA repositories at https://github.com/OCA to find if a module or similar functionality already exists:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Browse the OCA organization: https://github.com/orgs/OCA/repositories","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search by keyword: ","type":"text"},{"text":"https://github.com/OCA?q=\u003cKEYWORD>&type=repositories","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Key OCA repositories by domain:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Accounting:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/account-financial-reporting, https://github.com/OCA/account-financial-tools","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stock/Warehouse:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/stock-logistics-workflow, https://github.com/OCA/stock-logistics-warehouse","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sale:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/sale-workflow","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Purchase:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/purchase-workflow","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"HR:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/hr","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"POS:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/pos","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Website:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/website","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Server Tools:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/server-tools, https://github.com/OCA/server-ux","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Reporting:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/reporting-engine","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Connector:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/connector","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Localizations:","type":"text","marks":[{"type":"strong"}]},{"text":" https://github.com/OCA/l10n-{country_code} (e.g., ","type":"text"},{"text":"l10n-spain","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"l10n-brazil","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"d) Decision after search","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If found in Odoo core:","type":"text","marks":[{"type":"strong"}]},{"text":" Read implementation, inherit/extend.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If found in OCA:","type":"text","marks":[{"type":"strong"}]},{"text":" Read the module, check its version compatibility, and depend on it or use it as a reference pattern.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If partially found:","type":"text","marks":[{"type":"strong"}]},{"text":" Inherit and extend the closest module.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Only develop from scratch if","type":"text","marks":[{"type":"strong"}]},{"text":" no similar functionality exists anywhere.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"3. APPLY STRICT DEVELOPMENT STANDARDS","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Language:","type":"text","marks":[{"type":"strong"}]},{"text":" Communication with the user in ","type":"text"},{"text":"SPANISH","type":"text","marks":[{"type":"strong"}]},{"text":" (or user's preferred language). Code, variables, and docstrings in ","type":"text"},{"text":"ENGLISH","type":"text","marks":[{"type":"strong"}]},{"text":". ","type":"text"},{"text":"README.rst","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"index.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the user's preferred language.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Python:","type":"text","marks":[{"type":"strong"}]},{"text":" PEP8, SOLID, DRY, KISS. No ","type":"text"},{"text":"# -*- coding: utf-8 -*-","type":"text","marks":[{"type":"code_inline"}]},{"text":". Use ","type":"text"},{"text":"super()","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"JavaScript/OWL:","type":"text","marks":[{"type":"strong"}]},{"text":" Modern ES6+, correct OWL version (v15: 1.x, v16-18: 2.x, v19: 3.x).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"XML/Views:","type":"text","marks":[{"type":"strong"}]},{"text":" Version-specific visibility (","type":"text"},{"text":"attrs","type":"text","marks":[{"type":"code_inline"}]},{"text":" vs ","type":"text"},{"text":"invisible=...","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Always verify XML IDs before inheriting. Never replace.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Security:","type":"text","marks":[{"type":"strong"}]},{"text":" Always create ","type":"text"},{"text":"ir.model.access.csv","type":"text","marks":[{"type":"code_inline"}]},{"text":" for new models.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"4. AVAILABLE AGENTS (WORKFLOWS)","type":"text"}]},{"type":"paragraph","content":[{"text":"When requested, execute the following specialized workflows:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Code Review:","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"agents/odoo-code-reviewer.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" to perform comprehensive code quality and security audits.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Upgrade Analysis:","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"agents/odoo-upgrade-analyzer.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" to analyze migration compatibility between versions.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Context Gathering:","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"agents/odoo-context-gatherer.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before generating complex code.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Skill Discovery:","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"agents/odoo-skill-finder.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" to navigate the pattern library.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"📚 PATTERN DISCOVERY INDEX","type":"text"}]},{"type":"paragraph","content":[{"text":"When the user asks for a specific functionality, search the ","type":"text"},{"text":"skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" directory.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Intent / Keywords","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pattern File","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"fields, char, many2one, selection","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/field-type-reference.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"computed, depends, inverse","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/computed-field-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"constraint, validation, check","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/constraint-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"onchange, dynamic, domain","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/onchange-dynamic-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"view, form, tree, kanban, search","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/xml-view-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"widget, statusbar, badge, image","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/widget-field-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"qweb, template, t-if, t-foreach","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/qweb-template-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"action, window, server, client","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/action-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"menu, navigation, menuitem","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/menu-navigation-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"security, access, rule, group","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-security-guide.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"workflow, state, statusbar","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/workflow-state-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"wizard, transient, dialog","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/wizard-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"report, pdf, print","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/report-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cron, scheduled, automation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/cron-automation-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"controller, http, api, rest","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/controller-api-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mail, email, chatter, activity","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/mail-notification-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-company, company","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/multi-company-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"inherit, extend, override","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/inheritance-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"migration, upgrade, version","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/data-migration-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"website, portal, public","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/website-integration-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"external, api, webhook, sync","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/external-api-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"logging, debug, error","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/logging-debugging-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"stock, inventory, warehouse","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/stock-inventory-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"account, invoice, journal","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/accounting-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sale, order, quotation, crm","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/sale-crm-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"hr, employee, contract","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/hr-employee-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"domain, filter, search","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/domain-filter-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"sequence, numbering","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/sequence-numbering-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"purchase, vendor, procurement","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/purchase-procurement-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"project, task, timesheet","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/project-task-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"context, env, sudo","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/context-environment-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"portal, token, access","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/portal-access-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"settings, config, parameter","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/config-settings-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"translation, i18n, language","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/translation-i18n-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"assets, js, css, scss","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/assets-bundling-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"variant, attribute, product","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/product-variant-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uom, unit, measure","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/uom-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lot, serial, batch","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/lot-serial-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tax, fiscal, vat","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/tax-fiscal-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"owl, component, frontend","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-owl-components.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"test, unittest, integration","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-test-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"manifest, module, depends","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-module-generator.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"version, 14, 15, 16, 17, 18, 19","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-version-knowledge.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"attachment, binary, file, image","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/attachment-binary-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dashboard, kpi, analytics, graph","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/dashboard-kpi-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"exception, error, validation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/error-handling-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"import, export, csv, excel","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/import-export-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pricelist, price, discount","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/pricelist-pricing-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"performance, optimize, index","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-performance-guide.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"troubleshooting, debug, fix","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-troubleshooting-guide.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"quick, snippet, cheatsheet","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/quick-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"editions, community, enterprise","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-editions.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"end-to-end, example, full module","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/end-to-end-examples.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"template, scaffold, boilerplate","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/common-module-templates.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"module generation, example","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/module-generation-example.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"model, abstract, transient","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/odoo-model-patterns.md","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Version-Specific Pattern Files","type":"text"}]},{"type":"paragraph","content":[{"text":"Many patterns have ","type":"text"},{"text":"version-specific variants","type":"text","marks":[{"type":"strong"}]},{"text":" following the naming convention ","type":"text"},{"text":"skills/{pattern}-{version}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". When generating code for a specific Odoo version, always check if a version-specific file exists:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Single version:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"skills/{pattern}-{version}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g., ","type":"text"},{"text":"skills/odoo-model-patterns-18.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Migration guide:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"skills/{pattern}-{sourceV}-{targetV}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g., ","type":"text"},{"text":"skills/odoo-model-patterns-17-18.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"All versions:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"skills/{pattern}-all.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g., ","type":"text"},{"text":"skills/odoo-model-patterns-all.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Available version-specific pattern families:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"odoo-version-knowledge-{14..19}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and migration guides ","type":"text"},{"text":"{14-15..18-19}.md","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"odoo-model-patterns-{14..19}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and migration guides","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"odoo-module-generator-{14..19}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and migration guides","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"odoo-owl-components-{15..19}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and migration guides","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"odoo-security-guide-{14..19}.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and migration guides","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Rule:","type":"text","marks":[{"type":"strong"}]},{"text":" Always read the corresponding pattern file using file reading tools before generating code. DO NOT guess the syntax if you are unsure.","type":"text"}]}]},"metadata":{"date":"2026-06-05","name":"odoo-development-skill","author":"@skillopedia","source":{"stars":34,"repo_name":"odoo-development-skill","origin_url":"https://github.com/fhidalgodev/odoo-development-skill/blob/HEAD/SKILL.md","repo_owner":"fhidalgodev","body_sha256":"e7f665d49b270426df8930e1f22504829fe4199baa64caa0c1fd415addf2510c","cluster_key":"27f3a3b086be5269178f8066c621ae6acc5777713a984fdfdff105dfd2b7af3d","clean_bundle":{"format":"clean-skill-bundle-v1","source":"fhidalgodev/odoo-development-skill/SKILL.md","attachments":[{"id":"3e13a32a-eebb-535c-b4f7-7760dabb93aa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3e13a32a-eebb-535c-b4f7-7760dabb93aa/attachment","path":".gitignore","size":698,"sha256":"13bbd6012d62ad04bcf167b802b0c721fac18c58b87d9b353b1cd8311c708444","contentType":"text/plain; charset=utf-8"},{"id":"23a6db86-a785-5426-9198-8ec71d6a7f50","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/23a6db86-a785-5426-9198-8ec71d6a7f50/attachment.md","path":"README.md","size":2463,"sha256":"793df88c5c91b9f3d2a085907278d27353ca4dc669b86db435cd096f6c1ce84e","contentType":"text/markdown; charset=utf-8"},{"id":"286beac9-5089-5425-876f-dd0b13c67dfd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/286beac9-5089-5425-876f-dd0b13c67dfd/attachment.md","path":"agents/odoo-code-reviewer.md","size":7749,"sha256":"73fb8d89b387d4119b950999b91f19eecfb997745f187f7a3db4f24da5889fda","contentType":"text/markdown; charset=utf-8"},{"id":"0661753a-229c-5567-b560-4c2c75bd59b6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0661753a-229c-5567-b560-4c2c75bd59b6/attachment.md","path":"agents/odoo-context-gatherer.md","size":8073,"sha256":"6c4139f8a7aa5181d4cd45e56e6b6d692e9f3c275d64fbe10b95d6d9dec5af17","contentType":"text/markdown; charset=utf-8"},{"id":"cbd32670-eb4d-53cb-8eff-543d20093d32","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cbd32670-eb4d-53cb-8eff-543d20093d32/attachment.md","path":"agents/odoo-skill-finder.md","size":2255,"sha256":"9566a24bc2c3847b8c3803ff0c05ddef4f9537a2d8d366c51d84c195c3c8acf5","contentType":"text/markdown; charset=utf-8"},{"id":"f6880819-a46b-5050-8b2b-11f0658317c0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f6880819-a46b-5050-8b2b-11f0658317c0/attachment.md","path":"agents/odoo-upgrade-analyzer.md","size":10685,"sha256":"b5fd5d9c2e38576b0b31e0dd024cc09548efe4ea46ed56d626374ac353497ee4","contentType":"text/markdown; charset=utf-8"},{"id":"5cd99a25-33ed-56d0-a83e-62975d9b04e5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5cd99a25-33ed-56d0-a83e-62975d9b04e5/attachment.md","path":"skills/accounting-patterns.md","size":15216,"sha256":"2e65a5e41b1515e91fca82ba010eb766e9c1c5a14b4266b69ed05b61c0fb24cf","contentType":"text/markdown; charset=utf-8"},{"id":"e313282d-5c15-583f-86e6-f70607629107","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e313282d-5c15-583f-86e6-f70607629107/attachment.md","path":"skills/action-patterns.md","size":12535,"sha256":"adcee4e9147e0264fd0fa17dc9ea69505278e2014a2dcf52d7d9b5c5441e8c0c","contentType":"text/markdown; charset=utf-8"},{"id":"123ad587-9b00-5749-bd78-12031f5bcc3a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/123ad587-9b00-5749-bd78-12031f5bcc3a/attachment.md","path":"skills/assets-bundling-patterns.md","size":13447,"sha256":"7a4ff116ea720859d9f5d8054d13277a7ed68051fa39ea1a5126e65e21d6b014","contentType":"text/markdown; charset=utf-8"},{"id":"eca0908c-c211-5794-94ed-ef1a5af85e2f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eca0908c-c211-5794-94ed-ef1a5af85e2f/attachment.md","path":"skills/attachment-binary-patterns.md","size":14977,"sha256":"a4c7aa2d17f867dc2d18b759f00c8eef0c594411e163fbec035489fb81462af0","contentType":"text/markdown; charset=utf-8"},{"id":"ddf426d0-b1db-595e-9a3c-6e37f1875bc9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ddf426d0-b1db-595e-9a3c-6e37f1875bc9/attachment.md","path":"skills/common-module-templates.md","size":19321,"sha256":"59c63132f2e8b4f5f4e004a914921c32804f69a6c8753eaed8761e7a6f5e0e20","contentType":"text/markdown; charset=utf-8"},{"id":"07a4bfcb-d4b0-5c0a-afa1-34b514d98331","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/07a4bfcb-d4b0-5c0a-afa1-34b514d98331/attachment.md","path":"skills/computed-field-patterns.md","size":13263,"sha256":"50f80280ea4a13832fe3cbfafdedac1639cf348390335fcd082da947e8fbcd08","contentType":"text/markdown; charset=utf-8"},{"id":"b5a8e436-30df-59ab-a885-8528d3a6b574","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b5a8e436-30df-59ab-a885-8528d3a6b574/attachment.md","path":"skills/config-settings-patterns.md","size":15041,"sha256":"e8b1996034baf4f7e142a01d905ddffcd2b5beb48630a50897732cdb09ab1fb7","contentType":"text/markdown; charset=utf-8"},{"id":"a275166b-afb2-5464-9a9d-52c5d0894f7e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a275166b-afb2-5464-9a9d-52c5d0894f7e/attachment.md","path":"skills/constraint-patterns.md","size":13416,"sha256":"e53801f5939ccef6d2232dc9c3e53ebf9740d0c32ff87117513244e82dbfa483","contentType":"text/markdown; charset=utf-8"},{"id":"11a395d0-1687-53c9-8d7b-13ed38a86f3c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/11a395d0-1687-53c9-8d7b-13ed38a86f3c/attachment.md","path":"skills/context-environment-patterns.md","size":10969,"sha256":"5a8261005cfa1746cf321c28ee4af8b841d7f582aef0f52997ca08ccb235f8ea","contentType":"text/markdown; charset=utf-8"},{"id":"f21f725a-0c67-521e-b664-e4c9619a7fe3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f21f725a-0c67-521e-b664-e4c9619a7fe3/attachment.md","path":"skills/controller-api-patterns.md","size":14805,"sha256":"1fe3d4721c40aeaeea1d07937550353a0462daa168a10552cf4ac6bbfd84048e","contentType":"text/markdown; charset=utf-8"},{"id":"20109c8a-e467-5660-a25e-60a5b4508d48","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/20109c8a-e467-5660-a25e-60a5b4508d48/attachment.md","path":"skills/cron-automation-patterns.md","size":12698,"sha256":"a173d9580901b501c2e2106dd856f5d6becb9e7f09594c737b5d39ca8f8881bb","contentType":"text/markdown; charset=utf-8"},{"id":"e4fdcc19-0065-5a2b-b558-b490cbdb3378","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e4fdcc19-0065-5a2b-b558-b490cbdb3378/attachment.md","path":"skills/dashboard-kpi-patterns.md","size":21014,"sha256":"fec80f6df6b2802c6b0bf7a2c5943f6381993358f0656a969bcd02b3c8123dd3","contentType":"text/markdown; charset=utf-8"},{"id":"134eeab5-f878-5393-8b36-800c4553c340","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/134eeab5-f878-5393-8b36-800c4553c340/attachment.md","path":"skills/data-migration-patterns.md","size":15284,"sha256":"19e9905fb4312dae61379cb732e7bd7b19b86628023533278a4a43a051ed6c70","contentType":"text/markdown; charset=utf-8"},{"id":"0ac81cd9-07a8-5bec-a52e-f7db4e3736d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0ac81cd9-07a8-5bec-a52e-f7db4e3736d5/attachment.md","path":"skills/domain-filter-patterns.md","size":13505,"sha256":"8ca3d76fe43d60abf2f84d74b2d57b21fe3b1034b5cc5ff0c3b0d34a0d6d2e14","contentType":"text/markdown; charset=utf-8"},{"id":"adaddcea-2c4e-59c9-a0ca-8bb5ae592277","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/adaddcea-2c4e-59c9-a0ca-8bb5ae592277/attachment.md","path":"skills/end-to-end-examples.md","size":26480,"sha256":"379f8f0fdf2c8550bb68445163ea38b01559ff4a6c451c05edb181a4973445ff","contentType":"text/markdown; charset=utf-8"},{"id":"e8f37c07-1e98-5a83-a2f4-d7284920a1e0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e8f37c07-1e98-5a83-a2f4-d7284920a1e0/attachment.md","path":"skills/error-handling-patterns.md","size":13176,"sha256":"72f0d4ce1d1aea038e1e2a62694c4f1b27a73fb67d4489cd973971cc7d468aed","contentType":"text/markdown; charset=utf-8"},{"id":"1307b27d-f99c-59a4-8021-bdf0681e1d6a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1307b27d-f99c-59a4-8021-bdf0681e1d6a/attachment.md","path":"skills/external-api-patterns.md","size":21946,"sha256":"b7bf8db4fc5a07f9929325b37776b9e3078bddd71c49248546202db5c5d5dacb","contentType":"text/markdown; charset=utf-8"},{"id":"11f5f975-8989-566c-8c91-1d8af04aab28","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/11f5f975-8989-566c-8c91-1d8af04aab28/attachment.md","path":"skills/field-type-reference.md","size":12945,"sha256":"25f380c020d49ac26da731410885bb8be707b496624bceed90137a85977203df","contentType":"text/markdown; charset=utf-8"},{"id":"1d0386e2-0f61-588c-b7b0-83331b217b11","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1d0386e2-0f61-588c-b7b0-83331b217b11/attachment.md","path":"skills/hr-employee-patterns.md","size":16169,"sha256":"3077bde6c3f656ec916be01505ab7496d2b15e95abcb70f289619cc787149268","contentType":"text/markdown; charset=utf-8"},{"id":"80c355d3-4a42-500d-a6d9-bc23119977b2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/80c355d3-4a42-500d-a6d9-bc23119977b2/attachment.md","path":"skills/import-export-patterns.md","size":15343,"sha256":"e7f0a92a4a6152ae97b7dcd5e1a5db7eca46059932fc2494095d7d6e76d2361e","contentType":"text/markdown; charset=utf-8"},{"id":"1d686b3f-d88e-5f35-ba25-ca5e4a07adae","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1d686b3f-d88e-5f35-ba25-ca5e4a07adae/attachment.md","path":"skills/inheritance-patterns.md","size":27683,"sha256":"d451ef42ee3acc6e9a28c165193fa7b00c189bb3094ec0fe58dd5d43d08d8e7f","contentType":"text/markdown; charset=utf-8"},{"id":"afdc50d7-1818-588f-95ed-71acc9f9f111","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/afdc50d7-1818-588f-95ed-71acc9f9f111/attachment.md","path":"skills/logging-debugging-patterns.md","size":14021,"sha256":"b789dbf98de12d6bfa33f317d22e2b006e4d8fde2cddb006c90a9e8879734975","contentType":"text/markdown; charset=utf-8"},{"id":"1d495e19-ef24-5e42-b233-dc29e03d506a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1d495e19-ef24-5e42-b233-dc29e03d506a/attachment.md","path":"skills/lot-serial-patterns.md","size":13896,"sha256":"1dd03a1c28b29eaa5377f513cae5a68b0a5580f231a76d63e7b60368889036e8","contentType":"text/markdown; charset=utf-8"},{"id":"bd50cb92-2218-5384-acaa-b32573b54856","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bd50cb92-2218-5384-acaa-b32573b54856/attachment.md","path":"skills/mail-notification-patterns.md","size":19118,"sha256":"4167a9206de6575ee8e6af257ebe439b31b49671f6757ab956688da1fa76df14","contentType":"text/markdown; charset=utf-8"},{"id":"063b7bd4-69a1-5289-bffc-f57afac11a67","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/063b7bd4-69a1-5289-bffc-f57afac11a67/attachment.md","path":"skills/menu-navigation-patterns.md","size":11038,"sha256":"53aa9c2512e6fc3023375c5b15c99babf08c1e2cd69047270d962688a66f322d","contentType":"text/markdown; charset=utf-8"},{"id":"b93133ed-a0ab-5a6a-b01b-f6510748883c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b93133ed-a0ab-5a6a-b01b-f6510748883c/attachment.md","path":"skills/module-generation-example.md","size":25467,"sha256":"dfc99eb6e1315204b09cce90335b00bb884aa38dba8dbb1f36e7eb5de33e5ba1","contentType":"text/markdown; charset=utf-8"},{"id":"c903e270-b3c8-5299-b5d0-f928199a93ac","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c903e270-b3c8-5299-b5d0-f928199a93ac/attachment.md","path":"skills/multi-company-patterns.md","size":13322,"sha256":"35e20be7fc431d062c2894ab0d7d2bf66797618b275864ffad3e1bf5bfe22762","contentType":"text/markdown; charset=utf-8"},{"id":"8f20b110-dde1-5c9c-867c-05d81982817c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8f20b110-dde1-5c9c-867c-05d81982817c/attachment.md","path":"skills/odoo-editions.md","size":6244,"sha256":"42985a8223c7d03bc9dd4f4ee12439b18a2845280c9b09f40eb1303fba13e2e1","contentType":"text/markdown; charset=utf-8"},{"id":"9fbf372a-cbcf-5526-b46a-7bc4cfdcfc8c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9fbf372a-cbcf-5526-b46a-7bc4cfdcfc8c/attachment.md","path":"skills/odoo-model-patterns-14-15.md","size":8332,"sha256":"1b2281b506e0de1a6eb3d6306e99b36a624bfbab6c9a91fd90a4af2ec210970b","contentType":"text/markdown; charset=utf-8"},{"id":"36e89937-6406-587f-9d4c-89d4041aed86","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/36e89937-6406-587f-9d4c-89d4041aed86/attachment.md","path":"skills/odoo-model-patterns-14.md","size":10922,"sha256":"0afb091027592cc62e816ebb4bf88e1030e4040a07613d29d45acbc8a51d9330","contentType":"text/markdown; charset=utf-8"},{"id":"d90382e8-e9e2-5c7f-8dd0-f59c6e82e363","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d90382e8-e9e2-5c7f-8dd0-f59c6e82e363/attachment.md","path":"skills/odoo-model-patterns-15-16.md","size":9733,"sha256":"1da24e1d62abb50ce1f41a114e1eb1ff26c821bc014f2538e674bd570c4bed20","contentType":"text/markdown; charset=utf-8"},{"id":"ebd8ec54-0c53-520c-86a0-8b460e86b695","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ebd8ec54-0c53-520c-86a0-8b460e86b695/attachment.md","path":"skills/odoo-model-patterns-15.md","size":12164,"sha256":"1e7e27365bffee2d2db28b8e88c9fb5bc602e76b88cc368ddf01d319abce4059","contentType":"text/markdown; charset=utf-8"},{"id":"c263c6a9-133e-5989-aa4e-1ba25c2571b5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c263c6a9-133e-5989-aa4e-1ba25c2571b5/attachment.md","path":"skills/odoo-model-patterns-16-17.md","size":3979,"sha256":"64c1c66a6b872e16d194ed7d3e476dba2b5420b158c98d5d76e9c85879ada75a","contentType":"text/markdown; charset=utf-8"},{"id":"50676545-2240-5077-bb26-5574df9921ce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/50676545-2240-5077-bb26-5574df9921ce/attachment.md","path":"skills/odoo-model-patterns-16.md","size":10692,"sha256":"b494f6624d57af4da1dabbe9a212a41376a4ed52f8887f40daa2ba905c61e528","contentType":"text/markdown; charset=utf-8"},{"id":"7805a69f-a7d3-5b0e-918e-e6faf6790dd3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7805a69f-a7d3-5b0e-918e-e6faf6790dd3/attachment.md","path":"skills/odoo-model-patterns-17-18.md","size":5631,"sha256":"488c8cf3176b2251cadea3bd0bec4d8e6e37109ab4d0af6727b286bb22cc9afa","contentType":"text/markdown; charset=utf-8"},{"id":"5ccb5c4d-7928-50d3-91fa-f69113b66289","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5ccb5c4d-7928-50d3-91fa-f69113b66289/attachment.md","path":"skills/odoo-model-patterns-17.md","size":17926,"sha256":"72c4daeded426bc9542642e8e6db13bd8b203fcfe472aa93ddb91deb94a7ef26","contentType":"text/markdown; charset=utf-8"},{"id":"e16895c1-173f-5d73-aad3-468b5eec1f34","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e16895c1-173f-5d73-aad3-468b5eec1f34/attachment.md","path":"skills/odoo-model-patterns-18-19.md","size":9329,"sha256":"3dca826bbe4ee62e88722a3061a7d2ba7f62859cb0fb72edbf9485016d42d22d","contentType":"text/markdown; charset=utf-8"},{"id":"fe2edfb9-933d-525f-9f89-aad0c0dd91db","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fe2edfb9-933d-525f-9f89-aad0c0dd91db/attachment.md","path":"skills/odoo-model-patterns-18.md","size":15799,"sha256":"43d83e501d80c3dcbcc837f40246099225e10901ad0b0e39d9410ee25bbf3cfc","contentType":"text/markdown; charset=utf-8"},{"id":"1af7c659-dfc1-5022-a293-e037e7c546b9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1af7c659-dfc1-5022-a293-e037e7c546b9/attachment.md","path":"skills/odoo-model-patterns-19.md","size":16176,"sha256":"8003d301ada33b535116d410ff053e075933dd8803691118f5ece4e94d65598d","contentType":"text/markdown; charset=utf-8"},{"id":"48cc7802-bd2f-5961-b0b9-2d47cc7b4519","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/48cc7802-bd2f-5961-b0b9-2d47cc7b4519/attachment.md","path":"skills/odoo-model-patterns-all.md","size":12379,"sha256":"1f31c4c0328e3d176f17d2edd89c88cdf04eb228543c95213ea86fd003d462e4","contentType":"text/markdown; charset=utf-8"},{"id":"3e043cf0-1e5b-5242-bed3-c78eced21c1d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3e043cf0-1e5b-5242-bed3-c78eced21c1d/attachment.md","path":"skills/odoo-model-patterns.md","size":3364,"sha256":"e720009c3ec1e2cc6a8e2b402cf00f867f49a1fa407f8215a20388ba4472d9a8","contentType":"text/markdown; charset=utf-8"},{"id":"6a87b827-6721-5fef-8590-f2adbd889d02","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6a87b827-6721-5fef-8590-f2adbd889d02/attachment.md","path":"skills/odoo-module-generator-14-15.md","size":10409,"sha256":"a87f9c6ab94e820afd7d892966f71907acac2a26ba7e495fb9bec9a98704db55","contentType":"text/markdown; charset=utf-8"},{"id":"390be091-cf7e-5a6f-b49b-ce1965391ecc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/390be091-cf7e-5a6f-b49b-ce1965391ecc/attachment.md","path":"skills/odoo-module-generator-14.md","size":11890,"sha256":"d2627203ebce81edd29ef1f241a2b82633530b859db8c4551c16b28dceb42058","contentType":"text/markdown; charset=utf-8"},{"id":"244053db-341a-54c8-9719-8391b7a53b89","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/244053db-341a-54c8-9719-8391b7a53b89/attachment.md","path":"skills/odoo-module-generator-15-16.md","size":13489,"sha256":"083fcdd512735eb4e19860401321c32eae2a0c1c27c4af877a36b0d919c413da","contentType":"text/markdown; charset=utf-8"},{"id":"7876900f-9663-5323-92fb-6bff7485377c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7876900f-9663-5323-92fb-6bff7485377c/attachment.md","path":"skills/odoo-module-generator-15.md","size":12143,"sha256":"1dbc3a1f712db5d819c04a5297969032000197171c3d64bf2516dcdf9f0f697f","contentType":"text/markdown; charset=utf-8"},{"id":"84c51f72-0aaa-5aa8-a06a-c7cba1977d8b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/84c51f72-0aaa-5aa8-a06a-c7cba1977d8b/attachment.md","path":"skills/odoo-module-generator-16-17.md","size":6673,"sha256":"e080e4f16686a43e34001610567e1dc1cce1871e31c397a9a0f08be0d7657d73","contentType":"text/markdown; charset=utf-8"},{"id":"b4f0e16f-bd7d-5f47-b4ba-a922a11a536c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b4f0e16f-bd7d-5f47-b4ba-a922a11a536c/attachment.md","path":"skills/odoo-module-generator-16.md","size":13684,"sha256":"b1dd20a3f3fa6618dd316381bd9c7e5c24c4182dd002c00a510e4f807801638f","contentType":"text/markdown; charset=utf-8"},{"id":"db42a0fe-983a-5f3a-ac74-279538237c6a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/db42a0fe-983a-5f3a-ac74-279538237c6a/attachment.md","path":"skills/odoo-module-generator-17-18.md","size":9443,"sha256":"a589c21e07c78504c00830ac0c98244f2bd33990edee4b84df4dc18e0f4dc1f1","contentType":"text/markdown; charset=utf-8"},{"id":"e1e39613-8545-5009-a571-be0fb2b397ad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e1e39613-8545-5009-a571-be0fb2b397ad/attachment.md","path":"skills/odoo-module-generator-17.md","size":21532,"sha256":"c98816e7242a88b487983d329147896a7f91ddce3782bc41c8f664e1fe19f355","contentType":"text/markdown; charset=utf-8"},{"id":"040fb9a3-24f1-5fa7-8576-7a795db6533a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/040fb9a3-24f1-5fa7-8576-7a795db6533a/attachment.md","path":"skills/odoo-module-generator-18-19.md","size":10778,"sha256":"098da74887e2e942f78e6a13c371b3f2e91725dda069a5305e08fcf5307e750d","contentType":"text/markdown; charset=utf-8"},{"id":"42e99c39-24f9-57ac-9aeb-2e438f2a6812","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/42e99c39-24f9-57ac-9aeb-2e438f2a6812/attachment.md","path":"skills/odoo-module-generator-18.md","size":22867,"sha256":"901e1c0c6e14ee0e2ae63bfb6038a732b4c09a50ed665fa3ef6bc637d65b5d3e","contentType":"text/markdown; charset=utf-8"},{"id":"ee53199b-a0a8-518f-b345-0fd09026d07d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ee53199b-a0a8-518f-b345-0fd09026d07d/attachment.md","path":"skills/odoo-module-generator-19.md","size":19489,"sha256":"706b34353342ba85ecac48dc412dfd7d5e1998c15b9d840fa7ac8a1ab8a57718","contentType":"text/markdown; charset=utf-8"},{"id":"b3792756-f754-5872-b927-9cc7444644ce","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b3792756-f754-5872-b927-9cc7444644ce/attachment.md","path":"skills/odoo-module-generator-all.md","size":9711,"sha256":"a43d711799b03a2d2655a1edbd6ec5ba222909a95df392320728f981c682aa00","contentType":"text/markdown; charset=utf-8"},{"id":"da362337-9986-5e66-8d61-02b0da908c77","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/da362337-9986-5e66-8d61-02b0da908c77/attachment.md","path":"skills/odoo-module-generator.md","size":11175,"sha256":"506034ecc10eec389827a15ea633648f7d83a1576d8b45c723fbabb18374e4ac","contentType":"text/markdown; charset=utf-8"},{"id":"dd7d1fef-27ae-551e-905d-f41fb6026ff2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dd7d1fef-27ae-551e-905d-f41fb6026ff2/attachment.md","path":"skills/odoo-owl-components-15-16.md","size":7570,"sha256":"1c95d2a65dfa12fa9eb1d2dc872f9860109b8a8881dfe11a96820e133fbb6f81","contentType":"text/markdown; charset=utf-8"},{"id":"ada1eaef-d88b-5c74-9bf0-9788e54474d7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ada1eaef-d88b-5c74-9bf0-9788e54474d7/attachment.md","path":"skills/odoo-owl-components-15.md","size":8308,"sha256":"8050863d8510940ceb398d91a76fc67b6bebef595c6832663b1f2b061c559d85","contentType":"text/markdown; charset=utf-8"},{"id":"d3a28f40-fdba-525a-8157-d155e97ccd24","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d3a28f40-fdba-525a-8157-d155e97ccd24/attachment.md","path":"skills/odoo-owl-components-16-17.md","size":15611,"sha256":"77f522b41bc9d20974c6022a49c063fccaf5e05b7a33c93de1b7f4f636cd01d1","contentType":"text/markdown; charset=utf-8"},{"id":"690150c5-2f81-552f-b121-7439e40cfc2f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/690150c5-2f81-552f-b121-7439e40cfc2f/attachment.md","path":"skills/odoo-owl-components-16.md","size":9478,"sha256":"1130ebc6824ff82b3f648490efd6532b49b77b5c16110184df8ebd4d1cf601ab","contentType":"text/markdown; charset=utf-8"},{"id":"c2cf6b9e-d429-59a9-b8ff-8fd303d8c126","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c2cf6b9e-d429-59a9-b8ff-8fd303d8c126/attachment.md","path":"skills/odoo-owl-components-17-18.md","size":4270,"sha256":"379d36c6aa8f11f1040ab67f88bc0c74b092d304ee3d69247b1a84b1727d19f7","contentType":"text/markdown; charset=utf-8"},{"id":"b1ef0efc-cb78-59bc-9f78-d69995171474","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b1ef0efc-cb78-59bc-9f78-d69995171474/attachment.md","path":"skills/odoo-owl-components-17.md","size":8900,"sha256":"b3609f0a9fd5e8b5829bc19df25f1453403a0b38032356239f00cec78671eb30","contentType":"text/markdown; charset=utf-8"},{"id":"3e3d5711-f64e-5553-95b3-cbc832e89f6b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3e3d5711-f64e-5553-95b3-cbc832e89f6b/attachment.md","path":"skills/odoo-owl-components-18-19.md","size":7898,"sha256":"3e471cf2fc97bb813ea93a29fad8e544cb08794dc884e1cd6a053b883f34f27e","contentType":"text/markdown; charset=utf-8"},{"id":"06b0cce6-f01f-5d01-aa20-3017c9178153","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/06b0cce6-f01f-5d01-aa20-3017c9178153/attachment.md","path":"skills/odoo-owl-components-18.md","size":13758,"sha256":"83dee2a62991732a6a2f079dd7528842a43572b79f5c0dc947405ff483ab17db","contentType":"text/markdown; charset=utf-8"},{"id":"c6edf0cb-101e-5c3a-b2d3-bec9f0f4c84b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c6edf0cb-101e-5c3a-b2d3-bec9f0f4c84b/attachment.md","path":"skills/odoo-owl-components-19.md","size":15050,"sha256":"9e68dc0e91a2fb282893b2f3ae04c8d1f22878b0ccad2786cf29bda6a74ed655","contentType":"text/markdown; charset=utf-8"},{"id":"9364fba2-1ba5-559b-93d0-a7fc569cfb32","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9364fba2-1ba5-559b-93d0-a7fc569cfb32/attachment.md","path":"skills/odoo-owl-components-all.md","size":8289,"sha256":"0529162bb3396916ca8b2b8048cee45c3db09634de942ec3461fccaafe1af20d","contentType":"text/markdown; charset=utf-8"},{"id":"254f98d7-8306-527e-89e3-50978c1285c5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/254f98d7-8306-527e-89e3-50978c1285c5/attachment.md","path":"skills/odoo-owl-components.md","size":5485,"sha256":"de46b54cfd870a6a2411fd3fe36001bc3238a0ee778f49c7c4f670e41b8af74d","contentType":"text/markdown; charset=utf-8"},{"id":"8ae9c2ce-e80e-5555-8fbd-88a6208afc72","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8ae9c2ce-e80e-5555-8fbd-88a6208afc72/attachment.md","path":"skills/odoo-performance-guide.md","size":12782,"sha256":"d86db795e0b3a2116da84c31137608a57d3f40ed125949547421ca5dbbe60692","contentType":"text/markdown; charset=utf-8"},{"id":"6d08f277-7b08-516a-8df8-d777e6fcbe3c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6d08f277-7b08-516a-8df8-d777e6fcbe3c/attachment.md","path":"skills/odoo-security-guide-14-15.md","size":6885,"sha256":"07919e8b9fb733035e0598cda1a5348279edb003e47ce68abd4c4b22f3de3dd0","contentType":"text/markdown; charset=utf-8"},{"id":"90826da8-403a-590d-ad03-e6383fb6a544","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/90826da8-403a-590d-ad03-e6383fb6a544/attachment.md","path":"skills/odoo-security-guide-14.md","size":9139,"sha256":"6053df1d58662740dee5a80bf4a8116e67869245a096bd115a4cb70f4ab01ba4","contentType":"text/markdown; charset=utf-8"},{"id":"444145c8-dfbb-50ba-8fbb-57562cba0d69","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/444145c8-dfbb-50ba-8fbb-57562cba0d69/attachment.md","path":"skills/odoo-security-guide-15-16.md","size":6791,"sha256":"83154b504ab4e3bf9a546a656a3d687d2184bb4adfae6289ee9c774737db0967","contentType":"text/markdown; charset=utf-8"},{"id":"0354b73f-1a42-5b70-91e6-409f09239d16","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0354b73f-1a42-5b70-91e6-409f09239d16/attachment.md","path":"skills/odoo-security-guide-15.md","size":8433,"sha256":"9564bcbe08e5cbcf2533e6cc8a8d673117280dc120f4b823ecd1aee0663f2702","contentType":"text/markdown; charset=utf-8"},{"id":"6849cbec-83d5-5508-a591-a921b777512d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6849cbec-83d5-5508-a591-a921b777512d/attachment.md","path":"skills/odoo-security-guide-16-17.md","size":9527,"sha256":"9522c036b1696e8a0ae869de355513750186a29137d941e9241b086772c1a632","contentType":"text/markdown; charset=utf-8"},{"id":"d8f50550-d8f1-5cc9-b691-bdf5e58f74ed","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d8f50550-d8f1-5cc9-b691-bdf5e58f74ed/attachment.md","path":"skills/odoo-security-guide-16.md","size":8851,"sha256":"3eb158d511d181f9ed2eeb096faaeeb37a7929fca664c0b508faf4927f7e4854","contentType":"text/markdown; charset=utf-8"},{"id":"37ec2163-aa17-5991-aae1-b425657e5206","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/37ec2163-aa17-5991-aae1-b425657e5206/attachment.md","path":"skills/odoo-security-guide-17-18.md","size":8677,"sha256":"af153b30a0f845b927a1520291371871b2039637fbcaa066340c89206dd21c6c","contentType":"text/markdown; charset=utf-8"},{"id":"4858d462-01e9-52ec-9c6f-75495b2abb1c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4858d462-01e9-52ec-9c6f-75495b2abb1c/attachment.md","path":"skills/odoo-security-guide-17.md","size":11544,"sha256":"68105e1ad55b40418f7807dc02987efd3bdb7cb957bd12af64f7dfef314c2a95","contentType":"text/markdown; charset=utf-8"},{"id":"4f1a265a-afea-521a-89d3-f7df0f302f7c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f1a265a-afea-521a-89d3-f7df0f302f7c/attachment.md","path":"skills/odoo-security-guide-18-19.md","size":9830,"sha256":"20bf2196f4091853dfb3c3a1ad83fbfb1a118d07b9c632f47f996563752128d5","contentType":"text/markdown; charset=utf-8"},{"id":"e6df4faa-4163-5622-9648-432c15128091","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e6df4faa-4163-5622-9648-432c15128091/attachment.md","path":"skills/odoo-security-guide-18.md","size":14893,"sha256":"19ef5d589fb737cb5217a32c6d7b8357570a5e849495b567df38fa34312ee207","contentType":"text/markdown; charset=utf-8"},{"id":"66da7c4e-4dc3-59cf-a62d-ddee1049b7e6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/66da7c4e-4dc3-59cf-a62d-ddee1049b7e6/attachment.md","path":"skills/odoo-security-guide-19.md","size":11144,"sha256":"070567e8f523192164002eefc6c82da3c5b52c0eae8ac1277dc20b63fb29829d","contentType":"text/markdown; charset=utf-8"},{"id":"6072ce71-ce94-5501-83b0-ae6b91647f68","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6072ce71-ce94-5501-83b0-ae6b91647f68/attachment.md","path":"skills/odoo-security-guide-all.md","size":11534,"sha256":"e038211f4603838093dc2e088e91bef7dfdafba2855279c6490a0821ce46d883","contentType":"text/markdown; charset=utf-8"},{"id":"32b71948-4fcc-55f4-9cab-ee806c84c36b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/32b71948-4fcc-55f4-9cab-ee806c84c36b/attachment.md","path":"skills/odoo-security-guide.md","size":4323,"sha256":"9383449f4d54e4258712dd4379613112279d2b1318306ef48be8379826a12cb7","contentType":"text/markdown; charset=utf-8"},{"id":"61ff2ffb-2908-52cb-8baa-9655f6a8cda8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/61ff2ffb-2908-52cb-8baa-9655f6a8cda8/attachment.md","path":"skills/odoo-test-patterns.md","size":14715,"sha256":"dfc4d4f7f4e27b6b0981f828c501fcc2647bc443c3153ed7de01f39ca31fdc9c","contentType":"text/markdown; charset=utf-8"},{"id":"48239c4d-9ddb-53e5-bce5-a5804c93fb98","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/48239c4d-9ddb-53e5-bce5-a5804c93fb98/attachment.md","path":"skills/odoo-troubleshooting-guide.md","size":15600,"sha256":"a617724639bca893ffc6060ff934530b0bb27571ce32fa43fa369c34679ebf96","contentType":"text/markdown; charset=utf-8"},{"id":"bc5288ec-afe1-545e-8d0c-272dc332a804","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bc5288ec-afe1-545e-8d0c-272dc332a804/attachment.md","path":"skills/odoo-version-knowledge-14-15.md","size":5596,"sha256":"2b41be47f2257f5bc847242c69e552a35f431723c34b868e94843f69e4c309e4","contentType":"text/markdown; charset=utf-8"},{"id":"b0dba3bb-5667-5ddd-8fc5-0f02ebfcaba5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b0dba3bb-5667-5ddd-8fc5-0f02ebfcaba5/attachment.md","path":"skills/odoo-version-knowledge-14.md","size":7532,"sha256":"581b6f0c2ab5585e6157d0866b07aef6a217441a6d2f2ce0556e9099d03aa34d","contentType":"text/markdown; charset=utf-8"},{"id":"51737e77-87d1-5d23-a14e-6ab5dd46a702","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/51737e77-87d1-5d23-a14e-6ab5dd46a702/attachment.md","path":"skills/odoo-version-knowledge-15-16.md","size":6648,"sha256":"e57beb7ef1779653f2e8fb0240f93886a35f3b7bb21c3f4253034387c3ad62c1","contentType":"text/markdown; charset=utf-8"},{"id":"8c90319e-f03f-5bce-8f45-7aea5eed8b03","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8c90319e-f03f-5bce-8f45-7aea5eed8b03/attachment.md","path":"skills/odoo-version-knowledge-15.md","size":8620,"sha256":"a71adfedf42eff015dcbea2d35764091d36b85561172e4e4657b9e0cf04875ab","contentType":"text/markdown; charset=utf-8"},{"id":"b12fa30c-1225-5b61-9855-3629e593ed88","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b12fa30c-1225-5b61-9855-3629e593ed88/attachment.md","path":"skills/odoo-version-knowledge-16-17.md","size":8600,"sha256":"abade351de8253d9342053e0bc8f480ac3f4bd88a9e2aec286e7226696852633","contentType":"text/markdown; charset=utf-8"},{"id":"12d1fa7a-ebe2-5e39-a2e0-160cf431c59b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/12d1fa7a-ebe2-5e39-a2e0-160cf431c59b/attachment.md","path":"skills/odoo-version-knowledge-16.md","size":10627,"sha256":"873a8f526e8538985ea5427e793882b25ed2fb2f918a0cbeadb15645f313d198","contentType":"text/markdown; charset=utf-8"},{"id":"022a1461-cee1-517b-8d77-565b52a32d9e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/022a1461-cee1-517b-8d77-565b52a32d9e/attachment.md","path":"skills/odoo-version-knowledge-17-18.md","size":9268,"sha256":"2ca371c4e7933843411333b83f1f3bf52fa379c9778c512ce09faa64aa251867","contentType":"text/markdown; charset=utf-8"},{"id":"59bf1a31-6969-5994-845b-1375b0767056","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/59bf1a31-6969-5994-845b-1375b0767056/attachment.md","path":"skills/odoo-version-knowledge-17.md","size":13994,"sha256":"7c6d38353acb2946ceb20bf1f1cce987eba060e2cda9860f1e135caac7104942","contentType":"text/markdown; charset=utf-8"},{"id":"a609b9aa-e586-58b0-b1bd-c545e398f441","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a609b9aa-e586-58b0-b1bd-c545e398f441/attachment.md","path":"skills/odoo-version-knowledge-18-19.md","size":15003,"sha256":"233175f5ce847089e6afe76bc4553fb39dc5c28fea37fc53b3047a36c95ca59e","contentType":"text/markdown; charset=utf-8"},{"id":"4e7d175d-add7-54b4-9521-ac39b96dd9d4","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4e7d175d-add7-54b4-9521-ac39b96dd9d4/attachment.md","path":"skills/odoo-version-knowledge-18.md","size":7143,"sha256":"784ed7239a3811147c7f3a48cf418eb37b943440e7c9629ea3f29453a40067d5","contentType":"text/markdown; charset=utf-8"},{"id":"76090b7a-73d8-5869-989f-e054dd3f5fd8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/76090b7a-73d8-5869-989f-e054dd3f5fd8/attachment.md","path":"skills/odoo-version-knowledge-19.md","size":14748,"sha256":"736269637c2bc3f89ae1061b060f69991ca4ce0b4550256ecf340fe858c56727","contentType":"text/markdown; charset=utf-8"},{"id":"598e7a57-199d-5e8c-ae8b-8a77911d9c82","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/598e7a57-199d-5e8c-ae8b-8a77911d9c82/attachment.md","path":"skills/odoo-version-knowledge-all.md","size":8206,"sha256":"0e835c5915e929b4befc8d5c4dc53687096d656037385678375e1fe37644ecd7","contentType":"text/markdown; charset=utf-8"},{"id":"5b12fc3f-6d38-560c-b3d7-25e428677755","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5b12fc3f-6d38-560c-b3d7-25e428677755/attachment.md","path":"skills/odoo-version-knowledge.md","size":6932,"sha256":"e8d89faf650f24f34e75dba86c45a82b2d0013abe3f4f31630007a891f798799","contentType":"text/markdown; charset=utf-8"},{"id":"d7b8cf3d-bdf4-59bc-839f-f23ba51c8b30","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d7b8cf3d-bdf4-59bc-839f-f23ba51c8b30/attachment.md","path":"skills/onchange-dynamic-patterns.md","size":13816,"sha256":"36d6af997fe76aa3f64579f64efa6cdfcdf0619e1e9acea5a008e0dc8b55bfa4","contentType":"text/markdown; charset=utf-8"},{"id":"199b751b-729a-52a6-8098-01b5239cc7da","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/199b751b-729a-52a6-8098-01b5239cc7da/attachment.md","path":"skills/portal-access-patterns.md","size":17112,"sha256":"507b65fc5072b6d7a5241f248b90972f1d34813092ab8e49d1d8ae5db85b23a9","contentType":"text/markdown; charset=utf-8"},{"id":"69824dc7-fe5b-55f9-ac60-e16dbc167a53","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/69824dc7-fe5b-55f9-ac60-e16dbc167a53/attachment.md","path":"skills/pricelist-pricing-patterns.md","size":13756,"sha256":"bed303a7c0bd91db994a9f59f31cfba0e31150a8bddd4062bb362c39bbf0a7f9","contentType":"text/markdown; charset=utf-8"},{"id":"9ed87d4b-ebf3-537c-93db-f8dcf9b359cc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9ed87d4b-ebf3-537c-93db-f8dcf9b359cc/attachment.md","path":"skills/product-variant-patterns.md","size":14425,"sha256":"47c5d5786a94e38c6b34d97a70d1f99b8548801aa409cf0f76d83a1a1ef87469","contentType":"text/markdown; charset=utf-8"},{"id":"4f1c95c7-7651-5314-b88f-37282953d250","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f1c95c7-7651-5314-b88f-37282953d250/attachment.md","path":"skills/project-task-patterns.md","size":15756,"sha256":"6cfd9db74a5ad683aee3b868b822712a0c0fd65902bfaa1ac78a2e56baa73c4b","contentType":"text/markdown; charset=utf-8"},{"id":"6a0184f0-259e-5012-94fa-c65fa8a3ea39","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6a0184f0-259e-5012-94fa-c65fa8a3ea39/attachment.md","path":"skills/purchase-procurement-patterns.md","size":15459,"sha256":"cc5463281b8478e5e24549f2527d4718d214a7ba0338de116364b35d2aad0844","contentType":"text/markdown; charset=utf-8"},{"id":"c7e96218-bdc7-5f4c-aad9-49f385da6d8f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c7e96218-bdc7-5f4c-aad9-49f385da6d8f/attachment.md","path":"skills/quick-patterns.md","size":4391,"sha256":"10cda46d0d4deba744f18a78640b91191eaa5244e095998e993eba48bae36846","contentType":"text/markdown; charset=utf-8"},{"id":"2e09bb28-33ee-5020-97a1-081b191f17d5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2e09bb28-33ee-5020-97a1-081b191f17d5/attachment.md","path":"skills/qweb-template-patterns.md","size":14311,"sha256":"997d7dd8614a52406d259235b40ff28280ce07dc8006f08636e33495b3160138","contentType":"text/markdown; charset=utf-8"},{"id":"b8348d8e-7dc7-52a8-9e97-cae37de3bedf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b8348d8e-7dc7-52a8-9e97-cae37de3bedf/attachment.md","path":"skills/report-patterns.md","size":14792,"sha256":"bf4676c72e4b97664b6351adbfa9af630b5da2b46a75ef10c5a7447ae2c83802","contentType":"text/markdown; charset=utf-8"},{"id":"9efffcb1-93a1-5ade-ab47-1bfe6bbae5f8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9efffcb1-93a1-5ade-ab47-1bfe6bbae5f8/attachment.md","path":"skills/sale-crm-patterns.md","size":14508,"sha256":"0496621ef357d87409879f4abdbf5738787343674fc891b6d8c01a498b5383d9","contentType":"text/markdown; charset=utf-8"},{"id":"fed7ce25-2a16-5bdb-b57d-2dc7324a8859","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fed7ce25-2a16-5bdb-b57d-2dc7324a8859/attachment.md","path":"skills/sequence-numbering-patterns.md","size":13698,"sha256":"b7021f2085fa6288ee69a3313af3b2be05b767661373a440f69f6c4caabf5312","contentType":"text/markdown; charset=utf-8"},{"id":"0327541b-2099-553e-8a36-2e54c987cd2b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0327541b-2099-553e-8a36-2e54c987cd2b/attachment.md","path":"skills/stock-inventory-patterns.md","size":14766,"sha256":"6160cbf1faa99c358c3844f2ab994336385689283f9d1a5c5ed7430fbca2fea4","contentType":"text/markdown; charset=utf-8"},{"id":"e08939e9-a0ac-50ca-89f6-5696ff31586e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e08939e9-a0ac-50ca-89f6-5696ff31586e/attachment.md","path":"skills/tax-fiscal-patterns.md","size":13134,"sha256":"51b1f2f7bed3924a5738de8105ebb978b94ba511ae10c85eb92b04f255b9fdd4","contentType":"text/markdown; charset=utf-8"},{"id":"f5115b60-9586-516f-a237-994098ac2e69","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f5115b60-9586-516f-a237-994098ac2e69/attachment.md","path":"skills/translation-i18n-patterns.md","size":14504,"sha256":"844cebec7504ade1a5b40f1e5c5894bfa7b9f759a5eb059a0ea18bfd0db60312","contentType":"text/markdown; charset=utf-8"},{"id":"4d37eac9-8eec-537a-9d4b-3aba68097dbc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4d37eac9-8eec-537a-9d4b-3aba68097dbc/attachment.md","path":"skills/uom-patterns.md","size":11610,"sha256":"949b7dfde8303b80f10257df19a45ab98854dc39069162b8e82c7af33a02dbbf","contentType":"text/markdown; charset=utf-8"},{"id":"dc12704b-a8dd-54ac-97b6-1cde188bd029","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dc12704b-a8dd-54ac-97b6-1cde188bd029/attachment.md","path":"skills/website-integration-patterns.md","size":19848,"sha256":"1869d40825149c72a4e2f527642215911d32b68fd1649a82a0bd2af7d2a27fbd","contentType":"text/markdown; charset=utf-8"},{"id":"a1e752b4-c307-5ded-8d5b-748aa21e92a8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a1e752b4-c307-5ded-8d5b-748aa21e92a8/attachment.md","path":"skills/widget-field-patterns.md","size":13671,"sha256":"da497678bd500ea113c6ed7f9bb5a8955227264977f14927193ff451a7a10260","contentType":"text/markdown; charset=utf-8"},{"id":"8c09d046-287d-504e-8ea9-195fb6726602","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8c09d046-287d-504e-8ea9-195fb6726602/attachment.md","path":"skills/wizard-patterns.md","size":15600,"sha256":"d02d4c3537f5e960a4c925b6f82e23a81b0b1fe6a1a320f38d71453106b18e9c","contentType":"text/markdown; charset=utf-8"},{"id":"fb60dcde-8e76-557d-98cd-5c2a5d3d1d06","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fb60dcde-8e76-557d-98cd-5c2a5d3d1d06/attachment.md","path":"skills/workflow-state-patterns.md","size":18946,"sha256":"6dca073ce9bd41519e3e62038fb470202f4beaa953fd3accca1db12c7cf54e30","contentType":"text/markdown; charset=utf-8"},{"id":"c415f11d-debe-5b9d-b4de-57a5e0ccd9d7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c415f11d-debe-5b9d-b4de-57a5e0ccd9d7/attachment.md","path":"skills/xml-view-patterns.md","size":19502,"sha256":"eddeb467121f6ec4ff1ed72277aeb999dea028babd42614b6936f104259d0393","contentType":"text/markdown; charset=utf-8"}],"bundle_sha256":"ba6ee126b22fe99fffad9d48e59464f07b40af2cc130e8f19e6e2cc20fccae8e","attachment_count":120,"text_attachments":120,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"software-engineering","category_label":"Engineering"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"software-engineering","import_tag":"clean-skills-v1","description":"Universal Odoo development skill based on strict OCA standards, covering versions 14-19. Includes agents for code review, upgrade analysis, and pattern discovery."}},"renderedAt":1782987174703}

Odoo Development Skill (Universal) You are a Senior Odoo Architect expert in Python and JavaScript, following strict development standards. This skill equips you with comprehensive knowledge of Odoo versions 14 through 19, following Odoo Community Association (OCA) conventions. ⚠️ CRITICAL WORKFLOW - EXECUTE IN ORDER 1. DETECT ODOO VERSION Identify target version BEFORE applying any pattern: Read in the current directory and extract the version ( ). The first number represents the Odoo version (14, 15, 16, 17, 18, 19). 2. DON'T REINVENT THE WHEEL ⚡ BEFORE developing ANY new functionality, per…