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/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/__init__.py b/__init__.py new file mode 100644 index 0000000..86a2bfe --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Odoo Full-Stack Application modules package diff --git a/service_event_base/README.md b/service_event_base/README.md new file mode 100644 index 0000000..441a5d2 --- /dev/null +++ b/service_event_base/README.md @@ -0,0 +1,470 @@ +# Service Event Base Module + +**Version:** 19.1.0.0 +**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** (COMPLETE): Advanced Computed Fields + Constraints +- βœ… **Commit 4** (COMPLETE): Business Logic Layer +- βœ… **Commit 5** (COMPLETE): Security + Access Control +- πŸ”„ **Commit 6** (CURRENT): Views + UI Enhancement +- ⏳ **Commit 7**: Reports + Email Templates +- ⏳ **Commit 8**: Wizards + Workflows +- ⏳ **Commit 9-17**: Advanced features + +### Commit 5 Features (Security + Access Control) + +**Odoo 19 Privilege-Based Security:** +- βœ… Module category and privilege definition + - ir.module.category: "Service Events" + - res.groups.privilege: Links groups to category + - New architecture for better UI organization + +**Security Groups:** +- βœ… Service User (basic access) + - View published events + - Create and manage own bookings + - Read categories and tags +- βœ… Service Manager (full access) + - All User permissions + + - Manage all events (any state) + - Manage all bookings + - Access pricing and metrics + - Configure system + +**Access Control Layers:** +- βœ… Model-level access rights (ir.model.access.csv) + - 8 access rules for 4 models + - User: Read-only on events/categories/tags, CRU on bookings + - Manager: Full CRUD on all models +- βœ… Record-level security (8 record rules) + - Users see only published events + - Users see only own bookings + - Managers see all records +- βœ… Field-level security + - Pricing fields hidden from users + - Business metrics hidden from users + - Workflow buttons restricted to managers +- βœ… Menu security + - Root menu: Service User minimum + - Configuration menu: Manager only + +**Files Modified:** +- security/service_event_security.xml +- security/ir.model.access.csv (9 entries) +- views/service_event_views.xml (field restrictions) +- views/service_booking_views.xml (button restrictions) +- views/menus.xml (menu visibility) + +### Commit 4 Features (Business Logic Layer) + +**Service Event Model Enhancements:** +- βœ… 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:** +- βœ… 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 + +## 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')` lookupsfish + +## Development Status + +### βœ… Commit 5: Security + Access Control (COMPLETE) +- Odoo 19 privilege-based security architecture +- Module category and privilege definition +- 2 security groups (User, Manager) with hierarchy +- 8 model-level access rights (ir.model.access.csv) +- 8 record rules for row-level security +- Field-level security (pricing, metrics hidden from users) +- Menu security restrictions + +### βœ… Commit 4: Business Logic Layer (COMPLETE) +- Pricing logic (early bird, discounts, final price) +- Event lifecycle management (draftβ†’publishedβ†’closedβ†’completed/cancelled) +- Waitlist management (auto-waitlist, auto-promotion) +- Business metrics (fill rate, revenue per seat, cancellation rate) +- Enhanced validation (prevent duplicates, check capacity) +- Cascade operations (event cancelled β†’ bookings cancelled) +- Lifecycle action buttons in views + +### βœ… Commit 3: Advanced Computed Fields + Constraints (COMPLETE) +- Computed fields with dependencies +- Python constraints +- SQL constraints +- Field validations + +### βœ… 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 3: Advanced computed fields + constraints +- Commit 4: Method overrides & business logic +- 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..fbcbabf --- /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.0.1.6.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/service_event_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..afd79e9 --- /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..bc909cb --- /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..c8f2a9b --- /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..2c99ed8 --- /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..a375f37 --- /dev/null +++ b/service_event_base/models/service_booking.py @@ -0,0 +1,879 @@ +# -*- coding: utf-8 -*- +""" +Service Booking Model + +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 + +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 api, fields, models, _ +from odoo.exceptions import ValidationError + + +class ServiceBooking(models.Model): + """ + 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'), + ('waitlisted', 'Waitlisted'), + ('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 β†’ 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 + # - 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) + # ======================================================================== + + # ======================================================================== + # 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 + # ======================================================================== + + 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 + # + # 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 + # ======================================================================== + + 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) + + color = fields.Integer( + string='Color Index', + default=0, + help='Color index for Kanban and Calendar views (0-11)', + ) + + 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') + + # 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) + + # ======================================================================== + # 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() + + ENHANCEMENTS: + - Validate event allows bookings + - Auto-waitlist if capacity full + - Check for duplicate bookings + """ + 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).""" + self.ensure_one() + self.write({'state': 'done'}) + + def action_cancel(self): + """ + 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() + + 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).""" + 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' + ), + ] + + # ======================================================================== + # 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 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'): + 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', + '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 new file mode 100644 index 0000000..609190a --- /dev/null +++ b/service_event_base/models/service_event.py @@ -0,0 +1,1363 @@ +# -*- coding: utf-8 -*- +""" +Service Event Model + +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) + +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 api, fields, models +from odoo.exceptions import ValidationError +from datetime import datetime +from odoo import fields as odoo_fields + + + +class ServiceEvent(models.Model): + """ + 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([]) + # ======================================================================== + + color = fields.Integer( + string='Color Index', + default=0, + help='Color index for kanban view (0-11)', + ) + # ======================================================================== + # COLOR FIELD - FOR KANBAN VIEW CUSTOMIZATION + # ======================================================================== + # WHY: Allows users to color-code events in kanban view + # HOW: Integer 0-11 maps to Odoo's predefined color palette + # WHEN: Useful for visual categorization (urgent=red, normal=blue, etc.) + # USAGE: Users can click color picker in kanban to set color + # ======================================================================== + + color = fields.Integer( + string='Color Index', + default=0, + help='Color index for Kanban and Calendar views (0-11)', + ) + # ======================================================================== + # COLOR FIELD - UI VISUALIZATION + # ======================================================================== + # WHY: Used by Kanban and Calendar views for visual categorization + # HOW: Integer 0-11 maps to predefined Odoo color palette + # WHEN: Helps users quickly identify events visually + # USAGE: Users can pick colors in Kanban view to mark priorities/types + # ======================================================================== + + # ======================================================================== + # PRICING FIELDS + # ======================================================================== + + price_unit = fields.Float( + string='Regular Price', + digits='Product Price', # Uses decimal precision from settings + default=0.0, + 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) + # 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 + + # ======================================================================== + # 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 + """ + + 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, + search='_search_registration_open', + 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 + + def _search_registration_open(self, operator, value): + """ + Enable filtering events where registration is open. + + Criteria: + - state = 'published' + - capacity > 0 β†’ available_seats > 0 + - start_datetime > now + """ + + if operator == '=' and value: + # Search for events with open registration + return [ + ('state', '=', 'published'), + ('start_datetime', '>', datetime.now()), + '|', + ('capacity', '=', 0), # Unlimited + ('available_seats', '>', 0), # Has seats + ] + else: + # Search for events with closed registration + return [ + '|', '|', + ('state', '!=', 'published'), + ('start_datetime', '<=', datetime.now()), + '&', + ('capacity', '>', 0), + ('available_seats', '<=', 0), + ] + + # ======================================================================== + # 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) + + # ======================================================================== + # 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 + + # ======================================================================== + # 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 + # ======================================================================== + + 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 + # ======================================================================== + + # ======================================================================== + # 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 + # ======================================================================== + # + # 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' + ), + ( + '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)', + '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 + + # ======================================================================== + # 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 + + # ======================================================================== + # 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/models/service_event_category.py b/service_event_base/models/service_event_category.py new file mode 100644 index 0000000..d895ab5 --- /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..a1c13e7 --- /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..25342b7 --- /dev/null +++ b/service_event_base/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_service_event_category_user,Service Event Category User,model_service_event_category,group_service_event_user,1,0,0,0 +access_service_event_category_manager,Service Event Category Manager,model_service_event_category,group_service_event_manager,1,1,1,1 +access_service_event_tag_user,Service Event Tag User,model_service_event_tag,group_service_event_user,1,0,0,0 +access_service_event_tag_manager,Service Event Tag Manager,model_service_event_tag,group_service_event_manager,1,1,1,1 +access_service_event_user,Service Event User,model_service_event,group_service_event_user,1,0,0,0 +access_service_event_manager,Service Event Manager,model_service_event,group_service_event_manager,1,1,1,1 +access_service_booking_user,Service Booking User,model_service_booking,group_service_event_user,1,1,1,0 +access_service_booking_manager,Service Booking Manager,model_service_booking,group_service_event_manager,1,1,1,1 + + + diff --git a/service_event_base/security/security.xml b/service_event_base/security/security.xml new file mode 100644 index 0000000..fd7c119 --- /dev/null +++ b/service_event_base/security/security.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/service_event_base/security/service_event_security.xml b/service_event_base/security/service_event_security.xml new file mode 100644 index 0000000..7a5f8a5 --- /dev/null +++ b/service_event_base/security/service_event_security.xml @@ -0,0 +1,322 @@ + + + + + + + + + + + + + + + Service Events + Manage service events and bookings + 20 + + + + + Service Events + Access to service event management + No access + 10 + + + + + + + + + Service User + + + + + + + + + + Service Manager + + + + + + + + + + + + + + + + Service Event: User - Published Only + + [('state', '=', 'published')] + + + + + + + + + + + Service Event: Manager - All Events + + [(1, '=', 1)] + + + + + + + + + + + + + + + Service Booking: User - Own Bookings Only + + [('create_uid', '=', user.id)] + + + + + + + + + + + Service Booking: Manager - All Bookings + + [(1, '=', 1)] + + + + + + + + + + + + + + + Service Event Category: All Users Read + + [(1, '=', 1)] + + + + + + + + + + + Service Event Category: Manager - Full Access + + [(1, '=', 1)] + + + + + + + + + + Service Event Tag: All Users Read + + [(1, '=', 1)] + + + + + + + + + + Service Event Tag: Manager - Full Access + + [(1, '=', 1)] + + + + + + + + diff --git a/service_event_base/views/menus.xml b/service_event_base/views/menus.xml new file mode 100644 index 0000000..dfc567a --- /dev/null +++ b/service_event_base/views/menus.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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..47cfbe7 --- /dev/null +++ b/service_event_base/views/service_booking_views.xml @@ -0,0 +1,289 @@ + + + + + + + + service.booking.list + service.booking + + + + + + + + + + + + + + + + + + service.booking.kanban + service.booking + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + + Waitlist # + +
+
+
+
+ + +
+
+
+
+
+
+ + + + service.booking.calendar + service.booking + + + + + + + + + + + + + service.booking.graph + service.booking + + + + + + + + + + + service.booking.pivot + service.booking + + + + + + + + + + + + service.booking.search + service.booking + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + service.booking.form + service.booking + +
+
+
+ + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + Service Bookings + service.booking + kanban,list,calendar,form,graph,pivot + + { + 'search_default_filter_confirmed': 1, + } + +

