Skip to content

CRUD Operations

This guide explains how Create, Read, Update, and Delete operations work in django-admin-deux through the action-based pattern.

The Action-Based Pattern

Unlike traditional admin interfaces where CRUD is baked into views, django-admin-deux implements CRUD through actions. This provides several benefits:

  • Consistency: All operations follow the same action pattern
  • Extensibility: Easy to add new operations
  • Flexibility: Customize or replace any CRUD action
  • Discoverability: All operations are visible through the action system

Understanding the CRUD Actions

The core plugin provides four default CRUD actions:

Action Type Description Trigger Location
AddAction List Action Create new record ListView toolbar
EditAction Record Action Update existing record Each row in ListView
DeleteAction Record Action Delete single record Each row in ListView
DeleteBulkAction Bulk Action Delete multiple records ListView with selections

These actions are automatically available unless you override them.

Create Operations

How It Works

  1. User clicks "Add" button in ListView
  2. AddAction redirects to a CreateView
  3. CreateView displays form with create_fields or fields
  4. On submit, model instance is created
  5. User is redirected back to ListView

Basic Create

from djadmin import ModelAdmin, register

@register(Product)
class ProductAdmin(ModelAdmin):
    # AddAction is provided by default
    # Uses create_fields, falls back to fields
    create_fields = ['name', 'sku', 'price', 'category']

Custom Create Form

from django import forms
from .models import Product

class ProductCreateForm(forms.ModelForm):
    class Meta:
        model = Product
        fields = ['name', 'sku', 'price', 'category']
        widgets = {
            'category': forms.Select(attrs={'class': 'select2'}),
        }

    def clean_sku(self):
        sku = self.cleaned_data['sku']
        if Product.objects.filter(sku=sku).exists():
            raise forms.ValidationError("SKU already exists")
        return sku

@register(Product)
class ProductAdmin(ModelAdmin):
    create_form_class = ProductCreateForm

Customizing AddAction

You can customize the AddAction or replace it entirely:

from djadmin.plugins.core.actions import AddAction

class CustomAddAction(AddAction):
    label = 'Create Product'
    icon = 'plus-circle'

    def get_fields(self):
        # Dynamic field selection based on user
        if self.request.user.is_superuser:
            return '__all__'
        return ['name', 'sku', 'price']

@register(Product)
class ProductAdmin(ModelAdmin):
    list_actions = [CustomAddAction]

Create Success URL

By default, successful creation redirects to the ListView. Customize in AddAction:

class CustomAddAction(AddAction):
    def get_success_url(self):
        # Redirect to the newly created object's edit page
        opts = self.model._meta
        return reverse(
            f'djadmin:{opts.app_label}_{opts.model_name}_detail',
            args=[self.object.pk],
            current_app=self.admin_site.name,
        )

@register(Product)
class ProductAdmin(ModelAdmin):
    list_actions = [CustomAddAction]

Read Operations

List View (Read All)

The default ListAction provides the "read all" operation:

@register(Product)
class ProductAdmin(ModelAdmin):
    list_display = ['name', 'sku', 'price', 'status']
    paginate_by = 50

The ListView shows all records with the configured columns, pagination, and actions.

Detail View (Read One)

Currently, detail views are implemented through EditAction, which shows the update form. For read-only detail views, you can customize:

from djadmin.actions import BaseAction, RecordActionMixin
from django.views.generic import DetailView

class ViewAction(RecordActionMixin, BaseAction):
    label = 'View'
    icon = 'eye'

    def get_view_class(self):
        return DetailView

    def get_template_name(self):
        opts = self.model._meta
        return [
            f'djadmin/{opts.app_label}/{opts.model_name}_view.html',
            'djadmin/actions/view.html',
        ]

@register(Product)
class ProductAdmin(ModelAdmin):
    record_actions = [ViewAction, EditAction, DeleteAction]

Update Operations

How It Works

  1. User clicks "Edit" button in a ListView row
  2. EditAction redirects to an UpdateView
  3. UpdateView displays form with update_fields or fields
  4. On submit, model instance is updated
  5. User is redirected back to ListView

Basic Update

@register(Product)
class ProductAdmin(ModelAdmin):
    # EditAction is provided by default
    # Uses update_fields, falls back to fields
    update_fields = '__all__'

Different Fields for Update

@register(Product)
class ProductAdmin(ModelAdmin):
    # Simple create form
    create_fields = ['name', 'sku', 'price']

    # Full update form
    update_fields = '__all__'

