diff --git a/awesome_estate/__init__.py b/awesome_estate/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/awesome_estate/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/awesome_estate/__manifest__.py b/awesome_estate/__manifest__.py new file mode 100644 index 00000000000..b839d1e18a0 --- /dev/null +++ b/awesome_estate/__manifest__.py @@ -0,0 +1,28 @@ +{ + 'name': 'Awesome Estate', + 'version': '0.1', + 'category': 'Tutorials', + 'summary': 'Real Estate Advertisement tutorial module', + 'description': """ +Real Estate Advertisement Management Module + +Manage property listings, offers, property types, and tags. +Supports the full property lifecycle: new, offer received, offer accepted, sold, and canceled. + """, + 'author': 'Patja', + 'license': 'LGPL-3', + 'depends': ['base'], + 'data': [ + 'security/ir.model.access.csv', + 'views/awesome_estate_property_views.xml', + 'views/awesome_estate_property_maintenance_views.xml', + 'views/awesome_estate_property_maintenance_subtask_views.xml', + 'views/awesome_estate_property_offer_views.xml', + 'views/awesome_estate_property_type_views.xml', + 'views/awesome_estate_property_tag_views.xml', + 'views/awesome_estate_res_users_views.xml', + 'views/awesome_estate_menus.xml', + ], + 'application': True, + 'installable': True, +} diff --git a/awesome_estate/docs/chapter_2.md b/awesome_estate/docs/chapter_2.md new file mode 100644 index 00000000000..622ee340b0c --- /dev/null +++ b/awesome_estate/docs/chapter_2.md @@ -0,0 +1,25 @@ +# Chapter 2 + +## Module dependency + +`base` + +- My module needs Odoo core to be installed first. + +### Where `base` lives in this repo + +`community/odoo/addons/base/` + +### What `base` provides + +- Fundamental UI framework pieces and security bootstrap. +- Core records like languages, users, partners, currencies, companies, and countries. +- Base security, group, and access basics (Chapter 4). + +### Why my module depends on it + +Without `base`, Odoo is missing the required core models, configuration, and security layer, so my module cannot install safely. + +### Notes + +`application: true` suggests that this is an installable app, and `false` means it is a module. diff --git a/awesome_estate/docs/chapter_3.md b/awesome_estate/docs/chapter_3.md new file mode 100644 index 00000000000..60b4a7d9353 --- /dev/null +++ b/awesome_estate/docs/chapter_3.md @@ -0,0 +1,56 @@ +# How fields are converted to the database + +- `fields.Char` → `varchar` if a size is set, otherwise `text` +- `fields.Text` → `text` +- `fields.Integer` → `int4` (PostgreSQL integer) +- `fields.Float` → `numeric` with precision, or `float8` if no digits are set +- `fields.Boolean` → `bool` +- `fields.Date` → `date` +- `fields.Datetime` → `timestamp` without timezone (UTC) +- `fields.Selection` → `varchar` (stores the internal key string) +- `fields.Many2one` → `int4` (foreign key) +- `fields.Binary` → `bytea` if not attachment-backed, otherwise stored in `ir.attachment` +- `fields.Html` → `text` +- `fields.Monetary` → `numeric` linked to a currency + +--- + +## Blueprint, methods, and required fields + +- `class` = blueprint +- `methods` = functions +- `required=True` translates to `NOT NULL` in SQL + +--- + +## Module namespace vs business concept + +- `awesome_estate` is the module namespace prefix +- `property` is the business concept inside that module + +So the technical model name becomes `awesome_estate.property`. + +--- + +## Selection: key vs label + +- **Key** / internal value stored in the database + - `"north"`, `"south"`, `"east"`, `"west"` + +- **Label** / display value shown in the UI + - `"North"`, `"South"`, `"East"`, `"West"` + +--- + +## Chapter 3 verification + +### 1) Upgrade or install the module +`/home/odoo/odoo19/community/odoo-bin -d patja --addons-path=community/addons,enterprise,tutorials -u awesome_estate --stop-after-init` + +### 2) Check the table and columns +`psql -d patja -c "\pset pager off" -c "\d awesome_estate_property"` + +### 3) Check `required=True` becomes `NOT NULL` +`psql -d patja -c "\pset pager off" -c "SELECT column_name, is_nullable FROM information_schema.columns WHERE table_name='awesome_estate_property' AND column_name IN ('name', 'expected_price');"` + +You should see `is_nullable = NO` for `name` and `expected_price`. diff --git a/awesome_estate/docs/chapter_4.md b/awesome_estate/docs/chapter_4.md new file mode 100644 index 00000000000..13ac474398c --- /dev/null +++ b/awesome_estate/docs/chapter_4.md @@ -0,0 +1,103 @@ +# Security + +- module: `awesome_estate` +- model: `awesome_estate.property` +- ACL file: `tutorials/awesome_estate/security/ir.model.access.csv` +- manifest entry: `data: ['security/ir.model.access.csv']` + +If a model has no access rights, Odoo treats it as inaccessible and prints a warning in the logs. + +--- + +1. **Access rights (ACLs)** + Model-level permissions: + - read + - write + - create + - unlink + +2. **Groups** + ACLs are assigned to a group like `base.group_user`. + + Common groups: + - `base.group_user` — internal backend users who can log into the Odoo backend + - `base.group_portal` — portal users who usually access the frontend and their own documents only + - `base.group_public` — public/anonymous users who are not logged in + + Difference: + - internal users work in the backend and can use normal business screens + - portal users have limited frontend access + - public users have the least access and are typically anonymous visitors + +3. **Record rules** + Used later to limit which records a group can see or edit. + +For Chapter 4, the important part is ACLs. + +--- + +## ACL file format + +File: `tutorials/awesome_estate/security/ir.model.access.csv` + +```csv +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_awesome_estate_property,access_awesome_estate_property,model_awesome_estate_property,base.group_user,1,1,1,1 +``` + +### What each part means + +- `id` + External ID of the access rule record. + +- `name` + Human-readable name. + +- `model_id:id` + Model the rule applies to. + For `awesome_estate.property`, the value is: + - `model_awesome_estate_property` + +- `group_id:id` + Group that gets the permissions. + Here: + - `base.group_user` + +- `perm_read` + Can read records. + +- `perm_write` + Can edit records. + +- `perm_create` + Can create records. + +- `perm_unlink` + Can delete records. + In Odoo, `unlink` means delete. + +### What this row gives + +This row gives internal users in `base.group_user` full access to the model: + +- read = 1 +- write = 1 +- create = 1 +- unlink = 1 + +--- + +## Manifest wiring + +File: `tutorials/awesome_estate/__manifest__.py` + +```python +'data': ['security/ir.model.access.csv'], +``` + +Why this matters: + +- Odoo only loads security data files if they are declared in the manifest. +- The file is loaded when the module is installed or upgraded. + +--- diff --git a/awesome_estate/docs/chapter_5.md b/awesome_estate/docs/chapter_5.md new file mode 100644 index 00000000000..64a9ef48156 --- /dev/null +++ b/awesome_estate/docs/chapter_5.md @@ -0,0 +1,48 @@ +# Chapter 5 - First UI + +## Action and Menus +- **Action (`ir.actions.act_window`)**: Connects a model to the UI, specifying view modes like `list,form`. +- **Menu Hierarchy**: 3 levels deep: Root Menu -> First Level Menu -> Action Menu. +- **Manifest Order**: XML files containing these UI definitions must be added to `__manifest__.py` under `data`. Data is loaded sequentially! + +## Field Attributes +- `required=True`: Field cannot be empty. Translates to `NOT NULL` in the DB. +- `copy=False`: Prevents the field value from duplicating when a user clicks the "Duplicate" action on a record. Used for unique or situational data like `date_availability` or `selling_price`. +- `readonly=True`: Makes the field uneditable from the UI. E.g., `selling_price` updates programmatically when an offer is accepted, not by manual entry. + +## Default Values +- Pre-populates a field logically when "New" is clicked. +- Can be a literal (`default=2`) or evaluated via an anonymous function. +- **Why use `lambda self:` for logic?**: If you say `default=date.today()`, Python computes it *once* when the Odoo server boots. Using `lambda self: date.today() + relativedelta(months=3)` evaluates dynamically at the *exact moment* the record is created. + +## Reserved Fields +- **`active`**: Special boolean field. If `False`, the record is "Archived" and automatically hidden from standard searches (without deleting DB row). +- **`state`**: Selection field commonly used to drive business flow (e.g., New -> Offer Received -> Sold). + +## Python / Odoo Conventions +- **String quotes (`''` vs `""`)**: Mechanically identical in Python. By Odoo / PEP 8 convention, use single quotes `''` for internal strings (keys, backend values) and double quotes `""` for UI text or docstrings. + +## Selection Fields +Are lists of tuples acting as Key/Value pairs: `('north', 'North')` +- **Key (`'north'`)**: Backend identifier. Lower-case, internal logic, stored in DB. +- **Label (`'North'`)**: UI string. Shown to the user, can be translated easily. + +## Date Imports +- `datetime.date`: Native module for server calendar dates (`date.today()`). +- `dateutil.relativedelta`: Robust utility that cleanly handles calendar leaps when calculating logic like `months=3`. Other periods supported: `years`, `months`, `weeks`, `days`, `hours`. + +## Implementation Proof +All rules required by the Chapter 5 tutorial (readonly/copy overrides, dynamic default date, correctly formatted status options, active field implementation) have been applied exactly to specification in `awesome_estate_property.py`. + +## Developer Setup Notes (`--dev`) +When executing and testing UI/view creations regularly, use the backend server command flag `--dev=all`. It auto-reloads your codebase so you bypass server restarts. +```bash +./odoo-bin -d patja -u awesome_estate --dev=all +``` +**Common `--dev=` parameters:** +- `all`: Enables all developer configurations below. +- `reload`: Automatically bounces the python worker when Python code changes are detected. +- `qweb`: Forces QWeb templates/XML to read directly from disks instead of reading from the database caching engine. Highly recommended when editing views! +- `werkzeug`: Routes exceptions natively to the debug interactive debugger. +- `xml`: Validates XML files are structurally whole before trying to push them to PostgreSQL. + diff --git a/awesome_estate/docs/chapter_6.md b/awesome_estate/docs/chapter_6.md new file mode 100644 index 00000000000..5c7d5d3b814 --- /dev/null +++ b/awesome_estate/docs/chapter_6.md @@ -0,0 +1,134 @@ +# Chapter 6 - Basic Views + +- Model file: `awesome_estate_property.py` +- View file: `awesome_estate_property_views.xml` +- Manifest file: `__manifest__.py` + +The manifest loads the security file first and the views file after it. + +--- + +## Odoo XML basics + +- ``: root tag of the XML file +- ``: creates a database record +- ``: sets a value on that record +- `model="ir.ui.view"`: this record is a view +- `arch`: XML layout of the view +- `type="xml"`: tells Odoo that `arch` is XML text + +### View tags + +- ``: list view +- `
`: form view +- ``: search view +- ``: main form area +- ``: field grouping +- ``: tab container +- ``: one tab inside a notebook + +--- + +## Actions and menus + +### Action +- XML ID: `awesome_estate_property_action` +- Model opened: `awesome.estate.property` +- View mode: `list,form` + +An action tells Odoo which model to open and which views to use. + +### Menus +Menu path: + +- `Real Estate` + - `Properties` + - `Properties` + +Menu IDs: + +- `awesome_estate_root_menu` +- `awesome_estate_first_level_menu` +- `awesome_estate_property_menu` + +The menu IDs are the technical names Odoo uses in XML. + +### How actions and menus work + +- `ir.actions.act_window` (e.g. `awesome_estate_property_action`) — opens the model +- `ir.ui.menu` (e.g. `awesome_estate_property_menu`) — adds the menu entry +- `ir.ui.view` (e.g. property list/form/search views) — defines the screen layout + +--- + +## `ir.ui.view` + +`ir.ui.view` is the Odoo model that stores view definitions in the database. + +### `arch` +`arch` is the actual XML layout stored inside the view record. + +### Example +A view record like: + +```xml + +``` + +means: +- create a database record +- store it as a view +- give it an XML ID so other XML can reference it + +--- + +## Inheritance + +If we want to change an existing view, we use inheritance. + +- `inherit_id`: points to the original view +- `xpath`: updates part of the XML without replacing the full view + +That is how Odoo extends views cleanly. + +--- + +## List View attributes learned + +### `editable` +- `"top"` or `"bottom"` — makes list inline-editable, skips form view +- Used on Property Type list (single field `name`, form view is overkill) +- Used on Offers tab inside Property form (`editable="bottom"`) +- Internally: `_editable_tag_list()` in `ir_ui_view.py` returns `True` when `editable` is set + +### `form_view_ref` +- Context key (NOT `open_form_view`) — controls which form opens for relational fields +- `` +- Read by `_get_view_refs()` and `_get_x2many_missing_view_archs()` in `ir_ui_view.py` + +### Other common attributes +- `multi_edit="1"` — bulk editing +- `decoration-*` — conditional styling (bf=bold, danger=red, success=green, muted=grey) +- `create/delete/edit="0"` — hide buttons +- Field-level: `invisible`, `readonly`, `required`, `widget`, `sum`, `optional` + +--- + +## What I learned + +- Python defines the model +- XML defines the UI +- `ir.ui.view` stores the UI in the database +- `arch` is the XML layout +- actions open models +- menus make the model reachable +- XML IDs connect records together +- inheritance lets us modify an existing view instead of rewriting it +- `editable="bottom"` makes a list inline-editable, form view not needed for simple models + +--- + +## Quick verify + +```bash +community/odoo-bin -d patja --addons-path=community/addons,enterprise,tutorials -u awesome_estate --dev xml diff --git a/awesome_estate/docs/chapter_7.md b/awesome_estate/docs/chapter_7.md new file mode 100644 index 00000000000..18a0f98d9af --- /dev/null +++ b/awesome_estate/docs/chapter_7.md @@ -0,0 +1,32 @@ +# Chapter 7 — Relations Between Models + +The property record links to type, buyer, salesperson, tags, and offers. + +--- + +## New models added + +- **Property Type** — `Many2one`, `ondelete='set null'` +- **Buyer** — `Many2one` → `res.partner`, `copy=False`, `ondelete='set null'` +- **Salesperson** — `Many2one` → `res.users`, `default=self.env.user`, `ondelete='set null'` +- **Tags** — `Many2many`, auto-creates junction table +- **Offers** — `One2many` (inverse of `property_id`), `ondelete='cascade'`, `_order = 'price desc'`, embedded inside property form with `editable="bottom"` + +--- + +## Relation types + +- **Many2one** = one column + one FK in DB +- **Many2many** = junction table (not optimization — relational DB necessity). Named `{t1}_{t2}_rel` +- **One2many** = virtual field, nothing in DB. Backed by child's Many2one + +--- + +## What I learned extra + +- **M2M junction table** — SQL can't store ID lists. Junction table is THE standard solution. Odoo auto-creates it. +- **`editable="bottom"`** on list → inline editing, no form view needed for simple models. Applied to both **Property Type** and **Tags** (both only have `name` field). +- **`open_form_view="True"`** — built-in list attribute (Odoo JS parses it), adds arrow column per row to open form view. Only works with `editable` also set. No Python code needed. Also applied to both Type and Tags. +- **`form_view_ref`** context key → controls which form opens for relational sub-items (used on ``, NOT a list attribute) + +--- diff --git a/awesome_estate/docs/chapter_8.md b/awesome_estate/docs/chapter_8.md new file mode 100644 index 00000000000..d05782daab3 --- /dev/null +++ b/awesome_estate/docs/chapter_8.md @@ -0,0 +1,159 @@ +# Chapter 8 — Computed Fields, Onchanges, and Inverse Functions + +- Import change: `from odoo import api, fields, models` (added `api`) +- New fields on `awesome.estate.property`: `total_area`, `squared_area`, `best_price` +- New fields on `awesome.estate.property.offer`: `validity`, `date_deadline` + +--- + +## Computed fields + +A computed field's value is calculated by Python, not typed by the user. + +```python +total_area = fields.Integer(compute='_compute_total_area', store=True) + +@api.depends('living_area', 'garden_area') +def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + +squared_area = fields.Integer(compute='_compute_squared_area', store=True) + +@api.depends('living_area', 'garden_area', 'total_area') +def _compute_squared_area(self): + for record in self: + record.squared_area = record.total_area ** 2 + +best_price = fields.Float(compute='_compute_best_price', store=True) + +@api.depends('offer_ids.price') +def _compute_best_price(self): + for record in self: + record.best_price = max(record.offer_ids.mapped('price'), default=0.0) +``` + +### `store=True` +- Creates a real database column. Search filters and sorting need a column. +- Exception: don't use when value depends on current user/time/context. + +### `@api.depends` +- Lists every field used in the method body. Missing one = silent stale data. +- Works through relations: `@api.depends('offer_ids.price')` tracks price changes on linked offers. + +`squared_area` depends on `total_area`, which itself is a computed field. Both must have `store=True` for the cascade to work in SQL. + +--- + +## onchange vs depends + +The form view updates `total_area` when you change `living_area` whether you use onchange or depends. The difference shows up when you sort, filter, or duplicate. + +- `@api.onchange` only fires in the browser form. When records are created or modified through `write()` or `create()`, onchange never runs. It is form-only. +- `@api.depends` fires on ANY write. The ORM checks the dependency tree and recomputes automatically — form, import, server action, all of them. + +--- + +## `mapped()` — extracting field values from relations + +```python +# CORRECT: 1 line, Odoo standard +max(record.offer_ids.mapped('price'), default=0.0) + +# WRONG: 6-line Python loop +prices = [] +for offer in record.offer_ids: + prices.append(offer.price) +if prices: + record.best_price = max(prices) +``` + +`default=0.0` handles empty offer lists safely (`max([])` would crash). + +--- + +## Inverse function — two-way computed fields + +```python +date_deadline = fields.Date( + compute='_compute_date_deadline', # Forward: validity → date + inverse='_inverse_date_deadline', # Backward: date → validity +) +``` + +Without inverse, computed fields are read-only. With inverse, the user can edit either direction: +- Set validity=14 → deadline = today + 14 +- Set deadline=2026-07-01 → validity = days until July 1 + +### Critical: `create_date` is None at creation + +```python +if record.create_date: + record.date_deadline = fields.Date.add( + fields.Date.to_date(record.create_date), days=record.validity) +else: + record.date_deadline = fields.Date.add( + fields.Date.today(), days=record.validity) +``` + +Same guard in inverse. Without this, `None + 7 days` crashes. + +--- + +## `@api.onchange` — UI-only, never business logic + +```python +@api.onchange('garden') +def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = 0 + self.garden_orientation = False +``` + +- Only fires in the browser form view. +- `self` is a single record — NO `for record in self:` loop. +- Garden is correct because it is a UI hint, not a business rule. + +--- + +## What I learned + +- `@api.depends` fires on ANY write. `@api.onchange` fires only in browser form. +- `store=True` creates a DB column. Without it, sort and filter silently do nothing. +- `squared_area` depends on `total_area`, which is also computed — chain works when both have `store=True`. +- `mapped()` extracts field values in one line instead of a Python loop. +- `inverse=` makes a computed field editable in both directions. +- `create_date` is `None` for unsaved records — guard with `today()` fallback. + +--- + +## Review tasks — issues found and fixed + +### Off-track items (fixed now) + +| # | Issue | File | Fix applied? | +|---|-------|------|-------------| +| 1 | `squared_area` `@api.depends` missing `total_area` | `awesome_estate_property.py` | Added `'total_area'` to depends | +| 2 | `best_price` filter needed adding | `awesome_estate_property_views.xml` | Added filter to search view (works with `store=True`) | +| 3 | No `_rec_name` defined | `awesome_estate_property.py` | Works (defaults to `'name'`) but add for clarity | + +### View additions (done) + +| # | What | File | +|---|------|------| +| 1 | Added `squared_area` and `total_area` to list view columns | `awesome_estate_property_views.xml` | +| 2 | Added `squared_area` to form view (right column, under total_area) | `awesome_estate_property_views.xml` | + +### Future review items (chapters 9+) + +| Chapter | What to watch for | Pattern to use | +|---------|------------------|----------------| +| 9 | Button methods | `action_` prefix, `self.ensure_one()`, `UserError` for workflow blocks | +| 9 | Offer price escalation | `@api.model_create_multi`, `self.search([...])` not `filtered()` | +| 10 | Constraints | `models.Constraint` (SQL) for single-field checks, `@api.constrains` only for cross-field | +| 10 | Float comparisons | `float_compare()` from `odoo.tools.float_utils`, never `==` | +| 11 | UI decorations | `decoration-*` on list rows, `widget="statusbar"` on state | +| 12 | Deletion guards | `@api.ondelete(at_uninstall=False)`, not overriding `unlink()` | diff --git a/awesome_estate/docs/initial.md b/awesome_estate/docs/initial.md new file mode 100644 index 00000000000..d20f68537eb --- /dev/null +++ b/awesome_estate/docs/initial.md @@ -0,0 +1,45 @@ +# Notes + +## Start Odoo command + +`odoo-bin -d --addons-path=` + +### Breakdown + +- `odoo-bin` starts the Odoo server. +- `-d ` selects which PostgreSQL database to use. +- `--addons-path=` is a comma-separated list of addon folders that Odoo scans. + +### It does + +- Loads already-installed modules. +- Starts the UI and backend services. + +## Upgrade a module + +### Command + +`odoo-bin -d -u --addons-path=` + +### Meaning + +`-u ` reloads the module and applies its model and data changes. + +### It does + +- After changing Python models (ORM), upgrade the module so database schema changes happen. +- After adding security or ACLs, upgrade the module so access rules apply. + +### For me + +`odoo-bin --addons-path=addons,../enterprise/,../tutorials/ -d patja -u awesome_estate` + +## Install a module for the first time + +### Command + +`odoo-bin -d -i --addons-path=` + +### Meaning + +`-i ` installs the module for the first time in that database. diff --git a/awesome_estate/models/__init__.py b/awesome_estate/models/__init__.py new file mode 100644 index 00000000000..9d8ee6d0e37 --- /dev/null +++ b/awesome_estate/models/__init__.py @@ -0,0 +1,9 @@ +from . import ( + awesome_estate_property, + awesome_estate_property_maintenance, + awesome_estate_property_maintenance_subtask, + awesome_estate_property_offer, + awesome_estate_property_tag, + awesome_estate_property_type, + awesome_estate_res_users, +) diff --git a/awesome_estate/models/awesome_estate_property.py b/awesome_estate/models/awesome_estate_property.py new file mode 100644 index 00000000000..dfe775eef1a --- /dev/null +++ b/awesome_estate/models/awesome_estate_property.py @@ -0,0 +1,241 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.float_utils import float_compare, float_is_zero + + +class AwesomeEstateProperty(models.Model): + _name = 'awesome.estate.property' + _description = 'Real Estate Property' + _rec_name = 'name' + _order = 'id desc' + + name = fields.Char(string="Title", required=True) + description = fields.Text() + postcode = fields.Char() + date_availability = fields.Date( + string="Available From", + copy=False, + default=lambda self: fields.Date.add( + fields.Date.context_today(self), months=3), + ) + expected_price = fields.Float(required=True) + selling_price = fields.Float(copy=False) + bedrooms = fields.Integer(default=2) + living_area = fields.Integer(string="Living Area (sqm)") + facades = fields.Integer() + garage = fields.Boolean() + garden = fields.Boolean() + garden_area = fields.Integer(string="Garden Area (sqm)") + garden_orientation = fields.Selection( + [ + ('north', "North"), + ('south', "South"), + ('east', "East"), + ('west', "West"), + ], + ) + property_type_id = fields.Many2one( + 'awesome.estate.property.type', + string="Property Type", + ondelete='restrict', + index=True, + ) + buyer_id = fields.Many2one( + 'res.partner', + string="Buyer", + readonly=True, + copy=False, + index=True, + ) + salesperson_id = fields.Many2one( + 'res.users', + string="Salesperson", + default=lambda self: self.env.user, + index=True, + ) + tag_ids = fields.Many2many( + 'awesome.estate.property.tag', + ) + offer_ids = fields.One2many( + 'awesome.estate.property.offer', + 'property_id', + ) + active = fields.Boolean(default=True) + state = fields.Selection( + [ + ('new', "New"), + ('offer_received', "Offer Received"), + ('offer_accepted', "Offer Accepted"), + ('sold', "Sold"), + ('canceled', "Canceled"), + ], + string="Status", + required=True, + copy=False, + default='new', + ) + total_area = fields.Integer( + string="Total Area (sqm)", + compute='_compute_total_area', + store=True, + help="Total area computed by summing the living area and the garden area.", + ) + best_price = fields.Float( + string="Best Offer", + compute='_compute_best_price', + store=True, + help="Best offer received.", + ) + estate_maintenance_ids = fields.One2many( + 'awesome.estate.property.maintenance', + 'property_id', + string="Maintenance Requests", + ) + + # ----------------------------------------------------------------------- + # SQL Constraints + # ----------------------------------------------------------------------- + _check_living_area = models.Constraint( + 'CHECK (living_area >= 0 AND living_area <= 100000)', + "Living area must be between 0 and 100,000 sqm. Please enter a realistic value.", + ) + _check_garden_area = models.Constraint( + 'CHECK (garden_area >= 0 AND garden_area <= 100000)', + "Garden area must be between 0 and 100,000 sqm. Please enter a realistic value.", + ) + _check_expected_price = models.Constraint( + 'CHECK (expected_price > 0)', + "Expected price must be greater than zero.", + ) + _check_selling_price_positive = models.Constraint( + 'CHECK (selling_price >= 0)', + "The selling price must be positive.", + ) + _check_bedrooms = models.Constraint( + 'CHECK (bedrooms >= 0)', + "Bedrooms cannot be negative.", + ) + _check_facades = models.Constraint( + 'CHECK (facades >= 0)', + "Facades cannot be negative.", + ) + + @api.constrains('selling_price', 'expected_price') + def _check_selling_price(self): + """Only validate selling price floor when set manually, not via offer accept.""" + if self.env.context.get('accepting_offer'): + return + for record in self: + if float_is_zero(record.selling_price, precision_digits=2) or not record.expected_price: + continue + if float_compare(record.selling_price, record.expected_price * 0.9, precision_digits=2) == -1: + raise ValidationError( + _("The selling price cannot be lower than 90%% of the expected price."), + ) + + # ----------------------------------------------------------------------- + # Computed Fields + # ----------------------------------------------------------------------- + @api.depends('living_area', 'garden_area') + def _compute_total_area(self): + for record in self: + record.total_area = record.living_area + record.garden_area + + @api.depends('offer_ids', 'offer_ids.price') + def _compute_best_price(self): + for record in self: + prices = record.offer_ids.mapped('price') + record.best_price = max(prices) if prices else 0.0 + + # ----------------------------------------------------------------------- + # Onchange Methods + # ----------------------------------------------------------------------- + @api.onchange('garden') + def _onchange_garden(self): + if self.garden: + self.garden_area = 10 + self.garden_orientation = 'north' + else: + self.garden_area = 0 + self.garden_orientation = False + + # ----------------------------------------------------------------------- + # Action Methods + # ----------------------------------------------------------------------- + def action_sold(self): + self.ensure_one() + if self.state == 'canceled': + raise UserError(_("Canceled properties cannot be sold.")) + if self.state == 'sold': + raise UserError(_("Property is already sold.")) + # Auto-fill selling_price and buyer_id from the accepted offer. + # The state machine guarantees at most one accepted offer exists. + if self.state == 'offer_accepted' and not self.selling_price: + accepted = self.offer_ids.filtered(lambda o: o.status == 'accepted') + if accepted: + self.write({ + 'selling_price': accepted.price, + 'buyer_id': accepted.partner_id.id, + }) + # Guard: must have a selling price + if not self.selling_price: + raise UserError( + _("Set a selling price before selling the property.")) + self.state = 'sold' + return True + + def action_cancel(self): + self.ensure_one() + if self.state == 'sold': + raise UserError(_("Sold properties cannot be canceled.")) + if self.state == 'canceled': + raise UserError(_("Property is already canceled.")) + accepted = self.offer_ids.filtered(lambda o: o.status == 'accepted') + if accepted: + accepted.write({'status': 'refused'}) + pending = self.offer_ids.filtered(lambda o: not o.status) + if pending: + pending.write({'status': 'refused'}) + self.state = 'canceled' + # Odoo guideline: terminal "Canceled" state archives the record + # so it is hidden from default list views. + self.active = False + return True + + def action_reset(self): + """Reset sold or canceled property back to 'new' state.""" + self.ensure_one() + if self.state not in ('sold', 'canceled'): + raise UserError( + _("Only sold or canceled properties can be reset.")) + was_sold = self.state == 'sold' + self.write({ + 'state': 'new', + 'selling_price': 0.0, + 'buyer_id': False, + 'active': True, + }) + if was_sold: + self.offer_ids.write({'status': False}) + return True + + def action_accept_best_offer(self): + self.ensure_one() + if self.state in ('sold', 'canceled', 'offer_accepted'): + raise UserError( + _("Cannot accept offers on a property that is %s.", self.state), + ) + best_offers = self.offer_ids.filtered( + lambda o: o.price == self.best_price) + if not best_offers: + raise UserError(_("No offers available to accept.")) + best_offers[0].action_accept() + + # ----------------------------------------------------------------------- + # Deletion Guard + # ----------------------------------------------------------------------- + @api.ondelete(at_uninstall=False) + def _unlink_except_active_or_sold(self): + if any(record.state not in ('new', 'canceled') for record in self): + raise UserError( + _("You cannot delete a property with an active or sold status.")) diff --git a/awesome_estate/models/awesome_estate_property_maintenance.py b/awesome_estate/models/awesome_estate_property_maintenance.py new file mode 100644 index 00000000000..c91623059f4 --- /dev/null +++ b/awesome_estate/models/awesome_estate_property_maintenance.py @@ -0,0 +1,270 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class AwesomeEstatePropertyMaintenance(models.Model): + _name = 'awesome.estate.property.maintenance' + _description = 'Property Maintenance Request' + _rec_name = 'name' + _order = 'id desc' + + name = fields.Char(string="Title", required=True) + description = fields.Text() + issue_type = fields.Selection( + string="Issue Type", + default='other', + required=True, + selection=[ + ('electricity', "Electricity"), + ('plumbing', "Plumbing"), + ('carpentry', "Carpentry"), + ('painting', "Painting"), + ('cleaning', "Cleaning"), + ('pest_control', "Pest Control"), + ('structural', "Structural"), + ('other', "Other"), + ], + ) + property_id = fields.Many2one( + 'awesome.estate.property', string="Property", required=True, ondelete='cascade', + ) + requester_id = fields.Many2one( + 'res.partner', string="Requested By", + required=True, default=lambda self: self.env.user.partner_id.id, + ) + is_tenant = fields.Boolean(string="Is Tenant", default=False) + technician_id = fields.Many2one( + 'res.users', string="Technician", + domain="[('share', '=', False)]", + index='btree_not_null', + ) + priority = fields.Selection( + string="Priority", + default='2', + required=True, + selection=[ + ('0', "Very Low"), + ('1', "Low"), + ('2', "Normal"), + ('3', "High"), + ], + ) + company_id = fields.Many2one( + 'res.company', string="Company", + required=True, default=lambda self: self.env.company, + ) + currency_id = fields.Many2one( + 'res.currency', string="Currency", + default=lambda self: self.env.company.currency_id.id, + required=True, + ) + estimate_cost = fields.Monetary( + string="Estimated Cost", currency_field='currency_id', + ) + actual_cost = fields.Monetary( + string="Actual Cost", currency_field='currency_id', + compute='_compute_actual_cost', store=True, + ) + cost_line_ids = fields.One2many( + 'awesome.estate.property.maintenance.cost', + 'maintenance_id', string="Cost Lines", + ) + subtask_ids = fields.One2many( + 'awesome.estate.property.maintenance.subtask', + 'maintenance_id', string="Subtasks", + ) + request_date = fields.Date( + string="Request Date", + default=fields.Date.context_today, required=True, + ) + schedule_date = fields.Datetime(string="Scheduled Date") + close_date = fields.Date(string="Completion Date", readonly=True) + internal_notes = fields.Html(string="Internal Notes") + progress = fields.Integer( + string="Progress (%)", + compute='_compute_progress', store=True, + ) + state = fields.Selection( + string="Status", + default='new', required=True, copy=False, + selection=[ + ('new', "New"), + ('assigned', "Assigned"), + ('in_progress', "In Progress"), + ('done', "Done"), + ('canceled', "Canceled"), + ], + ) + + _check_progress = models.Constraint( + 'CHECK(progress >= 0 AND progress <= 100)', + "Progress must be between 0 and 100%.", + ) + + @api.depends('subtask_ids.cost', 'cost_line_ids.amount') + def _compute_actual_cost(self): + """Actual cost = sum of all subtask costs + general cost lines.""" + for record in self: + subtask_cost = sum(record.subtask_ids.mapped('cost')) + line_cost = sum(record.cost_line_ids.mapped('amount')) + record.actual_cost = subtask_cost + line_cost + + @api.depends('subtask_ids.state') + def _compute_progress(self): + """Progress % = (completed subtasks / total subtasks) * 100.""" + for record in self: + total = len(record.subtask_ids) + if not total: + record.progress = 0 + continue + done = len(record.subtask_ids.filtered(lambda s: s.state == 'done')) + record.progress = int((done / total) * 100) + + @api.onchange('issue_type') + def _onchange_issue_type(self): + """Auto-fill estimate_cost from issue type default whenever type changes.""" + ISSUE_TYPE_DEFAULT_COST = { + 'electricity': 1000.0, + 'plumbing': 1500.0, + 'carpentry': 2000.0, + 'painting': 5000.0, + 'cleaning': 2000.0, + 'pest_control': 2500.0, + 'structural': 10000.0, + 'other': 1000.0, + } + if self.issue_type: + self.estimate_cost = ISSUE_TYPE_DEFAULT_COST.get(self.issue_type, 1000.0) + + @api.onchange('technician_id') + def _onchange_technician(self): + """Auto-transition new -> assigned when technician is selected.""" + if self.technician_id and self.state == 'new': + self.state = 'assigned' + if not self.technician_id and self.state == 'assigned': + self.state = 'new' + + @api.constrains('state', 'technician_id') + def _check_assigned_has_technician(self): + for record in self: + if record.state == 'assigned' and not record.technician_id: + raise ValidationError( + _("An assigned maintenance request must have a technician."), + ) + + def action_assign(self): + """Assign the technician and move to 'assigned' state.""" + self.ensure_one() + if self.state != 'new': + raise UserError(_("Only new requests can be assigned.")) + if not self.technician_id: + raise UserError(_("Select a technician before assigning.")) + self.state = 'assigned' + return True + + def action_start(self): + """Start work on the request.""" + self.ensure_one() + if self.state != 'assigned': + raise UserError(_("Assign the request before starting work.")) + self.state = 'in_progress' + return True + + def action_done(self): + """Complete the request. Requires estimate_cost and all subtasks done.""" + self.ensure_one() + if self.state not in ('assigned', 'in_progress'): + raise UserError( + _("Only assigned or in-progress requests can be completed.")) + if not self.estimate_cost: + raise UserError( + _("Set an estimated cost before completing this request.")) + if self.subtask_ids: + pending = self.subtask_ids.filtered( + lambda s: s.state not in ('done', 'canceled')) + if pending: + raise UserError( + _("Complete all subtasks before marking this request as done. " + "Pending: %s", ', '.join(pending.mapped('name')))) + self.write({ + 'state': 'done', + 'close_date': fields.Date.today(), + 'progress': 100, + }) + return True + + def action_cancel(self): + """Cancel the request. Needs a reason in internal_notes if work started.""" + self.ensure_one() + if self.state == 'done': + if not self.internal_notes: + raise UserError( + _("Completed requests require a cancellation reason in Internal Notes.")) + self.write({ + 'state': 'canceled', + 'close_date': fields.Date.today(), + 'progress': 0, + }) + return True + if self.state in ('assigned', 'in_progress'): + if not self.internal_notes: + raise UserError( + _("Provide a cancellation reason in Internal Notes.")) + self.write({ + 'state': 'canceled', + 'close_date': fields.Date.today(), + 'progress': 0, + }) + return True + + def action_reset(self): + """Reopen a done or canceled request back to 'new'.""" + self.ensure_one() + if self.state not in ('done', 'canceled'): + raise UserError( + _("Only done or canceled requests can be reset.")) + self.write({ + 'state': 'new', + 'progress': 0, + 'close_date': False, + }) + return True + + @api.ondelete(at_uninstall=False) + def _unlink_if_not_final(self): + """Prevent deletion of requests that are in progress or completed.""" + for record in self: + if record.state not in ('new', 'canceled'): + raise UserError( + _("Cannot delete a maintenance request in '%s' state. " + "Cancel it first.", record.state)) + + +class AwesomeEstatePropertyMaintenanceCost(models.Model): + _name = 'awesome.estate.property.maintenance.cost' + _description = 'Maintenance Cost Line' + _rec_name = 'name' + _order = 'date desc, id desc' + + currency_id = fields.Many2one( + 'res.currency', + related='maintenance_id.currency_id', + store=True, + ) + maintenance_id = fields.Many2one( + 'awesome.estate.property.maintenance', + string="Maintenance Request", + required=True, + ondelete='cascade', + ) + name = fields.Char(string="Description", required=True) + amount = fields.Monetary( + string="Amount", + currency_field='currency_id', + required=True, + ) + date = fields.Date( + string="Date", + required=True, + default=fields.Date.context_today, + ) diff --git a/awesome_estate/models/awesome_estate_property_maintenance_subtask.py b/awesome_estate/models/awesome_estate_property_maintenance_subtask.py new file mode 100644 index 00000000000..3a31c1fe1d6 --- /dev/null +++ b/awesome_estate/models/awesome_estate_property_maintenance_subtask.py @@ -0,0 +1,67 @@ +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class AwesomeEstatePropertyMaintenanceSubtask(models.Model): + _name = 'awesome.estate.property.maintenance.subtask' + _description = 'Maintenance Subtask' + _rec_name = 'name' + _order = 'sequence, id' + + maintenance_id = fields.Many2one( + 'awesome.estate.property.maintenance', + string="Maintenance Request", + required=True, + ondelete='cascade', + index=True, + ) + name = fields.Char(string="Task", required=True) + technician_id = fields.Many2one( + 'res.users', + string="Assigned To", + default=lambda self: self.env.user, + domain="[('share', '=', False)]", + ) + state = fields.Selection( + string="Status", + default='pending', + required=True, + selection=[ + ('pending', "Pending"), + ('in_progress', "In Progress"), + ('done', "Done"), + ('canceled', "Canceled"), + ], + ) + sequence = fields.Integer(string="Order", default=10) + planned_hours = fields.Float(string="Planned Hours") + effective_hours = fields.Float(string="Effective Hours") + cost = fields.Monetary(string="Cost", currency_field='currency_id') + currency_id = fields.Many2one( + 'res.currency', + related='maintenance_id.currency_id', + store=True, + ) + description = fields.Text(string="Work Notes") + + def action_start(self): + self.ensure_one() + if self.state != 'pending': + raise UserError(_("Only pending subtasks can be started.")) + self.state = 'in_progress' + + def action_done(self): + self.ensure_one() + if self.state not in ('in_progress', 'pending'): + raise UserError( + _("Only in-progress or pending subtasks can be marked done.")) + self.write({ + 'state': 'done', + 'effective_hours': self.effective_hours or self.planned_hours, + }) + + def action_cancel(self): + self.ensure_one() + if self.state == 'done': + raise UserError(_("Completed subtasks cannot be canceled.")) + self.state = 'canceled' diff --git a/awesome_estate/models/awesome_estate_property_offer.py b/awesome_estate/models/awesome_estate_property_offer.py new file mode 100644 index 00000000000..ac3094213ce --- /dev/null +++ b/awesome_estate/models/awesome_estate_property_offer.py @@ -0,0 +1,165 @@ +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class AwesomeEstatePropertyOffer(models.Model): + _name = 'awesome.estate.property.offer' + _description = 'Real Estate Property Offer' + _order = 'price desc, id desc' + + price = fields.Float() + status = fields.Selection( + [ + ('accepted', "Accepted"), + ('refused', "Refused"), + ], + string="Status", + copy=False, + ) + partner_id = fields.Many2one( + 'res.partner', + string="Partner", + required=True, + ) + property_id = fields.Many2one( + 'awesome.estate.property', + string="Property", + required=True, + ondelete='cascade', + index=True, + ) + property_type_id = fields.Many2one( + 'awesome.estate.property.type', + string="Property Type", + related='property_id.property_type_id', + store=True, + ) + validity = fields.Integer(string="Validity (days)", default=7) + date_deadline = fields.Date( + string="Deadline", + compute='_compute_date_deadline', + inverse='_inverse_date_deadline', + ) + + # ----------------------------------------------------------------------- + # SQL Constraints + # ----------------------------------------------------------------------- + _check_offer_price = models.Constraint( + 'CHECK (price > 0)', + 'The offer price must be strictly positive.', + ) + _check_offer_price_max = models.Constraint( + 'CHECK (price <= 9999999999)', + 'The offer price seems unreasonably high.', + ) + + # ----------------------------------------------------------------------- + # Python Constraints + # ----------------------------------------------------------------------- + @api.constrains('validity') + def _check_validity_positive(self): + for record in self: + if record.validity <= 0: + raise ValidationError( + _("Validity must be a positive number of days."), + ) + + # ----------------------------------------------------------------------- + # Computed Fields & Inverse + # ----------------------------------------------------------------------- + @api.depends('create_date', 'validity') + def _compute_date_deadline(self): + for record in self: + if record.create_date: + record.date_deadline = fields.Date.add( + fields.Date.to_date(record.create_date), days=record.validity, + ) + else: + record.date_deadline = fields.Date.add( + fields.Date.today(), days=record.validity, + ) + + def _inverse_date_deadline(self): + for record in self: + if record.date_deadline and record.create_date: + deadline = fields.Date.to_date(record.date_deadline) + created = fields.Date.to_date(record.create_date) + if deadline < created: + raise ValidationError( + _("The deadline date cannot be before the offer creation date."), + ) + record.validity = (deadline - created).days + elif record.date_deadline: + deadline = fields.Date.to_date(record.date_deadline) + today = fields.Date.today() + if deadline <= today: + raise ValidationError( + _("The deadline date must be after today."), + ) + record.validity = (deadline - today).days + + # ----------------------------------------------------------------------- + # CRUD Methods + # ----------------------------------------------------------------------- + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + property_id = vals.get('property_id') + new_price = vals.get('price', 0) + if property_id: + estate_property = self.env['awesome.estate.property'].browse(property_id) + if estate_property.state in ('sold', 'canceled', 'offer_accepted'): + raise ValidationError( + _("Cannot create offers on a property that is %s.", estate_property.state), + ) + if property_id and new_price > 0: + existing_offers = self.search([ + ('property_id', '=', property_id), + ]) + if existing_offers: + max_price = max(existing_offers.mapped('price')) + if new_price <= max_price: + raise ValidationError( + _("Offer must be higher than the highest existing offer ($%.2f).", max_price), + ) + offers = super().create(vals_list) + for offer in offers: + if offer.property_id.state == 'new': + offer.property_id.state = 'offer_received' + return offers + + # ----------------------------------------------------------------------- + # Action Methods + # ----------------------------------------------------------------------- + def action_accept(self): + """Accept this offer: update property, refuse other pending offers.""" + self.ensure_one() + if self.status: + raise UserError(_("This offer has already been accepted or refused.")) + if self.property_id.state in ('sold', 'canceled', 'offer_accepted'): + raise UserError( + _("Cannot accept offers on a property that is %s.", self.property_id.state), + ) + existing_accepted = self.search([ + ('property_id', '=', self.property_id.id), + ('status', '=', 'accepted'), + ('id', '!=', self.id), + ]) + if existing_accepted: + raise UserError(_("Another offer has already been accepted for this property.")) + other_offers = self.property_id.offer_ids - self + other_offers.filtered(lambda o: not o.status).write({'status': 'refused'}) + self.property_id.with_context(accepting_offer=True).write({ + 'selling_price': self.price, + 'buyer_id': self.partner_id.id, + 'state': 'offer_accepted', + }) + return super().write({'status': 'accepted'}) + + def action_refuse(self): + """Refuse this offer. Only pending offers can be refused.""" + self.ensure_one() + if self.status: + raise UserError(_("This offer has already been accepted or refused.")) + self.status = 'refused' + return True diff --git a/awesome_estate/models/awesome_estate_property_tag.py b/awesome_estate/models/awesome_estate_property_tag.py new file mode 100644 index 00000000000..119dcf1b09e --- /dev/null +++ b/awesome_estate/models/awesome_estate_property_tag.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class AwesomeEstatePropertyTag(models.Model): + _name = 'awesome.estate.property.tag' + _description = 'Real Estate Property Tag' + _order = 'name' + + name = fields.Char(required=True) + color = fields.Integer() + + # ----------------------------------------------------------------------- + # SQL Constraints + # ----------------------------------------------------------------------- + _check_tag_name_unique = models.Constraint( + 'UNIQUE(name)', + 'The tag name must be unique.', + ) diff --git a/awesome_estate/models/awesome_estate_property_type.py b/awesome_estate/models/awesome_estate_property_type.py new file mode 100644 index 00000000000..46bdc39dc20 --- /dev/null +++ b/awesome_estate/models/awesome_estate_property_type.py @@ -0,0 +1,42 @@ +from odoo import api, fields, models + + +class AwesomeEstatePropertyType(models.Model): + _name = 'awesome.estate.property.type' + _description = 'Real Estate Property Type' + _order = 'sequence, name' + + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + property_ids = fields.One2many( + 'awesome.estate.property', + 'property_type_id', + string="Properties", + ) + offer_ids = fields.One2many( + 'awesome.estate.property.offer', + 'property_type_id', + string="Offers", + ) + offer_count = fields.Integer( + compute='_compute_offer_count', + store=True, + ) + + # ----------------------------------------------------------------------- + # SQL Constraints + # ----------------------------------------------------------------------- + _check_type_name_unique = models.Constraint( + 'UNIQUE(name)', + 'The property type name must be unique.', + ) + + # ----------------------------------------------------------------------- + # Computed Fields + # ----------------------------------------------------------------------- + @api.depends('offer_ids') + def _compute_offer_count(self): + for record in self: + record.offer_count = self.env['awesome.estate.property.offer'].search_count([ + ('property_type_id', 'in', record.ids), + ]) diff --git a/awesome_estate/models/awesome_estate_res_users.py b/awesome_estate/models/awesome_estate_res_users.py new file mode 100644 index 00000000000..c1104761469 --- /dev/null +++ b/awesome_estate/models/awesome_estate_res_users.py @@ -0,0 +1,12 @@ +from odoo import fields, models + + +class AwesomeEstateResUsers(models.Model): + _inherit = 'res.users' + + property_ids = fields.One2many( + 'awesome.estate.property', + 'salesperson_id', + string="Estate Properties", + domain=[('state', 'in', ('new', 'offer_received', 'offer_accepted'))], + ) diff --git a/awesome_estate/security/ir.model.access.csv b/awesome_estate/security/ir.model.access.csv new file mode 100644 index 00000000000..fd43df76b0e --- /dev/null +++ b/awesome_estate/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_awesome_estate_property,access_awesome_estate_property,model_awesome_estate_property,base.group_user,1,1,1,1 +access_awesome_estate_property_type,access_awesome_estate_property_type,model_awesome_estate_property_type,base.group_user,1,1,1,1 +access_awesome_estate_property_tag,access_awesome_estate_property_tag,model_awesome_estate_property_tag,base.group_user,1,1,1,1 +access_awesome_estate_property_offer,access_awesome_estate_property_offer,model_awesome_estate_property_offer,base.group_user,1,1,1,1 +access_awesome_estate_property_maintenance,access_awesome_estate_property_maintenance,model_awesome_estate_property_maintenance,base.group_user,1,1,1,1 +access_awesome_estate_property_maintenance_cost,access_awesome_estate_property_maintenance_cost,model_awesome_estate_property_maintenance_cost,base.group_user,1,1,1,1 +access_awesome_estate_property_maintenance_subtask,access_awesome_estate_property_maintenance_subtask,model_awesome_estate_property_maintenance_subtask,base.group_user,1,1,1,1 diff --git a/awesome_estate/views/awesome_estate_menus.xml b/awesome_estate/views/awesome_estate_menus.xml new file mode 100644 index 00000000000..1cb347eae28 --- /dev/null +++ b/awesome_estate/views/awesome_estate_menus.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/awesome_estate/views/awesome_estate_property_maintenance_subtask_views.xml b/awesome_estate/views/awesome_estate_property_maintenance_subtask_views.xml new file mode 100644 index 00000000000..72856fbed68 --- /dev/null +++ b/awesome_estate/views/awesome_estate_property_maintenance_subtask_views.xml @@ -0,0 +1,23 @@ + + + + + awesome.estate.property.maintenance.subtask.list + awesome.estate.property.maintenance.subtask + + + + + + + + + + + + + + + diff --git a/awesome_estate/views/awesome_estate_property_maintenance_views.xml b/awesome_estate/views/awesome_estate_property_maintenance_views.xml new file mode 100644 index 00000000000..8df754f680b --- /dev/null +++ b/awesome_estate/views/awesome_estate_property_maintenance_views.xml @@ -0,0 +1,185 @@ + + + + + Maintenance Requests + awesome.estate.property.maintenance + list,form + +