+ Create your first booking +

+

+ Bookings represent customer registrations for events.
+ Track payments, manage waitlists, and monitor attendance. +

+
+
+ +
+
+ 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..a2a568c --- /dev/null +++ b/service_event_base/views/service_event_views.xml @@ -0,0 +1,401 @@ + + + + + + + + service.event.list + service.event + + + + + + + + + + + + + + + + + + + + + + + service.event.kanban + service.event + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + +
+
+ +
+
+
+ + + + + +
+
+ + +
+ +
+
+ + + + β†’ + +
+
+ + +
+
+ + +
+
+ + + / + + + (Unlimited) + +
+
+ + + +
+
+
+
+ +
+
+
+
+
+ + +
+
+ Revenue:
+ +
+
+ + Open + + + Closed + +
+
+
+ + + +
+
+
+
+
+ + + + service.event.calendar + service.event + + + + + + + + + + + + + + + service.event.graph + service.event + + + + + + + + + + + + + service.event.pivot + service.event + + + + + + + + + + + + + + service.event.search + service.event + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + service.event.form + service.event + +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + Service Events + service.event + kanban,list,calendar,form,graph,pivot + + { + 'search_default_filter_upcoming': 1, + } + +

+ Create your first service event +

+

+ Events can be workshops, conferences, webinars, or consulting sessions.
+ Track bookings, manage capacity, and monitor revenue all in one place. +

