diff --git a/apps/conversations/__init__.py b/apps/conversations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/conversations/admin.py b/apps/conversations/admin.py new file mode 100644 index 00000000..b1667f6f --- /dev/null +++ b/apps/conversations/admin.py @@ -0,0 +1,92 @@ +# Django Imports +from django.contrib import admin + +# HTK Imports +from htk.utils import htk_setting +from htk.utils.general import resolve_model_dynamically + + +Conversation = resolve_model_dynamically(htk_setting('HTK_CONVERSATION_MODEL')) +ConversationMessage = resolve_model_dynamically( + htk_setting('HTK_CONVERSATION_MESSAGE_MODEL') +) +ConversationParticipant = resolve_model_dynamically( + htk_setting('HTK_CONVERSATION_PARTICIPANT_MODEL') +) + + +class ConversationParticipantInline(admin.TabularInline): + model = ConversationParticipant + extra = 0 + + +class ConversationMessageInline(admin.TabularInline): + model = ConversationMessage + extra = 0 + + +class BaseConversationAdmin(admin.ModelAdmin): + model = Conversation + + list_display = ( + 'id', + 'topic', + 'description', + 'num_participants', + 'num_messages', + 'created_by', + 'created_at', + 'updated_at', + ) + + inlines = ( + ConversationParticipantInline, + # NOTE: This will crash if the there are too many messages in the conversation + ConversationMessageInline, + ) + + search_fields = ( + 'topic', + 'description', + 'created_by__username', + 'created_by__first_name', + 'created_by__last_name', + ) + + +class BaseConversationParticipantAdmin(admin.ModelAdmin): + model = ConversationParticipant + + list_display = ( + 'id', + 'conversation', + 'user', + 'joined_at', + ) + + search_fields = ( + 'conversation__topic', + 'user__username', + 'user__first_name', + 'user__last_name', + ) + + list_filters = ( + 'conversation', + 'user', + ) + + +class BaseConversationMessageAdmin(admin.ModelAdmin): + model = ConversationMessage + + list_display = ( + 'id', + 'conversation', + 'author', + 'reply_to', + 'content', + 'posted_at', + 'edited_at', + 'deleted_at', + ) diff --git a/apps/conversations/constants/__init__.py b/apps/conversations/constants/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/conversations/constants/defaults.py b/apps/conversations/constants/defaults.py new file mode 100644 index 00000000..f2621a90 --- /dev/null +++ b/apps/conversations/constants/defaults.py @@ -0,0 +1,4 @@ +HTK_CONVERSATION_MODEL = None +HTK_CONVERSATION_PARTICIPANT_MODEL = None +HTK_CONVERSATION_MESSAGE_MODEL = None +HTK_CONVERSATION_MESSAGE_MAX_LENGTH = 2048 diff --git a/apps/conversations/fk_fields.py b/apps/conversations/fk_fields.py new file mode 100644 index 00000000..e43e72d1 --- /dev/null +++ b/apps/conversations/fk_fields.py @@ -0,0 +1,19 @@ +# Django Imports +from django.db import models + +# HTK Imports +from htk.models.fk_fields import build_kwargs +from htk.utils import htk_setting + + +def fk_conversation( + related_name: str, + required: bool = False, + **kwargs, +) -> models.ForeignKey: + field = models.ForeignKey( + htk_setting('HTK_CONVERSATION_MODEL'), + related_name=related_name, + **build_kwargs(required=required, **kwargs), + ) + return field diff --git a/apps/conversations/models.py b/apps/conversations/models.py new file mode 100644 index 00000000..beafcbde --- /dev/null +++ b/apps/conversations/models.py @@ -0,0 +1,126 @@ +# Django Imports +from django.db import models + +# HTK Imports +from htk.apps.conversations.fk_fields import fk_conversation +from htk.models.fk_fields import fk_user +from htk.utils import htk_setting + + +# isort: off + + +class BaseConversation(models.Model): + """A base conversation class which is extensible + + A conversation is a collection of messages between `n` participants, where `n >= 2`. + + When `n` is: + - `2`, it is a private conversation, or a direct message ("DM") + - `>2`, it is a group conversation + """ + + topic = models.CharField(max_length=256, blank=True) + description = models.TextField(max_length=1024, blank=True) + created_by = fk_user(related_name='created_conversations', required=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + def __str__(self): + value = '%s (#%s)' % ( + self.topic or '', + self.id, + ) + return value + + @property + def num_participants(self): + num = self.participants.count() + return num + + @property + def num_messages(self): + num = self.messages.count() + return num + + +class BaseConversationParticipant(models.Model): + """A participant in a conversation + + This is a many-to-many relationship between a User and a Conversation + For n-party conversations, there will be n-1 ConversationParticipants + """ + + conversation = fk_conversation(related_name='participants', required=True) + user = fk_user(related_name='conversation_participants', required=True) + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True + + def __str__(self): + value = '%s - %s' % ( + self.conversation, + self.user, + ) + return value + + +class BaseConversationMessage(models.Model): + """A message in a conversation + + A conversation message is a message that belongs to a conversation. + + A conversation message is visible to all participants in the conversation. + """ + + conversation = fk_conversation(related_name='messages', required=True) + # `author` is not required when the message is system-generated + author = fk_user( + related_name='authored_conversation_messages', required=False + ) + reply_to = models.ForeignKey( + 'self', + related_name='replies', + blank=True, + null=True, + on_delete=models.CASCADE, + ) + content = models.TextField( + max_length=htk_setting('HTK_CONVERSATION_MESSAGE_MAX_LENGTH') + ) + posted_at = models.DateTimeField(auto_now_add=True) + edited_at = models.DateTimeField(blank=True, null=True) + # soft-deletion: app will hide messages that are "deleted"; but also allow for messages to be "undeleted" + deleted_at = models.DateTimeField(blank=True, null=True) + + class Meta: + abstract = True + + def __str__(self): + value = '%s - %s' % ( + self.conversation, + self.author, + ) + return value + + @property + def was_edited(self): + return self.edited_at is not None and self.edited_at > self.posted_at + + @property + def is_deleted(self): + return self.deleted_at is not None + + def save(self, **kwargs): + """Saves this message. + + Side effect: also performs any customizations, like updating cache, etc + """ + super().save(**kwargs) + + # force update on `Conversation.updated_at` + self.conversation.save() diff --git a/apps/forums/models.py b/apps/forums/models.py index a2778a3e..7fb265bd 100644 --- a/apps/forums/models.py +++ b/apps/forums/models.py @@ -7,47 +7,43 @@ class Forum(models.Model): - """Forum represents a message forum - """ - name = models.CharField(max_length=64) - description = models.CharField(max_length=128, blank=True) - created = models.DateTimeField(auto_now_add=True) - updated = models.DateTimeField(auto_now=True) + """Forum represents a message forum""" + + name = models.CharField(max_length=128) + description = models.CharField(max_length=256, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) class Meta: app_label = 'htk' def __str__(self): - value = '%s' % ( - self.name, - ) + value = '%s' % (self.name,) return value + @property def recent_thread(self): - """Retrieves the most recent ForumThread - """ - ordered_threads = self.threads.order_by('-updated') - if len(ordered_threads): - thread = ordered_threads[0] - else: - thread = None + """Retrieves the most recent ForumThread""" + thread = self.threads.order_by('-updated').first() return thread - def num_threads(self): + @property + def num_threads(self) -> int: num = self.threads.count() return num - def num_messages(self): - num = 0 - for thread in self.threads.all(): - num += thread.num_messages() - return num + @property + def num_messages(self) -> int: + total = sum(thread.num_messages for thread in self.threads.all()) + return total class ForumThread(models.Model): forum = models.ForeignKey(Forum, related_name='threads') subject = models.CharField(max_length=128) - author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='authored_threads') + author = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name='authored_threads' + ) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) # status @@ -66,25 +62,25 @@ def __str__(self): ) return value + @property def num_messages(self): num = self.messages.count() return num + @property def recent_message(self): """Retrieves the most recent message in ForumThread Requires all ForumThreads to have at least one message """ - ordered_messages = self.messages.order_by('-timestamp') - if len(ordered_messages): - message = ordered_messages[0] - else: - message = None + message = self.messages.order_by('-timestamp').first() return message class ForumMessage(models.Model): thread = models.ForeignKey(ForumThread, related_name='messages') - author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='messages') + author = models.ForeignKey( + settings.AUTH_USER_MODEL, related_name='messages' + ) reply_to = models.ForeignKey( 'self', related_name='replies', @@ -93,7 +89,8 @@ class ForumMessage(models.Model): on_delete=models.CASCADE, ) text = models.TextField(max_length=3000) - timestamp = models.DateTimeField(auto_now=True) + posted_at = models.DateTimeField(auto_now_add=True) + edited_at = models.DateTimeField(auto_now=True) tags = models.ManyToManyField('ForumTag', blank=True) class Meta: @@ -103,21 +100,30 @@ class Meta: def __str__(self): return 'ForumMessage %s' % (self.id,) + @property + def was_edited(self): + return self.edited_at > self.posted_at + + @property def snippet(self): - snippet = (self.text[:FORUM_SNIPPET_LENGTH] + '...') if len(self.text) > FORUM_SNIPPET_LENGTH else self.text + snippet = ( + (self.text[:FORUM_SNIPPET_LENGTH] + '...') + if len(self.text) > FORUM_SNIPPET_LENGTH + else self.text + ) return snippet def save(self, **kwargs): - """Any customizations, like updating cache, etc - """ + """Any customizations, like updating cache, etc""" super(ForumMessage, self).save(**kwargs) - self.thread.save() # update ForumThread.updated timestamp + # force update on `ForumThread.updated_at` + self.thread.save() class ForumTag(models.Model): - """ForumTag can either apply to ForumThread or ForumMessage - """ - name = models.CharField(max_length=32) + """ForumTag can either apply to ForumThread or ForumMessage""" + + name = models.CharField(max_length=64) class Meta: app_label = 'htk' diff --git a/constants/defaults.py b/constants/defaults.py index 63e87680..c35aeb8c 100644 --- a/constants/defaults.py +++ b/constants/defaults.py @@ -92,6 +92,7 @@ from htk.apps.accounts.constants.defaults import * from htk.apps.bible.constants.defaults import * from htk.apps.changelog.constants.defaults import * +from htk.apps.conversations.constants.defaults import * from htk.apps.cpq.constants.defaults import * from htk.apps.file_storage.constants.defaults import * from htk.apps.geolocations.constants.defaults import *