Merge pull request #6360 from akatsoulas/seg-tags-moderation-tool

Expose segmentation tags in moderation
This commit is contained in:
Tasos Katsoulas 2024-11-20 11:19:40 +02:00 коммит произвёл GitHub
Родитель 247ccc36a9 382b6f82a7
Коммит 03b735d4f1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
8 изменённых файлов: 201 добавлений и 111 удалений

Просмотреть файл

@ -7,7 +7,11 @@
<hgroup> <hgroup>
<h2 class="sumo-card-heading">{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}</h2> <h2 class="sumo-card-heading">{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}</h2>
{% if object.notes %} {% if object.notes %}
<p class="notes">{{ _('Other reason:') }} {{ object.notes }}</p> {% if object.content_type.model == 'question' %}
<p class="notes">{{ _('Additional notes:') }} &nbsp;<a target="_blank" href="{{ object.content_object.get_absolute_url() }}">{{ object.notes }}</a></p>
{% else %}
<p class="notes">{{ _('Additional notes:') }} {{ object.notes }}</p>
{% endif %}
{% endif %} {% endif %}
</hgroup> </hgroup>
<div class="wrap"> <div class="wrap">
@ -33,4 +37,8 @@
{% else %} {% else %}
<p>{{ _('There is no content pending moderation.') }}</p> <p>{{ _('There is no content pending moderation.') }}</p>
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
{# Hide the deactivation log on content moderation #}
{% block deactivation_log %}
{% endblock %}

Просмотреть файл

@ -24,20 +24,28 @@
{% if object.reason == 'content_moderation' and question_model and user.has_perm('questions.change_question') %} {% if object.reason == 'content_moderation' and question_model and user.has_perm('questions.change_question') %}
<h3 class="flagged-content__subheading">{{ _('Take Action:') }}</h3> <h3 class="flagged-content__subheading">{{ _('Take Action:') }}</h3>
<div class="flagged-content__topic-update"> <div class="flagged-content__topic-update">
<label> {{ _('Current topic:') }} </label> <label> {{ _('Current topic:') }} </label>
<div> <p id="current-topic-{{ object.content_object.id }}" class="current-topic">{{ object.content_object.topic }}</p>
<p id="current-topic-{{ object.content_object.id }}" class="current-topic">{{ object.content_object.topic }}</p>
</div>
<form id="topic-update-form-{{ object.content_object.id }}" method="POST"> <form id="topic-update-form-{{ object.content_object.id }}" method="POST">
{% csrf_token %} {% csrf_token %}
<label for="topic">{{ _('Change Topic:') }}</label> <label for="topic">{{ _('Change Topic:') }}</label>
<select id="topic-dropdown-{{ object.content_object.id }}" class="topic-dropdown" name="topic" data-question-id="{{ object.content_object.id }}"> <select id="topic-dropdown-{{ object.content_object.id }}" class="topic-dropdown" name="topic" data-question-id="{{ object.content_object.id }}">
{% for topic in object.available_topics %} {% for topic in object.available_topics %}
<option value="{{ topic.id }}" {% if topic.id == object.content_object.topic.id %}selected{% endif %}>{{ topic.title }}</option> <option value="{{ topic.id }}" {% if topic.id == object.content_object.topic.id %}selected{% endif %}>{{ topic.title }}</option>
{% endfor %} {% endfor %}
</select> </select>
</form>
<div class="flagged-content__tag-select">
<label for="tag-select-{{ object.content_object.id }}">{{ _('Assign Tags:') }}</label>
<select id="tag-select-{{ object.content_object.id }}" name="tags" multiple class="tag-select" data-question-id="{{ object.content_object.id }}">
{% for tag in object.available_tags %}
<option value="{{ tag.id }}" {% if tag.get('id') in object.saved_tags %}selected{% endif %}>{{ tag.name }}</option>
{% endfor %}
</select>
</div>
</form>
</div> </div>
{% endif %} {% endif %}
</div> </div>

Просмотреть файл

@ -7,7 +7,7 @@
<hgroup> <hgroup>
<h2 class="sumo-card-heading">{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}</h2> <h2 class="sumo-card-heading">{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}</h2>
{% if object.notes %} {% if object.notes %}
<p class="notes">{{ _('Other reason:') }} {{ object.notes }}</p> <p class="notes">{{ _('Additional notes:') }} {{ object.notes }}</p>
{% endif %} {% endif %}
</hgroup> </hgroup>
<div class="wrap"> <div class="wrap">

Просмотреть файл

@ -14,6 +14,7 @@ from kitsune.questions.events import QuestionReplyEvent
from kitsune.questions.models import Answer, Question from kitsune.questions.models import Answer, Question
from kitsune.sumo.templatetags.jinja_helpers import urlparams from kitsune.sumo.templatetags.jinja_helpers import urlparams
from kitsune.sumo.urlresolvers import reverse from kitsune.sumo.urlresolvers import reverse
from kitsune.tags.models import SumoTag
def get_flagged_objects(reason=None, exclude_reason=None, content_model=None): 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") .prefetch_related("content_object__product")
) )
objects = set_form_action_for_objects(objects, reason=FlaggedObject.REASON_CONTENT_MODERATION) 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: for obj in objects:
question = obj.content_object question = obj.content_object
obj.available_topics = Topic.active.filter(products=question.product, is_archived=False) 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( return render(
request, request,
"flagit/content_moderation.html", "flagit/content_moderation.html",

Просмотреть файл

@ -3,6 +3,7 @@ import logging
import random import random
from collections import OrderedDict from collections import OrderedDict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import List, Optional, Tuple, Union
import requests import requests
from django.conf import settings from django.conf import settings
@ -16,6 +17,7 @@ from django.db.models import Q
from django.db.models.functions import Now from django.db.models.functions import Now
from django.http import ( from django.http import (
Http404, Http404,
HttpRequest,
HttpResponse, HttpResponse,
HttpResponseBadRequest, HttpResponseBadRequest,
HttpResponseForbidden, HttpResponseForbidden,
@ -1030,6 +1032,14 @@ def add_tag_async(request, question_id):
If the question already has the tag, do nothing. 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: try:
question, canonical_name = _add_tag(request, question_id) question, canonical_name = _add_tag(request, question_id)
except SumoTag.DoesNotExist: 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. 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") name = request.POST.get("name")
if name: if name:
question = get_object_or_404(Question, pk=question_id)
question.tags.remove(name) question.tags.remove(name)
question.clear_cached_tags() question.clear_cached_tags()
return HttpResponse("{}", content_type="application/json") return HttpResponse("{}", content_type="application/json")
return HttpResponseBadRequest( return HttpResponseBadRequest(
json.dumps({"error": str(NO_TAG)}), content_type="application/json" 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): def _add_tag(
"""Add a named tag to a question, creating it first if appropriate. 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.
Tag name (case-insensitive) must be in request.POST['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'].
If no tag name is provided or SumoTag.DoesNotExist is raised, return None.
Otherwise, return the canonicalized 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(): 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. # This raises SumoTag.DoesNotExist if the tag doesn't exist.
canonical_name = add_existing_tag(tag_name, question.tags) canonical_name = add_existing_tag(tag_name, question.tags)

Просмотреть файл

@ -1,123 +1,150 @@
import TomSelect from 'tom-select';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const csrfToken = document.querySelector('input[name=csrfmiddlewaretoken]')?.value;
const { reasonFilter, flaggedQueue } = { // Disable all update buttons initially
reasonFilter: document.getElementById('flagit-reason-filter'),
flaggedQueue: document.getElementById('flagged-queue'),
};
function disableUpdateStatusButtons() { function disableUpdateStatusButtons() {
const updateStatusButtons = document.querySelectorAll('form.update.inline-form input[type="submit"]'); document.querySelectorAll('form.update.inline-form input[type="submit"]').forEach(button => {
updateStatusButtons.forEach(button => {
button.disabled = true; button.disabled = true;
}); });
} }
disableUpdateStatusButtons(); 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 = {}) { async function fetchData(url, options = {}) {
try { 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, { const response = await fetch(url, {
method: options.method || 'GET', method: options.method || 'GET',
headers: { headers,
'X-Requested-With': 'XMLHttpRequest', body: options.body ? JSON.stringify(options.body) : null
...options.headers
},
body: options.body || null
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Error: ${response.statusText}`); 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) { } catch (error) {
console.error('Error:', error); console.error('Error in fetchData:', error);
return null; return null;
} }
} }
function findUpdateButton(questionId) {
if (reasonFilter) { return document.querySelector(`form.update.inline-form input[id="update-status-button-${questionId}"]`);
let reason = updateUrlParameter('get', 'reason');
if (reason) {
reasonFilter.value = reason;
}
reasonFilter.addEventListener('change', async () => {
const selectedReason = reasonFilter.value;
updateUrlParameter('set', 'reason', selectedReason);
fetchAndUpdateContent(new URL(window.location.href));
});
} }
function handleDropdownChange() { function updateStatusSelect(updateButton) {
const dropdowns = document.querySelectorAll('.topic-dropdown, select[name="status"]'); const statusDropdown = updateButton?.previousElementSibling;
dropdowns.forEach(dropdown => { if (statusDropdown && statusDropdown.tagName === 'SELECT') {
statusDropdown.value = '1';
}
}
function enableUpdateButton(updateButton) {
if (updateButton) {
updateButton.disabled = false;
}
}
function initializeDropdownsAndTags() {
document.querySelectorAll('.topic-dropdown, .tag-select').forEach(dropdown => {
const questionId = dropdown.dataset.questionId;
dropdown.addEventListener('change', async function () { dropdown.addEventListener('change', async function () {
const form = this.closest('form'); const form = this.closest('form');
const questionId = this.getAttribute('data-question-id'); const updateButton = findUpdateButton(questionId);
const updateButton = document.getElementById(`update-status-button-${questionId}`) || form.querySelector('input[type="submit"]');
if (!this.value || this.value === "") { enableUpdateButton(updateButton);
updateButton.disabled = true;
return;
}
updateButton.disabled = false;
// Update topic
if (this.classList.contains('topic-dropdown')) { if (this.classList.contains('topic-dropdown')) {
const topicId = this.value; const url = `/en-US/questions/${questionId}/edit`;
const csrfToken = form.querySelector('input[name=csrfmiddlewaretoken]').value; const response = await fetchData(url, { method: 'POST', body: { topic: this.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 })
});
if (response) { if (response) {
const data = await response.json(); const currentTopic = document.getElementById(`current-topic-${questionId}`);
currentTopic.textContent = data.updated_topic; currentTopic.textContent = response.updated_topic;
currentTopic.classList.add('updated'); currentTopic.classList.add('updated');
updateStatusSelect(updateButton);
}
}
const updateStatusSelect = updateButton.previousElementSibling; // Update tags
if (updateStatusSelect && updateStatusSelect.tagName === 'SELECT') { if (this.classList.contains('tag-select')) {
updateStatusSelect.value = '1'; 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();
}); });

Просмотреть файл

@ -60,4 +60,7 @@
} }
} }
&__tag-select {
margin-top: p.$spacing-md;
}
} }

Просмотреть файл

@ -3,6 +3,12 @@ from taggit.managers import TaggableManager
from taggit.models import GenericTaggedItemBase, TagBase 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): class BigVocabTaggableManager(TaggableManager):
"""TaggableManager for choosing among a predetermined set of tags """TaggableManager for choosing among a predetermined set of tags
@ -27,6 +33,8 @@ class SumoTag(TagBase):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
objects = SumoTagManager()
class Meta: class Meta:
ordering = ["name", "-updated"] ordering = ["name", "-updated"]