Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion farm_base/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Farm Base",
"summary": "Shared atoms for the farm pack: seasons, mixins, ag units",
"version": "19.0.1.0.0",
"version": "19.0.1.1.0",
"license": "AGPL-3",
"author": "Ledo Enterprises, Odoo Community Association (OCA)",
"website": "https://github.com/ledoent/farm-pack",
Expand Down
1 change: 1 addition & 0 deletions farm_base/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
from . import farm_mixin
from . import farm_measurement_mixin
from . import farm_gps_point_mixin
from . import farm_rank_mixin
77 changes: 77 additions & 0 deletions farm_base/models/farm_rank_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from odoo import api, fields, models


class FarmRankMixin(models.AbstractModel):
"""Selection-with-priority-rank helper for sortable models.

Several farm-pack models declare a Selection field whose **string
keys** don't sort alphabetically the way human-priority does. The
canonical example is observation urgency: `low / med / high` —
alpha-DESC gives "med > low > high" (m > l > h), which means
high-urgency items hide below medium ones in any list ordered by
that column.

The pattern this mixin extracts:

1. Subclass declares the user-visible Selection field as normal
(any name, any keys, any labels).
2. Subclass sets two class attributes pointing the mixin at that
field:

class FarmObservation(models.Model):
_inherit = ["mail.thread", "farm.rank.mixin"]
_order = "rank desc, observation_date desc"
_rank_selection_field = "urgency"
_rank_value_map = {"low": 0, "med": 1, "high": 2}

urgency = fields.Selection([
("low", "Low"), ("med", "Medium"), ("high", "High"),
], required=True, default="low")

3. The mixin contributes a stored, indexed `rank` Integer that
mirrors the selection key through `_rank_value_map`. The
subclass picks `rank` up in `_order` to get a real
priority sort.

Why not just rename selection keys? Two reasons:
- Keys are persisted to the DB and surface in xmlrpc / search
domains; "low/med/high" reads better than "0/1/2".
- Renaming requires a migration script; this mixin adds a column
without changing the existing column.
"""

_name = "farm.rank.mixin"
_description = "Selection-with-priority-rank helper"

# Subclasses MUST override these two attributes.
_rank_selection_field = ""
_rank_value_map: dict[str, int] = {}

rank = fields.Integer(
compute="_compute_rank",
store=True,
index=True,
readonly=True,
help="Numeric mirror of the configured selection field. Subclasses "
"should use `rank desc` in `_order` to get a priority sort that "
"actually matches the conceptual ordering of the selection keys.",
)

def _rank_depends(self):
"""Override hook — return the field names the rank depends on.

Defaults to the configured selection field. Override if a
subclass derives rank from multiple fields.
"""
return [self._rank_selection_field] if self._rank_selection_field else []

@api.depends(lambda self: self._rank_depends())
def _compute_rank(self):
sel_field = self._rank_selection_field
rank_map = self._rank_value_map
if not sel_field or not rank_map:
for rec in self:
rec.rank = 0
return
for rec in self:
rec.rank = rank_map.get(rec[sel_field], 0)
1 change: 1 addition & 0 deletions farm_base/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_farm_season
from . import test_rank_mixin
40 changes: 40 additions & 0 deletions farm_base/tests/test_rank_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from odoo.tests.common import TransactionCase


class TestFarmRankMixin(TransactionCase):
"""Smoke-test the mixin registers correctly and contributes the
expected `rank` field. End-to-end sort behavior is covered by the
consumer modules (farm_observation, farm_fence) which assert that
records ordered by `rank desc` come out in priority order.

We can't instantiate the AbstractModel directly with sample data —
Odoo abstract models aren't backed by a table — so this test focuses
on the field/attribute wiring contract subclasses rely on.
"""

def test_abstract_model_is_registered(self):
# Confirms farm_base/__init__.py imports the file so the registry
# picks up the AbstractModel. If this assertion ever fails the
# consumers will silently lose their `rank` column on next load.
self.assertIn("farm.rank.mixin", self.env.registry)

def test_rank_field_declared_with_required_attrs(self):
# Subclasses inherit the field declaration; if it loses `store`
# or `index` the priority sort silently becomes a Python sort
# which won't hit Postgres indexes on large lists.
mixin = self.env["farm.rank.mixin"]
field = mixin._fields["rank"]
self.assertEqual(field.type, "integer")
self.assertTrue(field.store, "rank must be stored for SQL _order")
self.assertTrue(field.index, "rank must be indexed for fast sort")
self.assertTrue(field.readonly)

