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>
<h2 class="sumo-card-heading">{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}</h2>
{% 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 %}
</hgroup>
<div class="wrap">
@ -34,3 +38,7 @@
<p>{{ _('There is no content pending moderation.') }}</p>
{% endfor %}
{% 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') %}
<h3 class="flagged-content__subheading">{{ _('Take Action:') }}</h3>
<div class="flagged-content__topic-update">
<label> {{ _('Current topic:') }} </label>
<div>
<p id="current-topic-{{ object.content_object.id }}" class="current-topic">{{ object.content_object.topic }}</p>
</div>
<label> {{ _('Current topic:') }} </label>
<p id="current-topic-{{ object.content_object.id }}" class="current-topic">{{ object.content_object.topic }}</p>
<form id="topic-update-form-{{ object.content_object.id }}" method="POST">
{% csrf_token %}
<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 }}">
{% for topic in object.available_topics %}
<option value="{{ topic.id }}" {% if topic.id == object.content_object.topic.id %}selected{% endif %}>{{ topic.title }}</option>
{% endfor %}
</select>
</form>
<form id="topic-update-form-{{ object.content_object.id }}" method="POST">
{% csrf_token %}
<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 }}">
{% for topic in object.available_topics %}
<option value="{{ topic.id }}" {% if topic.id == object.content_object.topic.id %}selected{% endif %}>{{ topic.title }}</option>
{% endfor %}
</select>
<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>
{% endif %}
</div>

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

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

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

@ -14,6 +14,7 @@ from kitsune.questions.events import QuestionReplyEvent
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",

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

@ -3,6 +3,7 @@ import logging
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 import Q
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.
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.
Tag name (case-insensitive) must be in request.POST['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)

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

@ -1,123 +1,150 @@
import TomSelect from 'tom-select';
document.addEventListener('DOMContentLoaded', () => {
const csrfToken = document.querySelector('input[name=csrfmiddlewaretoken]')?.value;
const { reasonFilter, flaggedQueue } = {
reasonFilter: document.getElementById('flagit-reason-filter'),
flaggedQueue: document.getElementById('flagged-queue'),
};
// 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;
}
}
if (reasonFilter) {
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 findUpdateButton(questionId) {
return document.querySelector(`form.update.inline-form input[id="update-status-button-${questionId}"]`);
}
function handleDropdownChange() {
const dropdowns = document.querySelectorAll('.topic-dropdown, select[name="status"]');
dropdowns.forEach(dropdown => {
function updateStatusSelect(updateButton) {
const statusDropdown = updateButton?.previousElementSibling;
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 () {
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();
});

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

@ -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
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"]