Ejemplo n.º 1
0
class EventCategory(MPTTModel):
    """
    The category of an event.

    :name: The name of the category.
    :slug: The slug of the category.
    :parent: Allows you to create hierarchies of event categories.

    """
    name = models.CharField(_('Name'), max_length=255)

    slug = AutoSlugField(_('Slug'), populate_from='name')

    parent = TreeForeignKey('self',
                            verbose_name=_('Parent'),
                            null=True,
                            blank=True,
                            related_name='children',
                            db_index=True)

    order = models.PositiveSmallIntegerField(blank=False, default=0)

    objects = TreeManager()

    class Meta:
        ordering = ('lft', )
        verbose_name = _('Event Category')
        verbose_name_plural = _('Event Categories')

    class MPTTMeta:
        order_insertion_by = ('order', )

    def __str__(self):
        return self.name
Ejemplo n.º 2
0
class Category(Post, MPTTModel):  # Для блога
    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children',
                            db_index=True)

    objects = TreeManager()

    def make_alias(self):
        return ''
Ejemplo n.º 3
0
class MenuItem(MPTTModel):
    class Meta:
        verbose_name = "Пункт меню"
        verbose_name_plural = "Пункты меню"
        ordering = ('order', )

    class MPTTMeta:
        order_insertion_by = ['order']

    class PositionChoices:
        HEADER = 'header'
        FOOTER = 'footer'
        _CHOICES = (
            (HEADER, 'Header'),
            (FOOTER, 'Footer')
        )

    parent = TreeForeignKey('self',
                            verbose_name='Родитель',
                            related_name='subitems',
                            blank=True, null=True)

    url = models.CharField("Ссылка", max_length=120)
    title = models.CharField("Название", max_length=255)
    linked_model = models.ForeignKey(ContentType,
                                     verbose_name="Тип контента",
                                     help_text="Определяет индикатор",
                                     blank=True,
                                     null=True)
    position = models.CharField("Положение",
                                max_length=10,
                                default=PositionChoices.HEADER,
                                choices=PositionChoices._CHOICES,
                                db_index=True)

    order = models.PositiveIntegerField('Порядок', default=0)

    objects = TreeManager.from_queryset(MenuItemQuerySet)()
    objects.use_for_related_fields = True

    def __unicode__(self):
        return '{} - {}'.format(self.position.capitalize(), self.title)

    @property
    def indicator(self):
        try:
            return self.linked_model.model_class().objects.indicator()
        except AttributeError:
            return None
Ejemplo n.º 4
0
class GLAccountType(MPTTModel):
    id = models.SlugField(primary_key=True, max_length=200)
    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children')
    account_type_name = models.CharField(max_length=200)
    description = RichTextField(blank=True, null=True)
    creation_date = models.DateTimeField(blank=True, null=True, editable=False)
    update_date = models.DateTimeField(blank=True, null=True, editable=False)
    author = models.ForeignKey(settings.AUTH_USER_MODEL,
                               blank=True,
                               null=True,
                               editable=False)

    objects = TreeManager()

    def save(self, *args, **kwargs):
        if self.creation_date is None:
            self.creation_date = timezone.now()
        self.update_date = timezone.now()
        self.author = get_request().user
        super(GLAccountType, self).save(*args, **kwargs)

    def __str__(self):
        return "GL Account Type " + self.id

    class Meta:
        app_label = 'accounting'
        db_table = 'acc_gl_account_type'
        verbose_name = _(__name__ + ".table_name")
        verbose_name_plural = _(__name__ + ".table_name_plural")

    class MPTTMeta:
        order_insertion_by = ['id']
        tree_manager_name = 'objects'
