diff --git a/fleet_traffic_infractions/README.rst b/fleet_traffic_infractions/README.rst new file mode 100644 index 000000000..a19c6bcb6 --- /dev/null +++ b/fleet_traffic_infractions/README.rst @@ -0,0 +1,236 @@ +========================= +Fleet Traffic Infractions +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a72564b08a702e50ad0785994d18f0ded624170855d95ba29dab9bed681daf97 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Ffleet-lightgray.png?logo=github + :target: https://github.com/OCA/fleet/tree/18.0/fleet_traffic_infractions + :alt: OCA/fleet +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/fleet-18-0/fleet-18-0-fleet_traffic_infractions + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/fleet&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a robust framework to manage and track traffic +infractions for your fleet vehicles. + +**Key Features:** + +- Create and manage a central registry of all traffic infractions. +- Automatically suggest the responsible driver by checking the vehicle's + assignment logs for the exact date and time of the incident. +- Log a detailed, timezone-aware note in the chatter if a user manually + changes the suggested driver, ensuring a clear audit trail. +- Define and categorize different types of infractions (e.g., speeding, + illegal parking) with jurisdictional details. +- Mark contact partners as "Traffic Issuing Agencies" to streamline data + entry. +- Integrates directly with the Vehicle and Partner forms, adding smart + buttons to quickly view all related infractions. +- Allows infractions to be saved in a draft state and only validates + mandatory fields upon confirmation. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Managing a fleet of vehicles often involves dealing with a constant and +disorganized flow of traffic tickets. Fleet managers face several key +challenges: + +- **Identifying the Responsible Driver:** It can be a time-consuming and + manual process to determine who was driving a specific vehicle at the + exact time a fine was issued, often leading to disputes. +- **Lack of Centralized Tracking:** Without a dedicated system, it's + difficult to track the status of an infraction from the moment it's + received until it's resolved. +- **Operational vs. Financial Disconnect:** The operational details of a + fine (who, what, when) are often disconnected from the financial + process (paying the agency, charging the driver). + +This module was created to solve the **operational** part of this +problem. It provides a clean, focused system for recording what happened +and, most importantly, automates the critical task of identifying the +responsible driver using the vehicle's assignment history. By creating a +reliable operational record, it lays the foundation for a transparent +and efficient management process. + +Installation +============ + +To install this module, you need to: + +1. Add this module to your Odoo addons path. +2. Restart the Odoo server. +3. Go to the "Apps" menu, find the module, and click "Install". + +**Dependencies:** + +This module's core driver suggestion feature relies on the +``fleet_vehicle_assignation_log_datetime`` module. Please ensure it is +installed and that your vehicle assignment logs are properly maintained +with start and end datetimes for accurate driver lookups. + +Configuration +============= + +Before you can effectively use this module, you need to perform two +configuration steps: + +1. **Mark Traffic Issuing Agencies:** + + - Navigate to the ``Contacts`` application. + - Find or create the contact record for an agency that issues fines + (e.g., "City Traffic Department", "State Highway Patrol"). + - On the contact form, check the box labeled **"Is a Traffic Issuing + Agency"**. + - *This will ensure that only relevant partners appear in the + "Issuing Agency" dropdown when creating an infraction.* + +2. **Define Infraction Types:** + + - Navigate to ``Fleet > Configuration > Infraction Types``. + - Click "New" to create categories for the fines you typically + receive. + - Fill in the details: + + - **Infraction Code:** A unique code for the infraction (e.g., + ``SPEED-01``). + - **Description:** A clear description (e.g., "Exceeding Speed + Limit by 10-20%"). + - **Jurisdiction:** Define if the rule is at the Country, State, or + Municipal level. + + - *This allows for consistent data entry and will be useful for + future reporting.* + +Usage +===== + +This module is designed to be straightforward. The main workflow is +creating and confirming an infraction. + +**1. Registering a New Traffic Infraction:** + +- Navigate to ``Fleet > Infractions > Traffic Infractions`` and click + "New". +- The form is designed for a logical workflow. For the best experience, + follow these steps: + + 1. Select the **Vehicle** that received the fine. + 2. Set the **Infraction Datetime** to the exact date and time the + incident occurred. + +- **Automatic Driver Suggestion:** After setting the vehicle and + datetime, the **Driver** field will be automatically populated based + on the vehicle's assignment logs. +- Fill in the remaining details of the ticket: + + - **Infraction Type:** Select the category you configured. + - **Infraction Auto Number:** Enter the official ticket or reference + number. + - **Issuing Agency:** Select the agency that issued the fine. + - **Fine Amount** and **Due Date**. + +- You can click **Save** at any time to keep the infraction in a + ``Draft`` state. + +**2. Confirming and Managing the Infraction:** + +- Once all details are complete, click the **Confirm** button. The + system will verify that all mandatory fields are filled before + changing the state to ``Confirmed``. +- **Driver Change Logging:** If the automatically suggested driver is + incorrect and you manually change it, a detailed note will be + automatically posted in the chatter. This note includes the old and + new driver, the infraction time (with the user's timezone), and a + comparison against the assignment log, ensuring full traceability. + +**3. Accessing Infractions from Other Views:** + +- On a **Vehicle** form, you can use the **"Traffic Infractions"** smart + button to see all fines associated with that vehicle. +- On a **Partner** form, you will see a **"Driver Infractions"** smart + button to view all infractions where that partner was the driver. + +Known issues / Roadmap +====================== + +This module provides the core operational foundation for managing +traffic infractions. The roadmap includes creating a separate companion +module for financial integration. + +**Future Module: ``fleet_traffic_infractions_account``** + +This planned module will extend the functionality of this one to handle +all accounting aspects: + +- Create a Vendor Bill from a confirmed infraction, with the "Issuing + Agency" as the vendor. +- Create a Customer Invoice to charge the fine and any administrative + fees to the responsible driver. +- Track the payment status of both the bill and the invoice. +- Provide a clear link between the infraction record and its + corresponding journal entries. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Raimundo Pereira da Silva Junior + +Contributors +------------ + +- Raimundo Pereira da Silva Junior +- Odoo Community Association (OCA) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/fleet `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fleet_traffic_infractions/__init__.py b/fleet_traffic_infractions/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/fleet_traffic_infractions/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fleet_traffic_infractions/__manifest__.py b/fleet_traffic_infractions/__manifest__.py new file mode 100644 index 000000000..6143c65de --- /dev/null +++ b/fleet_traffic_infractions/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Fleet Traffic Infractions", + "version": "18.0.1.0.0", + "category": "Fleet", + "summary": "Manage and track traffic infractions for your fleet vehicles.", + "author": "Raimundo Pereira da Silva Junior, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/fleet", + "license": "AGPL-3", + "depends": [ + "fleet", + "fleet_vehicle_assignation_log_datetime", + ], + "data": [ + "security/ir.model.access.csv", + "views/fleet_traffic_infraction_type_views.xml", + "views/fleet_traffic_infraction_views.xml", + "views/fleet_vehicle_views.xml", + "views/res_partner_views.xml", + "data/ir_sequence_data.xml", + "views/fleet_traffic_infraction_menus.xml", + ], + "demo": [ + "demo/fleet_traffic_infractions_demo.xml", + ], + "installable": True, +} diff --git a/fleet_traffic_infractions/data/ir_sequence_data.xml b/fleet_traffic_infractions/data/ir_sequence_data.xml new file mode 100644 index 000000000..732096eb2 --- /dev/null +++ b/fleet_traffic_infractions/data/ir_sequence_data.xml @@ -0,0 +1,11 @@ + + + + + Traffic Infraction Sequence + fleet.traffic.infractions + TI + 5 + + + diff --git a/fleet_traffic_infractions/demo/fleet_traffic_infractions_demo.xml b/fleet_traffic_infractions/demo/fleet_traffic_infractions_demo.xml new file mode 100644 index 000000000..8165fd355 --- /dev/null +++ b/fleet_traffic_infractions/demo/fleet_traffic_infractions_demo.xml @@ -0,0 +1,143 @@ + + + + + SPD001 + Exceeding speed limit + Traffic Code Section 1.2.3 + state + + + + + + + PRK001 + Illegal parking + Municipal Ordinance 4.5.6 + municipal + + + + Los Angeles + + + + RED001 + Running a red light + Traffic Code Section 7.8.9 + state + + + + + + + + City Police Department + + + 123 Main St + Los Angeles + + 90001 + + + + + State Highway Patrol + + + 456 Highway Ave + Sacramento + + 95814 + + + + + + + + New + + confirmed + + + + + + + 987654321 + 150.00 + + + + + New + + confirmed + + + + + + + PARK00123 + 75.00 + + + + + New + + confirmed + + + + + + + RLA45678 + 250.00 + + + + + New + + draft + + + + + + + SPD00998 + 120.00 + + diff --git a/fleet_traffic_infractions/models/__init__.py b/fleet_traffic_infractions/models/__init__.py new file mode 100644 index 000000000..81b5d7477 --- /dev/null +++ b/fleet_traffic_infractions/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import fleet_traffic_infraction_type +from . import fleet_traffic_infractions +from . import res_partner +from . import fleet_vehicle diff --git a/fleet_traffic_infractions/models/fleet_traffic_infraction_type.py b/fleet_traffic_infractions/models/fleet_traffic_infraction_type.py new file mode 100644 index 000000000..c04d8104c --- /dev/null +++ b/fleet_traffic_infractions/models/fleet_traffic_infraction_type.py @@ -0,0 +1,101 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class FleetTrafficInfractionType(models.Model): + _name = "fleet.traffic.infraction.type" + _description = "Fleet Traffic Infraction Type" + _rec_name = "name" + + def _default_company_country(self): + """Returns the country of the current company.""" + return self.env.company.country_id + + name = fields.Char(compute="_compute_name", store=True) + code = fields.Char(string="Infraction Code", required=True, index=True) + description = fields.Text() + legal_reference = fields.Char() + start_date = fields.Date() + end_date = fields.Date() + + jurisdiction_level = fields.Selection( + [ + ("country", "Country"), + ("state", "State / Province"), + ("municipal", "Municipal / City"), + ], + required=True, + default="country", + ) + country_id = fields.Many2one( + "res.country", + string="Country", + default=_default_company_country, + ) + state_id = fields.Many2one( + "res.country.state", + string="State / Province", + domain="[('country_id', '=', country_id)]", + ) + city = fields.Char() + + _sql_constraints = [ + ("code_unique", "unique(code)", "The infraction code must be unique!") + ] + + @api.depends("code", "description") + def _compute_name(self): + for rec in self: + rec.name = ( + f"[{rec.code}] {rec.description}" + if rec.code and rec.description + else rec.code + ) + + @api.constrains("jurisdiction_level", "state_id", "city") + def _check_jurisdiction_fields(self): + """Ensures that jurisdiction fields are consistent with the selected level.""" + for rec in self: + if rec.jurisdiction_level == "country" and (rec.state_id or rec.city): + raise ValidationError( + _( + "For a 'Country' level jurisdiction, State and City must be " + "empty." + ) + ) + if rec.jurisdiction_level == "state": + if rec.city: + raise ValidationError( + _( + "For a 'State' level jurisdiction, the City field must be " + "empty." + ) + ) + if not rec.state_id: + raise ValidationError( + _( + "For a 'State' level jurisdiction, the State field is " + "required." + ) + ) + if rec.jurisdiction_level == "municipal" and ( + not rec.state_id or not rec.city + ): + raise ValidationError( + _( + "For a 'Municipal' level jurisdiction, both State and City are " + "required." + ) + ) + + @api.onchange("jurisdiction_level") + def _onchange_jurisdiction_level(self): + """Clears irrelevant fields when the jurisdiction level changes for UX.""" + if self.jurisdiction_level == "country": + self.state_id = False + self.city = False + elif self.jurisdiction_level == "state": + self.city = False diff --git a/fleet_traffic_infractions/models/fleet_traffic_infractions.py b/fleet_traffic_infractions/models/fleet_traffic_infractions.py new file mode 100644 index 000000000..979ae6f97 --- /dev/null +++ b/fleet_traffic_infractions/models/fleet_traffic_infractions.py @@ -0,0 +1,204 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class FleetTrafficInfractions(models.Model): + _name = "fleet.traffic.infractions" + _description = "Fleet Traffic Infractions" + _inherit = ["mail.thread", "mail.activity.mixin"] + + _sql_constraints = [ + ( + "vehicle_infraction_auto_number_unique", + "unique(vehicle_id, infraction_auto_number, infraction_type_id)", + "This infraction number already exists for this vehicle and " + "infraction type!", + ) + ] + + name = fields.Char( + "Reference", required=True, index=True, copy=False, default="New" + ) + state = fields.Selection( + [ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("cancel", "Canceled"), + ], + string="Status", + copy=False, + index=True, + tracking=True, + default="draft", + ) + vehicle_id = fields.Many2one("fleet.vehicle") + driver_id = fields.Many2one("res.partner", copy=False) + infraction_type_id = fields.Many2one( + "fleet.traffic.infraction.type", + string="Infraction Type", + ondelete="restrict", + ) + issuing_agency_id = fields.Many2one( + "res.partner", + string="Issuing Agency", + ondelete="restrict", + domain="[('is_issuing_agency', '=', True)]", + help="Select a partner that is marked as an Issuing Agency.", + ) + infraction_key = fields.Char( + compute="_compute_infraction_key", + store=True, + help="A human-readable key for this infraction.", + ) + infraction_datetime = fields.Datetime() + due_date = fields.Date() + infraction_auto_number = fields.Char() + fine_amount = fields.Float() + currency_id = fields.Many2one("res.currency", related="company_id.currency_id") + company_id = fields.Many2one("res.company", default=lambda self: self.env.company) + + # Address Fields Mimicking res.partner + street = fields.Char() + zip = fields.Char() + city = fields.Char() + state_id = fields.Many2one( + "res.country.state", domain="[('country_id', '=', country_id)]" + ) + country_id = fields.Many2one("res.country") + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name", "New") == "New": + vals["name"] = self.env["ir.sequence"].next_by_code( + "fleet.traffic.infractions" + ) or _("New") + return super().create(vals_list) + + def write(self, vals): + original_drivers = ( + {rec.id: rec.driver_id for rec in self} if "driver_id" in vals else {} + ) + res = super().write(vals) + if original_drivers: + for record in self: + old_driver = original_drivers.get(record.id) + if old_driver != record.driver_id: + record._log_driver_change(old_driver) + return res + + def _log_driver_change(self, old_driver): + self.ensure_one() + new_driver = self.driver_id + old_driver_name = old_driver.name if old_driver else _("None") + new_driver_name = new_driver.name if new_driver else _("None") + message_parts = [ + _( + "Driver changed on infraction:\n" + "- Old Driver: %(old_driver_name)s\n" + "- New Driver: %(new_driver_name)s" + ) + % {"old_driver_name": old_driver_name, "new_driver_name": new_driver_name} + ] + if self.infraction_datetime: + user_tz_name = self.env.context.get("tz") or self.env.user.tz or "UTC" + lang = self.env["res.lang"]._lang_get(self.env.lang) + lang_format = f"{lang.date_format} {lang.time_format}" + user_tz_dt = fields.Datetime.context_timestamp( + self, self.infraction_datetime + ) + inf_datetime_str = f"{user_tz_dt.strftime(lang_format)} ({user_tz_name})" + log_driver = self.vehicle_id.get_driver_for_datetime( + self.infraction_datetime + ) + if log_driver and new_driver == log_driver: + message_parts.append( + _( + "The new driver matches the vehicle assignment log for " + "%(datetime)s." + ) + % {"datetime": inf_datetime_str} + ) + elif log_driver and new_driver != log_driver: + message_parts.append( + _( + "Note: The assignment log suggests '%(log_driver)s' was the " + "driver at %(datetime)s, which differs from the new driver." + ) + % {"log_driver": log_driver.name, "datetime": inf_datetime_str} + ) + elif not log_driver: + message_parts.append( + _("Note: No driver was found in assignment logs at %(datetime)s.") + % {"datetime": inf_datetime_str} + ) + self.message_post(body="\n\n".join(message_parts), subtype_xmlid="mail.mt_note") + + @api.depends( + "vehicle_id.license_plate", "infraction_auto_number", "infraction_type_id.code" + ) + def _compute_infraction_key(self): + for rec in self: + if ( + rec.vehicle_id.license_plate + and rec.infraction_auto_number + and rec.infraction_type_id.code + ): + plate = rec.vehicle_id.license_plate + auto_num = rec.infraction_auto_number + inf_code = rec.infraction_type_id.code + rec.infraction_key = f"{plate}-{auto_num}-{inf_code}" + else: + rec.infraction_key = False + + @api.onchange("vehicle_id", "infraction_datetime") + def _onchange_vehicle_infraction_datetime(self): + """Suggests the driver based on vehicle assignment logs.""" + if not self.vehicle_id or not self.infraction_datetime: + self.driver_id = False + return + driver = self.vehicle_id.get_driver_for_datetime(self.infraction_datetime) + if driver: + self.driver_id = driver.id + + def button_confirm(self): + self._check_required_fields_for_confirmation() + self.write({"state": "confirmed"}) + + def button_cancel(self): + self.write({"state": "cancel"}) + + def button_draft(self): + self.write({"state": "draft"}) + + def _check_required_fields_for_confirmation(self): + for record in self: + missing_fields = [] + if not record.vehicle_id: + missing_fields.append(_("Vehicle")) + if not record.driver_id: + missing_fields.append(_("Driver")) + if not record.infraction_type_id: + missing_fields.append(_("Infraction Type")) + if not record.issuing_agency_id: + missing_fields.append(_("Issuing Agency")) + if not record.infraction_datetime: + missing_fields.append(_("Infraction Datetime")) + if not record.street or not record.city or not record.country_id: + missing_fields.append(_("Infraction Address")) + if not record.infraction_auto_number: + missing_fields.append(_("Infraction Auto Number")) + if not record.fine_amount: + missing_fields.append(_("Fine Amount")) + + if missing_fields: + raise ValidationError( + _( + "Cannot confirm infraction '%(name)s'. The following fields " + "are mandatory:\n- %(fields)s" + ) + % {"name": record.name, "fields": "\n- ".join(missing_fields)} + ) diff --git a/fleet_traffic_infractions/models/fleet_vehicle.py b/fleet_traffic_infractions/models/fleet_vehicle.py new file mode 100644 index 000000000..827795639 --- /dev/null +++ b/fleet_traffic_infractions/models/fleet_vehicle.py @@ -0,0 +1,55 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AG_PL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class FleetVehicle(models.Model): + _inherit = "fleet.vehicle" + + infraction_ids = fields.One2many( + "fleet.traffic.infractions", "vehicle_id", string="Traffic Infractions" + ) + infraction_count = fields.Integer(compute="_compute_infraction_count") + + @api.depends("infraction_ids") + def _compute_infraction_count(self): + """Computes the number of infractions linked to this vehicle.""" + for vehicle in self: + vehicle.infraction_count = len(vehicle.infraction_ids) + + def action_view_infractions(self): + """ + Action to open the list of traffic infractions for the current vehicle. + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "fleet_traffic_infractions.action_fleet_traffic_infractions" + ) + action["domain"] = [("id", "in", self.infraction_ids.ids)] + action["context"] = {"default_vehicle_id": self.id} + return action + + def get_driver_for_datetime(self, date): + """ + Returns the driver assigned to the vehicle for a given datetime. + + This method searches for a vehicle assignment log where the provided + date falls between the start and end datetimes. An open-ended + assignment (no end datetime) is considered valid. + """ + self.ensure_one() + # Search domain to find a log covering the specific 'date' + domain = [ + ("vehicle_id", "=", self.id), + ("datetime_start", "<=", date), + "|", + ("datetime_end", "=", False), + ("datetime_end", ">", date), + ] + # Order by start date descending to get the most recent assignment + # in case of overlapping records. + log = self.env["fleet.vehicle.assignation.log"].search( + domain, order="datetime_start desc", limit=1 + ) + return log.driver_id if log else self.env["res.partner"] diff --git a/fleet_traffic_infractions/models/res_partner.py b/fleet_traffic_infractions/models/res_partner.py new file mode 100644 index 000000000..49cfebca3 --- /dev/null +++ b/fleet_traffic_infractions/models/res_partner.py @@ -0,0 +1,96 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ResPartner(models.Model): + _inherit = "res.partner" + + is_issuing_agency = fields.Boolean( + string="Is a Traffic Issuing Agency", + help="Check this box if this partner is an agency that can issue " + "traffic infractions.", + ) + + issuing_agency_rank = fields.Integer( + compute="_compute_issuing_agency_rank", + store=True, + help="The number of traffic infractions issued by this agency.", + ) + + issued_infraction_ids = fields.One2many( + "fleet.traffic.infractions", "issuing_agency_id", string="Issued Infractions" + ) + + driver_infraction_ids = fields.One2many( + "fleet.traffic.infractions", "driver_id", string="Driver Infractions" + ) + + total_infraction_fines = fields.Monetary( + compute="_compute_total_infraction_fines", + store=True, + help="Total value of fines attributed to this partner as a driver.", + ) + currency_id = fields.Many2one( + "res.currency", related="company_id.currency_id", string="Currency" + ) + + @api.depends("issued_infraction_ids") + def _compute_issuing_agency_rank(self): + if not self.ids: + return + infraction_data = self.env["fleet.traffic.infractions"].read_group( + [("issuing_agency_id", "in", self.ids)], + ["issuing_agency_id"], + ["issuing_agency_id"], + ) + mapped_data = { + data["issuing_agency_id"][0]: data["issuing_agency_id_count"] + for data in infraction_data + } + for partner in self: + partner.issuing_agency_rank = mapped_data.get(partner.id, 0) + + @api.depends("driver_infraction_ids.fine_amount", "driver_infraction_ids.state") + def _compute_total_infraction_fines(self): + for partner in self: + fines = partner.driver_infraction_ids.filtered( + lambda i: i.state not in ("draft", "cancel") + ).mapped("fine_amount") + partner.total_infraction_fines = sum(fines) + + def action_view_driver_infractions(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "fleet_traffic_infractions.action_fleet_traffic_infractions" + ) + action["domain"] = [("driver_id", "=", self.id)] + action["context"] = { + "default_driver_id": self.id, + "search_default_driver_id": self.id, + } + return action + + # MODIFICATION: Add new method to open issued infractions + def action_view_issued_infractions(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id( + "fleet_traffic_infractions.action_fleet_traffic_infractions" + ) + action["domain"] = [("issuing_agency_id", "=", self.id)] + action["context"] = { + "default_issuing_agency_id": self.id, + "search_default_issuing_agency_id": self.id, + } + return action + + @api.constrains("is_issuing_agency", "is_company") + def _check_issuing_agency_is_company(self): + """Ensures that only companies can be marked as Traffic Issuing Agencies.""" + for partner in self: + if partner.is_issuing_agency and not partner.is_company: + raise ValidationError( + _("Only companies can be marked as Traffic Issuing Agencies.") + ) diff --git a/fleet_traffic_infractions/pyproject.toml b/fleet_traffic_infractions/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fleet_traffic_infractions/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fleet_traffic_infractions/readme/CONFIGURE.md b/fleet_traffic_infractions/readme/CONFIGURE.md new file mode 100644 index 000000000..5523a6a6e --- /dev/null +++ b/fleet_traffic_infractions/readme/CONFIGURE.md @@ -0,0 +1,16 @@ +Before you can effectively use this module, you need to perform two configuration steps: + +1. **Mark Traffic Issuing Agencies:** + * Navigate to the `Contacts` application. + * Find or create the contact record for an agency that issues fines (e.g., "City Traffic Department", "State Highway Patrol"). + * On the contact form, check the box labeled **"Is a Traffic Issuing Agency"**. + * *This will ensure that only relevant partners appear in the "Issuing Agency" dropdown when creating an infraction.* + +2. **Define Infraction Types:** + * Navigate to `Fleet > Configuration > Infraction Types`. + * Click "New" to create categories for the fines you typically receive. + * Fill in the details: + * **Infraction Code:** A unique code for the infraction (e.g., `SPEED-01`). + * **Description:** A clear description (e.g., "Exceeding Speed Limit by 10-20%"). + * **Jurisdiction:** Define if the rule is at the Country, State, or Municipal level. + * *This allows for consistent data entry and will be useful for future reporting.* \ No newline at end of file diff --git a/fleet_traffic_infractions/readme/CONTEXT.md b/fleet_traffic_infractions/readme/CONTEXT.md new file mode 100644 index 000000000..04db81199 --- /dev/null +++ b/fleet_traffic_infractions/readme/CONTEXT.md @@ -0,0 +1,7 @@ +Managing a fleet of vehicles often involves dealing with a constant and disorganized flow of traffic tickets. Fleet managers face several key challenges: + +* **Identifying the Responsible Driver:** It can be a time-consuming and manual process to determine who was driving a specific vehicle at the exact time a fine was issued, often leading to disputes. +* **Lack of Centralized Tracking:** Without a dedicated system, it's difficult to track the status of an infraction from the moment it's received until it's resolved. +* **Operational vs. Financial Disconnect:** The operational details of a fine (who, what, when) are often disconnected from the financial process (paying the agency, charging the driver). + +This module was created to solve the **operational** part of this problem. It provides a clean, focused system for recording what happened and, most importantly, automates the critical task of identifying the responsible driver using the vehicle's assignment history. By creating a reliable operational record, it lays the foundation for a transparent and efficient management process. \ No newline at end of file diff --git a/fleet_traffic_infractions/readme/CONTRIBUTORS.md b/fleet_traffic_infractions/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..e0ac4368e --- /dev/null +++ b/fleet_traffic_infractions/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +* Raimundo Pereira da Silva Junior +* Odoo Community Association (OCA) \ No newline at end of file diff --git a/fleet_traffic_infractions/readme/DESCRIPTION.md b/fleet_traffic_infractions/readme/DESCRIPTION.md new file mode 100644 index 000000000..3126e1041 --- /dev/null +++ b/fleet_traffic_infractions/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module provides a robust framework to manage and track traffic infractions for your fleet vehicles. + +**Key Features:** + +* Create and manage a central registry of all traffic infractions. +* Automatically suggest the responsible driver by checking the vehicle's assignment logs for the exact date and time of the incident. +* Log a detailed, timezone-aware note in the chatter if a user manually changes the suggested driver, ensuring a clear audit trail. +* Define and categorize different types of infractions (e.g., speeding, illegal parking) with jurisdictional details. +* Mark contact partners as "Traffic Issuing Agencies" to streamline data entry. +* Integrates directly with the Vehicle and Partner forms, adding smart buttons to quickly view all related infractions. +* Allows infractions to be saved in a draft state and only validates mandatory fields upon confirmation. \ No newline at end of file diff --git a/fleet_traffic_infractions/readme/INSTALL.md b/fleet_traffic_infractions/readme/INSTALL.md new file mode 100644 index 000000000..85c462871 --- /dev/null +++ b/fleet_traffic_infractions/readme/INSTALL.md @@ -0,0 +1,9 @@ +To install this module, you need to: + +1. Add this module to your Odoo addons path. +2. Restart the Odoo server. +3. Go to the "Apps" menu, find the module, and click "Install". + +**Dependencies:** + +This module's core driver suggestion feature relies on the `fleet_vehicle_assignation_log_datetime` module. Please ensure it is installed and that your vehicle assignment logs are properly maintained with start and end datetimes for accurate driver lookups. \ No newline at end of file diff --git a/fleet_traffic_infractions/readme/ROADMAP.md b/fleet_traffic_infractions/readme/ROADMAP.md new file mode 100644 index 000000000..934d663dc --- /dev/null +++ b/fleet_traffic_infractions/readme/ROADMAP.md @@ -0,0 +1,10 @@ +This module provides the core operational foundation for managing traffic infractions. The roadmap includes creating a separate companion module for financial integration. + +**Future Module: `fleet_traffic_infractions_account`** + +This planned module will extend the functionality of this one to handle all accounting aspects: + +* Create a Vendor Bill from a confirmed infraction, with the "Issuing Agency" as the vendor. +* Create a Customer Invoice to charge the fine and any administrative fees to the responsible driver. +* Track the payment status of both the bill and the invoice. +* Provide a clear link between the infraction record and its corresponding journal entries. \ No newline at end of file diff --git a/fleet_traffic_infractions/readme/USAGE.md b/fleet_traffic_infractions/readme/USAGE.md new file mode 100644 index 000000000..b9c15ac6f --- /dev/null +++ b/fleet_traffic_infractions/readme/USAGE.md @@ -0,0 +1,25 @@ +This module is designed to be straightforward. The main workflow is creating and confirming an infraction. + +**1. Registering a New Traffic Infraction:** + +* Navigate to `Fleet > Infractions > Traffic Infractions` and click "New". +* The form is designed for a logical workflow. For the best experience, follow these steps: + 1. Select the **Vehicle** that received the fine. + 2. Set the **Infraction Datetime** to the exact date and time the incident occurred. +* **Automatic Driver Suggestion:** After setting the vehicle and datetime, the **Driver** field will be automatically populated based on the vehicle's assignment logs. +* Fill in the remaining details of the ticket: + * **Infraction Type:** Select the category you configured. + * **Infraction Auto Number:** Enter the official ticket or reference number. + * **Issuing Agency:** Select the agency that issued the fine. + * **Fine Amount** and **Due Date**. +* You can click **Save** at any time to keep the infraction in a `Draft` state. + +**2. Confirming and Managing the Infraction:** + +* Once all details are complete, click the **Confirm** button. The system will verify that all mandatory fields are filled before changing the state to `Confirmed`. +* **Driver Change Logging:** If the automatically suggested driver is incorrect and you manually change it, a detailed note will be automatically posted in the chatter. This note includes the old and new driver, the infraction time (with the user's timezone), and a comparison against the assignment log, ensuring full traceability. + +**3. Accessing Infractions from Other Views:** + +* On a **Vehicle** form, you can use the **"Traffic Infractions"** smart button to see all fines associated with that vehicle. +* On a **Partner** form, you will see a **"Driver Infractions"** smart button to view all infractions where that partner was the driver. \ No newline at end of file diff --git a/fleet_traffic_infractions/security/ir.model.access.csv b/fleet_traffic_infractions/security/ir.model.access.csv new file mode 100644 index 000000000..406010300 --- /dev/null +++ b/fleet_traffic_infractions/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fleet_traffic_infractions_user,fleet.traffic.infractions.user,model_fleet_traffic_infractions,base.group_user,1,0,0,0 +access_fleet_traffic_infractions_manager,fleet.traffic.infractions.manager,model_fleet_traffic_infractions,fleet.fleet_group_manager,1,1,1,1 +access_fleet_traffic_infraction_type_user,fleet.traffic.infraction.type.user,model_fleet_traffic_infraction_type,base.group_user,1,0,0,0 +access_fleet_traffic_infraction_type_manager,fleet.traffic.infraction.type.manager,model_fleet_traffic_infraction_type,fleet.fleet_group_manager,1,1,1,1 diff --git a/fleet_traffic_infractions/static/description/index.html b/fleet_traffic_infractions/static/description/index.html new file mode 100644 index 000000000..10fcf85aa --- /dev/null +++ b/fleet_traffic_infractions/static/description/index.html @@ -0,0 +1,583 @@ + + + + + +Fleet Traffic Infractions + + + +
+

