# Article Bulk Actions ## Example Overview The **Article Bulk Actions** example demonstrates how to use `django_tomselect` to enable selecting multiple articles and applying bulk actions like publishing, archiving, or assigning categories/authors. The example highlights dynamic filtering and multi-select capabilities with rich dropdowns. **Objective**: - Showcase multi-select dropdowns for managing multiple articles at once. - Demonstrate filtering articles dynamically based on user-selected criteria such as date range, category, or status. **Use Case**: - Editorial platforms managing large volumes of articles requiring bulk operations. - Content moderation tools for efficiently updating the status or attributes of multiple items. **Visual Examples** ![Screenshot: Article Bulk Actions](https://raw.githubusercontent.com/OmenApps/django-tomselect/refs/heads/main/docs/images/article-bulk-action1.png) ![Screenshot: Article Bulk Actions](https://raw.githubusercontent.com/OmenApps/django-tomselect/refs/heads/main/docs/images/article-bulk-action2.png) --- ## Key Code Segments ### Forms The bulk action form combines filters and actions with dynamic dropdowns for flexible workflows. :::{admonition} Article Bulk Action Form :class: dropdown ```python class ArticleBulkActionForm(forms.Form): """Form for bulk article management.""" date_range = forms.ChoiceField( required=False, choices=[ ("all", _("All Time")), ("today", _("Today")), ("week", _("Past Week")), ("month", _("Past Month")), ("quarter", _("Past Quarter")), ("year", _("Past Year")), ], initial="all", widget=forms.Select(attrs={"class": "form-select"}), ) main_category = TomSelectModelChoiceField( required=False, config=TomSelectConfig( url="autocomplete-category", value_field="id", label_field="name", placeholder=_("Filter by category..."), highlight=True, plugin_dropdown_header=PluginDropdownHeader( title=_("Categories"), extra_columns={ "total_articles": _("Total Articles"), }, ), ), ) status = TomSelectChoiceField( required=False, config=TomSelectConfig( url="autocomplete-article-status", value_field="value", label_field="label", placeholder=_("Filter by status..."), highlight=True, preload="focus", ), ) def __init__(self, *args, **kwargs): """Initialize form and update selected_articles config with current filters.""" super().__init__(*args, **kwargs) # Get initial filter values from kwargs or form data data = kwargs.get("data") or kwargs.get("initial", {}) date_range = data.get("date_range", "all") main_category = data.get("main_category", "") status = data.get("status", "") # Build the autocomplete_params string params = [] if date_range and date_range != "all": params.append(f"date_range={date_range}") if main_category: params.append(f"main_category={main_category}") if status: params.append(f"status={status}") autocomplete_params = "&".join(params) # Create the selected_articles field with dynamic filtering self.fields["selected_articles"] = TomSelectModelMultipleChoiceField( required=False, config=TomSelectConfig( url="autocomplete-article", value_field="id", label_field="title", placeholder=_("Select articles..."), highlight=True, max_items=None, plugin_dropdown_header=PluginDropdownHeader( title=_("Articles"), extra_columns={ "status": _("Status"), "category": _("Category"), }, ), # Pass filter parameters via attrs attrs={ "autocomplete_params": autocomplete_params, "data-depends-on": "date_range,main_category,status", # Fields this depends on "class": "tomselect-with-filters", # Class for easy JS targeting }, ), ) action = forms.ChoiceField( choices=[ ("", _("Select action...")), ("publish", _("Publish")), ("archive", _("Archive")), ("change_category", _("Change Category")), ("assign_author", _("Assign Author")), ], required=False, widget=forms.Select(attrs={"class": "form-select"}), ) target_category = TomSelectModelChoiceField( required=False, config=TomSelectConfig( url="autocomplete-category", value_field="id", label_field="name", placeholder=_("Select target category..."), ), ) target_author = TomSelectModelChoiceField( required=False, config=TomSelectConfig( url="autocomplete-author", value_field="id", label_field="name", placeholder=_("Select target author..."), ), ) def clean(self): """Validate that the necessary fields are provided based on the selected action.""" cleaned_data = super().clean() action = cleaned_data.get("action") selected_articles = cleaned_data.get("selected_articles") if action and not selected_articles: raise ValidationError(_("Please select at least one article")) if action == "change_category" and not cleaned_data.get("target_category"): raise ValidationError(_("Please select a target category")) if action == "assign_author" and not cleaned_data.get("target_author"): raise ValidationError(_("Please select a target author")) return cleaned_data ``` ::: --- ### Templates The form is rendered dynamically in the template, allowing users to filter articles and apply actions. When filters change, the article list updates accordingly via HTMX. #### Main Template :::{admonition} Bulk Action Form Template :class: dropdown ```html {% extends "example/base_with_bootstrap5.html" %} {% block extra_header %} {{ form.media }} {% endblock %} {% block content %}