Ejemplo n.º 5
0
class ContentNode(MPTTModel, models.Model):
    """
    By default, all nodes have a title and can be used as a topic.
    """
    # The id should be the same between the content curation server and Kolibri.
    id = UUIDField(primary_key=True, default=uuid.uuid4)

    # the content_id is used for tracking a user's interaction with a piece of
    # content, in the face of possibly many copies of that content. When a user
    # interacts with a piece of content, all substantially similar pieces of
    # content should be marked as such as well. We track these "substantially
    # similar" types of content by having them have the same content_id.
    content_id = UUIDField(primary_key=False,
                           default=uuid.uuid4,
                           editable=False,
                           db_index=True)
    node_id = UUIDField(primary_key=False, default=uuid.uuid4, editable=False)

    # TODO: disallow nulls once existing models have been set
    original_channel_id = UUIDField(
        primary_key=False, editable=False, null=True,
        db_index=True)  # Original channel copied from
    source_channel_id = UUIDField(primary_key=False, editable=False,
                                  null=True)  # Immediate channel copied from
    original_source_node_id = UUIDField(
        primary_key=False, editable=False, null=True, db_index=True
    )  # Original node_id of node copied from (TODO: original_node_id clashes with original_node field - temporary)
    source_node_id = UUIDField(
        primary_key=False, editable=False,
        null=True)  # Immediate node_id of node copied from

    # Fields specific to content generated by Ricecooker
    source_id = models.CharField(max_length=200, blank=True, null=True)
    source_domain = models.CharField(max_length=300, blank=True, null=True)

    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    kind = models.ForeignKey('ContentKind',
                             related_name='contentnodes',
                             db_index=True)
    license = models.ForeignKey('License', null=True, blank=True)
    license_description = models.CharField(max_length=400,
                                           null=True,
                                           blank=True)
    prerequisite = models.ManyToManyField(
        'self',
        related_name='is_prerequisite_of',
        through='PrerequisiteContentRelationship',
        symmetrical=False,
        blank=True)
    is_related = models.ManyToManyField('self',
                                        related_name='relate_to',
                                        through='RelatedContentRelationship',
                                        symmetrical=False,
                                        blank=True)
    language = models.ForeignKey('Language',
                                 null=True,
                                 blank=True,
                                 related_name='content_language')
    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children',
                            db_index=True)
    tags = models.ManyToManyField(ContentTag,
                                  symmetrical=False,
                                  related_name='tagged_content',
                                  blank=True)
    sort_order = models.FloatField(
        max_length=50,
        default=1,
        verbose_name=_("sort order"),
        help_text=_("Ascending, lowest number shown first"))
    copyright_holder = models.CharField(
        max_length=200,
        null=True,
        blank=True,
        default="",
        help_text=_("Organization of person who holds the essential rights"))
    cloned_source = TreeForeignKey('self',
                                   on_delete=models.SET_NULL,
                                   null=True,
                                   blank=True,
                                   related_name='clones')
    original_node = TreeForeignKey('self',
                                   on_delete=models.SET_NULL,
                                   null=True,
                                   blank=True,
                                   related_name='duplicates')
    thumbnail_encoding = models.TextField(blank=True, null=True)

    created = models.DateTimeField(auto_now_add=True,
                                   verbose_name=_("created"))
    modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
    published = models.BooleanField(default=False)
    publishing = models.BooleanField(default=False)

    changed = models.BooleanField(default=True, db_index=True)
    extra_fields = models.TextField(blank=True, null=True)
    author = models.CharField(max_length=200,
                              blank=True,
                              default="",
                              help_text=_("Person who created content"),
                              null=True)

    role_visibility = models.CharField(max_length=50,
                                       choices=roles.choices,
                                       default=roles.LEARNER)
    freeze_authoring_data = models.BooleanField(default=False)

    objects = TreeManager()

    @raise_if_unsaved
    def get_root(self):
        # Only topics can be root nodes
        if not self.parent and self.kind_id != content_kinds.TOPIC:
            return self
        return super(ContentNode, self).get_root()

    def __init__(self, *args, **kwargs):
        super(ContentNode, self).__init__(*args, **kwargs)
        self._original_fields = self._as_dict(
        )  # Fast way to keep track of updates (no need to query db again)

    def _as_dict(self):
        return dict([(f.name, getattr(self, f.name))
                     for f in self._meta.local_fields if not f.rel])

    def get_changed_fields(self):
        """ Returns a dictionary of all of the changed (dirty) fields """
        new_state = self._as_dict()
        return dict([(key, value)
                     for key, value in self._original_fields.iteritems()
                     if value != new_state[key]])

    def get_tree_data(self, include_self=True):
        if not include_self:
            return [c.get_tree_data() for c in self.children.all()]
        elif self.kind_id == content_kinds.TOPIC:
            return {
                "title": self.title,
                "kind": self.kind_id,
                "children": [c.get_tree_data() for c in self.children.all()],
                "node_id": self.node_id,
            }
        elif self.kind_id == content_kinds.EXERCISE:
            return {
                "title": self.title,
                "kind": self.kind_id,
                "count": self.assessment_items.count(),
                "node_id": self.node_id,
            }
        else:
            return {
                "title":
                self.title,
                "kind":
                self.kind_id,
                "file_size":
                self.files.values('file_size').aggregate(
                    size=Sum('file_size'))['size'],
                "node_id":
                self.node_id,
            }

    def get_node_tree_data(self):
        nodes = []
        for child in self.children.all():
            if child.kind_id == content_kinds.TOPIC:
                nodes.append({
                    "title": child.title,
                    "kind": child.kind_id,
                    "node_id": child.node_id,
                })
            elif child.kind_id == content_kinds.EXERCISE:
                nodes.append({
                    "title": child.title,
                    "kind": child.kind_id,
                    "count": child.assessment_items.count(),
                })
            else:
                nodes.append({
                    "title":
                    child.title,
                    "kind":
                    child.kind_id,
                    "file_size":
                    child.files.values('file_size').aggregate(
                        size=Sum('file_size'))['size'],
                })
        return nodes

    def get_original_node(self):
        original_node = self.original_node or self
        if self.original_channel_id and self.original_source_node_id:
            original_tree_id = Channel.objects.select_related("main_tree").get(
                pk=self.original_channel_id).main_tree.tree_id
            original_node = ContentNode.objects.filter(tree_id=original_tree_id, node_id=self.original_source_node_id).first() or \
                            ContentNode.objects.filter(tree_id=original_tree_id, content_id=self.content_id).first() or self
        return original_node

    def get_associated_presets(self):
        key = "associated_presets_{}".format(self.kind_id)
        cached_data = cache.get(key)
        if cached_data:
            return cached_data
        presets = FormatPreset.objects.filter(kind=self.kind).values()
        cache.set(key, presets, None)
        return presets

    def get_prerequisites(self):
        prerequisite_mapping = {}
        prerequisites = self.prerequisite.all()
        prereqlist = list(prerequisites)
        for prereq in prerequisites:
            prlist, prereqmapping = prereq.get_prerequisites()
            prerequisite_mapping.update({prereq.pk: prereqmapping})
            prereqlist.extend(prlist)
        return prereqlist, prerequisite_mapping

    def get_postrequisites(self):
        postrequisite_mapping = {}
        postrequisites = self.is_prerequisite_of.all()
        postreqlist = list(postrequisites)
        for postreq in postrequisites:
            prlist, postreqmapping = postreq.get_postrequisites()
            postrequisite_mapping.update({postreq.pk: postreqmapping})
            postreqlist.extend(prlist)
        return postreqlist, postrequisite_mapping

    def get_channel(self):
        try:
            root = self.get_root()
            return root.channel_main.first() or root.channel_chef.first(
            ) or root.channel_trash.first() or root.channel_staging.first(
            ) or root.channel_previous.first()
        except (ObjectDoesNotExist, MultipleObjectsReturned, AttributeError):
            return None

    def save(self, *args, **kwargs):
        if kwargs.get('request'):
            request = kwargs.pop('request')
            channel = self.get_channel()
            request.user.can_edit(channel and channel.pk)

        self.changed = self.changed or len(self.get_changed_fields()) > 0

        # Detect if node has been moved to another tree
        if self.pk and ContentNode.objects.filter(pk=self.pk).exists():
            original = ContentNode.objects.get(pk=self.pk)
            if original.parent and original.parent_id != self.parent_id and not original.parent.changed:
                original.parent.changed = True
                original.parent.save()

        if self.original_node is None:
            self.original_node = self
        if self.cloned_source is None:
            self.cloned_source = self

        # TODO: This SIGNIFICANTLY slows down the creation flow
        #   Avoid calling get_channel() (db read)
        channel = (
            self.parent and self.parent.get_channel()) or self.get_channel(
            )  # Check parent first otherwise new content won't have root
        if self.original_channel_id is None:
            self.original_channel_id = channel.id if channel else None
        if self.source_channel_id is None:
            self.source_channel_id = channel.id if channel else None
        if self.original_source_node_id is None:
            self.original_source_node_id = self.node_id
        if self.source_node_id is None:
            self.source_node_id = self.node_id

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

        try:
            # During saving for fixtures, this fails to find the root node
            root = self.get_root()
            if self.is_prerequisite_of.exists() and (
                    root.channel_trash.exists()
                    or root.user_clipboard.exists()):
                PrerequisiteContentRelationship.objects.filter(
                    Q(prerequisite_id=self.id)
                    | Q(target_node_id=self.id)).delete()
        except ContentNode.DoesNotExist:
            pass

    class MPTTMeta:
        order_insertion_by = ['sort_order']

    class Meta:
        verbose_name = _("Topic")
        verbose_name_plural = _("Topics")