def test_default_class_attrs_safe_when_unconfigured(self):
# A subclass that forgets to set _rank_selection_field /
# _rank_value_map should still load — the mixin's compute
# falls back to rank=0 rather than raising. End-to-end wiring
# for actual consumers (urgency, condition) is covered by the
# regression tests in farm_observation + farm_fence.
mixin = self.env["farm.rank.mixin"]
self.assertEqual(mixin._rank_selection_field, "")
self.assertEqual(mixin._rank_value_map, {})
7 changes: 4 additions & 3 deletions farm_fence/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ and the geodesic length in meters is converted to US survey feet
the same fence line in a rangeland improvement filing.

Repairs-needed bubble to the top of every list and grouped kanban
because ``_order`` uses a stored computed ``condition_rank`` integer
mirroring the selection — a naive string DESC would put "good" above
"fair" (alpha order).
because ``_order`` uses the stored ``rank`` integer contributed by
``farm.rank.mixin`` (from ``farm_base``), which mirrors the
``condition`` selection through a priority map — a naive string DESC on
the raw selection would put "good" above "fair" (alpha order).

Field link is optional (``ondelete="set null"``) so a perimeter fence
spanning multiple fields keeps its history even when one field is
Expand Down
2 changes: 1 addition & 1 deletion farm_fence/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Farm Fence",
"version": "19.0.1.0.0",
"version": "19.0.1.1.0",
"summary": "Fence lines + condition tracking + auto-computed length",
"author": "Ledo Enterprises, Odoo Community Association (OCA)",
"maintainers": ["dnplkndll"],
Expand Down
29 changes: 8 additions & 21 deletions farm_fence/models/farm_fence.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@
# for display — US farm fencing is universally specified in feet.
M_PER_US_SURVEY_FOOT = 0.3048006096012192

# Selection keys are descriptive strings (good/fair/repair) for the UI, but
# alpha-DESC would order "repair > good > fair" — fair-condition fences
# would hide below good-condition ones. `condition_rank` mirrors the
# selection so `_order` produces the actual urgency sort.
_CONDITION_RANK = {"good": 0, "fair": 1, "repair": 2}


@lru_cache(maxsize=1)
def _wgs84_to_albers_conus():
Expand All @@ -30,13 +24,18 @@ def _wgs84_to_albers_conus():
class FarmFence(models.Model):
_name = "farm.fence"
_description = "Fence"
_inherit = ["mail.thread"]
# Repairs-needed bubble to the top.
_order = "condition_rank desc, name"
_inherit = ["mail.thread", "farm.rank.mixin"]
# Repairs-needed bubble to the top. `rank` comes from farm.rank.mixin
# (mirrors `condition` through the selection→int map below so DESC
# sort gives repair > fair > good instead of alpha-DESC's broken order).
_order = "rank desc, name"
# Enforces field_id.company_id == fence.company_id at write time (only
# checked when field_id is set; perimeter fences with a null field_id
# pass through).
_check_company_auto = True
# farm.rank.mixin wiring:
_rank_selection_field = "condition"
_rank_value_map = {"good": 0, "fair": 1, "repair": 2}

name = fields.Char(required=True, tracking=True)
active = fields.Boolean(
Expand Down Expand Up @@ -91,13 +90,6 @@ class FarmFence(models.Model):
required=True,
tracking=True,
)
condition_rank = fields.Integer(
compute="_compute_condition_rank",
store=True,
index=True,
help="Numeric mirror of condition so _order produces an urgency sort "
"(string DESC on selection keys would put 'good' above 'fair').",
)
last_checked_date = fields.Date(tracking=True)
notes = fields.Text()
company_id = fields.Many2one(
Expand All @@ -107,11 +99,6 @@ class FarmFence(models.Model):
index=True,
)

@api.depends("condition")
def _compute_condition_rank(self):
for rec in self:
rec.condition_rank = _CONDITION_RANK.get(rec.condition, 0)

@api.depends("geom")
def _compute_length_feet(self):
# base_geoengine returns the field as a plain shapely geometry on
Expand Down
7 changes: 4 additions & 3 deletions farm_fence/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ and the geodesic length in meters is converted to US survey feet
for the same fence line in a rangeland improvement filing.

Repairs-needed bubble to the top of every list and grouped kanban
because `_order` uses a stored computed `condition_rank` integer
mirroring the selection — a naive string DESC would put "good" above
"fair" (alpha order).
because `_order` uses the stored `rank` integer contributed by
`farm.rank.mixin` (from `farm_base`), which mirrors the `condition`
selection through a priority map — a naive string DESC on the raw
selection would put "good" above "fair" (alpha order).

Field link is optional (`ondelete="set null"`) so a perimeter fence
spanning multiple fields keeps its history even when one field is
Expand Down
7 changes: 4 additions & 3 deletions farm_fence/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,10 @@ <h1>Farm Fence</h1>
(0.3048006096012192 m/ft). The number matches what NRCS would report for
the same fence line in a rangeland improvement filing.</p>
<p>Repairs-needed bubble to the top of every list and grouped kanban
because <tt class="docutils literal">_order</tt> uses a stored computed <tt class="docutils literal">condition_rank</tt> integer
mirroring the selection — a naive string DESC would put “good” above
“fair” (alpha order).</p>
because <tt class="docutils literal">_order</tt> uses the stored <tt class="docutils literal">rank</tt> integer contributed by
<tt class="docutils literal">farm.rank.mixin</tt> (from <tt class="docutils literal">farm_base</tt>), which mirrors the
<tt class="docutils literal">condition</tt> selection through a priority map — a naive string DESC on
the raw selection would put “good” above “fair” (alpha order).</p>
<p>Field link is optional (<tt class="docutils literal"><span class="pre">ondelete=&quot;set</span> null&quot;</tt>) so a perimeter fence
spanning multiple fields keeps its history even when one field is
archived or deleted.</p>
Expand Down
2 changes: 1 addition & 1 deletion farm_fence/tests/test_farm_fence.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def test_condition_sort_priority_not_alphabetical(self):
ordered.mapped("condition"),
["repair", "fair", "good"],
"Needs-repair must sort first; if you see good > fair > repair "
"condition_rank lost its compute",
"farm.rank.mixin's rank compute isn't wired to condition anymore",
)

def test_condition_tracking_declared_on_field(self):
Expand Down
2 changes: 1 addition & 1 deletion farm_observation/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Farm Observation",
"version": "19.0.1.0.0",
"version": "19.0.1.1.0",
"summary": "Geotagged field observations (photos + notes, urgency-sorted)",
"author": "Ledo Enterprises, Odoo Community Association (OCA)",
"maintainers": ["dnplkndll"],
Expand Down
29 changes: 8 additions & 21 deletions farm_observation/models/farm_observation.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
from odoo import api, fields, models

# Selection keys are alphabetical strings (low/med/high) for readability in
# the API + URLs, but a literal DESC sort would put "med" above "high"
# (alpha order). `urgency_rank` is a stored computed int that mirrors the
# selection so `_order` produces the actual priority sort.
_URGENCY_RANK = {"low": 0, "med": 1, "high": 2}


class FarmObservation(models.Model):
_name = "farm.observation"
_description = "Field Observation"
_inherit = ["mail.thread"]
_inherit = ["mail.thread", "farm.rank.mixin"]
# High-urgency, recent items rise to the top of every list / kanban.
_order = "urgency_rank desc, observation_date desc"
# `rank` comes from farm.rank.mixin (mirrors `urgency` through the
# selection→int map below so DESC sort gives high→med→low instead of
# alpha-DESC's med→low→high).
_order = "rank desc, observation_date desc"
# Enforces field_id.company_id == observation.company_id at write time.
_check_company_auto = True
# farm.rank.mixin wiring:
_rank_selection_field = "urgency"
_rank_value_map = {"low": 0, "med": 1, "high": 2}

field_id = fields.Many2one(
"farm.field",
Expand Down Expand Up @@ -75,25 +75,12 @@ class FarmObservation(models.Model):
required=True,
tracking=True,
)
urgency_rank = fields.Integer(
compute="_compute_urgency_rank",
store=True,
index=True,
help="Numeric mirror of urgency so _order produces a priority sort "
"(string DESC on selection keys would alphabetize, putting 'med' "
"above 'high').",
)
company_id = fields.Many2one(
related="field_id.company_id",
store=True,
index=True,
)

@api.depends("urgency")
def _compute_urgency_rank(self):
for rec in self:
rec.urgency_rank = _URGENCY_RANK.get(rec.urgency, 0)

@api.depends("field_id", "observation_type", "observation_date")
def _compute_name(self):
# E.g. "North 40 — Pest Damage — 2026-05-17"
Expand Down
3 changes: 2 additions & 1 deletion farm_observation/tests/test_farm_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,6 @@ def test_urgency_sort_priority_not_alphabetical(self):
ordered.mapped("urgency"),
["high", "med", "low"],
"high urgency must sort first; "
"if you see ['med', 'low', 'high'] urgency_rank lost its compute",
"if you see ['med', 'low', 'high'], farm.rank.mixin's rank "
"compute isn't wired to urgency anymore",
)
Loading