Skip to content

Commit

Permalink
Allow Staff members to message groups (mozilla#5922)
Browse files Browse the repository at this point in the history
* API for messages to get users/groups
* Added custom form field type in `form_fields.py` to handle group AND user selection and validation
* Update form, models, urls and tests for messages
* Update group profile page to add button for messaging group
* Removed unnecessary validation from `view.py` for `new_messages`
* Rewrote `new_messages` to handle Groups
* New .js file for handling drop down for messages `messages.autocomplete.js`
* Updated webpack `entrypoints.js` to handle new file
* Updated `tokenizer.js`
* Updated `outbox.html`, `read-outbox.html` and `_inbox.scss`
* Created abstracted group in `settings.py` for our `Staff` group
* Added new cached property to `Profile` model for `in_staff_group`
* Added `in_staff_group` function to `sumo/utils.py` ( thanks @escattone ! )
  • Loading branch information
smithellis authored Apr 11, 2024
1 parent 8ec0709 commit 7cdffd7
Show file tree
Hide file tree
Showing 27 changed files with 1,511 additions and 927 deletions.
10 changes: 6 additions & 4 deletions kitsune/groups/jinja2/groups/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
<a class="edit" href="{{ url('admin:groups_groupprofile_change', profile.id) }}">{{ _('Edit in admin') }}</a>
{% endif %}
<h1>{{ profile.group.name }}</h1>

{% if user_can_edit %}
<a class="edit" href="{{ url('groups.edit', profile.slug) }}">{{ _('Edit group profile') }}</a>
{% endif %}
{% if in_staff_group(user) %}
<p class="pm"><a class="sumo-button primary-button button-lg" href="{{ url('messages.new')|urlparams(to=profile.group.name) }}">{{ _('Private message group members') }}</a></p>
{% endif %}
{% if user_can_edit %}
<a class="edit" href="{{ url('groups.edit', profile.slug) }}">{{ _('Edit group profile') }}</a>
{% endif %}
<div id="doc-content">
{{ profile.information_html|safe }}
</div>
Expand Down
51 changes: 51 additions & 0 deletions kitsune/messages/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from django.conf import settings

from django.contrib.auth.models import Group, User
from django.db.models import Q
from django.views.decorators.http import require_GET

from kitsune.access.decorators import login_required
from kitsune.sumo.decorators import json_view
from kitsune.sumo.utils import webpack_static
from kitsune.users.templatetags.jinja_helpers import profile_avatar


@login_required
@require_GET
@json_view
def get_autocomplete_suggestions(request):
"""An API to provide auto-complete data for user names or groups."""
pre = request.GET.get("term", "") or request.GET.get("query", "")
if not pre or not request.user.is_authenticated:
return []

def create_suggestion(item):
"""Create a dictionary object for the autocomplete suggestion."""
is_user = isinstance(item, User)
return {
"type": "User" if is_user else "Group",
"type_icon": webpack_static(
settings.DEFAULT_USER_ICON if is_user else settings.DEFAULT_GROUP_ICON
),
"name": item.username if is_user else item.name,
"display_name": item.profile.name if is_user else item.name,
"avatar": profile_avatar(item, 24)
if is_user
else webpack_static(settings.DEFAULT_AVATAR),
}

suggestions = []
user_criteria = Q(username__istartswith=pre) | Q(profile__name__istartswith=pre)
users = User.objects.filter(
user_criteria, is_active=True, profile__is_fxa_migrated=True
).select_related("profile")[:10]

for user in users:
suggestions.append(create_suggestion(user))

if request.user.profile.in_staff_group:
groups = Group.objects.filter(name__istartswith=pre)[:10]
for group in groups:
suggestions.append(create_suggestion(group))

return suggestions
18 changes: 12 additions & 6 deletions kitsune/messages/forms.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
from django import forms
from django.utils.translation import gettext_lazy as _lazy

from kitsune.sumo.form_fields import MultiUsernameField


TO_PLACEHOLDER = _lazy("username1, username2,...")
from kitsune.sumo.form_fields import MultiUsernameOrGroupnameField


class MessageForm(forms.Form):
"""Form send a private message."""

to = MultiUsernameField(
to = MultiUsernameOrGroupnameField(
label=_lazy("To:"),
widget=forms.TextInput(
attrs={"placeholder": TO_PLACEHOLDER, "class": "user-autocomplete"}
attrs={"placeholder": "Search for Users", "class": "user-autocomplete"}
),
)
message = forms.CharField(label=_lazy("Message:"), max_length=10000, widget=forms.Textarea)
in_reply_to = forms.IntegerField(widget=forms.HiddenInput, required=False)

def __init__(self, *args, **kwargs):
# Grab the user
self.user = kwargs.pop("user")
super(MessageForm, self).__init__(*args, **kwargs)

# If the user is_staff, the placholder text needs to be updated
if self.user and self.user.profile.in_staff_group:
self.fields["to"].widget.attrs["placeholder"] = "Search for Users or Groups"


class ReplyForm(forms.Form):
"""Form to reply to a private message."""
Expand Down
9 changes: 4 additions & 5 deletions kitsune/messages/jinja2/messages/inbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,11 @@ <h1 class="sumo-page-heading">{{ title }}</h1>
{{ name_link(message.sender) }}
{{ datetimeformat(message.created) }}
</div>
</div>

<a class="read" href="{{ url('messages.read', message.id) }}" title="{{ _('Read message') }}">
{{ message.content_parsed|striptags|truncate(length=160) }}
</a>
<a class="read" href="{{ url('messages.read', message.id) }}" title="{{ _('Read message') }}">
{{ message.content_parsed|striptags|truncate(length=160) }}
</a>
<a class="delete" href="{{ url('messages.delete', message.id) }}" title="{{ _('Delete message') }}">&#x2716;</a>
</div>
</li>
{% endfor %}
</ol>
Expand Down
33 changes: 23 additions & 10 deletions kitsune/messages/jinja2/messages/includes/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,30 @@
{% endif %}
</span>
<span class="to">
{% if message.recipients > 1 -%}
{% set comma = joiner(', ') %}
{% for user in message.to.all() -%}
{{ comma() }}
{{ name_link(user) }}
{%- endfor %}
{% else %}{# Save a query! #}
{{ name_link(message.recipient) }}
<p><strong>{{ _('To') }}:</strong>
{% if message.recipients > 1 -%}
{% set comma = joiner(', ') %}
{% for user in message.to.all() -%}
{{ comma() }}
{{ name_link(user) }}
{%- endfor %}
</p>
</span>
{% if in_staff_group(request.user) and message.to_group %}
<span class="to-group">
<p><strong>{{ _('To Groups') }}:</strong>
{% set comma = joiner(', ') %}
{% for group in message.to_group.all() -%}
{{ comma() }}
{{ group_link(group) }}
{% endfor %}
</p>
</span>
{% endif %}
{% endif %}
{{ datetimeformat(message.created) }}
<span class="time">{{ datetimeformat(message.created) }}</span>
</span>
<div class="message">{{ message.content_parsed }}</div>
<div class="message read-message">{{ message.content_parsed }}</div>
{%- endmacro %}


Expand All @@ -87,6 +99,7 @@
</div>
{% else %}
{{ field|safe }}
{{ field.errors }}
{% endif %}
</li>
{% endfor %}
Expand Down
78 changes: 71 additions & 7 deletions kitsune/messages/jinja2/messages/outbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,41 @@
{% block content %}
<div class="sumo-page-section">
<article id="outbox" class="main">
<h1 class="sumo-page-subheading">{{ title }}</h1>
<h1 class="sumo-page-heading">{{ title }}</h1>
<div class="actions sumo-button-wrap">
<a class="sumo-button primary-button" href="{{ url('messages.new') }}">{{ _('New Message') }}</a>
</div>
{% if msgs %}
<form action="{{ url('messages.outbox_bulk_action') }}" method="POST">
<div class="sumo-button-wrap">
<button type="submit" name="delete" class="sumo-button">{{ _('Delete Selected') }}</button>
</div>
<ol class="message-list">
<li class="message-list--item">
<div class="field checkbox no-label">
</div>
<div class="avatar-details">
<span class="avatar">
</span>
<span class="to user">
<b>{{ _('Sent') }}</b>
</span>
<span class="to user">
<b>{{ _('To') }}</b>
</span>
{% if in_staff_group(request.user) %}
<span class="to group">
<b>{{ _('To Groups') }}</b>
</span>
{% endif %}
<span class="message-view">
<b>{{ _('Message') }}</b>
</span>
<span class="delete-button">

</span>
</div>
</li>
{% for message in msgs.object_list %}
<li class="message-list--item">
<div class="field checkbox no-label">
Expand All @@ -29,19 +57,55 @@ <h1 class="sumo-page-subheading">{{ title }}</h1>
{{ avatar_link(message.recipient) }}
{% endif %}
</span>
<span class="time">
<p>{{ datetimeformat(message.created) }}</p>
</span>
<span class="to user">
{% if message.recipients > 1 -%}
{{ name_link(request.user, name=_('You')) }}
{% for user in message.to.all()[:3] -%}
<a rel="nofollow" href="{{ profile_url(user) }}">
{%- if name -%}
{{ name }}
{%- else -%}
{{ display_name(user) }}
{%- endif %}
{%- if not loop.last -%},{%- endif -%}
</a>
{% endfor %}
{% if message.recipients > 3 -%}
...
{% endif %}
{% else %}
{{ name_link(message.recipient) }}
{%- endif %}
{{ datetimeformat(message.created) }}
</span>
{% if in_staff_group(request.user) %}
<span class="to group">
{% if message.to_group %}
{% if message.to_groups_count > 0 -%}
{%- for group in message.to_group.all()[:3] -%}
{{ group_link(group) }}
{%- if not loop.last -%},{%- endif -%}
{% endfor %}
{%- if message.to_groups_count > 3 -%}
...
{% endif %}
{% endif %}
{% if message.to_groups_count < 1 -%}
{{ _('None') }}
{% endif %}
{% endif %}
</span>
{% endif %}
<span class="message-view">
<a class="read message text-body-sm" href="{{ url('messages.read_outbox', message.id) }}">
{{ message.content_parsed|striptags|truncate(length=160) }}
</a>
</span>
<span class="delete-button">
<a class="delete" href="{{ url('messages.delete_outbox', message.id) }}" title="{{ _('Delete message') }}">&#x2716;</a>
</span>
</div>
<a class="read text-body-sm" href="{{ url('messages.read_outbox', message.id) }}">
{{ message.content_parsed|striptags|truncate(length=160) }}
</a>
<a class="delete" href="{{ url('messages.delete_outbox', message.id) }}" title="{{ _('Delete message') }}">&#x2716;</a>
</li>
{% endfor %}
</ol>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-03-18 11:23

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("kitsune_messages", "0001_initial"),
]

operations = [
migrations.AddField(
model_name="inboxmessage",
name="to_group",
field=models.ManyToManyField(blank=True, null=True, to="auth.group"),
),
migrations.AddField(
model_name="outboxmessage",
name="to_group",
field=models.ManyToManyField(blank=True, null=True, to="auth.group"),
),
]
7 changes: 5 additions & 2 deletions kitsune/messages/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import datetime

from django.contrib.auth.models import User
from django.contrib.auth.models import Group, User
from django.db import models

from kitsune.sumo.models import ModelBase
Expand Down Expand Up @@ -34,6 +34,7 @@ class InboxMessage(ModelBase):
"""A message in a user's private message inbox."""

to = models.ForeignKey(User, on_delete=models.CASCADE, related_name="inbox")
to_group = models.ManyToManyField(Group, null=True, blank=True)
sender = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
message = models.TextField()
created = models.DateTimeField(default=datetime.now, db_index=True)
Expand All @@ -59,12 +60,14 @@ class Meta:
class OutboxMessage(ModelBase):
sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name="outbox")
to = models.ManyToManyField(User)
to_group = models.ManyToManyField(Group, null=True, blank=True)
message = models.TextField()
created = models.DateTimeField(default=datetime.now, db_index=True)

def __str__(self):
to = ", ".join([u.username for u in self.to.all()])
return "from:%s to:%s %s" % (self.sender, to, self.message[0:30])
to_group = ", ".join([g.name for g in self.to_group.all()]) or None
return "from:%s to:%s groups:%s %s" % (self.sender, to, to_group, self.message[0:30])

@property
def content_parsed(self):
Expand Down
2 changes: 1 addition & 1 deletion kitsune/messages/tests/test_internal_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def test_send_message(self):
to = UserFactory.create_batch(2)
sender = UserFactory()
msg_text = "hi there!"
send_message(to=to, text=msg_text, sender=sender)
send_message(to=to, to_group="", text=msg_text, sender=sender)

msgs_in = InboxMessage.objects.all()
msgs_out = OutboxMessage.objects.all()
Expand Down
2 changes: 1 addition & 1 deletion kitsune/messages/tests/test_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_send_message_page(self):

def _test_send_message_to(self, to):
# Post a new message and verify it was sent.
data = {"to": to, "message": "hi there"}
data = {"to": to, "to_group": "", "message": "hi there"}
response = self.client.post(reverse("messages.new", locale="en-US"), data, follow=True)
self.assertEqual(200, response.status_code)
self.assertEqual("Your message was sent!", pq(response.content)("ul.user-messages").text())
Expand Down
13 changes: 11 additions & 2 deletions kitsune/messages/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
from django.urls import re_path
from django.urls import include, re_path

from kitsune.messages import views
from kitsune.messages import api, views

api_patterns = [
re_path(
r"^autocomplete",
api.get_autocomplete_suggestions,
name="messages.api.get_autocomplete_suggestions",
),
]

urlpatterns = [
re_path(r"^$", views.inbox, name="messages.inbox"),
re_path(r"^api/", include(api_patterns)),
re_path(r"^bulk_action$", views.bulk_action, name="messages.bulk_action"),
re_path(r"^read/(?P<msgid>\d+)$", views.read, name="messages.read"),
re_path(r"^read/(?P<msgid>\d+)/delete$", views.delete, name="messages.delete"),
Expand Down
Loading

0 comments on commit 7cdffd7

Please sign in to comment.