Ejemplo n.º 6
0
class Structure(MPTTModel):
    TYPE_CHOICES = (
        (1, "Cercle"),
        (2, "Clan Ainé"),
        (3, "Comité directeur"),
        (4, "Département"),
        (5, "Pôle"),
        (6, "Région"),
        (7, "Ronde"),
        (8, "Siège"),
        (9, "Sommet"),
        (10, "Structure locale d'activité"),
        (11, "Structure locale rattachée"),
        (12, "Unité Défi"),
        (13, "Unité Eclé"),
        (14, "Unité Nomade"),
        (15, "Centre permanent national"),
    )
    SUBTYPE_CHOICES = (
        (1, "Centre et terrain"),
        (2, "Groupe local"),
        (3, "Ludotheque"),
        (4, "Service vacances"),
    )

    number = models.CharField(
        "Numéro",
        max_length=10,
        unique=True,
        validators=[
            RegexValidator(
                '\d{10}',
                message="Le numéro de structure comporte 10 chiffres")
        ])
    name = models.CharField("Nom", max_length=100)
    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children',
                            db_index=True)
    type = models.IntegerField("Type", choices=TYPE_CHOICES)
    subtype = models.IntegerField("Sous-type",
                                  choices=SUBTYPE_CHOICES,
                                  null=True,
                                  blank=True)
    google = CredentialsField(null=True, blank=True)

    objects = TreeManager.from_queryset(StructureQuerySet)()

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = "Structure"

    class MPTTMeta:
        order_insertion_by = ['name']

    def nominated(self, person, season=None):
        if person.is_superuser:
            return True
        nominations = Nomination.objects.filter(
            adhesion__season=season or current_season())
        nominations = nominations.filter(adhesion__person=person)
        nominations = nominations.filter(structure=self)
        return nominations.exists()
