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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
2020-08-06 13:33:52 +01:00
|
|
|
|
from app.broadcast_areas import broadcast_area_libraries
|
2020-08-24 12:51:23 +01:00
|
|
|
|
from app.broadcast_areas.polygons import Polygons
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.notify_client.service_api_client import service_api_client
|
2020-09-17 11:03:14 +01:00
|
|
|
|
from app.utils import round_to_significant_figures
|
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',
|
|
|
|
|
|
'template_name',
|
|
|
|
|
|
'template_version',
|
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):
|
|
|
|
|
|
return (
|
2020-08-19 15:10:44 +01:00
|
|
|
|
self.cancelled_at or self.finishes_at or self.created_at
|
2020-07-10 09:26:47 +01:00
|
|
|
|
) < (
|
2020-08-19 15:10:44 +01:00
|
|
|
|
other.cancelled_at or other.finishes_at or self.created_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,
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
@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):
|
2020-09-02 14:54:00 +01:00
|
|
|
|
return self.get_areas(areas=self._dict['areas'])
|
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
|
|
|
|
|
2020-07-09 10:33:50 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def template(self):
|
|
|
|
|
|
response = service_api_client.get_service_template(
|
|
|
|
|
|
self.service_id,
|
|
|
|
|
|
self.template_id,
|
|
|
|
|
|
version=self.template_version,
|
|
|
|
|
|
)
|
|
|
|
|
|
return BroadcastPreviewTemplate(response['data'])
|
|
|
|
|
|
|
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):
|
|
|
|
|
|
return User.from_id(self.created_by_id)
|
|
|
|
|
|
|
2020-08-05 16:01:21 +01:00
|
|
|
|
@cached_property
|
2020-07-10 14:06:00 +01:00
|
|
|
|
def approved_by(self):
|
|
|
|
|
|
return User.from_id(self.approved_by_id)
|
|
|
|
|
|
|
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
|
|
|
|
|
|
bleed_area_estimate = self.simple_polygons.bleed.estimated_area - area_estimate
|
|
|
|
|
|
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=(
|
|
|
|
|
|
datetime.utcnow() + timedelta(hours=23, minutes=59)
|
|
|
|
|
|
).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
|
|
|
|
|
|
]
|