<reponame>20c/django-syncref
from django.core.exceptions import ValidationError

try:
    import reversion
except ImportError:
    reversion = None


class Version:

    """
    Object version interface - extend to support
    different types of django object versioning
    solutions
    """

    def __init__(self, version):

        """
        Argument(s):

            - version: version object instance, for example
                django reversion Version object

        """
        self.version = version

    @property
    def date(self):
        """
        Should return version date (datetime object)
        """
        raise NotImplementedError()

    @property
    def user(self):
        """
        Should return user (django User object) that commited the version
        or None if no such user exists
        """
        raise NotImplementedError()

    @property
    def id(self):
        """
        Should return the internal version id (primary
        key in the database)
        """
        raise NotImplementedError()

    @property
    def comment(self):
        """
        Should return comment (str) for the version
        or None if no such comment exists
        """
        raise NotImplementedError()

    @property
    def data(self):
        """
        Should return a dict of the object snapshot
        store in this version, with field names mapping
        to field values
        """
        raise NotImplementedError()

    @property
    def data_sorted(self):
        """
        Should return `data` in list form with (field, value)
        tuples as items, sorted by field
        """
        raise NotImplementedError()

    @property
    def model(self):
        """
        Should return the django model class of the object
        snapshotted by the version
        """
        raise NotImplementedError()

    @property
    def previous(self):
        """
        Should return a Version instance holding the previous
        version neighbour of this version

        If no previous version exists return an empty Version
        instance
        """
        raise NotImplementedError()

    @property
    def next(self):
        """
        Should return a Version instance holding the next
        version neighbour of this version

        if no next version exists return an empty Version
        instance
        """
        raise NotImplementedError()

    @classmethod
    def changes_summary(self, versions):
        """
        Compiles and return a changes summary of multiple
        Version objects.

        Argument(s):

            - versions(list): list of Version instances

        Returns:

            - list: a list of (field, dict) tuples where
                the dict will be mapping version ids to a
                dict containing changes made to the field
                by the version. Check `Diff` class for
                further information

        """

        changes = {}
        for version in versions:
            _changes = version.changes(version.previous)
            for field, diff in _changes.items():
                if field not in changes:
                    changes[field] = {}
                _diff = {"version": version}
                _diff.update(diff)
                changes[field].update({version.id: _diff})
        changes_list = list(changes.items())
        changes_list = sorted(changes_list, key=lambda i: i[0])
        return changes_list

    def changes(self, previous):
        """
        Return a `Diff` instance for this version
        and a previous version

        Argument(s):

            - previous(Version)

        Returns(s):

            - Diff

        """

        return Diff(previous, self).changes

    def changed_fields(self, previous):

        """
        Return a list of changed fields between
        this version and a previous version

        Argument(s):

            - previous(Version)

        Returns:

            - list(str)

        """

        changes = self.changes(previous)
        if changes is None:
            return None
        return sorted(changes.keys())


class ReversionVersion(Version):

    """
    Version abtraction for django-reversion
    """

    def __init__(self, version):
        """
        Argument(s):

            - Version(int|reversion.models.Version): can be either
                a reversion version instance or the id of one

        """
        if isinstance(version, int):
            version = reversion.models.Version.objects.get(id=version)
        super().__init__(version)

    @property
    def date(self):
        """
        Returns:

            - datetime: date of revision

        """
        return self.version.revision.date_created

    @property
    def user(self):
        """
        Returns:

            - User: user that authored revision
            - None: if no such user exists

        """
        return self.version.revision.user

    @property
    def comment(self):
        """
        Returns:

            - str: comment stored with revision
            - None: if no such comment exists

        """
        return self.version.revision.comment

    @property
    def id(self):
        """
        Returns:

            - int:version instance id

        """
        return self.version.id

    @property
    def data(self):
        """
        Returns:

            - dict: object data

        """
        return self.version.field_dict

    @property
    def model(self):
        """
        Returns:

            - model: django model for the object
                snapshotted by this version

        """
        return self.version._model

    @property
    def data_sorted(self):
        """
        Returns:

            - list: list of (field, value) tuples for
                object data

        """
        data = []

        for field, value in self.data.items():
            data.append((field, value))

        return sorted(data, key=lambda i: i[0])

    @property
    def previous(self):
        """
        Returns:

            - Version: previous version - if no previous version exists
                the Version instance will be empty

        """

        if hasattr(self, "_previous"):
            return self._previous

        versions = reversion.models.Version.objects.get_for_object(
            self.version.object
        ).order_by("-id")

        for version in versions:
            if version.id < self.version.id:
                self._previous = self.__class__(version)
                return self._previous

        return None

    @property
    def next(self):
        """
        Returns:

            - Version: next version - if no next version exists
                the Version instance will be empty

        """

        if hasattr(self, "_next"):
            return self._next
        qset = reversion.models.Version.objects.filter(
            content_type_id=self.version.content_type_id,
            object_id=self.version.object_id,
            id__gt=self.version.id,
        )
        qset = qset.order_by("id")
        self._next = self.__class__(qset.first())
        return self._next