Ejemplo n.º 7
0
class Header(BaseAccountModel):
    """Groups Accounts Together."""

    parent = TreeForeignKey('self', blank=True, null=True)
    active = models.BooleanField(default=True)

    objects = TreeManager()

    def __unicode__(self):
        return self.name

    def get_absolute_url(self):
        return reverse('accounts.views.show_accounts_chart',
                       args=[str(self.slug)])

    def account_number(self):
        tree = self.get_root().get_descendants(include_self=True)
        number = list(tree).index(self)
        return number

    def get_account_balance(self):
        """Traverse child Headers and Accounts to generate the current balance.

        :returns: The Value Balance of all :class:`Accounts<Account>` and
                :class:`Headers<Header>` under this Header.
        :rtype: :class:`decimal.Decimal`
        """
        balance = Decimal("0.00")
        child_headers = self.get_children()
        for header in child_headers:
            balance += header.get_account_balance()
        for account in self.account_set.all():
            balance += account.get_balance()
        return balance

    def _calculate_full_number(self):
        """Use type and tree position to generate full account number"""
        if self.parent:
            full_number = "{0}-{1:02d}000".format(self.type,
                                                  self.account_number())
        else:
            full_number = "{0}-00000".format(self.type)
        return full_number

    def _get_change_tree(self):
        """Get extra :class:`Headers<Header>` and :class:`Accounts<Account>`.

        A change in a :class:`Header` may cause changes in the number of Headers
        up to it's grandfather.

        We only save one :class:`Account` under each :class:`Header` because
        each :class:`Account` will save it's siblings.

        :returns: Additional instances to save.
        :rtype: list of :class:`Headers<Header>` and :class:`Accounts<Account>`

        """
        if self.parent and self.parent.parent:
            headers_to_change = list(self.parent.parent.get_descendants())
        else:
            headers_to_change = list(Header.objects.filter(type=self.type))
        accounts_to_change = [account for header in headers_to_change for
                              account in list(header.account_set.all())[-1:]]
        return headers_to_change + accounts_to_change
