Skip to content

Commit fedf9ec

Browse files
codinger41[Arusey]
authored and
[Arusey]
committed
CON-72-story(notify-admin-when-device-is-not-seen-for-a-while)
- create device is offline notification - periodically check the device last seen - add too states for a device: online or offline [Finishes CON-72]
1 parent 5ef0f1f commit fedf9ec

27 files changed

+222
-27
lines changed

.circleci/config.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ gcloud_setup: &gcloud_setup
3636
run:
3737
name: setup gcloud
3838
command: |
39-
# install
39+
# install
4040
sudo curl https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz > /tmp/google-cloud-sdk.tar.gz
41-
sudo mkdir -p /usr/local/gcloud
41+
sudo mkdir -p /usr/local/gcloud
4242
sudo tar -C /usr/local/gcloud -xvf /tmp/google-cloud-sdk.tar.gz
4343
sudo /usr/local/gcloud/google-cloud-sdk/install.sh --quiet
4444
echo PATH=$PATH:/usr/local/gcloud/google-cloud-sdk/bin >> ~/.bashrc
@@ -190,8 +190,8 @@ jobs:
190190
command: |
191191
./cc-test-reporter before-build
192192
. venv/bin/activate
193-
coverage combine parallel-coverage/
194-
coverage xml
193+
coverage combine parallel-coverage/
194+
coverage xml -i
195195
coverage report
196196
./cc-test-reporter format-coverage -o ./.coverage -t coverage.py
197197
./cc-test-reporter upload-coverage -i .coverage
@@ -304,13 +304,13 @@ jobs:
304304
command: |
305305
if [ "$CIRCLE_BRANCH" == master ] || [ "$CIRCLE_BRANCH" == develop ]; then
306306
touch google-service-key.json
307-
echo $GOOGLE_CREDENTIALS_STAGING | base64 --decode >> google-service-key.json
307+
echo $GOOGLE_CREDENTIALS_STAGING | base64 --decode >> google-service-key.json
308308
gcloud auth activate-service-account --key-file google-service-key.json
309309
gcloud --quiet config set project ${GOOGLE_PROJECT_ID_STAGING}
310310
gcloud --quiet config set compute/zone ${GOOGLE_COMPUTE_ZONE}
311311
else
312312
touch google-service-key.json
313-
echo $GOOGLE_CREDENTIALS_SANDBOX | base64 --decode >> google-service-key.json
313+
echo $GOOGLE_CREDENTIALS_SANDBOX | base64 --decode >> google-service-key.json
314314
gcloud auth activate-service-account --key-file google-service-key.json
315315
gcloud --quiet config set project ${GOOGLE_PROJECT_ID_SANDBOX}
316316
gcloud --quiet config set compute/zone ${GOOGLE_COMPUTE_ZONE}

.codeclimate.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ version: "2"
22
exclude_patterns:
33
- "helpers/auth/authentication.py"
44
- "helpers/calendar/events.py"
5-
- "alembic/"
5+
- "**/alembic/"
6+
- "**/*__init__.py"
7+
- "**/tests/"

.coveragerc

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,3 @@ omit =
1313

1414
[html]
1515
directory=html_coverage_report
16-
17-

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ html_coverage_report/
4242
.tox/
4343
.coverage
4444
.coverage.*
45-
.coveragerc
46-
setup.cfg
4745
.cache
4846
nosetests.xml
4947
coverage.xml

admin_notifications/__init__.py

Whitespace-only changes.

admin_notifications/helpers/__init__.py

