Skip to content

Commit b04fae0

Browse files
authored
Merge pull request #4698 from bcgov/4511-validate-config-before-saving-activity-data
chore: 4511 validate configuration exists for submitted data
2 parents fae206d + b0bd461 commit b04fae0

6 files changed

Lines changed: 242 additions & 0 deletions

File tree

bc_obps/common/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,13 @@ class UserError(Exception):
55
"""
66

77
pass
8+
9+
10+
class InternalSystemError(Exception):
11+
"""
12+
Base class for obps system-related errors.
13+
These errors are typically used for unexpected conditions that indicate a problem in either
14+
unforeseen manipulation of the system, or a bug in the code.
15+
"""
16+
17+
pass

bc_obps/reporting/service/report_validation/validators/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
report_data_by_fuel_type_validator,
1010
report_emission_allocation_other_excluded_category,
1111
report_emission_allocation_validator,
12+
reporting_configuration_validator,
1213
supplementary_report_attachments_confirmation,
1314
supplementary_report_version_change,
1415
report_regulated_product_presence,
@@ -41,6 +42,7 @@
4142
"report_emission_allocation_other_excluded_category",
4243
"report_data_by_fuel_type_validator",
4344
"report_regulated_product_presence",
45+
"reporting_configuration_validator",
4446
"required_fields_report_operation_information",
4547
"required_fields_report_person_responsible",
4648
"required_fields_report_activity_data",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from common.exceptions import InternalSystemError
2+
from django.contrib.postgres.aggregates import ArrayAgg
3+
from django.db.models import OuterRef, QuerySet
4+
from reporting.models.configuration_element import ConfigurationElement
5+
from reporting.models.report_methodology import ReportMethodology
6+
from reporting.models.report_version import ReportVersion
7+
from reporting.service.report_validation.report_validation_error import ReportValidationError
8+
from reporting.service.report_validation.report_validation_tags import ValidationTags
9+
10+
TAGS = [ValidationTags.REPORT_VALIDATION, ValidationTags.ON_SUBMIT]
11+
12+
13+
def annotated_report_methodology(report_version_id: int) -> QuerySet[ReportMethodology]:
14+
config_element = ConfigurationElement.objects.filter(
15+
activity_id=OuterRef("report_emission__report_source_type__report_activity__activity_id"),
16+
source_type_id=OuterRef("report_emission__report_source_type__source_type_id"),
17+
gas_type_id=OuterRef("report_emission__gas_type_id"),
18+
methodology_id=OuterRef("methodology_id"),
19+
valid_from__valid_from__year__lte=OuterRef("report_version__report__reporting_year_id"),
20+
valid_to__valid_to__year__gte=OuterRef("report_version__report__reporting_year_id"),
21+
).values("id")[:1]
22+
23+
return ReportMethodology.objects.filter(
24+
report_version_id=report_version_id,
25+
).annotate(
26+
matching_config_id=config_element,
27+
)
28+
29+
30+
def validate_configuration_elements_present(report_version: ReportVersion) -> dict[str, ReportValidationError]:
31+
32+
missing_config = (
33+
annotated_report_methodology(report_version.id).filter(matching_config_id__isnull=True).values("id") # type: ignore
34+
)
35+
36+
if missing_config.exists():
37+
raise InternalSystemError(
38+
f"Missing configuration elements for report methodology IDs: {str.join(', ', [str(m['id']) for m in missing_config])}"
39+
)
40+
41+
return {}
42+
43+
44+
def validate_reporting_fields(report_version: ReportVersion) -> dict[str, ReportValidationError]:
45+
"""
46+
Validate that for each ReportMethodology record, the reported fields are
47+
- either listed in its configuration ConfigurationElementReportingField models
48+
- or are a Unit field for an available reporting field (i.e. fieldSlug + "FieldUnits")
49+
"""
50+
methodology_records = annotated_report_methodology(report_version.id).annotate(
51+
allowed_slugs=(
52+
ConfigurationElement.objects.filter(id=OuterRef("matching_config_id"))
53+
.annotate(slugs=ArrayAgg("reporting_fields__slug"))
54+
.values("slugs")
55+
)
56+
)
57+
58+
for record in methodology_records:
59+
reported_slugs = {r.replace("FieldUnits", "") for r in record.json_data.keys()}
60+
allowed_slugs = set(record.allowed_slugs)
61+
62+
if not reported_slugs.issubset(allowed_slugs):
63+
raise InternalSystemError(
64+
f"ReportMethodology ID {record.id} has reporting fields {reported_slugs - allowed_slugs} which are not in the allowed fields {allowed_slugs} of its matching configuration element."
65+
)
66+
67+
return {}
68+
69+
70+
def validate(report_version: ReportVersion) -> dict[str, ReportValidationError]:
71+
"""
72+
Validator ensuring that all the activity data reported has a matching configuration
73+
defined for the reporting year of the report.
74+
75+
For each ReportMethodology record:
76+
- Collect ReportMethodology -> ReportEmission (Gas Type) -> ReportSourceType -> ReportActivity -> Report (Reporting Year)
77+
- Validate that there is a ConfigurationElement record matching that combination
78+
- Validate that the extra reporting fields reported are in that configuration element
79+
"""
80+
81+
validate_configuration_elements_present(report_version)
82+
validate_reporting_fields(report_version)
83+
84+
# This validator only raises system errors
85+
# Errors found by this validator are not expected to be fixable by users, but rather indicate a bug or malicious manipulation of data inputs.
86+
return {}

bc_obps/reporting/tests/service/report_validation/test_report_validation_service.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def test_initializes_with_the_proper_plugins(self):
4747
"reporting.service.report_validation.validators.report_emission_allocation_other_excluded_category",
4848
"reporting.service.report_validation.validators.report_data_by_fuel_type_validator",
4949
"reporting.service.report_validation.validators.report_regulated_product_presence",
50+
"reporting.service.report_validation.validators.reporting_configuration_validator",
5051
"reporting.service.report_validation.validators.required_fields.required_fields_report_operation_information",
5152
"reporting.service.report_validation.validators.required_fields.required_fields_report_person_responsible",
5253
"reporting.service.report_validation.validators.required_fields.required_fields_report_activity_data",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from common.exceptions import InternalSystemError
2+
from model_bakery.baker import make, make_recipe, prepare_recipe
3+
import pytest
4+
from reporting.models.configuration_element import ConfigurationElement
5+
from reporting.service.report_validation.report_validation_tags import ValidationTags
6+
from reporting.service.report_validation.validators import reporting_configuration_validator
7+
8+
9+
@pytest.mark.django_db
10+
class TestReportingConfigurationValidator:
11+
12+
validator_under_test = reporting_configuration_validator
13+
14+
def setup_method(self):
15+
self.report_version = make_recipe(
16+
"reporting.tests.utils.report_version", report__reporting_year__reporting_year=2018
17+
)
18+
self.configuration = make_recipe(
19+
"reporting.tests.utils.configuration", valid_from="2018-01-01", valid_to="2018-12-31"
20+
)
21+
22+
def test_has_the_proper_configuration(self):
23+
assert self.validator_under_test.TAGS == [ValidationTags.REPORT_VALIDATION, ValidationTags.ON_SUBMIT]
24+
25+
def test_raises_if_no_configuration_element_present(self):
26+
report_methodology = make_recipe(
27+
"reporting.tests.utils.report_methodology",
28+
report_version=self.report_version,
29+
)
30+
31+
with pytest.raises(
32+
InternalSystemError,
33+
match=f"Missing configuration elements for report methodology IDs: {report_methodology.id}",
34+
):
35+
self.validator_under_test.validate(self.report_version)
36+
37+
def test_raises_if_configuration_doesnt_match_reporting_year(self):
38+
report_methodology = make_recipe(
39+
"reporting.tests.utils.report_methodology",
40+
report_version=self.report_version,
41+
)
42+
43+
wrong_year_confguration = make_recipe(
44+
"reporting.tests.utils.configuration", valid_from="2011-01-01", valid_to="2012-12-31"
45+
)
46+
make(
47+
ConfigurationElement,
48+
activity=report_methodology.report_emission.report_source_type.report_activity.activity,
49+
source_type=report_methodology.report_emission.report_source_type.source_type,
50+
gas_type=report_methodology.report_emission.gas_type,
51+
methodology=report_methodology.methodology,
52+
valid_from=wrong_year_confguration,
53+
valid_to=wrong_year_confguration,
54+
)
55+
56+
with pytest.raises(
57+
InternalSystemError,
58+
match=f"Missing configuration elements for report methodology IDs: {report_methodology.id}",
59+
):
60+
self.validator_under_test.validate(self.report_version)
61+
62+
def test_raises_if_configuration_doesnt_match_activity(self):
63+
report_methodology = make_recipe(
64+
"reporting.tests.utils.report_methodology",
65+
report_version=self.report_version,
66+
)
67+
wrong_activity = make_recipe("reporting.tests.utils.activity")
68+
69+
make(
70+
ConfigurationElement,
71+
activity=wrong_activity,
72+
source_type=report_methodology.report_emission.report_source_type.source_type,
73+
gas_type=report_methodology.report_emission.gas_type,
74+
methodology=report_methodology.methodology,
75+
valid_from=self.configuration,
76+
valid_to=self.configuration,
77+
)
78+
79+
with pytest.raises(
80+
InternalSystemError,
81+
match=f"Missing configuration elements for report methodology IDs: {report_methodology.id}",
82+
):
83+
self.validator_under_test.validate(self.report_version)
84+
85+
def test_raises_if_reported_fields_not_allowed_in_configuration_element(self):
86+
report_methodology = make_recipe(
87+
"reporting.tests.utils.report_methodology",
88+
report_version=self.report_version,
89+
json_data={"allowedField": "test data", "unexpectedField": 123},
90+
)
91+
92+
make(
93+
ConfigurationElement,
94+
activity=report_methodology.report_emission.report_source_type.report_activity.activity,
95+
source_type=report_methodology.report_emission.report_source_type.source_type,
96+
gas_type=report_methodology.report_emission.gas_type,
97+
methodology=report_methodology.methodology,
98+
valid_from=self.configuration,
99+
valid_to=self.configuration,
100+
reporting_fields=[prepare_recipe("reporting.tests.utils.reporting_field", slug="allowedField")],
101+
)
102+
103+
with pytest.raises(
104+
InternalSystemError,
105+
match=f"ReportMethodology ID {report_methodology.id} has reporting fields"
106+
" {'unexpectedField'} which are not in the allowed fields {'allowedField'} of its matching configuration element.",
107+
):
108+
self.validator_under_test.validate(self.report_version)
109+
110+
def test_passes_if_no_report_methodlogy_for_that_report_version(self):
111+
assert self.validator_under_test.validate(self.report_version) == {}
112+
113+
def test_passes_with_fieldunits_field_reported(self):
114+
report_methodology = make_recipe(
115+
"reporting.tests.utils.report_methodology",
116+
report_version=self.report_version,
117+
json_data={
118+
"allowedField": "test data",
119+
"allowedFieldFieldUnits": "test units",
120+
"anotherAllowedReportingField": 111,
121+
},
122+
)
123+
124+
allowed_fields = [
125+
prepare_recipe("reporting.tests.utils.reporting_field", slug="allowedField"),
126+
prepare_recipe("reporting.tests.utils.reporting_field", slug="anotherAllowedReportingField"),
127+
prepare_recipe("reporting.tests.utils.reporting_field", slug="unreportedField"),
128+
]
129+
130+
make(
131+
ConfigurationElement,
132+
activity=report_methodology.report_emission.report_source_type.report_activity.activity,
133+
source_type=report_methodology.report_emission.report_source_type.source_type,
134+
gas_type=report_methodology.report_emission.gas_type,
135+
methodology=report_methodology.methodology,
136+
valid_from=self.configuration,
137+
valid_to=self.configuration,
138+
reporting_fields=allowed_fields,
139+
)
140+
141+
assert self.validator_under_test.validate(self.report_version) == {}

bc_obps/reporting/tests/utils/baker_recipes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from reporting.models.report_raw_activity_data import ReportRawActivityData
3939
from reporting.models.report_source_type import ReportSourceType
4040
from reporting.models.report_unit import ReportUnit
41+
from reporting.models.reporting_field import ReportingField
4142
from reporting.models.reporting_year import ReportingYear
4243
from reporting.models.report import Report
4344
from reporting.models.report_version import ReportVersion
@@ -106,6 +107,7 @@ def json_seq(
106107
valid_from=seq(date(4001, 1, 1), increment_by=timedelta(days=8)),
107108
valid_to=seq(date(4001, 1, 7), increment_by=timedelta(days=8)),
108109
)
110+
reporting_field = Recipe(ReportingField)
109111

110112
activity = Recipe(Activity)
111113
source_type = Recipe(SourceType)

0 commit comments

Comments
 (0)