Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8d8e886
CDD-3090: Update watermark for non public charts
May 18, 2026
198094f
Revert to models data classification
May 18, 2026
ab7899b
Wrap text for large watermarks
May 18, 2026
cf8b0df
CDD-3090: Unit testing
May 18, 2026
acc6784
linting
May 18, 2026
ed90c97
update comment
May 18, 2026
d3ffa63
wrap watermark in auth_enabled flag
May 18, 2026
1254f43
Update unit tests
May 18, 2026
44917c7
CDD-3090: Remove unused variable
mattjreynolds May 22, 2026
ba56e9e
Make watermark scale
May 28, 2026
77cae7d
Make font size dynamic
May 28, 2026
fa9a44f
tweak font size
May 28, 2026
47fa36e
linting
May 29, 2026
31c5ab9
Update font height
May 29, 2026
5809a7d
Update tests
May 29, 2026
0f48092
remove unused function
May 29, 2026
c5ed6f5
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale May 29, 2026
ed38361
update import
May 29, 2026
38e0bd8
linting
May 29, 2026
d2a6204
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale May 29, 2026
1f54d52
Update text sizing
Jun 3, 2026
c0e0e6b
tweak font padding
Jun 3, 2026
e3d33dd
Add chart width to chart output
Jun 4, 2026
83cd4c2
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale Jun 5, 2026
6a25246
linting
Jun 5, 2026
dff7b8e
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale Jun 8, 2026
bb7cf02
Fix tests
Jun 8, 2026
3c7b007
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale Jun 8, 2026
de8b98c
Update tests
Jun 8, 2026
24915d8
Fix unit test
Jun 8, 2026
5605018
update chart output test
Jun 8, 2026
2b42e2b
linting
Jun 8, 2026
6278e12
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale Jun 8, 2026
f32c3f1
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale Jun 8, 2026
f57660b
add log
Jun 10, 2026
7ded7a5
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
kathryn-dale Jun 10, 2026
7ddef4f
remove log
Jun 10, 2026
9948267
Merge branch 'main' into CDD-3090-simplified-BE-chart-watermark
sahmed06 Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cms/dashboard/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ def get_queryset(self):
req = self.request

if req.auth is None:

# Get all page ids for pages with is_public
public_topic_page_ids = TopicPage.objects.filter(
is_public=True, page_ptr__in=queryset
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"target_threshold": 95,
"target_threshold_label": "95% target",
"chart_parameters": {
"chart_width": 600,
"x_axis": "geography",
"y_axis": "metric",
"theme": "immunisation",
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
42 changes: 42 additions & 0 deletions metrics/interfaces/charts/common/chart_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,57 @@

import plotly.graph_objects as go

from metrics.api.settings.auth import AUTH_ENABLED
from metrics.domain.common.utils import (
DEFAULT_CHART_WIDTH,
)
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
class ChartOutput:
figure: go.Figure
description: str
is_headline: bool
chart_width: int = DEFAULT_CHART_WIDTH
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
target_px = self.chart_width * 0.75
font_size = target_px / (max(len(watermark_text), 1) * 0.6)
watermark_font_size = round(max(8, min(font_size, 300)))

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/common/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import plotly
from scour import scour

from metrics.interfaces.charts.common.chart_output import ChartOutput


@dataclass
class ChartResult:
Expand Down
3 changes: 3 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,9 @@ def generate_chart_output(self) -> ChartOutput:
figure=figure,
description=description,
is_headline=self.is_headline_data,
chart_width=self.chart_request_params.chart_width,
is_public=self.chart_request_params.is_public,
data_classification=self.chart_request_params.data_classification,
)

def _build_chart_figure(
Expand Down
3 changes: 3 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,9 @@ def generate_chart_output(self):
description=self.build_chart_description(plots_data=plots_data),
is_headline=False,
is_subplot=True,
chart_width=self.chart_request_params.chart_width,
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