Whitespace-only changes.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from admin_notifications.models import AdminNotification
2+
from api.location.models import Location
3+
from datetime import datetime
4+
5+
6+
def update_notification(notification_id):
7+
notification = AdminNotification.query.filter_by(id=notification_id).first()
8+
notification.date_received = datetime.now()
9+
notification.save()
10+
11+
12+
def create_notification(title, message, location_id):
13+
"""
14+
Create notifications in the database and emit them to the client
15+
"""
16+
from manage import socketio
17+
location = Location.query.filter_by(id=location_id).first()
18+
location_name = location.name
19+
notification = AdminNotification(
20+
title=title,
21+
message=message,
22+
location_id=location_id,
23+
status="unread"
24+
)
25+
notification.save()
26+
new_notification = {"title": title, "message": message}
27+
return socketio.emit(
28+
f"notifications-{location_name}",
29+
{'notification': new_notification},
30+
broadcast=True,
31+
callback=update_notification(notification.id)
32+
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from datetime import datetime
2+
from api.devices.models import Devices as DevicesModel
3+
from utilities.utility import update_entity_fields
4+
from admin_notifications.helpers.create_notification import create_notification
5+
from admin_notifications.helpers.notification_templates import device_offline_notification # noqa 501
6+
import celery
7+
8+
9+
@celery.task(name='check-device-last-seen')
10+
def notify_when_device_is_offline():
11+
"""Asynchronous method that checks whether a device's last seen is greater\
12+
than 24hours, turns them to offline and subsequently notify's
13+
"""
14+
query = DevicesModel.query
15+
online_devices = query.filter(DevicesModel.activity == "online").all()
16+
for device in online_devices:
17+
device_last_seen = device.last_seen
18+
current_time = datetime.now()
19+
duration_offline = current_time - device_last_seen
20+
21+
if duration_offline.days > 1:
22+
update_entity_fields(device, activity="offline")
23+
device.save()
24+
25+
room_name = device.room.name
26+
room_id = device.room.id
27+
notification_payload = device_offline_notification(
28+
room_name, room_id)
29+
create_notification(title=notification_payload['title'],
30+
message=notification_payload['message'],
31+
location_id=device.room.location_id)
32+
33+
return online_devices
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
2+
def device_offline_notification(room_name, room_id):
3+
"""Notification message when device has been offline for a while"""
4+
return {
5+
"title": "Device is offline",
6+
"message": f"A device in {room_name} roomid:{room_id} is offline."
7+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from datetime import timedelta
2+
"""Celery beat schedule that checks a device's last seen every 24 hours"""
3+
beat_schedule = {
4+
'run-check-device-last-seen-hourly': {
5+
'task': 'check-device-last-seen',
6+
'schedule': timedelta(hours=1)
7+
}
8+
}

admin_notifications/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from sqlalchemy import (Column, String, Enum, Integer, ForeignKey)
2+
from helpers.database import Base
3+
from utilities.utility import Utility, StatusType
4+
5+
6+
class AdminNotification(Base, Utility):
7+
__tablename__ = 'admin_notifications'
8+
9+
id = Column(Integer, primary_key=True) # noqa
10+
title = Column(String, nullable=True)
11+
message = Column(String, nullable=True)
12+
date_received = Column(String, nullable=True)
13+
date_read = Column(String, nullable=True)
14+
status = Column(Enum(StatusType), default="unread")
15+
location_id = Column(
16+
Integer,
17+
ForeignKey('locations.id', ondelete="CASCADE"),
18+
nullable=True)

admin_notifications/socket_handler.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from flask_socketio import send
2+
from admin_notifications.models import AdminNotification
3+
4+
5+
def serialize_message(notification):
6+
return {
7+
"title": notification.title,
8+
"message": notification.message,
9+
}
10+
11+
12+
def send_notifications():
13+
query = AdminNotification.query
14+
notifications = query.filter_by(status="unread").all()
15+
notifications = [serialize_message(notification)
16+
for notification in notifications]
17+
return send(notifications, broadcast=True)

alembic/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from api.response.models import Response
4646
from api.tag.models import Tag
4747
from api.structure.models import Structure
48-
48+
from admin_notifications.models import AdminNotification
4949

5050
target_metadata = Base.metadata
5151

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""add activity column to devices table
2+
3+
Revision ID: 79ef610dbd41
4+
Revises: af8e4f84b552
5+
Create Date: 2019-06-28 08:05:37.542613
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '79ef610dbd41'
15+
down_revision = 'af8e4f84b552'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
activitytype = postgresql.ENUM(
23+
'online', 'offline', name='activitytype')
24+
activitytype.create(op.get_bind())
25+
op.add_column('devices', sa.Column('activity', sa.Enum(
26+
'online', 'offline', name='activitytype'), nullable=True))
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade():
31+
# ### commands auto generated by Alembic - please adjust! ###
32+
op.drop_column('devices', 'activity')
33+
# ### end Alembic commands ###

api/devices/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
from helpers.database import Base
66
from utilities.validations import validate_empty_fields
7-
from utilities.utility import Utility, StateType
7+
from utilities.utility import Utility, StateType, ActivityType
88

99

1010
class Devices(Base, Utility):
1111
__tablename__ = 'devices'
12-
id = Column(Integer, Sequence('devices_id_seq', start=1, increment=1), primary_key=True) # noqa
12+
id = Column(Integer, Sequence('devices_id_seq', start=1, increment=1), primary_key=True) # noqa
1313
name = Column(String, nullable=False)
1414
device_type = Column(String, nullable=False)
1515
date_added = Column(DateTime, nullable=False)
@@ -18,6 +18,7 @@ class Devices(Base, Utility):
1818
room_id = Column(Integer, ForeignKey('rooms.id', ondelete="CASCADE"))
1919
room = relationship('Room')
2020
state = Column(Enum(StateType), default="active")
21+
activity = Column(Enum(ActivityType), default="online")
2122

2223
def __init__(self, **kwargs):
2324
validate_empty_fields(**kwargs)

api/devices/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def mutate(self, info, **kwargs):
4141
room_location = location_join_room().filter(
4242
RoomModel.id == kwargs['room_id'],
4343
RoomModel.state == 'active'
44-
).first()
44+
).first()
4545
if not room_location:
4646
raise GraphQLError("Room not found")
4747
user = get_user_from_db()

celerybeat.pid

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
35

cworker.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
import os
2-
32
from celery import Celery
43
from app import create_app
5-
4+
from admin_notifications.helpers.queue_manager import beat_schedule
65

76
app = create_app(os.getenv('APP_SETTINGS') or 'default')
87
app.app_context().push()
98

109

10+
app.config.update(
11+
CELERY_BROKER_URL=os.getenv('CELERY_BROKER_URL'),
12+
CELERY_RESULT_BACKEND=os.getenv('CELERY_RESULT_BACKEND'),
13+
CELERY_ACCEPT_CONTENT=['pickle'],
14+
CELERYBEAT_SCHEDULE=beat_schedule
15+
)
16+
17+
1118
def make_celery(app):
12-
celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'])
19+
celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL'], include=['admin_notifications.helpers.device_last_seen', 'admin_notifications.helpers.create_notification'], # noqa 501
20+
backend=app.config['CELERY_BROKER_URL'])
21+
1322
celery.conf.update(app.config)
23+
celery.conf.enable_utc = False
1424
TaskBase = celery.Task
1525

