From 382b6f82a728e9423c560d804831a03ed80ad740 Mon Sep 17 00:00:00 2001 From: Tasos Katsoulas Date: Tue, 19 Nov 2024 15:58:26 +0200 Subject: [PATCH] Expose segmentation tags in moderation --- .../jinja2/flagit/content_moderation.html | 12 +- .../flagit/includes/flagged_question.html | 34 ++-- kitsune/flagit/jinja2/flagit/queue.html | 2 +- kitsune/flagit/views.py | 5 +- kitsune/questions/views.py | 51 ++++- kitsune/sumo/static/sumo/js/flagit.js | 191 ++++++++++-------- .../static/sumo/scss/components/_flaggit.scss | 3 + kitsune/tags/models.py | 8 + 8 files changed, 198 insertions(+), 108 deletions(-) diff --git a/kitsune/flagit/jinja2/flagit/content_moderation.html b/kitsune/flagit/jinja2/flagit/content_moderation.html index 2ac5ca29115..861ddddcb56 100644 --- a/kitsune/flagit/jinja2/flagit/content_moderation.html +++ b/kitsune/flagit/jinja2/flagit/content_moderation.html @@ -7,7 +7,11 @@

{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}

{% if object.notes %} -

{{ _('Other reason:') }} {{ object.notes }}

+ {% if object.content_type.model == 'question' %} +

{{ _('Additional notes:') }}  {{ object.notes }}

+ {% else %} +

{{ _('Additional notes:') }} {{ object.notes }}

+ {% endif %} {% endif %}
@@ -33,4 +37,8 @@


{{ _('Update Status:') }}

{% else %}

{{ _('There is no content pending moderation.') }}

{% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} + +{# Hide the deactivation log on content moderation #} +{% block deactivation_log %} +{% endblock %} diff --git a/kitsune/flagit/jinja2/flagit/includes/flagged_question.html b/kitsune/flagit/jinja2/flagit/includes/flagged_question.html index 9e8cbbb06ea..3bbaf6a06a5 100644 --- a/kitsune/flagit/jinja2/flagit/includes/flagged_question.html +++ b/kitsune/flagit/jinja2/flagit/includes/flagged_question.html @@ -24,20 +24,28 @@

{{ _('Flagged:') }}

{% if object.reason == 'content_moderation' and question_model and user.has_perm('questions.change_question') %}

{{ _('Take Action:') }}

- -
-

{{ object.content_object.topic }}

-
+ +

{{ object.content_object.topic }}

+ +
+ {% csrf_token %} + + + +
+
{% endif %}
diff --git a/kitsune/flagit/jinja2/flagit/queue.html b/kitsune/flagit/jinja2/flagit/queue.html index 328eb069eab..8eae3ad548f 100644 --- a/kitsune/flagit/jinja2/flagit/queue.html +++ b/kitsune/flagit/jinja2/flagit/queue.html @@ -7,7 +7,7 @@

{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}

{% if object.notes %} -

{{ _('Other reason:') }} {{ object.notes }}

+

{{ _('Additional notes:') }} {{ object.notes }}

{% endif %}
diff --git a/kitsune/flagit/views.py b/kitsune/flagit/views.py index d620c057950..e0f9d01bda7 100644 --- a/kitsune/flagit/views.py +++ b/kitsune/flagit/views.py @@ -14,6 +14,7 @@ from kitsune.questions.models import Answer, Question from kitsune.sumo.templatetags.jinja_helpers import urlparams from kitsune.sumo.urlresolvers import reverse +from kitsune.tags.models import SumoTag def get_flagged_objects(reason=None, exclude_reason=None, content_model=None): @@ -118,11 +119,13 @@ def moderate_content(request): .prefetch_related("content_object__product") ) objects = set_form_action_for_objects(objects, reason=FlaggedObject.REASON_CONTENT_MODERATION) + available_tags = SumoTag.objects.segmentation_tags().values("id", "name") for obj in objects: question = obj.content_object obj.available_topics = Topic.active.filter(products=question.product, is_archived=False) - + obj.available_tags = available_tags + obj.saved_tags = question.tags.values_list("id", flat=True) return render( request, "flagit/content_moderation.html", diff --git a/kitsune/questions/views.py b/kitsune/questions/views.py index acca08b3fa3..00043d2b772 100644 --- a/kitsune/questions/views.py +++ b/kitsune/questions/views.py @@ -3,6 +3,7 @@ import random from collections import OrderedDict from datetime import date, datetime, timedelta +from typing import List, Optional, Tuple, Union import requests from django.conf import settings @@ -16,6 +17,7 @@ from django.db.models.functions import Now from django.http import ( Http404, + HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, @@ -1030,6 +1032,14 @@ def add_tag_async(request, question_id): If the question already has the tag, do nothing. """ + + if request.content_type == "application/json": + tag_ids = json.loads(request.body).get("tags", []) + question, tags = _add_tag(request, question_id, tag_ids) + if not tags: + return JsonResponse({"error": "Some tags do not exist or are invalid"}, status=400) + return JsonResponse({"message": "Tags updated successfully.", "data": {"tags": tags}}) + try: question, canonical_name = _add_tag(request, question_id) except SumoTag.DoesNotExist: @@ -1079,13 +1089,26 @@ def remove_tag_async(request, question_id): If question doesn't have that tag, do nothing. Return value is JSON. """ + + question = get_object_or_404(Question, pk=question_id) + if request.content_type == "application/json": + data = json.loads(request.body) + tag_id = data.get("tagId") + + try: + tag = SumoTag.objects.get(id=tag_id) + except SumoTag.DoesNotExist: + return JsonResponse({"error": "Tag does not exist."}, status=400) + + question.tags.remove(tag) + question.clear_cached_tags() + return JsonResponse({"message": f"Tag '{tag.name}' removed successfully."}) + name = request.POST.get("name") if name: - question = get_object_or_404(Question, pk=question_id) question.tags.remove(name) question.clear_cached_tags() return HttpResponse("{}", content_type="application/json") - return HttpResponseBadRequest( json.dumps({"error": str(NO_TAG)}), content_type="application/json" ) @@ -1424,17 +1447,27 @@ def _answers_data(request, question_id, form=None, watch_form=None, answer_previ } -def _add_tag(request, question_id): - """Add a named tag to a question, creating it first if appropriate. - - Tag name (case-insensitive) must be in request.POST['tag-name']. +def _add_tag( + request: HttpRequest, question_id: int, tag_ids: Optional[List[int]] = None +) -> Tuple[Optional[Question], Union[List[str], str, None]]: + """Add tags to a question by tag IDs or tag name. - If no tag name is provided or SumoTag.DoesNotExist is raised, return None. - Otherwise, return the canonicalized tag name. + If tag_ids is provided, adds tags with those IDs to the question. + Otherwise looks for tag name in request.POST['tag-name']. + Returns a tuple of (question, tag_names) if successful. + Returns (None, None) if no valid tags found or SumoTag.DoesNotExist raised. """ + + question = get_object_or_404(Question, pk=question_id) + if tag_ids: + sumo_tags = SumoTag.objects.filter(id__in=tag_ids) + if len(tag_ids) != len(sumo_tags): + return None, None + question.tags.add(*sumo_tags) + return question, list(sumo_tags.values_list("name", flat=True)) + if tag_name := request.POST.get("tag-name", "").strip(): - question = get_object_or_404(Question, pk=question_id) # This raises SumoTag.DoesNotExist if the tag doesn't exist. canonical_name = add_existing_tag(tag_name, question.tags) diff --git a/kitsune/sumo/static/sumo/js/flagit.js b/kitsune/sumo/static/sumo/js/flagit.js index 59465bc7568..9ca6eae5ed7 100644 --- a/kitsune/sumo/static/sumo/js/flagit.js +++ b/kitsune/sumo/static/sumo/js/flagit.js @@ -1,123 +1,150 @@ -document.addEventListener('DOMContentLoaded', () => { +import TomSelect from 'tom-select'; - const { reasonFilter, flaggedQueue } = { - reasonFilter: document.getElementById('flagit-reason-filter'), - flaggedQueue: document.getElementById('flagged-queue'), - }; +document.addEventListener('DOMContentLoaded', () => { + const csrfToken = document.querySelector('input[name=csrfmiddlewaretoken]')?.value; + // Disable all update buttons initially function disableUpdateStatusButtons() { - const updateStatusButtons = document.querySelectorAll('form.update.inline-form input[type="submit"]'); - updateStatusButtons.forEach(button => { + document.querySelectorAll('form.update.inline-form input[type="submit"]').forEach(button => { button.disabled = true; }); } disableUpdateStatusButtons(); - function updateUrlParameter(action, param, value) { - const url = new URL(window.location.href); - - if (action === 'set') { - if (value) { - url.searchParams.set(param, value); - window.history.pushState({}, '', url); - } else { - url.searchParams.delete(param); - window.history.replaceState({}, '', url.pathname); - } - } else if (action === 'get') { - return url.searchParams.get(param); - } - } - - async function fetchAndUpdateContent(url) { - const response = await fetchData(url); - if (response) { - const data = await response.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(data, 'text/html'); - flaggedQueue.innerHTML = doc.querySelector('#flagged-queue').innerHTML; - disableUpdateStatusButtons(); - handleDropdownChange(); - } - } - async function fetchData(url, options = {}) { try { + const headers = { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/json', + ...options.headers + }; + + if (options.method && options.method !== 'GET' && csrfToken) { + headers['X-CSRFToken'] = csrfToken; + } + const response = await fetch(url, { method: options.method || 'GET', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - ...options.headers - }, - body: options.body || null + headers, + body: options.body ? JSON.stringify(options.body) : null }); + if (!response.ok) { throw new Error(`Error: ${response.statusText}`); } - return response; + + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } + + return response; // Return raw response if not JSON } catch (error) { - console.error('Error:', error); + console.error('Error in fetchData:', error); return null; } } + function findUpdateButton(questionId) { + return document.querySelector(`form.update.inline-form input[id="update-status-button-${questionId}"]`); + } - if (reasonFilter) { - let reason = updateUrlParameter('get', 'reason'); - if (reason) { - reasonFilter.value = reason; + function updateStatusSelect(updateButton) { + const statusDropdown = updateButton?.previousElementSibling; + if (statusDropdown && statusDropdown.tagName === 'SELECT') { + statusDropdown.value = '1'; } + } - reasonFilter.addEventListener('change', async () => { - const selectedReason = reasonFilter.value; - - updateUrlParameter('set', 'reason', selectedReason); - fetchAndUpdateContent(new URL(window.location.href)); - }); + function enableUpdateButton(updateButton) { + if (updateButton) { + updateButton.disabled = false; + } } - function handleDropdownChange() { - const dropdowns = document.querySelectorAll('.topic-dropdown, select[name="status"]'); - dropdowns.forEach(dropdown => { + function initializeDropdownsAndTags() { + document.querySelectorAll('.topic-dropdown, .tag-select').forEach(dropdown => { + const questionId = dropdown.dataset.questionId; + dropdown.addEventListener('change', async function () { const form = this.closest('form'); - const questionId = this.getAttribute('data-question-id'); - const updateButton = document.getElementById(`update-status-button-${questionId}`) || form.querySelector('input[type="submit"]'); + const updateButton = findUpdateButton(questionId); - if (!this.value || this.value === "") { - updateButton.disabled = true; - return; - } - updateButton.disabled = false; + enableUpdateButton(updateButton); + // Update topic if (this.classList.contains('topic-dropdown')) { - const topicId = this.value; - const csrfToken = form.querySelector('input[name=csrfmiddlewaretoken]').value; - const currentTopic = document.getElementById(`current-topic-${questionId}`); - - const response = await fetchData(`/en-US/questions/${questionId}/edit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrfToken - }, - body: JSON.stringify({ 'topic': topicId }) - }); - + const url = `/en-US/questions/${questionId}/edit`; + const response = await fetchData(url, { method: 'POST', body: { topic: this.value } }); if (response) { - const data = await response.json(); - currentTopic.textContent = data.updated_topic; + const currentTopic = document.getElementById(`current-topic-${questionId}`); + currentTopic.textContent = response.updated_topic; currentTopic.classList.add('updated'); + updateStatusSelect(updateButton); + } + } - const updateStatusSelect = updateButton.previousElementSibling; - if (updateStatusSelect && updateStatusSelect.tagName === 'SELECT') { - updateStatusSelect.value = '1'; - } + // Update tags + if (this.classList.contains('tag-select')) { + const selectedTags = Array.from(this.selectedOptions).map(option => option.value); + const url = `/en-US/questions/${questionId}/add-tag-async`; + const response = await fetchData(url, { method: 'POST', body: { tags: selectedTags } }); + if (response) { + updateStatusSelect(updateButton); } } }); + + if (dropdown.classList.contains('tag-select')) { + new TomSelect(dropdown, { + plugins: ['remove_button'], + maxItems: null, + create: false, + onItemRemove: async (tagId) => { + const url = `/en-US/questions/${questionId}/remove-tag-async`; + const response = await fetchData(url, { method: 'POST', body: { tagId } }); + if (response) { + const updateButton = findUpdateButton(questionId); + updateStatusSelect(updateButton); + enableUpdateButton(updateButton); + } + } + }); + } }); } - handleDropdownChange(); + async function updateReasonAndFetchContent(reason) { + const url = new URL(window.location.href); + if (reason) { + url.searchParams.set('reason', reason); + window.history.pushState({}, '', url); + } else { + url.searchParams.delete('reason'); + window.history.replaceState({}, '', url.pathname); + } + + const response = await fetchData(url); + if (response) { + const parser = new DOMParser(); + const doc = parser.parseFromString(await response.text(), 'text/html'); + flaggedQueue.innerHTML = doc.querySelector('#flagged-queue').innerHTML; + disableUpdateStatusButtons(); + initializeDropdownsAndTags(); + } + } + + const reasonFilter = document.getElementById('flagit-reason-filter'); + const flaggedQueue = document.getElementById('flagged-queue'); + + if (reasonFilter) { + const reason = new URL(window.location.href).searchParams.get('reason'); + if (reason) reasonFilter.value = reason; + + reasonFilter.addEventListener('change', async () => { + const selectedReason = reasonFilter.value; + await updateReasonAndFetchContent(selectedReason); + }); + } + initializeDropdownsAndTags(); }); diff --git a/kitsune/sumo/static/sumo/scss/components/_flaggit.scss b/kitsune/sumo/static/sumo/scss/components/_flaggit.scss index 1bb9d541f01..06cc0ce30ed 100644 --- a/kitsune/sumo/static/sumo/scss/components/_flaggit.scss +++ b/kitsune/sumo/static/sumo/scss/components/_flaggit.scss @@ -60,4 +60,7 @@ } } + &__tag-select { + margin-top: p.$spacing-md; + } } \ No newline at end of file diff --git a/kitsune/tags/models.py b/kitsune/tags/models.py index be9d303d489..7a20dd371bf 100644 --- a/kitsune/tags/models.py +++ b/kitsune/tags/models.py @@ -3,6 +3,12 @@ from taggit.models import GenericTaggedItemBase, TagBase +class SumoTagManager(models.Manager): + + def segmentation_tags(self): + return self.filter(is_archived=False, slug__startswith="seg-") + + class BigVocabTaggableManager(TaggableManager): """TaggableManager for choosing among a predetermined set of tags @@ -27,6 +33,8 @@ class SumoTag(TagBase): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + objects = SumoTagManager() + class Meta: ordering = ["name", "-updated"]