Custom Update Form

class ProductUpdateForm(forms.ModelForm):
    class Meta:
        model = Product
        fields = '__all__'

    def clean_stock_quantity(self):
        quantity = self.cleaned_data['stock_quantity']
        if quantity < 0:
            raise forms.ValidationError("Stock cannot be negative")
        return quantity

@register(Product)
class ProductAdmin(ModelAdmin):
    update_form_class = ProductUpdateForm

Customizing EditAction

from djadmin.plugins.core.actions import EditAction

class CustomEditAction(EditAction):
    label = 'Modify'
    icon = 'edit'

    def get_fields(self):
        # Different fields based on object state
        if self.object.status == 'published':
            # Can't edit critical fields after publishing
            return ['title', 'content', 'tags']
        return '__all__'

@register(Product)
class ProductAdmin(ModelAdmin):
    record_actions = [CustomEditAction, DeleteAction]

Update Success URL

class CustomEditAction(EditAction):
    def get_success_url(self):
        # Stay on edit page after save
        opts = self.model._meta
        return reverse(
            f'djadmin:{opts.app_label}_{opts.model_name}_detail',
            args=[self.object.pk],
            current_app=self.admin_site.name,
        )

Delete Operations

Single Record Delete

The DeleteAction handles deleting individual records:

@register(Product)
class ProductAdmin(ModelAdmin):
    # DeleteAction is provided by default
    record_actions = [EditAction, DeleteAction]

Flow: 1. User clicks "Delete" button on a row 2. Confirmation page displays 3. User confirms deletion 4. Record is deleted 5. User redirected to ListView

Bulk Delete

The DeleteBulkAction handles deleting multiple selected records:

@register(Product)
class ProductAdmin(ModelAdmin):
    # DeleteBulkAction is provided by default
    bulk_actions = [DeleteBulkAction]

Flow: 1. User selects multiple checkboxes in ListView 2. User clicks "Delete Selected" button 3. Confirmation page shows count and list 4. User confirms deletion 5. Records are deleted in a transaction 6. User redirected to ListView

Customizing Delete Actions

from djadmin.plugins.core.actions import DeleteAction, DeleteBulkAction

class SafeDeleteAction(DeleteAction):
    label = 'Archive'
    icon = 'archive'

    # Declarative permission: Only staff can archive
    permission_class = IsStaff() & HasDjangoPermission(perm='delete')

    def get_template_name(self):
        return 'myapp/confirm_archive.html'

    # Override the actual deletion to soft-delete
    def delete(self, request, *args, **kwargs):
        self.object.is_archived = True
        self.object.save()
        messages.success(request, f"{self.object} archived successfully")
        return redirect(self.get_success_url())

@register(Product)
class ProductAdmin(ModelAdmin):
    record_actions = [EditAction, SafeDeleteAction]

Delete Confirmation

Customize the confirmation message:

class CustomDeleteAction(DeleteAction):
    def get_confirmation_message(self, obj=None, queryset=None):
        if obj:
            return f"Are you sure you want to delete {obj.name}? This cannot be undone."
        return "Are you sure?"

Delete Success URL

class CustomDeleteAction(DeleteAction):
    def get_success_url(self):
        # Redirect to dashboard instead of list
        return reverse('djadmin:index', current_app=self.admin_site.name)

Form Validation and Error Handling

Server-Side Validation

Use Django form validation:

class ProductForm(forms.ModelForm):
    class Meta:
        model = Product
        fields = '__all__'

    def clean_price(self):
        price = self.cleaned_data['price']
        cost = self.cleaned_data.get('cost')

        if price <= 0:
            raise forms.ValidationError("Price must be positive")

        if cost and price < cost:
            raise forms.ValidationError("Price cannot be less than cost")

        return price

    def clean(self):
        cleaned_data = super().clean()
        status = cleaned_data.get('status')
        stock = cleaned_data.get('stock_quantity')

        if status == 'active' and stock == 0:
            raise forms.ValidationError("Cannot activate product with zero stock")

        return cleaned_data

@register(Product)
class ProductAdmin(ModelAdmin):
    form_class = ProductForm

Model-Level Validation

Validation in model's clean() method is automatically enforced:

class Product(models.Model):
    name = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    cost = models.DecimalField(max_digits=10, decimal_places=2)

    def clean(self):
        if self.price < self.cost:
            raise ValidationError({
                'price': 'Price cannot be less than cost'
            })