class Diff:

    """
    Describes changes between two versions
    """

    # when generating diff ignore these fields

    diff_ignore_fields = [
        "version",
        "created",
        "updated",
    ]

    def __init__(self, version_a, version_b):

        """
        Argument(s):

            - version_a(Version): older version
            - version_b(Version): newer version

        """

        self.version_a = version_a
        self.version_b = version_b

    @property
    def changes(self):

        """
        Compile and return a dict describing changes between
        the two versions tracked in this diff

        Returns:

            - dict: dict mapping field names to a dict describing
              changed made to the field


              {
                  field_name: {
                      "old": old_value,
                      "changed": changed_value,
                  },
                  ...
              }

        """

        if not self.version_a or not self.version_b:
            return None

        if not self.version_a.version or not self.version_b.version:
            return None

        data_a = self.version_a.data
        data_b = self.version_b.data

        diff = {}

        for field, value_b in data_b.items():
            if field in self.diff_ignore_fields:
                continue

            value_a = data_a.get(field)

            if value_a == value_b:
                continue

            if isinstance(value_a, str) or isinstance(value_a, int):
                diff[field] = {"old": value_a, "changed": value_b}
            else:
                diff[field] = {
                    "old": self.format_value(value_a),
                    "changed": self.format_value(value_b),
                }

        return diff

    def format_value(self, value):
        return f"{value}"


class Reverter:

    """
    Allows to revert / rollback changes
    """

    def revert_fields(self, instance, field_versions, **kwargs):

        """
        Revert a set of fields

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - field_versions(dict): dict mapping field names to
              version object

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        for field, version in field_versions.items():
            setattr(instance, field, version.data[field])
            if field == "status":
                self.validate_status_change(instance, version.data[field])
        instance.full_clean()
        instance.save()

    def rollback(self, instance, version, **kwargs):

        """
        Rollback to a specific version

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - version(Version): version to roll back to

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        for field, value in version.data.items():
            if field in ["created", "updated", "version"]:
                continue
            if field == "status":
                self.validate_status_change(instance, value)
            setattr(instance, field, value)
        instance.full_clean()
        instance.save()

    def validate_status_change(self, instance, status):

        """
        Validate a status value change - this will make sure
        an object cannot be undeleted if a parent relationship
        is still flagged as deleted

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - status(str)

        """

        for field in instance.__class__._meta.get_fields():
            if not field.is_relation or not field.many_to_one:
                continue
            try:
                relation = getattr(instance, field.name)
            except Exception:
                continue
            self.validate_parent_status(instance, relation, status)

    def validate_parent_status(self, instance, parent, status):

        if not hasattr(parent, "HandleRef"):
            return

        if parent.status == "deleted" and status != "deleted":
            raise ValidationError(
                {
                    "non_field_errors": "Parent object {} is currently flagged as deleted."
                    "This object may not be undeleted while the parent "
                    "is still deleted.".format(parent)
                }
            )


class ReversionReverter(Reverter):

    """
    Reverter abstraction for django-reversion
    """

    def revert_fields(self, instance, field_versions, user=None):

        """
        Revert a set of fields

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - field_versions(dict): dict mapping field names to
              version pk

        Keyword Argument(s):

            - user(User): user that authored the revision

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        with reversion.create_revision():
            if user:
                reversion.set_user(user)
            version_ids = [
                "{}".format(version.data["version"])
                for version in field_versions.values()
            ]
            version_ids = list(set(version_ids))
            reversion.set_comment(
                "reverted some fields via versions: {}".format(", ".join(version_ids))
            )
            super().revert_fields(instance, field_versions)

    def rollback(self, instance, version, user=None):

        """
        Rollback to a specific version

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - version(Version): version to roll back to

        Keyword Argument(s):

            - user(User): user that authored the revision

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        with reversion.create_revision():
            if user:
                reversion.set_user(user)
            reversion.set_comment(
                "rollback to version {}".format(version.data["version"])
            )
            super().rollback(instance, version)

