diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd654a1dd48..356afcb86ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,7 @@ repos: args: - --color - --fix + - --no-warn-ignored verbose: true types: [javascript] language: node diff --git a/audit/.gitignore b/audit/.gitignore new file mode 100644 index 00000000000..70158bba4e8 --- /dev/null +++ b/audit/.gitignore @@ -0,0 +1,3 @@ +**/**.DS_Store +**/**.pyc +__pycache__/ diff --git a/audit/README.md b/audit/README.md new file mode 100644 index 00000000000..1d25363593b --- /dev/null +++ b/audit/README.md @@ -0,0 +1,497 @@ +# Audit module — technical documentation + +This module provides a **generic audit/checklist engine** with a **dashboard-driven +workflow** to: + +- design an audit (domain → sections → questions), +- link the audit to auditable items (“targets”), +- conduct audits and store the result as immutable-ish “snapshots” (with scoring, + comments, and optional images), +- browse results with search + pagination and a summary view. + +The backend is standard Odoo ORM models; the UI entry point is an Odoo backend client +action implemented in Owl/JS. + +## Contents + +- [Module summary](#module-summary) +- [Concepts](#concepts) +- [Data model](#data-model) +- [Security and access control](#security-and-access-control) +- [Audit lifecycle](#audit-lifecycle) +- [Scoring](#scoring) +- [UI / dashboard implementation](#ui--dashboard-implementation) +- [Backend API used by the dashboard](#backend-api-used-by-the-dashboard) +- [Configuration constants](#configuration-constants) +- [Operational notes and known constraints](#operational-notes-and-known-constraints) +- [Troubleshooting](#troubleshooting) + +## Module summary + +- **Module name**: `audit` +- **Location**: `src/internal_tools/audit/` +- **Manifest**: `src/internal_tools/audit/__manifest__.py` +- **Depends on**: + - `base` + - `web` +- **Backend assets**: all files under `audit/static/src/**/*` are included in + `web.assets_backend`. + +## Concepts + +- **Domain** (`audit.domain`): a _type/class_ of audit (e.g. “Retail Store H&S”, + “Warehouse Safety”, “ISO 27001”). +- **Target** (`audit.target`): a _specific auditable item_ within a domain (e.g. “Store + #12”, “Warehouse Auckland”). +- **Section** (`audit.section`): groups questions within a domain (e.g. “Fire safety”, + “Staff training”). +- **Question** (`audit.question`): a question template (prompt + answer type). +- **Inspector** (`audit.inspector`): the person conducting the audit (can be linked to + `res.partner`). +- **Team** (`audit.team`): groups inspectors; leaders can see team members’ snapshots. +- **Snapshot** (`audit.snapshot`): a conducted audit instance for a target at a point in + time. +- **Snapshot Section** (`audit.snapshot_section`): a copy of each section template + stored on a snapshot. +- **Snapshot Question** (`audit.snapshot_question`): a copy of each question template + stored on a snapshot, plus answers/comments/images. + +## Data model + +### `audit.domain` (Audit Domain) + +- **Purpose**: defines an audit “template” boundary (sections, questions, and allowed + targets). +- **Key fields**: + - `name` (Text) + - `section_ids` (One2many → `audit.section`) + - `target_ids` (One2many → `audit.target` via `audit.target.domain_id`) — + legacy/simple linkage + - `target_rel_ids` (Many2many → `audit.target` via relation table + `audit_domain_target_rel`) — primary linkage used by UI + - `all_target_rel_ids` (computed/inverse Many2many) — merges `target_ids` + + `target_rel_ids` for a unified UI field + - **Key methods**: + - `action_duplicate_domain()`: duplicates a domain and deep-copies sections + + questions; links existing targets to the new domain. + +### `audit.section` (Audit Section) + +- **Purpose**: groups question templates under a domain. +- **Key fields**: + - `name` (Text) + - `domain_id` (Many2one → `audit.domain`, required) + - `question_ids` (One2many → `audit.question`) + +### `audit.question` (Audit Question Template) + +- **Purpose**: defines a question prompt and its answer type. +- **Key fields**: + - `prompt` (Text) + - `answer_type` (Selection): `boolean` | `integer` | `float` + - `section_id` (Many2one → `audit.section`, required) + - `name` (computed from `prompt`) — used as the record display name + +### `audit.target` (Auditable Target) + +- **Purpose**: the entity being audited. +- **Key fields**: + - `name` (Text) + - `domain_id` (Many2one → `audit.domain`, optional) + - `domain_rel_ids` (Many2many → `audit.domain` via `audit_domain_target_rel`) + - `all_domain_rel_ids` (computed/inverse Many2many) — merges `domain_id` + + `domain_rel_ids` + - `snapshot_ids` (One2many → `audit.snapshot`) + - **Key methods**: + - `merge()`: consolidates targets with the same name by re-pointing snapshots and + deleting duplicates. + +### `audit.domain_target_rel` (Domain ↔ Target relation) + +- **Purpose**: intermediate model/table used by the domain/target Many2many + relationship. +- **Key fields**: + - `domain_id` (Many2one → `audit.domain`, required) + - `target_id` (Many2one → `audit.target`, required) + +### `audit.inspector` (Inspector) + +- **Purpose**: identifies the auditor/inspector/manager who conducts a snapshot. +- **Key fields**: + - `name` (Char, required) + - `partner_id` (Many2one → `res.partner`, optional) + - `inspector_email` (related → `partner_id.email`, editable) + - `forename`, `surname` (Char) + - `active` (Boolean) + - `snapshot_ids` (One2many → `audit.snapshot`) + - `team_ids` (Many2many → `audit.team`) + +### `audit.team` (Audit Team) + +- **Purpose**: groups inspectors and defines leadership visibility. +- **Key fields**: + - `name` (Char, required) + - `team_member_ids` (Many2many → `audit.inspector`) + - `team_leader_ids` (Many2many → `audit.inspector`) + +### `audit.snapshot` (Audit Snapshot) + +- **Purpose**: represents one conducted audit event for a target. +- **Key fields**: + - `domain_id` (Many2one → `audit.domain`) + - `target_id` (Many2one → `audit.target`, required) + - `inspector_id` (Many2one → `audit.inspector`, required) + - `date_conducted` (Datetime, default: now) + - `snapshot_section_ids` (One2many → `audit.snapshot_section`, required) + - `locked` (Boolean): UI uses this to prevent “submit” again; backend does not + strictly enforce immutability + - `active` (Boolean): used for archive/unarchive semantics + - `percentage_score` (Float, computed): overall score in decimal form \(0.00–1.00\) + - `questions_with_comments` (Integer, computed): count of snapshot questions with + comments (computed only when locked) + - `team_id` (Many2one → `audit.team`, optional) + - **Creation behavior**: + - `create()` is overridden to auto-generate `audit.snapshot_section` records for every + `audit.section` in the chosen domain and `audit.snapshot_question` records for every + `audit.question` in each section. + +### `audit.snapshot_section` (Snapshot Section) + +- **Purpose**: stores a copy of the domain’s section structure at the time of snapshot + creation. +- **Key fields**: + - `name` (Text) + - `original_section_id` (Many2one → `audit.section`) + - `domain_id` (Many2one → `audit.domain`, required) + - `snapshot_id` (Many2one → `audit.snapshot`) + - `snapshot_question_ids` (One2many → `audit.snapshot_question`) + - `maximum_section_score`, `actual_section_score`, `percentage_section_score` + (computed) + +### `audit.snapshot_question` (Snapshot Question) + +- **Purpose**: stores a copy of each question, plus answer/comment/image fields for the + specific snapshot. +- **Key fields**: + - `snapshot_id` (Many2one → `audit.snapshot`) + - `snapshot_section_id` (Many2one → `audit.snapshot_section`, required) + - `original_question_id` (Many2one → `audit.question`) + - `prompt` (Text) + - `answer_type` (Char) + - Answer fields (only one is expected to be used per record): + - `answer_yn` (Selection): `"0"`/`"1"` + - `answer_star` (Selection): `"1"`..`"4"` + - `answer_perc` (Float): \(0–100\) + - `applicable` (Boolean, default `True`): excludes the question from snapshot + maximum/actual scoring when `False` + - `comment` (Text) + - `image` (Image): can be set from the dashboard using a base64 data URL payload + - `value` (Float, computed): normalized score used by snapshot scoring + - **Key methods**: + - `toggle_not_applicable(id)`: flips `applicable` and returns `{id, applicable}` (used + by the dashboard) + - `write(vals)`: if `image` is provided as `data:;base64,`, it strips + the prefix and stores only the base64 payload. + +## Security and access control + +### Groups + +- **Module category**: `Audit` (defined in `security/security.xml`) +- **Primary group**: `audit.group_audit_permission` + - implies `base.group_user` + - access rules are primarily done via standard model access and custom menu actions + +### Model access + +`security/ir.model.access.csv` grants full CRUD to `audit.group_audit_permission` for: + +- `audit.domain`, `audit.section`, `audit.question` +- `audit.target`, `audit.domain_target_rel` +- `audit.inspector`, `audit.team` +- `audit.snapshot`, `audit.snapshot_section`, `audit.snapshot_question` +- `audit.menu.access.control` (Transient model used by server actions) + +### Menu access control (server actions) + +The menus for Teams/Inspectors/Snapshots/Snapshot Sections/Snapshot Questions are wired +to **server actions** (`views/actions.xml`) that call methods on +`audit.menu.access.control`. + +Those methods return an `ir.actions.act_window` with a dynamic **domain**: + +- **Admin users** (`base.group_system`): see everything. +- **Team leaders**: see records for inspectors in teams they lead. +- **Non-leaders**: see only their own records. + +Important: this logic relies on an “inspector ↔ user” link. Parts of the implementation +reference `audit.inspector.res_user_id`, which is not currently defined on +`audit.inspector` in this module. See +[Operational notes and known constraints](#operational-notes-and-known-constraints). + +## Audit lifecycle + +### 1) Design an audit (Domain → Sections → Questions) + +- Create an `audit.domain`. +- Add `audit.section` records. +- Add `audit.question` records to each section, choosing an answer type: + - boolean (Yes/No) + - integer (1–4 stars) + - float (0–100% slider) +- Optionally duplicate an existing domain via **Duplicate** on the domain form + (deep-copies sections + questions). + +### 2) Define auditable items (Targets) + +- Create `audit.target` records. +- Link targets to domains using the `audit_domain_target_rel` relationship (exposed in + the domain “Targets in Domain” page and target “Linked Domains” fields). + +### 3) Set up inspectors and teams + +- Create `audit.inspector` records (often linked to `res.partner` via `partner_id`). +- Create `audit.team` records and assign members/leaders. + +### 4) Conduct an audit (create a Snapshot) + +From **Audit Dashboard**: + +- Select domain → target → inspector. +- Create the snapshot. + +The snapshot creation process copies the current audit design into the snapshot: + +- `audit.snapshot_section` rows are generated from `audit.section` rows for the domain. +- `audit.snapshot_question` rows are generated from `audit.question` rows for each + section. + +### 5) Answer questions (auto-save) + +In the “View Questions” screen: + +- Each question renders an input based on `answer_type`: + - boolean → Yes/No dropdown + - integer → star rating dropdown (1–4) + - float → percentage slider (0–100) +- Comments are saved immediately when edited. +- Images can be uploaded; the dashboard sends a base64 data URL which the backend + stores. +- A question can be toggled **Applicable / Excluded**; excluded questions are removed + from overall snapshot scoring. + +### 6) Submit the snapshot (lock) + +Submitting a snapshot sets `audit.snapshot.locked = True`. + +- The dashboard prevents double-submission and visually indicates locked snapshots. +- The backend does not strictly block edits to locked snapshots; “locked” is currently + used as a workflow flag rather than a hard data integrity constraint. + +### 7) View summary + +For locked snapshots, the dashboard provides a Summary view that: + +- groups questions by section, +- displays **only questions with comments**, +- displays any stored images, +- shows section scores and the overall snapshot score. + +## Scoring + +### Question-level score (`audit.snapshot_question.value`) + +Snapshot questions normalize their answer into a value in the range \(0.0–1.0\). + +Implementation (current): + +- boolean: + - `"1"` → \(1.0\) + - `"0"` → \(0.0\) +- star rating: + - `"1"`..`"4"` → \(\text{stars}/4\) → \(0.25..1.0\) +- percentage: + - `0..100` → \(\text{percent}/100\) → \(0.0..1.0\) + +Important: the compute currently adds all three components: + +\[ value = float(answer_yn) + \frac{float(answer_star)}{4} + \frac{answer_perc}{100} \] + +This works as intended **only if non-selected answer fields are empty-but-coercible to +0**, and only one answer input is used per question (as enforced by the UI). + +### Snapshot overall score (`audit.snapshot.percentage_score`) + +- **Maximum score**: counts **applicable** snapshot questions, with a weight of 1 per + question. +- **Actual score**: sum of `value` for **applicable** snapshot questions. +- **Percentage score**: `round(actual_score / maximum_score, 2)` stored as a decimal + \(0.00–1.00\). + +The dashboard displays \(percentage_score \times 100\%\). + +### Pass/Fail threshold + +- Backend constant: `PASS_THRESHOLD = 0.85` +- UI uses the same effective threshold (85%) when rendering PASS/FAIL in the snapshot + list. + +### Section scoring note + +`audit.snapshot_section` computes per-section scores, but the current implementation +does **not** exclude `applicable = False` questions from section maximum/actual +computations. Overall snapshot scoring does exclude them. + +## UI / dashboard implementation + +### Entry point (menu → client action) + +- Menu: `views/menus.xml` defines the top-level menu “Audit Dashboard”. +- Action: `views/actions.xml` registers an `ir.actions.client` with tag + `audit.dashboard`. +- Client action: `static/src/javascript_components/dashboard/dashboard.js` registers the + Owl component in the action registry. + +### Components + +- `AuditDashboard` (`dashboard.js`): router-like parent; switches between pages. +- `SnapshotList` (`snapshot_list.js`): table of searched snapshots; provides buttons to + view questions and summary. +- `CreateSnapShot` (`create_snapshot.js`): selects domain/target/inspector and creates a + snapshot. +- `Snapshot` (`snapshot.js`): renders sections/questions and implements auto-save + + submission. +- `SnapshotSummary` (`snapshot_summary.js`): summary of commented questions and images + for a locked snapshot. + +### Shared store + +`static/src/store.js` defines a reactive store used by the dashboard pages: + +- Search inputs: `searchText`, `searchDate`, `searchStatus`, `searchPage` +- Results: `searchedSnapshots`, `numberOfPages` +- `executeSearch()` calls the backend `audit.snapshot.custom_search()` +- Pagination UI uses the store’s computed `visiblePages` + +## Backend API used by the dashboard + +The dashboard calls backend methods using the standard `orm` service (RPC to Odoo +models). + +### Snapshot search + +- **Method**: `audit.snapshot.custom_search(search_string)` +- **Called by**: `store.executeSearch()` +- **Inputs**: `search_string` is JSON with keys: + - `searchText` (string) + - `searchDate` (string, date-like) + - `searchStatus` (`PASS`/`FAIL`) + - `searchPage` (int) + - **Output**: dict: + - `snapshots`: list of `search_read` dicts for `audit.snapshot` + - `numberOfPages`: int + - `newPageNumber`: int + +### Snapshot creation + +- **Method**: `audit.snapshot.create([vals])` +- **Called by**: `createSnapshotInstance()` in `dashboard_helpers.js` +- **Required fields** (enforced by backend override): + - `domain_id` + - `target_id` + - `inspector_id` +- **Side effects**: + - Generates snapshot sections + snapshot questions from the current domain template. + +### Loading snapshot questions for display + +The frontend typically loads questions by: + +- reading the snapshot (for `snapshot_section_ids`), +- then `searchRead` on `audit.snapshot_question` for + `snapshot_section_id in snapshot_section_ids`, +- then grouping by `snapshot_section_id[1]` (section display name). + +For image thumbnails, the UI searches `ir.attachment` records for the +`audit.snapshot_question.image` field and uses `/web/image/` as a URL. + +### Auto-save answers/comments/images + +- **Method**: `audit.snapshot_question.write([id], {vals})` +- **Called by**: `autoSaveQuestionChanges()` in `dashboard_helpers.js` +- **Notes**: + - if `vals.image` is a base64 data URL, backend `write()` strips the prefix before + saving. + +### Mark question not applicable + +- **Method**: `audit.snapshot_question.toggle_not_applicable([id])` +- **Called by**: `questionApplicable()` in `dashboard_helpers.js` +- **Effect**: flips `applicable` and returns the new value. + +### Submit snapshot (lock) + +- **Method**: `audit.snapshot.write([snapshot_id], {vals: {locked: true}})` +- **Called by**: `submit()` in `dashboard_helpers.js` + +## Configuration constants + +In `models/audit_snapshot.py`: + +- `PAGE_SIZE = 10`: number of snapshot rows per page returned by `custom_search()` +- `PASS_THRESHOLD = 0.85`: pass/fail cutoff used by `custom_search()` filtering and the + list decorations + +## Operational notes and known constraints + +These are important when deploying or extending the module; they affect correctness +and/or permissions. + +- **Inspector ↔ User linking is incomplete** + - Menu access control uses `audit.inspector.res_user_id`, but `audit.inspector` does + not define `res_user_id` in this module. + - Snapshot visibility in `audit.snapshot.snapshots_per_user()` tries to locate an + inspector by either `res_user_id` or `partner_id`; only `partner_id` exists here. + - Practical impact: non-admin users may see “no records” and/or menu actions may not + filter as expected unless your deployment adds this link elsewhere. + +- **Search fields are partially implemented** + - The frontend submits `searchDate`, but the backend does not filter by date. + - The backend attempts a `searchText` filter against a field named `search_text`, + which is not defined on `audit.snapshot` in this module. + +- **“Locked” is a workflow flag, not a hard constraint** + - UI disables the submit button and indicates locking state. + - Backend does not prevent `write()` on `audit.snapshot_question` (auto-save continues + to work even if locked). + +- **Section scoring differs from snapshot scoring** + - Snapshot scoring excludes `applicable = False`. + - Snapshot section scoring currently does not. + +- **Uniqueness constraints** + - Some models declare constraints using `models.Constraint(...)` instead of Odoo’s + standard `_sql_constraints`. Depending on your Odoo version/config, the uniqueness + guarantees described here may not be enforced at the database level. + +## Extending the module + +- **Add a new question answer type** + - Backend: + - update `audit.question.QUESTION_OPTIONS` in `models/audit_question.py` + - add fields and scoring logic to `audit.snapshot_question` in + `models/audit_snapshot.py` + - Frontend: + - update rendering and validation in + `static/src/javascript_components/dashboard/snapshot.js` + - update auto-save payload generation in + `static/src/javascript_components/dashboard/dashboard_helpers.js` + +- **Enforce immutability after submit** + - Add backend guards in `audit.snapshot_question.write()` and/or + `audit.snapshot.write()` to block edits when the parent snapshot is locked (except + for admin/system users). + +- **Make visibility rules robust** + - Add an explicit link from `audit.inspector` to `res.users` (e.g. `res_user_id`) and + standardize all access-control code to use the same link. diff --git a/audit/__init__.py b/audit/__init__.py new file mode 100644 index 00000000000..b4f7f97bf4e --- /dev/null +++ b/audit/__init__.py @@ -0,0 +1,4 @@ +"""Audit addon: models and wizards.""" + +from . import models +from . import wizards diff --git a/audit/__manifest__.py b/audit/__manifest__.py new file mode 100644 index 00000000000..d91b7ff40ef --- /dev/null +++ b/audit/__manifest__.py @@ -0,0 +1,28 @@ +# pylint: disable=missing-module-docstring,pointless-statement +{ + "name": "Audit", + "summary": ( + "Generic audit and checklist engine: domains, targets, snapshots, " + "and team-based access control." + ), + "author": "AMV Limited, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-tools", + "category": "Services", + "version": "19.0.1.0.0", + "license": "LGPL-3", + "application": True, + "depends": [ + "base", + "web", + ], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/views.xml", + "views/actions.xml", + "views/menus.xml", + ], + "assets": { + "web.assets_backend": ["audit/static/src/**/*"], + }, +} diff --git a/audit/models/__init__.py b/audit/models/__init__.py new file mode 100644 index 00000000000..c3f1e9d88c3 --- /dev/null +++ b/audit/models/__init__.py @@ -0,0 +1,9 @@ +"""Module docstring for init file.""" + +from . import audit_question +from . import audit_section +from . import audit_snapshot +from . import audit_target +from . import audit_domain +from . import audit_team +from . import audit_inspector diff --git a/audit/models/audit_domain.py b/audit/models/audit_domain.py new file mode 100644 index 00000000000..c1a5188dee4 --- /dev/null +++ b/audit/models/audit_domain.py @@ -0,0 +1,98 @@ +"""Classes and backend functionality for Audit module""" + +import logging +import random + +from odoo import Command, api, fields, models + +_logger = logging.getLogger(__name__) + + +# We can add things that can be audited here +class Domain(models.Model): + """ + Domain model: each audit has a unique domain. + This groups the kind of thing that can be audited, for example + retail stores or software security. Within a domain we audit individual records. + """ + + _name = "audit.domain" + _description = "Audit Domain" + + # We should not let duplicate domains to be created + _name_uniq = models.Constraint( + "unique (name)", + "'Domain Name' must be unique, this domain name already exists.", + ) + + name = fields.Text() + + target_ids = fields.One2many( + comodel_name="audit.target", inverse_name="domain_id", string="Audit Targets" + ) + + target_rel_ids = fields.Many2many( + comodel_name="audit.target", + relation="audit_domain_target_rel", + column1="domain_id", + column2="target_id", + string="Audit Targets (Many2many)", + ) + + section_ids = fields.One2many( + comodel_name="audit.section", inverse_name="domain_id", string="Audit Sections" + ) + + all_target_rel_ids = fields.Many2many( + comodel_name="audit.target", + compute="_compute_all_target_ids", + inverse="_inverse_all_target_ids", + string="All Targets", + store=False, # Do not store since it's computed dynamically + ) + + @api.depends("target_ids", "target_rel_ids") + def _compute_all_target_ids(self): + """Compute a combined Many2many field of all targets (One2many + Many2many)""" + for record in self: + target_records = record.target_ids | record.target_rel_ids + record.all_target_rel_ids = target_records # Assign merged targets + + def _inverse_all_target_ids(self): + """Ensure new targets added via UI are linked to Many2many (`target_rel_ids`)""" + for record in self: + record.target_rel_ids = record.all_target_rel_ids + + # Duplicate Audit Design all related section with questions + def action_duplicate_domain(self): + """Duplicate the audit domain and update Many2many relationships correctly.""" + for record in self: + unique_key = random.randint(1, 100000) + # Temporary name until the user renames; domain names are unique. + new_domain = record.copy( + {"name": f"{record.name} - Duplicate_{unique_key}"} + ) + + # Duplicate Sections & Questions + section_mapping = {} + for section in record.section_ids: + new_section = section.copy({"domain_id": new_domain.id}) + section_mapping[section.id] = new_section.id + + for question in section.question_ids: + question.copy({"section_id": new_section.id}) + + # Add targets to relationship table doesn't need to duplicate it + for target in record.target_ids: + new_domain.write({"target_rel_ids": [Command.link(target.id)]}) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Success", + "message": f"Domain '{new_domain.name}' duplicated successfully!", + "sticky": False, + "type": "success", + }, + } diff --git a/audit/models/audit_inspector.py b/audit/models/audit_inspector.py new file mode 100644 index 00000000000..3276a2fc166 --- /dev/null +++ b/audit/models/audit_inspector.py @@ -0,0 +1,107 @@ +"""Classes and backend functionality for Audit module""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class Inspector(models.Model): + """Inspector model: links partners and users for audit access.""" + + _name = "audit.inspector" + _description = "Audit Inspector" + + res_user_id = fields.Many2one( + comodel_name="res.users", + string="Linked User", + help="Odoo user for this inspector (dashboards and access control).", + index=True, + ondelete="set null", + ) + partner_id = fields.Many2one( + comodel_name="res.partner", + required=False, + ondelete="cascade", + string="Linked Partner", + ) + name = fields.Char( + string="Inspector Name", required=True, help="Name of the audit inspector" + ) + snapshot_ids = fields.One2many( + comodel_name="audit.snapshot", inverse_name="inspector_id", string="Snapshots" + ) + team_ids = fields.Many2many( + comodel_name="audit.team", + string="Teams", + ) + + # Optional: make the email visible directly on Inspector, + # but keep it *related* to the partner record + inspector_email = fields.Char( + related="partner_id.email", store=True, readonly=False + ) + + # 'Name' already exists on res.partner, but we still want + # a full-name field, so we compute it from the partner value + full_name = fields.Char(compute="_compute_full_name", store=True) + forename = fields.Char() + surname = fields.Char() + active = fields.Boolean(default=True) + + @api.depends("partner_id", "partner_id.name", "name") + def _split_name(self): + for rec in self: + if rec.partner_id and rec.partner_id.name: + parts = rec.partner_id.name.split(" ", 1) + rec.forename = parts[0] + rec.surname = parts[1] if len(parts) > 1 else "" + + elif rec.name: + parts = rec.name.split(" ", 1) + rec.forename = parts[0] + rec.surname = parts[1] if len(parts) > 1 else "" + + else: + rec.forename = "" + rec.surname = "" + + @api.depends("partner_id", "partner_id.name", "forename", "surname") + def _compute_full_name(self): + """Optional helper if you still want a display name field.""" + + for rec in self: + if rec.partner_id and rec.partner_id.name: + rec.full_name = rec.partner_id.name + + elif rec.forename or rec.surname: + rec.full_name = f"{rec.forename or ''} {rec.surname or ''}".strip() + + else: + rec.full_name = rec.name or "Unnamed Inspector" + + @api.model_create_multi + def create(self, vals_list): + """Ensure partner has required fields for delegation inheritance""" + for vals in vals_list: + # If no partner_id provided, we need to ensure the partner gets a name + if not vals.get("partner_id"): + # Build a name from available fields + name_parts = [] + if vals.get("forename"): + name_parts.append(vals["forename"]) + if vals.get("surname"): + name_parts.append(vals["surname"]) + + if name_parts: + # Use the constructed name + vals["name"] = " ".join(name_parts) + elif vals.get("inspector_email"): + # Use email as fallback name + vals["name"] = vals["inspector_email"] + elif not vals.get("name"): + # Last resort - use a generic name + vals["name"] = "Inspector" + + return super().create(vals_list) diff --git a/audit/models/audit_question.py b/audit/models/audit_question.py new file mode 100644 index 00000000000..f8bd3fa510c --- /dev/null +++ b/audit/models/audit_question.py @@ -0,0 +1,36 @@ +"""Classes and backend functionality for Audit module""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class Question(models.Model): + """ + Question: each section has one or more questions; each domain has sections. + """ + + _name = "audit.question" + _description = "Questions for the form" + + BOOLEAN = "boolean" + INTEGER = "integer" + FLOAT = "float" + + QUESTION_OPTIONS = [ + (BOOLEAN, "True / False"), + (INTEGER, "Star Rating"), + (FLOAT, "Percentage"), + ] + + prompt = fields.Text() + answer_type = fields.Selection(selection=QUESTION_OPTIONS, required=True) + name = fields.Char(compute="_compute_name", store=True) + section_id = fields.Many2one(comodel_name="audit.section", required=True) + + @api.depends("prompt") + def _compute_name(self): + for record in self: + record.name = record.prompt diff --git a/audit/models/audit_section.py b/audit/models/audit_section.py new file mode 100644 index 00000000000..cacd3eaba05 --- /dev/null +++ b/audit/models/audit_section.py @@ -0,0 +1,22 @@ +"""Classes and backend functionality for Audit module""" + +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class Section(models.Model): + """Section model: an audit has many sections.""" + + _name = "audit.section" + _description = "To group questions" + + name = fields.Text() + question_ids = fields.One2many( + comodel_name="audit.question", inverse_name="section_id", string="Questions" + ) + domain_id = fields.Many2one( + comodel_name="audit.domain", string="Domain Name", required=True + ) diff --git a/audit/models/audit_snapshot.py b/audit/models/audit_snapshot.py new file mode 100644 index 00000000000..623a65dcddf --- /dev/null +++ b/audit/models/audit_snapshot.py @@ -0,0 +1,622 @@ +"""Classes and backend functionality for Audit module""" + +import json +import logging +from datetime import datetime +from math import ceil + +import pytz + +from odoo import api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +# Copy the snapshot IDs across +# UPDATE audit_snapshot_question +# SET snapshot_id = audit_snapshot.id +# FROM audit_snapshot +# JOIN audit_snapshot_section ON audit_snapshot.id = audit_snapshot_section.snapshot_id +# WHERE audit_snapshot_question.snapshot_section_id = audit_snapshot_section.id + + +# Need start and end date times for the day in NZ +# This date_str will be in yyyy-mm-dd format +def convert_nz_to_utc(date_str): + """ + Thank you chatgpt + """ + time_format = "%Y-%m-%d %H:%M:%S.%f" + + # Define the time zones + nz_tz = pytz.timezone("Pacific/Auckland") + utc_tz = pytz.utc + + # Parse the input date string as yyyy-mm-dd + nz_start_time = datetime.strptime(date_str + " 00:00:00.999", time_format) + nz_end_time = datetime.strptime(date_str + " 23:59:59.999", time_format) + + # Localize the datetime to New Zealand time zone (to consider daylight saving) + nz_start_time = nz_tz.localize(nz_start_time) + nz_end_time = nz_tz.localize(nz_end_time) + + # Convert to UTC + utc_start_time = nz_start_time.astimezone(utc_tz) + utc_end_time = nz_end_time.astimezone(utc_tz) + + # Return the UTC time in the same yyyy-mm-dd format + return { + "utc_start_time": utc_start_time.strftime(time_format), + "utc_end_time": utc_end_time.strftime(time_format), + } + + +class SnapshotSection(models.Model): + """Snapshot section: a copy of each section when an audit snapshot is created.""" + + _name = "audit.snapshot_section" + _description = "We copy the section at the time the audit snapshot is created" + + original_section_id = fields.Many2one(comodel_name="audit.section") + name = fields.Text() + snapshot_question_ids = fields.One2many( + comodel_name="audit.snapshot_question", + inverse_name="snapshot_section_id", + string="Questions", + ) + domain_id = fields.Many2one( + comodel_name="audit.domain", string="Domain", required=True + ) + snapshot_id = fields.Many2one( + comodel_name="audit.snapshot", string="Audit Snapshot" + ) + + # Section scores roll up to overall and per-section scores. + maximum_section_score = fields.Float( + compute="_compute_maximum_section_score", store=True + ) + + @api.depends("snapshot_question_ids") + def _compute_maximum_section_score(self): + for record in self: + record.maximum_section_score = 0.0 + for _question in record.snapshot_question_ids: + record.maximum_section_score += 1 + + actual_section_score = fields.Float( + compute="_compute_actual_section_score", store=True + ) + + @api.depends("snapshot_question_ids", "snapshot_question_ids.value") + def _compute_actual_section_score(self): + for record in self: + record.actual_section_score = 0.0 + for question in record.snapshot_question_ids: + record.actual_section_score += question.value + + percentage_section_score = fields.Float( + compute="_compute_percentage_section_score", store=True + ) + + @api.depends("actual_section_score", "maximum_section_score") + def _compute_percentage_section_score(self): + for record in self: + if record.maximum_section_score == 0: + record.percentage_section_score = 0 + else: + record.percentage_section_score = round( + (record.actual_section_score / record.maximum_section_score), 2 + ) + + +class SnapshotQuestion(models.Model): + """A snapshot of each question on the audit (one per Question on the template).""" + + _name = "audit.snapshot_question" + _description = "Copy of the question" + + YES = "1" + NO = "0" + + ANSWER_SELECTION = [(YES, "Yes"), (NO, "No")] + + ONE_STAR = "1" + TWO_STAR = "2" + THREE_STAR = "3" + FOUR_STAR = "4" + + STAR_SELECTION = [ + (ONE_STAR, "*"), + (TWO_STAR, "* *"), + (THREE_STAR, "* * *"), + (FOUR_STAR, "* * * *"), + ] + + snapshot_id = fields.Many2one(comodel_name="audit.snapshot") + snapshot_section_id = fields.Many2one( + comodel_name="audit.snapshot_section", string="Section", required=True + ) + original_question_id = fields.Many2one(comodel_name="audit.question") + prompt = fields.Text() + answer_type = fields.Char(required=True) + + answer_yn = fields.Selection(selection=ANSWER_SELECTION) + answer_star = fields.Selection(selection=STAR_SELECTION) + answer_perc = fields.Float() # min=0, max=100 + # All questions are by default applicable, hence default=True + applicable = fields.Boolean(string="applicable", default=True) + comment = fields.Text() + image = fields.Image(string="image") + value = fields.Float(compute="_compute_value") + + @api.depends("answer_yn", "answer_star", "answer_perc") + def _compute_value(recordset): + for record in recordset: + record.value = ( + float(record.answer_yn) + + (float(record.answer_star) / 4) + + (record.answer_perc / 100) + ) + + @api.model + def toggle_not_applicable(self, *args, **kwargs): + """ + Toggle the applicable field for the question. + Checks to see if snapshot is locked or not. + """ + snapshot_question = self.env["audit.snapshot_question"].browse(args[0]) + # if not snapshot_question.snapshot_id.locked: # this requirement was removed + snapshot_question.applicable = not snapshot_question.applicable + return {"id": snapshot_question.id, "applicable": snapshot_question.applicable} + + +class Snapshot(models.Model): + """One record per completed audit (a snapshot of that run).""" + + _name = "audit.snapshot" + _description = "Performing an audit creates a 'snapshot' of the item being audited." + _order = "date_conducted desc" + + domain_id = fields.Many2one(comodel_name="audit.domain", string="Domain") + target_id = fields.Many2one( + comodel_name="audit.target", string="Target", required=True + ) + date_conducted = fields.Datetime(default=datetime.today()) + inspector_id = fields.Many2one( + comodel_name="audit.inspector", string="Inspector / Manager", required=True + ) + notes = fields.Text() + snapshot_section_ids = fields.One2many( + comodel_name="audit.snapshot_section", + inverse_name="snapshot_id", + string="Sections", + required=True, + ) + + locked = fields.Boolean(string="locked", default=False) + name = fields.Text(compute="_compute_name") + maximum_score = fields.Float(compute="_compute_maximum_score") + actual_score = fields.Float(compute="_compute_actual_score") + # Per-section overall score, capped in logic so it does not exceed 100%. + percentage_score = fields.Float(compute="_compute_percentage_score", store=True) + # Count of snapshot questions on this snapshot that have a comment. + questions_with_comments = fields.Integer(compute="_compute_questions_with_comments") + # Active records are not archived. + active = fields.Boolean(default=True) + + search_text = fields.Char(compute="_compute_search_text", store=True) + team_id = fields.Many2one( + comodel_name="audit.team", string="Team", ondelete="restrict", required=False + ) + + def get_snapshot_questions(self): + """Get the questions for a particular snapshot.""" + snapshot = self + if not snapshot: + return {} + + snapshot.ensure_one() + snapshot_questions = self.env["audit.snapshot_question"].search_read( + [("snapshot_section_id", "in", snapshot.snapshot_section_ids.ids)] + ) + _sections_and_questions = {} + for snapshot_question in snapshot_questions: + if ( + snapshot_question["snapshot_section_id"][1] + not in _sections_and_questions.keys() # pylint: disable=consider-iterating-dictionary + ): + _sections_and_questions[ + snapshot_question["snapshot_section_id"][1] + ] = [] + + for snapshot_question in snapshot_questions: + _sections_and_questions.get( + snapshot_question["snapshot_section_id"][1], None + ).append(snapshot_question) + + return { + "sections": [section.name for section in self.snapshot_section_ids], + "questions": _sections_and_questions, + "all_locked": self.locked, + } + + @api.depends("domain_id", "target_id") + def _compute_search_text(self): + r""" + Search text compute: do not remove str() conversions. + + This runs during new snapshot creation; removing str() breaks snapshot creation + in odoo-test and odoo-prod (not reproduced locally). + """ + for record in self: + try: + record.search_text = "" + + if record.domain_id: + domain_name = record.domain_id.name + if isinstance(domain_name, bool): + domain_name = str(domain_name) + record.search_text += str(domain_name) + + if record.target_id: + target_name = record.target_id.name + if isinstance(target_name, bool): + target_name = str(target_name) + record.search_text += str(target_name) + + if record.inspector_id: + inspector_name = record.inspector_id.name + if isinstance(inspector_name, bool): + inspector_name = str(inspector_name) + record.search_text += str(inspector_name) + + except Exception as e: + _logger.error( + "Snapshot::_compute_search_text: error for record %s: %s", + record.id, + str(e), + ) + raise UserError( + self.env._( + "Failed to compute search text for snapshot %(id)s: %(err)s", + id=record.id, + err=str(e), + ) + ) from e + + # Create snapshot sections and questions from the domain's template. + @api.model + def create(self, vals): + """Overriding the create function.""" + if isinstance(vals, list): + # List create: use first item only (expected for this use case) + vals = vals[0] if vals else {} + + # We can supply existing links + domain_id = vals.get("domain_id", None) + target_id = vals.get("target_id", None) + inspector_id = vals.get("inspector_id", None) + # Or we can create some + new_target_name = vals.get("new_target_name", None) + new_inspector_name = vals.get("new_inspector_name", None) + + if domain_id is None: + raise UserError(self.env._("Must supply domain")) + if target_id is None and new_target_name is None: + raise UserError(self.env._("Must supply target or target name")) + if target_id is None and new_target_name is not None: + target = self.env["audit.target"].create( + { + "name": new_target_name, + "domain_rel_ids": [domain_id], + } + ) + target_id = target.id + if inspector_id is None and new_inspector_name is None: + raise UserError(self.env._("Must supply inspector or inspector name")) + if inspector_id is None and new_inspector_name is not None: + inspector = self.env["audit.inspector"].create( + { + "forename": new_inspector_name["forename"], + "surname": new_inspector_name["surname"], + } + ) + inspector_id = inspector.id + + # Create snapshot, then link snapshot sections and questions. + # team_id: from vals, else first available team + team_id = vals.get("team_id") + if not team_id: + team = self.env["audit.team"].search([], limit=1) + team_id = team.id if team else None + + new_snapshot = super().create( + { + "domain_id": domain_id, + "target_id": target_id, + "inspector_id": inspector_id, + "team_id": team_id, + } + ) + + # Now create snapshot_sections for each section linked to the domain id. + current_sections = self.env["audit.section"].search( + domain=[("domain_id", "=", domain_id)] + ) + for section in current_sections: + snapshot_section = self.env["audit.snapshot_section"].create( + { + "original_section_id": section.id, + "name": section.name, + "domain_id": domain_id, + "snapshot_id": new_snapshot.id, + } + ) + current_questions = self.env["audit.question"].search( + domain=[("section_id", "=", section.id)] + ) + + for question in current_questions: + self.env["audit.snapshot_question"].create( + { + "original_question_id": question.id, + "snapshot_id": new_snapshot.id, + "prompt": question.prompt, + "snapshot_section_id": snapshot_section.id, + "answer_type": question.answer_type, + } + ) + + # pylint: disable=protected-access + # Sections and questions are created; compute max score (not from listeners) + new_snapshot._compute_maximum_score() + _logger.info(f"Snapshot::create > After _compute_maximum_score {new_snapshot}") + return new_snapshot + + @api.depends("target_id", "target_id.name", "date_conducted") + def _compute_name(self): + for record in self: + if record.target_id: + record.name = "{} [{}]".format( + record.target_id.name, record.date_conducted.strftime("%y-%m-%d") + ) + else: + record.name = "Unnamed Snapshot" + + @api.depends( + "snapshot_section_ids", + "snapshot_section_ids.snapshot_question_ids", + "snapshot_section_ids.snapshot_question_ids.applicable", + ) + def _compute_maximum_score(snapshots): # pylint disable=no-self-use + r""" + Avoid Odoo re-evaluating on all records: loop snapshots explicitly to prevent + Singleton errors when computing `maximum_score`. + """ + for snapshot in snapshots: + snapshot.maximum_score = 0 + for section in snapshot.snapshot_section_ids: + for question in section.snapshot_question_ids: + if question.applicable: + snapshot.maximum_score += 1 + + @api.depends( + "snapshot_section_ids", + "snapshot_section_ids.snapshot_question_ids", + "snapshot_section_ids.snapshot_question_ids.value", + ) + def _compute_actual_score(snapshots): # pylint disable=no-self-use + """ + Add up only correct answers. + """ + for snapshot in snapshots: + snapshot.actual_score = 0 + for section in snapshot.snapshot_section_ids: + for question in section.snapshot_question_ids: + if question.applicable: + snapshot.actual_score += question.value + + @api.depends("actual_score", "maximum_score") + def _compute_percentage_score(snapshots): + for snapshot in snapshots: + if snapshot.maximum_score == 0: + snapshot.percentage_score = 0 + else: + # Shown in UI via a widget that maps decimal to float + snapshot.percentage_score = round( + (snapshot.actual_score / snapshot.maximum_score), 2 + ) + + def snapshot_percentage_score(self, snapshots: list): + """ + Recompute actual, max, and percentage score for the given snapshot dicts. + Called from the frontend to refresh only the rows in view. + """ + for snapshot in snapshots: + # Find the snapshot object + snapshot: object = self.env["audit.snapshot"].search( + [("id", "=", snapshot.get("id"))] + ) + # Recompute scores first to avoid division by zero in percentage + # pylint: disable=protected-access + snapshot._compute_actual_score() + snapshot._compute_maximum_score() + snapshot._compute_percentage_score() + # pylint: enable=protected-access + + @api.depends("snapshot_section_ids") + def _compute_questions_with_comments(snapshots): # pylint disable=no-self-use + """If locked, count how many questions have a non-empty comment.""" + for snapshot in snapshots: + snapshot.questions_with_comments = 0 + + if snapshot.read()[0]["locked"]: + snapshot_questions = snapshot.env["audit.snapshot_question"].search( + [ + ( + "snapshot_section_id", + "in", + snapshot.read()[0]["snapshot_section_ids"], + ) + ] + ) + for snapshot_question in snapshot_questions: + if snapshot_question.read()[0]["comment"]: + snapshot.questions_with_comments += 1 + + # JSON search API for the dashboard: flexible filters, always paginated. + PAGE_SIZE = 15 + PASS_THRESHOLD = 0.85 + + @api.model + def custom_search(self, search_string): + """ + This helps search for audits + """ + _logger.info(search_string) + search_object = json.loads(search_string) + search_query = [("active", "=", True)] + # Search name + if search_object.get("searchText"): + search_query.append( + ( + "search_text", + "ilike", + "%" + str(search_object.get("searchText")) + "%", + ) + ) + # Search status + if search_object.get("searchStatus"): + if str(search_object.get("searchStatus")) == "PASS": + search_query.append(("percentage_score", ">=", self.PASS_THRESHOLD)) + elif str(search_object.get("searchStatus")) == "FAIL": + search_query.append(("percentage_score", "<", self.PASS_THRESHOLD)) + if search_object.get("searchDate"): + # NZ local day [00:00, 24:00) to UTC (Pacific/Auckland). + utc_dates = convert_nz_to_utc(search_object.get("searchDate")) + search_query.append(("date_conducted", ">=", utc_dates["utc_start_time"])) + search_query.append(("date_conducted", "<=", utc_dates["utc_end_time"])) + + # We need to make these conditions overlapping, so all must be met to filter. + for _i in range(0, len(search_query) - 1): + search_query.insert(0, "&") + + # Get count (to compute page number) + snapshot_count = self.env["audit.snapshot"].search_count(search_query) + page_count = ceil(snapshot_count / self.PAGE_SIZE) + # If page number submitted is too high, fix that + page_number = search_object.get("searchPage") + if page_number > page_count > 0: + page_number = page_count + + # Get data + logged_in_user_snapshosts, logged_in_user_snapshosts_count = ( + self.snapshots_per_user(search_query=search_query, page_number=page_number) + ) + + # Update the snapshots in question before returning them + self.snapshot_percentage_score(logged_in_user_snapshosts) + + return { + "snapshots": logged_in_user_snapshosts, + "numberOfPages": ceil(logged_in_user_snapshosts_count / self.PAGE_SIZE), + "newPageNumber": page_number, + } + + # pylint: disable=too-many-return-statements + def snapshots_per_user( + self, search_query: list, page_number: int + ) -> tuple[list, int]: + """ + Visibility rules for snapshot search: + 1) Admin: all active snapshots + 2) Team leader: own team's inspectors and leaders + 3) Other inspector: own snapshots + 4) No inspector profile: none + """ + logged_in_user = self.env.user + admin_user = logged_in_user.has_group( + "base.group_system" + ) # Users/Administration/Settings + # If the user is an admin user then they should see all snapshots + offset = ((page_number - 1) * self.PAGE_SIZE) if page_number > 0 else 0 + if admin_user: + return self.env["audit.snapshot"].search_read( + search_query, + limit=self.PAGE_SIZE, + offset=offset, + order="date_conducted desc", + ), self.env["audit.snapshot"].search_count([("active", "=", True)]) + + # Find inspector row for this user (res.user or partner). + inspector = self.env["audit.inspector"].search( + [ + "|", + ("res_user_id", "=", logged_in_user.id), + ("partner_id", "=", logged_in_user.partner_id.id), + ], + limit=1, + ) + + # No inspector? no snapshots + if not inspector: + return [], 0 + + team = inspector and self.env["audit.team"].search( + [ + "|", + ("team_member_ids", "in", inspector.id), + ("team_leader_ids", "in", inspector.id), + ] + ) + team_leader = team and team.team_leader_ids + team_members = team and team.team_member_ids + + if inspector and team: + if inspector.id in team_leader.ids: # Logged-in user is a team leader + return self.env["audit.snapshot"].search_read( + [ + ( + "inspector_id", + "in", + list(set(team_members.ids + team_leader.ids)), + ) + ], + limit=self.PAGE_SIZE, + offset=offset, + order="date_conducted desc", + ), self.env["audit.snapshot"].search_count( + [ + ("active", "=", True), + ( + "inspector_id", + "in", + set(team_members.ids + team_leader.ids), + ), + ] + ) + + if inspector.id not in team_leader.ids: # Logged-in user not a team leader + return self.env["audit.snapshot"].search_read( + [("inspector_id", "=", inspector.id)], + limit=self.PAGE_SIZE, + offset=offset, + order="date_conducted desc", + ), self.env["audit.snapshot"].search_count( + [("active", "=", True), ("inspector_id", "=", inspector.id)] + ) + + return [], 0 + + if inspector and not team: # Inspector not part of a team + return self.env["audit.snapshot"].search_read( + [("inspector_id", "=", inspector.id)], + limit=self.PAGE_SIZE, + offset=offset, + order="date_conducted desc", + ), self.env["audit.snapshot"].search_count( + [("active", "=", True), ("inspector_id", "=", inspector.id)] + ) + + return [], 0 diff --git a/audit/models/audit_target.py b/audit/models/audit_target.py new file mode 100644 index 00000000000..e84e47d44b1 --- /dev/null +++ b/audit/models/audit_target.py @@ -0,0 +1,192 @@ +"""Classes and backend functionality for Audit module""" + +import logging + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class DomainTargetRel(models.Model): + """M2M link between audit domains and targets (one target per domain row).""" + + _name = "audit.domain_target_rel" + _description = "Audit Domain Target Relation" + _rec_name = "target_id" + _order = "id asc" + + _unique_domain_target = models.Constraint( + "unique(domain_id, target_id)", + "This target is already linked to the domain.", + ) + + domain_id = fields.Many2one( + comodel_name="audit.domain", + string="Audit Domain", + required=True, + ondelete="cascade", + ) + target_id = fields.Many2one( + comodel_name="audit.target", + string="Audit Target", + required=True, + ondelete="cascade", + ) + + +class Target(models.Model): + """ + Target model: an audit can have one or more targets per domain. + A specific business subject within a domain that is being audited. + """ + + _name = "audit.target" + _description = "Audit Target" + _order = "name asc" + + name = fields.Text() + domain_id = fields.Many2one( + comodel_name="audit.domain", string="Audit Domain", required=False + ) + + domain_rel_ids = fields.Many2many( + comodel_name="audit.domain", + relation="audit_domain_target_rel", + column1="target_id", + column2="domain_id", + string="Linked Audit Domains", + ) + + snapshot_ids = fields.One2many( + comodel_name="audit.snapshot", + inverse_name="target_id", + string="Audit Snapshots", + ) + + all_domain_rel_ids = fields.Many2many( + comodel_name="audit.domain", + compute="_compute_all_domain_ids", + inverse="_inverse_all_domain_ids", + string="All Domains", + store=False, # Do not store, computed dynamically + ) + + @api.depends("domain_id", "domain_rel_ids") + def _compute_all_domain_ids(self): + """Compute a Many2many field combining Main Domain and Related Domains""" + for record in self: + domain_records = record.domain_rel_ids + if record.domain_id: + domain_records |= record.domain_id # Add the primary domain + + record.all_domain_rel_ids = domain_records # Assign the merged domains + + def _inverse_all_domain_ids(self): + """Ensure new domain added via UI are linked to Many2many (`domain_rel_ids`)""" + for record in self: + record.domain_rel_ids = record.all_domain_rel_ids + + @api.model + def create(self, vals_list): + """Set ``domain_id`` if missing; reject duplicate target names.""" + # Handle both single dict and list of dicts + if not isinstance(vals_list, list): + vals_list = [vals_list] + + for vals in vals_list: + # Set domain_id from all_domain_rel_ids if missing + if not vals.get("domain_id") and vals.get("all_domain_rel_ids"): + domain_rel_ids = ( + vals["all_domain_rel_ids"][0][2] + if isinstance(vals["all_domain_rel_ids"], list) + else [] + ) + if domain_rel_ids: + vals["domain_id"] = domain_rel_ids[0] + + # Prevent duplicate targets with the same name in the same domain + existing_target = self.env["audit.target"].search( + [("name", "=", vals.get("name"))], limit=1 + ) + + if existing_target: + raise ValidationError( + self.env._( + "A target with the name '%(name)s' already exists !", + name=vals.get("name"), + ) + ) + + # Call parent create method with the processed vals_list + return super().create(vals_list) + + def link_to_domain(self, domain_id, target_id): + """ + Check if link exists already, if not link it + """ + domain_links = self.env["audit.domain_target_rel"].search( + [("target_id", "=", target_id), ("domain_id", "=", domain_id)] + ) + if bool(domain_links) is False: + self.env["audit.domain_target_rel"].create( + { + "domain_id": domain_id, + "target_id": target_id, + } + ) + + def merge(self): + """ + This will find matching targets then overwrite their IDs in the snapshot table + """ + if self.domain_id: + self.link_to_domain(self.domain_id.id, self.id) + matching_targets = self.env["audit.target"].search([("name", "=", self.name)]) + for matching_target in matching_targets: + if matching_target.id != self.id: + # Move snapshots to original target + for snapshot in self.env["audit.snapshot"].search( + [("target_id", "=", matching_target.id)] + ): + snapshot.target_id = self.id + # Check for domains that need to be merged as well + if matching_target.domain_id: + self.link_to_domain(matching_target.domain_id.id, self.id) + matching_target.unlink() + # Clear primary domain and M2M links, otherwise `write` below re-fills + # ``domain_id`` from ``all_domain_rel_ids`` when only ``domain_id`` is cleared. + self.write( + { + "domain_id": False, + "domain_rel_ids": [(5, 0, 0)], + } + ) + + def write(self, vals): + """Update domain from related fields and block duplicate target names.""" + for record in self: + new_name = vals.get("name", record.name) + duplicate = self.env["audit.target"].search( + [ + ("name", "=", new_name), + ("id", "!=", record.id), # Exclude current record to allow updates + ], + limit=1, + ) + + if duplicate: + raise ValidationError( + self.env._( + "A target with the name '%(name)s' already exists !", + name=new_name, + ) + ) + + # Ensure domain_id is assigned if missing + res = super().write(vals) + for record in self: + if not record.domain_id and record.all_domain_rel_ids: + record.domain_id = record.all_domain_rel_ids[0] + + return res diff --git a/audit/models/audit_team.py b/audit/models/audit_team.py new file mode 100644 index 00000000000..eff3876829b --- /dev/null +++ b/audit/models/audit_team.py @@ -0,0 +1,29 @@ +"""Audit team models for the audit app.""" + +from odoo import fields, models + + +class AuditTeam(models.Model): + """Represents a team of inspectors and leaders for audits.""" + + _name = "audit.team" + _description = "Audit Team" + + _name_unique = models.Constraint("UNIQUE(name)", "Team name must be unique!") + + name = fields.Char(required=True) + team_member_ids = fields.Many2many( + comodel_name="audit.inspector", + relation="audit_team_member_rel", + column1="team_id", + column2="user_id", + string="Team Members", + ) + # Leaders can see everyone's snapshots in that team + team_leader_ids = fields.Many2many( + comodel_name="audit.inspector", + relation="audit_team_leader_rel", + column1="team_id", + column2="user_id", + string="Team Leaders", + ) diff --git a/audit/pyproject.toml b/audit/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/audit/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/audit/security/ir.model.access.csv b/audit/security/ir.model.access.csv new file mode 100644 index 00000000000..1688c2819d8 --- /dev/null +++ b/audit/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_audit_domain,audit.domain,model_audit_domain,audit.group_audit_permission,1,1,1,1 +access_audit_target,audit.target,model_audit_target,audit.group_audit_permission,1,1,1,1 +access_audit_inspector,audit.inspector,model_audit_inspector,audit.group_audit_permission,1,1,1,1 +access_audit_section,audit.section,model_audit_section,audit.group_audit_permission,1,1,1,1 +access_audit_question,audit.question,model_audit_question,audit.group_audit_permission,1,1,1,1 +access_audit_snapshot,audit.snapshot,model_audit_snapshot,audit.group_audit_permission,1,1,1,1 +access_audit_snapshot_section,audit.snapshot_section,model_audit_snapshot_section,audit.group_audit_permission,1,1,1,1 +access_audit_snapshot_question,audit.snapshot_question,model_audit_snapshot_question,audit.group_audit_permission,1,1,1,1 +access_audit_domain_target_rel,audit.domain_target_rel,model_audit_domain_target_rel,audit.group_audit_permission,1,1,1,1 +access_audit_menu_access_control,audit.menu.access.control,model_audit_menu_access_control,audit.group_audit_permission,1,1,1,1 +access_audit_team_user,audit.team user,model_audit_team,audit.group_audit_permission,1,1,1,1 +access_audit_team_admin,audit.team admin,model_audit_team,audit.group_audit_permission,1,1,1,1 diff --git a/audit/security/security.xml b/audit/security/security.xml new file mode 100644 index 00000000000..ded916963e8 --- /dev/null +++ b/audit/security/security.xml @@ -0,0 +1,19 @@ + + + + Audit + 230 + + + + Audit + + 1 + + + + Audit Permission + + + + diff --git a/audit/static/description/icon-512.png b/audit/static/description/icon-512.png new file mode 100644 index 00000000000..2c799d6ec90 Binary files /dev/null and b/audit/static/description/icon-512.png differ diff --git a/audit/static/description/icon.png b/audit/static/description/icon.png new file mode 100644 index 00000000000..9bc8e2485af Binary files /dev/null and b/audit/static/description/icon.png differ diff --git a/audit/static/src/css/audit_styles.css b/audit/static/src/css/audit_styles.css new file mode 100644 index 00000000000..3ffcb2e7fa4 --- /dev/null +++ b/audit/static/src/css/audit_styles.css @@ -0,0 +1,18 @@ +/* Pass/Fail styling for percentage scores */ +.pass-fail-score.text-success::before { + content: "PASS"; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.95rem; + margin-right: 6px; + font-weight: bold; +} + +.pass-fail-score.text-danger::before { + content: "FAIL"; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.95rem; + margin-right: 6px; + font-weight: bold; +} diff --git a/audit/static/src/css/audit_team.css b/audit/static/src/css/audit_team.css new file mode 100644 index 00000000000..9ae1b2afd61 --- /dev/null +++ b/audit/static/src/css/audit_team.css @@ -0,0 +1,11 @@ +/* Audit Team Name Styling - Targeted but reliable selector */ +.o_field_char.audit_team_name_field { + background-color: #d1ecf1 !important; + color: #0c5460 !important; + border: 3px solid #bee5eb !important; + border-radius: 10px !important; + max-width: fit-content !important; + padding: 8px 12px !important; + font-weight: 500 !important; + margin-bottom: 30px !important; +} diff --git a/audit/static/src/css/fontawesome.js b/audit/static/src/css/fontawesome.js new file mode 100644 index 00000000000..4ba7e1ddeb0 --- /dev/null +++ b/audit/static/src/css/fontawesome.js @@ -0,0 +1,743 @@ +window.FontAwesomeKitConfig = { + id: 38106326, + version: "6.5.1", + token: "2263bf088c", + method: "css", + baseUrl: "https://ka-f.fontawesome.com", + license: "free", + asyncLoading: {enabled: false}, + autoA11y: {enabled: true}, + baseUrlKit: "https://kit.fontawesome.com", + detectConflictsUntil: null, + iconUploads: {}, + minify: {enabled: true}, + v4FontFaceShim: {enabled: true}, + v4shim: {enabled: true}, + v5FontFaceShim: {enabled: true}, +}; +!(function (t) { + "function" == typeof define && define.amd ? define("kit-loader", t) : t(); +})(function () { + "use strict"; + function t(t, e) { + var n = Object.keys(t); + if (Object.getOwnPropertySymbols) { + var r = Object.getOwnPropertySymbols(t); + (e && + (r = r.filter(function (e) { + return Object.getOwnPropertyDescriptor(t, e).enumerable; + })), + n.push.apply(n, r)); + } + return n; + } + function e(e) { + for (var n = 1; n < arguments.length; n++) { + var o = null != arguments[n] ? arguments[n] : {}; + n % 2 + ? t(Object(o), !0).forEach(function (t) { + r(e, t, o[t]); + }) + : Object.getOwnPropertyDescriptors + ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(o)) + : t(Object(o)).forEach(function (t) { + Object.defineProperty( + e, + t, + Object.getOwnPropertyDescriptor(o, t) + ); + }); + } + return e; + } + function n(t) { + return (n = + "function" == typeof Symbol && "symbol" == typeof Symbol.iterator + ? function (t) { + return typeof t; + } + : function (t) { + return t && + "function" == typeof Symbol && + t.constructor === Symbol && + t !== Symbol.prototype + ? "symbol" + : typeof t; + })(t); + } + function r(t, e, n) { + return ( + (e = (function (t) { + var e = (function (t, e) { + if ("object" != typeof t || null === t) return t; + var n = t[Symbol.toPrimitive]; + if (void 0 !== n) { + var r = n.call(t, e || "default"); + if ("object" != typeof r) return r; + throw new TypeError( + "@@toPrimitive must return a primitive value." + ); + } + return ("string" === e ? String : Number)(t); + })(t, "string"); + return "symbol" == typeof e ? e : String(e); + })(e)) in t + ? Object.defineProperty(t, e, { + value: n, + enumerable: !0, + configurable: !0, + writable: !0, + }) + : (t[e] = n), + t + ); + } + function o(t, e) { + return ( + (function (t) { + if (Array.isArray(t)) return t; + })(t) || + (function (t, e) { + var n = + null == t + ? null + : ("undefined" != typeof Symbol && t[Symbol.iterator]) || + t["@@iterator"]; + if (null != n) { + var r, + o, + i, + a, + c = [], + u = !0, + f = !1; + try { + if (((i = (n = n.call(t)).next), 0 === e)) { + if (Object(n) !== n) return; + u = !1; + } else + for ( + ; + !(u = (r = i.call(n)).done) && + (c.push(r.value), c.length !== e); + u = !0 + ); + } catch (t) { + ((f = !0), (o = t)); + } finally { + try { + if ( + !u && + null != n.return && + ((a = n.return()), Object(a) !== a) + ) + return; + } finally { + if (f) throw o; + } + } + return c; + } + })(t, e) || + (function (t, e) { + if (!t) return; + if ("string" == typeof t) return i(t, e); + var n = Object.prototype.toString.call(t).slice(8, -1); + "Object" === n && t.constructor && (n = t.constructor.name); + if ("Map" === n || "Set" === n) return Array.from(t); + if ( + "Arguments" === n || + /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) + ) + return i(t, e); + })(t, e) || + (function () { + throw new TypeError( + "Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method." + ); + })() + ); + } + function i(t, e) { + (null == e || e > t.length) && (e = t.length); + for (var n = 0, r = new Array(e); n < e; n++) r[n] = t[n]; + return r; + } + var a = "sharp", + c = ["classic", "duotone", "sharp"], + u = ["fak", "fa-kit", "fakd", "fa-kit-duotone"], + f = [ + "fa", + "fas", + "fa-solid", + "far", + "fa-regular", + "fal", + "fa-light", + "fat", + "fa-thin", + "fad", + "fa-duotone", + "fab", + "fa-brands", + "fass", + "fasr", + "fasl", + "fast", + ]; + function s(t, e) { + var n = (e && e.addOn) || "", + r = (e && e.baseFilename) || t.license + n, + o = e && e.minify ? ".min" : "", + i = (e && e.fileSuffix) || t.method, + a = (e && e.subdir) || t.method; + return ( + t.baseUrl + + "/releases/" + + ("latest" === t.version ? "latest" : "v".concat(t.version)) + + "/" + + a + + "/" + + r + + o + + "." + + i + ); + } + function d(t, e) { + var n = e || ["fa"], + r = "." + Array.prototype.join.call(n, ",."), + o = t.querySelectorAll(r); + Array.prototype.forEach.call(o, function (e) { + var n = e.getAttribute("title"); + e.setAttribute("aria-hidden", "true"); + var r = + !e.nextElementSibling || + !e.nextElementSibling.classList.contains("sr-only"); + if (n && r) { + var o = t.createElement("span"); + ((o.innerHTML = n), + o.classList.add("sr-only"), + e.parentNode.insertBefore(o, e.nextSibling)); + } + }); + } + var l, + h = function () {}, + m = + "undefined" != typeof global && + void 0 !== global.process && + "function" == typeof global.process.emit, + p = "undefined" == typeof setImmediate ? setTimeout : setImmediate, + v = []; + function b() { + for (var t = 0; t < v.length; t++) v[t][0](v[t][1]); + ((v = []), (l = !1)); + } + function y(t, e) { + (v.push([t, e]), l || ((l = !0), p(b, 0))); + } + function g(t) { + var e = t.owner, + n = e._state, + r = e._data, + o = t[n], + i = t.then; + if ("function" == typeof o) { + n = "fulfilled"; + try { + r = o(r); + } catch (t) { + O(i, t); + } + } + w(i, r) || ("fulfilled" === n && A(i, r), "rejected" === n && O(i, r)); + } + function w(t, e) { + var r; + try { + if (t === e) + throw new TypeError( + "A promises callback cannot return that same promise." + ); + if (e && ("function" == typeof e || "object" === n(e))) { + var o = e.then; + if ("function" == typeof o) + return ( + o.call( + e, + function (n) { + r || ((r = !0), e === n ? S(t, n) : A(t, n)); + }, + function (e) { + r || ((r = !0), O(t, e)); + } + ), + !0 + ); + } + } catch (e) { + return (r || O(t, e), !0); + } + return !1; + } + function A(t, e) { + (t !== e && w(t, e)) || S(t, e); + } + function S(t, e) { + "pending" === t._state && ((t._state = "settled"), (t._data = e), y(E, t)); + } + function O(t, e) { + "pending" === t._state && ((t._state = "settled"), (t._data = e), y(P, t)); + } + function j(t) { + t._then = t._then.forEach(g); + } + function E(t) { + ((t._state = "fulfilled"), j(t)); + } + function P(t) { + ((t._state = "rejected"), + j(t), + !t._handled && m && global.process.emit("unhandledRejection", t._data, t)); + } + function _(t) { + global.process.emit("rejectionHandled", t); + } + function F(t) { + if ("function" != typeof t) + throw new TypeError("Promise resolver " + t + " is not a function"); + if (this instanceof F == !1) + throw new TypeError( + "Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function." + ); + ((this._then = []), + (function (t, e) { + function n(t) { + O(e, t); + } + try { + t(function (t) { + A(e, t); + }, n); + } catch (t) { + n(t); + } + })(t, this)); + } + ((F.prototype = { + constructor: F, + _state: "pending", + _then: null, + _data: void 0, + _handled: !1, + then: function (t, e) { + var n = { + owner: this, + then: new this.constructor(h), + fulfilled: t, + rejected: e, + }; + return ( + (!e && !t) || + this._handled || + ((this._handled = !0), + "rejected" === this._state && m && y(_, this)), + "fulfilled" === this._state || "rejected" === this._state + ? y(g, n) + : this._then.push(n), + n.then + ); + }, + catch: function (t) { + return this.then(null, t); + }, + }), + (F.all = function (t) { + if (!Array.isArray(t)) + throw new TypeError("You must pass an array to Promise.all()."); + return new F(function (e, n) { + var r = [], + o = 0; + function i(t) { + return ( + o++, + function (n) { + ((r[t] = n), --o || e(r)); + } + ); + } + for (var a, c = 0; c < t.length; c++) + (a = t[c]) && "function" == typeof a.then + ? a.then(i(c), n) + : (r[c] = a); + o || e(r); + }); + }), + (F.race = function (t) { + if (!Array.isArray(t)) + throw new TypeError("You must pass an array to Promise.race()."); + return new F(function (e, n) { + for (var r, o = 0; o < t.length; o++) + (r = t[o]) && "function" == typeof r.then ? r.then(e, n) : e(r); + }); + }), + (F.resolve = function (t) { + return t && "object" === n(t) && t.constructor === F + ? t + : new F(function (e) { + e(t); + }); + }), + (F.reject = function (t) { + return new F(function (e, n) { + n(t); + }); + })); + var C = "function" == typeof Promise ? Promise : F; + function I(t, e) { + var n = e.fetch, + r = e.XMLHttpRequest, + o = e.token, + i = t; + return ( + o && + !(function (t) { + return t.indexOf("kit-upload.css") > -1; + })(t) && + ("URLSearchParams" in window + ? (i = new URL(t)).searchParams.set("token", o) + : (i = i + "?token=" + encodeURIComponent(o))), + (i = i.toString()), + new C(function (t, e) { + if ("function" == typeof n) + n(i, {mode: "cors", cache: "default"}) + .then(function (t) { + if (t.ok) return t.text(); + throw new Error(""); + }) + .then(function (e) { + t(e); + }) + .catch(e); + else if ("function" == typeof r) { + var o = new r(); + o.addEventListener("loadend", function () { + this.responseText ? t(this.responseText) : e(new Error("")); + }); + (["abort", "error", "timeout"].map(function (t) { + o.addEventListener(t, function () { + e(new Error("")); + }); + }), + o.open("GET", i), + o.send()); + } else { + e(new Error("")); + } + }) + ); + } + function U(t, e, n) { + var r = t; + return ( + [ + [ + /(url\("?)\.\.\/\.\.\/\.\./g, + function (t, n) { + return "".concat(n).concat(e); + }, + ], + [ + /(url\("?)\.\.\/webfonts/g, + function (t, r) { + return "" + .concat(r) + .concat(e, "/releases/v") + .concat(n, "/webfonts"); + }, + ], + [ + /(url\("?)https:\/\/kit-free([^.])*\.fontawesome\.com/g, + function (t, n) { + return "".concat(n).concat(e); + }, + ], + ].forEach(function (t) { + var e = o(t, 2), + n = e[0], + i = e[1]; + r = r.replace(n, i); + }), + r + ); + } + function k(t, n) { + var r = + arguments.length > 2 && void 0 !== arguments[2] + ? arguments[2] + : function () {}, + o = n.document || o, + i = d.bind( + d, + o, + [].concat( + f, + u, + c.map(function (t) { + return "fa-".concat(t); + }) + ) + ); + t.autoA11y.enabled && r(i); + var a = t.subsetPath && t.baseUrl + "/" + t.subsetPath, + l = [{id: "fa-main", addOn: void 0, url: a}]; + if ( + (t.v4shim && + t.v4shim.enabled && + l.push({id: "fa-v4-shims", addOn: "-v4-shims"}), + t.v5FontFaceShim && + t.v5FontFaceShim.enabled && + l.push({id: "fa-v5-font-face", addOn: "-v5-font-face"}), + t.v4FontFaceShim && + t.v4FontFaceShim.enabled && + l.push({id: "fa-v4-font-face", addOn: "-v4-font-face"}), + !a && t.customIconsCssPath) + ) { + var h = + t.customIconsCssPath.indexOf("kit-upload.css") > -1 + ? t.baseUrlKit + : t.baseUrl, + m = h + "/" + t.customIconsCssPath; + l.push({id: "fa-kit-upload", url: m}); + } + var p = l.map(function (r) { + return new C(function (o, i) { + var a = r.url || s(t, {addOn: r.addOn, minify: t.minify.enabled}), + c = {id: r.id}, + u = t.subset + ? c + : e( + e(e({}, n), c), + {}, + { + baseUrl: t.baseUrl, + version: t.version, + id: r.id, + contentFilter: function (t, e) { + return U(t, e.baseUrl, e.version); + }, + } + ); + I(a, n) + .then(function (t) { + o(T(t, u)); + }) + .catch(i); + }); + }); + return C.all(p); + } + function T(t, e) { + var n = + e.contentFilter || + function (t, e) { + return t; + }, + r = document.createElement("style"), + o = document.createTextNode(n(t, e)); + return ( + r.appendChild(o), + (r.media = "all"), + e.id && r.setAttribute("id", e.id), + e && + e.detectingConflicts && + e.detectionIgnoreAttr && + r.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)), + r + ); + } + function L(t, n) { + ((n.autoA11y = t.autoA11y.enabled), + "pro" === t.license && + ((n.autoFetchSvg = !0), + (n.fetchSvgFrom = + t.baseUrl + + "/releases/" + + ("latest" === t.version ? "latest" : "v".concat(t.version)) + + "/svgs"), + (n.fetchUploadedSvgFrom = t.uploadsUrl))); + var r = []; + return ( + t.v4shim.enabled && + r.push( + new C(function (r, o) { + I(s(t, {addOn: "-v4-shims", minify: t.minify.enabled}), n) + .then(function (t) { + r(x(t, e(e({}, n), {}, {id: "fa-v4-shims"}))); + }) + .catch(o); + }) + ), + r.push( + new C(function (r, o) { + I( + (t.subsetPath && t.baseUrl + "/" + t.subsetPath) || + s(t, {minify: t.minify.enabled}), + n + ) + .then(function (t) { + var o = x(t, e(e({}, n), {}, {id: "fa-main"})); + r( + (function (t, e) { + var n = + e && void 0 !== e.autoFetchSvg + ? e.autoFetchSvg + : void 0, + r = + e && void 0 !== e.autoA11y + ? e.autoA11y + : void 0; + void 0 !== r && + t.setAttribute( + "data-auto-a11y", + r ? "true" : "false" + ); + n && + (t.setAttributeNode( + document.createAttribute( + "data-auto-fetch-svg" + ) + ), + t.setAttribute( + "data-fetch-svg-from", + e.fetchSvgFrom + ), + t.setAttribute( + "data-fetch-uploaded-svg-from", + e.fetchUploadedSvgFrom + )); + return t; + })(o, n) + ); + }) + .catch(o); + }) + ), + C.all(r) + ); + } + function x(t, e) { + var n = document.createElement("SCRIPT"), + r = document.createTextNode(t); + return ( + n.appendChild(r), + (n.referrerPolicy = "strict-origin"), + e.id && n.setAttribute("id", e.id), + e && + e.detectingConflicts && + e.detectionIgnoreAttr && + n.setAttributeNode(document.createAttribute(e.detectionIgnoreAttr)), + n + ); + } + function M(t) { + var e, + n = [], + r = document, + o = r.documentElement.doScroll, + i = (o ? /^loaded|^c/ : /^loaded|^i|^c/).test(r.readyState); + (i || + r.addEventListener( + "DOMContentLoaded", + (e = function () { + for ( + r.removeEventListener("DOMContentLoaded", e), i = 1; + (e = n.shift()); + + ) + e(); + }) + ), + i ? setTimeout(t, 0) : n.push(t)); + } + function N(t) { + "undefined" != typeof MutationObserver && + new MutationObserver(t).observe(document, {childList: !0, subtree: !0}); + } + try { + if (window.FontAwesomeKitConfig) { + var D = window.FontAwesomeKitConfig, + R = { + detectingConflicts: + D.detectConflictsUntil && + new Date() <= new Date(D.detectConflictsUntil), + detectionIgnoreAttr: "data-fa-detection-ignore", + fetch: window.fetch, + token: D.token, + XMLHttpRequest: window.XMLHttpRequest, + document: document, + }, + H = document.currentScript, + K = H ? H.parentElement : document.head; + (function () { + var t = + arguments.length > 0 && void 0 !== arguments[0] + ? arguments[0] + : {}, + e = + arguments.length > 1 && void 0 !== arguments[1] + ? arguments[1] + : {}; + return "js" === t.method + ? L(t, e) + : "css" === t.method + ? k(t, e, function (t) { + (M(t), N(t)); + }) + : void 0; + })(D, R) + .then(function (t) { + (t.map(function (t) { + try { + K.insertBefore(t, H ? H.nextSibling : null); + } catch (e) { + K.appendChild(t); + } + }), + R.detectingConflicts && + H && + M(function () { + H.setAttributeNode( + document.createAttribute(R.detectionIgnoreAttr) + ); + var t = (function (t, e) { + var n = document.createElement("script"); + return ( + e && + e.detectionIgnoreAttr && + n.setAttributeNode( + document.createAttribute( + e.detectionIgnoreAttr + ) + ), + (n.src = s(t, { + baseFilename: "conflict-detection", + fileSuffix: "js", + subdir: "js", + minify: t.minify.enabled, + })), + n + ); + })(D, R); + document.body.appendChild(t); + })); + }) + .catch(function (t) { + console.error("".concat("Font Awesome Kit:", " ").concat(t)); + }); + } + } catch (a) { + console.error("".concat("Font Awesome Kit:", " ").concat(a)); + } +}); diff --git a/audit/static/src/css/form.css b/audit/static/src/css/form.css new file mode 100644 index 00000000000..fa9b29ebf07 --- /dev/null +++ b/audit/static/src/css/form.css @@ -0,0 +1,767 @@ +/* Reset */ + +.vec_img_centre { + display: block; + margin-left: auto; + margin-right: auto; +} + +.vec_audit_top_box { + width: 33%; + border-top: 1px solid; + padding: 5px 5px 5px 5px; +} + +/* Question section */ + +.vec_audit_question { + border: 1px solid; + margin-top: 10px; +} + +.vec_audit_question_label { + width: 80%; +} + +:is( + .vec_audit_question_label, + .vec_audit_question_label_half, + .vec_audit_question_notes + ) + :is(b, p) { + margin-left: 3px; + margin-top: 1px; +} + +.vec_audit_question_yn { + width: 20%; + border: 1px solid; + margin: -1px -1px -1px -1px; +} + +/** + * This is applied to the div that Odoo wraps the actual element with + * The goal is to center the checkbox inside the box defined above + */ +.vec_audit_question_yn_field { + width: 100%; + height: 100%; +} + +.vec_audit_question_yn_field div.form-check { + padding-left: 0px; + margin-left: 15%; + position: relative; + width: 70%; + margin-top: 10px; + height: 25px; +} + +.vec_audit_question_half { + border: 1px solid; + margin-top: 10px; +} + +.vec_audit_question_label_half { + width: 40%; + border: 1px solid; + margin: -1px -1px -1px -1px; +} + +.vec_audit_question_yn_half { + width: 10%; + border: 1px solid; + margin: -1px -1px -1px -1px; +} + +.vec_audit_question_notes { + width: 100%; + border: 1px solid; + margin: -1px -1px -1px -1px; +} +/** + * This is the only way to target a checkbox, since Odoo applies any classes + * on the element to a wrapper div. It also produces a label for us + * so we can also target this for custom viewing + */ +:is(.vec_audit_question_yn, .vec_audit_question_yn_half) input[type="checkbox"] { + display: none; +} + +:is(.vec_audit_question_yn, .vec_audit_question_yn_half) + input[type="checkbox"] + + label { + transform: skew(-10deg); + transition: all 0.5s ease; + border-radius: 5px; + border: 1px; + width: 100%; + height: 100%; + background-color: #de3b36; + color: #eeeeee; + /* font-weight: bold; */ + opacity: 0.9; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2); +} + +:is(.vec_audit_question_yn, .vec_audit_question_yn_half) + input[type="checkbox"] + + label::after { + content: "FAILED"; +} + +:is(.vec_audit_question_yn, .vec_audit_question_yn_half) + input[type="checkbox"]:checked + + label { + background-color: #48a858; +} + +:is(.vec_audit_question_yn, .vec_audit_question_yn_half) + input[type="checkbox"]:checked + + label::after { + content: "PASSED"; +} + +/* Snapshot page */ +.o_action_manager { + overflow-y: scroll !important; +} + +input[type="range"] { + /* removing default appearance */ + -webkit-appearance: none; + appearance: none; + /* creating a custom design */ + width: 100%; + cursor: pointer; + outline: none; + /* slider progress trick */ + overflow: hidden; + border-radius: 16px; +} + +/* Styling the slider, first we style the `track` */ +input[type="range"]::-webkit-slider-runnable-track { + /* removing default appearance */ + -webkit-appearance: none; + appearance: none; + /* creating a custom design */ + cursor: pointer; + outline: none; + height: 10px; + max-width: 180px; + background: #0c2d57; + border-radius: 16px; +} + +/* Now we style the thumb */ +input[type="range"]::-webkit-slider-thumb { + /* removing default appearance */ + -webkit-appearance: none; + appearance: none; + /* creating a custom design */ + height: 170px; + width: 15px; + background-color: #fc6736; + border-radius: 50%; +} + +.questions-form-body { + overflow-y: auto; + /*This calc is super important, if not present the scrolling area will be the entire page so some data won't show*/ + height: calc(100% - 50px); + min-height: 150px; + display: flex; + justify-content: center; + flex-direction: column; + border-style: hidden; + + @media screen and (max-width: 288px) { + height: auto; + } +} + +.summary-question-body { + border-style: solid !important; + border-width: 2px; + border-radius: 10px; + border-color: lightslategray !important; +} + +.questions-form { + overflow-y: auto; + height: 100% !important; +} + +.comment-text-box { + background-color: RGBA(177, 179, 188); + border-style: hidden; + border-radius: 5px; + max-width: 500px; + height: 32px; + + @media screen and (max-width: 600px) { + margin-left: 5px; + } +} + +.question-answers-select { + max-width: 100px; + background: #0c2d57 !important; + color: aquamarine !important; + border-radius: 5px !important; + font-weight: 500 !important; +} + +.file-upload-box { + max-width: 180px; + border-radius: 5px !important; + border-style: ridge !important; + border-width: 3px !important; + background-color: #0c2d57 !important; + color: aquamarine !important; + font-size: smaller !important; +} + +.questions-container { + max-width: 1000px; +} + +.individual-question-card { + margin-bottom: 15px; + border-radius: 5px !important; + border-style: hidden !important; +} + +.question-card-footer { + display: flex; + flex-direction: row; + justify-content: space-between; + height: fit-content; + margin-top: 10px; + border-style: hidden !important; + border-radius: 5px !important; + + @media screen and (max-width: 600px) { + display: flex; + flex-direction: column; + } +} + +.edit-button { + border-color: #00bbf0 !important; + color: #00bbf0 !important; +} + +.edit-button:hover { + background-color: #0c2d57 !important; +} + +.question-applicable { + background-color: #0c2d57 !important; + min-width: 48px; +} + +.upload-picture-form { + @media screen and (max-width: 600px) { + margin-left: 5px; + } +} + +.form-switch { + display: flex !important; + flex-direction: row; + margin-top: auto; + margin-right: 220px; +} + +.form-check-input { + margin-right: 5px; +} + +.form-check-label { + margin-right: 5px; +} + +.question-header { + border-style: hidden !important; + margin-bottom: 10px !important; +} + +.question-score { + display: flex; + justify-content: center; + min-width: 40px; + border-radius: 30px; + border-style: solid; + border-width: thin; + border-color: #48a858; + background: transparent; + color: #00a09d; + font-weight: bold; + font-size: smaller; +} + +.question-prompt { + margin-bottom: 8px; + border-radius: 5px !important; + max-width: 400px; + background: #00bbf0 !important; + color: #22313f !important; + + @media screen and (max-width: 600px) { + max-width: 300px; + } +} + +.back-to-snapshots { + width: fit-content; + margin: 10px 0 6px 14px; + border-radius: 5px !important; + border-color: gold !important; + color: gold !important; +} + +.back-to-snapshots:hover { + background-color: #0c2d57 !important; +} + +.submit-snapshot-btn { + display: flex !important; + flex-direction: row !important; + width: fit-content; + margin: 10px 0 6px 14px; + border-radius: 5px !important; + border-color: gold !important; + color: gold !important; +} + +.submit-snapshot-btn:hover { + background-color: #0c2d57 !important; +} + +.questions-font { + font-weight: bold; +} + +.image-thumbnail { + width: 100px; + height: 100px; + border-radius: 5px; +} + +.summary-image-thumbnail { + display: block; + width: 50px; + height: 50px; + border-radius: 5px; +} + +.locked-icon { + font-size: small; + color: #fe0000; +} + +.unlocked-icon { + font-size: small; + color: #6eccaf; +} + +.slider { + display: flex; + flex-direction: row; +} + +.slider-value { + font-weight: bold; + margin: 0 0 0 4px; +} + +.snapshot-header-footer { + max-width: 497px; +} + +.question-and-icon { + display: flex; + flex-direction: row-reverse; + justify-content: space-between; +} + +.submit-snapshot-modal-header { + background-color: crimson; +} + +.submit-snapshot-modal-body { + background-color: #f1f2f3; +} + +.submit-snapshot-modal-footer { + background-color: #f1f2f3; +} + +.cancel-button { + border-style: solid !important; + border-width: thin !important; + border-color: #0fc9e7 !important; + background-color: midnightblue !important; + color: aliceblue !important; +} + +.confirm-button { + border-style: solid !important; + border-width: thin !important; + border-color: #03c988 !important; + background-color: #00cc66 !important; + color: aliceblue !important; +} + +/* Audit Dashboard page */ +.search-bar { + display: flex; + max-width: fit-content; + max-height: 20px; + margin-top: 10px; +} + +.create-snapshot { + border-radius: 5px !important; + border-style: groove !important; + border-color: #5fbdff !important; + border-width: 3px !important; + background-color: #1e56a0 !important; + margin: 10px 10px 10px 30px; + + @media screen and (max-width: 600px) { + font-size: 9px !important; + } +} + +.card-text { + @media screen and (max-width: 600px) { + display: flex; + max-width: fit-content; + } + + .actions-column { + display: flex; + flex-direction: row; + /*actions-column has its own media styling*/ + @media screen and (max-width: 600px) { + display: flex !important; + flex-direction: row-reverse; + flex-wrap: wrap; + } + + /*Targeting the save-button nested deep inside the div of `actions-column`*/ + #save-button { + @media screen and (max-width: 600px) { + margin-right: 5px; + } + } + } +} + +.snapshot-list-buttons { + display: flex; + flex-direction: row; + justify-content: space-between; + + @media screen and (max-width: 600px) { + .view-questions { + height: 35px; + } + } +} + +.snapshot-pagination { + max-width: fit-content !important; + margin-left: 23px !important; + font-weight: 800 !important; +} + +/* Create new Snapshot page */ + +.create-snapshot-form-body { + display: flex; + justify-content: center; +} + +.create-snapshot-form { + width: 900px; + max-height: fit-content; + margin: 80px; + position: relative; +} + +.section-card-body { + display: inline-grid; +} + +.submit-and-create-btn { + display: flex; + flex-direction: row-reverse; + margin-top: 20px; +} + +.inspector-body-box { + display: flex; + align-items: baseline; +} + +.inspector-inputs { + max-width: fit-content; + margin-left: 8px; +} + +.inspector-first-last-name-labels { + margin-left: 8px; +} + +.answer-questions-btn { + display: flex !important; + flex-direction: row !important; + margin-bottom: 20px; + border-radius: 5px; + + .answer-questions-icon { + margin-right: 4px; + padding-top: inherit; + } + + .answer-questions-spinner { + margin-left: 8px; + font-size: large; + } +} + +.btn-general { + .btn { + border-radius: 5px; + font-size: 9px; + font-weight: bold; + } + @media screen and (max-width: 600px) { + display: flex; + flex: auto; + font-size: 9px !important; + } +} + +.create-back-to-snapshots { + border-color: gold !important; + border-radius: 5px !important; + color: gold !important; + margin-left: 14px; + margin-top: 14px; +} + +.create-back-to-snapshots:hover { + background-color: #0c2d57 !important; +} + +/* Snapshot List page */ + +.snapshot-list-table { + /* Add some height to get the overflow-y working */ + height: fit-content; + overflow-y: auto; + + /* Targeting `screens` up to a maximum width of 650px, any more will be treated as height: fit-content */ + @media (max-width: 1210px) { + height: calc(100% - 50px); + th { + display: none; + } + td:first-child { + padding-top: 2rem; + } + td:last-child { + padding-bottom: 2rem; + } + td { + display: block; + } + td::before { + content: attr(data-cell) " - "; + font-weight: 700; + text-transform: capitalize; + } + } + + .snapshot-list-table-body { + max-width: fit-content; + overflow-x: auto; + } +} + +.view-questions { + height: 30px; + @media screen and (max-width: 600px) { + font-size: 9px !important; + } +} + +.view-questions-button { + border-color: deepskyblue !important; + border-radius: 32px !important; + color: deepskyblue !important; + background: #0c2d57 !important; + font-weight: bold !important; +} + +.summary-button { + background-color: #f5f7f8 !important; + border-color: #186f65 !important; + border-radius: 32px !important; + border-width: 2px !important; + color: #22a39f !important; +} + +.question-count-badge { + display: flex !important; + max-width: fit-content !important; + position: relative; + left: 70px; + top: -27px !important; + background: #f96d00 !important; + border-style: solid; + border-width: medium; + font-size: 10px !important; + + @media screen and (max-width: 600px) { + position: relative; + top: -35px !important; + left: 42px; + font-size: 8px !important; + } +} + +.question-count-badge-value { + margin-left: 2px; + font-weight: 800; + color: #062743; +} + +.section-header { + background: #89c4ff !important; + border: #113f67 !important; + border-radius: 5px !important; + color: #38598b !important; +} + +.locks-in-span { + background-color: #163020 !important; + border-radius: 50%; + display: inline-block; + height: 22px !important; + width: 24px !important; + text-align: center; +} + +.snapshot-list-percentage-display { + font-weight: 600; + font-size: smaller; + text-align: center; +} + +/* Summary Page */ +.summary-page { + overflow-y: auto; + /*This calc is super important, if not present the scrolling area will be the entire page so some data won't show*/ + height: calc(100% - 50px); +} + +.summary-card-title { + display: flex; + flex-direction: row; + justify-content: space-between; + max-width: 440px !important; + + @media screen and (max-width: 380px) { + max-width: 300px; + } +} + +.summary-table { + @media screen and (max-width: 600px) { + display: block; + } +} + +.summary-question { + display: flex; + flex-direction: column; + font-size: smaller; + text-overflow: ellipsis; + overflow: hidden; + max-width: 120px; +} + +.snapshot-summary-card { + border-style: hidden !important; + background: dimgrey !important; + border-radius: 5px !important; + margin: 0 8px 13px 13px; + max-width: 440px; + + @media screen and (max-width: 600px) { + max-width: 385px; + } +} + +.section-score { + font-size: small !important; + background-color: #0962ea !important; + border-radius: 5px !important; + color: aliceblue !important; + min-width: min-content !important; + max-height: 20px; +} + +.final-snapshot-score { + font-size: small !important; + background-color: #e70000 !important; + border-radius: 5px !important; + color: aliceblue !important; +} + +.snapshot-final-score-footer { + margin: 0 0 13px 13px; + max-width: 440px; + border-style: hidden; + min-width: 400px; + + @media screen and (max-width: 414px) { + max-width: 355px !important; + min-width: 385px; + } +} + +/* xml pages styling */ +.view_snapshot_tree { + display: inline-flex; + border-style: solid; + border-radius: 10px; + border-color: darkturquoise; + max-width: fit-content; + padding-left: 5px; + padding-right: 5px; + color: darkslateblue; +} + +/* Red border for scores below 90% */ +.view_snapshot_tree.text-danger { + border-color: #dc3545 !important; + border-width: 3px !important; + color: #dc3545 !important; +} + +/* Green border for scores 90% and above */ +.view_snapshot_tree.text-success { + border-color: #28a745 !important; + border-width: 3px !important; + color: #28a745 !important; +} diff --git a/audit/static/src/javascript_components/dashboard/create_snapshot.js b/audit/static/src/javascript_components/dashboard/create_snapshot.js new file mode 100644 index 00000000000..6cba308285f --- /dev/null +++ b/audit/static/src/javascript_components/dashboard/create_snapshot.js @@ -0,0 +1,408 @@ +/** @odoo-module **/ +import {Component, useState, onWillStart, xml} from "@odoo/owl"; +import {useService} from "@web/core/utils/hooks"; +import {createSnapshotInstance} from "./dashboard_helpers"; + +export class CreateSnapShot extends Component { + static template = xml` + +