Trash functionality

Introduction

The Trash feature enables both logical deletion and permanent deletion of objects. Combined with History, it provides full control over data management, including recovery and auditing of deleted records.

Model Integration

To enable trash management in your models, inherit from DeletedWithTrash. This abstract class is related to the Trash model and provides the necessary logic.

from djgentelella.models import DeletedWithTrash

class Customer(DeletedWithTrash):
    name = models.CharField(max_length=150)
    phone_number = models.CharField(max_length=150)
    email = models.EmailField()

    def __str__(self):
        return self.name

    def delete(self, using=None, keep_parents=False, *, hard=False, user=None, **kwargs):
        if self.is_deleted and not hard:
            return

        result = super().delete(
            using=using, keep_parents=keep_parents,
            hard=hard, user=user
        )
        return result

ViewSets and Deletion

There are two possible cases when overriding perform_destroy in your ViewSet:

Case 1: Without history

def perform_destroy(self, instance):
    instance.delete(user=self.request.user)

Case 2: With history

from djgentelella.history.api import BaseViewSetWithLogs
from djgentelella.history.utils import add_log, DELETION

class CustomerViewSet(BaseViewSetWithLogs):
    ...
    def perform_destroy(self, instance):
        # add log for history
        add_log(
            self.request.user,
            instance,
            DELETION,
            "customer",
            [],
            change_message=_("Deleted"),
        )
        # add user to deleted_by for trash
        instance.delete(user=self.request.user)

In both cases, you must override the perform_destroy method.

Forms

When using forms, you should exclude the is_deleted field:

from djgentelella.forms.forms import GTForm
import djgentelella.widgets.core as gtw

class CustomerForm(GTForm, forms.ModelForm):
    class Meta:
        model = Customer
        fields = "__all__"
        exclude = ["is_deleted"]
        widgets = {
            "name": gtw.TextInput,
            "email": gtw.EmailInput,
            "phone_number": gtw.PhoneNumberMaskInput,
        }

Template Example

Example HTML template for Trash:

<div class="card mt-2">
    <div class="card-body">
        <div class="card-title titles">
            <h2 class="text-center"> {% trans 'Trash' %} </h2>
        </div>

        <div class="row mt-3 ">
            <div class="col-12">
                <table class="table table-hover table-striped w-100 table-responsive" id="table-trash">
                </table>
            </div>
        </div>
    </div>
</div>

