Skip to content

Commit

Permalink
provide flagged-object queues per user (#6457)
Browse files Browse the repository at this point in the history
* provide flagged-object queues by assignee

* adjustments based on feedback
  • Loading branch information
escattone authored Jan 23, 2025
1 parent f11e9c5 commit 73ae7d0
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 5 deletions.
34 changes: 33 additions & 1 deletion kitsune/flagit/jinja2/flagit/content_moderation.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@
<li id="flagged-item-{{ object.id }}" class="{{ object.content_type }}">
<div class="flagged-item-content">
<hgroup>
<h2 class="sumo-card-heading">{{ _('Flagged {t} (Reason: {r})')|f(t=object.content_type, r=object.get_reason_display()) }}</h2>
<div class="flagged-item-header">
<h2 class="sumo-card-heading">{{ _('Flagged {t}')|f(t=object.content_type) }}</h2>
<span>
{% if object.assignee %}
{{ _('Assigned: {username}')|f(username=object.assignee.username) }}
{% else %}
{{ _('Unassigned') }}
{% endif %}
</span>
</div>
{% if object.notes %}
{% 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>
Expand Down Expand Up @@ -57,6 +66,29 @@ <h3 class="sumo-card-heading"><br>{{ _('Update Status:') }}</h3>
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
) }}
<div id="my-queue-tools" data-current-username="{{ current_username }}"
{% if not (selected_assignee and (selected_assignee == current_username)) %} hidden{% endif %}>
<label>{{ _('Manage my queue') }}:</label>
<form hx-post="" hx-target="#flagged-queue" hx-select="#flagged-queue">
{% csrf_token %}
<input type="hidden" name="action" value="assign">
<input class="sumo-button primary-button button-lg btn" type="submit" value="{{ _('Assign more') }}" />
</form>
<form id="my-queue-unassign" hx-post="" hx-target="#flagged-queue" hx-select="#flagged-queue">
{% csrf_token %}
<input type="hidden" name="action" value="unassign">
<input class="sumo-button secondary-button button-lg btn" type="submit" value="{{ _('Unassign all') }}" />
</form>
</div>
{% endblock %}
{# Hide the deactivation log on content moderation #}
{% block deactivation_log %}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
5 changes: 5 additions & 0 deletions kitsune/flagit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 53 additions & 3 deletions kitsune/flagit/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = (
Expand All @@ -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)
Expand All @@ -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,
},
)

Expand All @@ -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
Expand Down
30 changes: 29 additions & 1 deletion kitsune/sumo/static/sumo/js/flagit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
Expand All @@ -161,6 +186,9 @@ document.addEventListener('DOMContentLoaded', () => {
initializeDropdownsAndTags();
htmx.process(flaggedQueue);
}
if (postChangeFunction) {
postChangeFunction(selectedValue);
}
});
}

Expand Down
27 changes: 27 additions & 0 deletions kitsune/sumo/static/sumo/scss/components/_flaggit.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}

0 comments on commit 73ae7d0

Please sign in to comment.