From fc948c68353d9a5cb1647dc9e6af101da4c7f514 Mon Sep 17 00:00:00 2001 From: jordanreedie Date: Wed, 24 Oct 2018 15:48:22 -0400 Subject: [PATCH] admin dashboard (#48) Admin dashboard for easily viewing key information about your project Currently set up to view all projects at once, because we don't normally have multiple projects running on a server when we do hub deployments. Shouldn't be too difficult to implement if necessary, though. Fields and metrics displayed are very easily configurable - open to any suggestions --- config/settings/common.py | 11 ++ config/urls.py | 2 + openbadge-server/openbadge/admin.py | 4 +- openbadge-server/openbadge/dashboard.py | 163 ++++++++++++++++++ .../migrations/0010_auto_20180816_2310.py | 33 ++++ openbadge-server/openbadge/models.py | 17 +- openbadge-server/openbadge/serializers.py | 13 +- requirements/base.txt | 2 + 8 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 openbadge-server/openbadge/dashboard.py create mode 100644 openbadge-server/openbadge/migrations/0010_auto_20180816_2310.py diff --git a/config/settings/common.py b/config/settings/common.py index 0f268f7..9d18ae5 100644 --- a/config/settings/common.py +++ b/config/settings/common.py @@ -27,6 +27,7 @@ 'rest_framework', 'rest_framework.authtoken', 'rest_framework_expiring_authtoken', + 'controlcenter', 'import_export', ) DJANGO_APPS = ( @@ -47,6 +48,10 @@ # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = THIRD_PARTY_APPS + DJANGO_APPS + LOCAL_APPS +CONTROLCENTER_DASHBOARDS = ( + ('dash', 'openbadge.dashboard.BadgeDashboard'), +) + # MIDDLEWARE CONFIGURATION # ------------------------------------------------------------------------------ MIDDLEWARE_CLASSES = ( @@ -191,3 +196,9 @@ DJANGO_SECURE_SSL_REDIRECT=False # django-allauth DJANGO_ACCOUNT_ALLOW_REGISTRATION=True + +LOW_VOLTAGE=2.7 +UNSYNC_CUTOFF_HOURS=24 +NUM_UNSYNCS=2 +LAST_SEEN_CUTOFF_SHORT_HOURS=2 +LAST_SEEN_CUTOFF_LONG_HOURS=6 diff --git a/config/urls.py b/config/urls.py index 9155ff6..4b889b5 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,10 +3,12 @@ from django.conf import settings from django.conf.urls.static import static from settings import common as settings +from controlcenter.views import controlcenter urlpatterns = [ url(r'^grappelli/', include('grappelli.urls')), url(r'^admin/', include(admin.site.urls)), + url(r'^dashboard/', include(controlcenter.urls)), url(r'', include('openbadge.urls',namespace='openbadge')), diff --git a/openbadge-server/openbadge/admin.py b/openbadge-server/openbadge/admin.py index 5aaa970..dedc9a0 100644 --- a/openbadge-server/openbadge/admin.py +++ b/openbadge-server/openbadge/admin.py @@ -55,7 +55,7 @@ class MemberInline(admin.TabularInline, GetLocalTimeMixin): 'email') readonly_fields = ('key', 'id', 'observed_id', 'last_voltage', 'last_seen', 'last_audio','last_proximity', - 'last_contacted','last_unsync') + 'last_contacted','last_unsync', 'last_unsync_ts') def last_seen(self, obj): return self.get_local_time(obj.last_seen_ts) @@ -156,7 +156,7 @@ def time_diff(x): @register(Member) class MemberAdmin(ImportExportModelAdmin, GetLocalTimeMixin): readonly_fields = ('key', 'id', 'observed_id', 'last_seen', 'last_audio', 'last_proximity', - 'last_contacted', 'last_unsync',) + 'last_contacted', 'last_unsync', 'last_unsync_ts') list_display = ( 'key','project' , 'id', 'name', 'badge', 'observed_id', 'last_voltage', 'last_seen', 'last_seen_ts', 'last_audio', 'last_audio_ts', 'last_audio_ts_fract', diff --git a/openbadge-server/openbadge/dashboard.py b/openbadge-server/openbadge/dashboard.py new file mode 100644 index 0000000..9176558 --- /dev/null +++ b/openbadge-server/openbadge/dashboard.py @@ -0,0 +1,163 @@ +from controlcenter import Dashboard, widgets +from .models import Member, Unsync, Hub, Beacon +from django.conf import settings +from django.db.models import Count +from datetime import datetime +import time +import pytz +from pytz import timezone + + +def hours_to_secs(hrs): + return hrs * 60 * 60 + +def secs_to_hours(secs): + return (secs / 60) / 60 + +def secs_to_minutes(secs): + return round((secs / 60), 1) + +def cutoff_to_ts(cutoff): + # cutoff is in hours + return time.time() - hours_to_secs(cutoff) + +def timestamp_to_date(ts): + return (pytz.utc.localize(datetime.utcfromtimestamp(ts)) + .astimezone(timezone(settings.TIME_ZONE)) + .strftime('%Y-%m-%d %H:%M:%S %Z')) + + + +class BaseItemList(widgets.ItemList): + + def last_seen_date(self, obj): + if (obj.last_seen_ts is not None and obj.last_seen_ts != 0): + return timestamp_to_date(int(obj.last_seen_ts)) + else: + return "Not yet seen" + + last_seen_date.short_description = "Last Seen" + + def last_unsync_date(self, obj): + if (obj.last_unsync_ts is not None and obj.last_unsync_ts != 0): + return timestamp_to_date(int(obj.last_unsync_ts)) + else: + return "No Unsyncs Recorded" + + last_unsync_date.short_description = "Last Unsync" + +class LowVoltageMembers(BaseItemList): + + model = Member + list_display = ('id', 'name', 'last_seen_date', 'last_voltage', 'last_unsync_date') + width = widgets.LARGE + sortable = True + limit_to = None + height = 400 + + def get_queryset(self): + return (self.model.objects + .filter(active=True) + .filter(last_voltage__lt=settings.LOW_VOLTAGE) + .order_by('last_voltage')) + + +class ManyResetMembers(BaseItemList): + model = Unsync + title = "MEMBERS WITH MULTIPLE RESETS WITHIN {} HOURS".format(settings.UNSYNC_CUTOFF_HOURS) + width = widgets.LARGE + sortable = True + + # get all unsyncs since cutoff + def get_queryset(self): + return (self.model.objects + .filter(unsync_ts__gt=cutoff_to_ts(settings.UNSYNC_CUTOFF_HOURS)) + .values('member__id', 'member__key', 'member__name', 'member__last_voltage') + .annotate(num_unsyncs=Count('member__id')) + .filter(num_unsyncs__gte=settings.NUM_UNSYNCS) + .order_by('-num_unsyncs')) + + list_display = ('member__id', 'member__key', 'member__name', 'num_unsyncs', 'member__last_voltage') + + +class ThingNotSeen(BaseItemList): + limit_to = None + width = widgets.LARGE + sortable = True + + def minutes_since_last_seen(self, obj): + if (obj.last_seen_ts and obj.last_seen_ts != 0): + return secs_to_minutes(time.time() - int(obj.last_seen_ts)) + else: + return "Not yet seen" + + def cutoff_long(self): + return cutoff_to_ts(settings.LAST_SEEN_CUTOFF_LONG_HOURS) + + def cutoff_short(self): + return cutoff_to_ts(settings.LAST_SEEN_CUTOFF_SHORT_HOURS) + + minutes_since_last_seen.short_description = "minutes since last seen" + +class HubsNotSeen(ThingNotSeen): + model = Hub + title = "HUBS NOT SEEN IN {} HOURS".format(settings.LAST_SEEN_CUTOFF_SHORT_HOURS) + list_display = ('id', 'name', 'last_seen_date', 'minutes_since_last_seen') + + def get_queryset(self): + return self.model.objects.filter(last_seen_ts__lt=self.cutoff_short()) + + +class BeaconsNotSeen(ThingNotSeen): + model = Beacon + title = "BEACONS NOT SEEN IN {} HOURS".format(settings.LAST_SEEN_CUTOFF_SHORT_HOURS) + list_display = ('id', 'name', 'last_seen_date', 'last_voltage', 'minutes_since_last_seen') + + def get_queryset(self): + return (self.model.objects + .filter(last_seen_ts__lt=self.cutoff_short()) + .filter(active=True) + .order_by('last_seen_ts')) + + +class MembersNotSeenShort(ThingNotSeen): + model = Member + title = "MEMBERS NOT SEEN IN {} HOURS".format(settings.LAST_SEEN_CUTOFF_SHORT_HOURS) + list_display = ('id', 'name', 'last_seen_date', 'minutes_since_last_seen', 'last_voltage', 'last_unsync_date') + + def get_queryset(self): + return (self.model.objects + .filter(last_seen_ts__lt=self.cutoff_short()) + .filter(active=True) + .order_by('last_seen_ts')) + + +class MembersNotSeenLong(ThingNotSeen): + model = Member + title = "MEMBERS NOT SEEN IN {} HOURS".format(settings.LAST_SEEN_CUTOFF_LONG_HOURS) + list_display = ('id', 'name', 'last_seen_date', 'minutes_since_last_seen', 'last_voltage', 'last_unsync_date') + + def get_queryset(self): + return (self.model.objects + .filter(last_seen_ts__lt=self.cutoff_long()) + .filter(active=True) + .order_by('last_seen_ts')) + + +class MembersAll(ThingNotSeen): + model = Member + title = "ALL MEMBERS" + list_display = ('id', 'name', 'last_seen_date', 'minutes_since_last_seen', 'last_voltage', 'last_unsync_date') + + def get_queryset(self): + return self.model.objects.filter(active=True) + + +class BadgeDashboard(Dashboard): + widgets = ( + ManyResetMembers, + LowVoltageMembers, + (MembersNotSeenShort, MembersNotSeenLong, MembersAll), + BeaconsNotSeen, + HubsNotSeen + ) diff --git a/openbadge-server/openbadge/migrations/0010_auto_20180816_2310.py b/openbadge-server/openbadge/migrations/0010_auto_20180816_2310.py new file mode 100644 index 0000000..92798cf --- /dev/null +++ b/openbadge-server/openbadge/migrations/0010_auto_20180816_2310.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import openbadge.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('openbadge', '0009_hub_all_ip_addresses'), + ] + + operations = [ + migrations.CreateModel( + name='Unsync', + fields=[ + ('id', models.AutoField(serialize=False, primary_key=True)), + ('key', models.CharField(db_index=True, unique=True, max_length=10, blank=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_updated', models.DateTimeField(auto_now=True)), + ('unsync_ts', models.DecimalField(default=openbadge.models._now_as_epoch, max_digits=20, decimal_places=3)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='unsync', + name='member', + field=models.ForeignKey(related_name='unsyncs', to='openbadge.Member'), + ), + ] diff --git a/openbadge-server/openbadge/models.py b/openbadge-server/openbadge/models.py index 0b54c8e..5ad4527 100644 --- a/openbadge-server/openbadge/models.py +++ b/openbadge-server/openbadge/models.py @@ -286,12 +286,13 @@ class Member(BaseModelMinimal): last_audio_ts_fract = models.DecimalField(max_digits=20, decimal_places=3, default=Decimal(0)) last_proximity_ts = models.DecimalField(max_digits=20, decimal_places=3, default=_now_as_epoch) last_contacted_ts = models.DecimalField(max_digits=20, decimal_places=3, default=Decimal(0)) - last_unsync_ts = models.DecimalField(max_digits=20, decimal_places=3, default=Decimal(0)) last_voltage = models.DecimalField(max_digits=5, decimal_places=3, default=Decimal(0)) last_seen_ts = models.DecimalField(max_digits=20, decimal_places=3, default=Decimal(0)) + last_unsync_ts = models.DecimalField(max_digits=20, decimal_places=3, default=Decimal(0)) project = models.ForeignKey(Project, related_name="members") + def get_advertisement_project_id(self): return self.project.advertisement_project_id @@ -316,7 +317,7 @@ def datetime_to_epoch(cls, d): """ epoch_seconds = (d - datetime.datetime(1970, 1, 1)).total_seconds() long_epoch_seconds = long(floor(epoch_seconds)) - ts_fract = d.microsecond / 1000; + ts_fract = d.microsecond / 1000 return (long_epoch_seconds, ts_fract) @classmethod @@ -376,6 +377,18 @@ def __unicode__(self): return unicode(self.name) +class Unsync(BaseModel): + """ + Represents a single time in which a badge became unsynced / restarted + + Many-to-one relationship w/ a Member + """ + + + member = models.ForeignKey(Member, related_name='unsyncs') + unsync_ts = models.DecimalField(max_digits=20, decimal_places=3, default=_now_as_epoch, db_index=True) + + class Meeting(BaseModel): """ Represents a Meeting, which belongs to a Project, and has a log_file and a last_update_time, among other standard diff --git a/openbadge-server/openbadge/serializers.py b/openbadge-server/openbadge/serializers.py index f5733d8..0ea71e8 100644 --- a/openbadge-server/openbadge/serializers.py +++ b/openbadge-server/openbadge/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers import time -from .models import Member, Project, Hub, Beacon +from .models import Member, Project, Hub, Beacon, Unsync import logging @@ -19,6 +19,8 @@ class Meta: read_only_fields = ('id','project','advertisement_project_id', 'key') def update(self, instance, validated_data): + # instance is the current Member stored in the DB, + # validated_data is the incoming data # if we have an older audio_ts, update it if validated_data.get('last_audio_ts') > instance.last_audio_ts: @@ -41,7 +43,16 @@ def update(self, instance, validated_data): if validated_data.get('last_contacted_ts') > instance.last_contacted_ts: instance.last_contacted_ts = validated_data.get('last_contacted_ts', instance.last_contacted_ts) + # need to record all unsyncs + # if this is not performant we can maintain both the last unsync + # in the member object as well as a separate table of unsync history if validated_data.get('last_unsync_ts') > instance.last_unsync_ts: + # create an unsync obj to keep a history of all unsyncs + Unsync.objects.create( + member=instance, + unsync_ts=validated_data.get('last_unsync_ts', instance.last_unsync_ts)) + + # save the latest ts in the member obj instance.last_unsync_ts = validated_data.get('last_unsync_ts', instance.last_unsync_ts) instance.observed_id = validated_data.get('observed_id', instance.observed_id) diff --git a/requirements/base.txt b/requirements/base.txt index 92b8d84..94a768f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -9,6 +9,8 @@ djangorestframework-expiring-authtoken==0.1.1 pytz==2015.7 python-dateutil==2.5.3 jsonfield==1.0.3 +django-controlcenter===0.2.6 + # Configuration django-environ==0.4.1