from django.core.paginator import InvalidPage, Paginator from django.db import transaction from django.db.models import Sum, functions from django.http import Http404 from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.urls import reverse from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy from wagtail.admin import messages from wagtail.admin.auth import permission_required from wagtail.admin.forms.search import SearchForm from wagtail.admin.modal_workflow import render_modal_workflow from wagtail.admin.ui.tables import Column, RelatedObjectsColumn, TitleColumn from wagtail.admin.views import generic from wagtail.contrib.search_promotions import forms, models from wagtail.contrib.search_promotions.models import Query, SearchPromotion from wagtail.log_actions import log from wagtail.permission_policies.base import ModelPermissionPolicy from wagtail.search.utils import normalise_query_string class SearchPromotionColumn(RelatedObjectsColumn): cell_template_name = "wagtailsearchpromotions/search_promotion_column.html" class IndexView(generic.IndexView): model = Query template_name = "wagtailsearchpromotions/index.html" results_template_name = "wagtailsearchpromotions/index_results.html" context_object_name = "queries" page_title = gettext_lazy("Search Terms") header_icon = "pick" paginate_by = 20 permission_policy = ModelPermissionPolicy(SearchPromotion) index_url_name = "wagtailsearchpromotions:index" index_results_url_name = "wagtailsearchpromotions:index_results" _show_breadcrumbs = True search_fields = ["query_string"] default_ordering = "query_string" add_url_name = "wagtailsearchpromotions:add" add_item_label = gettext_lazy("Add new promoted result") columns = [ TitleColumn( "query_string", label=gettext_lazy("Search term(s)"), width="40%", url_name="wagtailsearchpromotions:edit", sort_key="query_string", ), SearchPromotionColumn( "editors_picks", label=gettext_lazy("Promoted results"), width="40%", ), Column( "views", label=gettext_lazy("Views (past week)"), width="20%", sort_key="views", ), ] def get_base_queryset(self): # Use a subquery to filter out the Query objects that do not have a # SearchPromotion instead of using .filter(editors_picks__isnull=False). # The latter would use a JOIN which would result in duplicate rows before # the sum is calculated, causing the sum to be incorrect. has_promotions = SearchPromotion.objects.values_list("query_id", flat=True) queryset = self.model.objects.filter(pk__in=has_promotions) # Prevent N+1 queries by annotating the sum instead of using the # Query.hits property and prefetching the related editors_picks. queryset = queryset.annotate( views=functions.Coalesce(Sum("daily_hits__hits"), 0) ).prefetch_related("editors_picks", "editors_picks__page") return queryset def get_breadcrumbs_items(self): breadcrumbs = super().get_breadcrumbs_items() breadcrumbs[-1]["label"] = _("Promoted search results") return breadcrumbs def save_searchpicks(query, new_query, searchpicks_formset): # Save if searchpicks_formset.is_valid(): # Set sort_order for i, form in enumerate(searchpicks_formset.ordered_forms): form.instance.sort_order = i # Make sure the form is marked as changed so it gets saved with the new order form.has_changed = lambda: True # log deleted items before saving, otherwise we lose their IDs items_for_deletion = [ form.instance for form in searchpicks_formset.deleted_forms if form.instance.pk ] with transaction.atomic(): for search_pick in items_for_deletion: log(search_pick, "wagtail.delete") searchpicks_formset.save() for search_pick in searchpicks_formset.new_objects: log(search_pick, "wagtail.create") # If query was changed, move all search picks to the new query if query != new_query: searchpicks_formset.get_queryset().update(query=new_query) # log all items in the formset as having changed for search_pick, changed_fields in searchpicks_formset.changed_objects: log(search_pick, "wagtail.edit") else: # only log objects with actual changes for search_pick, changed_fields in searchpicks_formset.changed_objects: if changed_fields: log(search_pick, "wagtail.edit") return True else: return False @permission_required("wagtailsearchpromotions.add_searchpromotion") def add(request): if request.method == "POST": # Get query query_form = forms.QueryForm(request.POST) if query_form.is_valid(): query = Query.get(query_form["query_string"].value()) # Save search picks searchpicks_formset = forms.SearchPromotionsFormSet( request.POST, instance=query ) if save_searchpicks(query, query, searchpicks_formset): for search_pick in searchpicks_formset.new_objects: log(search_pick, "wagtail.create") messages.success( request, _("Editor's picks for '%(query)s' created.") % {"query": query}, buttons=[ messages.button( reverse("wagtailsearchpromotions:edit", args=(query.id,)), _("Edit"), ) ], ) return redirect("wagtailsearchpromotions:index") else: if len(searchpicks_formset.non_form_errors()): # formset level error (e.g. no forms submitted) messages.error( request, " ".join( error for error in searchpicks_formset.non_form_errors() ), ) else: # specific errors will be displayed within form fields messages.error( request, _("Recommendations have not been created due to errors"), ) else: searchpicks_formset = forms.SearchPromotionsFormSet() else: query_form = forms.QueryForm() searchpicks_formset = forms.SearchPromotionsFormSet() return TemplateResponse( request, "wagtailsearchpromotions/add.html", { "query_form": query_form, "searchpicks_formset": searchpicks_formset, "form_media": query_form.media + searchpicks_formset.media, }, ) @permission_required("wagtailsearchpromotions.change_searchpromotion") def edit(request, query_id): query = get_object_or_404(Query, id=query_id) if request.method == "POST": # Get query query_form = forms.QueryForm(request.POST) # and the recommendations searchpicks_formset = forms.SearchPromotionsFormSet( request.POST, instance=query ) if query_form.is_valid(): new_query = Query.get(query_form["query_string"].value()) # Save search picks if save_searchpicks(query, new_query, searchpicks_formset): messages.success( request, _("Editor's picks for '%(query)s' updated.") % {"query": new_query}, buttons=[ messages.button( reverse("wagtailsearchpromotions:edit", args=(query.id,)), _("Edit"), ) ], ) return redirect("wagtailsearchpromotions:index") else: if len(searchpicks_formset.non_form_errors()): messages.error( request, " ".join( error for error in searchpicks_formset.non_form_errors() ), ) # formset level error (e.g. no forms submitted) else: messages.error( request, _("Recommendations have not been saved due to errors") ) # specific errors will be displayed within form fields else: query_form = forms.QueryForm(initial={"query_string": query.query_string}) searchpicks_formset = forms.SearchPromotionsFormSet(instance=query) return TemplateResponse( request, "wagtailsearchpromotions/edit.html", { "query_form": query_form, "searchpicks_formset": searchpicks_formset, "query": query, "form_media": query_form.media + searchpicks_formset.media, }, ) @permission_required("wagtailsearchpromotions.delete_searchpromotion") def delete(request, query_id): query = get_object_or_404(Query, id=query_id) if request.method == "POST": editors_picks = query.editors_picks.all() with transaction.atomic(): for search_pick in editors_picks: log(search_pick, "wagtail.delete") editors_picks.delete() messages.success(request, _("Editor's picks deleted.")) return redirect("wagtailsearchpromotions:index") return TemplateResponse( request, "wagtailsearchpromotions/confirm_delete.html", { "query": query, }, ) def chooser(request, get_results=False): # Get most popular queries queries = models.Query.get_most_popular() # If searching, filter results by query string if "q" in request.GET: searchform = SearchForm(request.GET) if searchform.is_valid(): query_string = searchform.cleaned_data["q"] queries = queries.filter( query_string__icontains=normalise_query_string(query_string) ) else: searchform = SearchForm() paginator = Paginator(queries, per_page=10) try: queries = paginator.page(request.GET.get("p", 1)) except InvalidPage: raise Http404 # Render if get_results: return TemplateResponse( request, "wagtailsearchpromotions/queries/chooser/results.html", { "queries": queries, }, ) else: return render_modal_workflow( request, "wagtailsearchpromotions/queries/chooser/chooser.html", None, { "queries": queries, "searchform": searchform, }, json_data={"step": "chooser"}, ) def chooserresults(request): return chooser(request, get_results=True)