from django.core.exceptions import ValidationError

try:
    import reversion
except ImportError:
    reversion = None


class Version:

    """
    Object version interface - extend to support
    different types of django object versioning
    solutions
    """

    def __init__(self, version):

        """
        Argument(s):

            - version: version object instance, for example
                django reversion Version object

        """
        self.version = version

    @property
    def date(self):
        """
        Should return version date (datetime object)
        """
        raise NotImplementedError()

    @property
    def user(self):
        """
        Should return user (django User object) that commited the version
        or None if no such user exists
        """
        raise NotImplementedError()

    @property
    def id(self):
        """
        Should return the internal version id (primary
        key in the database)
        """
        raise NotImplementedError()

    @property
    def comment(self):
        """
        Should return comment (str) for the version
        or None if no such comment exists
        """
        raise NotImplementedError()

    @property
    def data(self):
        """
        Should return a dict of the object snapshot
        store in this version, with field names mapping
        to field values
        """
        raise NotImplementedError()

    @property
    def data_sorted(self):
        """
        Should return `data` in list form with (field, value)
        tuples as items, sorted by field
        """
        raise NotImplementedError()

    @property
    def model(self):
        """
        Should return the django model class of the object
        snapshotted by the version
        """
        raise NotImplementedError()

    @property
    def previous(self):
        """
        Should return a Version instance holding the previous
        version neighbour of this version

        If no previous version exists return an empty Version
        instance
        """
        raise NotImplementedError()

    @property
    def next(self):
        """
        Should return a Version instance holding the next
        version neighbour of this version

        if no next version exists return an empty Version
        instance
        """
        raise NotImplementedError()

    @classmethod
    def changes_summary(self, versions):
        """
        Compiles and return a changes summary of multiple
        Version objects.

        Argument(s):

            - versions(list): list of Version instances

        Returns:

            - list: a list of (field, dict) tuples where
                the dict will be mapping version ids to a
                dict containing changes made to the field
                by the version. Check `Diff` class for
                further information

        """

        changes = {}
        for version in versions:
            _changes = version.changes(version.previous)
            for field, diff in _changes.items():
                if field not in changes:
                    changes[field] = {}
                _diff = {"version": version}
                _diff.update(diff)
                changes[field].update({version.id: _diff})
        changes_list = list(changes.items())
        changes_list = sorted(changes_list, key=lambda i: i[0])
        return changes_list

    def changes(self, previous):
        """
        Return a `Diff` instance for this version
        and a previous version

        Argument(s):

            - previous(Version)

        Returns(s):

            - Diff

        """

        return Diff(previous, self).changes

    def changed_fields(self, previous):

        """
        Return a list of changed fields between
        this version and a previous version

        Argument(s):

            - previous(Version)

        Returns:

            - list(str)

        """

        changes = self.changes(previous)
        if changes is None:
            return None
        return sorted(changes.keys())


