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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions metrics/api/serializers/charts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
6 changes: 6 additions & 0 deletions metrics/api/serializers/charts/single_category_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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,
)

Expand Down
15 changes: 15 additions & 0 deletions metrics/api/serializers/charts/subplot_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)

Expand Down
12 changes: 12 additions & 0 deletions metrics/api/serializers/headlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions metrics/api/serializers/help_texts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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".
"""
2 changes: 2 additions & 0 deletions metrics/api/serializers/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
49 changes: 49 additions & 0 deletions metrics/api/views/charts/single_category_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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
Expand All @@ -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 |

---

Expand Down
2 changes: 2 additions & 0 deletions metrics/domain/models/charts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
dandammann marked this conversation as resolved.
data_classification: str | None = None

class Config:
arbitrary_types_allowed = True
Expand Down
4 changes: 4 additions & 0 deletions metrics/domain/models/charts/subplot_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions metrics/domain/models/headline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions metrics/interfaces/charts/common/chart_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions metrics/interfaces/charts/single_category_charts/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions metrics/interfaces/charts/subplot_charts/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
9 changes: 9 additions & 0 deletions metrics/interfaces/data_classification/access.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/unit/metrics/domain/models/test_headline.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class TestHeadlineParameters:
"stratum": "default",
"sex": "all",
"age": "all",
"is_public": True,
}

@pytest.mark.parametrize(
Expand Down
Loading
Loading