2020-08-25 16:55:09 +01:00
|
|
|
|
import itertools
|
2020-08-18 16:36:40 +01:00
|
|
|
|
from datetime import datetime, timedelta
|
2020-07-09 10:33:50 +01:00
|
|
|
|
|
2021-01-25 13:49:23 +00:00
|
|
|
|
from notifications_utils.polygons import Polygons
|
2020-07-09 10:33:50 +01:00
|
|
|
|
from notifications_utils.template import BroadcastPreviewTemplate
|
|
|
|
|
|
from orderedset import OrderedSet
|
2020-08-05 16:01:21 +01:00
|
|
|
|
from werkzeug.utils import cached_property
|
2020-07-09 10:33:50 +01:00
|
|
|
|
|
2021-06-10 15:05:38 +01:00
|
|
|
|
from app.broadcast_areas.models import (
|
|
|
|
|
|
CustomBroadcastAreas,
|
|
|
|
|
|
broadcast_area_libraries,
|
|
|
|
|
|
)
|
2021-01-06 12:12:01 +00:00
|
|
|
|
from app.formatters import round_to_significant_figures
|
2020-07-09 15:48:56 +01:00
|
|
|
|
from app.models import JSONModel, ModelList
|
2020-07-10 14:06:00 +01:00
|
|
|
|
from app.models.user import User
|
2020-07-09 10:33:50 +01:00
|
|
|
|
from app.notify_client.broadcast_message_api_client import (
|
|
|
|
|
|
broadcast_message_api_client,
|
|
|
|
|
|
)
|
2020-09-09 13:29:45 +01:00
|
|
|
|
|
|
|
|
|
|
|
2020-07-09 10:33:50 +01:00
|
|
|
|
class BroadcastMessage(JSONModel):
|
|
|
|
|
|
|
|
|
|
|
|
ALLOWED_PROPERTIES = {
|
|
|
|
|
|
'id',
|
|
|
|
|
|
'service_id',
|
|
|
|
|
|
'template_id',
|
2020-10-08 14:06:33 +01:00
|
|
|
|
'content',
|
2020-07-09 10:33:50 +01:00
|
|
|
|
'service_id',
|
|
|
|
|
|
'created_by',
|
|
|
|
|
|
'personalisation',
|
|
|
|
|
|
'starts_at',
|
|
|
|
|
|
'finishes_at',
|
|
|
|
|
|
'created_at',
|
|
|
|
|
|
'approved_at',
|
|
|
|
|
|
'cancelled_at',
|
|
|
|
|
|
'updated_at',
|
|
|
|
|
|
'created_by_id',
|
|
|
|
|
|
'approved_by_id',
|
|
|
|
|
|
'cancelled_by_id',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
libraries = broadcast_area_libraries
|
|
|
|
|
|
|
2020-07-10 09:26:47 +01:00
|
|
|
|
def __lt__(self, other):
|
2020-10-16 09:02:35 +01:00
|
|
|
|
if self.starts_at and other.starts_at:
|
|
|
|
|
|
return self.starts_at < other.starts_at
|
|
|
|
|
|
if self.starts_at and not other.starts_at:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if not self.starts_at and other.starts_at:
|
|
|
|
|
|
return False
|
2021-01-27 11:24:22 +00:00
|
|
|
|
if self.updated_at and not other.updated_at:
|
|
|
|
|
|
return self.updated_at < other.created_at
|
|
|
|
|
|
if not self.updated_at and other.updated_at:
|
|
|
|
|
|
return self.created_at < other.updated_at
|
2021-02-09 09:32:07 +00:00
|
|
|
|
if not self.updated_at and not other.updated_at:
|
|
|
|
|
|
return self.created_at < other.created_at
|
2020-10-16 09:02:35 +01:00
|
|
|
|
return self.updated_at < other.updated_at
|
2020-07-10 09:26:47 +01:00
|
|
|
|
|
2020-07-09 10:33:50 +01:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def create(cls, *, service_id, template_id):
|
|
|
|
|
|
return cls(broadcast_message_api_client.create_broadcast_message(
|
|
|
|
|
|
service_id=service_id,
|
|
|
|
|
|
template_id=template_id,
|
2020-11-20 15:42:52 +00:00
|
|
|
|
content=None,
|
|
|
|
|
|
reference=None,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def create_from_content(cls, *, service_id, content, reference):
|
|
|
|
|
|
return cls(broadcast_message_api_client.create_broadcast_message(
|
|
|
|
|
|
service_id=service_id,
|
|
|
|
|
|
template_id=None,
|
|
|
|
|
|
content=content,
|
|
|
|
|
|
reference=reference,
|
2020-07-09 10:33:50 +01:00
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_id(cls, broadcast_message_id, *, service_id):
|
|
|
|
|
|
return cls(broadcast_message_api_client.get_broadcast_message(
|
|
|
|
|
|
service_id=service_id,
|
|
|
|
|
|
broadcast_message_id=broadcast_message_id,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def areas(self):
|
2021-01-27 14:32:35 +00:00
|
|
|
|
library_areas = self.get_areas(areas=self._dict['areas'])
|
|
|
|
|
|
|
|
|
|
|
|
if library_areas:
|
|
|
|
|
|
if len(library_areas) != len(self._dict['areas']):
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f'BroadcastMessage has {len(self._dict["areas"])} areas '
|
|
|
|
|
|
f'but {len(library_areas)} found in the library'
|
|
|
|
|
|
)
|
|
|
|
|
|
return library_areas
|
|
|
|
|
|
|
|
|
|
|
|
return CustomBroadcastAreas(
|
2021-01-26 10:14:04 +00:00
|
|
|
|
areas=self._dict['areas'],
|
|
|
|
|
|
polygons=self._dict['simple_polygons'],
|
|
|
|
|
|
)
|
2020-07-09 10:33:50 +01:00
|
|
|
|
|
Suggest previously-used areas when adding new area
If you’re adding another area to your broadcast it’s likely to be close
to one of the areas you’ve already added.
But we make you start by choosing a library, then you have to find the
local authority again from the long list. This is clunky, and it
interrupts the task the user is trying to complete.
We thought about redirecting you somewhere deep into the hierarchy,
perhaps by sending you to either:
- the parent of the last area you’d chosen
- the common ancestor of all the areas you’d chosen
This approach would however mean you’d need a way to navigate back up
the hierarchy if we’d dropped you in the wrong place. And we don’t have
a pattern for that at the moment.
So instead this commit adds some ‘shortcuts’ to the chose library page,
giving you a choice of all the parents of the areas you’ve currently
selected. In most cases this will be one (unitary authority) or two
(county and district) choices, but it will scale to adding areas from
multiple different authorities.
It does mean an extra click compared to the redirect approach, but this
is still fewer, easier clicks compared to now.
This meant a couple of under-the-hood changes:
- making `BroadcastArea`s hashable so it’s possible to do
`set([BroadcastArea(…), BroadcastArea(…), BroadcastArea(…)])`
- making `BroadcastArea`s aware of which library they live in, so we can
link to the correct _Choose area_ page
2020-09-21 18:55:46 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def parent_areas(self):
|
|
|
|
|
|
return sorted(set(self._parent_areas_iterator))
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def _parent_areas_iterator(self):
|
|
|
|
|
|
for area in self.areas:
|
|
|
|
|
|
for parent in area.parents:
|
|
|
|
|
|
yield parent
|
|
|
|
|
|
|
2020-08-24 12:51:23 +01:00
|
|
|
|
@cached_property
|
2020-07-09 10:33:50 +01:00
|
|
|
|
def polygons(self):
|
2020-08-24 12:51:23 +01:00
|
|
|
|
return Polygons(
|
2020-08-25 16:55:09 +01:00
|
|
|
|
list(itertools.chain(*(
|
|
|
|
|
|
area.polygons for area in self.areas
|
|
|
|
|
|
)))
|
Show how a broadcast will overspill selected area
Broadcasting is not a precise technology, because:
- cell towers are directional
- their range varies depending on whether they are 2, 3, 4, or 5G
(the higher the bandwidth the shorter the range)
- in urban areas the towers are more densely packed, so a phone is
likely to have a greater choice of tower to connect to, and will
favour a closer one (which has a stronger signal)
- topography and even weather can affect the range of a tower
So it’s good for us to visually indicate that the broadcast is not as
precise as the boundaries of the area, because it gives the person
sending the message an indication of how the technology works.
At the same time we have a restriction on the number of polygons we
think and area can have, so we’ve done some work to make versions of
polygons which are simplified and buffered (see
https://github.com/alphagov/notifications-utils/pull/769 for context).
Serendipitously, the simplified and buffered polygons are larger and
smoother than the detailed polygons we’ve got from the GeoJSON files. So
they naturally give the impression of covering an area which is wider
and less precise.
So this commit takes those simple polygons and uses them to render the
blue fill. This makes the blue fill extend outside the black stroke,
which is still using the detailed polygons direct from the GeoJSON.
2020-08-06 13:38:15 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2020-08-24 14:43:28 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
|
def simple_polygons(self):
|
2020-09-02 14:54:00 +01:00
|
|
|
|
return self.get_simple_polygons(areas=self.areas)
|
2020-08-24 14:43:28 +01:00
|
|
|
|
|
Vary bleed amount based on population density
There are basically two kinds of 4G masts:
Frequency | Range | Bandwidth
----------|-------------|----------------------------------
800MHz | Long (500m) | Low (can handle a bit of traffic)
1800Mhz | Short (5km) | High (can handle lots of traffic)
The 1800Mhz masts are better in terms of how much traffic they can
handle and how fast a connection they provide. But because they have
quite short range, it’s only economical to install them in very built up
areas†.
In more rural areas the 800MHz masts are better because they cover a
wider area, and have enough bandwidth for the lower population density.
The net effect of this is that cell broadcasts in rural areas are likely
to bleed further, because the masts they are being broadcast from are
less precise.
We can use population density as a proxy for how likely it is to be
covered by 1800Mhz masts, and therefore how much bleed we should expect.
So this commit varies the amount of bleed shown based on the population
density.
I came up with the formula based on 3 fixed points:
- The most remote areas (for example the Scottish Highlands) should have
the highest average bleed, estimated at 5km
- An town, like Crewe, should have about the same bleed as we were
estimating before (1.5km) – Pete D thinks this is about right based on
his knowledge of the area around his office in Crewe
- The most built up areas, like London boroughs, could have as little as
500m of bleed
Based on these three figures I came up with the following formula, which
roughly gives the right bleed distance (`b`) for each of their population
densities (`d`):
```
b = 5900 - (log10(d) × 1_250)
```
Plotted on a curve it looks like this:
This is based on averages – remember that the UI shows where is _likely_
to receive the alert, based on bleed, not where it’s _possible_ to
receive the alert.
Here’s what it looks like on the map:
---
†There are some additional subtleties which make this not strictly true:
- The 800Mhz masts are also used in built up areas to fill in the gaps
between the areas covered by the 1800Mhz masts
- Switching between masts is inefficient, so if you’re moving fast
through a built up area (for example on a train) your phone will only
use the 800MHz masts so that you have to handoff from one mast to
another less often
2021-03-12 09:17:42 +00:00
|
|
|
|
@cached_property
|
|
|
|
|
|
def simple_polygons_with_bleed(self):
|
|
|
|
|
|
polygons = Polygons(
|
|
|
|
|
|
list(itertools.chain(*(
|
|
|
|
|
|
area.simple_polygons_with_bleed for area in self.areas
|
|
|
|
|
|
)))
|
|
|
|
|
|
)
|
|
|
|
|
|
# If we’ve added multiple areas then we need to re-simplify the
|
|
|
|
|
|
# combined shapes to keep the point count down
|
|
|
|
|
|
return polygons.smooth.simplify if len(self.areas) > 1 else polygons
|
|
|
|
|
|
|
2021-01-15 15:11:43 +00:00
|
|
|
|
@property
|
|
|
|
|
|
def reference(self):
|
|
|
|
|
|
if self.template_id:
|
|
|
|
|
|
return self._dict['template_name']
|
|
|
|
|
|
return self._dict['reference']
|
|
|
|
|
|
|
2020-07-09 10:33:50 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def template(self):
|
2021-01-15 15:11:43 +00:00
|
|
|
|
return BroadcastPreviewTemplate({
|
|
|
|
|
|
'template_type': BroadcastPreviewTemplate.template_type,
|
|
|
|
|
|
'name': self.reference,
|
|
|
|
|
|
'content': self.content,
|
|
|
|
|
|
})
|
2020-07-09 10:33:50 +01:00
|
|
|
|
|
2020-07-09 15:48:56 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def status(self):
|
|
|
|
|
|
if (
|
|
|
|
|
|
self._dict['status']
|
|
|
|
|
|
and self._dict['status'] == 'broadcasting'
|
|
|
|
|
|
and self.finishes_at < datetime.utcnow().isoformat()
|
|
|
|
|
|
):
|
|
|
|
|
|
return 'completed'
|
|
|
|
|
|
return self._dict['status']
|
|
|
|
|
|
|
2020-08-05 16:01:21 +01:00
|
|
|
|
@cached_property
|
2020-07-10 14:06:00 +01:00
|
|
|
|
def created_by(self):
|
2021-01-27 11:24:22 +00:00
|
|
|
|
return User.from_id(self.created_by_id) if self.created_by_id else None
|
2020-07-10 14:06:00 +01:00
|
|
|
|
|
2020-08-05 16:01:21 +01:00
|
|
|
|
@cached_property
|
2020-07-10 14:06:00 +01:00
|
|
|
|
def approved_by(self):
|
2021-04-08 13:34:29 +01:00
|
|
|
|
return User.from_id(self.approved_by_id) if self.approved_by_id else None
|
2020-07-10 14:06:00 +01:00
|
|
|
|
|
2020-08-05 16:01:21 +01:00
|
|
|
|
@cached_property
|
2020-07-10 14:06:00 +01:00
|
|
|
|
def cancelled_by(self):
|
|
|
|
|
|
return User.from_id(self.cancelled_by_id)
|
|
|
|
|
|
|
2020-09-09 13:29:45 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def count_of_phones(self):
|
|
|
|
|
|
return round_to_significant_figures(
|
2020-09-17 11:00:17 +01:00
|
|
|
|
sum(area.count_of_phones for area in self.areas),
|
Make estimated phone count clearer
We’ve had some feedback from user research that difference between
‘will get alert’ and ‘likely to get alert’ is not clear, and it’s hard
to tell if the latter is inclusive of the former. This leads people to
question the validity of these numbers, which is important, because an
the estimate should give you some idea of the impact of what you’re
about to do.
This commit reformats the number as a range, for example 1,000 to 2,000
phones.
If the range is small, eg 40,000,000 to 40,800,000 then this suggests
a false level of accuracy. So instead we just give one number and say
it’s an estimate, eg ‘40,000,000 phones estimated’
2020-09-24 15:52:18 +01:00
|
|
|
|
1
|
2020-09-09 13:29:45 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def count_of_phones_likely(self):
|
|
|
|
|
|
area_estimate = self.simple_polygons.estimated_area
|
Vary bleed amount based on population density
There are basically two kinds of 4G masts:
Frequency | Range | Bandwidth
----------|-------------|----------------------------------
800MHz | Long (500m) | Low (can handle a bit of traffic)
1800Mhz | Short (5km) | High (can handle lots of traffic)
The 1800Mhz masts are better in terms of how much traffic they can
handle and how fast a connection they provide. But because they have
quite short range, it’s only economical to install them in very built up
areas†.
In more rural areas the 800MHz masts are better because they cover a
wider area, and have enough bandwidth for the lower population density.
The net effect of this is that cell broadcasts in rural areas are likely
to bleed further, because the masts they are being broadcast from are
less precise.
We can use population density as a proxy for how likely it is to be
covered by 1800Mhz masts, and therefore how much bleed we should expect.
So this commit varies the amount of bleed shown based on the population
density.
I came up with the formula based on 3 fixed points:
- The most remote areas (for example the Scottish Highlands) should have
the highest average bleed, estimated at 5km
- An town, like Crewe, should have about the same bleed as we were
estimating before (1.5km) – Pete D thinks this is about right based on
his knowledge of the area around his office in Crewe
- The most built up areas, like London boroughs, could have as little as
500m of bleed
Based on these three figures I came up with the following formula, which
roughly gives the right bleed distance (`b`) for each of their population
densities (`d`):
```
b = 5900 - (log10(d) × 1_250)
```
Plotted on a curve it looks like this:
This is based on averages – remember that the UI shows where is _likely_
to receive the alert, based on bleed, not where it’s _possible_ to
receive the alert.
Here’s what it looks like on the map:
---
†There are some additional subtleties which make this not strictly true:
- The 800Mhz masts are also used in built up areas to fill in the gaps
between the areas covered by the 1800Mhz masts
- Switching between masts is inefficient, so if you’re moving fast
through a built up area (for example on a train) your phone will only
use the 800MHz masts so that you have to handoff from one mast to
another less often
2021-03-12 09:17:42 +00:00
|
|
|
|
bleed_area_estimate = self.simple_polygons_with_bleed.estimated_area - area_estimate
|
2020-09-09 13:29:45 +01:00
|
|
|
|
return round_to_significant_figures(
|
Make estimated phone count clearer
We’ve had some feedback from user research that difference between
‘will get alert’ and ‘likely to get alert’ is not clear, and it’s hard
to tell if the latter is inclusive of the former. This leads people to
question the validity of these numbers, which is important, because an
the estimate should give you some idea of the impact of what you’re
about to do.
This commit reformats the number as a range, for example 1,000 to 2,000
phones.
If the range is small, eg 40,000,000 to 40,800,000 then this suggests
a false level of accuracy. So instead we just give one number and say
it’s an estimate, eg ‘40,000,000 phones estimated’
2020-09-24 15:52:18 +01:00
|
|
|
|
self.count_of_phones + (self.count_of_phones * bleed_area_estimate / area_estimate),
|
2020-09-09 13:29:45 +01:00
|
|
|
|
1
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2020-09-02 14:54:00 +01:00
|
|
|
|
def get_areas(self, areas):
|
|
|
|
|
|
return broadcast_area_libraries.get_areas(
|
|
|
|
|
|
*areas
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def get_simple_polygons(self, areas):
|
|
|
|
|
|
polygons = Polygons(
|
|
|
|
|
|
list(itertools.chain(*(
|
|
|
|
|
|
area.simple_polygons for area in areas
|
|
|
|
|
|
)))
|
|
|
|
|
|
)
|
|
|
|
|
|
# If we’ve added multiple areas then we need to re-simplify the
|
|
|
|
|
|
# combined shapes to keep the point count down
|
|
|
|
|
|
return polygons.smooth.simplify if len(areas) > 1 else polygons
|
|
|
|
|
|
|
2020-07-09 10:33:50 +01:00
|
|
|
|
def add_areas(self, *new_areas):
|
2020-09-02 14:54:00 +01:00
|
|
|
|
areas = list(OrderedSet(
|
2020-07-17 08:07:42 +01:00
|
|
|
|
self._dict['areas'] + list(new_areas)
|
2020-09-02 14:54:00 +01:00
|
|
|
|
))
|
|
|
|
|
|
simple_polygons = self.get_simple_polygons(areas=self.get_areas(areas=areas))
|
|
|
|
|
|
self._update(areas=areas, simple_polygons=simple_polygons.as_coordinate_pairs_lat_long)
|
2020-07-09 10:33:50 +01:00
|
|
|
|
|
|
|
|
|
|
def remove_area(self, area_to_remove):
|
2020-09-02 14:54:00 +01:00
|
|
|
|
areas = [
|
2020-07-17 08:07:42 +01:00
|
|
|
|
area for area in self._dict['areas']
|
|
|
|
|
|
if area != area_to_remove
|
2020-09-02 14:54:00 +01:00
|
|
|
|
]
|
|
|
|
|
|
simple_polygons = self.get_simple_polygons(areas=self.get_areas(areas=areas))
|
|
|
|
|
|
self._update(areas=areas, simple_polygons=simple_polygons.as_coordinate_pairs_lat_long)
|
2020-07-17 08:07:42 +01:00
|
|
|
|
|
|
|
|
|
|
def _set_status_to(self, status):
|
|
|
|
|
|
broadcast_message_api_client.update_broadcast_message_status(
|
|
|
|
|
|
status,
|
2020-07-09 10:33:50 +01:00
|
|
|
|
broadcast_message_id=self.id,
|
|
|
|
|
|
service_id=self.service_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2020-07-17 08:07:42 +01:00
|
|
|
|
def _update(self, **kwargs):
|
2020-07-09 10:33:50 +01:00
|
|
|
|
broadcast_message_api_client.update_broadcast_message(
|
|
|
|
|
|
broadcast_message_id=self.id,
|
|
|
|
|
|
service_id=self.service_id,
|
2020-07-17 08:07:42 +01:00
|
|
|
|
data=kwargs,
|
2020-07-09 10:33:50 +01:00
|
|
|
|
)
|
2020-07-17 08:07:42 +01:00
|
|
|
|
|
2020-08-18 16:36:40 +01:00
|
|
|
|
def request_approval(self):
|
2020-07-17 08:07:43 +01:00
|
|
|
|
self._set_status_to('pending-approval')
|
2020-07-09 15:48:56 +01:00
|
|
|
|
|
2020-07-17 08:07:44 +01:00
|
|
|
|
def approve_broadcast(self):
|
|
|
|
|
|
self._update(
|
|
|
|
|
|
starts_at=datetime.utcnow().isoformat(),
|
2020-08-18 16:36:40 +01:00
|
|
|
|
finishes_at=(
|
2021-02-12 15:49:16 +00:00
|
|
|
|
datetime.utcnow() + timedelta(hours=4, minutes=0)
|
2020-08-18 16:36:40 +01:00
|
|
|
|
).isoformat(),
|
2020-07-17 08:07:44 +01:00
|
|
|
|
)
|
|
|
|
|
|
self._set_status_to('broadcasting')
|
|
|
|
|
|
|
2020-07-17 08:07:44 +01:00
|
|
|
|
def reject_broadcast(self):
|
|
|
|
|
|
self._set_status_to('rejected')
|
|
|
|
|
|
|
2020-07-10 09:16:36 +01:00
|
|
|
|
def cancel_broadcast(self):
|
2020-07-17 08:07:42 +01:00
|
|
|
|
self._set_status_to('cancelled')
|
2020-07-10 09:16:36 +01:00
|
|
|
|
|
2020-07-09 15:48:56 +01:00
|
|
|
|
|
|
|
|
|
|
class BroadcastMessages(ModelList):
|
|
|
|
|
|
|
|
|
|
|
|
model = BroadcastMessage
|
|
|
|
|
|
client_method = broadcast_message_api_client.get_broadcast_messages
|
|
|
|
|
|
|
|
|
|
|
|
def with_status(self, *statuses):
|
|
|
|
|
|
return [
|
|
|
|
|
|
broadcast for broadcast in self if broadcast.status in statuses
|
|
|
|
|
|
]
|