class ReversionVersion(Version):

    """
    Version abtraction for django-reversion
    """

    def __init__(self, version):
        """
        Argument(s):

            - Version(int|reversion.models.Version): can be either
                a reversion version instance or the id of one

        """
        if isinstance(version, int):
            version = reversion.models.Version.objects.get(id=version)
        super().__init__(version)

    @property
    def date(self):
        """
        Returns:

            - datetime: date of revision

        """
        return self.version.revision.date_created

    @property
    def user(self):
        """
        Returns:

            - User: user that authored revision
            - None: if no such user exists

        """
        return self.version.revision.user

    @property
    def comment(self):
        """
        Returns:

            - str: comment stored with revision
            - None: if no such comment exists

        """
        return self.version.revision.comment

    @property
    def id(self):
        """
        Returns:

            - int:version instance id

        """
        return self.version.id

    @property
    def data(self):
        """
        Returns:

            - dict: object data

        """
        return self.version.field_dict

    @property
    def model(self):
        """
        Returns:

            - model: django model for the object
                snapshotted by this version

        """
        return self.version._model

    @property
    def data_sorted(self):
        """
        Returns:

            - list: list of (field, value) tuples for
                object data

        """
        data = []

        for field, value in self.data.items():
            data.append((field, value))

        return sorted(data, key=lambda i: i[0])

    @property
    def previous(self):
        """
        Returns:

            - Version: previous version - if no previous version exists
                the Version instance will be empty

        """

        if hasattr(self, "_previous"):
            return self._previous

        versions = reversion.models.Version.objects.get_for_object(
            self.version.object
        ).order_by("-id")

        for version in versions:
            if version.id < self.version.id:
                self._previous = self.__class__(version)
                return self._previous

        return None

    @property
    def next(self):
        """
        Returns:

            - Version: next version - if no next version exists
                the Version instance will be empty

        """

        if hasattr(self, "_next"):
            return self._next
        qset = reversion.models.Version.objects.filter(
            content_type_id=self.version.content_type_id,
            object_id=self.version.object_id,
            id__gt=self.version.id,
        )
        qset = qset.order_by("id")
        self._next = self.__class__(qset.first())
        return self._next


class Diff:

    """
    Describes changes between two versions
    """

    # when generating diff ignore these fields

    diff_ignore_fields = [
        "version",
        "created",
        "updated",
    ]

    def __init__(self, version_a, version_b):

        """
        Argument(s):

            - version_a(Version): older version
            - version_b(Version): newer version

        """

        self.version_a = version_a
        self.version_b = version_b

    @property
    def changes(self):

        """
        Compile and return a dict describing changes between
        the two versions tracked in this diff

        Returns:

            - dict: dict mapping field names to a dict describing
              changed made to the field


              {
                  field_name: {
                      "old": old_value,
                      "changed": changed_value,
                  },
                  ...
              }

        """

        if not self.version_a or not self.version_b:
            return None

        if not self.version_a.version or not self.version_b.version:
            return None

        data_a = self.version_a.data
        data_b = self.version_b.data

        diff = {}

        for field, value_b in data_b.items():
            if field in self.diff_ignore_fields:
                continue

            value_a = data_a.get(field)

            if value_a == value_b:
                continue

            if isinstance(value_a, str) or isinstance(value_a, int):
                diff[field] = {"old": value_a, "changed": value_b}
            else:
                diff[field] = {
                    "old": self.format_value(value_a),
                    "changed": self.format_value(value_b),
                }

        return diff

    def format_value(self, value):
        return f"{value}"


class Reverter:

    """
    Allows to revert / rollback changes
    """

    def revert_fields(self, instance, field_versions, **kwargs):

        """
        Revert a set of fields

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - field_versions(dict): dict mapping field names to
              version object

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        for field, version in field_versions.items():
            setattr(instance, field, version.data[field])
            if field == "status":
                self.validate_status_change(instance, version.data[field])
        instance.full_clean()
        instance.save()

    def rollback(self, instance, version, **kwargs):

        """
        Rollback to a specific version

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - version(Version): version to roll back to

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        for field, value in version.data.items():
            if field in ["created", "updated", "version"]:
                continue
            if field == "status":
                self.validate_status_change(instance, value)
            setattr(instance, field, value)
        instance.full_clean()
        instance.save()

    def validate_status_change(self, instance, status):

        """
        Validate a status value change - this will make sure
        an object cannot be undeleted if a parent relationship
        is still flagged as deleted

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - status(str)

        """

        for field in instance.__class__._meta.get_fields():
            if not field.is_relation or not field.many_to_one:
                continue
            try:
                relation = getattr(instance, field.name)
            except Exception:
                continue
            self.validate_parent_status(instance, relation, status)

    def validate_parent_status(self, instance, parent, status):

        if not hasattr(parent, "HandleRef"):
            return

        if parent.status == "deleted" and status != "deleted":
            raise ValidationError(
                {
                    "non_field_errors": "Parent object {} is currently flagged as deleted."
                    "This object may not be undeleted while the parent "
                    "is still deleted.".format(parent)
                }
            )


