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 }}
+
+
{% 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"]