diff --git a/awx/api/urls.py b/awx/api/urls.py index e868610231..47a470aaa2 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -141,6 +141,11 @@ job_event_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/hosts/$', 'job_event_hosts_list'), ) +# activity_stream_urls = patterns('awx.api.views', +# url(r'^$', 'activity_stream_list'), +# url(r'^(?P[0-9]+)/$', 'activity_stream_detail'), +# ) + v1_urls = patterns('awx.api.views', url(r'^$', 'api_v1_root_view'), url(r'^config/$', 'api_v1_config_view'), @@ -162,6 +167,7 @@ v1_urls = patterns('awx.api.views', url(r'^jobs/', include(job_urls)), url(r'^job_host_summaries/', include(job_host_summary_urls)), url(r'^job_events/', include(job_event_urls)), + # url(r'^activity_stream/', include(activity_stream_urls)), ) urlpatterns = patterns('awx.api.views', diff --git a/awx/main/middleware.py b/awx/main/middleware.py new file mode 100644 index 0000000000..33a1e51f50 --- /dev/null +++ b/awx/main/middleware.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.db.models.signals import pre_save +from django.utils.functional import curry +from awx.main.models import ActivityStream + + +class ActvitiyStreamMiddleware(object): + + def process_request(self, request): + if hasattr(request, 'user') and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated(): + user = request.user + else: + user = None + + set_actor = curry(self.set_actor, user) + pre_save.connect(set_actor, sender=ActivityStream, dispatch_uid=(self.__class__, request), weak=False) + + def process_response(self, request, response): + pre_save.disconnect(dispatch_uid=(self.__class__, request)) + return response + + def set_actor(self, user, sender, instance, **kwargs): + if sender == ActivityStream and isinstance(user, settings.AUTH_USER_MODEL) and instance.user is None: + instance.user = user diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index abb68b57da..e25752e403 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -27,3 +27,20 @@ User.add_to_class('can_access', check_user_access) # Import signal handlers only after models have been defined. import awx.main.signals + +activity_stream_registrar.connect(Organization) +activity_stream_registrar.connect(Inventory) +activity_stream_registrar.connect(Host) +activity_stream_registrar.connect(Group) +activity_stream_registrar.connect(InventorySource) +activity_stream_registrar.connect(InventoryUpdate) +activity_stream_registrar.connect(Credential) +activity_stream_registrar.connect(Team) +activity_stream_registrar.connect(Project) +activity_stream_registrar.connect(ProjectUpdate) +activity_stream_registrar.connect(Permission) +activity_stream_registrar.connect(JobTemplate) +activity_stream_registrar.connect(Job) +activity_stream_registrar.connect(JobHostSummary) +activity_stream_registrar.connect(JobEvent) +activity_stream_registrar.connect(Profile) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 776f500972..bddcb4be1a 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -333,3 +333,28 @@ class CommonTask(PrimordialModel): self.cancel_flag = True self.save(update_fields=['cancel_flag']) return self.cancel_flag + +class ActivityStream(models.Model): + ''' + Model used to describe activity stream (audit) events + ''' + OPERATION_CHOICES = [ + ('create', _('Entity Created')), + ('update', _("Entity Updated")), + ('delete', _("Entity Deleted")), + ('associate', _("Entity Associated with another Entity")), + ('disaassociate', _("Entity was Disassociated with another Entity")) + ] + + user = models.ForeignKey('auth.User', null=True, on_delete=SET_NULL) + operation = models.CharField(max_length=9, choices=OPERATION_CHOICES) + timestamp = models.DateTimeField(auto_now_add=True) + changes = models.TextField(blank=True) + + object1_id = models.PositiveIntegerField(db_index=True) + object1_type = models.TextField() + + object2_id = models.PositiveIntegerField(db_index=True) + object2_type = models.TextField() + + object_relationship_type = models.TextField() diff --git a/awx/main/registrar.py b/awx/main/registrar.py new file mode 100644 index 0000000000..f5c631a053 --- /dev/null +++ b/awx/main/registrar.py @@ -0,0 +1,34 @@ +from django.db.models.signals import pre_save, post_save, post_delete, m2m_changed +from signals import activity_stream_create, activity_stream_update, activity_stream_delete + +class ActivityStreamRegistrar(object): + + def __init__(self): + self.models = [] + + def connect(self, model): + #(receiver, sender=model, dispatch_uid=self._dispatch_uid(signal, model)) + if model not in self.models: + self.models.append(model) + post_save.connect(activity_stream_create, sender=model, dispatch_uid=self.__class__ + str(model) + "_create") + pre_save.connect(activity_stream_update, sender=model, dispatch_uid=self.__class__ + str(model) + "_update") + post_delete.connect(activity_stream_delete, sender=model, dispatch_uid=self.__class__ + str(model) + "_delete") + + for m2mfield in model._meta.many_to_many: + m2m_attr = get_attr(model, m2mfield.name) + m2m_changed.connect(activity_stream_associate, sender=m2m_attr.through, + dispatch_uid=self.__class__ + str(m2m_attr.through) + "_associate") + + def disconnect(self, model): + if model in self.models: + post_save.disconnect(dispatch_uid=self.__class__ + str(model) + "_create") + pre_save.disconnect(dispatch_uid=self.__class__ + str(model) + "_update") + post_delete.disconnect(dispatch_uid=self.__class__ + str(model) + "_delete") + self.models.pop(model) + + + for m2mfield in model._meta.many_to_many: + m2m_attr = get_attr(model, m2mfield.name) + m2m_changed.disconnect(dispatch_uid=self.__class__ + str(m2m_attr.through) + "_associate") + +activity_stream_registrar = ActivityStreamRegistrar() diff --git a/awx/main/signals.py b/awx/main/signals.py index 9bcf1bb9f3..1b77338cd9 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -11,6 +11,7 @@ from django.dispatch import receiver # AWX from awx.main.models import * +from awx.main.utils import model_instance_diff __all__ = [] @@ -168,3 +169,59 @@ def update_host_last_job_after_job_deleted(sender, **kwargs): hosts_pks = getattr(instance, '_saved_hosts_pks', []) for host in Host.objects.filter(pk__in=hosts_pks): _update_host_last_jhs(host) + +# Set via ActivityStreamRegistrar to record activity stream events + +def activity_stream_create(sender, instance, created, **kwargs): + if created: + activity_entry = ActivityStream( + operation='create', + object1_id=instance.id, + object1_type=instance.__class__) + activity_entry.save() + +def activity_stream_update(sender, instance, **kwargs): + try: + old = sender.objects.get(id=instance.id) + except sender.DoesNotExist: + pass + + new = instance + changes = model_instance_diff(old, new) + activity_entry = ActivityStream( + operation='update', + object1_id=instance.id, + object1_type=instance.__class__, + changes=json.dumps(changes)) + activity_entry.save() + + +def activity_stream_delete(sender, instance, **kwargs): + activity_entry = ActivityStream( + operation='delete', + object1_id=instance.id, + object1_type=instance.__class__) + activity_entry.save() + +def activity_stream_associate(sender, instance, **kwargs): + if 'pre_add' in kwargs['action'] or 'pre_remove' in kwargs['action']: + if kwargs['action'] == 'pre_add': + action = 'associate' + elif kwargs['action'] == 'pre_remove': + action = 'disassociate' + else: + return + obj1 = instance + obj1_id = obj1.id + obj_rel = str(sender) + for entity_acted in kwargs['pk_set']: + obj2 = entity_acted + obj2_id = entity_acted.id + activity_entry = ActivityStream( + operation=action, + object1_id=obj1_id, + object1_type=obj1.__class__, + object2_id=obj2_id, + object2_type=obj2.__class__, + object_relationship_type=obj_rel) + activity_entry.save() diff --git a/awx/main/utils.py b/awx/main/utils.py index 562884a346..3fdfa21aa2 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -10,6 +10,9 @@ import subprocess import sys import urlparse +# Django +from django.db.models import Model + # Django REST Framework from rest_framework.exceptions import ParseError, PermissionDenied @@ -219,3 +222,36 @@ def update_scm_url(scm_type, url, username=True, password=True): new_url = urlparse.urlunsplit([parts.scheme, netloc, parts.path, parts.query, parts.fragment]) return new_url + +def model_instance_diff(old, new): + """ + Calculate the differences between two model instances. One of the instances may be None (i.e., a newly + created model or deleted model). This will cause all fields with a value to have changed (from None). + """ + if not(old is None or isinstance(old, Model)): + raise TypeError('The supplied old instance is not a valid model instance.') + if not(new is None or isinstance(new, Model)): + raise TypeError('The supplied new instance is not a valid model instance.') + + diff = {} + + if old is not None and new is not None: + fields = set(old._meta.fields + new._meta.fields) + elif old is not None: + fields = set(old._meta.fields) + elif new is not None: + fields = set(new._meta.fields) + else: + fields = set() + + for field in fields: + old_value = str(getattr(old, field.name, None)) + new_value = str(getattr(new, field.name, None)) + + if old_value != new_value: + diff[field.name] = (old_value, new_value) + + if len(diff) == 0: + diff = None + + return diff diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index dac6a86714..80f8650411 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -105,6 +105,7 @@ TEMPLATE_CONTEXT_PROCESSORS += ( MIDDLEWARE_CLASSES += ( 'django.middleware.transaction.TransactionMiddleware', # Middleware loaded after this point will be subject to transactions. + 'awx.main.middleware.ActivityStreamMiddleware' ) TEMPLATE_DIRS = (