Ejemplo n.º 8
0
class Album(MPTTModel):
    """Model representing an album"""
    gallery = models.ForeignKey(Gallery)

    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children')

    name = models.CharField(_('name'), max_length=250)

    description = models.TextField(_('description'), blank=True)

    image = FileBrowseField(_('image'),
                            max_length=255,
                            null=True,
                            blank=True,
                            default=None)

    template_name = models.CharField(
        _('template'),
        max_length=255,
        help_text=_('Template used to render the album'),
        choices=settings.PORTICUS_ALBUM_TEMPLATE_CHOICES,
        default=settings.PORTICUS_ALBUM_TEMPLATE_DEFAULT)

    publish = models.BooleanField(_('published'),
                                  choices=PUBLISHED_CHOICES,
                                  default=True)

    priority = models.IntegerField(_('display priority'), default=100)

    creation_date = models.DateTimeField(_('creation date'), editable=False)

    slug = models.SlugField(_('slug'), unique=True, max_length=100)

    def __unicode__(self):
        return self.name

    objects = TreeManager()
    published = AlbumPublishedManager()

    #@models.permalink
    #def get_absolute_url(self):
    #return ('porticus:album-detail', (self.gallery.slug, self.slug,))

    def get_tags(self):
        """
        Return a queryset of tags used from album's ressources
        """
        return Tag.objects.get_for_object(self)

    def get_published_children(self):
        """
        Return all ressources for the album and all its children
        """
        return self.get_children().filter(publish=True)

    def get_published_descendants(self):
        """
        Return all ressources for the album and its direct descendants
        """
        return self.get_descendants().filter(publish=True)

    def get_published_ressources(self):
        """
        Return all ressources for the album
        """
        return self.ressource_set.filter(publish=True).order_by(
            'priority', 'name')

    def save(self, *args, **kwargs):
        """
        Fill 'creation_date' attribute on first create
        """
        if self.creation_date is None:
            self.creation_date = tz_now()

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

    class Meta:
        verbose_name = _('album')
        verbose_name_plural = _('albums')

    class MPTTMeta:
        order_insertion_by = ['gallery', 'priority', 'name']