Error Display

Validation errors are automatically displayed in the form: - Field-specific errors appear next to the field - Form-wide errors appear at the top - Django messages framework shows success/error messages

Custom Error Messages

class ProductForm(forms.ModelForm):
    class Meta:
        model = Product
        fields = '__all__'
        error_messages = {
            'sku': {
                'unique': 'A product with this SKU already exists.',
                'required': 'SKU is required for all products.',
            },
        }

Success Messages

Django's messages framework is used for feedback:

from django.contrib import messages

class CustomAddAction(AddAction):
    def form_valid(self, form):
        response = super().form_valid(form)
        messages.success(
            self.request,
            f"Product '{self.object.name}' created successfully!"
        )
        return response

Success URL Patterns

Common redirection patterns:

Redirect to List (Default)

def get_success_url(self):
    opts = self.model._meta
    return reverse(
        f'djadmin:{opts.app_label}_{opts.model_name}_list',
        current_app=self.admin_site.name,
    )

Redirect to Edit Page

def get_success_url(self):
    opts = self.model._meta
    return reverse(
        f'djadmin:{opts.app_label}_{opts.model_name}_detail',
        args=[self.object.pk],
        current_app=self.admin_site.name,
    )

Redirect to Dashboard

def get_success_url(self):
    return reverse('djadmin:index', current_app=self.admin_site.name)

Conditional Redirect

def get_success_url(self):
    # Check for 'continue' button in form
    if '_continue' in self.request.POST:
        opts = self.model._meta
        return reverse(
            f'djadmin:{opts.app_label}_{opts.model_name}_detail',
            args=[self.object.pk],
            current_app=self.admin_site.name,
        )

    # Default: back to list
    return super().get_success_url()

Advanced Patterns

Multi-Step Creation

Create records with related objects:

from django.db import transaction

class ProductWithVariantsForm(forms.ModelForm):
    variant_count = forms.IntegerField(min_value=1)

    class Meta:
        model = Product
        fields = ['name', 'sku', 'price']

class ProductWithVariantsAction(AddAction):
    def form_valid(self, form):
        with transaction.atomic():
            # Create main product
            self.object = form.save()

            # Create variants
            count = form.cleaned_data['variant_count']
            for i in range(count):
                ProductVariant.objects.create(
                    product=self.object,
                    name=f"Variant {i+1}",
                )

        messages.success(
            self.request,
            f"Created {self.object.name} with {count} variants"
        )
        return redirect(self.get_success_url())

@register(Product)
class ProductAdmin(ModelAdmin):
    list_actions = [ProductWithVariantsAction]

Conditional Actions

For state-dependent actions, check state in the execute() method:

class PublishAction(RecordActionMixin, BaseAction):
    label = 'Publish'
    icon = 'check'
    permission_class = IsStaff() & HasDjangoPermission(perm='change')

    def execute(self, request, obj, **kwargs):
        # Check state at execution time
        if obj.status == 'published':
            messages.error(request, f"'{obj.title}' is already published")
            return redirect(self.get_success_url())

        obj.status = 'published'
        obj.published_date = timezone.now()
        obj.save()
        messages.success(request, f"'{obj.title}' published successfully")
        return redirect(self.get_success_url())

@register(Post)
class PostAdmin(ModelAdmin):
    record_actions = [EditAction, PublishAction, DeleteAction]

Note: For per-record visibility (e.g., showing "Publish" only for drafts), handle this in templates rather than at the action level, as action filtering happens at the list level, not per-record.

Pre-filled Forms

Pre-populate create forms:

class DuplicateAction(RecordActionMixin, BaseAction):
    label = 'Duplicate'
    icon = 'copy'

    def execute(self, request, obj, **kwargs):
        # Create form pre-filled with object data
        form_class = self.model_admin.create_form_class or ProductForm
        form = form_class(initial={
            'name': f"{obj.name} (Copy)",
            'sku': f"{obj.sku}-copy",
            'price': obj.price,
            'category': obj.category,
        })

        # Render create template with pre-filled form
        context = {
            'form': form,
            'original': obj,
            'opts': self.model._meta,
        }
        return render(request, 'djadmin/actions/add.html', context)

@register(Product)
class ProductAdmin(ModelAdmin):
    record_actions = [EditAction, DuplicateAction, DeleteAction]

See Also