зеркало из 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>
|
<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:') }} <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"]
|
||||||
|
|
||||||
|
|
Загрузка…
Ссылка в новой задаче