Fleet Traffic Infractions

+ + +

Beta License: AGPL-3 OCA/fleet Translate me on Weblate Try me on Runboat

+

This module provides a robust framework to manage and track traffic +infractions for your fleet vehicles.

+

Key Features:

+
    +
  • Create and manage a central registry of all traffic infractions.
  • +
  • Automatically suggest the responsible driver by checking the vehicle’s +assignment logs for the exact date and time of the incident.
  • +
  • Log a detailed, timezone-aware note in the chatter if a user manually +changes the suggested driver, ensuring a clear audit trail.
  • +
  • Define and categorize different types of infractions (e.g., speeding, +illegal parking) with jurisdictional details.
  • +
  • Mark contact partners as “Traffic Issuing Agencies” to streamline data +entry.
  • +
  • Integrates directly with the Vehicle and Partner forms, adding smart +buttons to quickly view all related infractions.
  • +
  • Allows infractions to be saved in a draft state and only validates +mandatory fields upon confirmation.
  • +
+

Table of contents

+ +
+

Use Cases / Context

+

Managing a fleet of vehicles often involves dealing with a constant and +disorganized flow of traffic tickets. Fleet managers face several key +challenges:

+
    +
  • Identifying the Responsible Driver: It can be a time-consuming and +manual process to determine who was driving a specific vehicle at the +exact time a fine was issued, often leading to disputes.
  • +
  • Lack of Centralized Tracking: Without a dedicated system, it’s +difficult to track the status of an infraction from the moment it’s +received until it’s resolved.
  • +
  • Operational vs. Financial Disconnect: The operational details of a +fine (who, what, when) are often disconnected from the financial +process (paying the agency, charging the driver).
  • +