1626
class ContextTask(TaskBase):
@@ -24,3 +34,6 @@ def __call__(self, *args, **kwargs):
2434

2535

2636
celery = make_celery(app)
37+
38+
celery_scheduler = Celery(app.name, broker=app.config['CELERY_BROKER_URL'])
39+
celery_scheduler.conf.enable_utc = False

docker/dev/start_redis.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
#done
55
cd /app
66
export $(cat .env | xargs)
7-
celery worker -A cworker.celery --loglevel=info
7+
celery worker -A cworker.celery --loglevel=info &
8+
celery -A cworker.celery beat -l info

docker/prod/start_redis.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/bin/bash
22
cd /app
33
export $(cat .env | xargs)
4-
celery worker -A cworker.celery --loglevel=info
4+
celery worker -A cworker.celery --loglevel=info &
5+
celery -A cworker.celery beat -l info

fixtures/devices/devices_fixtures.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@
4949
"name": "Samsung"
5050
}
5151
]
52-
}
5352
}
53+
}
5454

5555
query_device = '''
5656
{
@@ -289,7 +289,7 @@
289289
'id': '1',
290290
'name': 'Samsung',
291291
'deviceType': 'External Display'
292-
}]
293-
}
292+
}]
294293
}
294+
}
295295
devices_query_response = b'{"data":{"createDevice":{"device":{"name":"Apple tablet","location":"Kampala","deviceType":"External Display"}}}}' # noqaE501

helpers/email/email_setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(
3030
html=self.template,
3131
sender=self.sender)
3232

33-
@celery.task
33+
@celery.task(name='asynchronous-email-notifications')
3434
def send_async_email(msg_dict):
3535
mail = Mail()
3636
msg = Message()

manage.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
import bugsnag
44
from flask_script import Manager, Shell
55
from bugsnag.flask import handle_exceptions
6+
from flask_socketio import SocketIO
67

78

89
# Configure bugnsag
910
bugsnag.configure(
10-
api_key=os.getenv('BUGSNAG_API_TOKEN'),
11-
release_stage="development",
12-
project_root="app"
11+
api_key=os.getenv('BUGSNAG_API_TOKEN'),
12+
release_stage="development",
13+
project_root="app"
1314
)
1415

1516
# local imports
@@ -18,6 +19,7 @@
1819
app = create_app(os.getenv('APP_SETTINGS') or 'default')
1920
handle_exceptions(app)
2021
manager = Manager(app)
22+
socketio = SocketIO(app)
2123

2224

2325
def make_shell_context():
@@ -30,4 +32,5 @@ def make_shell_context():
3032

3133

3234
if __name__ == '__main__':
35+
socketio.run(app, debug=True)
3336
manager.run()

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Flask-JSON==0.3.2
99
Flask-Script==2.0.6
1010
Flask-GraphQL==1.4.1
1111
Flask-Mail==0.9.1
12+
Flask-SocketIO==4.1.0
1213
google-api-python-client==1.6.7
1314
graphene-sqlalchemy==2.0.0
1415
graphene==2.1

tests/test_admin_notification/__init__.py

Whitespace-only changes.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from tests.base import BaseTestCase
2+
from fixtures.token.token_fixture import ADMIN_TOKEN
3+
from fixtures.devices.devices_fixtures import devices_query
4+
from admin_notifications.helpers.device_last_seen import (
5+
notify_when_device_is_offline)
6+
7+
8+
class TestDeviceOffline(BaseTestCase):
9+
def test_when_device_is_offline(self):
10+
"""
11+
Testing for device creation
12+
"""
13+
headers = {"Authorization": "Bearer" + " " + ADMIN_TOKEN}
14+
self.app_test.post(devices_query, headers=headers)
15+
response = notify_when_device_is_offline()
16+
assert response[0].activity.value == 'offline'

0 commit comments

Comments
 (0)