Bulk Article Management

{{ paginator.count }} articles

Filters

{{ form.date_range }}
{{ form.main_category }}
{{ form.status }}

Bulk Actions

{% csrf_token %}
{{ form.selected_articles }} {% if form.selected_articles.errors %}
{{ form.selected_articles.errors }}
{% endif %}
{{ form.action }} {% if form.action.errors %}
{{ form.action.errors }}
{% endif %}
{{ form.target_category }} {% if form.target_category.errors %}
{{ form.target_category.errors }}
{% endif %}
{{ form.target_author }} {% if form.target_author.errors %}
{{ form.target_author.errors }}
{% endif %}
{% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %}
{% block articles_table %}

Filtered Articles

{{ paginator.count }} articles
{% include "example/advanced_demos/articles_table.html" %}
{% endblock %}
{% endblock %} ``` ::: #### Tables Template :::{admonition} Articles Table Template :class: dropdown ```html {{ paginator.count }} articles {% for article in articles|default:page_obj %} {% empty %} {% endfor %}
Title Category Author Status Created
{{ article.title }} {% for category in article.categories.all %} {{ category.name }} {% endfor %} {% for author in article.authors.all %} {{ author.name }}{% if not forloop.last %}, {% endif %} {% endfor %} {{ article.get_status_display }} {{ article.created_at|date:"Y-m-d H:i" }}
No articles match the current filters
{% if is_paginated %} {% endif %} ``` ::: --- ### Autocomplete Views Multiple autocomplete views are used to populate the dropdowns with dynamic data, such as categories, authors, and article statuses. Please see the example app's [Autocomplete Views Code](https://github.com/OmenApps/django-tomselect/blob/main/example_project/example/autocompletes.py) for more details. --- ### Views We use two views to handle the bulk actions and filtered article table. The `article_bulk_action_view` processes the bulk actions, while `article_filtered_table` returns the filtered articles table HTML. :::{admonition} Bulk Action View :class: dropdown ```python def article_bulk_action_view(request): """View for bulk article management.""" template = "example/advanced_demos/bulk_action.html" context = {} # Get current filter values from GET parameters date_range = request.GET.get("date_range", "all") main_category = request.GET.get("main_category") status = request.GET.get("status") # Build queryset with filters articles = Article.objects.all() if date_range != "all": now = timezone.now() date_filters = { "today": now.date(), "week": now - timedelta(days=7), "month": now - timedelta(days=30), "quarter": now - timedelta(days=90), "year": now - timedelta(days=365), } if date_range in date_filters: if date_range == "today": articles = articles.filter(created_at__date=date_filters[date_range]) else: articles = articles.filter(created_at__gte=date_filters[date_range]) if main_category: articles = articles.filter(categories__id=main_category) if status: articles = articles.filter(status=status) if request.method == "POST": form = ArticleBulkActionForm(request.POST) if form.is_valid(): selected_articles = form.cleaned_data["selected_articles"] action = form.cleaned_data["action"] if selected_articles: try: # Convert selected_articles to a queryset if it isn't already if not isinstance(selected_articles, models.QuerySet): article_ids = [art.id for art in selected_articles] selected_articles = Article.objects.filter(id__in=article_ids) if action == "publish": selected_articles.update(status=ArticleStatus.PUBLISHED) messages.success( request, _("{} articles published successfully").format(selected_articles.count()), ) elif action == "archive": selected_articles.update(status=ArticleStatus.ARCHIVED) messages.success( request, _("{} articles archived successfully").format(selected_articles.count()), ) elif action == "change_category": target_category = form.cleaned_data["target_category"] if target_category: for article in selected_articles: article.categories.clear() article.categories.add(target_category) messages.success( request, _("Category updated for {} articles").format(selected_articles.count()), ) elif action == "assign_author": target_author = form.cleaned_data["target_author"] if target_author: for article in selected_articles: article.authors.add(target_author) messages.success( request, _("Author assigned to {} articles").format(selected_articles.count()), ) # Redirect to preserve filters url = f"{reverse('article-bulk-action')}?{request.GET.urlencode()}" return HttpResponseRedirect(url) except Exception as e: messages.error(request, f"Error performing bulk action: {str(e)}") else: messages.error(request, "Please correct the form errors: %s" % form.errors) else: # Initialize form with current filter values form = ArticleBulkActionForm( initial={"date_range": date_range, "main_category": main_category, "status": status} ) # Pagination paginator = Paginator(articles.distinct(), 20) page = request.GET.get("page", 1) try: articles_page = paginator.page(page) except PageNotAnInteger: articles_page = paginator.page(1) except EmptyPage: articles_page = paginator.page(paginator.num_pages) context.update( { "form": form, "page_obj": articles_page, "paginator": paginator, "is_paginated": paginator.num_pages > 1, "articles": articles_page, # Add current filter values to context "current_filters": {"date_range": date_range, "main_category": main_category, "status": status}, } ) return TemplateResponse(request, template, context) @require_GET def article_filtered_table(request): """Return the filtered articles table HTML with OOB updates.""" template = "example/advanced_demos/articles_table.html" # Get filter values date_range = request.GET.get("date_range", "all") main_category = request.GET.get("main_category") status = request.GET.get("status") # Build queryset with filters articles = Article.objects.all() if date_range != "all": now = timezone.now() date_filters = { "today": now.date(), "week": now - timedelta(days=7), "month": now - timedelta(days=30), "quarter": now - timedelta(days=90), "year": now - timedelta(days=365), } if date_range in date_filters: if date_range == "today": articles = articles.filter(created_at__date=date_filters[date_range]) else: articles = articles.filter(created_at__gte=date_filters[date_range]) if main_category: articles = articles.filter(categories__id=main_category) if status: articles = articles.filter(status=status) articles = articles.distinct() # Pagination paginator = Paginator(articles, 20) page = request.GET.get("page", 1) try: articles_page = paginator.page(page) except PageNotAnInteger: articles_page = paginator.page(1) except EmptyPage: articles_page = paginator.page(paginator.num_pages) context = { "articles": articles_page, "is_paginated": paginator.num_pages > 1, "page_obj": articles_page, "paginator": paginator, "current_filters": {"date_range": date_range, "main_category": main_category, "status": status}, } return TemplateResponse(request, template, context) ``` ::: --- ## Design and Implementation Notes ### Key Features - **Multi-Select Dropdowns**: Easily select multiple articles and apply bulk operations. - **Dynamic Filtering**: Filters such as date range, category, and status dynamically refine the article selection process. ### Design Decisions - `PluginDropdownHeader` provides additional metadata for each dropdown (e.g., category total articles). - Target fields like "Category" or "Author" are conditionally required based on the action type, ensuring flexibility.