+

This module was created to solve the operational part of this +problem. It provides a clean, focused system for recording what happened +and, most importantly, automates the critical task of identifying the +responsible driver using the vehicle’s assignment history. By creating a +reliable operational record, it lays the foundation for a transparent +and efficient management process.

+
+
+

Installation

+

To install this module, you need to:

+
    +
  1. Add this module to your Odoo addons path.
  2. +
  3. Restart the Odoo server.
  4. +
  5. Go to the “Apps” menu, find the module, and click “Install”.
  6. +
+

Dependencies:

+

This module’s core driver suggestion feature relies on the +fleet_vehicle_assignation_log_datetime module. Please ensure it is +installed and that your vehicle assignment logs are properly maintained +with start and end datetimes for accurate driver lookups.

+
+
+

Configuration

+

Before you can effectively use this module, you need to perform two +configuration steps:

+
    +
  1. Mark Traffic Issuing Agencies:
      +
    • Navigate to the Contacts application.
    • +
    • Find or create the contact record for an agency that issues fines +(e.g., “City Traffic Department”, “State Highway Patrol”).
    • +
    • On the contact form, check the box labeled “Is a Traffic Issuing +Agency”.
    • +
    • This will ensure that only relevant partners appear in the +“Issuing Agency” dropdown when creating an infraction.
    • +
    +
  2. +
  3. Define Infraction Types:
      +
    • Navigate to Fleet > Configuration > Infraction Types.
    • +
    • Click “New” to create categories for the fines you typically +receive.
    • +
    • Fill in the details:
        +
      • Infraction Code: A unique code for the infraction (e.g., +SPEED-01).
      • +
      • Description: A clear description (e.g., “Exceeding Speed +Limit by 10-20%”).
      • +
      • Jurisdiction: Define if the rule is at the Country, State, or +Municipal level.
      • +
      +
    • +
    • This allows for consistent data entry and will be useful for +future reporting.
    • +
    +
  4. +
