Skip to content

Commit

Permalink
Add Direct Messages (#3093)
Browse files Browse the repository at this point in the history
This PR adds the Direct Messages application. It introduces three main
new models:

- `Conversation`: Many users are `participants` of one conversation.
Many permissions are added to each conversation - view,
create_conversation_direct_message, mark_conversation_read,
mark_conversation_message_as_spam.
- `DirectMessage`: Has a foreign key to a `conversation`, `sender` and
which users have not read this message. The sender can delete the
message and the conversation participants can mark the message as spam.
If a direct message is created the other participants in the group have
the message marked as unread. If a message is deleted or marked as spam
then all of the unread markers are deleted.
- `Mute`: tracks which users have muted which other users. If a direct
message is created and the receiver has muted the sender the message is
not marked as unread by the receiver.

The views use HTMX. As a conversation is loaded all of the messages are
marked as read as a callback. For now only challenge admins can message
their participants.

See DIAGNijmegen/rse-roadmap#255
  • Loading branch information
jmsmkn authored Nov 21, 2023
1 parent 20fa066 commit 175a3b0
Show file tree
Hide file tree
Showing 31 changed files with 2,590 additions and 17 deletions.
1 change: 1 addition & 0 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@
"grandchallenge.charts",
"grandchallenge.forums",
"grandchallenge.invoices",
"grandchallenge.direct_messages",
]

INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS + THIRD_PARTY_APPS
Expand Down
6 changes: 6 additions & 0 deletions app/config/urls/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ def handler500(request):
"components/",
include("grandchallenge.components.urls", namespace="components"),
),
path(
"messages/",
include(
"grandchallenge.direct_messages.urls", namespace="direct-messages"
),
),
]

