diff --git a/kitsune/flagit/jinja2/flagit/content_moderation.html b/kitsune/flagit/jinja2/flagit/content_moderation.html index f7f9a519466..aca1fb1fe2f 100644 --- a/kitsune/flagit/jinja2/flagit/content_moderation.html +++ b/kitsune/flagit/jinja2/flagit/content_moderation.html @@ -6,7 +6,16 @@
  • -

    {{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}

    +
    +

    {{ _('Flagged {t}')|f(t=object.content_type) }}

    + + {% if object.assignee %} + {{ _('Assigned: {username}')|f(username=object.assignee.username) }} + {% else %} + {{ _('Unassigned') }} + {% endif %} + +
    {% if object.notes %} {% if object.content_type.model == 'question' %}

    {{ _('Additional notes:') }}  {{ object.notes }}

    @@ -57,6 +66,29 @@


    {{ _('Update Status:') }}

    options=products, selected_filter=selected_product ) }} + {{ filter_dropdown( + form_id='assignee-filter-form', + select_id='flagit-assignee-filter', + label='Filter by assignee:', + name='assignee', + default_option='All assignees', + options=assignees, + selected_filter=selected_assignee + ) }} +
    + +
    + {% csrf_token %} + + +
    +
    + {% csrf_token %} + + +
    +
    {% endblock %} {# Hide the deactivation log on content moderation #} {% block deactivation_log %} diff --git a/kitsune/flagit/migrations/0005_flaggedobject_assigned_timestamp_and_more.py b/kitsune/flagit/migrations/0005_flaggedobject_assigned_timestamp_and_more.py new file mode 100644 index 00000000000..cb2ad6ec866 --- /dev/null +++ b/kitsune/flagit/migrations/0005_flaggedobject_assigned_timestamp_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.18 on 2025-01-23 08:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("flagit", "0004_alter_flaggedobject_reason"), + ] + + operations = [ + migrations.AddField( + model_name="flaggedobject", + name="assigned_timestamp", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AddField( + model_name="flaggedobject", + name="assignee", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="assigned_flags", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/kitsune/flagit/models.py b/kitsune/flagit/models.py index f127be97a61..1673ada7c60 100644 --- a/kitsune/flagit/models.py +++ b/kitsune/flagit/models.py @@ -54,6 +54,11 @@ class FlaggedObject(ModelBase): handled = models.DateTimeField(default=datetime.now, db_index=True) handled_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True) + assignee = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="assigned_flags" + ) + assigned_timestamp = models.DateTimeField(default=None, null=True) + objects = FlaggedObjectManager() class Meta: diff --git a/kitsune/flagit/views.py b/kitsune/flagit/views.py index f6815b641bc..790413cdcc0 100644 --- a/kitsune/flagit/views.py +++ b/kitsune/flagit/views.py @@ -1,13 +1,21 @@ import json from django.contrib import messages +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.http import HttpResponse, HttpResponseRedirect +from django.db.models.functions import Now +from django.http import ( + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + HttpResponseNotFound, + HttpResponseRedirect, +) from django.shortcuts import get_object_or_404, render from django.utils import timezone from django.utils.translation import gettext as _ -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_http_methods, require_POST from kitsune.access.decorators import group_required, login_required, permission_required from kitsune.flagit.models import FlaggedObject @@ -163,9 +171,19 @@ def build_hierarchy(parent_id=None, level=0): @group_required("Content Moderators") +@require_http_methods(["GET", "POST"]) def moderate_content(request): """Display flagged content that needs moderation.""" product_slug = request.GET.get("product") + assignee = request.GET.get("assignee") + + if ( + assignee + and not User.objects.filter( + is_active=True, username=assignee, groups__name="Content Moderators" + ).exists() + ): + return HttpResponseNotFound() content_type = ContentType.objects.get_for_model(Question) objects = ( @@ -174,10 +192,31 @@ def moderate_content(request): content_model=content_type, product_slug=product_slug, ) - .select_related("content_type", "creator") + .select_related("content_type", "creator", "assignee") .prefetch_related("content_object__product") ) + if request.method == "POST": + if not (assignee and (request.user.username == assignee)): + return HttpResponseForbidden() + + action = request.POST.get("action") + if not (action and (action in ("assign", "unassign"))): + return HttpResponseBadRequest() + + if action == "assign": + # Assign another batch of objects to the user. + assigment_qs = objects.filter(assignee__isnull=True)[:20].values_list("id", flat=True) + objects.filter(id__in=assigment_qs).update( + assignee=request.user, assigned_timestamp=Now() + ) + else: + # Unassign all of the user's objects. + objects.filter(assignee=request.user).update(assignee=None, assigned_timestamp=None) + + if assignee: + objects = objects.filter(assignee__username=assignee) + # It's essential that the objects are ordered for pagination. The # default ordering for flagged objects is by ascending created date. objects = paginate(request, objects) @@ -204,6 +243,15 @@ def moderate_content(request): for p in Product.active.filter(codename="", aaq_configs__is_active=True) ], "selected_product": product_slug, + "assignees": sorted( + (user.username, user.get_full_name() or user.username) + for user in User.objects.filter( + is_active=True, + groups__name="Content Moderators", + ).distinct() + ), + "selected_assignee": assignee, + "current_username": request.user.username, }, ) @@ -227,6 +275,8 @@ def update(request, flagged_object_id): QuestionReplyEvent(answer).fire(exclude=[answer.creator]) flagged.status = new_status + flagged.assignee = None + flagged.assigned_timestamp = None flagged.save() if flagged.reason == FlaggedObject.REASON_CONTENT_MODERATION: question = flagged.content_object diff --git a/kitsune/sumo/static/sumo/js/flagit.js b/kitsune/sumo/static/sumo/js/flagit.js index 57bf36b8ba6..1bd2e61f161 100644 --- a/kitsune/sumo/static/sumo/js/flagit.js +++ b/kitsune/sumo/static/sumo/js/flagit.js @@ -128,11 +128,33 @@ document.addEventListener('DOMContentLoaded', () => { }); } + const myQueueTools = document.getElementById('my-queue-tools'); + const myQueueUnassign = document.getElementById('my-queue-unassign'); const flaggedQueue = document.getElementById('flagged-queue'); initializeFilterDropdown('flagit-reason-filter', 'reason'); initializeFilterDropdown('flagit-product-filter', 'product'); + initializeFilterDropdown('flagit-assignee-filter', 'assignee', (selectedValue) => { + myQueueTools.hidden = selectedValue !== myQueueTools.dataset.currentUsername; + }); + + document.body.addEventListener('htmx:beforeRequest', function(evt) { + if (evt.detail.elt === myQueueUnassign) { + // We're going to remove all of the user's assigned items, + // so it doesn't make any sense to keep the page parameter. + const url = new URL(window.location.href); + url.searchParams.delete('page'); + window.history.pushState({}, '', url); + } + }); - function initializeFilterDropdown(filterId, queryParam) { + document.body.addEventListener('htmx:afterSwap', function(evt) { + if (evt.detail.target === flaggedQueue) { + disableUpdateStatusButtons(); + initializeDropdownsAndTags(); + } + }); + + function initializeFilterDropdown(filterId, queryParam, postChangeFunction) { const filterElement = document.getElementById(filterId); if (!filterElement) return; @@ -145,6 +167,9 @@ document.addEventListener('DOMContentLoaded', () => { const selectedValue = filterElement.value; const url = new URL(window.location.href); + // Remove the page parameter since we'll show a new queue of items. + url.searchParams.delete('page'); + if (selectedValue) { url.searchParams.set(queryParam, selectedValue); } else { @@ -161,6 +186,9 @@ document.addEventListener('DOMContentLoaded', () => { initializeDropdownsAndTags(); htmx.process(flaggedQueue); } + if (postChangeFunction) { + postChangeFunction(selectedValue); + } }); } diff --git a/kitsune/sumo/static/sumo/scss/components/_flaggit.scss b/kitsune/sumo/static/sumo/scss/components/_flaggit.scss index 4fe6599b7db..da911486ef6 100644 --- a/kitsune/sumo/static/sumo/scss/components/_flaggit.scss +++ b/kitsune/sumo/static/sumo/scss/components/_flaggit.scss @@ -24,6 +24,21 @@ /* Flagged Item Content */ .flagged-item-content { padding: p.$spacing-md; + + .flagged-item-header { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + margin: 0; + } + + span { + font-size: 1rem; + font-weight: bold; + } + } } .flagged-content { @@ -72,3 +87,15 @@ margin-top: p.$spacing-md; } } + +#assignee-filter-form { + margin-top: p.$spacing-md; +} + +#my-queue-tools { + margin-top: p.$spacing-2xl; + + form { + margin-bottom: p.$spacing-sm; + } +}