+
+
+

Usage

+

This module is designed to be straightforward. The main workflow is +creating and confirming an infraction.

+

1. Registering a New Traffic Infraction:

+
    +
  • Navigate to Fleet > Infractions > Traffic Infractions and click +“New”.
  • +
  • The form is designed for a logical workflow. For the best experience, +follow these steps:
      +
    1. Select the Vehicle that received the fine.
    2. +
    3. Set the Infraction Datetime to the exact date and time the +incident occurred.
    4. +
    +
  • +
  • Automatic Driver Suggestion: After setting the vehicle and +datetime, the Driver field will be automatically populated based +on the vehicle’s assignment logs.
  • +
  • Fill in the remaining details of the ticket:
      +
    • Infraction Type: Select the category you configured.
    • +
    • Infraction Auto Number: Enter the official ticket or reference +number.
    • +
    • Issuing Agency: Select the agency that issued the fine.
    • +
    • Fine Amount and Due Date.
    • +
    +
  • +
  • You can click Save at any time to keep the infraction in a +Draft state.
  • +
+

2. Confirming and Managing the Infraction:

+
    +
  • Once all details are complete, click the Confirm button. The +system will verify that all mandatory fields are filled before +changing the state to Confirmed.
  • +
  • Driver Change Logging: If the automatically suggested driver is +incorrect and you manually change it, a detailed note will be +automatically posted in the chatter. This note includes the old and +new driver, the infraction time (with the user’s timezone), and a +comparison against the assignment log, ensuring full traceability.
  • +