if settings.DEBUG and settings.ENABLE_DEBUG_TOOLBAR:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,31 @@ <h6 class="dropdown-header">{{ request.site.name }} Support</h6>
</div>
</li>
{% if user.is_authenticated %}
{% if ACTSTREAM_ENABLE %}
<li class="nav-item mr-auto">
{% if user.user_profile.has_unread_notifications %}
{% if user.directmessageunreadby_set.exists %}
<span class="text-danger float-right mb-n3">
<i class="fa fa-xs fa-circle"></i>
</span>
{% endif %}
<a class="nav-link pr-1"
href="{% url 'notifications:list' %}"
title="Notifications">
<i class="align-middle far fa-fw fa-lg fa-bell"></i>
href="{% url 'direct-messages:conversation-list' %}"
title="Conversations">
<i class="align-middle far fa-fw fa-lg fa-comment"></i>
</a>
</li>
{% if ACTSTREAM_ENABLE %}
<li class="nav-item mr-auto">
{% if user.user_profile.has_unread_notifications %}
<span class="text-danger float-right mb-n3">
<i class="fa fa-xs fa-circle"></i>
</span>
{% endif %}
<a class="nav-link pr-1"
href="{% url 'notifications:list' %}"
title="Notifications">
<i class="align-middle far fa-fw fa-lg fa-bell"></i>
</a>
</li>
{% endif %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle align-top"
Expand Down
Empty file.
75 changes: 75 additions & 0 deletions app/grandchallenge/direct_messages/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from django.contrib import admin

from grandchallenge.direct_messages.models import (
Conversation,
DirectMessage,
Mute,
)


@admin.register(Conversation)
class ConversationAdmin(admin.ModelAdmin):
list_display = (
"pk",
"participant_usernames",
)
list_prefetch_related = ("participants",)
readonly_fields = ("participant_usernames",)
search_fields = (
"pk",
"participants__username",
)

def participant_usernames(self, obj):
return ", ".join(user.username for user in obj.participants.all())


@admin.register(DirectMessage)
class DirectMessageAdmin(admin.ModelAdmin):
list_display = (
"pk",
"created",
"sender",
"unread_by_usernames",
"is_reported_as_spam",
"is_deleted",
"message",
)
readonly_fields = (
"conversation",
"sender",
"message",
"unread_by_usernames",
)
list_filter = (
"is_reported_as_spam",
"is_deleted",
)
list_prefetch_related = ("unread_by",)
ordering = ("-created",)
search_fields = (
"pk",
"sender__username",
"conversation__pk",
"unread_by__username",
)

def unread_by_usernames(self, obj):
return ", ".join(user.username for user in obj.unread_by.all())


@admin.register(Mute)
class MuteAdmin(admin.ModelAdmin):
list_display = (
"pk",
"source",
"target",
)
readonly_fields = (
"source",
"target",
)
search_fields = (
"source__username",
"target__username",
)
9 changes: 9 additions & 0 deletions app/grandchallenge/direct_messages/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.apps import AppConfig


class DirectMessagesConfig(AppConfig):
name = "grandchallenge.direct_messages"

def ready(self):
# noinspection PyUnresolvedReferences
import grandchallenge.direct_messages.signals # noqa: F401
188 changes: 188 additions & 0 deletions app/grandchallenge/direct_messages/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
from crispy_forms.bootstrap import StrictButton
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from django import forms
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from guardian.utils import get_anonymous_user

from grandchallenge.core.guardian import filter_by_permission
from grandchallenge.direct_messages.models import (
Conversation,
DirectMessage,
Mute,
)


class ConversationForm(forms.ModelForm):
def __init__(self, *args, participants, **kwargs):
super().__init__(*args, **kwargs)

self.helper = FormHelper(self)
self.helper.layout.append(
StrictButton(
'<i class="far fa-comment"></i> Message User',
type="submit",
css_class="btn btn-primary",
)
)

self.fields["participants"].queryset = participants
self.fields["participants"].initial = participants
self.fields["participants"].disabled = True
self.fields["participants"].widget = forms.MultipleHiddenInput()

def clean_participants(self):
participants = self.cleaned_data["participants"]

if get_anonymous_user() in participants:
raise ValidationError("You cannot add this user!")

if len(participants) < 2:
raise ValidationError("Too few participants")

if existing := Conversation.objects.for_participants(
participants=participants
):
self.existing_conversations = existing
raise ValidationError(
"Conversation already exists", code="CONVERSATION_EXISTS"
)

return participants

class Meta:
model = Conversation
fields = ("participants",)


class DirectMessageForm(forms.ModelForm):
def __init__(self, *args, conversation, sender, **kwargs):
super().__init__(*args, **kwargs)

self.helper = FormHelper(self)
self.helper.layout.append(Submit("save", "Send"))

self.fields["conversation"].queryset = filter_by_permission(
queryset=Conversation.objects.filter(pk=conversation.pk),
user=sender,
codename="create_conversation_direct_message",
)
self.fields["conversation"].initial = conversation
self.fields["conversation"].disabled = True
self.fields["conversation"].widget = forms.HiddenInput()

self.fields["sender"].queryset = get_user_model().objects.filter(
pk=sender.pk
)
self.fields["sender"].initial = sender
self.fields["sender"].disabled = True
self.fields["sender"].widget = forms.HiddenInput()

exclude_notifications = {
m.source.pk
for m in Mute.objects.filter(
target=sender, source__in=conversation.participants.all()
)
}
exclude_notifications.add(sender.pk)
unread_by = conversation.participants.exclude(
pk__in=exclude_notifications
)

self.fields["unread_by"].queryset = unread_by
self.fields["unread_by"].initial = unread_by
self.fields["unread_by"].disabled = True
self.fields["unread_by"].widget = forms.MultipleHiddenInput()
self.fields["unread_by"].required = False

self.fields["message"].widget = forms.Textarea(
attrs={
"placeholder": "Write a message...",
"rows": "3",
"style": "resize:none;",
}
)
self.fields["message"].label = ""

class Meta:
model = DirectMessage
fields = (
"conversation",
"sender",
"unread_by",
"message",
)


class DirectMessageReportSpamForm(forms.ModelForm):
is_reported_as_spam = forms.BooleanField(
initial=True, required=True, widget=forms.HiddenInput
)

class Meta:
model = DirectMessage
fields = ("is_reported_as_spam",)


class MuteForm(forms.ModelForm):
conversation = forms.ModelChoiceField(
queryset=Conversation.objects.none(), widget=forms.HiddenInput()
)

def __init__(self, *args, source, target, **kwargs):
super().__init__(*args, **kwargs)

self.fields["source"].queryset = get_user_model().objects.filter(
pk=source.pk
)
self.fields["source"].initial = source
self.fields["source"].disabled = True
self.fields["source"].widget = forms.HiddenInput()

self.fields["target"].queryset = get_user_model().objects.filter(
pk=target.pk
)
self.fields["target"].initial = target
self.fields["target"].disabled = True
self.fields["target"].widget = forms.HiddenInput()

self.fields["conversation"].queryset = filter_by_permission(
queryset=Conversation.objects.all(),
user=source,
codename="view_conversation",
)

def clean(self):
cleaned_data = super().clean()

if cleaned_data["source"] == cleaned_data["target"]:
raise ValidationError("You cannot mute yourself")

return cleaned_data

class Meta:
model = Mute
fields = (
"source",
"target",
)


class MuteDeleteForm(forms.ModelForm):
conversation = forms.ModelChoiceField(
queryset=Conversation.objects.none(), widget=forms.HiddenInput()
)

def __init__(self, *args, user, **kwargs):
super().__init__(*args, **kwargs)

self.fields["conversation"].queryset = filter_by_permission(
queryset=Conversation.objects.all(),
user=user,
codename="view_conversation",
)

class Meta:
model = Mute
fields = ()
Loading

0 comments on commit 175a3b0

Please sign in to comment.