"""This module provides the ``Training`` model for the ``trainings`` app"""

from collections import defaultdict

from django.conf import settings
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _

import waffle
from celery import group as task_group
from django_hashable_file_fields.hashers import with_default
from django_hashable_file_fields.models import HashableFileFieldsBaseModel

from mtp.discrete_apps.versionable.exceptions import NotDraftable
from mtp.discrete_apps.versionable.models import Versionable

from mtp.apps.accounts.constants import (
    GROUPTYPE_BUSINESS_UNIT, GROUPTYPE_INTERNAL,
    GROUPTYPE_JOB_PROFILE, INTERNAL_GROUP_WHOSWHO,
)
from mtp.apps.accounts.models import (
    Group,
    GroupRole,
    GroupSet,
    GroupType,
    MTPUser,
)
from mtp.apps.common.limpyd_base.django_models import RedisBackedDjangoModel
from mtp.apps.common.notifications.tasks import send_async
from mtp.apps.common.utils import bypass_orderable
from mtp.apps.sync.models import Syncable
from mtp.apps.themes.models import Theme

from ..managers import VersionableTrainingManager


class Training(RedisBackedDjangoModel, HashableFileFieldsBaseModel, Versionable, Syncable):
    """Django model representing a training.

    Attributes
    ----------
    id : django field, instance of ``django_extensions.db.fields.UUIDField``
    owner : django ``ForeignKey``
        The ``MTPUser`` the training belongs to.
    name : django ``CharField``
        The name of the training, mandatory.
    description : django ``TextField``
        Optional description for this training.
    theme : foreign key to ``mtp.apps.themes.models.Theme``
        The theme of the training
    header_image : django ``ImageField``
        The header image of the training.
    header_image_hash : django ``CharField``
        The hash of the image in the ``header_image`` field.
    default_group_set : django ``foreignKey`` to ``mtp.apps.accounts.models.GroupSet``
        A list of groups the user will be added to when joining a session of this training.
    dimensions : django ``ManyToManyField`` field.
        The "wheighted" dimensions of the training.
    business_unit : django ``ForeignKey`` to ``mtp.apps.accounts.models.Group``
        The group of user who have access to the training.
    job_profile : foreign key to ``mtp.apps.accounts.models.Group``
        The job_profile associated with this training.
    external_id : django ``Charfield`` field.
        ID of the the training in an external system (eg SABA).

    DuplicateInProgress : Exception
        Exception raised during ``duplicate_to_member`` if the same duplicate is in progress.

    Notes
    -----
    This model is ``Versionable`` and ``Syncable``.

    """
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='trainings',
        verbose_name=_("owner"),
        on_delete=models.CASCADE
    )

    name = models.CharField(max_length=30, verbose_name=_("name"))
    description = models.TextField(
        blank=True,
        verbose_name=_('Description')
    )

    theme = models.ForeignKey(
        'themes.Theme',
        verbose_name=_("theme"),
        null=True,
        on_delete=models.CASCADE
    )

    header_image = models.ImageField(
        blank=True, null=True, upload_to="training",
        help_text=_('Recommended ratio 16/9'),
        verbose_name=_('Training visual'))
    header_image_hash = models.CharField(_('Training visual Hash'), max_length=64, blank=True)

    default_group_set = models.ForeignKey(
        'accounts.GroupSet',
        verbose_name=_("default group set"),
        null=True,
        blank=True,
        on_delete=models.CASCADE
    )

    dimensions = models.ManyToManyField(
        'assessments.Dimension', blank=True,
        through='assessments.DimensionTrainingWeight'
    )

    business_unit = models.ForeignKey(
        'accounts.Group', null=True, blank=True,
        limit_choices_to={'type__name': GROUPTYPE_BUSINESS_UNIT},
        related_name='trainings_by_business_unit',
        verbose_name=_("Business unit"),
        on_delete=models.CASCADE
    )

    job_profile = models.ForeignKey(
        'accounts.Group', null=True, blank=True,
        help_text=_('Only for assessment / Job profile targeted by the assessment training.'),
        limit_choices_to={'type__name': GROUPTYPE_JOB_PROFILE},
        related_name='trainings_by_job_profile',
        on_delete=models.CASCADE
    )

    _external_id = models.CharField(
        max_length=255,
        blank=True,
        verbose_name=_('External id'),
        db_column='external_id'
    )

    objects = VersionableTrainingManager()

    non_lockable_fields = Versionable.non_lockable_fields + Syncable.non_lockable_fields

    hashable_fields = [
        ('header_image', 'header_image_hash',
         with_default(settings.EMPTY_FILE_HASH)),
    ]

    # pylint: disable=invalid-name
    DRAFTABLE_STATES = Versionable.VERSION_STATES.extract_subset(
        'COPIED',
        'DRAFT',
        'PUBLISHED',
        'ARCHIVED',
    )

    class Meta:
        ordering = ('name',)

    def __str__(self):
        return self.name

    class DuplicateInProgress(Exception):
        """Exception raised during ``duplicate_to_member`` if the same duplicate is in progress."""
        pass

    @cached_property
    def redis_model(self):
        """Returns the ``DjangoBackedRedisModel`` linked to the current one."""

        from ..redis_models import Training as RedisTraining

        return RedisTraining

    @cached_property
    def is_assessing(self):
        """Return if this training is assessing or not.

        Notes
        -----
        A training is assessing if it contains at least one assessing activity.

        Returns
        -------
        bool

        """

        return self.activities.assessing().exists()

    def save(self, *args, **kwargs):
        """Create and populate the ``default_grouo_set`` field if empty

        Also, populate the ``theme`` field if empty.

        For the parameters, see ``django.db.models.Model``"""

        if not self.default_group_set:
            default_group_set = GroupSet.objects.create(name="%s - Trainee group set" % self.name)

            group_type_internal, __ = GroupType.objects.get_or_create(
                name=GROUPTYPE_INTERNAL)
            group_whoswho, __ = Group.objects.get_or_create(
                name=INTERNAL_GROUP_WHOSWHO,
                type=group_type_internal,
            )
            self.default_group_set = default_group_set
            self.default_group_set.mgroups.add(group_whoswho)

        if not self.theme_id:
            self.theme = Theme.objects.get_default()

        super().save(*args, **kwargs)

    @property
    def options(self):
        """Get or create the ``TrainingClientOptions`` for this training

        Returns
        -------
        ``TrainingClientOptions`` instance
            The client options for this training.

        """

        from . import TrainingClientOptions
        options, __ = TrainingClientOptions.objects.get_or_create(training=self)
        return options

    @property
    def is_draftable(self):
        """Override the property to know if this training instance can be used to create a draft.

        A training is "draftable" in all his possible states, excepting the "COPYING" one.

        Returns
        -------
        boolean
            ``True`` if a draft can be created from this training, ``False`` otherwise.

        """

        return self.get_loaded_version_state() in self.DRAFTABLE_STATES

    def get_theme(self):
        """Return the theme tied to the training or the default one

        If there is not theme tied to the training, we save it with the default
        one only if we are on master to avoid sync problems

        Returns
        -------
        Theme
            The theme tied to the training or the default one

        """

        if not self.theme_id:
            self.theme = Theme.objects.get_default()

            if settings.INSTANCE_IS_MASTER:
                # do not save on slave to avoid sync problems
                self.save()

        return self.theme

    def get_thumbnail_image(self):
        """Get the thumbnail image of the training.

        Returns
        -------
        ImageField
            The ``thumbnail_image`` value from the training if set,
            or the one from the theme if not.

        """

        return self.thumbnail_image or self.get_theme().thumbnail_image

    def get_header_image(self):
        """Get the header image of the training.

        Returns
        -------
        ImageField
            The ``header_image`` value from the training if set,
            or the one from the theme if not.

        """

        return self.header_image or self.get_theme().header_image

    def get_header_image_hash(self):
        """Get the header image hash of the training.

        Returns
        -------
        str
            The ``header_image_hash`` value from the training if set,
            or the one from the theme if not.

        """

        return self.header_image_hash or self.get_theme().header_image_hash

    @cached_property
    def media_files(self):
        """Get a list of the media files for this training

        Returns
        -------
        list(str)
            A list of media path for this training

        """

        file_list = []

        # Add the media from the activities
        for activity in self.activities.non_deleted():
            if hasattr(activity, 'media_files'):
                file_list.extend(activity.media_files)

        # Add the media from the associated theme
        file_list.extend(self.get_theme().media_files)

        return file_list

    def get_survey_url(self):
        """Get the url to edit the survey of this training.

        Returns
        -------
        string

        """

        return reverse('trainings:training-survey', kwargs={'training_uuid': self.pk})

    @classmethod
    def get_list_url(cls):
        """Get the url to display a list of training

        Returns
        -------
        string

        """

        return reverse('trainings:training-list')

    @classmethod
    def get_waiting_and_recent_json_list_url(cls):
        """Get the url to get the list of trainings tasks as json

        Returns
        -------
        string

        """

        return reverse('trainings:waiting-and-recents')

    def get_detail_url(self):
        """Get the url to view the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-detail', kwargs={'training_uuid': self.pk})

    def get_edit_url(self):
        """Get the url to edit the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-update', kwargs={'training_uuid': self.pk})

    def get_activities_add_url(self):
        """Get the url to add activities on the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:activities-add', kwargs={'training_uuid': self.pk})

    def get_create_draft_url(self):
        """Get the url to create a new draft based on the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-draft', kwargs={'training_uuid': self.pk})

    def get_publish_version_state_url(self):
        """Get the url to set the ``version_state`` to "publish" on the
        current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-publish', kwargs={'training_uuid': self.pk})

    def get_archive_version_state_url(self):
        """Get the url to set the ``version_state`` to "archived" on the current training instance.

        Returns
        -------
        str

        """

        return reverse('trainings:training-archive', kwargs={'training_uuid': self.pk})

    def get_duplicate_url(self):
        """Get the url to duplicate the current instance to the trainers.

        Returns
        -------
        string

        """

        return reverse('trainings:training-duplicate', kwargs={'training_uuid': self.pk})

    def get_publish_and_share_url(self):
        """Get the url to duplicate and publish the current instance to the trainers.

        Returns
        -------
        string

        """

        return reverse('trainings:training-publish-duplicate', kwargs={'training_uuid': self.pk})

    def get_delete_url(self):
        """Get the url to soft-delete the current training instance.

        Returns
        -------
        string

        """

        return reverse('trainings:training-delete', kwargs={'training_uuid': self.pk})

    def get_session_create_url(self):
        """Get the url to create a training session from the current training.

        Returns
        -------
        string

        """

        return reverse('trainings:session-create', kwargs={'training_uuid': self.pk})

    def get_session_list_url(self):
        """ Get the url to list the sessions which belongs to this training.

        Returns
        -------
        string

        """

        return reverse('trainings:session-list', kwargs={'training_uuid': self.pk})

    @classmethod
    def get_create_url(cls):
        """Get the url to create a training.

        Returns
        -------
        string

        Notes
        -----
        As it's not tied to an instance, this method is a ``classmethod``.

        """

        return reverse('trainings:training-create')

    def get_qrcodes_url(self):
        """Get the url to download the training QRCodes able to launch activity.

        Returns
        -------
        string

        """

        return reverse(
            'trainings:download-launch-qrcode-list',
            kwargs={
                'training_uuid': self.pk
            }
        )

    def is_new_draft_allowed(self, **fields):
        """Check if creating a new draft is allowed.

        Parameters
        ----------
        fields : dict
            Some fields to override on the new draft.

        Returns
        -------
        tuple(bool, exception)
            A tuple with two entries:
            1. bool: ``True`` if creating a new draft is allowed, ``False`` otherwise.
            2. ``None`` if the first entry is ``True``, and, if ``False``, an instance of an
               exception to know why the creation of a new draft is not allowed.

        Notes
        -----
        The returned exceptions are:
        - ``NotDraftable`` if the training is not in a state that allow the draft creation
        - ``Training.DuplicateInProgress`` if a draft creation is already in progress.

        """

        if not self.is_draftable:
            return False, NotDraftable(self)

        # We need the owner in the fields, at least to compute the lock key.
        lock_params = dict(fields)
        if 'owner' not in lock_params:
            lock_params['owner'] = self.owner

        # First check if the draft-creation is already running
        if self.redis_instance.is_create_draft_locked(**lock_params):
            return False, self.DuplicateInProgress(_('A draft of this training is currently '
                                                     'being created.'))

        return True, None

    def create_draft(self, async=False, skip_in_progress=False, author=None, **fields):
        """Create a new draft the current training.

        Only one draft creation can be run at a given time with the same ``fields`` value, or a
        ``DuplicateInProgress`` exception will be raised.
        If ``fields`` doesn't define a new owner, the current owner is added to it to allow
        locking.

        Parameters
        ----------
        async : bool, default to ``False``
            If ``True``, the draft creation will be done asynchronously, otherwise it will be
            executed directly.
        skip_in_progress : bool, default to ``False``
            If the same duplicate is in progress, a ``DuplicateInProgress`` exception
            will be raised if ``skip_in_progress`` is ``False``, but the process will be skipped if
            ``True``.
        author: mtp.apps.accounts.models.MTPUser
            The person who wanted to create this draft. Will be stored in the ``TrainingTask`` to
            let the user follow the draft creation.
        fields : dict
            Some fields to override on the new draft.

        Returns
        -------
        Training / AsyncResult
            If ``async`` is ``True``, will return an ``AsyncResult`` from celery.
            If it's ``False``, will return the new duplicated training.

        Raises
        ------
        NotDraftable
            If the training is not in a state that allow the creation of a new draft.
        DuplicateInProgress
            If a draft creation is currently running with the same ``fields`` (including the owner
            if not set in ``fields``), and ``skip_in_progress`` if ``False``.

        Notes
        -----
        When called with ``async=True``, the task ``duplicate_training_to_member`` is delayed.
        This task will then, when executed, call back this current method with ``async=False``.

        """

        allowed, reason = self.is_new_draft_allowed()
        if not allowed:
            # One is in progress, but we are asked to ignore this, so we stop here.
            if skip_in_progress and isinstance(reason, self.DuplicateInProgress):
                return

            # If the training is not draftable, or are not asked to skip if the draft creation
            # is already in progress, we raise the error returned by ``is_new_draft_allowed``.
            raise reason

        if async:
            # Async mode, we delay the task

            from ..tasks import create_training_draft
            from .tasks import TASK_TYPES

            # Will return an ``AsyncResult`` from celery

            # Arguments will be updated by ``TrainingTask``
            # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
            return create_training_draft.delay(
                user=author,
                training=self,
                task_type=TASK_TYPES.CREATE_DRAFT,
                fields=fields  # Should contain only PKs, no complex objects
            )

        # We need the owner in the fields, at least to compute the lock key.
        lock_params = dict(fields)
        if 'owner' not in lock_params:
            lock_params['owner'] = self.owner

        # We tell the world that we are currently running this draft-creation
        lock_set = self.redis_instance.lock_create_draft(**lock_params)

        # But the world told us that someone is already doing the job, so we skip
        if not lock_set:
            if skip_in_progress:
                return
            raise self.DuplicateInProgress(_('A draft of this training is currently '
                                             'being created.'))

        try:
            with bypass_orderable():  # The ordering is done by copying the values!

                new_training = super().create_draft(**fields)

        finally:
            # We can now tell the world that we are done, so someone could do it again.
            self.redis_instance.release_create_draft_lock(**lock_params)

        return new_training

    def duplicate_to_members(self, replace_published=False, async=False, author=None):
        """Duplicate the current training to members that should receive it.

        "members that should receive it" is defined by a call to the ``get_members_to_duplicate``
        method.

        Parameters
        ----------
        replace_published : bool, default to ``False``
            If set to ``True``, users that don't own a group will all have the ``PUBLISHED``
            duplicate, even if they already have one. The existing one will then be archived.
        async : bool, default to False
            If ``True``, each duplicate will be done asynchronously, otherwise it will be executed
            directly.
            This is decided in the ``duplicate_to_member`` method.
        author: mtp.apps.accounts.models.MTPUser
            The person who wanted to do the duplicate. Will be stored in the ``TrainingTask`` to
            let the user follow the duplicate creation.

        Returns
        -------
        list
            If not async, will return the new duplicated trainings.
            If async, will return a list of ``AsyncResult`` object from celery (one for each
            duplicate to do)

        """

        to_duplicate = self.get_members_to_duplicate(
            replace_published=replace_published,
            use_pks=async
        )

        return [
            # Arguments will be updated by ``TrainingTask``
            # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
            self.duplicate_to_member(
                new_state=entry.pop('version_state'),  # Remove key from the dict and return value
                params=entry,  # Don't have "version_state" anymore
                async=async,
                skip_in_progress=True,
                author=author,
            )
            for entry in to_duplicate
        ]

    def duplicate_to_member(self, new_state, params, async=False, skip_in_progress=False,
                            author=None):
        """Duplicate the current training to a member defined in ``params``.

        Only one duplicate can be run at a given time with the same ``params`` value, or a
        ``DuplicateInProgress`` exception will be raised.

        Parameters
        ----------
        new_state : string
            Value from ``VERSION_STATES`` to set to the ``version_state`` on the duplicated
            training.
        params : dict
            The fields to set on the training to duplicate. Must at least contain a ``owner`` key.
            If ``async`` is ``True``, entries that represent a model field (FK, One2One...) should
            be the primary keys, not the objects themselves (ie ``owner_id`` with a PK instead of
            ``owner`` with a ``MTPUser`` object)
        async : bool, default to ``False``
            If ``True``, the duplicate will be done asynchronously, otherwise it will be executed
            directly.
        skip_in_progress : bool, default to ``False``
            If the same duplicate is in progress, a ``DuplicateInProgress`` exception
            will be raised if ``skip_in_progress`` is ``False``, but the process will be skipped if
            ``True``.
        author: mtp.apps.accounts.models.MTPUser
            The person who wanted to do the duplicate. Will be stored in the ``TrainingTask`` to
            let the user follow the duplicate creation.

        Returns
        -------
        Training / AsyncResult
            If ``async`` is ``True``, will return an ``AsyncResult`` from celery.
            If it's ``False``, will return the new duplicated training.

        Raises
        ------
        DuplicateInProgress
            If a duplicate is currently running with the same ``params``, and ``skip_in_progress``
            if ``False``.

        Notes
        -----
        When called with ``async=True``, the task ``duplicate_training_to_member`` is delayed.
        This task will then, when executed, call back this current method with ``async=False``.

        """

        # First check if the duplicate is already running
        if self.redis_instance.is_duplicate_to_member_locked(**params):
            if skip_in_progress:
                return
            raise self.DuplicateInProgress(_('The duplicate to this person is currently '
                                             'being created.'))

        if async:
            # Async mode, we delay the task

            from ..tasks import duplicate_training_to_member
            from .tasks import TASK_TYPES

            # Will return an ``AsyncResult`` from celery
            return duplicate_training_to_member.delay(
                user=author,
                training=self,
                task_type=TASK_TYPES.DUPLICATE_TO_MEMBER,
                new_state=str(new_state),
                params=params  # Should contain only PKs, no complex objects
            )

        # We tell the world that we are currently running this duplicate
        lock_set = self.redis_instance.lock_duplicate_to_member(**params)

        # But the world told us that someone is already doing the job, so we skip
        if not lock_set:
            if skip_in_progress:
                return
            raise self.DuplicateInProgress(_('The duplicate to this person is currently '
                                             'being created.'))

        try:

            # We can duplicate the training
            with bypass_orderable():  # The ordering is done by copying the values!
                new_training = self.create_new_version(
                    new_state=new_state,
                    params=params
                )

            if (settings.INSTANCE_IS_MASTER and
                    waffle.switch_is_active('enable_duplicate_training_mail')):

                # Send the happy training's owner a mail to let him know
                subtasks = [send_async.s(
                    [new_training.owner],
                    'new_available_training',
                    {
                        'author': new_training.parent_version.owner,
                        'training': new_training
                    }
                )]

                task_group(subtasks)()

        finally:
            # We can now tell the world that we are done, so someone could do it again.
            self.redis_instance.release_duplicate_to_member_lock(**params)

        return new_training

    def get_members_to_duplicate(self, replace_published=False, use_pks=False):
        """Returns the member who need to receive a duplicate of the current training.

        The ``Training`` instance will be copied to all the members of the ``business_unit``,
        who don't own an instance with the same ``parent_version``.
        Users that don't own a group will have a ``PUBLISHED`` duplicate, while the others will have
        a ``DRAFT`` duplicate.

        Parameters
        ----------
        replace_published : bool, default to ``False``
            If set to ``True``, users that don't own a group will all have the ``PUBLISHED``
            duplicate, even if they already have one. The existing one will then be archived.
        use_pks : bool, default to ``False``
            If set to ``False``, entries from the result dict that represent a model field (FK,
            One2One...) must be objects, but if ``True``, they should be the primary keys, not the
            objects themselves (ie ``owner_id`` with a PK instead of ``owner`` with a ``MTPUser``
            object)
            It must be set to ``True`` when the result is used for asynchronous tasks.

        Returns
        -------
        list
            A list of dict, each entry being one duplicate to do, with these keys:
            - owner : ``MTPUSer``
                The owner of the duplicated training
                If ``use_pks`` is ``True``, the key will instead be ``owner_id`` and the value
                will be the primary key of this user.
            - business_unit : ``Group``
                To fill the ``business_unit`` field of the duplicated training
                If ``use_pks`` is ``True``, the key will instead be ``business_unit_id`` and the
                value will be the primary key of this group.
            - version_state : str
                The ``version_state`` to set on the duplicated training

        """

        # Get all the existing duplicates for this training
        existing_duplicates = self.direct_duplicates.all()

        # Get all the potential owners of a version
        owners = MTPUser.objects.filter(
            is_active=True,
            mgroups=self.business_unit,
            group_roles__role=GroupRole.ROLES.MEMBER,
        )

        # Get the groups for which we have a duplicate for this user
        duplicate_groups_by_user = defaultdict(list)
        for duplicate in existing_duplicates:
            duplicate_groups_by_user[duplicate.owner.pk].append(duplicate.business_unit)

        # Get all the groups owned by the users
        groups_by_user = defaultdict(list)
        for group in GroupRole.objects.filter(user__in=owners, role=GroupRole.ROLES.MANAGER):
            groups_by_user[group.user_id].append(group)

        # Prepare the dict to return
        to_duplicate = []

        # Prepare the result value for each user
        for owner in owners:

            # Get the groups for which we need a duplicate
            # Its the groups in `groups_by_user[user_id]`
            # that are not in `duplicate_groups_by_user[user_id]`
            wanted_groups_for_user = [
                g for g in groups_by_user[owner.pk]
                if g.group not in duplicate_groups_by_user[owner.pk]
            ]

            for group in wanted_groups_for_user:
                to_duplicate.append({
                    ('owner_id' if use_pks else 'owner'):
                        owner.pk if use_pks else owner,
                    ('business_unit_id' if use_pks else 'business_unit'):
                        group.group_id if use_pks else group.group,
                    'version_state': self.VERSION_STATES.DRAFT
                })

            # If the user is not managing any group, he will get one copy,
            # associated with the current ``business_unit``.
            # Only if he don't own one already, or if ``replace_published`` is ``True``

            if groups_by_user[owner.pk]:
                continue

            if self.business_unit in duplicate_groups_by_user[owner.pk] and not replace_published:
                continue

            to_duplicate.append({
                ('owner_id' if use_pks else 'owner'):
                    owner.pk if use_pks else owner,
                ('business_unit_id' if use_pks else 'business_unit'):
                    self.business_unit.pk if use_pks else self.business_unit,
                'version_state': self.VERSION_STATES.PUBLISHED,
            })

        return to_duplicate

    # pylint: disable=arguments-differ,missing-param-doc,missing-type-doc
    def archive(self, cascade=False, commit=True):
        """Override the archive to allow cascading

        Parameters
        ----------
        cascade: bool, default to False
            If False, only the current training will be archived,
            if True, all the duplicates (direct and indirect) of the current training
            will be also archived.

        For the other parameters see the ``Versionable.archive`` method.

        Notes
        -----
        The "cascade" part is done asynchronously using the ``archive_training`` task.

        """

        if cascade and not commit:
            raise ValueError(
                "Can't set the 'commit' argument to 'False' when 'cascade' is set to 'True'."
            )

        super().archive(commit=commit)

        if cascade:
            from ..tasks import archive_training  # Recursive imports

            duplicates_ids = Training.objects.get_duplicate_ids(
                training_ids=[self.id],
                exclude_owner_id=self.owner_id
            )
            archive_tasks = task_group([
                archive_training.s(training_id)
                for training_id in duplicates_ids
            ])
            archive_tasks.delay()

    @cached_property
    def external_id(self):
        """Get external_id from self or original_version

        Returns
        -------
        string
            The external id, can be empty string.
        """

        return (self.original_version or self)._external_id
