diff --git a/spp_metrics_core/README.md b/spp_metrics_core/README.md new file mode 100644 index 00000000..74c8c9bb --- /dev/null +++ b/spp_metrics_core/README.md @@ -0,0 +1,184 @@ +# OpenSPP Metrics Core + +Shared foundation for all metrics (statistics, simulation metrics, etc.) in OpenSPP. + +## Overview + +`spp_metrics_core` provides the base model and categorization system that eliminates +duplication of genuinely shared fields across different metric types. All domain modules +(statistics, simulations, dashboards) inherit from the base model and add their own +computation-specific fields. + +## Architecture + +``` +spp.metric.base (AbstractModel) + │ + ├── spp.statistic (extends with publication flags) + ├── spp.simulation.metric (extends with scenario-specific fields) + └── [Your custom metric models] +``` + +## Models + +### spp.metric.base + +Abstract model providing genuinely shared fields for all metric types. + +Concrete models define their own computation-specific fields (metric_type, format, +expressions, etc.) since these vary incompatibly between metric types. + +**Identity** + +- `name` - Technical identifier (e.g., 'children_under_5') +- `label` - Human-readable display label (translated) +- `description` - Detailed description (translated) + +**Presentation** + +- `unit` - Unit of measurement (e.g., 'people', 'USD', '%') +- `decimal_places` - Decimal precision for display + +**Categorization** + +- `category_id` - Many2one to `spp.metric.category` + +**Metadata** + +- `sequence` - Display order within category +- `active` - Inactive metrics are hidden + +**What's NOT in the base** (defined by concrete models): + +- `metric_type` / `format` - Each concrete model defines its own selections +- `cel_expression` / `variable_id` - Computation approaches vary by type +- `aggregation` - Only relevant for certain metric types + +### spp.metric.category + +Shared categorization for all metric types: + +- `name` - Category name (e.g., "Population") +- `code` - Technical code (e.g., "population") +- `description` - Category description +- `sequence` - Display order +- `parent_id` - Optional parent category for hierarchical organization + +## Default Categories + +- **Population** - Population counts and demographics +- **Coverage** - Program coverage and reach metrics +- **Targeting** - Targeting accuracy and fairness metrics +- **Distribution** - Distribution and entitlement metrics + +## Defining Metrics + +Since `spp.metric.base` is an **AbstractModel**, it does not store data directly. Domain +modules define concrete metrics by inheriting from the base: + +- `spp_statistic` - Defines published statistics +- `spp_simulation` - Defines simulation metrics +- Custom modules - Define domain-specific metrics + +See the [Usage](#usage) section below for examples. + +## Usage + +### Creating Custom Metrics + +Inherit from `spp.metric.base` to create domain-specific metrics: + +```python +class CustomMetric(models.Model): + _name = "custom.metric" + _inherit = ["spp.metric.base"] + _description = "Custom Metric Type" + + # Shared fields inherited from base: + # - name, label, description + # - unit, decimal_places + # - category_id, sequence, active + + # Define your computation-specific fields + metric_type = fields.Selection([...]) # Your type selections + computation_field = fields.Text() # Your computation approach + + # Add domain-specific fields + custom_field = fields.Boolean() + custom_config = fields.Text() +``` + +### Using Categories + +Reference categories in your metrics: + +```xml + + my_metric + My Custom Metric + + +``` + +### Creating Custom Categories + +Add domain-specific categories: + +```xml + + Health + health + Health-related metrics + 50 + +``` + +## Migration + +### From spp_statistic.category + +The migration automatically renames `spp.statistic.category` to `spp.metric.category` +while preserving all data and external references. + +**Before**: + +```python +category = env['spp.statistic.category'].search([...]) +``` + +**After**: + +```python +category = env['spp.metric.category'].search([...]) +``` + +See [Migration Guide](../../docs/migration/statistics-refactoring.md) for details. + +## Benefits + +1. **No Duplication**: Genuinely shared fields defined once, reused everywhere +2. **Model-Specific Freedom**: Each concrete model defines its own computation fields + without conflicts +3. **Consistent UI**: Common fields (name, label, category) display the same way +4. **Shared Categories**: One categorization system for all metrics +5. **Future-Proof**: New metric types easily add their own computation approaches + +## Dependencies + +- `base` - Odoo core + +## Used By + +- `spp_metrics_services` - Aggregation and computation services +- `spp_statistic` - Published statistics +- `spp_simulation` - Simulation metrics +- Domain modules with custom metrics + +## Architecture Documentation + +See [Statistics System Architecture](../../docs/architecture/statistics-systems.md) for +the complete system design. + +## License + +LGPL-3 diff --git a/spp_metrics_core/__init__.py b/spp_metrics_core/__init__.py new file mode 100644 index 00000000..c4ccea79 --- /dev/null +++ b/spp_metrics_core/__init__.py @@ -0,0 +1,3 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/spp_metrics_core/__manifest__.py b/spp_metrics_core/__manifest__.py new file mode 100644 index 00000000..5a5074f8 --- /dev/null +++ b/spp_metrics_core/__manifest__.py @@ -0,0 +1,26 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP Metrics Core", + "summary": "Unified metric foundation for statistics and simulations", + "category": "OpenSPP", + "version": "19.0.2.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "base", + ], + "data": [ + "security/ir.model.access.csv", + "data/metric_categories.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_metrics_core/data/metric_categories.xml b/spp_metrics_core/data/metric_categories.xml new file mode 100644 index 00000000..92a32bf8 --- /dev/null +++ b/spp_metrics_core/data/metric_categories.xml @@ -0,0 +1,35 @@ + + + + + + + Population + population + Population counts and demographics + 10 + + + + Coverage + coverage + Program coverage and reach metrics + 20 + + + + Targeting + targeting + Targeting accuracy and fairness metrics + 30 + + + + Distribution + distribution + Distribution and entitlement metrics + 40 + + diff --git a/spp_metrics_core/migrations/19.0.1.0.0/pre-migrate.py b/spp_metrics_core/migrations/19.0.1.0.0/pre-migrate.py new file mode 100644 index 00000000..36276181 --- /dev/null +++ b/spp_metrics_core/migrations/19.0.1.0.0/pre-migrate.py @@ -0,0 +1,65 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Migrate spp.statistic.category to spp.metric.category.""" + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + """Migrate spp.statistic.category to spp.metric.category. + + Renames the table and sequence if they exist. This allows existing + statistic categories to be used as metric categories without data loss. + """ + _logger.info("Starting migration: spp.statistic.category -> spp.metric.category") + + # Check if old table exists + cr.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'spp_statistic_category' + ) + """ + ) + table_exists = cr.fetchone()[0] + + if table_exists: + _logger.info("Found spp_statistic_category table, renaming to spp_metric_category") + + # Rename table + cr.execute( + """ + ALTER TABLE spp_statistic_category + RENAME TO spp_metric_category + """ + ) + + # Check if old sequence exists + cr.execute( + """ + SELECT EXISTS ( + SELECT FROM pg_class + WHERE relname = 'spp_statistic_category_id_seq' + AND relkind = 'S' + ) + """ + ) + seq_exists = cr.fetchone()[0] + + if seq_exists: + _logger.info("Found spp_statistic_category_id_seq sequence, renaming to spp_metric_category_id_seq") + + # Rename sequence + cr.execute( + """ + ALTER SEQUENCE spp_statistic_category_id_seq + RENAME TO spp_metric_category_id_seq + """ + ) + + _logger.info("Successfully migrated spp.statistic.category to spp.metric.category") + else: + _logger.info("No spp_statistic_category table found, skipping migration") diff --git a/spp_metrics_core/models/__init__.py b/spp_metrics_core/models/__init__.py new file mode 100644 index 00000000..d4ec37fa --- /dev/null +++ b/spp_metrics_core/models/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import metric_base +from . import metric_category diff --git a/spp_metrics_core/models/metric_base.py b/spp_metrics_core/models/metric_base.py new file mode 100644 index 00000000..6c63e25b --- /dev/null +++ b/spp_metrics_core/models/metric_base.py @@ -0,0 +1,102 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Metric Base - Unified foundation for all metric types.""" + +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class MetricBase(models.AbstractModel): + """Shared foundation for all metric types. + + Provides genuinely shared fields that concrete metrics inherit without overriding: + - Identity (name, label, description) + - Presentation (unit, decimal_places) + - Categorization (category_id) + - Metadata (active, sequence) + + Computation-specific fields (metric_type, cel_expression, aggregation, format) + are defined by concrete models since they vary incompatibly between types. + + Usage + ----- + Concrete models inherit this to avoid field duplication: + + class Statistic(models.Model): + _name = "spp.statistic" + _inherit = ["spp.metric.base"] + + # Add statistic-specific fields + variable_id = fields.Many2one(...) + format = fields.Selection([("count", "Count"), ...]) + is_published_gis = fields.Boolean() + ... + + class SimulationMetric(models.Model): + _name = "spp.simulation.metric" + _inherit = ["spp.metric.base"] + + # Add simulation-specific fields + metric_type = fields.Selection([("aggregate", "Aggregate"), ...]) + cel_expression = fields.Text(...) + aggregation = fields.Selection(...) + ... + """ + + _name = "spp.metric.base" + _description = "Metric Base Model" + + # ─── Identity ─────────────────────────────────────────────────────── + name = fields.Char( + string="Name", + required=True, + index=True, + help="Technical identifier (e.g., 'children_under_5')", + ) + label = fields.Char( + string="Label", + required=True, + translate=True, + help="Human-readable display label (e.g., 'Children Under 5')", + ) + description = fields.Text( + string="Description", + translate=True, + help="Detailed description of what this metric measures", + ) + + # ─── Presentation ─────────────────────────────────────────────────── + unit = fields.Char( + string="Unit", + help="Unit of measurement (e.g., 'people', 'USD', '%')", + ) + + decimal_places = fields.Integer( + string="Decimal Places", + default=0, + help="Decimal places for display", + ) + + # ─── Categorization ───────────────────────────────────────────────── + category_id = fields.Many2one( + comodel_name="spp.metric.category", + string="Category", + ondelete="restrict", + help="Category for organizing metrics in displays", + ) + + # ─── Flags ────────────────────────────────────────────────────────── + active = fields.Boolean( + string="Active", + default=True, + help="Inactive metrics are hidden", + ) + + # ─── Metadata ─────────────────────────────────────────────────────── + sequence = fields.Integer( + string="Sequence", + default=10, + help="Display order within category", + ) diff --git a/spp_metrics_core/models/metric_category.py b/spp_metrics_core/models/metric_category.py new file mode 100644 index 00000000..2b3541f3 --- /dev/null +++ b/spp_metrics_core/models/metric_category.py @@ -0,0 +1,68 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Metric Category - Shared categorization for all metric types.""" + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class MetricCategory(models.Model): + """Categorization for metrics. + + Provides unified category management for all metric types: + - Statistics (spp.statistic) + - Simulation metrics (spp.simulation.metric) + - Future metric types + + Migrated from ``spp.statistic.category`` to provide a shared + foundation across all metric types. + """ + + _name = "spp.metric.category" + _description = "Metric Category" + _order = "sequence, name" + + name = fields.Char( + string="Name", + required=True, + translate=True, + help="Display name (e.g., 'Demographics')", + ) + code = fields.Char( + string="Code", + required=True, + index=True, + help="Unique technical identifier (e.g., 'demographics')", + ) + description = fields.Text( + string="Description", + translate=True, + help="Description of what metrics belong in this category", + ) + sequence = fields.Integer( + string="Sequence", + default=10, + help="Display order", + ) + active = fields.Boolean( + string="Active", + default=True, + help="Inactive categories are hidden", + ) + parent_id = fields.Many2one( + comodel_name="spp.metric.category", + string="Parent Category", + ondelete="restrict", + help="Parent category for hierarchical organization", + ) + + _sql_constraints = [("code_unique", "UNIQUE(code)", "Category code must be unique!")] + + @api.constrains("parent_id") + def _check_parent_recursion(self): + """Prevent circular parent relationships.""" + if self._has_cycle(): + raise ValidationError(_("You cannot create recursive category hierarchies.")) diff --git a/spp_metrics_core/pyproject.toml b/spp_metrics_core/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_metrics_core/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_metrics_core/security/ir.model.access.csv b/spp_metrics_core/security/ir.model.access.csv new file mode 100644 index 00000000..dc7bf773 --- /dev/null +++ b/spp_metrics_core/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_metric_category_all,access_spp_metric_category_all,model_spp_metric_category,base.group_user,1,0,0,0 +access_spp_metric_category_admin,access_spp_metric_category_admin,model_spp_metric_category,base.group_system,1,1,1,1 diff --git a/spp_metrics_core/tests/__init__.py b/spp_metrics_core/tests/__init__.py new file mode 100644 index 00000000..9acfa486 --- /dev/null +++ b/spp_metrics_core/tests/__init__.py @@ -0,0 +1,5 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from . import test_metric_base +from . import test_metric_category +from . import test_migration diff --git a/spp_metrics_core/tests/test_metric_base.py b/spp_metrics_core/tests/test_metric_base.py new file mode 100644 index 00000000..edfbe0cb --- /dev/null +++ b/spp_metrics_core/tests/test_metric_base.py @@ -0,0 +1,127 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestMetricBase(TransactionCase): + """Test the unified metric base model.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.category = cls.env["spp.metric.category"].create( + { + "name": "Test Category", + "code": "test_category", + "sequence": 10, + } + ) + + def test_metric_base_fields_exist(self): + """Test that base model shared fields are defined.""" + # Skip if spp_statistic is not installed + if "spp.statistic" not in self.env: + self.skipTest("spp_statistic module not installed") + + # Get a concrete model that inherits from metric.base + # We'll use spp.statistic which should inherit from it + fields = self.env["spp.statistic"]._fields + + # Identity fields (from base) + self.assertIn("name", fields, "name field should exist") + self.assertIn("label", fields, "label field should exist") + self.assertIn("description", fields, "description field should exist") + + # Presentation fields (from base) + self.assertIn("unit", fields, "unit field should exist") + self.assertIn("decimal_places", fields, "decimal_places field should exist") + + # Categorization (from base) + self.assertIn("category_id", fields, "category_id field should exist") + + # Flags (from base) + self.assertIn("active", fields, "active field should exist") + + # Metadata (from base) + self.assertIn("sequence", fields, "sequence field should exist") + + # Note: metric_type, cel_expression, aggregation, format are NOT in base + # They are defined by concrete models with model-specific selections + + def test_metric_base_inherited_by_statistic(self): + """Test that spp.statistic inherits from spp.metric.base.""" + # Skip if spp_statistic is not installed + if "spp.statistic" not in self.env: + self.skipTest("spp_statistic module not installed") + + # Check if spp.metric.base is in the inheritance chain + stat_model = self.env["spp.statistic"] + self.assertIn( + "spp.metric.base", + stat_model._inherit if isinstance(stat_model._inherit, list) else [stat_model._inherit], + "spp.statistic should inherit from spp.metric.base", + ) + + def test_metric_base_default_values(self): + """Test default field values from base model.""" + # Skip if spp_statistic is not installed + if "spp.statistic" not in self.env: + self.skipTest("spp_statistic module not installed") + + # Create a minimal statistic to test defaults + # We need a CEL variable first + variable = self.env["spp.cel.variable"].create( + { + "name": "test_var", + "label": "Test Variable", + "source_type": "computed", + "state": "active", + "expression": "1 + 1", + } + ) + + stat = self.env["spp.statistic"].create( + { + "name": "test_metric", + "label": "Test Metric", + "variable_id": variable.id, + } + ) + + # Test defaults from base model + self.assertTrue(stat.active, "active should default to True") + self.assertEqual(stat.sequence, 10, "sequence should default to 10") + self.assertEqual(stat.decimal_places, 0, "decimal_places should default to 0") + + def test_metric_base_category_assignment(self): + """Test that metrics can be assigned to categories.""" + # Skip if spp_statistic is not installed + if "spp.statistic" not in self.env: + self.skipTest("spp_statistic module not installed") + + variable = self.env["spp.cel.variable"].create( + { + "name": "test_var_2", + "label": "Test Variable 2", + "source_type": "computed", + "state": "active", + "expression": "2 + 2", + } + ) + + stat = self.env["spp.statistic"].create( + { + "name": "test_metric_2", + "label": "Test Metric 2", + "variable_id": variable.id, + "category_id": self.category.id, + } + ) + + self.assertEqual( + stat.category_id, + self.category, + "Metric should be assigned to category", + ) diff --git a/spp_metrics_core/tests/test_metric_category.py b/spp_metrics_core/tests/test_metric_category.py new file mode 100644 index 00000000..0e0069ab --- /dev/null +++ b/spp_metrics_core/tests/test_metric_category.py @@ -0,0 +1,77 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestMetricCategory(TransactionCase): + """Test the metric category model.""" + + def test_create_category(self): + """Test creating a metric category.""" + category = self.env["spp.metric.category"].create( + { + "name": "Demographics", + "code": "demographics", + "description": "Demographic statistics", + "sequence": 10, + } + ) + + self.assertEqual(category.name, "Demographics") + self.assertEqual(category.code, "demographics") + self.assertTrue(category.active) + + def test_category_code_unique(self): + """Test that category codes must be unique. + + Note: SQL unique constraint is enforced at database level. + This test verifies the constraint is defined in the model. + """ + # Verify the SQL constraint is defined + constraints = {name for name, _, _ in self.env["spp.metric.category"]._sql_constraints} + self.assertIn("code_unique", constraints, "SQL unique constraint should be defined for code field") + + def test_category_parent_child(self): + """Test parent-child category relationships.""" + parent = self.env["spp.metric.category"].create( + { + "name": "Parent Category", + "code": "parent", + } + ) + + child = self.env["spp.metric.category"].create( + { + "name": "Child Category", + "code": "child", + "parent_id": parent.id, + } + ) + + self.assertEqual(child.parent_id, parent) + + def test_category_sequence_ordering(self): + """Test that categories are ordered by sequence.""" + cat1 = self.env["spp.metric.category"].create( + { + "name": "Category 1", + "code": "cat1", + "sequence": 20, + } + ) + + cat2 = self.env["spp.metric.category"].create( + { + "name": "Category 2", + "code": "cat2", + "sequence": 10, + } + ) + + categories = self.env["spp.metric.category"].search([]) + # cat2 should come before cat1 due to lower sequence + cat2_index = list(categories).index(cat2) + cat1_index = list(categories).index(cat1) + self.assertLess(cat2_index, cat1_index, "Categories should be ordered by sequence") diff --git a/spp_metrics_core/tests/test_migration.py b/spp_metrics_core/tests/test_migration.py new file mode 100644 index 00000000..f51a26f1 --- /dev/null +++ b/spp_metrics_core/tests/test_migration.py @@ -0,0 +1,51 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestMigration(TransactionCase): + """Test migration from spp.statistic.category to spp.metric.category.""" + + def test_metric_category_table_exists(self): + """Test that spp_metric_category table exists.""" + self.env.cr.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'spp_metric_category' + ) + """ + ) + exists = self.env.cr.fetchone()[0] + self.assertTrue(exists, "spp_metric_category table should exist") + + def test_metric_category_sequence_exists(self): + """Test that spp_metric_category_id_seq sequence exists.""" + self.env.cr.execute( + """ + SELECT EXISTS ( + SELECT FROM pg_class + WHERE relname = 'spp_metric_category_id_seq' + AND relkind = 'S' + ) + """ + ) + exists = self.env.cr.fetchone()[0] + self.assertTrue(exists, "spp_metric_category_id_seq sequence should exist") + + def test_default_categories_loaded(self): + """Test that default metric categories are loaded.""" + categories = self.env["spp.metric.category"].search([]) + + # Check for expected default categories + category_codes = categories.mapped("code") + + expected_codes = ["population", "coverage", "targeting", "distribution"] + for code in expected_codes: + self.assertIn( + code, + category_codes, + f"Default category '{code}' should be loaded", + ) diff --git a/spp_statistic/__init__.py b/spp_statistic/__init__.py new file mode 100644 index 00000000..d3361032 --- /dev/null +++ b/spp_statistic/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import models diff --git a/spp_statistic/__manifest__.py b/spp_statistic/__manifest__.py new file mode 100644 index 00000000..d26a67b7 --- /dev/null +++ b/spp_statistic/__manifest__.py @@ -0,0 +1,28 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP Statistics", + "summary": "Publishable statistics based on CEL variables for dashboards, GIS, and APIs", + "category": "OpenSPP", + "version": "19.0.2.0.0", + "sequence": 1, + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "development_status": "Alpha", + "maintainers": ["jeremi", "gonzalesedwin1123"], + "depends": [ + "spp_cel_domain", + "spp_metrics_core", + "spp_security", + ], + "data": [ + "security/ir.model.access.csv", + "data/statistic_categories.xml", + ], + "assets": {}, + "demo": [], + "images": [], + "application": False, + "installable": True, + "auto_install": False, +} diff --git a/spp_statistic/data/statistic_categories.xml b/spp_statistic/data/statistic_categories.xml new file mode 100644 index 00000000..466c710e --- /dev/null +++ b/spp_statistic/data/statistic_categories.xml @@ -0,0 +1,70 @@ + + + + + + Demographics + demographics + Population composition statistics: age groups, gender distribution, household size + 10 + + + + + Vulnerability + vulnerability + Vulnerability indicators: disability, female-headed households, at-risk populations + 20 + + + + + Programs + programs + Program statistics: enrollment counts, coverage rates, benefit distribution + 30 + + + + + Geographic + geographic + Geographic statistics: area coverage, density, distribution patterns + 40 + + + + + Economic + economic + Economic statistics: income levels, poverty indicators, benefit amounts + 50 + + + + + Fairness + fairness + Equity and fairness metrics: disparity ratios, inclusion rates, coverage gaps + 60 + + diff --git a/spp_statistic/models/__init__.py b/spp_statistic/models/__init__.py new file mode 100644 index 00000000..1e589a56 --- /dev/null +++ b/spp_statistic/models/__init__.py @@ -0,0 +1,4 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +# statistic_category moved to spp_metrics_core as metric_category +from . import statistic +from . import statistic_context diff --git a/spp_statistic/models/statistic.py b/spp_statistic/models/statistic.py new file mode 100644 index 00000000..99f00d3c --- /dev/null +++ b/spp_statistic/models/statistic.py @@ -0,0 +1,311 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Statistic - Publishable statistics based on CEL variables.""" + +import logging +import re + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class Statistic(models.Model): + """A publishable statistic based on a CEL variable. + + Statistics separate concerns: + - CEL Variable: "What data and how to compute it" + - Statistic: "Where to publish and how to present it" + + A single CEL variable can be published as multiple statistics + with different presentations for different contexts. + + Inherits from spp.metric.base for common metric fields + (name, label, description, category_id, sequence, etc.) + """ + + _name = "spp.statistic" + _description = "Publishable Statistic" + _inherit = ["spp.metric.base"] + _order = "category_id, sequence, name" + + # ─── Source ───────────────────────────────────────────────────────── + variable_id = fields.Many2one( + comodel_name="spp.cel.variable", + string="CEL Variable", + required=True, + ondelete="restrict", + domain="[('source_type', 'in', ['aggregate', 'computed', 'field']), ('state', '=', 'active')]", + help="The CEL variable that provides the computation for this statistic", + ) + variable_accessor = fields.Char( + string="Variable Accessor", + related="variable_id.cel_accessor", + store=True, + help="CEL accessor for the underlying variable", + ) + variable_source_type = fields.Selection( + string="Variable Source", + related="variable_id.source_type", + store=True, + ) + + # ─── Presentation ─────────────────────────────────────────────────── + # Note: format is overridden here to provide statistic-specific selection values + # that represent aggregation types rather than display formats + format = fields.Selection( + selection=[ + ("count", "Count"), + ("sum", "Sum"), + ("avg", "Average"), + ("percent", "Percentage"), + ("ratio", "Ratio"), + ("currency", "Currency"), + ], + string="Format", + default="count", + help="How to format/aggregate this statistic in displays", + ) + # unit and decimal_places inherited from spp.metric.base + + # ─── Privacy / Small Cell Suppression ─────────────────────────────── + # Based on k-anonymity principles used in healthcare/census data + minimum_count = fields.Integer( + string="Minimum Count (k)", + default=5, + help="Suppress values when the underlying count is below this threshold. " + "This prevents re-identification of individuals in small groups. " + "Common values: 5 (basic), 10 (moderate), 20+ (sensitive data).", + ) + suppression_display = fields.Selection( + selection=[ + ("null", "Null/Empty"), + ("asterisk", "* (Suppressed)"), + ("less_than", "< [threshold]"), + ], + string="Suppression Display", + default="less_than", + help="How to display suppressed values in outputs.", + ) + is_sensitive = fields.Boolean( + string="Sensitive Data", + default=False, + help="Mark statistics involving sensitive attributes (disability, health, etc.) " + "which may warrant higher suppression thresholds.", + ) + + # ─── Organization ─────────────────────────────────────────────────── + # category_id and sequence inherited from spp.metric.base + # Note: category_id points to spp.metric.category (migrated from spp.statistic.category) + + # ─── Publication Flags ────────────────────────────────────────────── + is_published_gis = fields.Boolean( + string="Publish to GIS", + default=False, + help="Make this statistic available in GIS/QGIS spatial queries", + ) + is_published_dashboard = fields.Boolean( + string="Publish to Dashboard", + default=False, + help="Make this statistic available in dashboard widgets", + ) + is_published_api = fields.Boolean( + string="Publish to API", + default=False, + help="Make this statistic available in external API responses", + ) + is_published_report = fields.Boolean( + string="Publish to Reports", + default=False, + help="Make this statistic available in reports and exports", + ) + + # ─── Context-specific Configuration ───────────────────────────────── + context_ids = fields.One2many( + comodel_name="spp.statistic.context", + inverse_name="statistic_id", + string="Context Configurations", + help="Context-specific presentation overrides", + ) + + # ─── State ────────────────────────────────────────────────────────── + # active inherited from spp.metric.base + + # ─── Constraints ──────────────────────────────────────────────────── + _name_unique = models.Constraint("UNIQUE(name)", "Statistic name must be unique.") + + @api.constrains("name") + def _check_name_format(self): + """Ensure name follows snake_case convention.""" + for rec in self: + if not re.match(r"^[a-z][a-z0-9_]*$", rec.name): + raise ValidationError( + _("Statistic name '%(name)s' must be lowercase with underscores (e.g., 'children_under_5').") + % {"name": rec.name} + ) + + # ─── Query Methods ────────────────────────────────────────────────── + + @api.model + def get_published_for_context(self, context): + """Get all statistics published for a specific context. + + Args: + context: One of 'gis', 'dashboard', 'api', 'report' + + Returns: + recordset: Statistics published for that context + """ + field_map = { + "gis": "is_published_gis", + "dashboard": "is_published_dashboard", + "api": "is_published_api", + "report": "is_published_report", + } + + field = field_map.get(context) + if not field: + _logger.warning("Unknown statistic context: %s", context) + return self.browse() + + return self.search( + [ + (field, "=", True), + ("active", "=", True), + ] + ) + + @api.model + def get_published_by_category(self, context): + """Get published statistics grouped by category. + + Args: + context: One of 'gis', 'dashboard', 'api', 'report' + + Returns: + dict: {category_code: [statistics], ...} + """ + stats = self.get_published_for_context(context) + + result = {} + for stat in stats: + cat_code = stat.category_id.code if stat.category_id else "uncategorized" + if cat_code not in result: + result[cat_code] = [] + result[cat_code].append(stat) + + return result + + def get_context_config(self, context): + """Get context-specific configuration for this statistic. + + Falls back to default values if no context-specific config exists. + + Args: + context: One of 'gis', 'dashboard', 'api', 'report' + + Returns: + dict: Configuration with label, format, group, etc. + """ + self.ensure_one() + + # Look for context-specific override + ctx_config = self.context_ids.filtered(lambda c: c.context == context) + + if ctx_config: + ctx_config = ctx_config[0] + return { + "label": ctx_config.label or self.label, + "format": ctx_config.format or self.format, + "group": ctx_config.group or (self.category_id.code if self.category_id else None), + "icon": ctx_config.icon, + "color": ctx_config.color, + "minimum_count": ctx_config.minimum_count or self.minimum_count, + "suppression_display": self.suppression_display, + } + + # Fall back to defaults + return { + "label": self.label, + "format": self.format, + "group": self.category_id.code if self.category_id else None, + "icon": None, + "color": None, + "minimum_count": self.minimum_count, + "suppression_display": self.suppression_display, + } + + def apply_suppression(self, value, count=None, context=None): + """Apply small cell suppression to protect privacy. + + Based on k-anonymity principles: if the underlying count is below + the minimum threshold, the value is suppressed to prevent + re-identification of individuals in small groups. + + Args: + value: The computed statistic value + count: The underlying count (if different from value). + For count statistics, this equals value. + For averages/percentages, this is the denominator. + context: Optional context for context-specific thresholds + + Returns: + tuple: (display_value, is_suppressed) + - display_value: The value to show (may be suppressed indicator) + - is_suppressed: Boolean indicating if suppression was applied + """ + self.ensure_one() + + # Get context-specific config + config = self.get_context_config(context) if context else {} + min_count = config.get("minimum_count", self.minimum_count) or 5 + display_mode = config.get("suppression_display", self.suppression_display) + + # Use value as count if not specified (for count-type statistics) + if count is None: + count = value if isinstance(value, int) else 0 + + # Delegate to unified privacy service + privacy_service = self.env.get("spp.metrics.privacy") + if privacy_service is not None: + stat_config = {"minimum_count": min_count, "suppression_display": display_mode} + return privacy_service.suppress_value(value, count, stat_config=stat_config) + + # Fallback: inline suppression if service unavailable + if count < min_count: + if display_mode == "null": + return None, True + elif display_mode == "asterisk": + return "*", True + elif display_mode == "less_than": + return f"<{min_count}", True + else: + return None, True + + return value, False + + def to_dict(self, context=None): + """Convert statistic to dictionary for API/UI consumption. + + Args: + context: Optional context for context-specific config + + Returns: + dict: Statistic data + """ + self.ensure_one() + + config = self.get_context_config(context) if context else {} + + return { + "name": self.name, + "label": config.get("label", self.label), + "description": self.description, + "format": config.get("format", self.format), + "unit": self.unit, + "decimal_places": self.decimal_places, + "category": self.category_id.code if self.category_id else None, + "group": config.get("group"), + "variable_accessor": self.variable_accessor, + "minimum_count": config.get("minimum_count", self.minimum_count), + } diff --git a/spp_statistic/models/statistic_context.py b/spp_statistic/models/statistic_context.py new file mode 100644 index 00000000..b5a7276a --- /dev/null +++ b/spp_statistic/models/statistic_context.py @@ -0,0 +1,106 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Statistic Context - Context-specific presentation configuration.""" + +from odoo import fields, models + + +class StatisticContext(models.Model): + """Context-specific presentation configuration for a statistic. + + Allows overriding default presentation settings for specific contexts + (GIS, dashboard, API, reports). For example, a statistic might use + a different label or grouping in the GIS context vs. dashboard. + """ + + _name = "spp.statistic.context" + _description = "Statistic Context Configuration" + _order = "statistic_id, context" + + statistic_id = fields.Many2one( + comodel_name="spp.statistic", + string="Statistic", + required=True, + ondelete="cascade", + ) + + context = fields.Selection( + selection=[ + ("gis", "GIS / QGIS"), + ("dashboard", "Dashboard"), + ("api", "External API"), + ("report", "Reports / Export"), + ], + string="Context", + required=True, + help="The context this configuration applies to", + ) + + # ─── Presentation Overrides ───────────────────────────────────────── + label = fields.Char( + string="Label Override", + translate=True, + help="Override the default label for this context", + ) + format = fields.Selection( + selection=[ + ("count", "Count"), + ("sum", "Sum"), + ("avg", "Average"), + ("percent", "Percentage"), + ("ratio", "Ratio"), + ("currency", "Currency"), + ], + string="Format Override", + help="Override the default format for this context", + ) + group = fields.Char( + string="Group Override", + help="Override the category grouping for this context", + ) + + # ─── Privacy Override ──────────────────────────────────────────────── + minimum_count = fields.Integer( + string="Minimum Count Override", + help="Override the minimum count threshold for this context. " + "Use higher values for public-facing contexts (e.g., 10 for GIS maps) " + "and lower values for internal dashboards (e.g., 5). " + "Leave empty to use the statistic's default.", + ) + + # ─── Context-specific Fields ──────────────────────────────────────── + icon = fields.Char( + string="Icon", + help="Icon for this context (Font Awesome class)", + ) + color = fields.Char( + string="Color", + help="Color for this context (hex code)", + ) + + # GIS-specific + gis_threshold_mode = fields.Selection( + selection=[ + ("auto_quartile", "Auto - Quartiles"), + ("auto_equal", "Auto - Equal Intervals"), + ("auto_jenks", "Auto - Natural Breaks"), + ("manual", "Manual Thresholds"), + ], + string="GIS Threshold Mode", + help="How to calculate color thresholds for GIS visualization", + ) + + # Dashboard-specific + dashboard_widget_type = fields.Selection( + selection=[ + ("number", "Number"), + ("gauge", "Gauge"), + ("chart", "Chart"), + ], + string="Dashboard Widget", + help="Type of widget to use in dashboards", + ) + + _statistic_context_unique = models.Constraint( + "UNIQUE(statistic_id, context)", + "Each statistic can only have one configuration per context.", + ) diff --git a/spp_statistic/pyproject.toml b/spp_statistic/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_statistic/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_statistic/security/ir.model.access.csv b/spp_statistic/security/ir.model.access.csv new file mode 100644 index 00000000..f1631d6c --- /dev/null +++ b/spp_statistic/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_spp_statistic_user,spp.statistic.user,model_spp_statistic,base.group_user,1,0,0,0 +access_spp_statistic_admin,spp.statistic.admin,model_spp_statistic,spp_security.group_spp_admin,1,1,1,1 +access_spp_statistic_context_user,spp.statistic.context.user,model_spp_statistic_context,base.group_user,1,0,0,0 +access_spp_statistic_context_admin,spp.statistic.context.admin,model_spp_statistic_context,spp_security.group_spp_admin,1,1,1,1 diff --git a/spp_statistic/tests/__init__.py b/spp_statistic/tests/__init__.py new file mode 100644 index 00000000..e73e3e48 --- /dev/null +++ b/spp_statistic/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +from . import test_statistic diff --git a/spp_statistic/tests/test_statistic.py b/spp_statistic/tests/test_statistic.py new file mode 100644 index 00000000..a99a5484 --- /dev/null +++ b/spp_statistic/tests/test_statistic.py @@ -0,0 +1,329 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests for publishable statistics module.""" + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestStatisticCategory(TransactionCase): + """Test statistic category model integration. + + Note: Category model tests are in spp_metrics_core/tests/test_metric_category.py + This class tests the integration between statistics and categories. + """ + + def test_create_category(self): + """Test creating a metric category (used by statistics).""" + Category = self.env["spp.metric.category"] + + category = Category.create( + { + "name": "Test Category", + "code": "test_category", + "description": "A test category", + "sequence": 100, + } + ) + + self.assertEqual(category.name, "Test Category") + self.assertEqual(category.code, "test_category") + self.assertTrue(category.active) + + def test_statistic_uses_metric_category(self): + """Test that statistics can use metric categories.""" + Category = self.env["spp.metric.category"] + + category = Category.create({"name": "Test", "code": "test_cat"}) + + # Verify we can assign this category to a statistic + variable = self.env["spp.cel.variable"].create( + { + "name": "test_var", + "cel_accessor": "test_var", + "source_type": "computed", + "cel_expression": "true", + "state": "active", + } + ) + + stat = self.env["spp.statistic"].create( + { + "name": "test_stat", + "label": "Test Stat", + "variable_id": variable.id, + "category_id": category.id, + } + ) + + self.assertEqual(stat.category_id, category) + + +class TestStatistic(TransactionCase): + """Test statistic model.""" + + @classmethod + def setUpClass(cls): + """Set up test data.""" + super().setUpClass() + + # Create a CEL variable for testing + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "test_cel_var", + "cel_accessor": "test_var", + "source_type": "aggregate", + "aggregate_type": "count", + "aggregate_target": "members", + "aggregate_filter": "true", + "value_type": "number", + "applies_to": "group", + "state": "active", + } + ) + + # Create a category + cls.category = cls.env["spp.metric.category"].create( + { + "name": "Test Category", + "code": "test", + } + ) + + def test_create_statistic(self): + """Test creating a statistic.""" + Statistic = self.env["spp.statistic"] + + stat = Statistic.create( + { + "name": "test_stat", + "label": "Test Statistic", + "variable_id": self.cel_variable.id, + "format": "count", + "category_id": self.category.id, + "is_published_gis": True, + "is_published_dashboard": True, + } + ) + + self.assertEqual(stat.name, "test_stat") + self.assertEqual(stat.label, "Test Statistic") + self.assertTrue(stat.is_published_gis) + self.assertTrue(stat.is_published_dashboard) + self.assertFalse(stat.is_published_api) + + def test_name_format_validation(self): + """Test that statistic names must be snake_case.""" + Statistic = self.env["spp.statistic"] + + # Valid names + stat = Statistic.create( + { + "name": "valid_name_123", + "label": "Valid", + "variable_id": self.cel_variable.id, + } + ) + self.assertTrue(stat.id) + + # Invalid: uppercase + with self.assertRaises(ValidationError): + Statistic.create( + { + "name": "Invalid_Name", + "label": "Invalid", + "variable_id": self.cel_variable.id, + } + ) + + # Invalid: starts with number + with self.assertRaises(ValidationError): + Statistic.create( + { + "name": "123_invalid", + "label": "Invalid", + "variable_id": self.cel_variable.id, + } + ) + + def test_get_published_for_context(self): + """Test querying statistics by context.""" + Statistic = self.env["spp.statistic"] + + # Create statistics with different publication flags + stat_gis = Statistic.create( + { + "name": "gis_only", + "label": "GIS Only", + "variable_id": self.cel_variable.id, + "is_published_gis": True, + } + ) + stat_dashboard = Statistic.create( + { + "name": "dashboard_only", + "label": "Dashboard Only", + "variable_id": self.cel_variable.id, + "is_published_dashboard": True, + } + ) + stat_both = Statistic.create( + { + "name": "both_contexts", + "label": "Both", + "variable_id": self.cel_variable.id, + "is_published_gis": True, + "is_published_dashboard": True, + } + ) + + # Query GIS statistics + gis_stats = Statistic.get_published_for_context("gis") + self.assertIn(stat_gis, gis_stats) + self.assertIn(stat_both, gis_stats) + self.assertNotIn(stat_dashboard, gis_stats) + + # Query dashboard statistics + dashboard_stats = Statistic.get_published_for_context("dashboard") + self.assertIn(stat_dashboard, dashboard_stats) + self.assertIn(stat_both, dashboard_stats) + self.assertNotIn(stat_gis, dashboard_stats) + + def test_get_published_by_category(self): + """Test grouping statistics by category.""" + Statistic = self.env["spp.statistic"] + + cat2 = self.env["spp.metric.category"].create({"name": "Second", "code": "second"}) + + Statistic.create( + { + "name": "cat1_stat", + "label": "Cat 1 Stat", + "variable_id": self.cel_variable.id, + "category_id": self.category.id, + "is_published_gis": True, + } + ) + Statistic.create( + { + "name": "cat2_stat", + "label": "Cat 2 Stat", + "variable_id": self.cel_variable.id, + "category_id": cat2.id, + "is_published_gis": True, + } + ) + + grouped = Statistic.get_published_by_category("gis") + + self.assertIn("test", grouped) + self.assertIn("second", grouped) + + def test_to_dict(self): + """Test dictionary conversion for API.""" + Statistic = self.env["spp.statistic"] + + stat = Statistic.create( + { + "name": "dict_test", + "label": "Dict Test", + "description": "Test description", + "variable_id": self.cel_variable.id, + "format": "count", + "unit": "people", + "category_id": self.category.id, + } + ) + + data = stat.to_dict() + + self.assertEqual(data["name"], "dict_test") + self.assertEqual(data["label"], "Dict Test") + self.assertEqual(data["format"], "count") + self.assertEqual(data["unit"], "people") + self.assertEqual(data["category"], "test") + + +class TestStatisticContext(TransactionCase): + """Test statistic context configuration.""" + + @classmethod + def setUpClass(cls): + """Set up test data.""" + super().setUpClass() + + cls.cel_variable = cls.env["spp.cel.variable"].create( + { + "name": "context_test_var", + "cel_accessor": "context_test", + "source_type": "computed", + "cel_expression": "true", + "value_type": "number", + "state": "active", + } + ) + + cls.statistic = cls.env["spp.statistic"].create( + { + "name": "context_test_stat", + "label": "Default Label", + "variable_id": cls.cel_variable.id, + "format": "count", + "is_published_gis": True, + "is_published_dashboard": True, + } + ) + + def test_context_override(self): + """Test that context-specific config overrides defaults.""" + Context = self.env["spp.statistic.context"] + + # Create GIS-specific override + Context.create( + { + "statistic_id": self.statistic.id, + "context": "gis", + "label": "GIS Label", + "format": "sum", + "group": "custom_group", + } + ) + + # Get GIS config - should use override + gis_config = self.statistic.get_context_config("gis") + self.assertEqual(gis_config["label"], "GIS Label") + self.assertEqual(gis_config["format"], "sum") + self.assertEqual(gis_config["group"], "custom_group") + + # Get dashboard config - should use defaults + dashboard_config = self.statistic.get_context_config("dashboard") + self.assertEqual(dashboard_config["label"], "Default Label") + self.assertEqual(dashboard_config["format"], "count") + + def test_context_unique_constraint(self): + """Test that each statistic can only have one config per context.""" + Context = self.env["spp.statistic.context"] + + Context.create( + { + "statistic_id": self.statistic.id, + "context": "gis", + } + ) + + # Second context for same statistic should fail due to unique constraint + # Use a savepoint to avoid polluting the transaction log with expected errors + with self.assertRaises(Exception) as context_manager: + with self.env.cr.savepoint(): + Context.create( + { + "statistic_id": self.statistic.id, + "context": "gis", + } + ) + + # Verify it was a constraint error + error_msg = str(context_manager.exception).lower() + self.assertTrue( + "unique" in error_msg or "duplicate" in error_msg, + f"Expected unique constraint error, got: {context_manager.exception}", + ) diff --git a/spp_statistic_studio/__init__.py b/spp_statistic_studio/__init__.py new file mode 100644 index 00000000..441611e1 --- /dev/null +++ b/spp_statistic_studio/__init__.py @@ -0,0 +1 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. diff --git a/spp_statistic_studio/__manifest__.py b/spp_statistic_studio/__manifest__.py new file mode 100644 index 00000000..58792641 --- /dev/null +++ b/spp_statistic_studio/__manifest__.py @@ -0,0 +1,23 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +{ + "name": "OpenSPP Statistics Studio", + "version": "19.0.2.0.0", + "category": "OpenSPP/Configuration", + "summary": "Studio UI for managing publishable statistics", + "author": "OpenSPP.org", + "website": "https://github.com/OpenSPP/OpenSPP2", + "license": "LGPL-3", + "depends": [ + "spp_statistic", + "spp_studio", + ], + "data": [ + "security/ir.model.access.csv", + "views/statistic_views.xml", + "views/statistic_category_views.xml", + "views/menus.xml", + ], + "installable": True, + # Bridge module: auto-install when both spp_statistic and spp_studio are present + "auto_install": True, +} diff --git a/spp_statistic_studio/pyproject.toml b/spp_statistic_studio/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/spp_statistic_studio/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/spp_statistic_studio/security/ir.model.access.csv b/spp_statistic_studio/security/ir.model.access.csv new file mode 100644 index 00000000..61bb300a --- /dev/null +++ b/spp_statistic_studio/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_spp_statistic_studio_admin,spp.statistic studio admin,spp_statistic.model_spp_statistic,spp_studio.group_studio_manager,1,1,1,1 +access_spp_metric_category_studio_admin,spp.metric.category studio admin,spp_metrics_core.model_spp_metric_category,spp_studio.group_studio_manager,1,1,1,1 +access_spp_statistic_context_studio_admin,spp.statistic.context studio admin,spp_statistic.model_spp_statistic_context,spp_studio.group_studio_manager,1,1,1,1 diff --git a/spp_statistic_studio/views/menus.xml b/spp_statistic_studio/views/menus.xml new file mode 100644 index 00000000..b0434f04 --- /dev/null +++ b/spp_statistic_studio/views/menus.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/spp_statistic_studio/views/statistic_category_views.xml b/spp_statistic_studio/views/statistic_category_views.xml new file mode 100644 index 00000000..38cb74dd --- /dev/null +++ b/spp_statistic_studio/views/statistic_category_views.xml @@ -0,0 +1,92 @@ + + + + + + + spp.metric.category.view.list + spp.metric.category + + + + + + + + + + + + + spp.metric.category.view.form + spp.metric.category + +
+ + +
+
+ + + + + + + + + + +
+
+
+
+ + + + spp.metric.category.view.search + spp.metric.category + + + + + + + + + + + + + Metric Categories + spp.metric.category + list,form + + +