+ Add a new maintenance request +

+

+ Track issues reported by tenants and manage technician assignments. +

+
+
+ + + awesome.estate.property.maintenance.list + awesome.estate.property.maintenance + + + + + + + + + + + + + + + + + + + + + awesome.estate.property.maintenance.form + awesome.estate.property.maintenance + + + + + + +
+
+ +
+ Canceled +
+
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + + +
+
+ +
+
+ + + awesome.estate.property.maintenance.search + awesome.estate.property.maintenance + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/awesome_estate/views/awesome_estate_property_offer_views.xml b/awesome_estate/views/awesome_estate_property_offer_views.xml new file mode 100644 index 00000000000..643d47c13d5 --- /dev/null +++ b/awesome_estate/views/awesome_estate_property_offer_views.xml @@ -0,0 +1,97 @@ + + + + + + awesome.estate.property.offer.list + awesome.estate.property.offer + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + Property Types + awesome.estate.property.type + list,form + + +
diff --git a/awesome_estate/views/awesome_estate_property_views.xml b/awesome_estate/views/awesome_estate_property_views.xml new file mode 100644 index 00000000000..7302aed5946 --- /dev/null +++ b/awesome_estate/views/awesome_estate_property_views.xml @@ -0,0 +1,294 @@ + + + + + + awesome.estate.property.list + awesome.estate.property + + + + + + + + + + + + + + + + + + + + + + + awesome.estate.property.form + awesome.estate.property + +
+
+
+ +
+

+ +

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +