diff --git a/.circleci/config.yml b/.circleci/config.yml index 0e4a4b37a..77b938523 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -191,7 +191,7 @@ jobs: ./cc-test-reporter before-build . venv/bin/activate coverage combine parallel-coverage/ - coverage xml + coverage xml -i coverage report ./cc-test-reporter format-coverage -o ./.coverage -t coverage.py ./cc-test-reporter upload-coverage -i .coverage diff --git a/.codeclimate.yml b/.codeclimate.yml index d0ded27fc..7ae6e6674 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -2,4 +2,7 @@ version: "2" exclude_patterns: - "helpers/auth/authentication.py" - "helpers/calendar/events.py" - - "alembic/" \ No newline at end of file + - "alembic/" + - "admin_notifications/__init__.py" + - "admin_notifications/helpers/__init__.py" + - "admin_notifications/helpers/notification_templates.py" diff --git a/.coveragerc b/.coveragerc index 3d0cca121..a52e224fe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -13,5 +13,3 @@ omit = [html] directory=html_coverage_report - - \ No newline at end of file diff --git a/.gitignore b/.gitignore index dbf0b9abc..adcf114e1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,6 @@ html_coverage_report/ .tox/ .coverage .coverage.* -.coveragerc -setup.cfg .cache nosetests.xml coverage.xml diff --git a/admin_notifications/__init__.py b/admin_notifications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/admin_notifications/helpers/__init__.py b/admin_notifications/helpers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/admin_notifications/helpers/create_notification.py b/admin_notifications/helpers/create_notification.py new file mode 100644 index 000000000..67a965eaf --- /dev/null +++ b/admin_notifications/helpers/create_notification.py @@ -0,0 +1,34 @@ +from admin_notifications.models import AdminNotification +from api.location.models import Location +from datetime import datetime +import celery + + +def update_notification(notification_id): + notification = AdminNotification.query.filter_by(id=notification_id).first() + notification.date_received = datetime.now() + notification.save() + + +@celery.task +def create_notification(title, message, location_id): + """ + Create notifications in the database and emit them to the client + """ + from manage import socketio + location = Location.query.filter_by(id=location_id).first() + location_name = location.name + notification = AdminNotification( + title=title, + message=message, + location_id=location_id, + status="unread" + ) + notification.save() + new_notification = {"title": title, "message": message} + return socketio.emit( + f"notifications-{location_name}", + {'notification': new_notification}, + broadcast=True, + callback=update_notification(notification.id) + ) diff --git a/admin_notifications/helpers/notification_templates.py b/admin_notifications/helpers/notification_templates.py new file mode 100644 index 000000000..624a01160 --- /dev/null +++ b/admin_notifications/helpers/notification_templates.py @@ -0,0 +1,6 @@ +def event_auto_cancelled_notification(event_name, room_name): + return { + "title": "Event Auto cancelled.", + "message": f"An event {event_name} in {room_name} \ +has been auto cancelled." + } diff --git a/admin_notifications/models.py b/admin_notifications/models.py new file mode 100644 index 000000000..8485377fa --- /dev/null +++ b/admin_notifications/models.py @@ -0,0 +1,19 @@ +from sqlalchemy import (Column, String, Enum, Integer, ForeignKey) +from helpers.database import Base +from utilities.utility import Utility, StatusType + + +class AdminNotification(Base, Utility): + __tablename__ = 'admin_notifications' + + id = Column(Integer, primary_key=True) # noqa + title = Column(String, nullable=True) + message = Column(String, nullable=True) + date_received = Column(String, nullable=True) + date_read = Column(String, nullable=True) + status = Column(Enum(StatusType), default="unread") + location_id = Column( + Integer, + ForeignKey('locations.id', ondelete="CASCADE"), + nullable=True + ) diff --git a/admin_notifications/schema.py b/admin_notifications/schema.py new file mode 100644 index 000000000..c08814291 --- /dev/null +++ b/admin_notifications/schema.py @@ -0,0 +1,67 @@ +import graphene +from graphene_sqlalchemy import SQLAlchemyObjectType +from graphql import GraphQLError +from helpers.auth.authentication import Auth +from datetime import datetime +from admin_notifications.models import AdminNotification as \ + AdminNotificationModel + + +class AdminNotifications(SQLAlchemyObjectType): + """ + Returns the admin_notificationspayload + """ + class Meta: + model = AdminNotificationModel + + +class NotificationsList(graphene.ObjectType): + """ + Class to return the types for Admin notifications + \n- rooms: The rooms data + """ + notifications = graphene.List(AdminNotifications) + + +class Query(graphene.ObjectType): + all_unread_notifications = graphene.Field( + NotificationsList, + description="Returns a list of admin notifications" + ) + + @Auth.user_roles('Admin') + def resolve_all_unread_notifications(self, info): + query = AdminNotifications.get_query(info) + notifications = query.filter( + AdminNotificationModel.status == "unread").all() + return NotificationsList(notifications=notifications) + + +class UpdateNotificationStatus(graphene.Mutation): + """ + Class to update the status of a notification + """ + + class Arguments: + notification_id = graphene.Int(required=True) + notification = graphene.Field(AdminNotifications) + + @Auth.user_roles('Admin') + def mutate(self, info, notification_id): + query = AdminNotifications.get_query(info) + unread_notifications = query.filter( + AdminNotificationModel.status == "unread") + notification = unread_notifications.filter( + AdminNotificationModel.id == notification_id).first() + if not notification: + raise GraphQLError("Notification is already read or not found.") + notification.status = "read" + notification.date_read = datetime.now() + notification.save() + return UpdateNotificationStatus(notification=notification) + + +class Mutation(graphene.ObjectType): + update_notification_status = UpdateNotificationStatus.Field( + description="Updates the status of a notification and takes the argument\ + \n- notification_id: The name of the room[required]") diff --git a/alembic/env.py b/alembic/env.py index 01ce12db5..5a0334069 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -45,7 +45,7 @@ from api.response.models import Response from api.tag.models import Tag from api.structure.models import Structure - +from admin_notifications.models import AdminNotification target_metadata = Base.metadata diff --git a/alembic/versions/f0873fb8edb3_add_admin_notifications_table.py b/alembic/versions/f0873fb8edb3_add_admin_notifications_table.py new file mode 100644 index 000000000..c593c4156 --- /dev/null +++ b/alembic/versions/f0873fb8edb3_add_admin_notifications_table.py @@ -0,0 +1,39 @@ +"""add admin notifications table + +Revision ID: f0873fb8edb3 +Revises: 50173cb0491f +Create Date: 2019-06-27 13:31:19.694650 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f0873fb8edb3' +down_revision = '50173cb0491f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute('DROP TYPE statustype;') + op.create_table('admin_notifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=True), + sa.Column('message', sa.String(), nullable=True), + sa.Column('date_received', sa.String(), nullable=True), + sa.Column('date_read', sa.String(), nullable=True), + sa.Column('status', sa.Enum('read', 'unread', name='statustype'), nullable=True), + sa.Column('location_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('admin_notifications') + # ### end Alembic commands ### diff --git a/api/devices/models.py b/api/devices/models.py index 78a225e9f..8bf4b1f82 100644 --- a/api/devices/models.py +++ b/api/devices/models.py @@ -9,7 +9,7 @@ class Devices(Base, Utility): __tablename__ = 'devices' - id = Column(Integer, Sequence('devices_id_seq', start=1, increment=1), primary_key=True) # noqa + id = Column(Integer, Sequence('devices_id_seq', start=1, increment=1), primary_key=True) # noqa name = Column(String, nullable=False) device_type = Column(String, nullable=False) date_added = Column(DateTime, nullable=False) diff --git a/app.py b/app.py index f6ef5faea..6ace0096c 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,6 @@ from healthcheck_schema import healthcheck_schema from helpers.auth.authentication import Auth from api.analytics.analytics_request import AnalyticsRequest - mail = Mail() diff --git a/cworker.py b/cworker.py index ad6695d1a..948d990ca 100644 --- a/cworker.py +++ b/cworker.py @@ -1,16 +1,24 @@ import os - from celery import Celery from app import create_app - app = create_app(os.getenv('APP_SETTINGS') or 'default') app.app_context().push() +app.config.update( + CELERY_BROKER_URL=os.getenv('CELERY_BROKER_URL'), + CELERY_RESULT_BACKEND=os.getenv('CELERY_RESULT_BACKEND'), + CELERY_ACCEPT_CONTENT=['pickle'] +) + + def make_celery(app): - celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL']) + celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'], include=['admin_notifications.helpers.create_notification'], # noqa 501 + backend=app.config['CELERY_BROKER_URL']) + celery.conf.update(app.config) + celery.conf.enable_utc = False TaskBase = celery.Task class ContextTask(TaskBase): @@ -24,3 +32,5 @@ def __call__(self, *args, **kwargs): celery = make_celery(app) +celery_scheduler = Celery(app.name, broker=app.config['CELERY_BROKER_URL']) +celery_scheduler.conf.update(app.config) diff --git a/fixtures/admin_notification/__init__.py b/fixtures/admin_notification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fixtures/admin_notification/admin_notification_fixtures.py b/fixtures/admin_notification/admin_notification_fixtures.py new file mode 100644 index 000000000..ec7e0bd11 --- /dev/null +++ b/fixtures/admin_notification/admin_notification_fixtures.py @@ -0,0 +1,53 @@ +null = None + +get_all_unread_notifications = ''' +query { + allUnreadNotifications{ + notifications { + title + message + id + } + } +} +''' + +get_all_unread_notifications_response = { + "data": { + "allUnreadNotifications": { + "notifications": [] + } + } +} + +change_notification_status_unexistent_id = ''' +mutation { + updateNotificationStatus(notificationId: 260){ + notification { + id + title + message + } + } +} +''' + +change_notification_status_unexistent_id_response = { + "errors": [ + { + "message": "Notification is already read or not found.", + "locations": [ + { + "line": 3, + "column": 3 + } + ], + "path": [ + "updateNotificationStatus" + ] + } + ], + "data": { + "updateNotificationStatus": null + } +} diff --git a/helpers/email/email_setup.py b/helpers/email/email_setup.py index 236499f23..82ff25ad1 100644 --- a/helpers/email/email_setup.py +++ b/helpers/email/email_setup.py @@ -30,7 +30,7 @@ def __init__( html=self.template, sender=self.sender) - @celery.task + @celery.task(name='asynchronous-email-notifications') def send_async_email(msg_dict): mail = Mail() msg = Message() diff --git a/manage.py b/manage.py index a9e7943d6..39891de9f 100644 --- a/manage.py +++ b/manage.py @@ -3,7 +3,7 @@ import bugsnag from flask_script import Manager, Shell from bugsnag.flask import handle_exceptions - +from flask_socketio import SocketIO # Configure bugnsag bugsnag.configure( @@ -18,16 +18,23 @@ app = create_app(os.getenv('APP_SETTINGS') or 'default') handle_exceptions(app) manager = Manager(app) +socketio = SocketIO(app) def make_shell_context(): return dict(app=app) +@socketio.on('message') +def hanldleMessage(msg): + from admin_notifications.socket_handler import send_notifications + return send_notifications() + + manager.add_command( "shell", Shell( make_context=make_shell_context)) - if __name__ == '__main__': + socketio.run(app, debug=True) manager.run() diff --git a/requirements.txt b/requirements.txt index e27578af6..8ade2b2f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,39 +1,87 @@ alembic==0.9.8 -bugsnag==3.4.2 +amqp==1.4.9 +aniso8601==3.0.2 +anyjson==0.3.3 +astroid==2.2.5 +attrs==19.1.0 +autopep8==1.4.4 +billiard==3.3.0.23 blinker==1.4 +bugsnag==3.4.2 celery==3.1.17 +certifi==2019.3.9 +chardet==3.0.4 +Click==7.0 coverage==4.5.1 +coveralls==1.8.0 +decorator==4.4.0 +docopt==0.6.2 +eventlet==0.25.0 +flake8==3.5.0 Flask==0.12.2 Flask-Cors==3.0.4 -Flask-JSON==0.3.2 -Flask-Script==2.0.6 Flask-GraphQL==1.4.1 +Flask-JSON==0.3.2 Flask-Mail==0.9.1 +Flask-Script==2.0.6 +flask-socketio==4.1.0 +Flask-Testing==0.7.1 google-api-python-client==1.6.7 -graphene-sqlalchemy==2.0.0 graphene==2.1 +graphene-sqlalchemy==2.0.0 +graphql-core==2.2 +graphql-relay==0.4.5 +httplib2==0.13.0 +idna==2.8 imgkit==1.0.1 +iso8601==0.1.12 +isort==4.3.20 +itsdangerous==1.1.0 +Jinja2==2.10.1 +kombu==3.0.37 +lazy-object-proxy==1.4.1 +Mako==1.0.12 MarkupSafe==1.0 +mccabe==0.6.1 more-itertools==4.1.0 +nose==1.3.7 numpy==1.15.2 +oauth2client==4.1.3 opencv-python==3.4.3.18 pandas==0.23.4 -Pillow==5.3.0 pdfkit==0.6.1 +Pillow==5.3.0 +pluggy==0.6.0 +promise==2.2.1 psycopg2-binary==2.7.4 py==1.5.3 +pyasn1==0.4.5 +pyasn1-modules==0.2.5 +pycodestyle==2.3.1 +pyflakes==1.6.0 +PyJWT==1.6.4 +pylint==2.3.1 pytest==3.5.0 python-dateutil==2.7.0 -PyJWT==1.6.4 -redis==2.10.3 -nose==1.3.7 python-editor==1.0.3 +python-engineio==3.8.1 +python-socketio==4.1.0 +pytz==2019.1 +redis==2.10.3 +requests==2.22.0 +rsa==4.0 +Rx==1.6.1 +singledispatch==3.4.0.3 +six==1.12.0 SQLAlchemy==1.2.6 -tox==3.0.0 SQLAlchemy-Utils==0.33.2 -virtualenv==15.2.0 -Flask-Testing==0.7.1 +tox==3.0.0 +typed-ast==1.4.0 typing==3.6.4 -flake8==3.5.0 -coveralls -validators==0.12.4 \ No newline at end of file +uritemplate==3.0.0 +urllib3==1.25.3 +validators==0.12.4 +virtualenv==15.2.0 +WebOb==1.8.5 +Werkzeug==0.15.4 +wrapt==1.11.1 diff --git a/schema.py b/schema.py index 8d28e261e..3488084d0 100644 --- a/schema.py +++ b/schema.py @@ -16,6 +16,7 @@ import api.response.schema_query import api.structure.schema import api.analytics.all_analytics_query +import admin_notifications.schema class Query( @@ -33,6 +34,7 @@ class Query( api.structure.schema.Query, api.analytics.all_analytics_query.Query, api.events.schema.Query, + admin_notifications.schema.Query ): """Root for converge Graphql queries""" pass @@ -51,6 +53,7 @@ class Mutation( api.response.schema.Mutation, api.tag.schema.Mutation, api.structure.schema.Mutation, + admin_notifications.schema.Mutation ): """The root query for implementing GraphQL mutations.""" pass diff --git a/setup.cfg b/setup.cfg index 877d1b6ed..526c9f8cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,4 +4,4 @@ testpaths = tests [coverage:run] branch = True source = - mrm_api/ \ No newline at end of file + mrm_api/ diff --git a/tests/test_admin_notification/__init__.py b/tests/test_admin_notification/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_admin_notification/test_admin_notification.py b/tests/test_admin_notification/test_admin_notification.py new file mode 100644 index 000000000..6ec151e61 --- /dev/null +++ b/tests/test_admin_notification/test_admin_notification.py @@ -0,0 +1,23 @@ +from tests.base import BaseTestCase, CommonTestCases +from fixtures.admin_notification.admin_notification_fixtures import ( + get_all_unread_notifications, + get_all_unread_notifications_response, + change_notification_status_unexistent_id, + change_notification_status_unexistent_id_response +) + + +class TestDeleteTag(BaseTestCase): + def test_get_all_notifications(self): + CommonTestCases.admin_token_assert_equal( + self, + get_all_unread_notifications, + get_all_unread_notifications_response + ) + + def test_change_notification_status(self): + CommonTestCases.admin_token_assert_equal( + self, + change_notification_status_unexistent_id, + change_notification_status_unexistent_id_response + ) diff --git a/utilities/utility.py b/utilities/utility.py index 6fa044428..271596f7f 100644 --- a/utilities/utility.py +++ b/utilities/utility.py @@ -85,5 +85,10 @@ class StateType(enum.Enum): class QuestionType(enum.Enum): rate = "rate" check = "check" - textarea = "textarea" - missingitem = "missing_items" + text_area = "text_area" + missing_items = "missing_items" + + +class StatusType(enum.Enum): + read = "read" + unread = "unread"