+ Create your first metric category +

+

+ Categories help organize metrics by theme (Demographics, Vulnerability, Programs, etc.) +

+
+
+
diff --git a/spp_statistic_studio/views/statistic_views.xml b/spp_statistic_studio/views/statistic_views.xml new file mode 100644 index 00000000..14e30147 --- /dev/null +++ b/spp_statistic_studio/views/statistic_views.xml @@ -0,0 +1,256 @@ + + + + + + + spp.statistic.view.list + spp.statistic + + + + + + + + + + + + + + + + + + + + + + spp.statistic.view.form + spp.statistic + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Privacy Protection: Values are suppressed when the underlying count is below the minimum threshold to prevent re-identification of individuals in small groups.

+

Common thresholds: 5 (basic), 10 (moderate), 20+ (sensitive data like disability status).

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

Context overrides allow customizing how this statistic appears in different contexts (GIS, Dashboard, API, Reports).

+

Leave fields empty to use the default values from above. Use higher minimum counts for public-facing contexts.

+
+
+
+
+
+
+
+ + + + spp.statistic.view.search + spp.statistic + + + + + + + + + + + + + + + + + + + + + + + + + + spp.statistic.view.kanban + spp.statistic + + + + + + + + + + + +
+ + + +
+ +
+
+ GIS + Dashboard + API +
+
+ Format: +
+
+
+
+
+
+
+ + + + Statistics + spp.statistic + list,kanban,form + + {'search_default_group_category': 1} + +

+ Create your first statistic +

+

+ Statistics define what data to publish and where. + Each statistic is linked to a CEL variable that defines the computation. +

+
+
+