class ReversionReverter(Reverter):

    """
    Reverter abstraction for django-reversion
    """

    def revert_fields(self, instance, field_versions, user=None):

        """
        Revert a set of fields

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - field_versions(dict): dict mapping field names to
              version pk

        Keyword Argument(s):

            - user(User): user that authored the revision

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        with reversion.create_revision():
            if user:
                reversion.set_user(user)
            version_ids = [
                "{}".format(version.data["version"])
                for version in field_versions.values()
            ]
            version_ids = list(set(version_ids))
            reversion.set_comment(
                "reverted some fields via versions: {}".format(", ".join(version_ids))
            )
            super().revert_fields(instance, field_versions)

    def rollback(self, instance, version, user=None):

        """
        Rollback to a specific version

        Argument(s):

            - instance(model instance): instance of django model
              to be reverted
            - version(Version): version to roll back to

        Keyword Argument(s):

            - user(User): user that authored the revision

        Raises:

            - ValidationError: if any of the fields fail validation

        """

        with reversion.create_revision():
            if user:
                reversion.set_user(user)
            reversion.set_comment(
                "rollback to version {}".format(version.data["version"])
            )
            super().rollback(instance, version)
Object version interface - extend to support     different types of django object versioning     solutions Argument(s):              - version: version object instance, for example                 django reversion Version object Should return version date (datetime object) Should return user (django User object) that commited the version         or None if no such user exists Should return the internal version id (primary         key in the database) Should return comment (str) for the version         or None if no such comment exists Should return a dict of the object snapshot         store in this version, with field names mapping         to field values Should return `data` in list form with (field, value)         tuples as items, sorted by field Should return the django model class of the object         snapshotted by the version Should return a Version instance holding the previous         version neighbour of this version          If no previous version exists return an empty Version         instance Should return a Version instance holding the next         version neighbour of this version          if no next version exists return an empty Version         instance Compiles and return a changes summary of multiple         Version objects.          Argument(s):              - versions(list): list of Version instances          Returns:              - list: a list of (field, dict) tuples where                 the dict will be mapping version ids to a                 dict containing changes made to the field                 by the version. Check `Diff` class for                 further information Return a `Diff` instance for this version         and a previous version          Argument(s):              - previous(Version)          Returns(s):              - Diff Return a list of changed fields between         this version and a previous version          Argument(s):              - previous(Version)          Returns:              - list(str) Version abtraction for django-reversion Argument(s):              - Version(int|reversion.models.Version): can be either                 a reversion version instance or the id of one Returns:              - datetime: date of revision Returns:              - User: user that authored revision             - None: if no such user exists Returns:              - str: comment stored with revision             - None: if no such comment exists Returns:              - int:version instance id Returns:              - dict: object data Returns:              - model: django model for the object                 snapshotted by this version Returns:              - list: list of (field, value) tuples for                 object data Returns:              - Version: previous version - if no previous version exists                 the Version instance will be empty Returns:              - Version: next version - if no next version exists                 the Version instance will be empty Describes changes between two versions # when generating diff ignore these fields Argument(s):              - version_a(Version): older version             - version_b(Version): newer version Compile and return a dict describing changes between         the two versions tracked in this diff          Returns:              - dict: dict mapping field names to a dict describing               changed made to the field                 {                   field_name: {                       "old": old_value,                       "changed": changed_value,                   },                   ...               } Allows to revert / rollback changes Revert a set of fields          Argument(s):              - instance(model instance): instance of django model               to be reverted             - field_versions(dict): dict mapping field names to               version object          Raises:              - ValidationError: if any of the fields fail validation Rollback to a specific version          Argument(s):              - instance(model instance): instance of django model               to be reverted             - version(Version): version to roll back to          Raises:              - ValidationError: if any of the fields fail validation Validate a status value change - this will make sure         an object cannot be undeleted if a parent relationship         is still flagged as deleted          Argument(s):              - instance(model instance): instance of django model               to be reverted             - status(str) Reverter abstraction for django-reversion Revert a set of fields          Argument(s):              - instance(model instance): instance of django model               to be reverted             - field_versions(dict): dict mapping field names to               version pk          Keyword Argument(s):              - user(User): user that authored the revision          Raises:              - ValidationError: if any of the fields fail validation Rollback to a specific version          Argument(s):              - instance(model instance): instance of django model               to be reverted             - version(Version): version to roll back to          Keyword Argument(s):              - user(User): user that authored the revision          Raises:              - ValidationError: if any of the fields fail validation