+
+
+ +
+
+ diff --git a/service_event_website/__init__.py b/service_event_website/__init__.py new file mode 100644 index 0000000..39ebd8e --- /dev/null +++ b/service_event_website/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +""" +Service Event Website Module + +This module extends service_event_base with website/portal functionality. +""" + +from . import controllers diff --git a/service_event_website/__manifest__.py b/service_event_website/__manifest__.py new file mode 100644 index 0000000..4fba2f3 --- /dev/null +++ b/service_event_website/__manifest__.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Service Event Website Module Manifest +# +# ARCHITECTURE: +# This is the WEBSITE module, separated from service_event_base. +# - service_event_base: Core business logic (models, security, ORM) +# - service_event_website: Web pages, controllers, portal (THIS MODULE) +# +# WHY SEPARATE: +# - Clean separation of concerns (backend vs frontend) +# - service_event_base can be used without website (API, mobile, etc.) +# - Easier to maintain and test +# - Follows Odoo best practices (sale vs sale_management pattern) +# +# DEPENDENCIES: +# - service_event_base: Our core business logic +# - website: Odoo's website builder framework +# - portal: Customer portal functionality + +{ + 'name': 'Service Event Website', + 'version': '19.0.1.0.0', + 'category': 'Website/Website', + 'summary': 'Website pages and controllers for service event booking system', + 'description': """ + Service Event Website Module + ============================= + + Extends service_event_base with website functionality. + + Features: + --------- + * Public event listing page + * Event detail pages with booking form + * Online booking submission + * Customer portal for booking management + * SEO-optimized (sitemap, meta tags) + * Responsive design (Bootstrap 5) + + Controllers: + ------------ + * /events - Event listing (GET, auth=public) + * /events/ - Event detail (GET, auth=public) + * /events/book - Booking submission (POST, auth=user, CSRF protected) + * Sitemap integration for search engines + + Technical Highlights: + --------------------- + * Demonstrates HTTP routing (GET/POST) + * Model converters for clean URLs + * CSRF protection on forms + * QWeb template rendering + * Bootstrap 5 responsive design + * Portal integration + """, + + 'author': 'Odoo Full-Stack Development Team', + 'website': 'https://www.example.com', + 'license': 'LGPL-3', + + # Dependencies + 'depends': [ + 'service_event_base', # Our core business logic + 'website', # Odoo website builder + 'portal', # Customer portal + ], + + # Data files + 'data': [ + # Security + 'security/portal_security.xml', + # Templates + 'views/website_templates.xml', + 'views/portal_templates.xml', + ], + + # Assets (JS/CSS) + 'assets': { + 'web.assets_frontend': [ + # Future: custom CSS/JS for event pages + ], + }, + + # Module flags + 'installable': True, + 'application': False, # This is an extension, not standalone + 'auto_install': False, +} diff --git a/service_event_website/controllers/__init__.py b/service_event_website/controllers/__init__.py new file mode 100644 index 0000000..65a8c12 --- /dev/null +++ b/service_event_website/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import main diff --git a/service_event_website/controllers/main.py b/service_event_website/controllers/main.py new file mode 100644 index 0000000..9ad2b24 --- /dev/null +++ b/service_event_website/controllers/main.py @@ -0,0 +1,1159 @@ +# -*- coding: utf-8 -*- +""" +Service Event Website Controllers + +This module demonstrates Odoo's HTTP routing system for website pages. + +ODOO HTTP ROUTING CONCEPTS: +=========================== + +1. ROUTE DECORATOR (@http.route) + - Defines URL patterns and HTTP methods + - Configures authentication and website integration + - Maps URLs to Python methods + +2. HTTP METHODS: + - GET: Retrieve/display data (idempotent, cacheable) + - POST: Submit/create data (non-idempotent, not cacheable) + +3. AUTH TYPES: + - auth='public': Anyone can access (even non-logged-in visitors) + - auth='user': Requires logged-in user (portal or internal) + - auth='admin': Requires internal user (backend access) + +4. WEBSITE ATTRIBUTE: + - website=True: Enables website context (theme, menu, snippets) + - website=False: Raw HTTP response without website wrapper + +5. CSRF PROTECTION: + - type='http': CSRF token required for POST (automatic in forms) + - type='json': CSRF token in JSON-RPC header + - Prevents cross-site request forgery attacks + +6. MODEL CONVERTERS: + - in route + - Automatically fetches record, returns 404 if not found + - Cleaner than manual record.browse() + +CONTROLLER METHODS: +=================== + +This controller provides 4 main routes: + +1. /events + - Lists all published events + - GET, auth=public, website=True + - Accessible to everyone + +2. /events/ + - Shows single event detail + - GET, auth=public, website=True + - Uses model converter for clean URLs + +3. /events/book + - Submits booking form + - POST, auth=user, website=True + - CSRF protected, requires login + +4. Sitemap + - Generates XML sitemap for SEO + - Lists all public event URLs + - Helps search engines index pages +""" + +from odoo import http +from odoo.http import request +from odoo.exceptions import ValidationError, AccessError, MissingError +from odoo.tools import consteq + + +# ======================================================================== +# SITEMAP GENERATOR FUNCTION (MODULE-LEVEL) +# ======================================================================== + +def sitemap_events(env, rule, qs): + """ + Generate sitemap entries for all published events. + + SITEMAP PURPOSE: + - Helps search engines (Google, Bing) discover pages + - Lists all public URLs with metadata (priority, frequency) + - Automatically called by Odoo's sitemap generator + + HOW IT WORKS: + 1. Route /events has sitemap=sitemap_events parameter + 2. When generating sitemap.xml, Odoo calls this function + 3. Function yields/returns URLs to include + + PARAMETERS: + env: Odoo environment (like request.env) + rule: Routing rule being processed + qs: Query string (not used here) + + YIELDS: + Dictionaries with: + - loc: URL path + - priority: 0.0-1.0 (higher = more important) + - changefreq: How often page changes + + REGISTRATION: + This function is called because: + 1. @http.route('/events', sitemap=sitemap_events) + 2. Odoo passes this function reference to the route + 3. When building sitemap, Odoo calls it automatically + """ + + # Get all published events + events = env['service.event'].sudo().search([ + ('state', '=', 'published'), + ('active', '=', True), + ]) + + # Yield main events listing page + yield { + 'loc': '/events', + 'priority': 0.8, + 'changefreq': 'daily', + } + + # Yield individual event pages + for event in events: + yield { + 'loc': '/events/%s' % event.id, + 'priority': 0.6, + 'changefreq': 'weekly', + } + + +class ServiceEventWebsite(http.Controller): + """ + Website controller for Service Event pages. + + Handles public event browsing and booking submissions. + """ + + # ======================================================================== + # EVENT LISTING PAGE (GET) + # ======================================================================== + + @http.route('/events', type='http', auth='public', website=True, sitemap=sitemap_events) + def events_list(self, **kwargs): + """ + Display list of all published events. + + ROUTE ATTRIBUTES: + - type='http': HTTP request (not JSON-RPC) + - auth='public': No login required + - website=True: Render with website theme/layout + - sitemap=True: Include this URL in sitemap.xml + + KWARGS: + - **kwargs: Captures URL query parameters + - Example: /events?category=1&search=python + - Access via kwargs.get('category'), kwargs.get('search') + + RETURNS: + - Rendered QWeb template with event data + + SEARCH/FILTER LOGIC: + - Only shows published events (state='published') + - Future: Add category filter, search, pagination + + USAGE: + GET /events β†’ Shows all published events + GET /events?category=1 β†’ Filter by category (future) + """ + + # Get published events only + events = request.env['service.event'].sudo().search([ + ('state', '=', 'published'), + ('active', '=', True), + ], order='start_datetime asc') + + # Get all categories for filter menu (future use) + categories = request.env['service.event.category'].sudo().search([]) + + # Prepare template values + values = { + 'events': events, + 'categories': categories, + 'page_name': 'events', + } + + # Render template + return request.render('service_event_website.events_listing', values) + + # ======================================================================== + # EVENT LISTING PAGE - EXPLANATION + # ======================================================================== + # + # WHY sudo()? + # Public users don't have read access to service.event by default. + # sudo() bypasses access rights to show published events. + # SECURITY: We still filter by state='published' to avoid leaking drafts. + # + # WHY search() not browse()? + # search() finds records matching domain + # browse() requires known IDs + # We want to LIST all matching events, so search() is correct + # + # request.render(): + # - Takes template XML ID + # - Takes dictionary of values (available in template as variables) + # - Returns rendered HTML response + # + # ALTERNATIVE PATTERNS: + # - Could use website.render() (same thing) + # - Could return request.redirect() for redirects + # - Could return werkzeug.wrappers.Response() for custom responses + # ======================================================================== + + # ======================================================================== + # EVENT DETAIL PAGE (GET, MODEL CONVERTER) + # ======================================================================== + + @http.route('/events/', type='http', auth='public', website=True, sitemap=True) + def event_detail(self, event, **kwargs): + """ + Display single event detail page. + + MODEL CONVERTER: + - in route + - Odoo automatically fetches the record + - If record doesn't exist β†’ 404 error + - If record exists β†’ passed as 'event' parameter + + ROUTE EXAMPLES: + /events/1 β†’ Fetches event with ID=1 + /events/python-workshop-25 β†’ Uses slug if configured + /events/999 β†’ Returns 404 if doesn't exist + + PARAMETERS: + event: service.event record (auto-fetched by model converter) + **kwargs: URL query parameters (not used here) + + RETURNS: + Rendered event detail template + + ACCESS CONTROL: + - auth='public' allows anyone to view + - We check event.state == 'published' to prevent draft leaks + - Redirect to 404 if event is not published + """ + + # Security check: Only show published events + if event.state != 'published': + return request.redirect('/events') + + # Check if user is logged in (for booking button) + is_logged_in = not request.env.user._is_public() + + # Get related bookings if user is logged in + user_bookings = [] + if is_logged_in: + user_bookings = request.env['service.booking'].search([ + ('event_id', '=', event.id), + ('partner_id', '=', request.env.user.partner_id.id), + ]) + + # Prepare template values + values = { + 'event': event, + 'is_logged_in': is_logged_in, + 'user_bookings': user_bookings, + 'page_name': 'event_detail', + } + + return request.render('service_event_website.event_detail', values) + + # ======================================================================== + # MODEL CONVERTER DEEP DIVE + # ======================================================================== + # + # WHAT IT DOES: + # 1. Extracts ID from URL (/events/5 β†’ ID=5) + # 2. Calls service.event.browse(5) + # 3. Checks if record exists + # 4. Passes record to method or returns 404 + # + # BENEFITS: + # - Cleaner URLs (/events/5 vs /events?id=5) + # - Automatic 404 handling + # - Less boilerplate code + # - Type safety (always get a record) + # + # SLUG SUPPORT: + # - Can use /events/python-workshop-25 instead of /events/25 + # - Requires website_slug field on model + # - Better for SEO (keywords in URL) + # + # ALTERNATIVE WITHOUT CONVERTER: + # @http.route('/events/') + # def event_detail(self, event_id, **kwargs): + # event = request.env['service.event'].browse(event_id) + # if not event.exists(): + # return request.not_found() + # # ... rest of code + # + # WHY _is_public()? + # - request.env.user always exists (public user if not logged in) + # - _is_public() returns True for anonymous visitors + # - _is_public() returns False for logged-in users + # ======================================================================== + + # ======================================================================== + # BOOKING FORM SUBMISSION (POST, CSRF PROTECTED) + # ======================================================================== + + @http.route('/events/book', type='http', auth='user', methods=['POST'], website=True, csrf=True) + def event_book(self, **post): + """ + Handle booking form submission. + + ROUTE ATTRIBUTES: + - type='http': HTTP POST request + - auth='user': Requires logged-in user (not public) + - methods=['POST']: Only accepts POST requests + - website=True: Maintain website context + - csrf=True: Require CSRF token (default for POST) + + CSRF PROTECTION: + - Prevents cross-site request forgery + - Form must include CSRF token: + + - If token missing/invalid β†’ 400 Bad Request error + + POST DATA: + - **post: Dictionary of form fields + - Example: {'event_id': '5', 'partner_id': '10', 'quantity': '2'} + - Access via post.get('event_id') + + RETURNS: + - Redirect to confirmation page on success + - Re-render form with errors on failure + + ERROR HANDLING: + - Try/except for validation errors + - Show user-friendly error messages + - Log errors for debugging + """ + + # Validate required fields + event_id = post.get('event_id') + if not event_id: + return request.redirect('/events') + + try: + # Get event and validate + event = request.env['service.event'].sudo().browse(int(event_id)) + + if not event.exists() or event.state != 'published': + raise ValidationError("Event not available for booking") + + # Check capacity + if event.capacity > 0 and event.available_seats <= 0: + raise ValidationError("Event is fully booked") + + # Create booking + from datetime import datetime + booking = request.env['service.booking'].create({ + 'event_id': event.id, + 'partner_id': request.env.user.partner_id.id, + 'booking_date': datetime.now(), + 'state': 'draft', + }) + + # Confirm booking + booking.action_confirm() + + # Redirect to confirmation page + return request.redirect('/events/booking/%s/confirm' % booking.id) + + except ValidationError as e: + # Show error message to user + return request.render('service_event_website.booking_error', { + 'error_message': str(e), + 'event': event if event.exists() else None, + }) + except Exception as e: + # Log unexpected errors using Python logging + import logging + _logger = logging.getLogger(__name__) + _logger.error('Booking error: %s', e, exc_info=True) + + return request.render('service_event_website.booking_error', { + 'error_message': 'An unexpected error occurred. Please try again.', + }) + + # ======================================================================== + # POST METHOD EXPLANATION + # ======================================================================== + # + # WHY POST not GET? + # - POST is for actions that change data (create booking) + # - GET is for retrieving data (view events) + # - POST requests are not cached by browsers + # - POST prevents accidental double-submission via browser refresh + # + # WHY auth='user'? + # - Bookings require customer information + # - Must be logged in to book (portal or internal user) + # - Public users redirected to login page + # + # request.env.user: + # - Always exists (public user if not logged in) + # - Has partner_id (res.partner record for user) + # - For public users: partner_id is "Public User" partner + # + # CSRF TOKEN REQUIREMENT: + # - All POST forms MUST include: + # + # - Without it: 400 Bad Request error + # - Protects against malicious websites submitting forms + # + # ERROR HANDLING PATTERN: + # 1. Try to process booking + # 2. Catch ValidationError (business logic errors) β†’ Show to user + # 3. Catch Exception (unexpected errors) β†’ Log and show generic message + # 4. Always provide user feedback (success or error) + # ======================================================================== + + # ======================================================================== + # BOOKING CONFIRMATION PAGE (GET) + # ======================================================================== + + @http.route('/events/booking//confirm', type='http', auth='user', website=True) + def booking_confirmation(self, booking_id, **kwargs): + """ + Display booking confirmation page. + + ROUTE PATTERN: + - : URL parameter (integer only) + - Example: /events/booking/42/confirm β†’ booking_id=42 + + ACCESS CONTROL: + - auth='user': Requires login + - Check booking belongs to current user + - Return 403 if user doesn't own the booking + + RETURNS: + Confirmation template with booking details + """ + + # Get booking + booking = request.env['service.booking'].browse(booking_id) + + # Security check: Only show user's own bookings + if not booking.exists() or booking.partner_id != request.env.user.partner_id: + return request.redirect('/events') + + values = { + 'booking': booking, + 'event': booking.event_id, + 'page_name': 'booking_confirmation', + } + + return request.render('service_event_website.booking_confirmation', values) + + +# ======================================================================== +# SITEMAP EXPLANATION +# ======================================================================== +# +# WHAT IS SITEMAP.XML? +# - XML file listing all public pages on website +# - Submitted to search engines for indexing +# - Accessible at: https://yoursite.com/sitemap.xml +# +# HOW IT WORKS IN ODOO: +# 1. Define a generator function (yields dictionaries) +# 2. Pass function to @http.route(sitemap=function_name) +# 3. When user visits /sitemap.xml, Odoo calls all sitemap functions +# 4. Results combined into one XML document +# +# PRIORITY VALUES: +# - 1.0: Most important pages (homepage) +# - 0.8: Important pages (main category pages) +# - 0.6: Regular pages (individual products/events) +# - 0.4: Less important pages (old blog posts) +# +# CHANGEFREQ VALUES: +# - always: Page changes with every access +# - hourly: Updated every hour +# - daily: Updated daily +# - weekly: Updated weekly +# - monthly: Updated monthly +# - yearly: Rarely updated +# - never: Archived content +# +# SEO BENEFITS: +# - Faster discovery of new pages +# - Better crawling efficiency +# - Improved search rankings +# - Shows when pages were last modified +# +# EXAMPLE OUTPUT (sitemap.xml): +# +# +# +# https://example.com/events +# 0.8 +# daily +# +# +# https://example.com/events/1 +# 0.6 +# weekly +# +# +# ======================================================================== + + +# ############################################################################ +# JSON API CONTROLLERS +# ############################################################################ + +class ServiceEventAPI(http.Controller): + """ + JSON API endpoints for dynamic event data. + + Provides RESTful JSON endpoints for: + - Fetching event pricing + - Checking availability + - Validating booking data + + All endpoints return standardized JSON responses. + """ + + # ======================================================================== + # JSON RESPONSE HELPERS + # ======================================================================== + + def _json_response(self, success=True, data=None, error=None, message=None): + """ + Create standardized JSON response structure. + + STANDARD FORMAT: + { + "success": true/false, + "data": {...}, // On success + "error": "error_code", // On failure + "message": "Human-readable message" + } + + WHY STANDARDIZED FORMAT: + - Consistent API for frontend developers + - Easy error handling on client side + - Clear success/failure indication + - Machine-readable errors + human messages + + USAGE: + return self._json_response(success=True, data={'price': 99.99}) + return self._json_response(success=False, error='not_found', message='Event not found') + """ + response = { + 'success': success, + } + + if success: + response['data'] = data or {} + if message: + response['message'] = message + else: + response['error'] = error or 'unknown_error' + response['message'] = message or 'An error occurred' + + return response + + # ======================================================================== + # FETCH EVENT PRICE (JSON-RPC) + # ======================================================================== + + @http.route('/api/event/price', type='jsonrpc', auth='public', methods=['POST'], cors='*') + def get_event_price(self, event_id, quantity=1, **kwargs): + """ + Fetch event pricing with quantity calculation. + + ROUTE ATTRIBUTES: + - type='json': JSON-RPC endpoint (not regular HTTP) + - auth='public': No login required + - methods=['POST']: POST only (JSON-RPC always POST) + - cors='*': Allow cross-origin requests from any domain + + JSON-RPC vs HTTP: + - HTTP: Returns HTML/text, uses query params + - JSON-RPC: Returns JSON, uses request body + - JSON-RPC: Automatic JSON parsing + - JSON-RPC: No need for request.jsonrequest + + REQUEST BODY: + { + "jsonrpc": "2.0", + "method": "call", + "params": { + "event_id": 5, + "quantity": 2 + } + } + + RESPONSE: + { + "jsonrpc": "2.0", + "result": { + "success": true, + "data": { + "event_id": 5, + "event_name": "Python Workshop", + "price_unit": 299.99, + "quantity": 2, + "subtotal": 599.98, + "currency": "USD" + } + } + } + + CORS EXPLAINED: + - cors='*': Allows requests from any domain + - cors='https://example.com': Only from specific domain + - cors=None: Same-origin only (default) + - Needed for external apps/websites to call API + """ + try: + # Validate input + if not event_id: + return self._json_response( + success=False, + error='missing_event_id', + message='Event ID is required' + ) + + # Get event + event = request.env['service.event'].sudo().browse(int(event_id)) + + if not event.exists(): + return self._json_response( + success=False, + error='event_not_found', + message=f'Event with ID {event_id} not found' + ) + + # Check if published + if event.state != 'published': + return self._json_response( + success=False, + error='event_not_available', + message='Event is not available for booking' + ) + + # Calculate pricing + quantity = int(quantity) if quantity else 1 + price_unit = event.final_price # Uses early bird pricing if applicable + subtotal = price_unit * quantity + + # Return pricing data + return self._json_response( + success=True, + data={ + 'event_id': event.id, + 'event_name': event.name, + 'price_unit': price_unit, + 'early_bird_price': event.early_bird_price, + 'regular_price': event.price_unit, + 'quantity': quantity, + 'subtotal': subtotal, + 'currency': event.currency_id.name, + 'currency_symbol': event.currency_id.symbol, + } + ) + + except ValueError as e: + return self._json_response( + success=False, + error='invalid_input', + message=f'Invalid input: {str(e)}' + ) + except Exception as e: + return self._json_response( + success=False, + error='server_error', + message='An unexpected error occurred' + ) + + # ======================================================================== + # JSON-RPC EXPLANATION + # ======================================================================== + # + # WHAT IS JSON-RPC? + # - Remote Procedure Call protocol using JSON + # - Client sends function call as JSON + # - Server executes function, returns result as JSON + # - Standard protocol (version 2.0) + # + # WHY type='json' not type='http'? + # - Automatic JSON parsing (no manual json.loads()) + # - Parameters passed directly to function + # - Return value auto-converted to JSON + # - Cleaner API design + # + # HOW TO CALL FROM JAVASCRIPT: + # fetch('/api/event/price', { + # method: 'POST', + # headers: {'Content-Type': 'application/json'}, + # body: JSON.stringify({ + # jsonrpc: '2.0', + # method: 'call', + # params: {event_id: 5, quantity: 2} + # }) + # }) + # + # HOW TO CALL FROM PYTHON: + # import requests + # response = requests.post('http://localhost:8069/api/event/price', json={ + # 'jsonrpc': '2.0', + # 'method': 'call', + # 'params': {'event_id': 5, 'quantity': 2} + # }) + # + # AUTHENTICATION: + # - auth='public': No session/token needed + # - auth='user': Session cookie required + # - Can also use API keys (custom implementation) + # ======================================================================== + + # ======================================================================== + # CHECK AVAILABILITY (JSON) + # ======================================================================== + + @http.route('/api/event/availability', type='jsonrpc', auth='public', methods=['POST'], cors='*') + def check_availability(self, event_id, quantity=1, **kwargs): + """ + Check real-time seat availability for an event. + + USE CASE: + - Frontend shows "X seats left" dynamically + - Validate before submitting booking form + - Prevent overbooking + + REQUEST: + POST /api/event/availability + { + "jsonrpc": "2.0", + "params": { + "event_id": 5, + "quantity": 2 + } + } + + RESPONSE: + { + "success": true, + "data": { + "available": true, + "capacity": 50, + "booked": 30, + "available_seats": 20, + "requested_quantity": 2, + "can_book": true, + "registration_open": true + } + } + """ + try: + # Get event + event = request.env['service.event'].sudo().browse(int(event_id)) + + if not event.exists(): + return self._json_response( + success=False, + error='event_not_found', + message='Event not found' + ) + + # Check registration status + registration_open = event.registration_open + + # Calculate availability + quantity = int(quantity) if quantity else 1 + unlimited_capacity = event.capacity == 0 + available_seats = event.available_seats if not unlimited_capacity else 999999 + can_book = (unlimited_capacity or available_seats >= quantity) and registration_open + + # Return availability data + return self._json_response( + success=True, + data={ + 'event_id': event.id, + 'event_name': event.name, + 'available': registration_open, + 'capacity': event.capacity, + 'booked': event.booking_count_confirmed, + 'available_seats': available_seats if not unlimited_capacity else None, + 'unlimited_capacity': unlimited_capacity, + 'requested_quantity': quantity, + 'can_book': can_book, + 'registration_open': registration_open, + 'fill_rate': event.fill_rate, + 'start_datetime': event.start_datetime.isoformat() if event.start_datetime else None, + } + ) + + except Exception as e: + return self._json_response( + success=False, + error='server_error', + message=str(e) + ) + + # ======================================================================== + # VALIDATE BOOKING DATA (JSON) + # ======================================================================== + + @http.route('/api/event/validate', type='jsonrpc', auth='public', methods=['POST'], cors='*') + def validate_booking(self, event_id, partner_data=None, **kwargs): + """ + Validate booking data before submission. + + USE CASE: + - Frontend validation before form submit + - Check all requirements met + - Provide helpful error messages + + VALIDATES: + - Event exists and is published + - Seats available + - Partner data complete (if provided) + - Registration still open + + REQUEST: + { + "event_id": 5, + "partner_data": { + "name": "John Doe", + "email": "john@example.com" + } + } + + RESPONSE: + { + "success": true, + "data": { + "valid": true, + "errors": [], + "warnings": ["Only 2 seats remaining"] + } + } + """ + try: + errors = [] + warnings = [] + + # Validate event + event = request.env['service.event'].sudo().browse(int(event_id)) + + if not event.exists(): + errors.append('Event not found') + return self._json_response( + success=True, # Validation completed + data={'valid': False, 'errors': errors, 'warnings': warnings} + ) + + # Check published + if event.state != 'published': + errors.append('Event is not available for booking') + + # Check registration open + if not event.registration_open: + errors.append('Registration is closed for this event') + + # Check availability + if event.capacity > 0: + if event.available_seats <= 0: + errors.append('Event is fully booked') + elif event.available_seats <= 5: + warnings.append(f'Only {event.available_seats} seats remaining') + + # Validate partner data if provided + if partner_data: + if not partner_data.get('name'): + errors.append('Name is required') + if not partner_data.get('email'): + errors.append('Email is required') + elif '@' not in partner_data.get('email', ''): + errors.append('Invalid email format') + + # Return validation result + return self._json_response( + success=True, + data={ + 'valid': len(errors) == 0, + 'errors': errors, + 'warnings': warnings, + 'event_name': event.name, + 'event_date': event.start_datetime.isoformat() if event.start_datetime else None, + } + ) + + except Exception as e: + return self._json_response( + success=False, + error='validation_error', + message=str(e) + ) + + # ======================================================================== + # CORS EXPLANATION + # ======================================================================== + # + # WHAT IS CORS? + # - Cross-Origin Resource Sharing + # - Browser security feature + # - Prevents malicious websites from stealing data + # - Controls which domains can access your API + # + # WHY cors='*'? + # - Allows API calls from ANY domain + # - Useful for public APIs + # - Mobile apps, external websites can use API + # - WARNING: Less secure for sensitive operations + # + # CORS SECURITY LEVELS: + # cors='*' β†’ Allow all domains + # cors='https://example.com' β†’ Only this domain + # cors=None β†’ Same origin only (default) + # + # WHEN TO USE cors='*': + # βœ“ Public read-only APIs + # βœ“ Event listing, pricing info + # βœ— User authentication + # βœ— Payment processing + # βœ— Sensitive data access + # + # HOW CORS WORKS: + # 1. Browser sends OPTIONS request (preflight) + # 2. Server responds with allowed origins + # 3. Browser allows/blocks actual request + # 4. Response includes Access-Control-Allow-Origin header + # + # HEADERS ADDED BY cors='*': + # Access-Control-Allow-Origin: * + # Access-Control-Allow-Methods: POST, GET, OPTIONS + # Access-Control-Allow-Headers: Content-Type + # ======================================================================== + + +# ============================================================================ +# PORTAL CONTROLLER - Customer Self-Service +# ============================================================================ +class PortalBooking(http.Controller): + """ + Customer Portal Controller for Service Event Bookings + + PORTAL PATTERN: + =============== + Odoo's portal module provides customer self-service features. + Portal users can view and manage their own records. + + KEY CONCEPTS: + ------------- + 1. AUTHENTICATION: + - auth='user': Requires login (portal or internal user) + - Portal users have limited access (only their records) + - Internal users have full access + + 2. ACCESS CONTROL: + - Record rules restrict portal users to their own records + - Sudo() needed carefully to access related records + - Always check ownership before allowing actions + + 3. PAGINATION: + - Use portal.pager utility for consistent pagination + - Standard pattern: /my/records/page/2 + - Support sorting and filtering + + 4. URL PATTERNS: + - /my/bookings - List view + - /my/booking/ - Detail view + - Follows Odoo portal conventions + + SECURITY NOTES: + --------------- + - Never use sudo() without checking record ownership + - Validate all user inputs + - Use record rules, not just controller checks + - Portal users can only access published events + """ + + def _prepare_portal_layout_values(self): + """Add booking count to portal home counters""" + values = {} + BookingModel = request.env['service.booking'] + booking_count = BookingModel.search_count([]) + values['booking_count'] = booking_count + return values + + @http.route(['/my/bookings', '/my/bookings/page/'], type='http', auth='user', website=True) + def portal_my_bookings(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw): + """ + Customer's booking listing page with pagination and filters + + ROUTE PATTERN: + ------------- + /my/bookings - First page + /my/bookings/page/2 - Page 2 + + PARAMETERS: + ----------- + - page: Page number (default 1) + - date_begin: Filter bookings after this date + - date_end: Filter bookings before this date + - sortby: Sort order (date, name, state) + - filterby: Filter by status (all, draft, confirmed, attended, cancelled) + + PAGINATION: + ----------- + - 10 bookings per page + - Uses portal.pager utility + - SEO-friendly URLs + + SORTING OPTIONS: + --------------- + - date: Booking date (newest first) + - name: Event name (A-Z) + - state: Status (confirmed first) + + FILTERING OPTIONS: + ----------------- + - all: All bookings + - draft: Draft bookings + - confirmed: Confirmed bookings + - attended: Attended events + - cancelled: Cancelled bookings + """ + BookingModel = request.env['service.booking'] + + # Sorting options + searchbar_sortings = { + 'date': {'label': 'Newest', 'order': 'booking_date desc'}, + 'name': {'label': 'Event Name', 'order': 'event_id'}, + 'state': {'label': 'Status', 'order': 'state'}, + } + + # Filter options + searchbar_filters = { + 'all': {'label': 'All', 'domain': []}, + 'draft': {'label': 'Draft', 'domain': [('state', '=', 'draft')]}, + 'confirmed': {'label': 'Confirmed', 'domain': [('state', '=', 'confirmed')]}, + 'attended': {'label': 'Attended', 'domain': [('state', '=', 'attended')]}, + 'cancelled': {'label': 'Cancelled', 'domain': [('state', '=', 'cancelled')]}, + } + + # Default sort and filter + if not sortby: + sortby = 'date' + order = searchbar_sortings[sortby]['order'] + + if not filterby: + filterby = 'all' + domain = searchbar_filters[filterby]['domain'] + + # Date range filter + if date_begin and date_end: + domain += [('booking_date', '>=', date_begin), ('booking_date', '<=', date_end)] + + # Count bookings for pagination + booking_count = BookingModel.search_count(domain) + + # Pagination + from odoo.addons.portal.controllers.portal import pager as portal_pager + pager = portal_pager( + url='/my/bookings', + total=booking_count, + page=page, + step=10, # 10 bookings per page + url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby}, + ) + + # Fetch bookings + bookings = BookingModel.search(domain, order=order, limit=10, offset=pager['offset']) + + values = { + 'bookings': bookings, + 'page_name': 'booking', + 'pager': pager, + 'default_url': '/my/bookings', + 'searchbar_sortings': searchbar_sortings, + 'searchbar_filters': searchbar_filters, + 'sortby': sortby, + 'filterby': filterby, + 'date_begin': date_begin, + 'date_end': date_end, + } + return request.render('service_event_website.portal_my_bookings', values) + + @http.route(['/my/booking/'], type='http', auth='user', website=True) + def portal_my_booking(self, booking_id, access_token=None, **kw): + """ + Booking detail page - shows full booking information + + ROUTE PATTERN: + ------------- + /my/booking/1 - Booking ID 1 + + SECURITY: + --------- + - Portal users can only view their own bookings + - Access token support for sharing (optional) + - Record rules handle access control + + FEATURES: + --------- + - View booking details + - View event information + - Download booking confirmation (future) + - Cancel booking (if allowed) + + ACCESS TOKEN: + ------------ + Optional parameter for sharing booking details + without login (e.g., email links) + """ + try: + booking_sudo = self._document_check_access('service.booking', booking_id, access_token) + except (AccessError, MissingError): + return request.redirect('/my') + + # Get related records + event = booking_sudo.event_id + + values = { + 'booking': booking_sudo, + 'event': event, + 'page_name': 'booking', + } + return request.render('service_event_website.portal_my_booking', values) + + def _document_check_access(self, model_name, document_id, access_token=None): + """ + Check if current user can access document + + SECURITY PATTERN: + ---------------- + 1. Try to access with current user (portal rules apply) + 2. If access_token provided, verify and grant access + 3. Raise AccessError if not allowed + + This prevents users from accessing other users' records + """ + document = request.env[model_name].browse([document_id]) + document_sudo = document.sudo() + + # Check if user has access (portal record rules) + try: + document.check_access_rights('read') + document.check_access_rule('read') + except AccessError: + # If no access token, deny access + if not access_token or not document_sudo.access_token or not consteq(document_sudo.access_token, access_token): + raise + + return document_sudo diff --git a/service_event_website/security/portal_security.xml b/service_event_website/security/portal_security.xml new file mode 100644 index 0000000..4290273 --- /dev/null +++ b/service_event_website/security/portal_security.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + Portal User: Own Bookings Only + + [('partner_id', '=', user.partner_id.id)] + + + + + + + + + + + + + + + + Portal User: Published Events Only + + [('state', '=', 'published')] + + + + + + + + + + + + + + + + Public User: Browse Published Events + + [('state', '=', 'published')] + + + + + + + + + + + diff --git a/service_event_website/views/portal_templates.xml b/service_event_website/views/portal_templates.xml new file mode 100644 index 0000000..ff4ecb4 --- /dev/null +++ b/service_event_website/views/portal_templates.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + diff --git a/service_event_website/views/website_templates.xml b/service_event_website/views/website_templates.xml new file mode 100644 index 0000000..76bbff0 --- /dev/null +++ b/service_event_website/views/website_templates.xml @@ -0,0 +1,751 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test_json_api.py b/test_json_api.py new file mode 100755 index 0000000..660a108 --- /dev/null +++ b/test_json_api.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Test JSON-RPC API endpoints for service_event_website module""" +import json +import requests + +BASE_URL = "http://localhost:8070" +HEADERS = {"Content-Type": "application/json"} + +def json_rpc_call(endpoint, params): + """Make a JSON-RPC 2.0 call""" + payload = { + "jsonrpc": "2.0", + "method": "call", + "params": params, + "id": 1 + } + response = requests.post(f"{BASE_URL}{endpoint}", json=payload, headers=HEADERS) + return response.json() + +def test_price_api(): + """Test /api/event/price endpoint""" + print("\n=== Testing /api/event/price ===") + result = json_rpc_call("/api/event/price", {"event_id": 1, "quantity": 2}) + print(f"Response: {json.dumps(result, indent=2)}") + return result + +def test_availability_api(): + """Test /api/event/availability endpoint""" + print("\n=== Testing /api/event/availability ===") + result = json_rpc_call("/api/event/availability", {"event_id": 1, "quantity": 2}) + print(f"Response: {json.dumps(result, indent=2)}") + return result + +def test_validate_api(): + """Test /api/event/validate endpoint""" + print("\n=== Testing /api/event/validate ===") + partner_data = { + "name": "Test Customer", + "email": "test@example.com", + "phone": "+1234567890" + } + result = json_rpc_call("/api/event/validate", {"event_id": 1, "partner_data": partner_data}) + print(f"Response: {json.dumps(result, indent=2)}") + return result + +if __name__ == "__main__": + print("Testing Service Event Website JSON-RPC APIs") + print("=" * 50) + + try: + test_price_api() + test_availability_api() + test_validate_api() + print("\n" + "=" * 50) + print("βœ… All API tests completed!") + except Exception as e: + print(f"\n❌ Error: {e}")