- {{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}
+
{% 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
+ ) }}
+
+
+
+
+
{% 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;
+ }
+}