diff --git a/applications/models.py b/applications/models.py index 189ce917460ac3b3462a9b37e7edc5f8eca9aec2..33fc2eca58c5c78c0dce1483ae5f9d81a5526f34 100644 --- a/applications/models.py +++ b/applications/models.py @@ -8,9 +8,10 @@ from django.utils.text import slugify from rest_framework.authtoken.models import Token from core.models import BaseAppDataModel, SoftDeletionManager +from provenance.models import ProvAbleModel, ProvApplicationModel -class Application(BaseAppDataModel): +class Application(ProvAbleModel, ProvApplicationModel, BaseAppDataModel): """ Manage the state of and access to an external application. diff --git a/datasources/models.py b/datasources/models.py index 59427541b2da0f3b1a103ae7e54a2292a0b53a40..e7c1b70fcfbba5e81ebb70ab6a16b56fd0097c4d 100644 --- a/datasources/models.py +++ b/datasources/models.py @@ -11,11 +11,10 @@ from django.conf import settings from django.core import validators from django.db import models from django.urls import reverse -import requests -import requests.exceptions from core.models import BaseAppDataModel, MAX_LENGTH_API_KEY, MAX_LENGTH_NAME, MAX_LENGTH_PATH, SoftDeletionManager from datasources.connectors.base import AuthMethod, BaseDataConnector, REQUEST_AUTH_FUNCTIONS +from provenance.models import ProvAbleModel #: Length of request reason field - must include brief description of project MAX_LENGTH_REASON = 511 @@ -207,7 +206,7 @@ class UserPermissionLink(models.Model): unique_together = (('user', 'datasource'),) -class DataSource(BaseAppDataModel): +class DataSource(ProvAbleModel, BaseAppDataModel): """ Manage access to a data source API. @@ -305,7 +304,7 @@ class DataSource(BaseAppDataModel): # TODO avoid determining auth method if existing one still works self.auth_method = self.data_connector_class.determine_auth_method(self.url, self.api_key) - return super().save(*args, **kwargs) + super().save(*args, **kwargs) def delete(self, using=None, keep_parents=False): """ diff --git a/provenance/models.py b/provenance/models.py index f1aa4a711ecd30a171297abba1af89de88ef8430..e94d04867341e9af939cb6b1ac773bccb24be8ec 100644 --- a/provenance/models.py +++ b/provenance/models.py @@ -13,8 +13,7 @@ import uuid from django import apps from django.contrib.contenttypes.models import ContentType -from django.db.models import QuerySet, signals -from django.dispatch import receiver +from django.db.models import QuerySet from django.utils import timezone from django.utils.text import slugify @@ -22,27 +21,29 @@ import mongoengine from mongoengine.queryset.visitor import Q import prov.model -from applications.models import Application from core.models import BaseAppDataModel -from datasources.models import DataSource MAX_LENGTH_NAME_FIELD = 100 -class PedasiDummyApplication: +class ProvApplicationModel: """ Dummy application model to fall back to when an action was performed via the PEDASI web interface. + + Also to be used as parent class of :class:`Application` to help with type hinting. """ - name = 'PEDASI' - pk = 'pedasi' # We convert pk to string anyway - so this is fine + def __init__(self, *args, **kwargs): + self.pk = kwargs.get('pk', 'pedasi') + self.name = kwargs.get('name', 'PEDASI') + + super().__init__(*args, **kwargs) - @staticmethod - def get_absolute_url(): + def get_absolute_url(self): """ Return the URL at which PEDASI is hosted. """ # TODO don't hardcode URL - return 'http://www.pedasi-iot.org/' + return 'https://dev.iotobservatory.io/' @enum.unique @@ -66,7 +67,7 @@ class ProvEntry(mongoengine.DynamicDocument): def create_prov(cls, instance: BaseAppDataModel, user_uri: str, - application: typing.Optional[Application] = None, + application: typing.Optional[ProvApplicationModel] = None, activity_type: typing.Optional[ProvActivity] = ProvActivity.UPDATE) -> 'ProvEntry': """ Build a PROV document representing a particular activity within PEDASI. @@ -116,7 +117,7 @@ class ProvEntry(mongoengine.DynamicDocument): ) if application is None: - application = PedasiDummyApplication + application = ProvApplicationModel() agent_application = document.agent( 'piot:app-' + str(application.pk), @@ -217,7 +218,7 @@ class ProvWrapper(mongoengine.Document): def create_prov(cls, instance: BaseAppDataModel, user_uri: str, - application: typing.Optional[Application] = None, + application: typing.Optional[ProvApplicationModel] = None, activity_type: typing.Optional[ProvActivity] = ProvActivity.UPDATE) -> ProvEntry: """ Create a PROV record for a single action. @@ -251,15 +252,34 @@ class ProvWrapper(mongoengine.Document): super().delete(signal_kwargs, **write_concern) -@receiver(signals.post_save, sender=Application) -@receiver(signals.post_save, sender=DataSource) -def save_prov(sender, instance, **kwargs): +class ProvAbleModel: """ - Signal receiver to create a :class:`ProvEntry` when a PROV tracked model is saved. + Mixin for models which are capable of having updates tracked by PROV records. + + Creates a new PROV record every time the object is modified and saved. """ - ProvWrapper.create_prov( - instance, - # TODO what if an admin edits a model? - instance.owner.get_uri(), - activity_type=ProvActivity.UPDATE - ) + def save(self, *args, **kwargs): + try: + # Have to read existing saved version from database + existing = type(self).objects.get(pk=self.pk) + changed = False + + for field in self._meta.fields: + attr = field.attname + + if getattr(existing, attr) != getattr(self, attr): + changed = True + break + + except type(self).DoesNotExist: + # First time this object has been saved + changed = True + + super().save(*args, **kwargs) + + if changed: + ProvWrapper.create_prov( + self, + self.owner.get_uri(), + activity_type=ProvActivity.UPDATE + ) diff --git a/provenance/tests.py b/provenance/tests.py index 53e2bd6e460c47b29b32d2d3a8eabfadcabe2bb8..8ed6f443eb0ccb5001269e04b3883339c148a851 100644 --- a/provenance/tests.py +++ b/provenance/tests.py @@ -4,7 +4,6 @@ Tests for PROV tracking functionality and the models required to support it. import json import pathlib -import unittest from django.conf import settings from django.contrib.auth import get_user_model @@ -15,6 +14,7 @@ import jsonschema import mongoengine from mongoengine.queryset.visitor import Q +from applications.models import Application from datasources.models import DataSource from provenance import models @@ -134,19 +134,6 @@ class ProvWrapperTest(TestCase): # Another PROV record should be created when model is changed and saved self.assertEqual(self._count_prov(self.datasource), n_provs + 1) - @unittest.expectedFailure - def test_prov_datasource_no_update(self): - """ - Test that a new :class:`ProvEntry` is not created when a model is saved without changes. - """ - n_provs = self._count_prov(self.datasource) - - self.datasource.save() - - # Another PROV record should be created when model is changed and saved - self.assertEqual(self._count_prov(self.datasource), n_provs) - - @unittest.expectedFailure def test_prov_datasource_null_update(self): """ Test that no new :class:`ProvEntry` is created when a model is saved without changes. @@ -173,3 +160,86 @@ class ProvWrapperTest(TestCase): intersection = set(prov_entries).intersection(new_prov_entries) self.assertFalse(intersection) + + +class ProvApplicationTest(TestCase): + """ + Test the wrapper that allows us to look up :class:`ProvEntry`s for a given Application. + """ + model = Application + + @classmethod + def setUpTestData(cls): + cls.user_model = get_user_model() + cls.user = cls.user_model.objects.create_user('Test Prov User') + + def setUp(self): + self.object = self.model.objects.create( + name='Test Object', + url='http://www.example.com', + owner=self.user, + ) + + def tearDown(self): + # Have to delete instance manually since we're not using Django's database manager + object_type = ContentType.objects.get_for_model(self.model) + + models.ProvWrapper.objects( + Q(app_label=object_type.app_label) & + Q(model_name=object_type.model) & + Q(related_pk=self.object.pk) + ).delete() + + @staticmethod + def _count_prov(obj) -> int: + """ + Count PROV records for a given object. + """ + prov_entries = models.ProvWrapper.filter_model_instance(obj) + return prov_entries.count() + + def test_prov_application_create(self): + """ + Test that a :class:`ProvEntry` is created when a model is created. + """ + # PROV record should be created when model is created + self.assertEqual(self._count_prov(self.object), 1) + + def test_prov_application_update(self): + """ + Test that a new :class:`ProvEntry` is created when a model is updated. + """ + n_provs = self._count_prov(self.object) + + self.object.url = 'http://example.com' + self.object.save() + + # Another PROV record should be created when model is changed and saved + self.assertEqual(self._count_prov(self.object), n_provs + 1) + + def test_prov_application_null_update(self): + """ + Test that no new :class:`ProvEntry` is created when a model is saved without changes. + """ + n_provs = self._count_prov(self.object) + + self.object.save() + + # No PROV record should be created when saving a model that has not changed + self.assertEqual(self._count_prov(self.object), n_provs) + + def test_prov_records_distinct(self): + """ + Test that :class:`ProvEntry`s are not reused. + """ + prov_entries = models.ProvWrapper.filter_model_instance(self.object) + + new_object = self.model.objects.create( + name='Another Test Object', + url='http://www.example.com', + owner=self.user, + ) + new_prov_entries = models.ProvWrapper.filter_model_instance(new_object) + + intersection = set(prov_entries).intersection(new_prov_entries) + self.assertFalse(intersection)