From 67f6dcae45c46dc3fe86ba3a980a788bf3842a11 Mon Sep 17 00:00:00 2001 From: Leo Hemsted Date: Tue, 7 Jul 2020 11:42:38 +0100 Subject: [PATCH] add broadcast message crud new blueprint `/service//broadcast-message` with the following endpoints: * GET / - get all broadcast messages for a service * GET / - get a single broadcast message * POST / - create a new broadcast message * POST / - update an existing broadcast message's data * POST //status - move a broadcast message to a new status I've kept the regular data update (eg personalisation, start and end times) separate from the status update, just to keep separation of concerns a bit more rigid, especially around who can update. I've included schemas for the three POSTs, they're pretty straightforward. --- app/__init__.py | 4 + app/broadcast_message/__init__.py | 0 .../broadcast_message_schema.py | 48 +++++++ app/broadcast_message/rest.py | 123 ++++++++++++++++++ app/dao/broadcast_message_dao.py | 26 ++++ app/models.py | 44 ++++++- 6 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 app/broadcast_message/__init__.py create mode 100644 app/broadcast_message/broadcast_message_schema.py create mode 100644 app/broadcast_message/rest.py create mode 100644 app/dao/broadcast_message_dao.py diff --git a/app/__init__.py b/app/__init__.py index 255af272d..d16e0cdbe 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -152,6 +152,7 @@ def register_blueprint(application): from app.template_folder.rest import template_folder_blueprint from app.letter_branding.letter_branding_rest import letter_branding_blueprint from app.upload.rest import upload_blueprint + from app.broadcast_message.rest import broadcast_message_blueprint service_blueprint.before_request(requires_admin_auth) application.register_blueprint(service_blueprint, url_prefix='/service') @@ -237,6 +238,9 @@ def register_blueprint(application): upload_blueprint.before_request(requires_admin_auth) application.register_blueprint(upload_blueprint) + broadcast_message_blueprint.before_request(requires_admin_auth) + application.register_blueprint(broadcast_message_blueprint) + def register_v2_blueprints(application): from app.v2.inbound_sms.get_inbound_sms import v2_inbound_sms_blueprint as get_inbound_sms diff --git a/app/broadcast_message/__init__.py b/app/broadcast_message/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/broadcast_message/broadcast_message_schema.py b/app/broadcast_message/broadcast_message_schema.py new file mode 100644 index 000000000..f8a668127 --- /dev/null +++ b/app/broadcast_message/broadcast_message_schema.py @@ -0,0 +1,48 @@ +from app.schema_validation.definitions import uuid +from app.models import BroadcastStatusType + +create_broadcast_message_schema = { + '$schema': 'http://json-schema.org/draft-04/schema#', + 'description': 'POST create broadcast_message schema', + 'type': 'object', + 'title': 'Create broadcast_message', + 'properties': { + 'template_id': uuid, + 'service_id': uuid, + 'created_by': uuid, + 'personalisation': {'type': 'object'}, + 'starts_at': {'type': 'string', 'format': 'date-time'}, + 'finishes_at': {'type': 'string', 'format': 'date-time'}, + 'areas': {"type": "array", "items": {"type": "string"}}, + }, + 'required': ['template_id', 'service_id', 'created_by'], + 'additionalProperties': False +} + +update_broadcast_message_schema = { + '$schema': 'http://json-schema.org/draft-04/schema#', + 'description': 'POST update broadcast_message schema', + 'type': 'object', + 'title': 'Update broadcast_message', + 'properties': { + 'personalisation': {'type': 'object'}, + 'starts_at': {'type': 'string', 'format': 'date-time'}, + 'finishes_at': {'type': 'string', 'format': 'date-time'}, + 'areas': {"type": "array", "items": {"type": "string"}}, + }, + 'required': [], + 'additionalProperties': False +} + +update_broadcast_message_status_schema = { + '$schema': 'http://json-schema.org/draft-04/schema#', + 'description': 'POST update broadcast_message status schema', + 'type': 'object', + 'title': 'Update broadcast_message', + 'properties': { + 'status': {'type': 'string', 'enum': BroadcastStatusType.STATUSES}, + 'created_by': uuid, + }, + 'required': ['status', 'created_by'], + 'additionalProperties': False +} diff --git a/app/broadcast_message/rest.py b/app/broadcast_message/rest.py new file mode 100644 index 000000000..9fa67a44a --- /dev/null +++ b/app/broadcast_message/rest.py @@ -0,0 +1,123 @@ +from datetime import datetime + +import iso8601 +from flask import Blueprint, jsonify, request + +from app.dao.templates_dao import dao_get_template_by_id_and_service_id +from app.dao.users_dao import get_user_by_id +from app.dao.broadcast_message_dao import ( + dao_create_broadcast_message, + dao_get_broadcast_message_by_id_and_service_id, + dao_get_broadcast_messages_for_service, + dao_update_broadcast_message, +) +from app.dao.services_dao import dao_fetch_service_by_id +from app.errors import register_errors +from app.models import BroadcastMessage, BroadcastStatusType +from app.broadcast_message.broadcast_message_schema import ( + create_broadcast_message_schema, + update_broadcast_message_schema, + update_broadcast_message_status_schema, +) +from app.schema_validation import validate + +broadcast_message_blueprint = Blueprint( + 'broadcast_message', + __name__, + url_prefix='/service//broadcast-message' +) +register_errors(broadcast_message_blueprint) + + +def _parse_nullable_datetime(dt): + if dt: + return iso8601.parse_date(dt).replace(tzinfo=None) + return dt + + +@broadcast_message_blueprint.route('', methods=['GET']) +def get_broadcast_messages_for_service(service_id): + # TODO: should this return template content/data in some way? or can we rely on them being cached admin side. + # we might need stuff like template name for showing on the dashboard. + # TODO: should this paginate or filter on dates or anything? + broadcast_messages = [o.serialize() for o in dao_get_broadcast_messages_for_service(service_id)] + return jsonify(broadcast_messages=broadcast_messages) + + +@broadcast_message_blueprint.route('/', methods=['GET']) +def get_broadcast_message(service_id, broadcast_message_id): + return jsonify(dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id).serialize()) + + +@broadcast_message_blueprint.route('', methods=['POST']) +def create_broadcast_message(service_id): + data = request.get_json() + + validate(data, create_broadcast_message_schema) + service = dao_fetch_service_by_id(data['service_id']) + user = get_user_by_id(data['created_by']) + template = dao_get_template_by_id_and_service_id(data['template_id'], data['service_id']) + + broadcast_message = BroadcastMessage( + service_id=service.id, + template_id=template.id, + template_version=template.version, + personalisation=data.get('personalisation', {}), + areas=data.get('areas', []), + status=BroadcastStatusType.DRAFT, + starts_at=_parse_nullable_datetime(data.get('starts_at')), + finishes_at=_parse_nullable_datetime(data.get('finishes_at')), + created_by_id=user.id, + ) + + dao_create_broadcast_message(broadcast_message) + + return jsonify(broadcast_message.serialize()), 201 + + +@broadcast_message_blueprint.route('/', methods=['POST']) +def update_broadcast_message(service_id, broadcast_message_id): + data = request.get_json() + + validate(data, update_broadcast_message_schema) + + broadcast_message = dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id) + + if 'personalisation' in data: + broadcast_message.personalisation = data['personalisation'] + if 'starts_at' in data: + broadcast_message.starts_at = _parse_nullable_datetime(data['starts_at']) + if 'finishes_at' in data: + broadcast_message.starts_at = _parse_nullable_datetime(data['finishes_at']) + if 'areas' in data: + broadcast_message.areas = data['areas'] + + dao_update_broadcast_message(broadcast_message) + + return jsonify(broadcast_message.serialize()), 200 + + +@broadcast_message_blueprint.route('//status', methods=['POST']) +def update_broadcast_message_status(service_id, broadcast_message_id): + data = request.get_json() + + validate(data, update_broadcast_message_status_schema) + broadcast_message = dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id) + + new_status = data['status'] + + # TODO: Restrict status transitions + # TODO: Do we need to validate that the user belongs to the same service, isn't the creator, has permissions, etc? + # or is that admin's job + if new_status == BroadcastStatusType.BROADCASTING: + broadcast_message.approved_at = datetime.utcnow() + broadcast_message.approved_by = get_user_by_id(data['created_by']) + if new_status == BroadcastStatusType.CANCELLED: + broadcast_message.cancelled_at = datetime.utcnow() + broadcast_message.cancelled_by = get_user_by_id(data['created_by']) + + broadcast_message.status = new_status + + dao_update_broadcast_message(broadcast_message) + + return jsonify(broadcast_message.serialize()), 200 diff --git a/app/dao/broadcast_message_dao.py b/app/dao/broadcast_message_dao.py new file mode 100644 index 000000000..068dd3653 --- /dev/null +++ b/app/dao/broadcast_message_dao.py @@ -0,0 +1,26 @@ +from app import db +from app.models import BroadcastMessage +from app.dao.dao_utils import transactional + + +@transactional +def dao_create_broadcast_message(broadcast_message): + db.session.add(broadcast_message) + + +@transactional +def dao_update_broadcast_message(broadcast_message): + db.session.add(broadcast_message) + + +def dao_get_broadcast_message_by_id_and_service_id(broadcast_message_id, service_id): + return BroadcastMessage.query.filter( + BroadcastMessage.id == broadcast_message_id, + BroadcastMessage.service_id == service_id + ).one() + + +def dao_get_broadcast_messages_for_service(service_id): + return BroadcastMessage.query.filter( + BroadcastMessage.service_id == service_id + ).order_by(BroadcastMessage.created_at) diff --git a/app/models.py b/app/models.py index eae9c921a..be00abfd1 100644 --- a/app/models.py +++ b/app/models.py @@ -2174,7 +2174,7 @@ class BroadcastMessage(db.Model): {} ) - id = db.Column(UUID(as_uuid=True), primary_key=True) + id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) service_id = db.Column(UUID(as_uuid=True), db.ForeignKey('services.id')) service = db.relationship('Service', backref='broadcast_messages') @@ -2184,6 +2184,8 @@ class BroadcastMessage(db.Model): template = db.relationship('TemplateHistory', backref='broadcast_messages') _personalisation = db.Column(db.String, nullable=True) + # defaults to empty list + areas = db.Column(JSONB(none_as_null=True), nullable=False, default=list) status = db.Column( db.String, @@ -2197,7 +2199,7 @@ class BroadcastMessage(db.Model): finishes_at = db.Column(db.DateTime, nullable=True) # isn't updated if user cancels # these times correspond to when - created_at = db.Column(db.DateTime, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) approved_at = db.Column(db.DateTime, nullable=True) cancelled_at = db.Column(db.DateTime, nullable=True) updated_at = db.Column(db.DateTime, nullable=True, onupdate=datetime.datetime.utcnow) @@ -2209,3 +2211,41 @@ class BroadcastMessage(db.Model): created_by = db.relationship('User', foreign_keys=[created_by_id]) approved_by = db.relationship('User', foreign_keys=[approved_by_id]) cancelled_by = db.relationship('User', foreign_keys=[cancelled_by_id]) + + @property + def personalisation(self): + if self._personalisation: + return encryption.decrypt(self._personalisation) + return {} + + @personalisation.setter + def personalisation(self, personalisation): + self._personalisation = encryption.encrypt(personalisation or {}) + + def serialize(self): + return { + 'id': self.id, + + 'service_id': self.service_id, + + 'template_id': self.template_id, + 'template_version': self.template_version, + 'template_name': self.template.name, + + 'personalisation': self.personalisation, + 'areas': self.areas, + + 'status': self.status, + + 'starts_at': self.starts_at.strftime(DATETIME_FORMAT) if self.starts_at else None, + 'finishes_at': self.finishes_at.strftime(DATETIME_FORMAT) if self.finishes_at else None, + + 'created_at': self.created_at.strftime(DATETIME_FORMAT) if self.created_at else None, + 'approved_at': self.approved_at.strftime(DATETIME_FORMAT) if self.approved_at else None, + 'cancelled_at': self.cancelled_at.strftime(DATETIME_FORMAT) if self.cancelled_at else None, + 'updated_at': self.updated_at.strftime(DATETIME_FORMAT) if self.updated_at else None, + + 'created_by_id': self.created_by_id, + 'approved_by_id': self.approved_by_id, + 'cancelled_by_id': self.cancelled_by_id, + }