From 3b7338eacd332d1afc277af4283cceedeb5f3f38 Mon Sep 17 00:00:00 2001
From: Sanjay Sharma
Date: Thu, 22 Jan 2026 11:03:09 +0530
Subject: [PATCH 1/9] [ADD] service_event_base: for managing service events and
bookings Commit 1: Module Foundation + Hooks - Module structure
(`__manifest__.py`, `__init__.py`) - Pre-init hook (table preparation, SQL
cleanup) - Post-init hook (default data, categories) - Init function
(indexes, SQL views)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Module Structure:
service_event_base/
├── __init__.py # Module entry point
├── __manifest__.py # Module metadata and dependencies
├── hooks.py # Lifecycle hooks (pre-init, post-init)
├── models/ # Business models
│ ├── __init__.py
│ ├── service_event_category.py
│ ├── service_event_tag.py
│ ├── service_event.py
│ └── service_booking.py
├── security/ # Access control
│ ├── security.xml
│ └── ir.model.access.csv
├── data/ # Default data
│ ├── sequences.xml
│ └── categories.xml
├── views/ # User interface
│ ├── service_event_views.xml
│ ├── service_booking_views.xml
│ └── menus.xml
└── demo/ # Demo data (optional)
└── demo_data.xml
---
service_event_base/README.md | 322 +++++++++++++++
service_event_base/__init__.py | 43 ++
service_event_base/__manifest__.py | 113 ++++++
service_event_base/data/categories.xml | 80 ++++
service_event_base/data/sequences.xml | 101 +++++
service_event_base/demo/demo_data.xml | 27 ++
service_event_base/hooks.py | 371 ++++++++++++++++++
service_event_base/models/__init__.py | 45 +++
service_event_base/models/service_booking.py | 22 ++
service_event_base/models/service_event.py | 22 ++
.../models/service_event_category.py | 274 +++++++++++++
.../models/service_event_tag.py | 146 +++++++
.../security/ir.model.access.csv | 3 +
service_event_base/security/security.xml | 46 +++
service_event_base/views/menus.xml | 29 ++
.../views/service_booking_views.xml | 17 +
.../views/service_event_views.xml | 32 ++
17 files changed, 1693 insertions(+)
create mode 100644 service_event_base/README.md
create mode 100644 service_event_base/__init__.py
create mode 100644 service_event_base/__manifest__.py
create mode 100644 service_event_base/data/categories.xml
create mode 100644 service_event_base/data/sequences.xml
create mode 100644 service_event_base/demo/demo_data.xml
create mode 100644 service_event_base/hooks.py
create mode 100644 service_event_base/models/__init__.py
create mode 100644 service_event_base/models/service_booking.py
create mode 100644 service_event_base/models/service_event.py
create mode 100644 service_event_base/models/service_event_category.py
create mode 100644 service_event_base/models/service_event_tag.py
create mode 100644 service_event_base/security/ir.model.access.csv
create mode 100644 service_event_base/security/security.xml
create mode 100644 service_event_base/views/menus.xml
create mode 100644 service_event_base/views/service_booking_views.xml
create mode 100644 service_event_base/views/service_event_views.xml
diff --git a/service_event_base/README.md b/service_event_base/README.md
new file mode 100644
index 0000000..0d6e559
--- /dev/null
+++ b/service_event_base/README.md
@@ -0,0 +1,322 @@
+# Service Event Base Module
+
+**Version:** 19.1.0.0
+**Category:** Services/Events
+**License:** LGPL-3
+
+## Overview
+
+Core business logic module for the Service Event & Booking System. This module provides foundational models, business logic, security, and data management for service event operations.
+
+## Architecture
+
+This is a **two-module system**:
+- **service_event_base** (this module): Core business logic, models, security
+- **service_event_website** (companion): Website pages, portal, snippets, controllers
+
+### Why Separate Modules?
+
+- ✅ Base module can be used without website (API, mobile, POS)
+- ✅ Cleaner dependency management
+- ✅ Easier testing and maintenance
+- ✅ Follows Odoo best practices (e.g., sale vs sale_management)
+
+## Dependencies
+
+- `base`: Core Odoo framework
+- `mail`: Activity tracking and messaging
+
+**Does NOT depend on:**
+- `website` (handled by service_event_website)
+- `portal` (handled by service_event_website)
+
+## Features (Planned)
+
+### Core Business Logic
+- Service/Event catalog management
+- Booking/Registration system with workflow
+- Multi-company support
+- Advanced ORM operations
+
+### Security
+- User groups (Service User, Service Manager)
+- Access rights (model-level permissions)
+- Record rules (row-level security)
+
+### Data Management
+- Auto-numbering sequences
+- Master data (categories, tags)
+- SQL-level optimizations
+
+## Installation
+
+### Module Structure
+```
+service_event_base/
+├── __init__.py # Module entry point
+├── __manifest__.py # Module metadata and dependencies
+├── hooks.py # Lifecycle hooks (pre-init, post-init)
+├── models/ # Business models
+│ ├── __init__.py
+│ ├── service_event_category.py
+│ ├── service_event_tag.py
+│ ├── service_event.py
+│ └── service_booking.py
+├── security/ # Access control
+│ ├── security.xml
+│ └── ir.model.access.csv
+├── data/ # Default data
+│ ├── sequences.xml
+│ └── categories.xml
+├── views/ # User interface
+│ ├── service_event_views.xml
+│ ├── service_booking_views.xml
+│ └── menus.xml
+└── demo/ # Demo data (optional)
+ └── demo_data.xml
+```
+
+### Installation Lifecycle
+
+1. **Pre-init Hook** (`hooks.pre_init_hook`)
+ - Prepares database at SQL level
+ - Creates materialized views
+ - Creates database extensions (pg_trgm)
+ - Ensures sequences exist
+
+2. **Module Installation**
+ - Odoo creates tables from model definitions
+ - Loads XML data files
+ - Registers models with ORM
+
+3. **Post-init Hook** (`hooks.post_init_hook`)
+ - Creates default categories via ORM
+ - Creates default tags
+ - Refreshes materialized views
+ - Initializes configuration
+
+### Install Command
+
+```bash
+# Development mode with demo data
+./odoo-bin -d database_name -i service_event_base --dev=all
+
+# Production mode without demo data
+./odoo-bin -d database_name -i service_event_base --without-demo=all
+```
+
+## Odoo Concepts Demonstrated
+
+### Lifecycle Hooks
+- **Pre-init Hook**: Database preparation before ORM loads
+- **Post-init Hook**: Data initialization after installation
+- **Init Method**: Model-level database optimizations
+
+### ORM Concepts
+- Model registration and inheritance
+- Field types and attributes
+- SQL constraints
+- Database indexes
+- Materialized views
+
+### Data Management
+- XML data files with `noupdate` flag
+- `ir.model.data` for XML ID resolution
+- Sequence generation for auto-numbering
+
+### Security
+- Groups and categories
+- Access rights (CRUD permissions)
+- Record rules (domain filtering)
+
+## Technical Highlights
+
+### 1. Materialized View for Performance
+
+Pre-init hook creates a materialized view for booking statistics:
+```sql
+CREATE MATERIALIZED VIEW service_booking_statistics AS
+SELECT
+ total_bookings,
+ confirmed_bookings,
+ total_revenue
+FROM ...
+```
+
+**Why:** Pre-computed aggregations for dashboards (faster than real-time queries)
+
+### 2. Database Indexes
+
+Category model creates partial indexes in `_auto_init()`:
+```python
+CREATE INDEX service_event_category_code_index
+ON service_event_category (code)
+WHERE active = true
+```
+
+**Why:** Faster lookups on frequently queried fields
+
+### 3. PostgreSQL Extensions
+
+Pre-init enables `pg_trgm` extension for fuzzy search:
+```sql
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+```
+
+**Why:** Enables typo-tolerant searching (similarity matching)
+
+### 4. Smart Sequence Configuration
+
+Booking sequence uses year-based prefixes:
+```xml
+BOOK/%(year)s/
+```
+
+**Result:** `BOOK/2026/0001`, `BOOK/2026/0002`
+
+### 5. Idempotent Hooks
+
+All hooks are safe to run multiple times:
+- Use `IF NOT EXISTS` / `IF EXISTS` patterns
+- Check for existing data before creating
+- Graceful error handling
+
+## Magic Fields (Auto-created by Odoo)
+
+Every model automatically gets 5 magic fields:
+
+1. **id**: Integer primary key (auto-increment)
+2. **create_date**: Timestamp when record was created
+3. **create_uid**: Many2one to res.users (who created)
+4. **write_date**: Timestamp of last modification
+5. **write_uid**: Many2one to res.users (who last modified)
+
+**Why automatic:**
+- Every Odoo model needs tracking
+- Reduces boilerplate code
+- Ensures consistency across all models
+
+## Active Field Pattern
+
+Models use `active` field for soft deletion:
+```python
+active = fields.Boolean(default=True)
+```
+
+**Behavior:**
+- `active=False` → Record is archived (not deleted)
+- Default search filters: `[('active', '=', True)]`
+- Preserves historical data
+- Can be restored anytime
+
+## Display Name Resolution
+
+Odoo uses `_rec_name` to determine record display:
+```python
+_rec_name = 'name' # Use 'name' field for display
+```
+
+**display_name** (magic field):
+- Automatically computed from `_rec_name`
+- Used in Many2one widgets, breadcrumbs, logs
+- Can be customized via `_compute_display_name()`
+
+## ir.model.data Explained
+
+When you define a record with XML ID:
+```xml
+
+ Workshops
+
+```
+
+Odoo creates TWO records:
+
+1. **service_event_category** table:
+ - Actual category record with data
+
+2. **ir_model_data** table:
+ - module: `service_event_base`
+ - name: `category_workshop`
+ - model: `service.event.category`
+ - res_id: (ID from table 1)
+
+**Purpose:**
+- Enables cross-module references
+- Prevents duplicate creation on reinstall
+- Powers module upgrades and dependencies
+- Allows `self.env.ref('module.xml_id')` lookups
+
+## Development Status
+
+### ✅ Commit 1: Module Foundation + Hooks (CURRENT)
+- Module structure
+- Pre-init hook (SQL preparation)
+- Post-init hook (data initialization)
+- Init method (indexes)
+- Category and Tag models
+
+### 🔜 Upcoming Commits
+- Commit 2: Core models + ORM concepts
+- Commit 3: Computed fields + constraints
+- Commit 4: Method overrides
+- Commit 5: Security implementation
+- Commit 6: Backend views
+
+## Testing
+
+After installation, verify:
+
+1. **Categories Created:**
+ ```python
+ self.env['service.event.category'].search([])
+ # Should return: Workshops, Conferences, Webinars, Consulting
+ ```
+
+2. **Tags Created:**
+ ```python
+ self.env['service.event.tag'].search([])
+ # Should return: Popular, New, Limited Seats, Premium
+ ```
+
+3. **Sequence Ready:**
+ ```python
+ self.env['ir.sequence'].next_by_code('service.booking')
+ # Should return: 'BOOK/2026/0001'
+ ```
+
+4. **Materialized View Exists:**
+ ```sql
+ SELECT * FROM service_booking_statistics;
+ ```
+
+## Troubleshooting
+
+### Module Won't Install
+- Check dependencies are installed (`base`, `mail`)
+- Check Python syntax errors
+- Review Odoo logs for detailed errors
+
+### Pre-init Hook Fails
+- Database user may lack extension creation rights
+- Try manual: `CREATE EXTENSION pg_trgm;`
+- Graceful degradation: Module continues without extension
+
+### Post-init Hook Fails
+- Check model imports in `models/__init__.py`
+- Verify no circular dependencies
+- Ensure `SUPERUSER_ID` is available
+
+## Contributing
+
+This module is part of a learning project demonstrating production-grade Odoo development patterns.
+
+## License
+
+LGPL-3
+
+---
+
+**Author:** Odoo Full-Stack Development Team
+**Odoo Version:** 19.0
+**Module Version:** 1.0.0
diff --git a/service_event_base/__init__.py b/service_event_base/__init__.py
new file mode 100644
index 0000000..a5d7b6b
--- /dev/null
+++ b/service_event_base/__init__.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+"""
+Service Event Base Module - Main Initialization
+
+This file is the entry point for the service_event_base module.
+
+PYTHON PACKAGE STRUCTURE:
+ Odoo treats each module as a Python package.
+ The __init__.py file defines what gets imported when the module loads.
+
+IMPORT ORDER MATTERS:
+ 1. Hooks first (needed by __manifest__.py declarations)
+ 2. Models next (register with ORM)
+ 3. Controllers last (if any - none in base module)
+
+WHY THIS ORDER:
+ - Hooks are referenced by name in __manifest__.py (must be importable)
+ - Models must load before views reference them
+ - Controllers depend on models being registered
+
+WHY NOT IMPORT EVERYTHING:
+ - Only import what's needed for module to function
+ - Avoid circular dependencies
+ - Keep namespace clean
+ - Explicit imports = better debugging
+
+ODOO MODULE LOADING SEQUENCE:
+ 1. Odoo scans __manifest__.py
+ 2. Odoo imports __init__.py (this file)
+ 3. Hooks are called (if declared in manifest)
+ 4. Models are registered with ORM
+ 5. Data files (XML) are loaded
+ 6. Module is marked as installed
+"""
+
+# Import hook functions to module level
+# WHY: __manifest__.py references these by name (pre_init_hook, post_init_hook)
+# Odoo looks for them as module attributes
+from .hooks import pre_init_hook, post_init_hook
+
+# Import models subpackage
+# This will trigger models/__init__.py which imports all model files
+from . import models
diff --git a/service_event_base/__manifest__.py b/service_event_base/__manifest__.py
new file mode 100644
index 0000000..a9494c7
--- /dev/null
+++ b/service_event_base/__manifest__.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+# Service Event Base Module Manifest
+#
+# This manifest defines the core business logic module for the Service Event & Booking System.
+#
+# ARCHITECTURE DECISION:
+# - Separated into base module (this) and website module (service_event_website)
+# - Base module handles: models, business logic, security, ORM operations
+# - Website module handles: web pages, controllers, snippets, portal
+#
+# WHY THIS SEPARATION:
+# - Allows installation of core business logic without website dependency
+# - Enables reuse of business models in other contexts (mobile, API, POS, etc.)
+# - Follows Odoo's modular architecture pattern (e.g., sale vs sale_management)
+# - Cleaner dependency graph and easier maintenance
+#
+# WHY NOT SINGLE MODULE:
+# - Would violate separation of concerns
+# - Would force website dependency even for API-only use cases
+# - Would make testing more complex (harder to unit test models separately)
+#
+# HOOK DECLARATIONS:
+# - pre_init_hook: Runs BEFORE module installation (SQL-level preparation)
+# - post_init_hook: Runs AFTER module installation (data initialization via ORM)
+#
+# These hooks are declared as strings matching function names in hooks.py.
+# Odoo will import and execute them at the appropriate lifecycle stage.
+
+{
+ 'name': 'Service Event Base',
+ 'version': '19.2.1.0.0',
+ 'category': 'Services',
+ 'summary': 'Core business logic for service event management and booking system',
+ 'description': """
+ Service Event Base Module
+ ==========================
+
+ This module provides the foundational business logic for managing service events and bookings.
+
+ Core Features:
+ --------------
+ * Service/Event Management (catalog, pricing, categorization)
+ * Booking/Registration System (state workflow, validation)
+ * Multi-company support
+ * Advanced ORM operations (computed fields, constraints, overrides)
+ * Security framework (groups, access rights, record rules)
+
+ Technical Highlights:
+ ---------------------
+ * Demonstrates Odoo ORM concepts (compute, related, constraints)
+ * Implements proper lifecycle hooks (pre-init, post-init, init)
+ * Uses sequences for auto-numbering
+ * Includes SQL-level optimizations (indexes, views)
+ * Multi-company aware with proper domain filtering
+
+ Architecture:
+ -------------
+ This is the BASE module. It must NOT depend on 'website' or 'portal'.
+ For web integration, install the companion module: service_event_website
+
+ Dependencies:
+ -------------
+ * base: Core Odoo framework
+ * mail: Activity tracking and messaging (used for booking notifications)
+ """,
+
+ 'author': 'Odoo Full-Stack Development Team',
+ 'website': 'https://www.example.com',
+ 'license': 'LGPL-3',
+
+ # Dependencies - ONLY base and mail, NO website/portal
+ 'depends': [
+ 'base',
+ 'mail', # For activity tracking, chatter, notifications
+ ],
+
+ # Always load these data files
+ 'data': [
+ # Security must load first (groups before access rights)
+ 'security/security.xml',
+ 'security/ir.model.access.csv',
+
+ # Data files
+ 'data/sequences.xml',
+ 'data/categories.xml',
+
+ # Views
+ 'views/service_event_views.xml',
+ 'views/service_booking_views.xml',
+ 'views/menus.xml',
+ ],
+
+ # Demo data (only loaded with demo mode)
+ 'demo': [
+ 'demo/demo_data.xml',
+ ],
+
+ # Lifecycle hooks - executed during installation
+ 'pre_init_hook': 'pre_init_hook',
+ 'post_init_hook': 'post_init_hook',
+
+ # Module behavior flags
+ 'installable': True,
+ 'application': True, # This is a standalone application
+
+ # External dependencies (Python packages)
+ 'external_dependencies': {
+ 'python': [], # No additional Python packages required
+ },
+
+ # Assets (JS/CSS) - base module has no web assets
+ 'assets': {},
+}
diff --git a/service_event_base/data/categories.xml b/service_event_base/data/categories.xml
new file mode 100644
index 0000000..0da8743
--- /dev/null
+++ b/service_event_base/data/categories.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/service_event_base/data/sequences.xml b/service_event_base/data/sequences.xml
new file mode 100644
index 0000000..8a47454
--- /dev/null
+++ b/service_event_base/data/sequences.xml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+ Service Booking Sequence
+ service.booking
+ BOOK/%(year)s/
+
+ 4
+
+ 1
+ 1
+ standard
+
+
+
+
+
diff --git a/service_event_base/demo/demo_data.xml b/service_event_base/demo/demo_data.xml
new file mode 100644
index 0000000..1e745e5
--- /dev/null
+++ b/service_event_base/demo/demo_data.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/service_event_base/hooks.py b/service_event_base/hooks.py
new file mode 100644
index 0000000..156a029
--- /dev/null
+++ b/service_event_base/hooks.py
@@ -0,0 +1,371 @@
+# -*- coding: utf-8 -*-
+"""
+Service Event Base Module - Lifecycle Hooks
+
+This module defines pre-init and post-init hooks for the service_event_base module.
+
+HOOK EXECUTION ORDER:
+ 1. pre_init_hook (BEFORE ORM is available)
+ 2. Module installation (XML data, models registration)
+ 3. post_init_hook (AFTER ORM is available)
+
+WHY USE HOOKS:
+ - Some operations cannot be done via XML (e.g., conditional SQL)
+ - Some operations must happen before/after module load
+ - Allows database preparation and cleanup
+ - Enables data migration and initialization logic
+
+WHY NOT XML DATA FILES:
+ - XML cannot execute conditional logic
+ - XML cannot access raw SQL (needed for schema changes)
+ - XML runs during installation, not before/after
+ - Hooks provide finer control over execution timing
+"""
+
+import logging
+from odoo import api, SUPERUSER_ID
+
+_logger = logging.getLogger(__name__)
+
+
+def pre_init_hook(cr):
+ """
+ Pre-initialization hook executed BEFORE module installation.
+
+ PURPOSE:
+ Prepare the database at the SQL level before Odoo ORM loads the module.
+ This is the ONLY place where you can safely modify database schema
+ before Odoo's ORM attempts to create tables.
+
+ WHEN TO USE:
+ - Renaming existing tables/columns (data migration)
+ - Creating SQL views that models will reference
+ - Dropping conflicting constraints from previous versions
+ - Backing up critical data before schema changes
+ - Adding indexes that must exist before ORM operations
+
+ WHY NOT ORM:
+ - ORM is NOT available yet (models not loaded)
+ - Only raw SQL via cursor (cr) is allowed
+ - Attempting to use env or models will fail
+
+ PARAMETERS:
+ cr: Database cursor (psycopg2 cursor object)
+ - Used for executing raw SQL
+ - No transaction management needed (Odoo handles it)
+
+ IDEMPOTENCY:
+ This function may run multiple times (upgrades, reinstalls).
+ All operations must be safe to re-execute.
+ Use IF EXISTS / IF NOT EXISTS patterns.
+
+ EXAMPLE USE CASES:
+ - Migrate data from old module version
+ - Rename deprecated tables to new names
+ - Create PostgreSQL extensions (e.g., pg_trgm for fuzzy search)
+ - Add database-level constraints before ORM validation
+
+ WHY THIS IMPLEMENTATION:
+ - Creates a materialized view for booking statistics (performance)
+ - Adds a partial index for active services (query optimization)
+ - These must exist before models reference them
+
+ WHY NOT IN INIT METHOD:
+ - Init runs after models are loaded (too late for schema prep)
+ - Pre-init ensures database is ready for model registration
+ """
+
+ _logger.info("=" * 80)
+ _logger.info("EXECUTING PRE-INIT HOOK: service_event_base")
+ _logger.info("=" * 80)
+
+ # ========================================================================
+ # STEP 1: Create helper function for safe SQL execution
+ # ========================================================================
+ # WHY: Prevents crashes if view/table already exists
+ # WHY NOT try/except: Explicit is better than implicit, clearer logs
+
+ def execute_safe_sql(sql_query, description):
+ """Execute SQL with error handling and logging."""
+ try:
+ cr.execute(sql_query)
+ _logger.info("✓ PRE-INIT: %s", description)
+ except Exception as e:
+ _logger.warning("⚠ PRE-INIT: %s failed - %s", description, str(e))
+ # Don't raise - this may be a re-installation
+
+ # ========================================================================
+ # STEP 2: Drop old/conflicting objects (migration safety)
+ # ========================================================================
+ # WHY: If upgrading from older version, old structures must be removed
+ # PATTERN: Always use DROP IF EXISTS (idempotent)
+
+ execute_safe_sql(
+ """
+ DROP MATERIALIZED VIEW IF EXISTS service_booking_statistics CASCADE;
+ """,
+ "Dropped old booking statistics view (if existed)"
+ )
+
+ # ========================================================================
+ # STEP 3: Create materialized view for reporting
+ # ========================================================================
+ # WHY MATERIALIZED VIEW:
+ # - Pre-computed aggregations for dashboard/reports
+ # - Faster than real-time aggregation on large datasets
+ # - Can be refreshed periodically via cron
+ #
+ # WHY NOT REGULAR VIEW:
+ # - Regular views execute query every time (slow for aggregations)
+ #
+ # WHY NOT COMPUTE FIELD:
+ # - Compute fields calculate per-record (not efficient for stats)
+ # - Materialized view aggregates across all records once
+ #
+ # NOTE: This will be populated by post_init_hook after data exists
+
+ execute_safe_sql(
+ """
+ CREATE MATERIALIZED VIEW IF NOT EXISTS service_booking_statistics AS
+ SELECT
+ 1 as id, -- Dummy ID for now (will be populated post-init)
+ 0 as total_bookings,
+ 0 as confirmed_bookings,
+ 0.0 as total_revenue
+ WITH NO DATA; -- Don't populate yet (no data exists pre-install)
+ """,
+ "Created materialized view for booking statistics"
+ )
+
+ # ========================================================================
+ # STEP 4: Create extension for advanced search (optional)
+ # ========================================================================
+ # WHY pg_trgm:
+ # - Enables fuzzy text search (typo tolerance)
+ # - Powers similarity() and % operator
+ # - Useful for service name searching
+ #
+ # WHY NOT ALWAYS INSTALL:
+ # - Requires PostgreSQL extension installation rights
+ # - May fail on restricted database servers
+ # - Graceful degradation if unavailable
+
+ execute_safe_sql(
+ """
+ CREATE EXTENSION IF NOT EXISTS pg_trgm;
+ """,
+ "Created PostgreSQL trigram extension (for fuzzy search)"
+ )
+
+ # ========================================================================
+ # STEP 5: Prepare sequence (if migrating from old system)
+ # ========================================================================
+ # WHY: Ensure sequence starts at correct number after migration
+ # SCENARIO: Importing existing bookings from external system
+
+ execute_safe_sql(
+ """
+ -- Create sequence if not exists (will be used by booking name)
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_sequences WHERE schemaname = 'public' AND sequencename = 'service_booking_sequence'
+ ) THEN
+ CREATE SEQUENCE service_booking_sequence START 1;
+ END IF;
+ END $$;
+ """,
+ "Ensured booking sequence exists"
+ )
+
+ _logger.info("=" * 80)
+ _logger.info("PRE-INIT HOOK COMPLETED SUCCESSFULLY")
+ _logger.info("=" * 80)
+
+
+def post_init_hook(env):
+ """
+ Post-initialization hook executed AFTER module installation.
+
+ PURPOSE:
+ Initialize data and configure the system using the Odoo ORM.
+ This runs after all models are loaded and tables are created.
+
+ WHEN TO USE:
+ - Create default master data (categories, tags)
+ - Assign default access rights
+ - Populate initial configuration
+ - Refresh materialized views
+ - Generate demo/seed data
+
+ WHY USE ORM HERE:
+ - ORM is fully available (all models loaded)
+ - Can use model methods, constraints, computed fields
+ - Automatic logging, tracking, translations
+ - Type safety and validation
+
+ PARAMETERS:
+ env: Odoo Environment object (provides access to ORM)
+ In Odoo 19+, hooks receive env directly (not cr + registry)
+
+ ENVIRONMENT PROVIDED:
+ Odoo 19 automatically provides an Environment instance.
+ No need to manually create it using api.Environment.
+ WHY CHANGE: Simpler API, follows Odoo's direction
+
+ IDEMPOTENCY:
+ Use with noupdate="1" or check existence before create.
+ This may run on upgrade, so avoid duplicate data creation.
+
+ WHY NOT XML DATA FILES:
+ - XML runs during installation (this runs AFTER)
+ - Can use Python logic (conditionals, loops)
+ - Can reference just-created records
+ - Can perform complex calculations
+
+ EXAMPLE USE CASES:
+ - Create default service categories
+ - Assign groups to the default admin user
+ - Populate configuration parameters
+ - Refresh materialized views with initial data
+ - Create welcome messages or tours
+
+ WHY THIS IMPLEMENTATION:
+ - Creates default service categories (required for demo data)
+ - Refreshes booking statistics view
+ - Creates sample data for testing
+
+ WHY NOT IN MODEL _AUTO_INIT:
+ - _auto_init is too early (other modules not loaded)
+ - Post-init guarantees all dependencies are ready
+ """
+
+ _logger.info("=" * 80)
+ _logger.info("EXECUTING POST-INIT HOOK: service_event_base")
+ _logger.info("=" * 80)
+
+ # ========================================================================
+ # Environment is provided directly in Odoo 19+
+ # ========================================================================
+ # No need to create api.Environment(cr, SUPERUSER_ID, {})
+ # env is already available with SUPERUSER context
+
+ # ========================================================================
+ # STEP 2: Create default service categories
+ # ========================================================================
+ # WHY CREATE HERE NOT XML:
+ # - XML would create duplicates on upgrade
+ # - Can check existence dynamically
+ # - Can use ORM methods for validation
+ #
+ # PATTERN: Check existence before create (idempotent)
+
+ _logger.info("Creating default service categories...")
+
+ Category = env['service.event.category']
+
+ # Define default categories
+ # WHY DICT: Structured data, easy to extend
+ default_categories = [
+ {
+ 'name': 'Workshops',
+ 'description': 'Educational workshops and training sessions',
+ 'code': 'WORKSHOP',
+ },
+ {
+ 'name': 'Conferences',
+ 'description': 'Professional conferences and seminars',
+ 'code': 'CONFERENCE',
+ },
+ {
+ 'name': 'Webinars',
+ 'description': 'Online webinars and virtual events',
+ 'code': 'WEBINAR',
+ },
+ {
+ 'name': 'Consulting',
+ 'description': 'One-on-one consulting services',
+ 'code': 'CONSULTING',
+ },
+ ]
+
+ for cat_data in default_categories:
+ # Check if category exists (by code)
+ # WHY BY CODE: Name may change (translations), code is unique
+ existing = Category.search([('code', '=', cat_data['code'])], limit=1)
+
+ if not existing:
+ category = Category.create(cat_data)
+ _logger.info(" ✓ Created category: %s", category.name)
+ else:
+ _logger.info(" ⊳ Category already exists: %s", existing.name)
+
+ # ========================================================================
+ # STEP 3: Create default tags
+ # ========================================================================
+
+ _logger.info("Creating default service tags...")
+
+ Tag = env['service.event.tag']
+
+ default_tags = [
+ {'name': 'Popular', 'color': 2}, # Green
+ {'name': 'New', 'color': 4}, # Blue
+ {'name': 'Limited Seats', 'color': 1}, # Red
+ {'name': 'Premium', 'color': 5}, # Purple
+ ]
+
+ for tag_data in default_tags:
+ existing = Tag.search([('name', '=', tag_data['name'])], limit=1)
+ if not existing:
+ tag = Tag.create(tag_data)
+ _logger.info(" ✓ Created tag: %s", tag.name)
+ else:
+ _logger.info(" ⊳ Tag already exists: %s", existing.name)
+
+ # ========================================================================
+ # STEP 4: Refresh materialized view
+ # ========================================================================
+ # WHY REFRESH HERE:
+ # - Materialized view created in pre-init (empty)
+ # - After installation, initial data may exist
+ # - Refresh populates view with current statistics
+ #
+ # WHY NOT AUTOMATIC:
+ # - Materialized views don't auto-update (by design)
+ # - Must explicitly refresh (here and via cron)
+
+ _logger.info("Refreshing booking statistics materialized view...")
+
+ try:
+ # Use a savepoint to prevent transaction abort on error
+ env.cr.execute("SAVEPOINT refresh_mat_view;")
+ env.cr.execute("REFRESH MATERIALIZED VIEW service_booking_statistics;")
+ env.cr.execute("RELEASE SAVEPOINT refresh_mat_view;")
+ _logger.info(" ✓ Materialized view refreshed")
+ except Exception as e:
+ # May fail if view doesn't exist yet (pre-init didn't run SQL)
+ env.cr.execute("ROLLBACK TO SAVEPOINT refresh_mat_view;")
+ _logger.warning(" ⚠ Could not refresh view: %s", str(e))
+
+ # ========================================================================
+ # STEP 5: Create welcome message (optional)
+ # ========================================================================
+ # WHY: Inform admins that module is installed
+ # MECHANISM: Create ir.mail_message or log note
+
+ _logger.info("Module initialization complete!")
+ _logger.info(" - Default categories created: %d", len(default_categories))
+ _logger.info(" - Default tags created: %d", len(default_tags))
+ _logger.info(" - Database optimizations applied")
+
+ # ========================================================================
+ # COMMIT NOT NEEDED
+ # ========================================================================
+ # WHY: Odoo manages transaction automatically
+ # Hooks run within module installation transaction
+ # If hook fails, entire installation rolls back
+
+ _logger.info("=" * 80)
+ _logger.info("POST-INIT HOOK COMPLETED SUCCESSFULLY")
+ _logger.info("=" * 80)
diff --git a/service_event_base/models/__init__.py b/service_event_base/models/__init__.py
new file mode 100644
index 0000000..4df3b0b
--- /dev/null
+++ b/service_event_base/models/__init__.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+"""
+Service Event Base Models Package
+
+This package contains all model definitions for the service_event_base module.
+
+MODEL REGISTRATION:
+ Odoo automatically discovers and registers models when they are imported.
+ Each model class inherits from models.Model and is registered in the ORM.
+
+IMPORT PATTERN:
+ - Import model files in logical dependency order
+ - Base/parent models first, dependent models later
+ - If models reference each other, import order doesn't matter
+ (Odoo resolves forward references)
+
+WHY SEPARATE FILES:
+ - Each model in its own file (maintainability)
+ - Easier code review and git diffs
+ - Follows Odoo standard practice
+ - Enables parallel development on different models
+
+WHY THIS STRUCTURE:
+ models/
+ __init__.py (this file)
+ service_event.py (core service/event model)
+ service_event_category.py (master data)
+ service_event_tag.py (master data)
+ service_booking.py (transaction model)
+
+FILE NAMING CONVENTION:
+ - Lowercase with underscores
+ - Matches model _name (e.g., service.event → service_event.py)
+ - Singular form (event not events, booking not bookings)
+"""
+
+# Master data models (no dependencies)
+from . import service_event_category
+from . import service_event_tag
+
+# Core business models
+from . import service_event
+
+# Transaction models (depend on core models)
+from . import service_booking
diff --git a/service_event_base/models/service_booking.py b/service_event_base/models/service_booking.py
new file mode 100644
index 0000000..9846896
--- /dev/null
+++ b/service_event_base/models/service_booking.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+"""
+Service Booking Model - Placeholder
+
+This file will be fully implemented in Commit 2.
+Created now to satisfy import in models/__init__.py.
+
+COMMIT 1 SCOPE:
+ Module foundation and hooks only.
+
+COMMIT 2 WILL ADD:
+ Full service.booking model with all fields and relationships.
+"""
+
+from odoo import models
+
+
+class ServiceBooking(models.Model):
+ """Service Booking Model - To be implemented in Commit 2."""
+
+ _name = 'service.booking'
+ _description = 'Service Booking'
diff --git a/service_event_base/models/service_event.py b/service_event_base/models/service_event.py
new file mode 100644
index 0000000..60084ef
--- /dev/null
+++ b/service_event_base/models/service_event.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+"""
+Service Event Model - Placeholder
+
+This file will be fully implemented in Commit 2.
+Created now to satisfy import in models/__init__.py.
+
+COMMIT 1 SCOPE:
+ Module foundation and hooks only.
+
+COMMIT 2 WILL ADD:
+ Full service.event model with all fields and relationships.
+"""
+
+from odoo import models
+
+
+class ServiceEvent(models.Model):
+ """Service Event Model - To be implemented in Commit 2."""
+
+ _name = 'service.event'
+ _description = 'Service Event'
diff --git a/service_event_base/models/service_event_category.py b/service_event_base/models/service_event_category.py
new file mode 100644
index 0000000..a829376
--- /dev/null
+++ b/service_event_base/models/service_event_category.py
@@ -0,0 +1,274 @@
+# -*- coding: utf-8 -*-
+"""
+Service Event Category Model
+
+PURPOSE:
+ Master data model for categorizing services and events.
+ Provides hierarchical organization and filtering capability.
+
+BUSINESS RATIONALE:
+ - Services need logical grouping (workshops, conferences, webinars)
+ - Categories enable filtered browsing on website
+ - Hierarchical structure allows sub-categories (e.g., Tech Workshops)
+ - Used in snippet options for dynamic filtering
+
+ORM CONCEPTS DEMONSTRATED:
+ - Init method (_auto_init override)
+ - SQL indexes for performance
+ - Translatable fields
+ - Hierarchical parent/child relationships
+
+WHY THIS IS A SEPARATE MODEL:
+ - Categories are master data (low change frequency)
+ - Reusable across many services
+ - Can be managed independently by administrators
+ - Enables reporting and analytics by category
+
+ALTERNATIVES NOT USED:
+ - Selection field: Not flexible, hard-coded values, no hierarchy
+ - Tags: Categories are exclusive (one per service), tags are inclusive
+ - Free text: No consistency, no filtering capability
+"""
+
+from odoo import models, fields, api, _
+from odoo.tools import SQL
+
+
+class ServiceEventCategory(models.Model):
+ """
+ Category master data for service events.
+
+ TECHNICAL CHARACTERISTICS:
+ - Simple master data model
+ - Supports hierarchy (parent/child)
+ - Translatable name and description
+ - Indexed for fast filtering
+
+ MODEL INHERITANCE:
+ Inherits from models.Model (standard persistent model)
+
+ ORM REGISTRATION:
+ Odoo automatically creates table: service_event_category
+ Table name derived from _name with dots → underscores
+ """
+
+ _name = 'service.event.category'
+ _description = 'Service Event Category'
+
+ # ========================================================================
+ # MAGIC FIELDS
+ # ========================================================================
+ # Odoo automatically creates 5 magic fields (don't define them):
+ # 1. id: Integer primary key (auto-increment)
+ # 2. create_date: Timestamp when record was created
+ # 3. create_uid: Many2one to res.users (who created)
+ # 4. write_date: Timestamp when record was last modified
+ # 5. write_uid: Many2one to res.users (who last modified)
+ #
+ # WHY AUTOMATIC:
+ # - Every Odoo model needs tracking (auditing, sync, caching)
+ # - Reduces boilerplate code
+ # - Ensures consistency across all models
+ #
+ # ACCESS:
+ # record.id, record.create_date, etc.
+
+ # ========================================================================
+ # BASIC FIELDS
+ # ========================================================================
+
+ name = fields.Char(
+ string='Category Name',
+ required=True,
+ translate=True, # Enable multi-language support
+ help='Name of the service category (e.g., Workshop, Conference)',
+ )
+ # WHY TRANSLATE=TRUE:
+ # - Website may be multi-lingual
+ # - Category names should display in user's language
+ # - Odoo manages translations automatically (ir.translation table)
+
+ code = fields.Char(
+ string='Category Code',
+ required=True,
+ help='Unique technical identifier (e.g., WORKSHOP, WEBINAR)',
+ )
+ # WHY CODE FIELD:
+ # - Technical reference (doesn't change with translations)
+ # - Used in post_init_hook for idempotent category creation
+ # - Easier to reference in code than database IDs
+
+ description = fields.Text(
+ string='Description',
+ translate=True,
+ help='Detailed description of this category',
+ )
+
+ # ========================================================================
+ # HIERARCHICAL FIELDS (Parent/Child Relationship)
+ # ========================================================================
+
+ parent_id = fields.Many2one(
+ comodel_name='service.event.category',
+ string='Parent Category',
+ ondelete='cascade',
+ help='Parent category for hierarchical organization',
+ )
+ # WHY SELF-REFERENTIAL:
+ # - Allows tree structure (Category → Sub-category)
+ # - Example: "Events" → "Workshops" → "Tech Workshops"
+ #
+ # WHY ondelete='cascade':
+ # - If parent deleted, children should also be deleted
+ # - Alternative: 'restrict' (prevent deletion if has children)
+ # - Alternative: 'set null' (orphan the children)
+
+ child_ids = fields.One2many(
+ comodel_name='service.event.category',
+ inverse_name='parent_id',
+ string='Sub-categories',
+ help='Child categories under this category',
+ )
+ # WHY One2many:
+ # - Automatically computed inverse of parent_id
+ # - Enables tree view and hierarchical navigation
+
+ # ========================================================================
+ # ACTIVE FIELD (Archive/Unarchive Pattern)
+ # ========================================================================
+
+ active = fields.Boolean(
+ default=True,
+ help='Uncheck to archive the category without deleting it',
+ )
+ # ROLE OF ACTIVE FIELD:
+ # - Enables soft delete (archive instead of hard delete)
+ # - Records with active=False are hidden by default
+ # - Can be restored by setting active=True
+ #
+ # WHY USE ACTIVE:
+ # - Preserves historical data (bookings reference old categories)
+ # - Prevents accidental data loss
+ # - Odoo automatically filters active=True in searches
+ #
+ # HOW ODOO USES IT:
+ # - Default domain in search(): [('active', '=', True)]
+ # - Can show archived: search([]) or search([('active', 'in', [True, False])])
+ # - UI has "Archive" action automatically
+
+ # ========================================================================
+ # DISPLAY NAME AND RECORD NAME
+ # ========================================================================
+
+ _rec_name = 'name'
+ # ROLE OF _rec_name:
+ # - Tells Odoo which field to use for display_name
+ # - Default is 'name' (can be overridden)
+ # - Used in Many2one widgets, breadcrumbs, logs
+ #
+ # DISPLAY_NAME:
+ # - Magical computed field (always available)
+ # - Defaults to value of _rec_name field
+ # - Can be customized via _compute_display_name()
+ #
+ # WHY NOT OVERRIDE HERE:
+ # - Simple models use name directly
+ # - Complex models might compute: "Code - Name (Parent)"
+
+ # ========================================================================
+ # SQL CONSTRAINTS
+ # ========================================================================
+ # NOTE: _sql_constraints shows deprecation warning in Odoo 19
+ # but new Constraint API is not yet available
+ # This is safe to use - will be updated when API is stable
+
+ _sql_constraints = [
+ (
+ 'unique_category_code',
+ 'UNIQUE(code)',
+ 'Category code must be unique!'
+ ),
+ ]
+ # WHY SQL CONSTRAINT:
+ # - Enforced at database level (faster, more reliable)
+ # - Prevents race conditions (simultaneous creates)
+ # - Works even if bypassing ORM
+ #
+ # WHY NOT PYTHON CONSTRAINT:
+ # - Python runs after data is prepared (can still have race condition)
+ # - SQL is the last line of defense
+ #
+ # WHEN TO USE BOTH:
+ # - SQL for data integrity
+ # - Python for user-friendly error messages
+
+ # ========================================================================
+ # INIT METHOD (Database Optimization)
+ # ========================================================================
+
+ def _auto_init(self):
+ """
+ Initialize database table with indexes.
+
+ PURPOSE:
+ Called when model is first loaded or upgraded.
+ Used to create/update database schema beyond basic fields.
+
+ EXECUTION TIMING:
+ - Runs after table creation
+ - Runs on every module upgrade
+ - Must be idempotent (safe to run multiple times)
+
+ USE CASES:
+ - Create database indexes (performance)
+ - Create materialized views
+ - Add check constraints
+ - Modify column properties
+
+ WHY OVERRIDE _auto_init:
+ - Indexes are not declaratively defined in field definitions
+ - Need SQL-level control over database structure
+ - Performance optimization for frequent queries
+
+ WHY NOT pre_init_hook:
+ - pre_init runs before table exists
+ - _auto_init runs after table creation (safe to add indexes)
+
+ ODOO PATTERN:
+ Always call super()._auto_init() first to ensure table exists.
+ """
+
+ # Call parent to ensure table and columns exist
+ result = super()._auto_init()
+
+ # Create index on 'code' for fast lookups
+ # WHY INDEX ON CODE:
+ # - post_init_hook searches by code
+ # - Snippet options filter by code
+ # - Without index: full table scan (slow)
+ # - With index: O(log n) lookup (fast)
+ self.env.cr.execute(SQL(
+ """
+ CREATE INDEX IF NOT EXISTS service_event_category_code_index
+ ON service_event_category (code)
+ WHERE active = true
+ """
+ ))
+ # WHY PARTIAL INDEX (WHERE active = true):
+ # - Most queries only care about active categories
+ # - Partial index is smaller (faster, less disk space)
+ # - Archived categories rarely accessed
+
+ # Create index on parent_id for hierarchy traversal
+ # WHY INDEX ON parent_id:
+ # - Tree views need fast child lookups
+ # - Recursive queries benefit from index
+ self.env.cr.execute(SQL(
+ """
+ CREATE INDEX IF NOT EXISTS service_event_category_parent_id_index
+ ON service_event_category (parent_id)
+ WHERE parent_id IS NOT NULL
+ """
+ ))
+
+ return result
diff --git a/service_event_base/models/service_event_tag.py b/service_event_base/models/service_event_tag.py
new file mode 100644
index 0000000..7fa428f
--- /dev/null
+++ b/service_event_base/models/service_event_tag.py
@@ -0,0 +1,146 @@
+# -*- coding: utf-8 -*-
+"""
+Service Event Tag Model
+
+PURPOSE:
+ Master data model for tagging services with multiple labels.
+ Enables flexible, non-hierarchical classification.
+
+BUSINESS RATIONALE:
+ - Services can have multiple characteristics (Popular, New, Premium)
+ - Tags are inclusive (service can have many tags)
+ - Categories are exclusive (service has one category)
+ - Used for filtering, visual indicators, marketing
+
+ORM CONCEPTS DEMONSTRATED:
+ - Many2many relationships (service ↔ tags)
+ - Color field (for UI badges)
+ - No hierarchy (flat structure vs categories)
+
+DIFFERENCE FROM CATEGORY:
+ - Category: One per service, hierarchical, structural
+ - Tag: Many per service, flat, descriptive
+
+ Example:
+ Category: "Workshop"
+ Tags: "Popular", "Premium", "Limited Seats"
+
+ALTERNATIVES NOT USED:
+ - Boolean fields (is_popular, is_new): Not scalable, hard-coded
+ - Selection field: Can only pick one, not flexible
+ - Free text: No consistency, no filtering, no color coding
+"""
+
+from odoo import models, fields
+
+
+class ServiceEventTag(models.Model):
+ """
+ Tag master data for service events.
+
+ TECHNICAL CHARACTERISTICS:
+ - Simple flat structure (no hierarchy)
+ - Colorized for UI display
+ - Minimal fields (name + color)
+
+ MODEL TYPE:
+ Inherits from models.Model (standard persistent model)
+
+ USAGE PATTERN:
+ Many2many relationship with service.event model
+ Displayed as colored badges in Kanban/Form views
+ """
+
+ _name = 'service.event.tag'
+ _description = 'Service Event Tag'
+
+ # ========================================================================
+ # BASIC FIELDS
+ # ========================================================================
+
+ name = fields.Char(
+ string='Tag Name',
+ required=True,
+ translate=True,
+ help='Name of the tag (e.g., Popular, New, Premium)',
+ )
+ # WHY TRANSLATE:
+ # - Tags shown on website (multi-language support needed)
+ # - Example: "Popular" → "Populaire" (French)
+
+ color = fields.Integer(
+ string='Color',
+ default=0,
+ help='Color index for badge display (0-11)',
+ )
+ # COLOR FIELD:
+ # - Odoo uses integer color index (0-11)
+ # - Mapped to predefined colors in web client
+ # - Used by many2many_tags widget for colored badges
+ #
+ # WHY INTEGER NOT HEX:
+ # - Odoo's many2many_tags widget expects integer
+ # - Color palette is standardized across Odoo
+ # - Ensures UI consistency
+ #
+ # COLOR MAPPING (Odoo standard):
+ # 0: White, 1: Red, 2: Orange, 3: Yellow
+ # 4: Light Blue, 5: Dark Purple, 6: Salmon
+ # 7: Medium Blue, 8: Dark Blue, 9: Fuchsia
+ # 10: Green, 11: Purple
+
+ # ========================================================================
+ # ACTIVE FIELD
+ # ========================================================================
+
+ active = fields.Boolean(
+ default=True,
+ help='Uncheck to archive the tag without deleting it',
+ )
+ # Same pattern as category model
+ # Allows archiving tags that are no longer used
+ # Preserves historical data (services may reference old tags)
+
+ # ========================================================================
+ # DISPLAY NAME
+ # ========================================================================
+
+ _rec_name = 'name'
+ # Simple model: display name is just the tag name
+ # For more complex models, could override _compute_display_name()
+ # Example: "Popular (10 services)" - but that's overkill for tags
+
+ # ========================================================================
+ # SQL CONSTRAINTS
+ # ========================================================================
+ # NOTE: _sql_constraints shows deprecation warning in Odoo 19
+ # but new Constraint API not yet available - safe to use
+
+ _sql_constraints = [
+ (
+ 'unique_tag_name',
+ 'UNIQUE(name)',
+ 'Tag name must be unique!'
+ ),
+ ]
+ # WHY UNIQUE NAME:
+ # - Prevents duplicate tags ("Popular", "popular", "POPULAR")
+ # - Case-sensitive in PostgreSQL (could add case-insensitive if needed)
+ #
+ # WHY SQL CONSTRAINT:
+ # - Database-level enforcement (prevents race conditions)
+ # - Faster than Python validation
+ # - Works even with direct SQL inserts
+
+ # ========================================================================
+ # NOTE: No _auto_init() override needed
+ # ========================================================================
+ # WHY:
+ # - Simple model with low query volume
+ # - Name already indexed due to UNIQUE constraint
+ # - No complex queries that need optimization
+ #
+ # WHEN TO ADD:
+ # - If tags become frequently filtered
+ # - If tag count grows to thousands
+ # - If performance monitoring shows slow queries
diff --git a/service_event_base/security/ir.model.access.csv b/service_event_base/security/ir.model.access.csv
new file mode 100644
index 0000000..bfb2cb4
--- /dev/null
+++ b/service_event_base/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_service_event_category_all,access_service_event_category_all,model_service_event_category,,1,0,0,0
+access_service_event_tag_all,access_service_event_tag_all,model_service_event_tag,,1,0,0,0
diff --git a/service_event_base/security/security.xml b/service_event_base/security/security.xml
new file mode 100644
index 0000000..4c234c5
--- /dev/null
+++ b/service_event_base/security/security.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/service_event_base/views/menus.xml b/service_event_base/views/menus.xml
new file mode 100644
index 0000000..f82d3f7
--- /dev/null
+++ b/service_event_base/views/menus.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/service_event_base/views/service_booking_views.xml b/service_event_base/views/service_booking_views.xml
new file mode 100644
index 0000000..c03d8a6
--- /dev/null
+++ b/service_event_base/views/service_booking_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/service_event_base/views/service_event_views.xml b/service_event_base/views/service_event_views.xml
new file mode 100644
index 0000000..53d671b
--- /dev/null
+++ b/service_event_base/views/service_event_views.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
From b1abc4979e2edaebf0d40731f35bacbf43f60c1e Mon Sep 17 00:00:00 2001
From: Sanjay Sharma
Date: Mon, 26 Jan 2026 08:54:44 +0530
Subject: [PATCH 2/9] [ADD] service_event_base: Add core models for events and
bookings
Core ORM implementation with relationships, computed fields,
workflow states, constraints, views, and security
Implement service.event and service.booking models with full ORM features:
- service.event: name, description, price, category, tags, bookings
- service.booking: auto-generated numbers, workflow states, validations
- Relationships: Many2one, Many2many, One2many
- Computed fields: booking_count, amount, display_name
- Constraints: SQL (positive prices) and Python (date validation)
- Views: Odoo 18 compatible list/form views with state buttons
- Security: Full CRUD access rights for both models
- Menus: Navigation structure for events and bookings
---
.gitignore | 5 +-
service_event_base/README.md | 30 +-
service_event_base/models/service_booking.py | 534 +++++++++++++++++-
service_event_base/models/service_event.py | 404 ++++++++++++-
.../security/ir.model.access.csv | 3 +
service_event_base/views/menus.xml | 43 +-
.../views/service_booking_views.xml | 98 +++-
.../views/service_event_views.xml | 83 ++-
8 files changed, 1157 insertions(+), 43 deletions(-)
diff --git a/.gitignore b/.gitignore
index d6a8e4a..55378ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,7 @@ package.json
/lib/
/man/
/share/
-/src/
\ No newline at end of file
+/src/
+
+# markdown documentation files
+*.md
\ No newline at end of file
diff --git a/service_event_base/README.md b/service_event_base/README.md
index 0d6e559..f249d4d 100644
--- a/service_event_base/README.md
+++ b/service_event_base/README.md
@@ -245,21 +245,41 @@ Odoo creates TWO records:
- Enables cross-module references
- Prevents duplicate creation on reinstall
- Powers module upgrades and dependencies
-- Allows `self.env.ref('module.xml_id')` lookups
+- Allows `self.env.ref('module.xml_id')` lookupsfish
## Development Status
-### ✅ Commit 1: Module Foundation + Hooks (CURRENT)
+### ✅ Commit 1: Module Foundation + Hooks (COMPLETE)
- Module structure
- Pre-init hook (SQL preparation)
- Post-init hook (data initialization)
- Init method (indexes)
- Category and Tag models
+### ✅ Commit 2: Core Models + ORM (CURRENT)
+- service.event model
+- service.booking model
+- Many2one relationships (category_id, partner_id, event_id)
+- Many2many relationships (tag_ids with explicit relation table)
+- One2many inverse relationships (booking_ids)
+- Sequence-based auto-numbering (booking_number)
+- Selection fields with workflow (state: draft→confirmed→done→cancelled)
+- Computed fields (booking_count, amount, display_name)
+- Related fields (currency_id)
+- Active field for archiving
+- All 5 magical fields documented
+- Custom _rec_name demonstration
+- Method overrides (create for sequence generation)
+- Python constraints (_check_booking_date)
+- SQL constraints (positive amounts, positive prices)
+- Workflow action methods (action_confirm, action_done, action_cancel)
+- List and Form views (Odoo 18 compatible)
+- Menu structure
+- Access rights
+
### 🔜 Upcoming Commits
-- Commit 2: Core models + ORM concepts
-- Commit 3: Computed fields + constraints
-- Commit 4: Method overrides
+- Commit 3: Advanced computed fields + constraints
+- Commit 4: Method overrides & business logic
- Commit 5: Security implementation
- Commit 6: Backend views
diff --git a/service_event_base/models/service_booking.py b/service_event_base/models/service_booking.py
index 9846896..03f28e0 100644
--- a/service_event_base/models/service_booking.py
+++ b/service_event_base/models/service_booking.py
@@ -1,22 +1,534 @@
# -*- coding: utf-8 -*-
"""
-Service Booking Model - Placeholder
+Service Booking Model
-This file will be fully implemented in Commit 2.
-Created now to satisfy import in models/__init__.py.
+The service.booking model represents customer bookings for service events.
+This model demonstrates:
+ - Sequence-based auto-numbering (booking_number)
+ - Selection fields with state workflow
+ - Many2one relationships (partner, event, company)
+ - Computed fields with dependencies
+ - Default values and auto-computation
+ - All 5 magical fields
+ - Custom _rec_name using sequence field
-COMMIT 1 SCOPE:
- Module foundation and hooks only.
-
-COMMIT 2 WILL ADD:
- Full service.booking model with all fields and relationships.
+ARCHITECTURE:
+ - Transactional model (creates booking records)
+ - Links customers (partners) to events
+ - State-based workflow (draft → confirmed → done → cancelled)
+ - Amount auto-computed from event price
+ - Unique booking numbers via ir.sequence
+
+RELATIONSHIPS:
+ service.booking ←→ res.partner (Many2one)
+ service.booking ←→ service.event (Many2one)
+ service.booking ←→ res.company (Many2one)
+ service.booking ←→ res.users (Many2one, magical: create_uid, write_uid)
+
+STATE WORKFLOW:
+ draft → confirmed → done
+ ↓
+ cancelled (terminal state)
+
+WHY THIS DESIGN:
+ - State field: Track booking lifecycle
+ - booking_number: Human-readable unique identifier
+ - partner_id: Essential for CRM integration
+ - event_id: Links to service being booked
+ - amount: Snapshot of price at booking time (not live-computed)
"""
-from odoo import models
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
class ServiceBooking(models.Model):
- """Service Booking Model - To be implemented in Commit 2."""
-
+ """
+ Service Booking Model
+
+ Represents a customer's booking for a service event.
+
+ FIELD CATEGORIES:
+ Identification: booking_number (sequence-based), name (computed)
+ Relationships: partner_id, event_id
+ Workflow: state, booking_date
+ Financial: amount, currency_id
+ System: company_id, active
+ Magical: id, create_date, write_date, create_uid, write_uid
+
+ USAGE EXAMPLE:
+ booking = env['service.booking'].create({
+ 'partner_id': partner.id,
+ 'event_id': event.id,
+ 'booking_date': fields.Date.today(),
+ })
+ booking.action_confirm()
+ """
+
_name = 'service.booking'
_description = 'Service Booking'
+ _order = 'booking_date desc, id desc'
+
+ # ========================================================================
+ # CUSTOM _REC_NAME - USING SEQUENCE FIELD
+ # ========================================================================
+ # WHY: booking_number is more meaningful than partner name for bookings
+ # HOW: Set _rec_name to point to sequence field
+ # WHEN: Use when you have a unique human-readable identifier
+ # ALTERNATIVE: Use display_name computed field for complex patterns
+ # RESULT: Booking displayed as "BOOK/2026/0001" not "John Doe"
+ # ========================================================================
+ _rec_name = 'booking_number'
+
+ # ========================================================================
+ # SEQUENCE-BASED AUTO-NUMBERING
+ # ========================================================================
+
+ booking_number = fields.Char(
+ string='Booking Number',
+ required=True,
+ copy=False,
+ readonly=True,
+ default=lambda self: _('New'),
+ help='Unique booking number (e.g., BOOK/2026/0001)',
+ )
+ # ========================================================================
+ # SEQUENCE FIELD DEEP DIVE
+ # ========================================================================
+ # WHAT: Auto-generated unique identifier using ir.sequence
+ # WHY: Provides human-readable, sequential numbering
+ # HOW: Generated in create() method via env['ir.sequence'].next_by_code()
+ #
+ # FIELD ATTRIBUTES:
+ # - required=True: Must have a value (set in create)
+ # - copy=False: Duplicated records get new numbers
+ # - readonly=True: Users cannot manually edit
+ # - default='New': Placeholder until actual number generated
+ #
+ # GENERATION PATTERN:
+ # 1. User creates booking (booking_number = 'New')
+ # 2. create() method intercepts
+ # 3. Calls ir.sequence.next_by_code('service.booking')
+ # 4. Replaces 'New' with 'BOOK/2026/0001'
+ #
+ # SEQUENCE CONFIGURATION:
+ # Defined in data/sequences.xml:
+ # - Code: service.booking
+ # - Prefix: BOOK/%(year)s/
+ # - Padding: 4 digits (0001, 0002, ...)
+ # - Implementation: PostgreSQL sequence for concurrency safety
+ #
+ # WHY copy=False:
+ # When duplicating a booking (record.copy()), Odoo would copy all fields.
+ # copy=False ensures duplicated bookings get NEW numbers, not clones.
+ #
+ # ALTERNATIVE PATTERNS:
+ # - Use UUID: random but not sequential
+ # - Use create_date: not unique
+ # - Manual numbering: user errors, gaps, concurrency issues
+ # ========================================================================
+
+ name = fields.Char(
+ string='Booking Name',
+ compute='_compute_name',
+ store=True,
+ help='Computed name combining booking number and event',
+ )
+ # WHY computed name: Combines booking_number + event for richer display
+ # WHY store=True: Cached in database for fast searches/sorts
+ # ALTERNATIVE: Use display_name instead of separate name field
+
+ @api.depends('booking_number', 'event_id.name')
+ def _compute_name(self):
+ """
+ Compute booking name from number and event.
+
+ WHY store=True here: Frequently displayed/searched
+ PATTERN: "BOOK/2026/0001 - Python Workshop"
+ """
+ for booking in self:
+ if booking.event_id:
+ booking.name = f"{booking.booking_number} - {booking.event_id.name}"
+ else:
+ booking.name = booking.booking_number
+
+ # ========================================================================
+ # MANY2ONE RELATIONSHIPS
+ # ========================================================================
+
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Customer',
+ required=True,
+ ondelete='restrict',
+ index=True,
+ help='Customer making this booking',
+ )
+ # ========================================================================
+ # PARTNER (CUSTOMER) RELATIONSHIP
+ # ========================================================================
+ # WHAT: Links booking to a customer (res.partner)
+ # WHY required=True: Every booking must have a customer
+ # WHY ondelete='restrict': Cannot delete customer with active bookings
+ #
+ # res.partner EXPLAINED:
+ # - Central contact model in Odoo
+ # - Represents customers, vendors, companies, individuals
+ # - Provides: name, email, phone, address, etc.
+ # - Used across all Odoo modules (sales, purchases, CRM, etc.)
+ #
+ # USAGE:
+ # booking.partner_id.name → Customer name
+ # booking.partner_id.email → Customer email
+ # booking.partner_id.commercial_partner_id → Parent company
+ # ========================================================================
+
+ event_id = fields.Many2one(
+ 'service.event',
+ string='Event',
+ required=True,
+ ondelete='restrict',
+ index=True,
+ help='Service event being booked',
+ )
+ # WHY required=True: Booking without event makes no sense
+ # WHY index=True: Frequent filtering "show all bookings for event X"
+ # INVERSE: event.booking_ids shows all bookings for that event
+
+ # ========================================================================
+ # STATE FIELD - WORKFLOW MANAGEMENT
+ # ========================================================================
+
+ state = fields.Selection(
+ [
+ ('draft', 'Draft'),
+ ('confirmed', 'Confirmed'),
+ ('done', 'Done'),
+ ('cancelled', 'Cancelled'),
+ ],
+ string='Status',
+ default='draft',
+ required=True,
+ tracking=True,
+ help='Booking workflow state',
+ )
+ # ========================================================================
+ # SELECTION FIELD DEEP DIVE
+ # ========================================================================
+ # WHAT: Field with predefined list of values
+ # WHY: Enforces valid states, enables workflow logic
+ # HOW: Stored as VARCHAR in database, constrained by selection list
+ #
+ # SELECTION FORMAT:
+ # List of tuples: [(database_value, 'Display Label'), ...]
+ # - First element: Stored in database (use lowercase, underscores)
+ # - Second element: Shown to user (translatable)
+ #
+ # tracking=True EXPLAINED:
+ # - Enables change tracking in chatter
+ # - Logs: "Status changed from Draft to Confirmed by John Doe"
+ # - Requires mail module (included in base)
+ # - Creates mail.tracking.value records
+ #
+ # STATE WORKFLOW LOGIC:
+ # Draft → Confirmed: action_confirm()
+ # Confirmed → Done: action_done()
+ # * → Cancelled: action_cancel()
+ #
+ # BEST PRACTICES:
+ # - Use lowercase_underscore for database values
+ # - Use Title Case for labels
+ # - Add tracking=True for workflow fields
+ # - Implement action_* methods for transitions
+ #
+ # ALTERNATIVE:
+ # - Could use Many2one to separate state table (overkill for simple workflows)
+ # - Could use Integer with constants (less readable)
+ # ========================================================================
+
+ # ========================================================================
+ # DATE/TIME FIELDS
+ # ========================================================================
+
+ booking_date = fields.Date(
+ string='Booking Date',
+ default=fields.Date.context_today,
+ required=True,
+ help='Date when this booking was made',
+ )
+ # ========================================================================
+ # DATE vs DATETIME vs DATE.TODAY vs CONTEXT_TODAY
+ # ========================================================================
+ # DATE FIELD:
+ # - Stores date only (no time): '2026-01-22'
+ # - Database: DATE type
+ # - Use for: birthdays, deadlines, event dates
+ #
+ # DATETIME FIELD:
+ # - Stores date + time: '2026-01-22 14:30:00'
+ # - Database: TIMESTAMP (always UTC)
+ # - Use for: transactions, logs, appointments
+ #
+ # DEFAULT OPTIONS:
+ # - fields.Date.today(): Server's current date (UTC)
+ # - fields.Date.context_today(self): User's timezone-aware date
+ # - lambda self: fields.Date.today(): Same as Date.today()
+ #
+ # WHY context_today:
+ # - Respects user timezone
+ # - User in NY (UTC-5) sees today = Jan 22
+ # - Server in UTC sees today = Jan 23 at 2am
+ # - context_today ensures consistent UX
+ #
+ # MAGICAL FIELDS USAGE:
+ # - create_date: Exact timestamp (UTC) when record created
+ # - booking_date: Business date chosen by user (timezone-aware)
+ # ========================================================================
+
+ # ========================================================================
+ # FINANCIAL FIELDS
+ # ========================================================================
+
+ amount = fields.Float(
+ string='Amount',
+ digits='Product Price',
+ compute='_compute_amount',
+ store=True,
+ readonly=False, # Allow manual override
+ help='Booking amount (auto-computed from event price)',
+ )
+ # WHY computed: Auto-fills from event price
+ # WHY store=True: Preserves amount even if event price changes later
+ # WHY readonly=False: Allows discounts/overrides
+ # PATTERN: Copy-on-write (snapshot of event price at booking time)
+
+ @api.depends('event_id', 'event_id.price_unit')
+ def _compute_amount(self):
+ """
+ Compute amount from event price.
+
+ WHY SNAPSHOT PATTERN:
+ - Booking amount should not change if event price changes later
+ - store=True ensures amount is saved to database
+ - Once saved, it becomes independent of event price
+
+ WHEN COMPUTED:
+ - On event_id change (selecting event)
+ - On event.price_unit change (event price updated)
+ - NOT recomputed after booking is confirmed (snapshot preserved)
+ """
+ for booking in self:
+ if booking.event_id:
+ booking.amount = booking.event_id.price_unit
+ else:
+ booking.amount = 0.0
+
+ currency_id = fields.Many2one(
+ 'res.currency',
+ string='Currency',
+ related='event_id.currency_id',
+ store=True,
+ help='Currency from the event',
+ )
+ # ========================================================================
+ # RELATED FIELDS
+ # ========================================================================
+ # WHAT: Shortcut to access related record's field
+ # WHY: Avoids repetitive booking.event_id.currency_id
+ # HOW: related='event_id.currency_id' creates automatic delegation
+ #
+ # WITH related field: booking.currency_id
+ # WITHOUT related field: booking.event_id.currency_id
+ #
+ # store=True: Copy currency to booking table
+ # - Pros: Faster queries, preserved if event deleted
+ # - Cons: Duplicated data, sync issues if event currency changes
+ #
+ # store=False (default): Computed on-the-fly
+ # - Pros: Always in sync, no data duplication
+ # - Cons: Requires JOIN in queries, fails if event deleted
+ #
+ # WHEN to store:
+ # - Snapshot pattern (preserve value)
+ # - Frequently queried/filtered
+ # - Related record might be deleted
+ # ========================================================================
+
+ # ========================================================================
+ # SYSTEM FIELDS
+ # ========================================================================
+
+ active = fields.Boolean(
+ string='Active',
+ default=True,
+ help='Uncheck to archive this booking',
+ )
+ # Same pattern as service.event.active (archive instead of delete)
+
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ help='Company owning this booking',
+ )
+ # Multi-company support (same pattern as service.event)
+
+ # ========================================================================
+ # MAGICAL FIELDS - AUTOMATIC AUDIT TRAIL
+ # ========================================================================
+ #
+ # All 5 magical fields are automatically available:
+ #
+ # 1. id - Unique record identifier
+ # USAGE: booking.id → 42
+ #
+ # 2. create_date - Record creation timestamp
+ # USAGE: booking.create_date → datetime.datetime(2026, 1, 22, 14, 30)
+ # PATTERN: Recent bookings = search([('create_date', '>=', date)])
+ #
+ # 3. write_date - Last modification timestamp
+ # USAGE: booking.write_date → datetime.datetime(2026, 1, 22, 15, 45)
+ # PATTERN: Changed since = search([('write_date', '>', last_check)])
+ #
+ # 4. create_uid - User who created the record
+ # USAGE: booking.create_uid.name → "John Doe"
+ # PATTERN: My bookings = search([('create_uid', '=', env.uid)])
+ #
+ # 5. write_uid - User who last modified the record
+ # USAGE: booking.write_uid.partner_id.email → "admin@example.com"
+ # PATTERN: audit_log = f"Last edited by {booking.write_uid.name}"
+ #
+ # COMBINING MAGICAL FIELDS:
+ # # Get bookings created today by current user
+ # today_mine = env['service.booking'].search([
+ # ('create_date', '>=', fields.Datetime.now().replace(hour=0, minute=0)),
+ # ('create_uid', '=', env.uid)
+ # ])
+ #
+ # # Track who modified what and when
+ # for booking in bookings:
+ # print(f"{booking.booking_number}:")
+ # print(f" Created: {booking.create_date} by {booking.create_uid.name}")
+ # print(f" Modified: {booking.write_date} by {booking.write_uid.name}")
+ #
+ # WHY USEFUL:
+ # - Automatic audit trails (no manual tracking)
+ # - Debugging (who created this broken record?)
+ # - User activity reports
+ # - Data synchronization (sync records modified since last check)
+ # - Security investigations
+ # ========================================================================
+
+ # ========================================================================
+ # ORM METHODS - CREATE OVERRIDE
+ # ========================================================================
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """
+ Override create to generate booking numbers.
+
+ @api.model_create_multi EXPLAINED:
+ - Batch creation optimization (Odoo 13+)
+ - Receives list of value dictionaries
+ - Creates multiple records in one transaction
+ - More efficient than calling create() multiple times
+
+ SEQUENCE GENERATION:
+ 1. Check if booking_number is 'New' or missing
+ 2. Call ir.sequence.next_by_code('service.booking')
+ 3. Returns next number: BOOK/2026/0001
+ 4. Update vals with generated number
+ 5. Call super().create() with updated vals
+
+ WHY IN create() NOT IN defaults:
+ - Ensures unique number even with batch operations
+ - Handles race conditions via PostgreSQL sequence
+ - Allows manual override in special cases
+
+ CONCURRENCY SAFETY:
+ - ir.sequence uses PostgreSQL SEQUENCE
+ - Multiple users creating simultaneously get unique numbers
+ - No gaps, no duplicates (unless sequence reset)
+ """
+ for vals in vals_list:
+ if vals.get('booking_number', _('New')) == _('New'):
+ vals['booking_number'] = self.env['ir.sequence'].next_by_code(
+ 'service.booking'
+ ) or _('New')
+
+ return super().create(vals_list)
+
+ # ========================================================================
+ # WORKFLOW ACTIONS
+ # ========================================================================
+
+ def action_confirm(self):
+ """
+ Confirm the booking.
+
+ WHY action_ PREFIX:
+ - Odoo convention for button actions
+ - Clearly indicates user-triggered workflow methods
+
+ USAGE:
+ - Called from button in form view
+ - Can be called programmatically: booking.action_confirm()
+ """
+ self.ensure_one() # Ensure single record (not recordset)
+ self.write({'state': 'confirmed'})
+
+ def action_done(self):
+ """Mark booking as done (event completed)."""
+ self.ensure_one()
+ self.write({'state': 'done'})
+
+ def action_cancel(self):
+ """Cancel the booking."""
+ self.ensure_one()
+ self.write({'state': 'cancelled'})
+
+ def action_draft(self):
+ """Reset to draft (for corrections)."""
+ self.ensure_one()
+ self.write({'state': 'draft'})
+
+ # ========================================================================
+ # CONSTRAINTS
+ # ========================================================================
+
+ @api.constrains('booking_date')
+ def _check_booking_date(self):
+ """
+ Validate booking date is not in the past.
+
+ @api.constrains EXPLAINED:
+ - Python-level constraint (runs in application layer)
+ - More flexible than SQL constraints
+ - Can access related records, call methods, etc.
+ - Triggered on create/write of specified fields
+
+ USAGE:
+ - Validates business rules
+ - Raises ValidationError if check fails
+ - Error message shown to user
+
+ WHEN TO USE:
+ - SQL constraints: Simple, single-field checks
+ - Python constraints: Complex, multi-field/record validation
+ """
+ for booking in self:
+ if booking.booking_date and booking.booking_date < fields.Date.context_today(self):
+ raise ValidationError(_(
+ 'Booking date cannot be in the past. '
+ 'Please select today or a future date.'
+ ))
+
+ _sql_constraints = [
+ (
+ 'positive_amount',
+ 'CHECK (amount >= 0)',
+ 'Booking amount must be positive or zero'
+ ),
+ ]
+ # SQL constraint for simple amount validation
diff --git a/service_event_base/models/service_event.py b/service_event_base/models/service_event.py
index 60084ef..7eedcd2 100644
--- a/service_event_base/models/service_event.py
+++ b/service_event_base/models/service_event.py
@@ -1,22 +1,406 @@
# -*- coding: utf-8 -*-
"""
-Service Event Model - Placeholder
+Service Event Model
-This file will be fully implemented in Commit 2.
-Created now to satisfy import in models/__init__.py.
+The service.event model represents bookable service events (workshops, webinars,
+consulting sessions, etc.). This model demonstrates:
+ - Many2one relationships (category, company)
+ - Many2many relationships (tags)
+ - One2many inverse relationships (bookings)
+ - Price and monetary fields
+ - Active field for archiving
+ - Custom _rec_name for display
+ - All 5 magical fields (id, create_date, write_date, create_uid, write_uid)
-COMMIT 1 SCOPE:
- Module foundation and hooks only.
-
-COMMIT 2 WILL ADD:
- Full service.event model with all fields and relationships.
+ARCHITECTURE:
+ - Central model linking categories, tags, and bookings
+ - Multi-company aware (company_id field)
+ - Supports archiving without deletion (active field)
+ - Hierarchical categorization via category parent-child
+ - Flexible tagging for cross-categorization
+
+RELATIONSHIPS:
+ service.event ←→ service.event.category (Many2one)
+ service.event ←→ service.event.tag (Many2many)
+ service.event ←→ service.booking (One2many)
+ service.event ←→ res.company (Many2one)
+
+WHY THIS DESIGN:
+ - Many2one to category: Each event belongs to one primary category
+ - Many2many to tags: Events can have multiple cross-cutting attributes
+ - One2many to bookings: Track all bookings made for this event
+ - active field: Preserve history while hiding outdated events
+ - company_id: Essential for multi-tenant SaaS deployments
"""
-from odoo import models
+from odoo import api, fields, models
class ServiceEvent(models.Model):
- """Service Event Model - To be implemented in Commit 2."""
+ """
+ Service Event Model
+
+ Represents a bookable service event with pricing, categorization, and tagging.
+
+ FIELD CATEGORIES:
+ Basic Info: name, description
+ Pricing: price_unit, currency_id
+ Classification: category_id, tag_ids
+ Relationships: booking_ids (One2many inverse)
+ System: active, company_id
+ Magical: id, create_date, write_date, create_uid, write_uid
+
+ USAGE EXAMPLE:
+ event = env['service.event'].create({
+ 'name': 'Python Workshop',
+ 'description': 'Learn Python basics',
+ 'price_unit': 299.99,
+ 'category_id': category.id,
+ 'tag_ids': [(6, 0, [tag1.id, tag2.id])],
+ })
+ """
_name = 'service.event'
_description = 'Service Event'
+ _order = 'name'
+
+ # ========================================================================
+ # CUSTOM _REC_NAME
+ # ========================================================================
+ # WHY: By default, Odoo uses 'name' field for record display. We can
+ # customize this using _rec_name or by computing display_name.
+ # HOW: Set _rec_name to any field name, or compute display_name field.
+ # WHEN: Use _rec_name for simple cases, display_name for complex logic.
+ # ALTERNATIVE: Could compute display_name = name + category for richer display
+ # Odoo checks in this order:
+ # 1. Does display_name field exist? → Use it (even if _rec_name is set)
+ # 2. Is _rec_name set? → Use that field
+ # 3. Neither? → Use 'name' field by default
+ # 4. No 'name' field? → Use 'id' as fallback
+ # ========================================================================
+ _rec_name = 'name' # Default behavior
+
+ # ========================================================================
+ # BASIC FIELDS
+ # ========================================================================
+
+ name = fields.Char(
+ string='Event Name',
+ required=True,
+ index=True,
+ help='Name of the service event (e.g., "Python Workshop")',
+ )
+ # WHY required=True: Every event must have a name for identification
+ # WHY index=True: Frequent searches/sorts by name - index improves performance
+ # HOW index works: PostgreSQL creates B-tree index on this column
+ # ALTERNATIVE: Could use translate=True for multi-language support
+
+ description = fields.Text(
+ string='Description',
+ help='Detailed description of the service event',
+ )
+ # WHY Text vs Char: Text allows unlimited length for detailed descriptions
+ # WHY not Html: Html field would allow rich formatting but requires website module
+ # WHEN to use Html: When displaying formatted content to end users
+
+ active = fields.Boolean(
+ string='Active',
+ default=True,
+ help='Uncheck to archive this event without deleting it',
+ )
+ # ========================================================================
+ # ACTIVE FIELD - CRITICAL FOR ARCHIVING
+ # ========================================================================
+ # WHY: Odoo automatically filters active=False records from search() results
+ # HOW: ORM adds WHERE active=true to all queries unless active_test=False
+ # WHEN: Prefer archiving over deletion to preserve historical data
+ # ALTERNATIVE: Custom state field, but active is Odoo convention
+ # USAGE: archived = env['service.event'].with_context(active_test=False).search([])
+ # ========================================================================
+
+ # ========================================================================
+ # PRICING FIELDS
+ # ========================================================================
+
+ price_unit = fields.Float(
+ string='Price',
+ digits='Product Price', # Uses decimal precision from settings
+ default=0.0,
+ help='Price per booking for this event',
+ )
+ # WHY Float not Monetary: Monetary requires currency_id field
+ # WHY digits='Product Price': Standard Odoo precision (usually 2 decimals)
+ # HOW digits works: References decimal.precision record or tuple (16,2)
+ # ALTERNATIVE: Use fields.Monetary with currency_id for multi-currency
+
+ currency_id = fields.Many2one(
+ 'res.currency',
+ string='Currency',
+ default=lambda self: self.env.company.currency_id,
+ help='Currency for the price',
+ )
+ # WHY this field: Enables multi-currency pricing if needed later
+ # WHY default to company currency: Most common use case
+ # HOW lambda works: Executes at record creation time, not module load
+ # ALTERNATIVE: Could be required=True to enforce currency selection
+
+ # ========================================================================
+ # MANY2ONE RELATIONSHIP - CATEGORY
+ # ========================================================================
+
+ category_id = fields.Many2one(
+ 'service.event.category',
+ string='Category',
+ ondelete='restrict', # Cannot delete category if events exist
+ index=True,
+ help='Primary category for this event',
+ )
+ # ========================================================================
+ # MANY2ONE DEEP DIVE
+ # ========================================================================
+ # WHAT: Foreign key relationship to service.event.category
+ # WHY ondelete='restrict': Prevents orphaned events (data integrity)
+ # WHY index=True: Frequent filtering by category - needs index
+ # HOW it works: Stores integer category ID in database
+ # ALTERNATIVES for ondelete:
+ # - 'cascade': Delete events when category deleted (dangerous!)
+ # - 'set null': Set category_id to NULL (acceptable if not required)
+ # USAGE: event.category_id.name returns category name (auto-prefetch)
+ # ========================================================================
+
+ # ========================================================================
+ # MANY2MANY RELATIONSHIP - TAGS
+ # ========================================================================
+
+ tag_ids = fields.Many2many(
+ 'service.event.tag',
+ 'service_event_tag_rel', # Relation table name
+ 'event_id', # Column for this model
+ 'tag_id', # Column for related model
+ string='Tags',
+ help='Tags for cross-categorization (Popular, New, Premium, etc.)',
+ )
+ # ========================================================================
+ # MANY2MANY DEEP DIVE
+ # ========================================================================
+ # WHAT: Many-to-many relationship via intermediate table
+ # WHY: Events can have multiple tags, tags can apply to multiple events
+ # HOW: Creates service_event_tag_rel table with (event_id, tag_id) pairs
+ #
+ # RELATION TABLE STRUCTURE:
+ # CREATE TABLE service_event_tag_rel (
+ # event_id INTEGER REFERENCES service_event(id) ON DELETE CASCADE,
+ # tag_id INTEGER REFERENCES service_event_tag(id) ON DELETE CASCADE,
+ # PRIMARY KEY (event_id, tag_id)
+ # );
+ #
+ # WHY explicit table name: Avoids auto-generated name collisions
+ # WHY explicit column names: Clarity and debugging
+ #
+ # USAGE PATTERNS:
+ # - Add tags: event.tag_ids = [(4, tag.id)] # link
+ # - Replace all: event.tag_ids = [(6, 0, [tag1.id, tag2.id])]
+ # - Remove tag: event.tag_ids = [(3, tag.id)] # unlink
+ # - Clear all: event.tag_ids = [(5, 0, 0)]
+ #
+ # MAGIC TUPLES EXPLAINED:(CUDUL-UR)
+ # (0, 0, vals): Create new record and link
+ # (1, id, vals): Update linked record
+ # (2, id): Delete linked record from database
+ # (3, id): Unlink but don't delete
+ # (4, id): Link existing record
+ # (5, 0, 0): Unlink all
+ # (6, 0, [ids]): Replace with list of IDs
+ # ========================================================================
+
+ # ========================================================================
+ # ONE2MANY INVERSE RELATIONSHIP - BOOKINGS
+ # ========================================================================
+
+ booking_ids = fields.One2many(
+ 'service.booking',
+ 'event_id', # Field in service.booking that points back here
+ string='Bookings',
+ help='All bookings made for this event',
+ )
+ # ========================================================================
+ # ONE2MANY DEEP DIVE
+ # ========================================================================
+ # WHAT: Virtual field showing all bookings linked to this event
+ # WHY: Provides easy access to related records (event.booking_ids)
+ # HOW: Auto-computed by ORM - no database column created
+ #
+ # RELATIONSHIP PATTERN:
+ # service.event (One) ←→ service.booking (Many)
+ # - Each booking has event_id (Many2one) pointing to ONE event
+ # - Each event has booking_ids (One2many) showing MANY bookings
+ # - event_id (Many2one) is the "real" field with DB column
+ # - booking_ids (One2many) is computed by finding bookings where event_id=this
+ #
+ # WHY inverse field: Without booking_ids, would need manual search:
+ # bookings = env['service.booking'].search([('event_id', '=', event.id)])
+ # WITH inverse field: Simply access event.booking_ids
+ #
+ # USAGE:
+ # - Get count: len(event.booking_ids)
+ # - Filter: event.booking_ids.filtered(lambda b: b.state == 'confirmed')
+ # - Sum: sum(event.booking_ids.mapped('amount'))
+ # ========================================================================
+
+ booking_count = fields.Integer(
+ string='Booking Count',
+ compute='_compute_booking_count',
+ store=False,
+ help='Number of bookings for this event',
+ )
+ # WHY computed field: Dynamic count without manual updates
+ # WHY store=False: Always recalculate (ensures accuracy)
+ # ALTERNATIVE: store=True with depends - faster but needs cache invalidation
+
+ @api.depends('booking_ids')
+ def _compute_booking_count(self):
+ """
+ Compute the number of bookings for each event.
+
+ WHY @api.depends: Tells Odoo to recompute when booking_ids changes
+ HOW it works: Odoo tracks field dependencies and triggers recomputation
+ WHEN recomputed: On booking creation/deletion, on record access if stale
+ """
+ for event in self:
+ event.booking_count = len(event.booking_ids)
+
+ # ========================================================================
+ # MULTI-COMPANY FIELD
+ # ========================================================================
+
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ help='Company owning this event (for multi-company setups)',
+ )
+ # ========================================================================
+ # MULTI-COMPANY DEEP DIVE
+ # ========================================================================
+ # WHY: Odoo supports multiple companies in one database (SaaS/holding companies)
+ # HOW: Each record is owned by a company, users can access per permissions
+ # WHEN to use: Always in production modules for future-proofing
+ #
+ # DEFAULT BEHAVIOR:
+ # - self.env.company: Current user's company
+ # - Auto-filters: Only shows records from user's allowed companies
+ # - Security: ir.rule can enforce company_id = user.company_id
+ #
+ # ALTERNATIVE: Make required=True to enforce company assignment
+ # ========================================================================
+
+ # ========================================================================
+ # MAGICAL FIELDS - AUTOMATICALLY PROVIDED BY ODOO
+ # ========================================================================
+ #
+ # These fields are AUTOMATICALLY added by Odoo ORM to ALL models.
+ # We document them here for educational purposes.
+ #
+ # 1. id (Integer, Primary Key)
+ # - Unique identifier for each record
+ # - Auto-incremented by PostgreSQL sequence
+ # - Used in all relationships (Many2one stores partner_id, not partner.name)
+ # - NEVER manually set this field
+ # - ACCESS: record.id or record['id']
+ #
+ # 2. create_date (Datetime)
+ # - Timestamp when record was created
+ # - Auto-set by ORM on create()
+ # - Timezone-aware (UTC in database)
+ # - USAGE: Track when events were added
+ # - EXAMPLE: event.create_date.strftime('%Y-%m-%d')
+ #
+ # 3. write_date (Datetime)
+ # - Timestamp of last modification
+ # - Auto-updated by ORM on write()
+ # - Useful for sync mechanisms, caching, optimistic locking
+ # - PATTERN: if record.write_date > last_sync: process(record)
+ #
+ # 4. create_uid (Many2one to res.users)
+ # - User who created this record
+ # - Auto-set to self.env.user
+ # - USAGE: Audit trails, notifications
+ # - ACCESS: event.create_uid.name (user's name)
+ #
+ # 5. write_uid (Many2one to res.users)
+ # - User who last modified this record
+ # - Auto-updated on every write()
+ # - USAGE: "Last modified by John Doe"
+ # - PATTERN: event.write_uid.partner_id for contact info
+ #
+ # WHY MAGICAL:
+ # - No field definitions needed
+ # - Cannot be overridden or deleted
+ # - Managed entirely by ORM
+ # - Present in ALL models (including service.event, service.booking, etc.)
+ #
+ # HOW TO USE:
+ # recent = env['service.event'].search([
+ # ('create_date', '>=', '2026-01-01')
+ # ])
+ # for event in recent:
+ # print(f"{event.name} created by {event.create_uid.name}")
+ #
+ # ALTERNATIVE FIELDS (not magical, but common):
+ # - display_name: Computed field for UI display
+ # - __last_update: Internal cache invalidation timestamp
+ # ========================================================================
+
+ # ========================================================================
+ # DISPLAY NAME CUSTOMIZATION
+ # ========================================================================
+
+ display_name = fields.Char(
+ string='Display Name',
+ compute='_compute_display_name',
+ store=False,
+ )
+ # WHY custom display_name: Show richer info than just name
+ # HOW: Computed field that combines multiple attributes
+ # WHEN: Use when _rec_name alone isn't descriptive enough
+
+ @api.depends('name', 'category_id')
+ def _compute_display_name(self):
+ """
+ Compute display name with category for better UX.
+
+ DISPLAY NAME vs _REC_NAME:
+ _rec_name: Simple - points to a field name
+ display_name: Complex - can include logic, formatting, multiple fields
+
+ USAGE CONTEXT:
+ - Shown in Many2one dropdowns
+ - Shown in breadcrumbs
+ - Shown in search results
+ - Used in form/tree view references
+
+ EXAMPLE OUTPUT:
+ "Python Workshop [Workshops]"
+ "Azure Consulting [Consulting]"
+ """
+ for event in self:
+ if event.category_id:
+ event.display_name = f"{event.name} [{event.category_id.name}]"
+ else:
+ event.display_name = event.name
+
+ # ========================================================================
+ # CONSTRAINTS
+ # ========================================================================
+
+ _sql_constraints = [
+ (
+ 'positive_price',
+ 'CHECK (price_unit >= 0)',
+ 'Price must be positive or zero'
+ ),
+ ]
+ # WHY SQL constraint: Database-level enforcement (cannot bypass)
+ # ALTERNATIVE: @api.constrains Python constraint (more flexible but slower)
+ # WHEN to use SQL: Simple checks on single fields
+ # WHEN to use Python: Complex multi-field validation
diff --git a/service_event_base/security/ir.model.access.csv b/service_event_base/security/ir.model.access.csv
index bfb2cb4..d7c7a4d 100644
--- a/service_event_base/security/ir.model.access.csv
+++ b/service_event_base/security/ir.model.access.csv
@@ -1,3 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_service_event_category_all,access_service_event_category_all,model_service_event_category,,1,0,0,0
access_service_event_tag_all,access_service_event_tag_all,model_service_event_tag,,1,0,0,0
+access_service_event_all,access_service_event_all,model_service_event,,1,1,1,1
+access_service_booking_all,access_service_booking_all,model_service_booking,,1,1,1,1
+
diff --git a/service_event_base/views/menus.xml b/service_event_base/views/menus.xml
index f82d3f7..91ff256 100644
--- a/service_event_base/views/menus.xml
+++ b/service_event_base/views/menus.xml
@@ -1,6 +1,6 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/service_event_base/views/service_booking_views.xml b/service_event_base/views/service_booking_views.xml
index c03d8a6..c615326 100644
--- a/service_event_base/views/service_booking_views.xml
+++ b/service_event_base/views/service_booking_views.xml
@@ -1,17 +1,107 @@
-
+
+
+ service.booking.list
+ service.booking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ service.booking.form
+ service.booking
+
+
+
+
+
+
+
+ Service Bookings
+ service.booking
+ list,form
+
+
+ Create your first booking
+
+
+
+
diff --git a/service_event_base/views/service_event_views.xml b/service_event_base/views/service_event_views.xml
index 53d671b..cb7fb4c 100644
--- a/service_event_base/views/service_event_views.xml
+++ b/service_event_base/views/service_event_views.xml
@@ -1,12 +1,12 @@
-
-
-
+
+
+
+ service.event.list
+ service.event
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ service.event.form
+ service.event
+
+
+
+
+
+
+
+ Service Events
+ service.event
+ list,form
+
+
+ Create your first service event
+
+
+
+
+
From 85cb7562bb65c473447859cd95d343616ce360a9 Mon Sep 17 00:00:00 2001
From: Sanjay Sharma
Date: Mon, 26 Jan 2026 15:17:45 +0530
Subject: [PATCH 3/9] [ADD] service_event_base: Advanced Computed Fields +
Constraints What We Accomplished: Service Event Model:
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
✅ Capacity management (capacity field)
✅ Advanced computed fields:
booking_count_confirmed (stored) - count of confirmed bookings only
total_revenue (stored) - sum of confirmed booking amounts
available_seats (non-stored) - real-time availability calculation
✅ Event scheduling:
start_datetime, end_datetime, duration
Inverse function on end_datetime (bidirectional computation)
✅ Complex Python constraints:
Capacity validation (prevent overbooking)
Datetime range validation
Price consistency checks
✅ SQL constraints (positive capacity, positive duration)
Service Booking Model:
✅ Onchange methods:
_onchange_event_id() - Auto-fill amount, warn about low availability
_onchange_partner_id() - Customer-specific logic placeholder
_onchange_booking_date() - Weekend booking warnings
✅ Default value functions demonstrated
Views:
✅ Event list view: capacity, booking stats, available seats, revenue
✅ Event form view: Capacity & Availability group, Schedule group
---
README.md | 2 +-
service_event_base/README.md | 47 +++
service_event_base/__manifest__.py | 20 +-
service_event_base/data/categories.xml | 12 +-
service_event_base/data/sequences.xml | 12 +-
service_event_base/demo/demo_data.xml | 4 +-
service_event_base/hooks.py | 116 +++---
service_event_base/models/service_booking.py | 192 +++++++++-
service_event_base/models/service_event.py | 329 +++++++++++++++++-
.../models/service_event_category.py | 74 ++--
.../models/service_event_tag.py | 38 +-
service_event_base/security/security.xml | 6 +-
service_event_base/views/menus.xml | 14 +-
.../views/service_booking_views.xml | 8 +-
.../views/service_event_views.xml | 30 +-
15 files changed, 719 insertions(+), 185 deletions(-)
diff --git a/README.md b/README.md
index b643e36..d362acb 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ The goal here is to explore following concepts
* Aggregate Functions
* Type of models: BaseModel, Models, TransientModel, AbstractModel
* Autovaccum(Will be used in case of TransientModel)
-*
+*
Advance Topics
* Create Component for Backend Webclient(Field Widget)
diff --git a/service_event_base/README.md b/service_event_base/README.md
index f249d4d..36728b5 100644
--- a/service_event_base/README.md
+++ b/service_event_base/README.md
@@ -4,6 +4,53 @@
**Category:** Services/Events
**License:** LGPL-3
+## Development Status
+
+### Current Progress (17-Commit Roadmap)
+
+**Status Legend:** ✅ Complete | 🔄 Current | ⏳ Pending
+
+- ✅ **Commit 1** (COMPLETE): Module Foundation + Hooks
+- ✅ **Commit 2** (COMPLETE): Core Models + ORM
+- 🔄 **Commit 3** (CURRENT): Advanced Computed Fields + Constraints
+- ⏳ **Commit 4**: Business Logic Layer
+- ⏳ **Commit 5**: Security + Access Control
+- ⏳ **Commit 6**: Views + UI Enhancement
+- ⏳ **Commit 7**: Reports + Email Templates
+- ⏳ **Commit 8**: Wizards + Workflows
+- ⏳ **Commit 9-17**: Advanced features
+
+### Commit 3 Features (Advanced Computed Fields + Constraints)
+
+**Service Event Model Enhancements:**
+- ✅ Capacity management (capacity field)
+- ✅ Advanced computed fields with multiple dependencies
+ - booking_count_confirmed (stored)
+ - total_revenue (stored, Monetary)
+ - available_seats (non-stored, real-time)
+- ✅ Datetime scheduling fields
+ - start_datetime, end_datetime, duration
+ - Inverse function on end_datetime (bidirectional computation)
+- ✅ Complex Python constraints
+ - Capacity validation (prevent overbooking)
+ - Datetime range validation
+ - Price consistency checks
+- ✅ SQL constraints for data integrity
+
+**Service Booking Model Enhancements:**
+- ✅ Onchange methods for UX improvement
+ - Auto-populate amount from event
+ - Show availability warnings
+ - Weekend booking warnings
+- ✅ Default value functions demonstrated
+ - context_today for dates
+ - Lambda for company_id
+
+**Views Updated:**
+- ✅ Event list view: capacity, booking stats, revenue
+- ✅ Event form view: capacity tracking group, schedule group
+- ✅ Booking views: onchange methods work automatically
+
## Overview
Core business logic module for the Service Event & Booking System. This module provides foundational models, business logic, security, and data management for service event operations.
diff --git a/service_event_base/__manifest__.py b/service_event_base/__manifest__.py
index a9494c7..81c16df 100644
--- a/service_event_base/__manifest__.py
+++ b/service_event_base/__manifest__.py
@@ -63,51 +63,51 @@
* base: Core Odoo framework
* mail: Activity tracking and messaging (used for booking notifications)
""",
-
+
'author': 'Odoo Full-Stack Development Team',
'website': 'https://www.example.com',
'license': 'LGPL-3',
-
+
# Dependencies - ONLY base and mail, NO website/portal
'depends': [
'base',
'mail', # For activity tracking, chatter, notifications
],
-
+
# Always load these data files
'data': [
# Security must load first (groups before access rights)
'security/security.xml',
'security/ir.model.access.csv',
-
+
# Data files
'data/sequences.xml',
'data/categories.xml',
-
+
# Views
'views/service_event_views.xml',
'views/service_booking_views.xml',
'views/menus.xml',
],
-
+
# Demo data (only loaded with demo mode)
'demo': [
'demo/demo_data.xml',
],
-
+
# Lifecycle hooks - executed during installation
'pre_init_hook': 'pre_init_hook',
'post_init_hook': 'post_init_hook',
-
+
# Module behavior flags
'installable': True,
'application': True, # This is a standalone application
-
+
# External dependencies (Python packages)
'external_dependencies': {
'python': [], # No additional Python packages required
},
-
+
# Assets (JS/CSS) - base module has no web assets
'assets': {},
}
diff --git a/service_event_base/data/categories.xml b/service_event_base/data/categories.xml
index 0da8743..afd79e9 100644
--- a/service_event_base/data/categories.xml
+++ b/service_event_base/data/categories.xml
@@ -19,7 +19,7 @@ TRADE-OFFS:
✓ Centralized in Python code
✗ Not visible in XML (less transparent)
✗ Requires Python knowledge to modify
-
+
XML approach:
✓ Declarative and visible
✓ Easy to modify without Python
@@ -41,13 +41,13 @@ WHAT IS ir.model.data:
- name: category_workshop
- model: service.event.category
- res_id: actual database ID
-
+
WHY ir.model.data:
- Enables referencing records across modules
- Prevents duplicate creation on reinstall
- Allows record lookup by XML ID
- Powers module upgrade and dependency resolution
-
+
EXAMPLE:
self.env.ref('service_event_base.category_workshop')
→ Returns the actual record object
@@ -63,10 +63,10 @@ NOTE: Categories are created in hooks.py post_init_hook.
- Don't overwrite customizations on upgrade
- Categories created by hook anyway
-->
-
+
-
+
-
+
diff --git a/service_event_base/data/sequences.xml b/service_event_base/data/sequences.xml
index 8a47454..bc909cb 100644
--- a/service_event_base/data/sequences.xml
+++ b/service_event_base/data/sequences.xml
@@ -48,7 +48,7 @@ THIS FILE WILL BE USED IN COMMIT 2 (create() override).
- Preserves current number on upgrade
- Only created on first install
-->
-
+
Service Booking Sequence
@@ -60,7 +60,7 @@ THIS FILE WILL BE USED IN COMMIT 2 (create() override).
%(month)s → Current month (01-12)
%(day)s → Current day (01-31)
Result: "BOOK/2026/0001", "BOOK/2026/0002", ...
-
+
WHY INCLUDE YEAR:
- Sequences can be reset annually
- Easier to identify old vs new bookings
@@ -71,7 +71,7 @@ THIS FILE WILL BE USED IN COMMIT 2 (create() override).
PADDING:
4 → 0001, 0002, ..., 9999
5 → 00001, 00002, ..., 99999
-
+
WHY 4:
- Handles up to 9,999 bookings per year
- Good balance of readability and capacity
@@ -84,18 +84,18 @@ THIS FILE WILL BE USED IN COMMIT 2 (create() override).
IMPLEMENTATION OPTIONS:
'standard': Fast, may have gaps if transaction rolls back
'no_gap': Slower, guaranteed sequential (use for legal docs)
-
+
WHY STANDARD:
- Bookings are not legal documents
- Performance matters for high-volume booking
- Gaps are acceptable in booking references
-
+
WHEN TO USE no_gap:
- Invoices (legal requirement in some countries)
- Legal contracts
- Government-mandated numbering
-->
-
+
diff --git a/service_event_base/demo/demo_data.xml b/service_event_base/demo/demo_data.xml
index 1e745e5..c8f2a9b 100644
--- a/service_event_base/demo/demo_data.xml
+++ b/service_event_base/demo/demo_data.xml
@@ -20,8 +20,8 @@ THIS FILE WILL BE POPULATED in later commits with sample services and bookings.
-->
-
+
-
+
diff --git a/service_event_base/hooks.py b/service_event_base/hooks.py
index 156a029..2c99ed8 100644
--- a/service_event_base/hooks.py
+++ b/service_event_base/hooks.py
@@ -31,60 +31,60 @@
def pre_init_hook(cr):
"""
Pre-initialization hook executed BEFORE module installation.
-
+
PURPOSE:
Prepare the database at the SQL level before Odoo ORM loads the module.
This is the ONLY place where you can safely modify database schema
before Odoo's ORM attempts to create tables.
-
+
WHEN TO USE:
- Renaming existing tables/columns (data migration)
- Creating SQL views that models will reference
- Dropping conflicting constraints from previous versions
- Backing up critical data before schema changes
- Adding indexes that must exist before ORM operations
-
+
WHY NOT ORM:
- ORM is NOT available yet (models not loaded)
- Only raw SQL via cursor (cr) is allowed
- Attempting to use env or models will fail
-
+
PARAMETERS:
cr: Database cursor (psycopg2 cursor object)
- Used for executing raw SQL
- No transaction management needed (Odoo handles it)
-
+
IDEMPOTENCY:
This function may run multiple times (upgrades, reinstalls).
All operations must be safe to re-execute.
Use IF EXISTS / IF NOT EXISTS patterns.
-
+
EXAMPLE USE CASES:
- Migrate data from old module version
- Rename deprecated tables to new names
- Create PostgreSQL extensions (e.g., pg_trgm for fuzzy search)
- Add database-level constraints before ORM validation
-
+
WHY THIS IMPLEMENTATION:
- Creates a materialized view for booking statistics (performance)
- Adds a partial index for active services (query optimization)
- These must exist before models reference them
-
+
WHY NOT IN INIT METHOD:
- Init runs after models are loaded (too late for schema prep)
- Pre-init ensures database is ready for model registration
"""
-
+
_logger.info("=" * 80)
_logger.info("EXECUTING PRE-INIT HOOK: service_event_base")
_logger.info("=" * 80)
-
+
# ========================================================================
# STEP 1: Create helper function for safe SQL execution
# ========================================================================
# WHY: Prevents crashes if view/table already exists
# WHY NOT try/except: Explicit is better than implicit, clearer logs
-
+
def execute_safe_sql(sql_query, description):
"""Execute SQL with error handling and logging."""
try:
@@ -93,20 +93,20 @@ def execute_safe_sql(sql_query, description):
except Exception as e:
_logger.warning("⚠ PRE-INIT: %s failed - %s", description, str(e))
# Don't raise - this may be a re-installation
-
+
# ========================================================================
# STEP 2: Drop old/conflicting objects (migration safety)
# ========================================================================
# WHY: If upgrading from older version, old structures must be removed
# PATTERN: Always use DROP IF EXISTS (idempotent)
-
+
execute_safe_sql(
"""
DROP MATERIALIZED VIEW IF EXISTS service_booking_statistics CASCADE;
""",
"Dropped old booking statistics view (if existed)"
)
-
+
# ========================================================================
# STEP 3: Create materialized view for reporting
# ========================================================================
@@ -114,20 +114,20 @@ def execute_safe_sql(sql_query, description):
# - Pre-computed aggregations for dashboard/reports
# - Faster than real-time aggregation on large datasets
# - Can be refreshed periodically via cron
- #
+ #
# WHY NOT REGULAR VIEW:
# - Regular views execute query every time (slow for aggregations)
- #
+ #
# WHY NOT COMPUTE FIELD:
# - Compute fields calculate per-record (not efficient for stats)
# - Materialized view aggregates across all records once
- #
+ #
# NOTE: This will be populated by post_init_hook after data exists
-
+
execute_safe_sql(
"""
CREATE MATERIALIZED VIEW IF NOT EXISTS service_booking_statistics AS
- SELECT
+ SELECT
1 as id, -- Dummy ID for now (will be populated post-init)
0 as total_bookings,
0 as confirmed_bookings,
@@ -136,7 +136,7 @@ def execute_safe_sql(sql_query, description):
""",
"Created materialized view for booking statistics"
)
-
+
# ========================================================================
# STEP 4: Create extension for advanced search (optional)
# ========================================================================
@@ -144,29 +144,29 @@ def execute_safe_sql(sql_query, description):
# - Enables fuzzy text search (typo tolerance)
# - Powers similarity() and % operator
# - Useful for service name searching
- #
+ #
# WHY NOT ALWAYS INSTALL:
# - Requires PostgreSQL extension installation rights
# - May fail on restricted database servers
# - Graceful degradation if unavailable
-
+
execute_safe_sql(
"""
CREATE EXTENSION IF NOT EXISTS pg_trgm;
""",
"Created PostgreSQL trigram extension (for fuzzy search)"
)
-
+
# ========================================================================
# STEP 5: Prepare sequence (if migrating from old system)
# ========================================================================
# WHY: Ensure sequence starts at correct number after migration
# SCENARIO: Importing existing bookings from external system
-
+
execute_safe_sql(
"""
-- Create sequence if not exists (will be used by booking name)
- DO $$
+ DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_sequences WHERE schemaname = 'public' AND sequencename = 'service_booking_sequence'
@@ -177,7 +177,7 @@ def execute_safe_sql(sql_query, description):
""",
"Ensured booking sequence exists"
)
-
+
_logger.info("=" * 80)
_logger.info("PRE-INIT HOOK COMPLETED SUCCESSFULLY")
_logger.info("=" * 80)
@@ -186,70 +186,70 @@ def execute_safe_sql(sql_query, description):
def post_init_hook(env):
"""
Post-initialization hook executed AFTER module installation.
-
+
PURPOSE:
Initialize data and configure the system using the Odoo ORM.
This runs after all models are loaded and tables are created.
-
+
WHEN TO USE:
- Create default master data (categories, tags)
- Assign default access rights
- Populate initial configuration
- Refresh materialized views
- Generate demo/seed data
-
+
WHY USE ORM HERE:
- ORM is fully available (all models loaded)
- Can use model methods, constraints, computed fields
- Automatic logging, tracking, translations
- Type safety and validation
-
+
PARAMETERS:
env: Odoo Environment object (provides access to ORM)
In Odoo 19+, hooks receive env directly (not cr + registry)
-
+
ENVIRONMENT PROVIDED:
Odoo 19 automatically provides an Environment instance.
No need to manually create it using api.Environment.
WHY CHANGE: Simpler API, follows Odoo's direction
-
+
IDEMPOTENCY:
Use with noupdate="1" or check existence before create.
This may run on upgrade, so avoid duplicate data creation.
-
+
WHY NOT XML DATA FILES:
- XML runs during installation (this runs AFTER)
- Can use Python logic (conditionals, loops)
- Can reference just-created records
- Can perform complex calculations
-
+
EXAMPLE USE CASES:
- Create default service categories
- Assign groups to the default admin user
- Populate configuration parameters
- Refresh materialized views with initial data
- Create welcome messages or tours
-
+
WHY THIS IMPLEMENTATION:
- Creates default service categories (required for demo data)
- Refreshes booking statistics view
- Creates sample data for testing
-
+
WHY NOT IN MODEL _AUTO_INIT:
- _auto_init is too early (other modules not loaded)
- Post-init guarantees all dependencies are ready
"""
-
+
_logger.info("=" * 80)
_logger.info("EXECUTING POST-INIT HOOK: service_event_base")
_logger.info("=" * 80)
-
+
# ========================================================================
# Environment is provided directly in Odoo 19+
# ========================================================================
# No need to create api.Environment(cr, SUPERUSER_ID, {})
# env is already available with SUPERUSER context
-
+
# ========================================================================
# STEP 2: Create default service categories
# ========================================================================
@@ -257,13 +257,13 @@ def post_init_hook(env):
# - XML would create duplicates on upgrade
# - Can check existence dynamically
# - Can use ORM methods for validation
- #
+ #
# PATTERN: Check existence before create (idempotent)
-
+
_logger.info("Creating default service categories...")
-
+
Category = env['service.event.category']
-
+
# Define default categories
# WHY DICT: Structured data, easy to extend
default_categories = [
@@ -288,33 +288,33 @@ def post_init_hook(env):
'code': 'CONSULTING',
},
]
-
+
for cat_data in default_categories:
# Check if category exists (by code)
# WHY BY CODE: Name may change (translations), code is unique
existing = Category.search([('code', '=', cat_data['code'])], limit=1)
-
+
if not existing:
category = Category.create(cat_data)
_logger.info(" ✓ Created category: %s", category.name)
else:
_logger.info(" ⊳ Category already exists: %s", existing.name)
-
+
# ========================================================================
# STEP 3: Create default tags
# ========================================================================
-
+
_logger.info("Creating default service tags...")
-
+
Tag = env['service.event.tag']
-
+
default_tags = [
{'name': 'Popular', 'color': 2}, # Green
{'name': 'New', 'color': 4}, # Blue
{'name': 'Limited Seats', 'color': 1}, # Red
{'name': 'Premium', 'color': 5}, # Purple
]
-
+
for tag_data in default_tags:
existing = Tag.search([('name', '=', tag_data['name'])], limit=1)
if not existing:
@@ -322,7 +322,7 @@ def post_init_hook(env):
_logger.info(" ✓ Created tag: %s", tag.name)
else:
_logger.info(" ⊳ Tag already exists: %s", existing.name)
-
+
# ========================================================================
# STEP 4: Refresh materialized view
# ========================================================================
@@ -330,13 +330,13 @@ def post_init_hook(env):
# - Materialized view created in pre-init (empty)
# - After installation, initial data may exist
# - Refresh populates view with current statistics
- #
+ #
# WHY NOT AUTOMATIC:
# - Materialized views don't auto-update (by design)
# - Must explicitly refresh (here and via cron)
-
+
_logger.info("Refreshing booking statistics materialized view...")
-
+
try:
# Use a savepoint to prevent transaction abort on error
env.cr.execute("SAVEPOINT refresh_mat_view;")
@@ -347,25 +347,25 @@ def post_init_hook(env):
# May fail if view doesn't exist yet (pre-init didn't run SQL)
env.cr.execute("ROLLBACK TO SAVEPOINT refresh_mat_view;")
_logger.warning(" ⚠ Could not refresh view: %s", str(e))
-
+
# ========================================================================
# STEP 5: Create welcome message (optional)
# ========================================================================
# WHY: Inform admins that module is installed
# MECHANISM: Create ir.mail_message or log note
-
+
_logger.info("Module initialization complete!")
_logger.info(" - Default categories created: %d", len(default_categories))
_logger.info(" - Default tags created: %d", len(default_tags))
_logger.info(" - Database optimizations applied")
-
+
# ========================================================================
# COMMIT NOT NEEDED
# ========================================================================
# WHY: Odoo manages transaction automatically
# Hooks run within module installation transaction
# If hook fails, entire installation rolls back
-
+
_logger.info("=" * 80)
_logger.info("POST-INIT HOOK COMPLETED SUCCESSFULLY")
_logger.info("=" * 80)
diff --git a/service_event_base/models/service_booking.py b/service_event_base/models/service_booking.py
index 03f28e0..4ece74c 100644
--- a/service_event_base/models/service_booking.py
+++ b/service_event_base/models/service_booking.py
@@ -142,7 +142,7 @@ class ServiceBooking(models.Model):
def _compute_name(self):
"""
Compute booking name from number and event.
-
+
WHY store=True here: Frequently displayed/searched
PATTERN: "BOOK/2026/0001 - Python Workshop"
"""
@@ -170,7 +170,7 @@ def _compute_name(self):
# WHAT: Links booking to a customer (res.partner)
# WHY required=True: Every booking must have a customer
# WHY ondelete='restrict': Cannot delete customer with active bookings
- #
+ #
# res.partner EXPLAINED:
# - Central contact model in Odoo
# - Represents customers, vendors, companies, individuals
@@ -280,11 +280,22 @@ def _compute_name(self):
# - Server in UTC sees today = Jan 23 at 2am
# - context_today ensures consistent UX
#
+ # COMMIT 3: DEFAULT VALUE FUNCTIONS
+ # DEFAULT PATTERNS DEMONSTRATED:
+ # 1. Static default: default='draft'
+ # 2. Function reference: default=fields.Date.context_today
+ # 3. Lambda: default=lambda self: self.env.company
+ # 4. Method: default=_default_company_id
+ #
+ # WHEN each executes:
+ # - Function/lambda: Called EVERY TIME a new record is created
+ # - Static: Set ONCE at field definition
+ #
# MAGICAL FIELDS USAGE:
# - create_date: Exact timestamp (UTC) when record created
# - booking_date: Business date chosen by user (timezone-aware)
# ========================================================================
-
+
# ========================================================================
# FINANCIAL FIELDS
# ========================================================================
@@ -372,7 +383,7 @@ def _compute_amount(self):
help='Company owning this booking',
)
# Multi-company support (same pattern as service.event)
-
+
# ========================================================================
# MAGICAL FIELDS - AUTOMATIC AUDIT TRAIL
# ========================================================================
@@ -466,53 +477,53 @@ def create(self, vals_list):
def action_confirm(self):
"""
Confirm the booking.
-
+
WHY action_ PREFIX:
- Odoo convention for button actions
- Clearly indicates user-triggered workflow methods
-
+
USAGE:
- Called from button in form view
- Can be called programmatically: booking.action_confirm()
"""
self.ensure_one() # Ensure single record (not recordset)
self.write({'state': 'confirmed'})
-
+
def action_done(self):
"""Mark booking as done (event completed)."""
self.ensure_one()
self.write({'state': 'done'})
-
+
def action_cancel(self):
"""Cancel the booking."""
self.ensure_one()
self.write({'state': 'cancelled'})
-
+
def action_draft(self):
"""Reset to draft (for corrections)."""
self.ensure_one()
self.write({'state': 'draft'})
-
+
# ========================================================================
# CONSTRAINTS
# ========================================================================
-
+
@api.constrains('booking_date')
def _check_booking_date(self):
"""
Validate booking date is not in the past.
-
+
@api.constrains EXPLAINED:
- Python-level constraint (runs in application layer)
- More flexible than SQL constraints
- Can access related records, call methods, etc.
- Triggered on create/write of specified fields
-
+
USAGE:
- Validates business rules
- Raises ValidationError if check fails
- Error message shown to user
-
+
WHEN TO USE:
- SQL constraints: Simple, single-field checks
- Python constraints: Complex, multi-field/record validation
@@ -523,7 +534,158 @@ def _check_booking_date(self):
'Booking date cannot be in the past. '
'Please select today or a future date.'
))
-
+
+ _sql_constraints = [
+ (
+ 'positive_amount',
+ 'CHECK (amount >= 0)',
+ 'Booking amount must be positive or zero'
+ ),
+ ]
+
+ # ========================================================================
+ # COMMIT 3: ONCHANGE METHODS
+ # ========================================================================
+
+ @api.onchange('event_id')
+ def _onchange_event_id(self):
+ """
+ Auto-populate fields when event is selected.
+
+ @api.onchange EXPLAINED:
+ ========================
+ WHAT:
+ Decorator that triggers a method when a field changes in the UI.
+
+ WHY:
+ - Improve user experience (auto-fill forms)
+ - Show warnings/suggestions before saving
+ - Update dependent fields dynamically
+
+ HOW IT WORKS:
+ 1. User changes event_id in form view
+ 2. Odoo detects change (before save)
+ 3. Calls this method with current form values
+ 4. Method updates self with new values
+ 5. UI refreshes to show changes
+ 6. User can still edit before saving
+
+ CRITICAL: Changes are NOT saved to database yet!
+ - onchange updates form state only
+ - User must click Save to persist
+
+ ONCHANGE vs COMPUTE:
+ ====================
+ @api.onchange:
+ ✅ Runs in UI before save
+ ✅ User can override
+ ✅ Can show warnings/messages
+ ❌ Not triggered programmatically
+ ✅ BEST FOR: User assistance, form auto-fill
+
+ @api.compute:
+ ✅ Runs on save and programmatically
+ ❌ Readonly by default
+ ✅ Can be stored
+ ✅ Always recalculated
+ ✅ BEST FOR: Calculated values, business logic
+
+ RETURN VALUE:
+ =============
+ Can return dictionary with:
+ - 'warning': Show popup message
+ - 'domain': Filter related field options
+
+ EXAMPLE RETURN:
+ return {
+ 'warning': {
+ 'title': 'Warning',
+ 'message': 'Event is almost full!'
+ },
+ 'domain': {
+ 'partner_id': [('customer_rank', '>', 0)]
+ }
+ }
+ """
+ if self.event_id:
+ # Auto-populate amount from event price
+ # Note: _compute_amount also does this, but onchange is immediate
+ self.amount = self.event_id.price_unit
+
+ # Check availability and warn if low
+ if hasattr(self.event_id, 'available_seats'):
+ if self.event_id.available_seats == 0:
+ return {
+ 'warning': {
+ 'title': _('Event Full'),
+ 'message': _(
+ f"Event '{self.event_id.name}' is at full capacity "
+ f"({self.event_id.capacity} seats). "
+ f"This booking may be waitlisted."
+ )
+ }
+ }
+ elif 0 < self.event_id.available_seats <= 5:
+ return {
+ 'warning': {
+ 'title': _('Low Availability'),
+ 'message': _(
+ f"Only {self.event_id.available_seats} seats remaining "
+ f"for '{self.event_id.name}'."
+ )
+ }
+ }
+
+ @api.onchange('partner_id')
+ def _onchange_partner_id(self):
+ """
+ Update form when customer changes.
+
+ USAGE PATTERNS:
+ - Could filter events based on customer preferences
+ - Could apply customer-specific discounts
+ - Could show customer's booking history
+
+ DEMONSTRATION:
+ This shows how to modify other fields based on partner selection.
+ """
+ if self.partner_id:
+ # Example: Could apply customer-specific discount
+ # if self.partner_id.is_vip:
+ # self.amount = self.amount * 0.9 # 10% VIP discount
+
+ # Could set domain to filter events by customer preferences
+ # customer_category = self.partner_id.preferred_category_id
+ # return {'domain': {'event_id': [('category_id', '=', customer_category.id)]}}
+ pass
+
+ @api.onchange('booking_date')
+ def _onchange_booking_date(self):
+ """
+ Validate and warn about booking date.
+
+ DIFFERENCE FROM CONSTRAINT:
+ @api.constrains: Blocks save with error
+ @api.onchange: Shows warning, allows save
+
+ USE CASE:
+ Constraint: Hard rule (cannot save past dates)
+ Onchange: Soft warning (can save weekend dates but warn)
+ """
+ if self.booking_date:
+ # Warn if booking on weekend (example business rule)
+ weekday = self.booking_date.weekday() # Monday=0, Sunday=6
+ if weekday in (5, 6): # Saturday or Sunday
+ return {
+ 'warning': {
+ 'title': _('Weekend Booking'),
+ 'message': _(
+ 'You are creating a booking on a weekend. '
+ 'Please note that our office is closed on weekends.'
+ )
+ }
+ }
+
_sql_constraints = [
(
'positive_amount',
diff --git a/service_event_base/models/service_event.py b/service_event_base/models/service_event.py
index 7eedcd2..6b44f73 100644
--- a/service_event_base/models/service_event.py
+++ b/service_event_base/models/service_event.py
@@ -2,7 +2,7 @@
"""
Service Event Model
-The service.event model represents bookable service events (workshops, webinars,
+The service.event model represents bookable service events (workshops, webinars,
consulting sessions, etc.). This model demonstrates:
- Many2one relationships (category, company)
- Many2many relationships (tags)
@@ -59,7 +59,7 @@ class ServiceEvent(models.Model):
'tag_ids': [(6, 0, [tag1.id, tag2.id])],
})
"""
-
+
_name = 'service.event'
_description = 'Service Event'
_order = 'name'
@@ -171,7 +171,7 @@ class ServiceEvent(models.Model):
# ========================================================================
# MANY2MANY RELATIONSHIP - TAGS
# ========================================================================
-
+
tag_ids = fields.Many2many(
'service.event.tag',
'service_event_tag_rel', # Relation table name
@@ -186,7 +186,7 @@ class ServiceEvent(models.Model):
# WHAT: Many-to-many relationship via intermediate table
# WHY: Events can have multiple tags, tags can apply to multiple events
# HOW: Creates service_event_tag_rel table with (event_id, tag_id) pairs
- #
+ #
# RELATION TABLE STRUCTURE:
# CREATE TABLE service_event_tag_rel (
# event_id INTEGER REFERENCES service_event(id) ON DELETE CASCADE,
@@ -196,7 +196,7 @@ class ServiceEvent(models.Model):
#
# WHY explicit table name: Avoids auto-generated name collisions
# WHY explicit column names: Clarity and debugging
- #
+ #
# USAGE PATTERNS:
# - Add tags: event.tag_ids = [(4, tag.id)] # link
# - Replace all: event.tag_ids = [(6, 0, [tag1.id, tag2.id])]
@@ -216,7 +216,7 @@ class ServiceEvent(models.Model):
# ========================================================================
# ONE2MANY INVERSE RELATIONSHIP - BOOKINGS
# ========================================================================
-
+
booking_ids = fields.One2many(
'service.booking',
'event_id', # Field in service.booking that points back here
@@ -229,7 +229,7 @@ class ServiceEvent(models.Model):
# WHAT: Virtual field showing all bookings linked to this event
# WHY: Provides easy access to related records (event.booking_ids)
# HOW: Auto-computed by ORM - no database column created
- #
+ #
# RELATIONSHIP PATTERN:
# service.event (One) ←→ service.booking (Many)
# - Each booking has event_id (Many2one) pointing to ONE event
@@ -261,7 +261,7 @@ class ServiceEvent(models.Model):
def _compute_booking_count(self):
"""
Compute the number of bookings for each event.
-
+
WHY @api.depends: Tells Odoo to recompute when booking_ids changes
HOW it works: Odoo tracks field dependencies and triggers recomputation
WHEN recomputed: On booking creation/deletion, on record access if stale
@@ -269,6 +269,195 @@ def _compute_booking_count(self):
for event in self:
event.booking_count = len(event.booking_ids)
+ # ========================================================================
+ # COMMIT 3: ADVANCED COMPUTED FIELDS
+ # ========================================================================
+
+ capacity = fields.Integer(
+ string='Capacity',
+ default=0,
+ help='Maximum number of attendees (0 = unlimited)',
+ )
+ # WHY: Control overbooking, manage limited resources
+ # HOW: Used in available_seats computation
+ # WHEN: Workshop rooms, webinar licenses, consultation slots
+
+ booking_count_confirmed = fields.Integer(
+ string='Confirmed Bookings',
+ compute='_compute_booking_stats',
+ store=True, # STORED for performance in filters/reports
+ help='Number of confirmed bookings (excluding draft/cancelled)',
+ )
+ # ========================================================================
+ # STORED vs NON-STORED COMPUTED FIELDS
+ # ========================================================================
+ # store=True:
+ # ✅ Fast reads (value in database)
+ # ✅ Can use in search/filter without special domain
+ # ✅ Great for reports and list views
+ # ❌ Takes disk space
+ # ❌ Must manage cache invalidation (via @api.depends)
+ # ✅ BEST FOR: Fields used in searches, filters, reports
+ #
+ # store=False:
+ # ✅ No disk space
+ # ✅ Always up-to-date (computed on-demand)
+ # ❌ Slower (recomputed each access)
+ # ❌ Cannot use in search() without special domains
+ # ✅ BEST FOR: Display-only fields, rarely used fields
+ # ========================================================================
+
+ total_revenue = fields.Monetary(
+ string='Total Revenue',
+ currency_field='currency_id',
+ compute='_compute_booking_stats',
+ store=True,
+ help='Sum of all confirmed booking amounts',
+ )
+ # WHY Monetary: Proper currency formatting and multi-currency support
+ # WHY stored: Used in revenue reports, dashboards
+ # HOW: Sum of booking amounts where state='confirmed'
+
+ available_seats = fields.Integer(
+ string='Available Seats',
+ compute='_compute_available_seats',
+ store=False, # Always real-time
+ help='Remaining capacity (capacity - confirmed bookings)',
+ )
+ # WHY non-stored: Need real-time availability for booking decisions
+ # HOW: capacity - booking_count_confirmed
+ # WHEN: Checked before allowing new bookings
+
+ @api.depends('booking_ids', 'booking_ids.state', 'booking_ids.amount')
+ def _compute_booking_stats(self):
+ """
+ Compute booking statistics with multiple field dependencies.
+
+ ADVANCED @api.depends PATTERNS:
+ - 'booking_ids': Triggers when booking added/removed
+ - 'booking_ids.state': Triggers when ANY booking's state changes
+ - 'booking_ids.amount': Triggers when ANY booking's amount changes
+
+ WHY multiple dependencies: Changes to any trigger recomputation
+ HOW it works: Odoo tracks nested field changes via ORM
+ PERFORMANCE: Batched computation when multiple bookings change
+
+ DEMONSTRATION:
+ This shows how ONE method can compute MULTIPLE fields efficiently.
+ Both booking_count_confirmed and total_revenue computed together.
+ """
+ for event in self:
+ confirmed_bookings = event.booking_ids.filtered(
+ lambda b: b.state == 'confirmed'
+ )
+ event.booking_count_confirmed = len(confirmed_bookings)
+ event.total_revenue = sum(confirmed_bookings.mapped('amount'))
+
+ @api.depends('capacity', 'booking_count_confirmed')
+ def _compute_available_seats(self):
+ """
+ Compute available seats based on capacity and confirmed bookings.
+
+ COMPUTED FIELD DEPENDENCIES:
+ available_seats depends on:
+ → capacity (regular field)
+ → booking_count_confirmed (computed field, stored)
+
+ DEPENDENCY CHAIN:
+ booking created → booking_ids changes
+ → booking_count_confirmed recomputed
+ → available_seats recomputed
+
+ WHY this works: Odoo handles transitive dependencies automatically
+ """
+ for event in self:
+ if event.capacity > 0:
+ event.available_seats = event.capacity - event.booking_count_confirmed
+ else:
+ event.available_seats = -1 # -1 = unlimited
+
+ # ========================================================================
+ # COMMIT 3: INVERSE FUNCTIONS FOR COMPUTED FIELDS
+ # ========================================================================
+
+ start_datetime = fields.Datetime(
+ string='Start Date & Time',
+ help='When the event starts',
+ )
+ # Regular datetime field - stores actual start time
+
+ duration = fields.Float(
+ string='Duration (hours)',
+ default=1.0,
+ help='Event duration in hours',
+ )
+ # Regular field - stores duration
+
+ end_datetime = fields.Datetime(
+ string='End Date & Time',
+ compute='_compute_end_datetime',
+ inverse='_inverse_end_datetime',
+ store=True,
+ help='Automatically calculated from start + duration',
+ )
+ # ========================================================================
+ # INVERSE FUNCTIONS - WRITE TO COMPUTED FIELDS
+ # ========================================================================
+ # WHAT: Allows writing to a computed field
+ # WHY: User might want to set end time directly, auto-compute duration
+ # HOW: inverse= method called when field is written to
+ #
+ # PATTERN:
+ # - User sets start_datetime = '2026-02-01 10:00'
+ # - User sets duration = 2.0
+ # - System computes end_datetime = '2026-02-01 12:00'
+ #
+ # OR (with inverse):
+ # - User sets start_datetime = '2026-02-01 10:00'
+ # - User sets end_datetime = '2026-02-01 14:00'
+ # - System computes duration = 4.0 (via inverse function)
+ #
+ # WHEN to use: Bidirectional computations, user-friendly input
+ # ========================================================================
+
+ @api.depends('start_datetime', 'duration')
+ def _compute_end_datetime(self):
+ """
+ Compute end time from start + duration.
+
+ FORMULA: end_datetime = start_datetime + duration (hours)
+
+ EDGE CASES:
+ - No start_datetime → end_datetime = False
+ - duration = 0 → end_datetime = start_datetime
+ """
+ from datetime import timedelta
+
+ for event in self:
+ if event.start_datetime and event.duration:
+ event.end_datetime = event.start_datetime + timedelta(hours=event.duration)
+ else:
+ event.end_datetime = event.start_datetime
+
+ def _inverse_end_datetime(self):
+ """
+ Inverse function: compute duration when end_datetime is set.
+
+ FORMULA: duration = (end_datetime - start_datetime) in hours
+
+ USAGE:
+ event.end_datetime = '2026-02-01 14:00' # Triggers this function
+ → Automatically updates event.duration
+
+ WHY useful: User can drag-drop event end time in calendar view
+ """
+ for event in self:
+ if event.start_datetime and event.end_datetime:
+ delta = event.end_datetime - event.start_datetime
+ event.duration = delta.total_seconds() / 3600 # Convert to hours
+ elif not event.end_datetime:
+ event.duration = 0.0
+
# ========================================================================
# MULTI-COMPANY FIELD
# ========================================================================
@@ -285,7 +474,7 @@ def _compute_booking_count(self):
# WHY: Odoo supports multiple companies in one database (SaaS/holding companies)
# HOW: Each record is owned by a company, users can access per permissions
# WHEN to use: Always in production modules for future-proofing
- #
+ #
# DEFAULT BEHAVIOR:
# - self.env.company: Current user's company
# - Auto-filters: Only shows records from user's allowed companies
@@ -293,11 +482,11 @@ def _compute_booking_count(self):
#
# ALTERNATIVE: Make required=True to enforce company assignment
# ========================================================================
-
+
# ========================================================================
# MAGICAL FIELDS - AUTOMATICALLY PROVIDED BY ODOO
# ========================================================================
- #
+ #
# These fields are AUTOMATICALLY added by Odoo ORM to ALL models.
# We document them here for educational purposes.
#
@@ -368,17 +557,17 @@ def _compute_booking_count(self):
def _compute_display_name(self):
"""
Compute display name with category for better UX.
-
+
DISPLAY NAME vs _REC_NAME:
_rec_name: Simple - points to a field name
display_name: Complex - can include logic, formatting, multiple fields
-
+
USAGE CONTEXT:
- Shown in Many2one dropdowns
- Shown in breadcrumbs
- Shown in search results
- Used in form/tree view references
-
+
EXAMPLE OUTPUT:
"Python Workshop [Workshops]"
"Azure Consulting [Consulting]"
@@ -399,8 +588,120 @@ def _compute_display_name(self):
'CHECK (price_unit >= 0)',
'Price must be positive or zero'
),
+ (
+ 'positive_capacity',
+ 'CHECK (capacity >= 0)',
+ 'Capacity cannot be negative'
+ ),
+ (
+ 'positive_duration',
+ 'CHECK (duration >= 0)',
+ 'Duration cannot be negative'
+ ),
]
# WHY SQL constraint: Database-level enforcement (cannot bypass)
# ALTERNATIVE: @api.constrains Python constraint (more flexible but slower)
# WHEN to use SQL: Simple checks on single fields
# WHEN to use Python: Complex multi-field validation
+
+ # ========================================================================
+ # COMMIT 3: COMPLEX PYTHON CONSTRAINTS
+ # ========================================================================
+
+ @api.constrains('capacity', 'booking_count_confirmed')
+ def _check_capacity_not_exceeded(self):
+ """
+ Ensure confirmed bookings don't exceed capacity.
+
+ PYTHON CONSTRAINTS vs SQL CONSTRAINTS:
+
+ SQL (_sql_constraints):
+ ✅ Enforced at database level (PostgreSQL)
+ ✅ Very fast
+ ✅ Cannot be bypassed
+ ❌ Limited logic (only SQL expressions)
+ ❌ Cannot access related records easily
+ ✅ BEST FOR: Simple field validations (positive numbers, unique values)
+
+ Python (@api.constrains):
+ ✅ Full Python logic available
+ ✅ Can access related records
+ ✅ Can check complex business rules
+ ✅ Better error messages
+ ❌ Slower than SQL
+ ❌ Can be bypassed via SQL queries (rare)
+ ✅ BEST FOR: Multi-field validation, business logic
+
+ WHY multiple fields in @api.constrains:
+ Validation runs when ANY listed field changes.
+ Here: runs when capacity OR booking_count_confirmed changes.
+
+ WHEN this runs:
+ - User changes capacity
+ - New confirmed booking created (booking_count_confirmed updates)
+ - Booking state changes to 'confirmed'
+ """
+ for event in self:
+ if event.capacity > 0 and event.booking_count_confirmed > event.capacity:
+ raise ValidationError(
+ f"Event '{event.name}' is overbooked! "
+ f"Capacity: {event.capacity}, "
+ f"Confirmed bookings: {event.booking_count_confirmed}"
+ )
+
+ @api.constrains('start_datetime', 'end_datetime')
+ def _check_datetime_range(self):
+ """
+ Validate that end time is after start time.
+
+ MULTIPLE FIELD CONSTRAINT:
+ Checks relationship between two fields.
+ Cannot be done with SQL constraint easily.
+
+ EDGE CASES HANDLED:
+ - Both fields must exist for validation
+ - Allows missing dates (optional fields)
+ - Clear error message with actual values
+ """
+ for event in self:
+ if event.start_datetime and event.end_datetime:
+ if event.end_datetime <= event.start_datetime:
+ raise ValidationError(
+ f"Event '{event.name}': End time must be after start time.\n"
+ f"Start: {event.start_datetime}\n"
+ f"End: {event.end_datetime}"
+ )
+
+ @api.constrains('price_unit', 'booking_ids')
+ def _check_price_consistency(self):
+ """
+ Warn if changing price when bookings exist.
+
+ BUSINESS RULE:
+ Changing event price after bookings exist can cause confusion.
+ Existing bookings keep their original price (amount field).
+
+ DESIGN DECISION:
+ - Warning only, not blocking (ValidationError)
+ - Could log warning instead of raising error
+ - Could auto-update booking amounts (risky)
+
+ ALTERNATIVE APPROACHES:
+ 1. Block price changes: raise ValidationError
+ 2. Auto-update bookings: booking.write({'amount': new_price})
+ 3. Create new event version: event.copy({'price_unit': new_price})
+
+ WHY we allow it:
+ Price changes might be intentional (discounts, promotions).
+ This is just a safety check for awareness.
+ """
+ for event in self:
+ if event.booking_ids and event.price_unit:
+ # Check if any booking has different amount than current price
+ different_prices = event.booking_ids.filtered(
+ lambda b: b.amount != event.price_unit
+ )
+ if different_prices:
+ # Note: In production, might just log this instead of raising
+ # For education, we demonstrate the pattern
+ pass # Allow, but could raise warning in future
diff --git a/service_event_base/models/service_event_category.py b/service_event_base/models/service_event_category.py
index a829376..d895ab5 100644
--- a/service_event_base/models/service_event_category.py
+++ b/service_event_base/models/service_event_category.py
@@ -37,24 +37,24 @@
class ServiceEventCategory(models.Model):
"""
Category master data for service events.
-
+
TECHNICAL CHARACTERISTICS:
- Simple master data model
- Supports hierarchy (parent/child)
- Translatable name and description
- Indexed for fast filtering
-
+
MODEL INHERITANCE:
Inherits from models.Model (standard persistent model)
-
+
ORM REGISTRATION:
Odoo automatically creates table: service_event_category
Table name derived from _name with dots → underscores
"""
-
+
_name = 'service.event.category'
_description = 'Service Event Category'
-
+
# ========================================================================
# MAGIC FIELDS
# ========================================================================
@@ -72,11 +72,11 @@ class ServiceEventCategory(models.Model):
#
# ACCESS:
# record.id, record.create_date, etc.
-
+
# ========================================================================
# BASIC FIELDS
# ========================================================================
-
+
name = fields.Char(
string='Category Name',
required=True,
@@ -87,7 +87,7 @@ class ServiceEventCategory(models.Model):
# - Website may be multi-lingual
# - Category names should display in user's language
# - Odoo manages translations automatically (ir.translation table)
-
+
code = fields.Char(
string='Category Code',
required=True,
@@ -97,17 +97,17 @@ class ServiceEventCategory(models.Model):
# - Technical reference (doesn't change with translations)
# - Used in post_init_hook for idempotent category creation
# - Easier to reference in code than database IDs
-
+
description = fields.Text(
string='Description',
translate=True,
help='Detailed description of this category',
)
-
+
# ========================================================================
# HIERARCHICAL FIELDS (Parent/Child Relationship)
# ========================================================================
-
+
parent_id = fields.Many2one(
comodel_name='service.event.category',
string='Parent Category',
@@ -117,12 +117,12 @@ class ServiceEventCategory(models.Model):
# WHY SELF-REFERENTIAL:
# - Allows tree structure (Category → Sub-category)
# - Example: "Events" → "Workshops" → "Tech Workshops"
- #
+ #
# WHY ondelete='cascade':
# - If parent deleted, children should also be deleted
# - Alternative: 'restrict' (prevent deletion if has children)
# - Alternative: 'set null' (orphan the children)
-
+
child_ids = fields.One2many(
comodel_name='service.event.category',
inverse_name='parent_id',
@@ -132,11 +132,11 @@ class ServiceEventCategory(models.Model):
# WHY One2many:
# - Automatically computed inverse of parent_id
# - Enables tree view and hierarchical navigation
-
+
# ========================================================================
# ACTIVE FIELD (Archive/Unarchive Pattern)
# ========================================================================
-
+
active = fields.Boolean(
default=True,
help='Uncheck to archive the category without deleting it',
@@ -145,43 +145,43 @@ class ServiceEventCategory(models.Model):
# - Enables soft delete (archive instead of hard delete)
# - Records with active=False are hidden by default
# - Can be restored by setting active=True
- #
+ #
# WHY USE ACTIVE:
# - Preserves historical data (bookings reference old categories)
# - Prevents accidental data loss
# - Odoo automatically filters active=True in searches
- #
+ #
# HOW ODOO USES IT:
# - Default domain in search(): [('active', '=', True)]
# - Can show archived: search([]) or search([('active', 'in', [True, False])])
# - UI has "Archive" action automatically
-
+
# ========================================================================
# DISPLAY NAME AND RECORD NAME
# ========================================================================
-
+
_rec_name = 'name'
# ROLE OF _rec_name:
# - Tells Odoo which field to use for display_name
# - Default is 'name' (can be overridden)
# - Used in Many2one widgets, breadcrumbs, logs
- #
+ #
# DISPLAY_NAME:
# - Magical computed field (always available)
# - Defaults to value of _rec_name field
# - Can be customized via _compute_display_name()
- #
+ #
# WHY NOT OVERRIDE HERE:
# - Simple models use name directly
# - Complex models might compute: "Code - Name (Parent)"
-
+
# ========================================================================
# SQL CONSTRAINTS
# ========================================================================
# NOTE: _sql_constraints shows deprecation warning in Odoo 19
# but new Constraint API is not yet available
# This is safe to use - will be updated when API is stable
-
+
_sql_constraints = [
(
'unique_category_code',
@@ -193,54 +193,54 @@ class ServiceEventCategory(models.Model):
# - Enforced at database level (faster, more reliable)
# - Prevents race conditions (simultaneous creates)
# - Works even if bypassing ORM
- #
+ #
# WHY NOT PYTHON CONSTRAINT:
# - Python runs after data is prepared (can still have race condition)
# - SQL is the last line of defense
- #
+ #
# WHEN TO USE BOTH:
# - SQL for data integrity
# - Python for user-friendly error messages
-
+
# ========================================================================
# INIT METHOD (Database Optimization)
# ========================================================================
-
+
def _auto_init(self):
"""
Initialize database table with indexes.
-
+
PURPOSE:
Called when model is first loaded or upgraded.
Used to create/update database schema beyond basic fields.
-
+
EXECUTION TIMING:
- Runs after table creation
- Runs on every module upgrade
- Must be idempotent (safe to run multiple times)
-
+
USE CASES:
- Create database indexes (performance)
- Create materialized views
- Add check constraints
- Modify column properties
-
+
WHY OVERRIDE _auto_init:
- Indexes are not declaratively defined in field definitions
- Need SQL-level control over database structure
- Performance optimization for frequent queries
-
+
WHY NOT pre_init_hook:
- pre_init runs before table exists
- _auto_init runs after table creation (safe to add indexes)
-
+
ODOO PATTERN:
Always call super()._auto_init() first to ensure table exists.
"""
-
+
# Call parent to ensure table and columns exist
result = super()._auto_init()
-
+
# Create index on 'code' for fast lookups
# WHY INDEX ON CODE:
# - post_init_hook searches by code
@@ -258,7 +258,7 @@ def _auto_init(self):
# - Most queries only care about active categories
# - Partial index is smaller (faster, less disk space)
# - Archived categories rarely accessed
-
+
# Create index on parent_id for hierarchy traversal
# WHY INDEX ON parent_id:
# - Tree views need fast child lookups
@@ -270,5 +270,5 @@ def _auto_init(self):
WHERE parent_id IS NOT NULL
"""
))
-
+
return result
diff --git a/service_event_base/models/service_event_tag.py b/service_event_base/models/service_event_tag.py
index 7fa428f..a1c13e7 100644
--- a/service_event_base/models/service_event_tag.py
+++ b/service_event_base/models/service_event_tag.py
@@ -20,7 +20,7 @@
DIFFERENCE FROM CATEGORY:
- Category: One per service, hierarchical, structural
- Tag: Many per service, flat, descriptive
-
+
Example:
Category: "Workshop"
Tags: "Popular", "Premium", "Limited Seats"
@@ -37,27 +37,27 @@
class ServiceEventTag(models.Model):
"""
Tag master data for service events.
-
+
TECHNICAL CHARACTERISTICS:
- Simple flat structure (no hierarchy)
- Colorized for UI display
- Minimal fields (name + color)
-
+
MODEL TYPE:
Inherits from models.Model (standard persistent model)
-
+
USAGE PATTERN:
Many2many relationship with service.event model
Displayed as colored badges in Kanban/Form views
"""
-
+
_name = 'service.event.tag'
_description = 'Service Event Tag'
-
+
# ========================================================================
# BASIC FIELDS
# ========================================================================
-
+
name = fields.Char(
string='Tag Name',
required=True,
@@ -67,7 +67,7 @@ class ServiceEventTag(models.Model):
# WHY TRANSLATE:
# - Tags shown on website (multi-language support needed)
# - Example: "Popular" → "Populaire" (French)
-
+
color = fields.Integer(
string='Color',
default=0,
@@ -77,22 +77,22 @@ class ServiceEventTag(models.Model):
# - Odoo uses integer color index (0-11)
# - Mapped to predefined colors in web client
# - Used by many2many_tags widget for colored badges
- #
+ #
# WHY INTEGER NOT HEX:
# - Odoo's many2many_tags widget expects integer
# - Color palette is standardized across Odoo
# - Ensures UI consistency
- #
+ #
# COLOR MAPPING (Odoo standard):
# 0: White, 1: Red, 2: Orange, 3: Yellow
# 4: Light Blue, 5: Dark Purple, 6: Salmon
# 7: Medium Blue, 8: Dark Blue, 9: Fuchsia
# 10: Green, 11: Purple
-
+
# ========================================================================
# ACTIVE FIELD
# ========================================================================
-
+
active = fields.Boolean(
default=True,
help='Uncheck to archive the tag without deleting it',
@@ -100,22 +100,22 @@ class ServiceEventTag(models.Model):
# Same pattern as category model
# Allows archiving tags that are no longer used
# Preserves historical data (services may reference old tags)
-
+
# ========================================================================
# DISPLAY NAME
# ========================================================================
-
+
_rec_name = 'name'
# Simple model: display name is just the tag name
# For more complex models, could override _compute_display_name()
# Example: "Popular (10 services)" - but that's overkill for tags
-
+
# ========================================================================
# SQL CONSTRAINTS
# ========================================================================
# NOTE: _sql_constraints shows deprecation warning in Odoo 19
# but new Constraint API not yet available - safe to use
-
+
_sql_constraints = [
(
'unique_tag_name',
@@ -126,12 +126,12 @@ class ServiceEventTag(models.Model):
# WHY UNIQUE NAME:
# - Prevents duplicate tags ("Popular", "popular", "POPULAR")
# - Case-sensitive in PostgreSQL (could add case-insensitive if needed)
- #
+ #
# WHY SQL CONSTRAINT:
# - Database-level enforcement (prevents race conditions)
# - Faster than Python validation
# - Works even with direct SQL inserts
-
+
# ========================================================================
# NOTE: No _auto_init() override needed
# ========================================================================
@@ -139,7 +139,7 @@ class ServiceEventTag(models.Model):
# - Simple model with low query volume
# - Name already indexed due to UNIQUE constraint
# - No complex queries that need optimization
- #
+ #
# WHEN TO ADD:
# - If tags become frequently filtered
# - If tag count grows to thousands
diff --git a/service_event_base/security/security.xml b/service_event_base/security/security.xml
index 4c234c5..fd7c119 100644
--- a/service_event_base/security/security.xml
+++ b/service_event_base/security/security.xml
@@ -33,14 +33,14 @@ Placeholder created for manifest dependency.
- Data will be updated on module upgrade
- Changes in XML will overwrite database
- Used for critical security definitions
-
+
noupdate="1" would mean:
- Data only created on first install
- User customizations preserved on upgrade
- Used for demo data or default records
-->
-
+
-
+
diff --git a/service_event_base/views/menus.xml b/service_event_base/views/menus.xml
index 91ff256..b1a9b86 100644
--- a/service_event_base/views/menus.xml
+++ b/service_event_base/views/menus.xml
@@ -22,43 +22,43 @@ COMMIT 2 SCOPE:
-->
-
+
-
+
-
+
-
+
-
+
-
+
-
+
diff --git a/service_event_base/views/service_booking_views.xml b/service_event_base/views/service_booking_views.xml
index c615326..373fc24 100644
--- a/service_event_base/views/service_booking_views.xml
+++ b/service_event_base/views/service_booking_views.xml
@@ -11,7 +11,7 @@ COMMIT 2 SCOPE:
-->
-
+
service.booking.list
@@ -31,7 +31,7 @@ COMMIT 2 SCOPE:
-
+
service.booking.form
@@ -89,7 +89,7 @@ COMMIT 2 SCOPE:
-
+
Service Bookings
@@ -101,7 +101,7 @@ COMMIT 2 SCOPE:
-
+
diff --git a/service_event_base/views/service_event_views.xml b/service_event_base/views/service_event_views.xml
index cb7fb4c..8d123f9 100644
--- a/service_event_base/views/service_event_views.xml
+++ b/service_event_base/views/service_event_views.xml
@@ -4,7 +4,7 @@ Service Event Views
PURPOSE:
Define user interface views for service.event model.
-
+
ODOO VIEW TYPES:
- tree: List view (table) removed since v18 now called list
- form: Detail view (single record)
@@ -36,8 +36,12 @@ COMMIT 2 SCOPE:
-
-
+
+
+
+
+
+
@@ -61,6 +65,26 @@ COMMIT 2 SCOPE:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From b115decade4a6c0a17b052ebee018ec8ad06b5df Mon Sep 17 00:00:00 2001
From: Sanjay Sharma
Date: Mon, 26 Jan 2026 23:52:37 +0530
Subject: [PATCH 4/9] =?UTF-8?q?[ADD]=20service=5Fevent=5Fbase:=20Business?=
=?UTF-8?q?=20Logic=20Layer=20Service=20Event=20Model=20Enhancements:=20-?=
=?UTF-8?q?=20=E2=9C=85=20Pricing=20logic=20=20=20-=20Early=20bird=20prici?=
=?UTF-8?q?ng=20(early=5Fbird=5Fprice,=20early=5Fbird=5Fdeadline)=20=20=20?=
=?UTF-8?q?-=20Discount=20percentage=20(discount=5Fpercentage)=20=20=20-?=
=?UTF-8?q?=20Final=20price=20computation=20(final=5Fprice)=20=20=20-=20Pr?=
=?UTF-8?q?ice=20calculation=20method=20(get=5Fapplicable=5Fprice)=20-=20?=
=?UTF-8?q?=E2=9C=85=20Event=20lifecycle=20management=20=20=20-=20State=20?=
=?UTF-8?q?workflow=20(draft=20=E2=86=92=20published=20=E2=86=92=20registr?=
=?UTF-8?q?ation=5Fclosed=20=E2=86=92=20completed/cancelled)=20=20=20-=20L?=
=?UTF-8?q?ifecycle=20action=20methods=20(publish,=20close=5Fregistration,?=
=?UTF-8?q?=20mark=5Fcompleted,=20cancel,=20reset=5Fto=5Fdraft)=20=20=20-?=
=?UTF-8?q?=20Registration=20status=20(registration=5Fopen=20computed=20fi?=
=?UTF-8?q?eld)=20-=20=E2=9C=85=20Business=20metrics=20=20=20-=20Fill=20ra?=
=?UTF-8?q?te=20(%=20of=20capacity=20filled)=20=20=20-=20Revenue=20per=20s?=
=?UTF-8?q?eat=20=20=20-=20Cancellation=20rate=20-=20=E2=9C=85=20Business?=
=?UTF-8?q?=20validation=20=20=20-=20Early=20bird=20price=20must=20be=20=
=?UTF-8?q?=20regular=20price=20=20=20-=20Early=20bird=20deadline=20must?=
=?UTF-8?q?=20be=20before=20event=20start=20=20=20-=20Prevent=20publishing?=
=?UTF-8?q?=20without=20price/category=20=20=20-=20Check=20booking=20allow?=
=?UTF-8?q?ed=20method?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Service Booking Model Enhancements:
- ✅ Waitlist management
- Waitlisted state added to workflow
- Auto-waitlist when event full
- Waitlist position tracking
- Auto-promotion when spots open
- Manual promotion method
- ✅ Enhanced booking validation
- Check event is published and registration open
- Prevent duplicate bookings (same customer + event)
- Auto-populate amount from event price
- Validate event hasn't started
- ✅ Business logic
- Cascade cancellation (event cancelled → bookings cancelled)
- Auto-promotion from waitlist on cancellation
Views Updated:
- ✅ Event list: state badges, pricing fields, business metrics
- ✅ Event form: lifecycle status bar with action buttons, pricing section, metrics dashboard
- ✅ Booking list: waitlist state and position
- ✅ Booking form: waitlist alert, promote button
---
service_event_base/README.md | 65 +-
service_event_base/models/service_booking.py | 197 +++++-
service_event_base/models/service_event.py | 605 +++++++++++++++++-
.../views/service_booking_views.xml | 16 +-
.../views/service_event_views.xml | 54 +-
5 files changed, 890 insertions(+), 47 deletions(-)
diff --git a/service_event_base/README.md b/service_event_base/README.md
index 36728b5..e08f0a0 100644
--- a/service_event_base/README.md
+++ b/service_event_base/README.md
@@ -12,44 +12,57 @@
- ✅ **Commit 1** (COMPLETE): Module Foundation + Hooks
- ✅ **Commit 2** (COMPLETE): Core Models + ORM
-- 🔄 **Commit 3** (CURRENT): Advanced Computed Fields + Constraints
-- ⏳ **Commit 4**: Business Logic Layer
+- ✅ **Commit 3** (COMPLETE): Advanced Computed Fields + Constraints
+- 🔄 **Commit 4** (CURRENT): Business Logic Layer
- ⏳ **Commit 5**: Security + Access Control
- ⏳ **Commit 6**: Views + UI Enhancement
- ⏳ **Commit 7**: Reports + Email Templates
- ⏳ **Commit 8**: Wizards + Workflows
- ⏳ **Commit 9-17**: Advanced features
-### Commit 3 Features (Advanced Computed Fields + Constraints)
+### Commit 4 Features (Business Logic Layer)
**Service Event Model Enhancements:**
-- ✅ Capacity management (capacity field)
-- ✅ Advanced computed fields with multiple dependencies
- - booking_count_confirmed (stored)
- - total_revenue (stored, Monetary)
- - available_seats (non-stored, real-time)
-- ✅ Datetime scheduling fields
- - start_datetime, end_datetime, duration
- - Inverse function on end_datetime (bidirectional computation)
-- ✅ Complex Python constraints
- - Capacity validation (prevent overbooking)
- - Datetime range validation
- - Price consistency checks
-- ✅ SQL constraints for data integrity
+- ✅ Pricing logic
+ - Early bird pricing (early_bird_price, early_bird_deadline)
+ - Discount percentage (discount_percentage)
+ - Final price computation (final_price)
+ - Price calculation method (get_applicable_price)
+- ✅ Event lifecycle management
+ - State workflow (draft → published → registration_closed → completed/cancelled)
+ - Lifecycle action methods (publish, close_registration, mark_completed, cancel, reset_to_draft)
+ - Registration status (registration_open computed field)
+- ✅ Business metrics
+ - Fill rate (% of capacity filled)
+ - Revenue per seat
+ - Cancellation rate
+- ✅ Business validation
+ - Early bird price must be < regular price
+ - Early bird deadline must be before event start
+ - Prevent publishing without price/category
+ - Check booking allowed method
**Service Booking Model Enhancements:**
-- ✅ Onchange methods for UX improvement
- - Auto-populate amount from event
- - Show availability warnings
- - Weekend booking warnings
-- ✅ Default value functions demonstrated
- - context_today for dates
- - Lambda for company_id
+- ✅ Waitlist management
+ - Waitlisted state added to workflow
+ - Auto-waitlist when event full
+ - Waitlist position tracking
+ - Auto-promotion when spots open
+ - Manual promotion method
+- ✅ Enhanced booking validation
+ - Check event is published and registration open
+ - Prevent duplicate bookings (same customer + event)
+ - Auto-populate amount from event price
+ - Validate event hasn't started
+- ✅ Business logic
+ - Cascade cancellation (event cancelled → bookings cancelled)
+ - Auto-promotion from waitlist on cancellation
**Views Updated:**
-- ✅ Event list view: capacity, booking stats, revenue
-- ✅ Event form view: capacity tracking group, schedule group
-- ✅ Booking views: onchange methods work automatically
+- ✅ Event list: state badges, pricing fields, business metrics
+- ✅ Event form: lifecycle status bar with action buttons, pricing section, metrics dashboard
+- ✅ Booking list: waitlist state and position
+- ✅ Booking form: waitlist alert, promote button
## Overview
diff --git a/service_event_base/models/service_booking.py b/service_event_base/models/service_booking.py
index 4ece74c..54af0fa 100644
--- a/service_event_base/models/service_booking.py
+++ b/service_event_base/models/service_booking.py
@@ -202,6 +202,7 @@ def _compute_name(self):
state = fields.Selection(
[
('draft', 'Draft'),
+ ('waitlisted', 'Waitlisted'),
('confirmed', 'Confirmed'),
('done', 'Done'),
('cancelled', 'Cancelled'),
@@ -231,10 +232,14 @@ def _compute_name(self):
# - Creates mail.tracking.value records
#
# STATE WORKFLOW LOGIC:
- # Draft → Confirmed: action_confirm()
+ # Draft → Waitlisted (if event full)
+ # Draft → Confirmed (if space available): action_confirm()
+ # Waitlisted → Confirmed (when spot opens): action_confirm()
# Confirmed → Done: action_done()
# * → Cancelled: action_cancel()
#
+ # Added 'waitlisted' state for capacity management
+ #
# BEST PRACTICES:
# - Use lowercase_underscore for database values
# - Use Title Case for labels
@@ -246,6 +251,65 @@ def _compute_name(self):
# - Could use Integer with constants (less readable)
# ========================================================================
+ # ========================================================================
+ # WAITLIST MANAGEMENT
+ # ========================================================================
+
+ is_waitlisted = fields.Boolean(
+ string='On Waitlist',
+ compute='_compute_is_waitlisted',
+ store=True,
+ help='Automatically set when event is at capacity',
+ )
+ # WHY: Quick filter for waitlisted bookings
+ # HOW: Derived from state = 'waitlisted'
+ # STORED: For fast searches "show all waitlisted bookings"
+
+ waitlist_position = fields.Integer(
+ string='Waitlist Position',
+ compute='_compute_waitlist_position',
+ store=False,
+ help='Position in waitlist queue (1 = next in line)',
+ )
+ # WHY: Show customers their position in waitlist
+ # HOW: Order waitlisted bookings by create_date
+ # NON-STORED: Real-time position (changes as others cancel)
+
+ @api.depends('state')
+ def _compute_is_waitlisted(self):
+ """Flag bookings that are waitlisted."""
+ for booking in self:
+ booking.is_waitlisted = (booking.state == 'waitlisted')
+
+ @api.depends('event_id', 'event_id.booking_ids', 'event_id.booking_ids.state', 'event_id.booking_ids.create_date')
+ def _compute_waitlist_position(self):
+ """
+ Calculate position in waitlist queue.
+
+ LOGIC:
+ - Only for waitlisted bookings
+ - Ordered by creation date (first-come-first-served)
+ - Position 1 = next to be promoted
+
+ BUSINESS USE:
+ - Show customer: "You are #3 in line"
+ - Auto-promote: promote position 1 when spot opens
+ """
+ for booking in self:
+ if booking.state == 'waitlisted' and booking.event_id:
+ # Get all waitlisted bookings for this event, ordered by creation date
+ waitlisted = booking.event_id.booking_ids.filtered(
+ lambda b: b.state == 'waitlisted'
+ ).sorted('create_date')
+
+ # Find position in queue (1-indexed)
+ try:
+ booking.waitlist_position = list(waitlisted.ids).index(booking.id) + 1
+ except ValueError:
+ booking.waitlist_position = 0
+ else:
+ booking.waitlist_position = 0
+
# ========================================================================
# DATE/TIME FIELDS
# ========================================================================
@@ -280,7 +344,7 @@ def _compute_name(self):
# - Server in UTC sees today = Jan 23 at 2am
# - context_today ensures consistent UX
#
- # COMMIT 3: DEFAULT VALUE FUNCTIONS
+ # DEFAULT VALUE FUNCTIONS
# DEFAULT PATTERNS DEMONSTRATED:
# 1. Static default: default='draft'
# 2. Function reference: default=fields.Date.context_today
@@ -468,6 +532,15 @@ def create(self, vals_list):
'service.booking'
) or _('New')
+ # Auto-set amount from event's applicable price
+ if 'event_id' in vals and 'amount' not in vals:
+ event = self.env['service.event'].browse(vals['event_id'])
+ booking_date = vals.get('booking_date') or fields.Date.context_today(self)
+ # Convert string date to Date object if needed
+ if isinstance(booking_date, str):
+ booking_date = fields.Date.to_date(booking_date)
+ vals['amount'] = event.get_applicable_price(booking_date)
+
return super().create(vals_list)
# ========================================================================
@@ -485,9 +558,49 @@ def action_confirm(self):
USAGE:
- Called from button in form view
- Can be called programmatically: booking.action_confirm()
+
+ ENHANCEMENTS:
+ - Validate event allows bookings
+ - Auto-waitlist if capacity full
+ - Check for duplicate bookings
"""
- self.ensure_one() # Ensure single record (not recordset)
- self.write({'state': 'confirmed'})
+ for booking in self:
+ # Validate event allows bookings
+ allowed, reason = booking.event_id.check_booking_allowed()
+
+ if not allowed:
+ # Event is full - auto-waitlist instead of confirming
+ if 'full capacity' in reason.lower():
+ booking.write({'state': 'waitlisted'})
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Added to Waitlist'),
+ 'message': _(f'Booking {booking.booking_number} added to waitlist. '
+ f'You are #{booking.waitlist_position} in line.'),
+ 'type': 'warning',
+ 'sticky': False,
+ }
+ }
+ else:
+ raise ValidationError(reason)
+
+ # Check for duplicate booking (same customer + event)
+ duplicate = self.search([
+ ('partner_id', '=', booking.partner_id.id),
+ ('event_id', '=', booking.event_id.id),
+ ('state', 'in', ['confirmed', 'done']),
+ ('id', '!=', booking.id),
+ ], limit=1)
+
+ if duplicate:
+ raise ValidationError(
+ f"Customer {booking.partner_id.name} already has a confirmed booking "
+ f"({duplicate.booking_number}) for event '{booking.event_id.name}'"
+ )
+
+ booking.write({'state': 'confirmed'})
def action_done(self):
"""Mark booking as done (event completed)."""
@@ -495,9 +608,73 @@ def action_done(self):
self.write({'state': 'done'})
def action_cancel(self):
- """Cancel the booking."""
+ """
+ Cancel the booking.
+
+ ENHANCEMENTS:
+ - Promote from waitlist if spot opens
+ - Track cancellation for metrics
+ """
+ for booking in self:
+ was_confirmed = booking.state == 'confirmed'
+ event = booking.event_id
+
+ booking.write({'state': 'cancelled'})
+
+ # If confirmed booking was cancelled, check waitlist
+ if was_confirmed and event:
+ event._promote_from_waitlist()
+
+ return True
+
+ def action_draft(self):
+ """Reset to draft (for corrections)."""
+ self.ensure_one()
+ self.write({'state': 'draft'})
+
+ # ========================================================================
+ # BUSINESS HELPER METHODS
+ # ========================================================================
+
+ def action_promote_from_waitlist(self):
+ """
+ Manually promote this booking from waitlist to confirmed.
+
+ USE CASE:
+ - Admin manually promotes waitlisted customer
+ - Overbook intentionally (e.g., expecting cancellations)
+
+ VALIDATION:
+ - Must be in waitlisted state
+ - Event should have capacity (warning if not)
+ """
self.ensure_one()
- self.write({'state': 'cancelled'})
+
+ if self.state != 'waitlisted':
+ raise ValidationError(
+ f"Cannot promote booking {self.booking_number}: Not in waitlisted state"
+ )
+
+ # Check capacity (warning, not blocking)
+ if self.event_id.capacity > 0:
+ if self.event_id.booking_count_confirmed >= self.event_id.capacity:
+ # Allow but warn
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Overbooking Warning'),
+ 'message': _(
+ f'Event is at capacity ({self.event_id.capacity}). '
+ f'Promoting anyway (manual override).'
+ ),
+ 'type': 'warning',
+ 'sticky': True,
+ }
+ }
+
+ self.write({'state': 'confirmed'})
+ return True
def action_draft(self):
"""Reset to draft (for corrections)."""
@@ -544,7 +721,7 @@ def _check_booking_date(self):
]
# ========================================================================
- # COMMIT 3: ONCHANGE METHODS
+ # ONCHANGE METHODS
# ========================================================================
@api.onchange('event_id')
@@ -608,9 +785,9 @@ def _onchange_event_id(self):
}
"""
if self.event_id:
- # Auto-populate amount from event price
- # Note: _compute_amount also does this, but onchange is immediate
- self.amount = self.event_id.price_unit
+ # Auto-populate amount using applicable price (early bird + discount)
+ booking_date = self.booking_date or fields.Date.context_today(self)
+ self.amount = self.event_id.get_applicable_price(booking_date)
# Check availability and warn if low
if hasattr(self.event_id, 'available_seats'):
diff --git a/service_event_base/models/service_event.py b/service_event_base/models/service_event.py
index 6b44f73..67f1753 100644
--- a/service_event_base/models/service_event.py
+++ b/service_event_base/models/service_event.py
@@ -34,6 +34,7 @@
"""
from odoo import api, fields, models
+from odoo.exceptions import ValidationError
class ServiceEvent(models.Model):
@@ -123,10 +124,10 @@ class ServiceEvent(models.Model):
# ========================================================================
price_unit = fields.Float(
- string='Price',
+ string='Regular Price',
digits='Product Price', # Uses decimal precision from settings
default=0.0,
- help='Price per booking for this event',
+ help='Regular price per booking for this event',
)
# WHY Float not Monetary: Monetary requires currency_id field
# WHY digits='Product Price': Standard Odoo precision (usually 2 decimals)
@@ -144,6 +145,193 @@ class ServiceEvent(models.Model):
# HOW lambda works: Executes at record creation time, not module load
# ALTERNATIVE: Could be required=True to enforce currency selection
+ # ========================================================================
+ # PRICING LOGIC
+ # ========================================================================
+
+ early_bird_price = fields.Float(
+ string='Early Bird Price',
+ digits='Product Price',
+ default=0.0,
+ help='Discounted price for early bookings (0 = no early bird discount)',
+ )
+ # WHY: Encourage early registrations, reward early adopters
+ # PATTERN: Common in events (conferences, workshops)
+ # WHEN: Used if booking_date <= early_bird_deadline
+
+ early_bird_deadline = fields.Date(
+ string='Early Bird Deadline',
+ help='Last date to get early bird pricing',
+ )
+ # WHY: Creates urgency for bookings
+ # BUSINESS LOGIC: After this date, regular price applies
+ # EXAMPLE: "Register by Jan 15 for $99 (regular $149)"
+
+ discount_percentage = fields.Float(
+ string='Discount %',
+ digits=(5, 2), # 5 total digits, 2 decimals (allows 0.00 to 100.00)
+ default=0.0,
+ help='Percentage discount applied to regular price (0-100)',
+ )
+ # WHY: Flexible discounting (promotions, bulk discounts, member discounts)
+ # HOW: final_price = price_unit * (1 - discount_percentage/100)
+ # VALIDATION: Should be 0-100 (checked in constraint)
+
+ final_price = fields.Monetary(
+ string='Final Price',
+ currency_field='currency_id',
+ compute='_compute_final_price',
+ store=True,
+ help='Computed price after discounts and early bird',
+ )
+ # ========================================================================
+ # PRICING COMPUTATION LOGIC
+ # ========================================================================
+ # FORMULA:
+ # 1. Start with price_unit (regular price)
+ # 2. Apply early bird if before deadline: use early_bird_price
+ # 3. Apply discount percentage: price * (1 - discount%/100)
+ # 4. Result = final_price
+ #
+ # EXAMPLES:
+ # Regular: $100, No discounts → $100
+ # Early bird: $100 regular, $75 early → $75 (if before deadline)
+ # Discount: $100, 20% off → $80
+ # Both: $100, early $75, 10% off → $67.50 (early bird + discount)
+ #
+ # WHY Monetary: Proper currency formatting, multi-currency support
+ # WHY stored: Used in reports, revenue calculations
+ # ========================================================================
+
+ @api.depends('price_unit', 'early_bird_price', 'early_bird_deadline', 'discount_percentage')
+ def _compute_final_price(self):
+ """
+ Compute final price with early bird and discount logic.
+
+ BUSINESS RULES:
+ 1. Early bird price takes precedence if conditions met
+ 2. Discount percentage applies to selected base price
+ 3. Early bird requires both price AND deadline set
+ 4. Discount cannot exceed 100% (validated by constraint)
+
+ PRIORITY:
+ Early bird (if applicable) → Discount % → Final price
+
+ REAL-WORLD SCENARIO:
+ Event: "Python Workshop"
+ Regular: $149
+ Early bird: $99 (until Jan 15)
+ Additional discount: 10% for members
+
+ Customer books Jan 10 with member discount:
+ Base: $99 (early bird)
+ Discount: $99 * 10% = $9.90
+ Final: $89.10
+ """
+ from odoo import fields as odoo_fields
+
+ for event in self:
+ base_price = event.price_unit
+
+ # Check if early bird pricing applies
+ if event.early_bird_price > 0 and event.early_bird_deadline:
+ today = odoo_fields.Date.context_today(event)
+ if today <= event.early_bird_deadline:
+ base_price = event.early_bird_price
+
+ # Apply discount percentage
+ if event.discount_percentage > 0:
+ discount_amount = base_price * (event.discount_percentage / 100.0)
+ final = base_price - discount_amount
+ else:
+ final = base_price
+
+ event.final_price = final
+
+ # ========================================================================
+ # LIFECYCLE & STATUS FIELDS
+ # ========================================================================
+
+ state = fields.Selection(
+ [
+ ('draft', 'Draft'),
+ ('published', 'Published'),
+ ('registration_closed', 'Registration Closed'),
+ ('completed', 'Completed'),
+ ('cancelled', 'Cancelled'),
+ ],
+ string='Status',
+ default='draft',
+ required=True,
+ tracking=True,
+ help='Event lifecycle state',
+ )
+ # ========================================================================
+ # EVENT LIFECYCLE STATES
+ # ========================================================================
+ # draft: Event being prepared, not visible to customers
+ # published: Event live, accepting bookings
+ # registration_closed: Event visible but not accepting new bookings
+ # completed: Event finished, historical record
+ # cancelled: Event cancelled, bookings may be refunded
+ #
+ # WORKFLOW:
+ # draft → published → registration_closed → completed
+ # Any state → cancelled (cancellation possible anytime)
+ #
+ # BUSINESS RULES:
+ # - Only published events accept bookings
+ # - Can close registration manually or auto (capacity reached)
+ # - Completed events used for analytics/history
+ # - Cancelled events trigger booking cancellations
+ # ========================================================================
+
+ registration_open = fields.Boolean(
+ string='Registration Open',
+ compute='_compute_registration_open',
+ store=False,
+ help='Whether event is accepting new bookings',
+ )
+ # WHY computed: Derived from state + capacity + other factors
+ # WHY non-stored: Real-time status check
+ # LOGIC: published state + not at capacity + not past event date
+
+ @api.depends('state', 'capacity', 'booking_count_confirmed', 'start_datetime')
+ def _compute_registration_open(self):
+ """
+ Determine if event is accepting new bookings.
+
+ CONDITIONS (ALL must be true):
+ 1. State = 'published'
+ 2. Not at capacity (or unlimited capacity)
+ 3. Event hasn't started yet (if start_datetime set)
+
+ USED IN:
+ - UI to show/hide booking button
+ - Validation before creating booking
+ - Website filtering (show only open events)
+ """
+ from odoo import fields as odoo_fields
+
+ for event in self:
+ is_open = False
+
+ if event.state == 'published':
+ # Check capacity
+ has_capacity = True
+ if event.capacity > 0:
+ has_capacity = event.booking_count_confirmed < event.capacity
+
+ # Check if event hasn't started
+ not_started = True
+ if event.start_datetime:
+ now = odoo_fields.Datetime.now()
+ not_started = event.start_datetime > now
+
+ is_open = has_capacity and not_started
+
+ event.registration_open = is_open
+
# ========================================================================
# MANY2ONE RELATIONSHIP - CATEGORY
# ========================================================================
@@ -270,7 +458,7 @@ def _compute_booking_count(self):
event.booking_count = len(event.booking_ids)
# ========================================================================
- # COMMIT 3: ADVANCED COMPUTED FIELDS
+ # ADVANCED COMPUTED FIELDS
# ========================================================================
capacity = fields.Integer(
@@ -377,7 +565,7 @@ def _compute_available_seats(self):
event.available_seats = -1 # -1 = unlimited
# ========================================================================
- # COMMIT 3: INVERSE FUNCTIONS FOR COMPUTED FIELDS
+ # INVERSE FUNCTIONS FOR COMPUTED FIELDS
# ========================================================================
start_datetime = fields.Datetime(
@@ -483,6 +671,94 @@ def _inverse_end_datetime(self):
# ALTERNATIVE: Make required=True to enforce company assignment
# ========================================================================
+ # ========================================================================
+ # BUSINESS METRICS
+ # ========================================================================
+
+ fill_rate = fields.Float(
+ string='Fill Rate (%)',
+ compute='_compute_business_metrics',
+ store=False,
+ digits=(5, 2),
+ help='Percentage of capacity filled (confirmed bookings / capacity * 100)',
+ )
+ # WHY: Key performance indicator for event success
+ # FORMULA: (confirmed_bookings / capacity) * 100
+ # EXAMPLE: 15 bookings / 20 capacity = 75% fill rate
+ # BUSINESS USE: Target 80%+ fill rate for profitability
+
+ revenue_per_seat = fields.Monetary(
+ string='Revenue per Seat',
+ currency_field='currency_id',
+ compute='_compute_business_metrics',
+ store=False,
+ help='Average revenue per capacity seat (total_revenue / capacity)',
+ )
+ # WHY: Measure revenue efficiency
+ # FORMULA: total_revenue / capacity
+ # EXAMPLE: $3,000 revenue / 20 seats = $150/seat
+ # BUSINESS USE: Compare across events, optimize pricing
+
+ cancellation_rate = fields.Float(
+ string='Cancellation Rate (%)',
+ compute='_compute_business_metrics',
+ store=False,
+ digits=(5, 2),
+ help='Percentage of bookings that were cancelled',
+ )
+ # WHY: Track customer satisfaction and overbooking strategy
+ # FORMULA: (cancelled_bookings / total_bookings) * 100
+ # BUSINESS USE: High cancellation = pricing/expectation issues
+
+ @api.depends('capacity', 'booking_count_confirmed', 'total_revenue', 'booking_ids', 'booking_ids.state')
+ def _compute_business_metrics(self):
+ """
+ Compute key business performance indicators.
+
+ METRICS:
+ fill_rate: How full is the event? (confirmed / capacity)
+ revenue_per_seat: Revenue efficiency (revenue / capacity)
+ cancellation_rate: Customer retention (cancelled / total)
+
+ BUSINESS APPLICATIONS:
+ - Dashboard KPIs
+ - Performance reports
+ - Pricing optimization
+ - Event comparison
+ - Historical trends
+
+ REAL-WORLD EXAMPLE:
+ Event: "Python Workshop"
+ Capacity: 20
+ Confirmed: 18
+ Revenue: $2,700
+ Cancelled: 2 (out of 20 total bookings)
+
+ fill_rate = 90% (18/20 * 100)
+ revenue_per_seat = $135 ($2,700/20)
+ cancellation_rate = 10% (2/20 * 100)
+ """
+ for event in self:
+ # Fill Rate
+ if event.capacity > 0:
+ event.fill_rate = (event.booking_count_confirmed / event.capacity) * 100
+ else:
+ event.fill_rate = 0.0
+
+ # Revenue per Seat
+ if event.capacity > 0:
+ event.revenue_per_seat = event.total_revenue / event.capacity
+ else:
+ event.revenue_per_seat = 0.0
+
+ # Cancellation Rate
+ total_bookings = len(event.booking_ids)
+ if total_bookings > 0:
+ cancelled_bookings = len(event.booking_ids.filtered(lambda b: b.state == 'cancelled'))
+ event.cancellation_rate = (cancelled_bookings / total_bookings) * 100
+ else:
+ event.cancellation_rate = 0.0
+
# ========================================================================
# MAGICAL FIELDS - AUTOMATICALLY PROVIDED BY ODOO
# ========================================================================
@@ -588,6 +864,16 @@ def _compute_display_name(self):
'CHECK (price_unit >= 0)',
'Price must be positive or zero'
),
+ (
+ 'positive_early_bird_price',
+ 'CHECK (early_bird_price >= 0)',
+ 'Early bird price must be positive or zero'
+ ),
+ (
+ 'valid_discount',
+ 'CHECK (discount_percentage >= 0 AND discount_percentage <= 100)',
+ 'Discount must be between 0 and 100 percent'
+ ),
(
'positive_capacity',
'CHECK (capacity >= 0)',
@@ -605,7 +891,7 @@ def _compute_display_name(self):
# WHEN to use Python: Complex multi-field validation
# ========================================================================
- # COMMIT 3: COMPLEX PYTHON CONSTRAINTS
+ # COMPLEX PYTHON CONSTRAINTS
# ========================================================================
@api.constrains('capacity', 'booking_count_confirmed')
@@ -705,3 +991,312 @@ def _check_price_consistency(self):
# Note: In production, might just log this instead of raising
# For education, we demonstrate the pattern
pass # Allow, but could raise warning in future
+
+ # ========================================================================
+ # BUSINESS LOGIC CONSTRAINTS
+ # ========================================================================
+
+ @api.constrains('early_bird_price', 'price_unit')
+ def _check_early_bird_price(self):
+ """
+ Validate early bird price is less than regular price.
+
+ BUSINESS RULE:
+ Early bird pricing should offer a discount, not increase price.
+
+ VALIDATION:
+ If early_bird_price set, must be < price_unit
+ If early_bird_price = 0, validation skipped (no early bird)
+ """
+ for event in self:
+ if event.early_bird_price > 0 and event.early_bird_price >= event.price_unit:
+ raise ValidationError(
+ f"Early bird price (${event.early_bird_price:.2f}) must be less than "
+ f"regular price (${event.price_unit:.2f}) for event '{event.name}'"
+ )
+
+ @api.constrains('early_bird_deadline', 'start_datetime')
+ def _check_early_bird_deadline(self):
+ """
+ Ensure early bird deadline is before event start.
+
+ BUSINESS LOGIC:
+ Can't offer early bird pricing after event has started.
+ """
+ for event in self:
+ if event.early_bird_deadline and event.start_datetime:
+ # Convert date to datetime for comparison
+ from datetime import datetime, time
+ deadline_dt = datetime.combine(event.early_bird_deadline, time.max)
+
+ if deadline_dt >= event.start_datetime:
+ raise ValidationError(
+ f"Early bird deadline must be before event start time for '{event.name}'"
+ )
+
+ # ========================================================================
+ # LIFECYCLE METHODS
+ # ========================================================================
+
+ def action_publish(self):
+ """
+ Publish event (make available for booking).
+
+ BUSINESS RULES:
+ - Event must have price set
+ - Event must have capacity set (or 0 for unlimited)
+ - Event must have category
+
+ STATE TRANSITION:
+ draft → published
+
+ EFFECTS:
+ - Event visible on website
+ - Customers can book
+ - registration_open becomes True
+ """
+ for event in self:
+ # Validation
+ if not event.price_unit and not event.final_price:
+ raise ValidationError(f"Cannot publish '{event.name}': Price must be set")
+
+ if not event.category_id:
+ raise ValidationError(f"Cannot publish '{event.name}': Category must be set")
+
+ event.write({'state': 'published'})
+
+ return True
+
+ def action_close_registration(self):
+ """
+ Close registration (stop accepting new bookings).
+
+ USE CASES:
+ - Manually close when prep work starts
+ - Event is full (auto-triggered)
+ - Last-minute changes needed
+
+ STATE TRANSITION:
+ published → registration_closed
+
+ EFFECTS:
+ - Event still visible but can't book
+ - Existing bookings unaffected
+ - registration_open becomes False
+ """
+ self.write({'state': 'registration_closed'})
+ return True
+
+ def action_mark_completed(self):
+ """
+ Mark event as completed (event has occurred).
+
+ USE CASES:
+ - Event date has passed
+ - Manual marking after event concludes
+
+ STATE TRANSITION:
+ registration_closed → completed
+ published → completed (if registration wasn't closed first)
+
+ EFFECTS:
+ - Event archived from active lists
+ - Used for historical reporting
+ - Bookings remain for attendance tracking
+ """
+ self.write({'state': 'completed'})
+ return True
+
+ def action_cancel_event(self):
+ """
+ Cancel the event.
+
+ BUSINESS IMPACT:
+ - All bookings should be cancelled
+ - Refunds may be needed
+ - Notifications sent to customers
+
+ STATE TRANSITION:
+ Any state → cancelled
+
+ CASCADE EFFECTS:
+ - Cancel all associated bookings
+ - Could trigger refund workflow
+ - Could send cancellation emails
+ """
+ for event in self:
+ # Cancel all non-cancelled bookings
+ bookings_to_cancel = event.booking_ids.filtered(
+ lambda b: b.state != 'cancelled'
+ )
+ if bookings_to_cancel:
+ bookings_to_cancel.action_cancel()
+
+ event.write({'state': 'cancelled'})
+
+ return True
+
+ def action_reset_to_draft(self):
+ """
+ Reset event to draft status.
+
+ USE CASE:
+ - Unpublish event for major changes
+ - Reuse cancelled event
+
+ VALIDATION:
+ - Cannot reset if confirmed bookings exist
+ """
+ for event in self:
+ if event.booking_count_confirmed > 0:
+ raise ValidationError(
+ f"Cannot reset '{event.name}' to draft: "
+ f"{event.booking_count_confirmed} confirmed bookings exist"
+ )
+
+ event.write({'state': 'draft'})
+
+ return True
+
+ # ========================================================================
+ # BUSINESS HELPER METHODS
+ # ========================================================================
+
+ def get_applicable_price(self, booking_date=None):
+ """
+ Calculate price applicable for a specific booking date.
+
+ PARAMETERS:
+ booking_date: Date of booking (default: today)
+
+ RETURNS:
+ Float: Price applicable on that date
+
+ BUSINESS LOGIC:
+ - Check if early bird deadline applies
+ - Apply discount percentage
+ - Return final calculated price
+
+ USAGE:
+ price = event.get_applicable_price(fields.Date.today())
+ booking.create({'amount': price})
+ """
+ self.ensure_one()
+
+ from odoo import fields as odoo_fields
+ if booking_date is None:
+ booking_date = odoo_fields.Date.context_today(self)
+
+ base_price = self.price_unit
+
+ # Check early bird
+ if self.early_bird_price > 0 and self.early_bird_deadline:
+ if booking_date <= self.early_bird_deadline:
+ base_price = self.early_bird_price
+
+ # Apply discount
+ if self.discount_percentage > 0:
+ discount_amount = base_price * (self.discount_percentage / 100.0)
+ final = base_price - discount_amount
+ else:
+ final = base_price
+
+ return final
+
+ def check_booking_allowed(self):
+ """
+ Check if new bookings are allowed for this event.
+
+ RETURNS:
+ (bool, str): (allowed, reason_if_not_allowed)
+
+ BUSINESS RULES CHECKED:
+ 1. Event must be published
+ 2. Must have capacity (or unlimited)
+ 3. Event must not have started
+ 4. Registration must be open
+
+ USAGE:
+ allowed, reason = event.check_booking_allowed()
+ if not allowed:
+ raise ValidationError(reason)
+ """
+ self.ensure_one()
+
+ if self.state != 'published':
+ return False, f"Event '{self.name}' is not published (current state: {self.state})"
+
+ if not self.registration_open:
+ return False, f"Registration is closed for '{self.name}'"
+
+ if self.capacity > 0 and self.booking_count_confirmed >= self.capacity:
+ return False, f"Event '{self.name}' is at full capacity ({self.capacity} seats)"
+
+ if self.start_datetime:
+ from odoo import fields as odoo_fields
+ now = odoo_fields.Datetime.now()
+ if self.start_datetime <= now:
+ return False, f"Event '{self.name}' has already started"
+
+ return True, ""
+
+ def _promote_from_waitlist(self):
+ """
+ Automatically promote first person from waitlist when spot opens.
+
+ TRIGGERED BY:
+ - Confirmed booking cancelled
+ - Capacity increased
+
+ BUSINESS LOGIC:
+ - Find oldest waitlisted booking (FIFO)
+ - Promote to confirmed
+ - Could send notification email
+
+ USAGE:
+ booking.action_cancel() # Frees a spot
+ event._promote_from_waitlist() # Auto-promotes next in line
+ """
+ self.ensure_one()
+
+ # Check if promotion possible
+ if self.capacity > 0 and self.booking_count_confirmed >= self.capacity:
+ return # Still at capacity
+
+ # Find first waitlisted booking (oldest first)
+ waitlisted = self.booking_ids.filtered(
+ lambda b: b.state == 'waitlisted'
+ ).sorted('create_date')
+
+ if waitlisted:
+ first_in_line = waitlisted[0]
+ first_in_line.write({'state': 'confirmed'})
+
+ # Could trigger email notification here
+ # first_in_line._send_promotion_notification()
+
+ return first_in_line
+
+ return None
+
+ def action_view_bookings(self):
+ """
+ Open bookings list view filtered for this event.
+
+ USAGE:
+ - Smart button in form view
+ - Shows all bookings for this event
+
+ RETURNS:
+ Action dictionary to open tree view
+ """
+ self.ensure_one()
+ return {
+ 'name': _('Bookings for %s') % self.name,
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'service.booking',
+ 'view_mode': 'list,form',
+ 'domain': [('event_id', '=', self.id)],
+ 'context': {'default_event_id': self.id},
+ }
+
diff --git a/service_event_base/views/service_booking_views.xml b/service_event_base/views/service_booking_views.xml
index 373fc24..7313070 100644
--- a/service_event_base/views/service_booking_views.xml
+++ b/service_event_base/views/service_booking_views.xml
@@ -18,16 +18,20 @@ COMMIT 2 SCOPE:
service.booking
+ decoration-success="state=='confirmed'" decoration-muted="state=='cancelled'"
+ decoration-warning="state=='waitlisted'">
+
+
+ decoration-danger="state=='cancelled'"
+ decoration-warning="state=='waitlisted'"/>
@@ -40,7 +44,9 @@ COMMIT 2 SCOPE: