Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0944b7f
fix(spp_dci_server_social): SR server correctness + safety (Phase A)
gonzalesedwin1123 Jun 4, 2026
b75d43f
feat(spp_dci_server_social): serve programme enrollments in person re…
gonzalesedwin1123 Jun 4, 2026
5212445
feat(spp_dci_server_social): household summary in person records (Pha…
gonzalesedwin1123 Jun 4, 2026
3602bb7
feat(spp_dci_indicators): SR fetch handlers for the six sr.dci.* vari…
gonzalesedwin1123 Jun 4, 2026
dd6a4f4
fix(spp_dci_server): SPDCI-compliant /registry mount + endpoint-user …
gonzalesedwin1123 Jun 4, 2026
25d966e
style(spp_dci_server): keep nosemgrep suppression on the sudo match line
gonzalesedwin1123 Jun 4, 2026
d42ba42
fix(spp_dci_client_sr): use the data source's registry type, not a ha…
gonzalesedwin1123 Jun 4, 2026
87bb912
fix(spp_dci_indicators): SR handlers return values for every queried …
gonzalesedwin1123 Jun 4, 2026
8f7a4d0
fix(spp_cel_domain): metric comparisons compose correctly in and/or
gonzalesedwin1123 Jun 4, 2026
c43c583
fix(spp_cel_event): align _execute_plan override with the as_root par…
gonzalesedwin1123 Jun 4, 2026
328ca85
fix(spp_dci_server_social): defensive guards on required m2o derefere…
gonzalesedwin1123 Jun 4, 2026
7b5de7e
test(spp_cel_event): cover the _execute_plan passthrough
gonzalesedwin1123 Jun 4, 2026
eca3a91
fix(spp_dci_client): show bearer token field in data source form
gonzalesedwin1123 Jun 8, 2026
ea554b5
feat(spp_dci_server): settings page for API tokens and security flags
gonzalesedwin1123 Jun 8, 2026
fc4912a
ci(security): retry gitleaks download to survive transient 5xx
gonzalesedwin1123 Jun 8, 2026
ae7961f
feat(spp_dci): Test Connection validates the bearer token
gonzalesedwin1123 Jun 8, 2026
ce0ddaa
fix(spp_dci_indicators): depend on spp_dci_client_sr
gonzalesedwin1123 Jun 8, 2026
800189b
refactor(spp_dci_indicators): rename CEL accessors to r.dci.<registry…
gonzalesedwin1123 Jun 8, 2026
8c563bf
feat(spp_dci_client): drop unsupported Basic Authentication option
gonzalesedwin1123 Jun 8, 2026
57a8505
feat(spp_dci_server): accept OAuth2 access tokens for bearer auth
gonzalesedwin1123 Jun 8, 2026
2e4063f
docs(spp_dci_server): document the endpoint-user requirement for SR s…
gonzalesedwin1123 Jun 8, 2026
04e6f55
Merge remote-tracking branch 'origin/19.0' into feat/dci-followups
gonzalesedwin1123 Jun 8, 2026
aba70d8
docs(spp_dci_server): fix readme heading level skip
gonzalesedwin1123 Jun 8, 2026
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
8 changes: 6 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ jobs:
run: |
GITLEAKS_VERSION="8.21.2"
mkdir -p "$HOME/.local/bin"
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C "$HOME/.local/bin" gitleaks
# Download to a file (not a pipe) with retries so a transient 5xx
# from the release CDN doesn't fail the whole security gate.
curl -sSfL --retry 5 --retry-all-errors --retry-delay 3 \
-o /tmp/gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xz -C "$HOME/.local/bin" gitleaks < /tmp/gitleaks.tar.gz
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

- name: Run Gitleaks
Expand Down
32 changes: 25 additions & 7 deletions spp_cel_domain/models/cel_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ def compile_and_preview(
expr,
)
exec_self = self.with_context(cel_mode="preview", cel_request_id=request_id)
ids = exec_self._execute_plan(model, plan, metrics_info)
ids = exec_self._execute_plan(model, plan, metrics_info, as_root=True)
# If a fast-path domain override was provided in metrics_info, use it instead of materializing ids
override_domain: list[Any] | None = None
for mi in metrics_info:
Expand Down Expand Up @@ -709,7 +709,13 @@ def _ensure_domain_list(self, domain: list[Any]) -> list[Any]:
return [domain]

# Execute
def _execute_plan(self, model: str, plan: Any, metrics_info: list[dict[str, Any]] | None = None) -> list[int]: # noqa: C901
def _execute_plan(
self,
model: str,
plan: Any,
metrics_info: list[dict[str, Any]] | None = None,
as_root: bool = False,
) -> list[int]: # noqa: C901
if isinstance(plan, LeafDomain):
return self.env[plan.model].search(plan.domain).ids
if isinstance(plan, AND):
Expand Down Expand Up @@ -744,7 +750,7 @@ def _execute_plan(self, model: str, plan: Any, metrics_info: list[dict[str, Any]
if isinstance(plan, CountThrough):
return self._exec_count(plan)
if isinstance(plan, MetricCompare):
return self._exec_metric(model, plan, metrics_info)
return self._exec_metric(model, plan, metrics_info, as_root=as_root)
if isinstance(plan, CoverageRequire):
# Only support gating on MetricCompare results for now
if not isinstance(plan.node, MetricCompare):
Expand Down Expand Up @@ -1071,10 +1077,16 @@ def _exec_metric(
model: str,
p: MetricCompare,
metrics_info: list[dict[str, Any]] | None = None,
as_root: bool = False,
) -> list[int]:
"""Evaluate metric comparison and return matching subject IDs for current model.

Uses openspp.metrics service with mode=fallback.

``as_root`` is True only when this comparison IS the whole plan: in
that case the fresh-cache SQL shortcut may return no ids and stash an
``override_domain`` for the caller. Composed inside and/or/not, the
ids must be materialized so set composition stays correct.
"""
# Check metrics availability
self._check_metrics_available(p.metric)
Expand Down Expand Up @@ -1139,13 +1151,19 @@ def _exec_metric(
"metric": p.metric,
"period_key": period_key,
"path": path,
"override_domain": domain,
}
)
if as_root:
mi["override_domain"] = domain
metrics_info.append(mi)
# We return [] and let compile_and_preview use override_domain to avoid
# materializing ids into a huge 'in' list
return []
if as_root:
# The comparison is the whole plan: return [] and let
# compile_and_preview use override_domain to avoid
# materializing ids into a huge 'in' list.
return []
# Composed inside and/or/not: materialize the matching ids so
# set composition (intersection/union) stays correct.
return self.env[subject_model].search(self._and_domains(base_dom, domain)).ids
# Preview mode behavior when not fresh
cel_mode = self.env.context.get("cel_mode")
preview_cache_only_mode = cel_mode == "preview" and preview_cache_only
Expand Down
1 change: 1 addition & 0 deletions spp_cel_domain/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from . import test_cel_caching
from . import test_cel_exceptions
from . import test_cel_field_aggregations
from . import test_cel_metric_conjunction
from . import test_cel_functions
from . import test_cel_parser
from . import test_cel_security
Expand Down
73 changes: 73 additions & 0 deletions spp_cel_domain/tests/test_cel_metric_conjunction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Composing multiple metric() comparisons with and/or.

The fresh-cache SQL shortcut for a single metric comparison returns no ids
and stashes an override domain for the caller. That shortcut is only valid
when the comparison IS the whole plan: inside a conjunction or disjunction
the override of the first metric must not replace the composed result
(live-found: `metricA == true and metricB == true` matched everyone that
matched metricA alone).
"""

from odoo.tests import TransactionCase, tagged


@tagged("post_install", "-at_install")
class TestCelMetricConjunction(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.svc = cls.env["spp.cel.service"]
Partner = cls.env["res.partner"]
cls.a = Partner.create({"name": "Conj A", "is_registrant": True, "is_group": False})
cls.b = Partner.create({"name": "Conj B", "is_registrant": True, "is_group": False})
cls.c = Partner.create({"name": "Conj C", "is_registrant": True, "is_group": False})
# Complete, fresh cache for both metrics across all three subjects:
# m1: A=1, B=1, C=0 / m2: A=1, B=0, C=1
rows = []
for metric, values in (
("zz.conj.m1", {cls.a: 1, cls.b: 1, cls.c: 0}),
("zz.conj.m2", {cls.a: 1, cls.b: 0, cls.c: 1}),
):
for partner, value in values.items():
rows.append(
{
"variable_name": metric,
"subject_model": "res.partner",
"subject_id": partner.id,
"period_key": "current",
"value_json": {"value": value},
"value_type": "number",
"source_type": "external",
"ttl_seconds": 3600,
}
)
cls.env["spp.data.value"].upsert_values(rows)

def _match(self, expr):
r = self.svc.compile_expression(
expr,
profile="registry_individuals",
base_domain=[("id", "in", (self.a | self.b | self.c).ids)],
limit=0,
materialize_sql=True,
)
self.assertTrue(r.get("valid"), r.get("error"))
return self.env["res.partner"].search(r["domain"])

def test_metric_and_metric_intersects(self):
matched = self._match("metric('zz.conj.m1', me) >= 1 and metric('zz.conj.m2', me) >= 1")
self.assertEqual(matched, self.a, f"AND must intersect both metrics, got {matched.mapped('name')}")

def test_metric_or_metric_unions(self):
matched = self._match("metric('zz.conj.m1', me) >= 1 or metric('zz.conj.m2', me) >= 1")
self.assertEqual(
matched,
self.a | self.b | self.c,
f"OR must union both metrics, got {matched.mapped('name')}",
)

def test_single_metric_shortcut_still_works(self):
"""The root-level single-metric shortcut keeps its behavior."""
matched = self._match("metric('zz.conj.m1', me) >= 1")
self.assertEqual(matched, self.a | self.b)
10 changes: 8 additions & 2 deletions spp_cel_event/models/cel_event_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ class CelEventExecutor(models.AbstractModel):
# This prevents memory exhaustion from overly broad queries
MAX_QUERY_RESULTS = 100000

def _execute_plan(self, model: str, plan: Any, metrics_info: list[dict[str, Any]] | None = None) -> list[int]:
def _execute_plan(
self,
model: str,
plan: Any,
metrics_info: list[dict[str, Any]] | None = None,
as_root: bool = False,
) -> list[int]:
"""Execute query plan with event data support.

Extends base executor to handle EventValueCompare, EventExists, and EventsAggregate nodes.
Expand All @@ -42,7 +48,7 @@ def _execute_plan(self, model: str, plan: Any, metrics_info: list[dict[str, Any]
return self._exec_event_exists(model, plan)
if isinstance(plan, EventsAggregate):
return self._exec_event_aggregate(model, plan)
return super()._execute_plan(model, plan, metrics_info)
return super()._execute_plan(model, plan, metrics_info, as_root=as_root)

# ══════════════════════════════════════════════════════════════════════════════
# EventValueCompare Execution
Expand Down
17 changes: 17 additions & 0 deletions spp_cel_event/tests/test_cel_event_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,3 +1258,20 @@ def test_compute_aggregation_dict_dispatch(self):
self.assertEqual(executor._compute_aggregation(events, "count", None), 2)
# Unknown agg type returns 0
self.assertEqual(executor._compute_aggregation(events, "unknown_agg", "score"), 0)


@tagged("post_install", "-at_install")
class TestEventExecutorPassthrough(TransactionCase):
"""Non-event plan nodes fall through the event executor's _execute_plan
override to the base implementation (with the as_root parameter intact)."""

def test_non_event_plan_falls_through_to_base(self):
from odoo.addons.spp_cel_domain.models.cel_queryplan import LeafDomain

partner = self.env["res.partner"].create(
{"name": "Passthrough Person", "is_registrant": True, "is_group": False}
)
plan = LeafDomain(model="res.partner", domain=[("id", "=", partner.id)])
executor = self.env["spp.cel.executor"]
self.assertEqual(executor._execute_plan("res.partner", plan), [partner.id])
self.assertEqual(executor._execute_plan("res.partner", plan, as_root=True), [partner.id])
4 changes: 3 additions & 1 deletion spp_dci/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Place,
AdditionalAttribute,
)
from .person import Person, RelatedPerson, DisabilityInfo
from .person import HouseholdInfo, Person, ProgramEnrollment, RelatedPerson, DisabilityInfo
from .group import Group, Member
from .search import (
SearchCriteria,
Expand Down Expand Up @@ -76,6 +76,8 @@
"AdditionalAttribute",
# Person
"Person",
"ProgramEnrollment",
"HouseholdInfo",
"RelatedPerson",
"DisabilityInfo",
# Group
Expand Down
55 changes: 55 additions & 0 deletions spp_dci/schemas/person.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,53 @@ class DisabilityInfo(BaseModel):
)


class ProgramEnrollment(BaseModel):
"""A person's enrollment in a social protection programme.

Field names follow the SPDCI social profile (programme_name,
enrolment_status, ...). No internal database ids are carried.
"""

model_config = ConfigDict(populate_by_name=True)

programme_identifier: str | None = Field(
None,
description="External programme identifier, when one exists",
)
programme_name: str = Field(
...,
description="Human-readable programme name",
)
enrolment_status: str | None = Field(
None,
description="Enrollment status (enrolled, paused, ...)",
)
enrolment_date: date | None = Field(
None,
description="Date the person was enrolled",
)


class HouseholdInfo(BaseModel):
"""Summary of the person's household (OpenSPP extension).

Carried inside the person record so one search answers household-level
questions (size, headship) without a second group query.
"""

model_config = ConfigDict(populate_by_name=True)

household_size: int | None = Field(
None,
ge=1,
description="Number of active members in the person's household",
)
is_household_head: bool | None = Field(
None,
description="Whether the person is the head of the household",
)


class RelatedPerson(BaseModel):
"""DCI RelatedPerson schema - family relationship information."""

Expand Down Expand Up @@ -96,6 +143,14 @@ class Person(BaseModel):
None,
description="Date when person details were last updated",
)
enrolled_programs: list[ProgramEnrollment] | None = Field(
None,
description="Active social protection programme enrollments",
)
household_info: HouseholdInfo | None = Field(
None,
description="Summary of the person's household (OpenSPP extension)",
)


# Update forward references
Expand Down
2 changes: 1 addition & 1 deletion spp_dci_client/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"name": "OpenSPP DCI Client",
"summary": "Base DCI client infrastructure with OAuth2 and data source management",
"version": "19.0.2.0.0",
"version": "19.0.2.0.1",
"category": "OpenSPP/Integration",
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
Loading
Loading