+

3. Accessing Infractions from Other Views:

+
    +
  • On a Vehicle form, you can use the “Traffic Infractions” smart +button to see all fines associated with that vehicle.
  • +
  • On a Partner form, you will see a “Driver Infractions” smart +button to view all infractions where that partner was the driver.
  • +
+
+
+

Known issues / Roadmap

+

This module provides the core operational foundation for managing +traffic infractions. The roadmap includes creating a separate companion +module for financial integration.

+

Future Module: ``fleet_traffic_infractions_account``

+

This planned module will extend the functionality of this one to handle +all accounting aspects:

+
    +
  • Create a Vendor Bill from a confirmed infraction, with the “Issuing +Agency” as the vendor.
  • +
  • Create a Customer Invoice to charge the fine and any administrative +fees to the responsible driver.
  • +
  • Track the payment status of both the bill and the invoice.
  • +
  • Provide a clear link between the infraction record and its +corresponding journal entries.
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Raimundo Pereira da Silva Junior
  • +
+
+
+

Contributors

+
    +
  • Raimundo Pereira da Silva Junior
  • +
  • Odoo Community Association (OCA)
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/fleet project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fleet_traffic_infractions/tests/__init__.py b/fleet_traffic_infractions/tests/__init__.py new file mode 100644 index 000000000..cd96b1edd --- /dev/null +++ b/fleet_traffic_infractions/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_fleet_traffic_infractions diff --git a/fleet_traffic_infractions/tests/test_fleet_traffic_infractions.py b/fleet_traffic_infractions/tests/test_fleet_traffic_infractions.py new file mode 100644 index 000000000..e517e13d1 --- /dev/null +++ b/fleet_traffic_infractions/tests/test_fleet_traffic_infractions.py @@ -0,0 +1,375 @@ +# Copyright 2025 Raimundo Pereira da Silva Junior, Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestFleetTrafficInfractions(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tz="UTC", + ) + ) + # Models + cls.Partner = cls.env["res.partner"] + cls.Vehicle = cls.env["fleet.vehicle"] + cls.VehicleModel = cls.env["fleet.vehicle.model"] + cls.VehicleModelBrand = cls.env["fleet.vehicle.model.brand"] + cls.Infraction = cls.env["fleet.traffic.infractions"] + cls.InfractionType = cls.env["fleet.traffic.infraction.type"] + cls.AssignationLog = cls.env["fleet.vehicle.assignation.log"] + cls.Country = cls.env["res.country"] + cls.State = cls.env["res.country.state"] + + # Create Partners + cls.partner_agency = cls.Partner.create( + {"name": "Traffic Agency", "is_issuing_agency": True, "is_company": True} + ) + cls.driver_1 = cls.Partner.create({"name": "Driver One"}) + cls.driver_2 = cls.Partner.create({"name": "Driver Two"}) + + # Create Vehicle + cls.brand = cls.VehicleModelBrand.create({"name": "Test Brand"}) + cls.model = cls.VehicleModel.create( + {"brand_id": cls.brand.id, "name": "Test Model"} + ) + cls.vehicle_1 = cls.Vehicle.create( + {"model_id": cls.model.id, "license_plate": "TEST-1"} + ) + + # Create Infraction Type + cls.infraction_type_1 = cls.InfractionType.create( + {"code": "SPEED", "description": "Speeding"} + ) + + # Create Geo Data for Jurisdiction tests + cls.country_us = cls.env.ref("base.us") + cls.state_ca = cls.env.ref("base.state_us_5") + + # Create Vehicle Assignment Logs + cls.now = datetime.now() + cls.log_1 = cls.AssignationLog.create( + { + "vehicle_id": cls.vehicle_1.id, + "driver_id": cls.driver_1.id, + "datetime_start": cls.now - timedelta(days=2), + "datetime_end": cls.now - timedelta(days=1), + } + ) + cls.log_2 = cls.AssignationLog.create( + { + "vehicle_id": cls.vehicle_1.id, + "driver_id": cls.driver_2.id, + "datetime_start": cls.now - timedelta(hours=8), + "datetime_end": cls.now + timedelta(hours=8), + } + ) + + def test_01_infraction_creation_and_defaults(self): + """Test the creation of an infraction and its default values.""" + infraction = self.Infraction.create({}) + self.assertEqual(infraction.state, "draft", "Default state should be 'draft'.") + self.assertNotEqual( + infraction.name, "New", "Reference should be assigned by sequence." + ) + + def test_02_get_driver_for_datetime_method(self): + """Test the helper method to get the correct driver for a datetime.""" + time_for_driver_1 = self.now - timedelta(days=1, hours=12) + found_driver_1 = self.vehicle_1.get_driver_for_datetime(time_for_driver_1) + self.assertEqual( + found_driver_1, + self.driver_1, + "Should find driver 1 for the specified time.", + ) + time_for_driver_2 = self.now + found_driver_2 = self.vehicle_1.get_driver_for_datetime(time_for_driver_2) + self.assertEqual( + found_driver_2, + self.driver_2, + "Should find driver 2 for the specified time.", + ) + time_for_no_driver = self.now - timedelta(days=5) + found_no_driver = self.vehicle_1.get_driver_for_datetime(time_for_no_driver) + self.assertFalse( + found_no_driver, "Should not find any driver for the specified time." + ) + + def test_03_onchange_driver_suggestion(self): + """Test that the onchange correctly suggests the driver.""" + infraction = self.Infraction.new() + infraction.vehicle_id = self.vehicle_1 + infraction.infraction_datetime = self.now + infraction._onchange_vehicle_infraction_datetime() + self.assertEqual( + infraction.driver_id, + self.driver_2, + "Onchange should suggest driver 2 for the current time.", + ) + infraction.infraction_datetime = self.now - timedelta(days=1, hours=12) + infraction._onchange_vehicle_infraction_datetime() + self.assertEqual( + infraction.driver_id, + self.driver_1, + "Onchange should suggest driver 1 for the past time.", + ) + infraction.vehicle_id = False + infraction._onchange_vehicle_infraction_datetime() + self.assertFalse( + infraction.driver_id, "Clearing vehicle should clear the driver." + ) + + def test_04_confirmation_logic_and_validation(self): + """Test that drafts can be saved and confirmation requires fields.""" + infraction = self.Infraction.create({}) + self.assertEqual(infraction.state, "draft") + with self.assertRaises(ValidationError) as e: + infraction.button_confirm() + self.assertIn("Vehicle", str(e.exception)) + self.assertIn("Driver", str(e.exception)) + self.assertIn("Infraction Address", str(e.exception)) + infraction.write( + { + "vehicle_id": self.vehicle_1.id, + "driver_id": self.driver_2.id, + "infraction_type_id": self.infraction_type_1.id, + "issuing_agency_id": self.partner_agency.id, + "infraction_datetime": self.now, + "street": "123 Test St", + "city": "Testville", + "country_id": self.country_us.id, + "infraction_auto_number": "TICKET-123", + "fine_amount": 150.0, + } + ) + infraction.button_confirm() + self.assertEqual( + infraction.state, "confirmed", "Infraction should be in confirmed state." + ) + + def test_05_driver_change_logging(self): + """Test that changing a driver posts a detailed message.""" + infraction = self.Infraction.create( + { + "vehicle_id": self.vehicle_1.id, + "driver_id": self.driver_2.id, + "infraction_datetime": self.now, + "infraction_auto_number": "LOG-TEST", + } + ) + infraction.write({"driver_id": self.driver_1.id}) + last_message = infraction.message_ids[0] + self.assertIn("Driver changed on infraction", last_message.body) + self.assertIn(self.driver_2.name, last_message.body) + self.assertIn(self.driver_1.name, last_message.body) + self.assertIn("The assignment log suggests", last_message.body) + self.assertIn(self.driver_2.name, last_message.body) + + def test_06_computed_and_related_fields(self): + """Test computed fields like infraction_key and smart button counts.""" + self.vehicle_1.infraction_ids.unlink() + infraction = self.Infraction.create( + { + "vehicle_id": self.vehicle_1.id, + "driver_id": self.driver_1.id, + "infraction_type_id": self.infraction_type_1.id, + "infraction_auto_number": "KEY-TEST", + "fine_amount": 100.0, + "state": "confirmed", + } + ) + expected_key = ( + f"{self.vehicle_1.license_plate}-KEY-TEST-{self.infraction_type_1.code}" + ) + self.assertEqual(infraction.infraction_key, expected_key) + self.assertEqual(self.vehicle_1.infraction_count, 1) + self.driver_1.invalidate_recordset(["total_infraction_fines"]) + self.assertEqual( + self.driver_1.total_infraction_fines, + 100.0, + "Total fines for driver 1 should be updated.", + ) + self.Infraction.create( + { + "vehicle_id": self.vehicle_1.id, + "driver_id": self.driver_1.id, + "infraction_type_id": self.infraction_type_1.id, + "infraction_auto_number": "KEY-TEST-DRAFT", + "fine_amount": 50.0, + "state": "draft", + } + ) + self.driver_1.invalidate_recordset(["total_infraction_fines"]) + self.assertEqual( + self.driver_1.total_infraction_fines, + 100.0, + "Draft fines should not be included in the total.", + ) + + def test_07_res_partner_logic(self): + """Test partner model computations, actions, and constraints.""" + self.Infraction.create( + { + "issuing_agency_id": self.partner_agency.id, + "infraction_auto_number": "AGENCY-1", + } + ) + self.partner_agency.invalidate_recordset(["issuing_agency_rank"]) + self.assertEqual(self.partner_agency.issuing_agency_rank, 1) + action_driver = self.driver_1.action_view_driver_infractions() + self.assertIn(("driver_id", "=", self.driver_1.id), action_driver["domain"]) + self.assertEqual( + action_driver["context"]["default_driver_id"], self.driver_1.id + ) + action_agency = self.partner_agency.action_view_issued_infractions() + self.assertIn( + ("issuing_agency_id", "=", self.partner_agency.id), action_agency["domain"] + ) + self.assertEqual( + action_agency["context"]["default_issuing_agency_id"], + self.partner_agency.id, + ) + with self.assertRaises(ValidationError): + self.driver_1.write({"is_issuing_agency": True}) + + def test_08_infraction_type_constraints(self): + """Test the jurisdiction constraints on infraction types.""" + with self.assertRaises(ValidationError, msg="Country level cannot have state"): + self.InfractionType.create( + { + "code": "C1", + "jurisdiction_level": "country", + "country_id": self.country_us.id, + "state_id": self.state_ca.id, + } + ) + with self.assertRaises(ValidationError, msg="State level cannot have city"): + self.InfractionType.create( + { + "code": "S1", + "jurisdiction_level": "state", + "country_id": self.country_us.id, + "state_id": self.state_ca.id, + "city": "Test City", + } + ) + with self.assertRaises(ValidationError, msg="State level must have state"): + self.InfractionType.create( + { + "code": "S2", + "jurisdiction_level": "state", + "country_id": self.country_us.id, + } + ) + with self.assertRaises( + ValidationError, msg="Municipal level must have state and city" + ): + self.InfractionType.create( + { + "code": "M1", + "jurisdiction_level": "municipal", + "country_id": self.country_us.id, + "state_id": self.state_ca.id, + } + ) + + def test_09_infraction_type_onchange(self): + """Test the onchange logic for jurisdiction level.""" + infraction_type = self.InfractionType.new( + { + "jurisdiction_level": "municipal", + "state_id": self.state_ca.id, + "city": "Test City", + } + ) + infraction_type.jurisdiction_level = "state" + infraction_type._onchange_jurisdiction_level() + self.assertFalse(infraction_type.city) + self.assertTrue(infraction_type.state_id) + infraction_type.jurisdiction_level = "country" + infraction_type._onchange_jurisdiction_level() + self.assertFalse(infraction_type.state_id) + + def test_10_vehicle_actions(self): + """Test the action method on the fleet.vehicle model.""" + infraction = self.Infraction.create({"vehicle_id": self.vehicle_1.id}) + action = self.vehicle_1.action_view_infractions() + self.assertIn(("id", "in", infraction.ids), action["domain"]) + self.assertEqual(action["context"]["default_vehicle_id"], self.vehicle_1.id) + + def test_11_infraction_edge_cases(self): + """Test various edge cases for traffic infractions.""" + common_vals = { + "vehicle_id": self.vehicle_1.id, + "infraction_auto_number": "UNIQUE-TEST", + "infraction_type_id": self.infraction_type_1.id, + } + self.Infraction.create(common_vals) + with mute_logger("odoo.sql_db"), self.assertRaises(IntegrityError): + with self.env.cr.savepoint(): + self.Infraction.create(common_vals) + infraction = self.Infraction.create({"driver_id": self.driver_1.id}) + infraction.write({"driver_id": self.driver_2.id}) + last_message = infraction.message_ids[0] + self.assertNotIn("assignment log", last_message.body) + infraction_no_driver_time = self.now - timedelta(days=4) + infraction_no_driver = self.Infraction.create( + { + "vehicle_id": self.vehicle_1.id, + "driver_id": self.driver_1.id, + "infraction_datetime": infraction_no_driver_time, + } + ) + infraction_no_driver.write({"driver_id": self.driver_2.id}) + last_message_no_driver = infraction_no_driver.message_ids[0] + self.assertIn("No driver was found", last_message_no_driver.body) + infraction.vehicle_id.license_plate = False + self.assertFalse(infraction.infraction_key) + infraction.state = "confirmed" + infraction.button_cancel() + self.assertEqual(infraction.state, "cancel") + infraction.button_draft() + self.assertEqual(infraction.state, "draft") + + def test_12_coverage_enhancements(self): + """Specific tests to cover requested lines for higher coverage.""" + self.env["res.partner"]._compute_issuing_agency_rank() + self.assertTrue(True, "Calling compute on empty recordset should not error.") + infraction = self.Infraction.create({"driver_id": self.driver_1.id}) + self.assertFalse(infraction.infraction_datetime) + infraction.write({"driver_id": self.driver_2.id}) + last_message = infraction.message_ids[0] + self.assertNotIn("assignment log", last_message.body) + self.assertNotIn("Note:", last_message.body) + + def test_13_advanced_coverage(self): + """Test specific edge cases for increased test coverage.""" + infraction = self.Infraction.create({"driver_id": self.driver_1.id}) + initial_message_count = len(infraction.message_ids) + infraction.write({"driver_id": self.driver_1.id}) + final_message_count = len(infraction.message_ids) + self.assertEqual( + initial_message_count, + final_message_count, + "Writing the same driver should not create a new message.", + ) + infraction_onchange = self.Infraction.new() + infraction_onchange.driver_id = self.driver_1 + infraction_onchange.vehicle_id = self.vehicle_1 + infraction_onchange.infraction_datetime = self.now - timedelta(days=5) + infraction_onchange._onchange_vehicle_infraction_datetime() + self.assertEqual( + infraction_onchange.driver_id, + self.driver_1, + "Onchange should not clear a manual driver if no log is found.", + ) diff --git a/fleet_traffic_infractions/views/fleet_traffic_infraction_menus.xml b/fleet_traffic_infractions/views/fleet_traffic_infraction_menus.xml new file mode 100644 index 000000000..d56f916cd --- /dev/null +++ b/fleet_traffic_infractions/views/fleet_traffic_infraction_menus.xml @@ -0,0 +1,69 @@ + + + + + Traffic Infractions + fleet.traffic.infractions + list,form + + + + + Infraction Types + fleet.traffic.infraction.type + list,form + + + + + Activity Types (Traffic Infractions) + mail.activity.type + list,kanban,form + + ['|', ('res_model', '=', False), ('res_model', '=', 'fleet.traffic.infractions')] + {'default_res_model': 'fleet.traffic.infractions'} + + + + + + + + + + + + + + diff --git a/fleet_traffic_infractions/views/fleet_traffic_infraction_type_views.xml b/fleet_traffic_infractions/views/fleet_traffic_infraction_type_views.xml new file mode 100644 index 000000000..d41e4a750 --- /dev/null +++ b/fleet_traffic_infractions/views/fleet_traffic_infraction_type_views.xml @@ -0,0 +1,57 @@ + + + + + fleet.traffic.infraction.type.form + fleet.traffic.infraction.type + +
+ +
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + + fleet.traffic.infraction.type.tree + fleet.traffic.infraction.type + + + + + + + + + +
diff --git a/fleet_traffic_infractions/views/fleet_traffic_infraction_views.xml b/fleet_traffic_infractions/views/fleet_traffic_infraction_views.xml new file mode 100644 index 000000000..908ea992c --- /dev/null +++ b/fleet_traffic_infractions/views/fleet_traffic_infraction_views.xml @@ -0,0 +1,224 @@ + + + + + + + fleet.traffic.infractions.tree + fleet.traffic.infractions + + + + + + + + + + + + + + + + + fleet.traffic.infractions.form + fleet.traffic.infractions + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + + + fleet.traffic.infractions.search + fleet.traffic.infractions + + + + + + + + + + + + + + + + + + + + + + + + Traffic Infractions + fleet.traffic.infractions + tree,form + + +

+ No traffic infractions found. Let's create one! +

+
+
+
diff --git a/fleet_traffic_infractions/views/fleet_vehicle_views.xml b/fleet_traffic_infractions/views/fleet_vehicle_views.xml new file mode 100644 index 000000000..67427e814 --- /dev/null +++ b/fleet_traffic_infractions/views/fleet_vehicle_views.xml @@ -0,0 +1,24 @@ + + + + fleet.vehicle.form.inherit.infractions + fleet.vehicle + + + + + + + + diff --git a/fleet_traffic_infractions/views/res_partner_views.xml b/fleet_traffic_infractions/views/res_partner_views.xml new file mode 100644 index 000000000..22ff205ab --- /dev/null +++ b/fleet_traffic_infractions/views/res_partner_views.xml @@ -0,0 +1,70 @@ + + + + res.partner.form.inherit.fleet.infraction + res.partner + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..fee35e6c2 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1 @@ +odoo-addon-fleet_vehicle_assignation_log_datetime@ git+https://github.com/OCA/fleet@refs/pull/218/head#subdirectory=fleet_vehicle_assignation_log_datetime