Ejemplo n.º 9
0
class ContentNode(MPTTModel, models.Model):
    """
    By default, all nodes have a title and can be used as a topic.
    """
    # The id should be the same between the content curation server and Kolibri.
    id = UUIDField(primary_key=True, default=uuid.uuid4)

    # the content_id is used for tracking a user's interaction with a piece of
    # content, in the face of possibly many copies of that content. When a user
    # interacts with a piece of content, all substantially similar pieces of
    # content should be marked as such as well. We track these "substantially
    # similar" types of content by having them have the same content_id.
    content_id = UUIDField(primary_key=False,
                           default=uuid.uuid4,
                           editable=False)
    node_id = UUIDField(primary_key=False, default=uuid.uuid4, editable=False)

    # TODO: disallow nulls once existing models have been set
    original_channel_id = UUIDField(
        primary_key=False, editable=False, null=True,
        db_index=True)  # Original channel copied from
    source_channel_id = UUIDField(primary_key=False, editable=False,
                                  null=True)  # Immediate channel copied from
    original_source_node_id = UUIDField(
        primary_key=False, editable=False, null=True, db_index=True
    )  # Original node_id of node copied from (TODO: original_node_id clashes with original_node field - temporary)
    source_node_id = UUIDField(
        primary_key=False, editable=False,
        null=True)  # Immediate node_id of node copied from

    # Fields specific to content generated by Ricecooker
    source_id = models.CharField(max_length=200, blank=True, null=True)
    source_domain = models.CharField(max_length=300, blank=True, null=True)

    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    kind = models.ForeignKey('ContentKind',
                             related_name='contentnodes',
                             db_index=True)
    license = models.ForeignKey('License',
                                null=True,
                                default=settings.DEFAULT_LICENSE)
    license_description = models.CharField(max_length=400,
                                           null=True,
                                           blank=True)
    prerequisite = models.ManyToManyField(
        'self',
        related_name='is_prerequisite_of',
        through='PrerequisiteContentRelationship',
        symmetrical=False,
        blank=True)
    is_related = models.ManyToManyField('self',
                                        related_name='relate_to',
                                        through='RelatedContentRelationship',
                                        symmetrical=False,
                                        blank=True)
    parent = TreeForeignKey('self',
                            null=True,
                            blank=True,
                            related_name='children',
                            db_index=True)
    tags = models.ManyToManyField(ContentTag,
                                  symmetrical=False,
                                  related_name='tagged_content',
                                  blank=True)
    sort_order = models.FloatField(
        max_length=50,
        default=1,
        verbose_name=_("sort order"),
        help_text=_("Ascending, lowest number shown first"))
    copyright_holder = models.CharField(
        max_length=200,
        null=True,
        blank=True,
        default="",
        help_text=_("Organization of person who holds the essential rights"))
    cloned_source = TreeForeignKey('self',
                                   on_delete=models.SET_NULL,
                                   null=True,
                                   blank=True,
                                   related_name='clones')
    original_node = TreeForeignKey('self',
                                   on_delete=models.SET_NULL,
                                   null=True,
                                   blank=True,
                                   related_name='duplicates')

    created = models.DateTimeField(auto_now_add=True,
                                   verbose_name=_("created"))
    modified = models.DateTimeField(auto_now=True, verbose_name=_("modified"))
    published = models.BooleanField(default=False)

    changed = models.BooleanField(default=True, db_index=True)
    extra_fields = models.TextField(blank=True, null=True)
    author = models.CharField(max_length=200,
                              blank=True,
                              default="",
                              help_text=_("Person who created content"),
                              null=True)

    objects = TreeManager()

    def get_original_node(self):
        original_node = self.original_node or self
        if self.original_channel_id and self.original_source_node_id:
            original_channel = Channel.objects.select_related("main_tree").get(
                pk=self.original_channel_id)
            original_node = ContentNode.objects.filter(
                tree_id=original_channel.main_tree.tree_id,
                node_id=self.original_source_node_id).first() or self
        return original_node

    def get_associated_presets(self):
        key = "associated_presets_{}".format(self.kind_id)
        cached_data = cache.get(key)
        if cached_data:
            return cached_data
        presets = FormatPreset.objects.filter(kind=self.kind).values()
        cache.set(key, presets, None)
        return presets

    def get_channel(self):
        try:
            root = self.get_root()
            return root.channel_main.first() or root.channel_chef.first(
            ) or root.channel_trash.first() or root.channel_staging.first(
            ) or root.channel_previous.first()
        except ObjectDoesNotExist:
            return None

    def save(self, *args, **kwargs):
        # Detect if node has been moved to another tree
        if self.pk and ContentNode.objects.filter(pk=self.pk).exists():
            original = ContentNode.objects.get(pk=self.pk)
            if original.parent and original.parent_id != self.parent_id and not original.parent.changed:
                original.parent.changed = True
                original.parent.save()

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

        post_save_changes = False
        if self.original_node is None:
            self.original_node = self
            post_save_changes = True
        if self.cloned_source is None:
            self.cloned_source = self
            post_save_changes = True

        if self.original_channel_id is None and self.get_channel():
            self.original_channel_id = self.get_channel().id
            post_save_changes = True
        if self.source_channel_id is None and self.get_channel():
            self.source_channel_id = self.get_channel().id
            post_save_changes = True

        if self.original_source_node_id is None:
            self.original_source_node_id = self.node_id
            post_save_changes = True
        if self.source_node_id is None:
            self.source_node_id = self.node_id
            post_save_changes = True

        if post_save_changes:
            self.save()

    class MPTTMeta:
        order_insertion_by = ['sort_order']

    class Meta:
        verbose_name = _("Topic")
        verbose_name_plural = _("Topics")