diff --git a/metrics/api/serializers/charts/common.py b/metrics/api/serializers/charts/common.py index decec6be7..7fef1440c 100644 --- a/metrics/api/serializers/charts/common.py +++ b/metrics/api/serializers/charts/common.py @@ -94,3 +94,13 @@ class BaseChartsSerializer(serializers.Serializer): allow_null=True, default="", ) + is_public = serializers.BooleanField( + required=False, default=False, help_text=help_texts.IS_PUBLIC_FIELD + ) + data_classification = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + default=None, + help_text=help_texts.DATA_CLASSIFICATION_FIELD, + ) diff --git a/metrics/api/serializers/charts/single_category_charts.py b/metrics/api/serializers/charts/single_category_charts.py index 46b9d958f..01d5294c6 100644 --- a/metrics/api/serializers/charts/single_category_charts.py +++ b/metrics/api/serializers/charts/single_category_charts.py @@ -83,6 +83,10 @@ def to_models(self, request: Request) -> ChartRequestParams: plot["x_axis"] = x_axis plot["y_axis"] = y_axis + # If not provided, default to public data + is_public: bool = self.data.get("is_public", True) + data_classification: str | None = self.data.get("data_classification") + return ChartRequestParams( plots=self.data["plots"], file_format=self.data["file_format"], @@ -98,6 +102,8 @@ def to_models(self, request: Request) -> ChartRequestParams: legend_title=self.data.get("legend_title", ""), confidence_intervals=self.data.get("confidence_intervals", False), confidence_colour=self.data.get("confidence_colour", ""), + is_public=is_public, + data_classification=data_classification, request=request, ) diff --git a/metrics/api/serializers/charts/subplot_charts.py b/metrics/api/serializers/charts/subplot_charts.py index 99475d04c..c16b860e0 100644 --- a/metrics/api/serializers/charts/subplot_charts.py +++ b/metrics/api/serializers/charts/subplot_charts.py @@ -157,6 +157,16 @@ class SubplotChartRequestSerializer(serializers.Serializer): allow_blank=True, allow_null=True, ) + is_public = serializers.BooleanField( + required=False, default=True, help_text=help_texts.IS_PUBLIC_FIELD + ) + data_classification = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + default=None, + help_text=help_texts.DATA_CLASSIFICATION_FIELD, + ) chart_parameters = ChartParametersSerializer() subplots = SubplotsSerializer() @@ -197,6 +207,9 @@ def to_models(self, request: Request) -> SubplotChartRequestParameters: ) plot["metric_value_ranges"] = metric_value_ranges + is_public: bool = self.validated_data.get("is_public", True) + data_classification: str | None = self.validated_data.get("data_classification") + return SubplotChartRequestParameters( file_format=self.validated_data["file_format"], chart_height=self.validated_data["chart_height"] or DEFAULT_CHART_HEIGHT, @@ -211,6 +224,8 @@ def to_models(self, request: Request) -> SubplotChartRequestParameters: "target_threshold_label", None ), subplots=self.validated_data["subplots"], + is_public=is_public, + data_classification=data_classification, request=request, ) diff --git a/metrics/api/serializers/headlines.py b/metrics/api/serializers/headlines.py index 1e6c3989b..0ccfcea1a 100644 --- a/metrics/api/serializers/headlines.py +++ b/metrics/api/serializers/headlines.py @@ -57,6 +57,18 @@ class HeadlinesQuerySerializer(serializers.Serializer): required=False, help_text=help_texts.SEX_FIELD, ) + is_public = serializers.BooleanField( + required=False, + default=True, + help_text=help_texts.IS_PUBLIC_FIELD, + ) + data_classification = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + default=None, + help_text=help_texts.DATA_CLASSIFICATION_FIELD, + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/metrics/api/serializers/help_texts.py b/metrics/api/serializers/help_texts.py index e99a758cf..dc0366489 100644 --- a/metrics/api/serializers/help_texts.py +++ b/metrics/api/serializers/help_texts.py @@ -132,3 +132,10 @@ GEOGRAPHY_LIST_FORMATTING: str = """ "List of [id, name] pairs for dropdown options" """ +IS_PUBLIC_FIELD: str = """ +Whether the chart data is intended for public display. Defaults to True. +When False, a data classification watermark will be applied to the chart. +""" +DATA_CLASSIFICATION_FIELD: str = """ +The data classification watermark to apply on non-public charts, eg "OFFICIAL-SENSITIVE". +""" diff --git a/metrics/api/serializers/tables.py b/metrics/api/serializers/tables.py index 8c2e2a008..f96d4f480 100644 --- a/metrics/api/serializers/tables.py +++ b/metrics/api/serializers/tables.py @@ -68,6 +68,8 @@ def to_models(self, request: Request) -> ChartRequestParams: chart_width=DEFAULT_CHART_WIDTH, x_axis=self.data.get("x_axis") or DEFAULT_X_AXIS, y_axis=self.data.get("y_axis") or DEFAULT_Y_AXIS, + is_public=self.data.get("is_public", True), + data_classification=self.data.get("data_classification"), request=request, ) diff --git a/metrics/api/views/charts/single_category_charts.py b/metrics/api/views/charts/single_category_charts.py index 2767c3982..5bde26b12 100644 --- a/metrics/api/views/charts/single_category_charts.py +++ b/metrics/api/views/charts/single_category_charts.py @@ -97,6 +97,8 @@ def post(cls, request, *args, **kwargs): | `label` | The label to assign on the legend for this individual plot | Females | No | | `line_colour` | The colour to use for the line of this individual plot | BLUE | No | | `line_type` | The type to assign for this individual plot i.e. SOLID or DASH | DASH | No | + | `is_public` | Whether the chart is for the public / non-public dashboard environment | True | Yes | + | `data_classification` | The watermark wording (only for non-public charts) | OFFICIAL-SENSITIVE | No | --- @@ -225,6 +227,51 @@ class EncodedChartsView(APIView): request=EncodedChartsRequestSerializer, responses={HTTPStatus.OK.value: EncodedChartResponseSerializer}, tags=[CHARTS_API_TAG], + examples=[ + OpenApiExample( + "COVID-19 encoded SVG example", + value={ + "file_format": "svg", + "x_axis": "date", + "y_axis": "metric", + "is_public": False, + "data_classification": "OFFICIAL-SENSITIVE", + "plots": [ + { + "topic": "COVID-19", + "metric": "COVID-19_cases_casesByDay", + "chart_type": "bar", + "date_from": "2022-01-01", + "date_to": "2023-02-01", + } + ], + }, + request_only=True, + ), + OpenApiExample( + "COVID-19 encoded SVG response example", + value={ + "last_updated": "2023-02-01", + "chart": "%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20900%20300%22%3E%3Ctext%20x%3D%22450%22%20y%3D%22150%22%20text-anchor%3D%22middle%22%3EOFFICIAL%20SENSITIVE%3C%2Ftext%3E%3C%2Fsvg%3E", + "alt_text": "There is only 1 plot on this chart. The horizontal X-axis is labelled 'date'. Whilst the vertical Y-axis is labelled 'metric'. This is a blue solid bar plot showing COVID-19 cases by day.", + "figure": { + "data": [ + { + "x": ["2023-01-01", "2023-01-02"], + "y": [100, 150], + "type": "bar", + } + ], + "layout": { + "title": "COVID-19 Cases by Day", + "xaxis": {"title": "Date"}, + "yaxis": {"title": "Cases"}, + }, + }, + }, + response_only=True, + ), + ], ) @cache_response() @require_authorisation @@ -249,6 +296,8 @@ def post(cls, request, *args, **kwargs): | `label` | The label to assign on the legend for this individual plot | Females | No | | `line_colour` | The colour to use for the line of this individual plot | BLUE | No | | `line_type` | The type to assign for this individual plot i.e. SOLID or DASH | DASH | No | + | `is_public` | Whether the chart is for the public / non-public dashboard environment | True | Yes | + | `data_classification` | The watermark wording (only for non-public charts) | OFFICIAL-SENSITIVE | No | --- diff --git a/metrics/domain/models/charts/common.py b/metrics/domain/models/charts/common.py index 317301450..b610ceff1 100644 --- a/metrics/domain/models/charts/common.py +++ b/metrics/domain/models/charts/common.py @@ -20,6 +20,8 @@ class BaseChartRequestParams(BaseModel): request: Request | None = None confidence_intervals: bool | None = False confidence_colour: str | None = "" + is_public: bool | None = True + data_classification: str | None = None class Config: arbitrary_types_allowed = True diff --git a/metrics/domain/models/charts/subplot_charts.py b/metrics/domain/models/charts/subplot_charts.py index 63b0c7e2d..3d92f3322 100644 --- a/metrics/domain/models/charts/subplot_charts.py +++ b/metrics/domain/models/charts/subplot_charts.py @@ -42,6 +42,8 @@ class SubplotChartRequestParameters(BaseModel): target_threshold: float | None = None target_threshold_label: str | None = "" request: Request | None = None + is_public: bool | None = True + data_classification: str | None = None subplots: list[Subplots] @@ -131,6 +133,8 @@ def output_payload_for_tables(self) -> list[ChartRequestParams]: y_axis_minimum_value=self.y_axis_minimum_value, y_axis_maximum_value=self.y_axis_maximum_value, request=self.request, + is_public=self.is_public, + data_classification=self.data_classification, ) overall_payload.append(grouped_subplot) diff --git a/metrics/domain/models/headline.py b/metrics/domain/models/headline.py index 675c8e4e5..30b737ef8 100644 --- a/metrics/domain/models/headline.py +++ b/metrics/domain/models/headline.py @@ -12,6 +12,8 @@ class HeadlineParameters(BaseModel): geography_type: str sex: str age: str + is_public: bool | None = True + data_classification: str | None = None request: Request | None = None class Config: diff --git a/metrics/interfaces/charts/common/chart_output.py b/metrics/interfaces/charts/common/chart_output.py index a48319dc8..e0be4ae74 100644 --- a/metrics/interfaces/charts/common/chart_output.py +++ b/metrics/interfaces/charts/common/chart_output.py @@ -2,7 +2,12 @@ import plotly.graph_objects as go +from metrics.api.settings.auth import AUTH_ENABLED +from metrics.interfaces.data_classification.access import DataClassification + HEX_COLOUR_BLACK = "#0b0c0c" +WATERMARK_FONT_COLOUR = "rgba(0, 0, 0, 0.25)" +WATERMARK_OPACITY = 0.58 @dataclass @@ -11,6 +16,41 @@ class ChartOutput: description: str is_headline: bool is_subplot: bool = False + is_public: bool = True + data_classification: str | None = None + + def __post_init__(self) -> None: + if (not self.is_public) and (self.data_classification) and (AUTH_ENABLED): + self._apply_watermark() + + def _apply_watermark(self) -> None: + """ + Adds a horizontal watermark to the Plotly figure. + + The watermark is added directly to the figure as a layout + annotation using paper coordinates, so it is consistently + rendered in static SVG exports, interactive Plotly outputs, + and any downloaded chart artefacts. + """ + + watermark_text = DataClassification[self.data_classification].value + + width = self.figure.layout.width or 800 + + max_font_size = int((width * 0.85) / (len(watermark_text) * 0.65)) + watermark_font_size = max(12, min(max_font_size, 100)) + + self.figure.add_annotation( + text=watermark_text, + xref="paper", + yref="paper", + x=0.5, + y=0.8, + showarrow=False, + font={"size": watermark_font_size, "color": WATERMARK_FONT_COLOUR}, + textangle=0, + opacity=WATERMARK_OPACITY, + ) @property def interactive_chart_figure_output(self) -> dict: diff --git a/metrics/interfaces/charts/single_category_charts/access.py b/metrics/interfaces/charts/single_category_charts/access.py index 471083c15..961fe16b3 100644 --- a/metrics/interfaces/charts/single_category_charts/access.py +++ b/metrics/interfaces/charts/single_category_charts/access.py @@ -142,6 +142,8 @@ def generate_chart_output(self) -> ChartOutput: figure=figure, description=description, is_headline=self.is_headline_data, + is_public=self.chart_request_params.is_public, + data_classification=self.chart_request_params.data_classification, ) def _build_chart_figure( diff --git a/metrics/interfaces/charts/subplot_charts/access.py b/metrics/interfaces/charts/subplot_charts/access.py index b07ff7701..9a7bb6afb 100644 --- a/metrics/interfaces/charts/subplot_charts/access.py +++ b/metrics/interfaces/charts/subplot_charts/access.py @@ -156,6 +156,8 @@ def generate_chart_output(self): description=self.build_chart_description(plots_data=plots_data), is_headline=False, is_subplot=True, + is_public=self.chart_request_params.is_public, + data_classification=self.chart_request_params.data_classification, ) @classmethod diff --git a/metrics/interfaces/data_classification/__init__.py b/metrics/interfaces/data_classification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/metrics/interfaces/data_classification/access.py b/metrics/interfaces/data_classification/access.py new file mode 100644 index 000000000..10df6bbcd --- /dev/null +++ b/metrics/interfaces/data_classification/access.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class DataClassification(Enum): + official = "OFFICIAL" + official_sensitive = "OFFICIAL-SENSITIVE" + protective_marking_not_set = "PROTECTIVE MARKING NOT SET" + secret = "SECRET" # nosec #noqa: S105 + top_secret = "TOP SECRET" # nosec #noqa: S105 diff --git a/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py b/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py index fbf7aa36e..71385de0d 100644 --- a/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py +++ b/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py @@ -626,6 +626,7 @@ def test_to_models_returns_correct_models(self): "plots": chart_plots, "y_axis_minimum_value": 0, "y_axis_maximum_value": None, + "is_public": True, } serializer = ChartsSerializer(data=valid_data_payload) @@ -644,6 +645,7 @@ def test_to_models_returns_correct_models(self): chart_width=valid_data_payload["chart_width"], x_axis=DEFAULT_X_AXIS, y_axis=DEFAULT_Y_AXIS, + is_public=True, ) assert chart_plots_serialized_models == expected_chart_plots_model diff --git a/tests/unit/metrics/domain/models/test_headline.py b/tests/unit/metrics/domain/models/test_headline.py index b78ac6427..cd39bcb18 100644 --- a/tests/unit/metrics/domain/models/test_headline.py +++ b/tests/unit/metrics/domain/models/test_headline.py @@ -13,6 +13,7 @@ class TestHeadlineParameters: "stratum": "default", "sex": "all", "age": "all", + "is_public": True, } @pytest.mark.parametrize( diff --git a/tests/unit/metrics/interfaces/charts/common/test_chart_output.py b/tests/unit/metrics/interfaces/charts/common/test_chart_output.py new file mode 100644 index 000000000..51966e064 --- /dev/null +++ b/tests/unit/metrics/interfaces/charts/common/test_chart_output.py @@ -0,0 +1,146 @@ +from unittest import mock +import pytest + +import plotly.graph_objects as go + + +from metrics.interfaces.charts.common.chart_output import ChartOutput + +MODULE_PATH = "metrics.interfaces.charts.common.chart_output" + +WATERMARK_FONT_COLOUR = "rgba(0, 0, 0, 0.25)" +WATERMARK_OPACITY = 0.58 + + +class TestPostInitAppliesWatermark: + @mock.patch(f"{MODULE_PATH}.ChartOutput._apply_watermark") + @mock.patch(f"{MODULE_PATH}.AUTH_ENABLED", True) + def test_applies_watermark_when_not_public_and_classified_and_auth_enabled( + self, spy_apply_watermark: mock.MagicMock + ): + """ + Given an instance that is not public and has data_classification + When __post_init__() is triggered + Then _apply_watermark() is called + """ + # Given / When + ChartOutput( + figure=go.Figure(), + description="Test chart", + is_headline=False, + is_public=False, + data_classification="official", + ) + + # Then + spy_apply_watermark.assert_called_once() + + @mock.patch(f"{MODULE_PATH}.ChartOutput._apply_watermark") + @mock.patch(f"{MODULE_PATH}.AUTH_ENABLED", True) + def test_does_not_apply_watermark_when_public( + self, spy_apply_watermark: mock.MagicMock + ): + """ + Given an instance that is public + When __post_init__() is triggered + Then _apply_watermark() is not called + """ + # Given / When + ChartOutput( + figure=go.Figure(), + description="Test chart", + is_headline=False, + data_classification="official", + ) + + # Then + spy_apply_watermark.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.ChartOutput._apply_watermark") + @mock.patch(f"{MODULE_PATH}.AUTH_ENABLED", True) + def test_does_not_apply_watermark_when_no_classification( + self, spy_apply_watermark: mock.MagicMock + ): + """ + Given an instance that has no data_classification + When __post_init__() is triggered + Then _apply_watermark() is not called + """ + # Given / When + ChartOutput( + figure=go.Figure(), + description="Test chart", + is_headline=False, + ) + + # Then + spy_apply_watermark.assert_not_called() + + @mock.patch(f"{MODULE_PATH}.ChartOutput._apply_watermark") + @mock.patch(f"{MODULE_PATH}.AUTH_ENABLED", False) + def test_does_not_apply_watermark_when_no_auth_enabled( + self, spy_apply_watermark: mock.MagicMock + ): + """ + Given an instance that has no data_classification + When __post_init__() is triggered + Then _apply_watermark() is not called + """ + # Given / When + ChartOutput( + figure=go.Figure(), + description="Test chart", + is_headline=False, + is_public=False, + data_classification="official", + ) + + # Then + spy_apply_watermark.assert_not_called() + + +class TestApplyWatermark: + @mock.patch(f"{MODULE_PATH}.DataClassification") + @mock.patch(f"{MODULE_PATH}.AUTH_ENABLED", True) + def test_adds_watermark_annotation(self, mock_data_classification): + """ + Given a ChartOutput with a valid data_classification + When _apply_watermark() is called + Then the watermark text is resolved, and added as a scaled annotation + """ + # Given / When (apply is called with postInit on instantiation) + figure = mock.Mock(spec=go.Figure) + + # mock layout.width for scaling logic + figure.layout.width = 800 + expected_font_size = max( + 12, min(int((800 * 0.85) / (len("Highly Confidential") * 0.65)), 100) + ) + + mock_data_classification.__getitem__.return_value.value = "Highly Confidential" + + ChartOutput( + figure=figure, + description="Test", + is_headline=False, + is_public=False, + data_classification="official", + ) + + # Then + mock_data_classification.__getitem__.assert_called_once_with("official") + + figure.add_annotation.assert_called_once_with( + text="Highly Confidential", + xref="paper", + yref="paper", + x=0.5, + y=0.8, + showarrow=False, + font={ + "size": expected_font_size, + "color": WATERMARK_FONT_COLOUR, + }, + textangle=0, + opacity=WATERMARK_OPACITY, + ) diff --git a/tests/unit/metrics/interfaces/headlines/test_access.py b/tests/unit/metrics/interfaces/headlines/test_access.py index c2d87e54f..5e7b1e3c4 100644 --- a/tests/unit/metrics/interfaces/headlines/test_access.py +++ b/tests/unit/metrics/interfaces/headlines/test_access.py @@ -22,6 +22,7 @@ def example_headline_args() -> dict[str, str]: "stratum": "default", "age": "all", "sex": "all", + "is_public": True, }