{# Modals actions #}
{% url "api-trash-list" as list_trash_url %}

{% trans 'Delete Instance' as delete_trash_tittle %}
{% include 'gentelella/blocks/modal_template_delete.html'
    with form=form_delete form_id="delete_trash_form"
    id="delete_trash_modal" title=delete_trash_tittle
    url=detail_trash_url %}

JavaScript Initialization

const trash_urls = {
    list_url: "{% url 'api-trash-list' %}",
    restore_url: "{% url "api-trash-restore"  0 %}",
    destroy_url: "{% url "api-trash-detail"  0 %}",
}

const modal_trash_ids = {
    destroy: "#delete_trash_modal",
}

const actions_trash = {
    table_actions: [],
    object_actions: [
        {
            'name': "restore",
            'action': 'restore',
            'in_action_column': true,
            'i_class': 'fa fa-undo',
            'method': 'POST',
            'title': gettext("Restore"),
            data_fn: function (data) {
                return data;
            }
        }
    ],
    title: 'Actions',
    className: "no-export-col"
}

const datatable_trash_inits = {
    columns: [
        {data: "id", name: "id", title: "ID", type: "string", visible: false},
        {
            data: "created_at",
            name: "created_at",
            title: gettext("Deleted Date"),
            visible: true,
            type: "date",
            dateformat: document.datetime_format
        },
        {
            data: "deleted_by",
            name: "deleted_by",
            title: gettext("Deleted by"),
            type: "readonly",
            visible: true,
        },
        {
            data: "object_repr",
            name: "object_repr",
            title: gettext("Description"),
            type: "readonly",
            visible: true,
        },
        {
            data: "model_name",
            name: "content_type__model",
            title: gettext("Model"),
            type: "readonly",
            visible: true,
        },
        {
            data: "actions",
            name: "actions",
            title: gettext("Actions"),
            type: "string",
            visible: true,
        },
    ],
    addfilter: true,
}

const trash_config = {
    datatable_element: "#table-trash",
    modal_ids: modal_trash_ids,
    actions: actions_trash,
    datatable_inits: datatable_trash_inits,
    add_filter: true,
    relation_render: {'field_autocomplete': 'text'},
    delete_display: data => {
        return `${gettext("Registration ID")}: ${data['id']} <br>
                ${gettext('Model')}: ${data["model_name"]} <br>
                <span class="text-danger">
                    ${gettext("This action will permanently delete the register, as well as all related data.")}
                </span>`;
    },
    icons: icons,
    urls: trash_urls,
}

const trash_crud = ObjectCRUD("trashcrudobj", trash_config)

trash_crud.restore = function (data) {
    const url = trash_urls.restore_url.replace("0", data.id)

    $.ajax({
        url: url, type: "POST", data: {}, headers: {
            "X-CSRFToken": getCookie('csrftoken'),
        }, success: function (response) {
            if (response.result) {
                Swal.fire({
                    icon: 'success',
                    title: gettext('Success'),
                    text: gettext(response.detail),
                    confirmButtonText: gettext('Accept'),
                })

                ocrud.datatable.ajax.reload() // only for example
                trash_crud.datatable.ajax.reload()
            } else {
                window.location.reload();
            }
        }, error: function (xhr, status, error) {
            console.error("Error executing the API:", error);

            Swal.fire({
                icon: 'error',
                title: gettext('Error'),
                text: gettext("Sorry, an error occurred."),
                confirmButtonText: gettext('Accept'),
            })
        }
    });
}

trash_crud.init();

Object Managers

When inheriting from DeletedWithTrash, three different managers are available to handle objects:

  • objects = ObjectManager() → Returns only non-deleted objects.

  • objects_with_deleted = AllObjectsManager() → Returns all objects, including deleted.

  • objects_deleted_only = DeletedObjectsManager() → Returns only deleted objects.

This allows flexible querying and management of your application data.

Behaviour notes

  • restore goes through get_object(): a subclass that scopes get_queryset() (a per-tenant trash) also scopes what can be restored.

  • Restoring an entry whose original object no longer exists answers 410 Gone; hard-deleting such an orphan removes the trash row.

  • perform_destroy needs no override when the viewset extends BaseViewSetWithLogs: the base logs the action and calls instance.delete(user=request.user) so deleted_by is recorded.

  • A queryset-level delete(user=...) on DeletedWithTrash managers creates the trash rows too, so a bulk soft-delete stays recoverable from the trash screen.

  • Restoring is gated by djgentelella.change_trash everywhere (viewset and serializer actions).

Deletion context and multi-tenant scoping

A deleted object rarely stands alone: it belonged to an organization, a project, a folder. delete() — instance and queryset level — accepts related_objects (a model instance or an iterable of instances; bare pks raise ValueError) and records one TrashRelation row per instance next to the Trash entry:

document.delete(user=request.user,
                related_objects=[organization, project])

Document.objects.filter(...).delete(user=request.user,
                                    related_objects=[organization])

The first deletion that provides context wins: an entry already carrying relations keeps them on a re-delete. Relations disappear with their entry (restore or hard delete) through the cascade.

That context is what a multi-tenant trash screen scopes by. TrashViewSet offers two hooks, mirroring HistoryViewSet:

  • The generic query params related_contenttype (app.model) and related_id (repeatable) narrow the list to entries related to the given object.

  • scope_queryset(queryset) is the last word on what one request may see; it also guards restore and destroy, which resolve through get_object(). Override it per tenant:

    class OrgTrashViewSet(TrashViewSet):
        def scope_queryset(self, queryset):
            return queryset.filter(
                gt_relations__content_type=self.org_ctype,
                gt_relations__object_id=self.kwargs['org_pk'],
            ).distinct()
    

recordsTotal follows the scoped queryset, so the count never leaks the size of other tenants’ trash.