-
Notifications
You must be signed in to change notification settings - Fork 52
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
31 changed files
with
2,590 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 = () |
Oops, something went wrong.