зеркало из https://github.com/mozilla/kitsune.git
Merge pull request #6360 from akatsoulas/seg-tags-moderation-tool
Expose segmentation tags in moderation
This commit is contained in:
Коммит
03b735d4f1
|
@ -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:') }} <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"]
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче