├── vestlus ├── migrations │ ├── __init__.py │ ├── 0003_channel_photo.py │ ├── 0002_auto_20200701_0012.py │ └── 0001_initial.py ├── viewsets │ ├── mixin │ │ ├── __init__.py │ │ ├── detail_action.py │ │ └── non_detail_action.py │ ├── __init__.py │ ├── permissions │ │ ├── __init__.py │ │ ├── is_owner.py │ │ ├── is_sender.py │ │ ├── is_admin.py │ │ └── is_member.py │ ├── router.py │ ├── channel.py │ ├── membership.py │ └── message.py ├── fixtures │ ├── __init__.py │ └── vestlus-channels.json ├── templates │ ├── __init__.py │ ├── search │ │ └── indexes │ │ │ └── vestlus │ │ │ ├── message_text.txt │ │ │ ├── groupmessage_text.txt │ │ │ ├── privatemessage_text.txt │ │ │ ├── channel_text.txt │ │ │ ├── channel_rendered.txt │ │ │ └── message_rendered.txt │ ├── .idea │ │ ├── .gitignore │ │ ├── misc.xml │ │ ├── vcs.xml │ │ ├── modules.xml │ │ ├── templates.iml │ │ └── dbnavigator.xml │ ├── membership_list.html │ ├── channel_create.html │ ├── membership_create.html │ ├── message_create.html │ ├── channel_delete.html │ ├── message_delete.html │ ├── vue-templates.html │ ├── vestlus.html │ ├── message_detail.html │ ├── membership_detail.html │ ├── message_list.html │ ├── index.html │ ├── channel_list.html │ └── channel_detail.html ├── middleware │ ├── __init__.py │ └── helpers │ │ └── __init__.py ├── forms │ ├── helpers │ │ └── __init__.py │ ├── channel.py │ ├── membership.py │ ├── __init__.py │ └── message.py ├── models │ ├── validators │ │ └── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── message.py │ ├── signals │ │ ├── __init__.py │ │ ├── create_membership.py │ │ └── update_index.py │ ├── __init__.py │ ├── managers │ │ ├── __init__.py │ │ ├── reaction.py │ │ ├── membership.py │ │ ├── channel.py │ │ └── message.py │ ├── membership.py │ ├── channel.py │ └── message.py ├── admin │ ├── permissions │ │ └── __init__.py │ ├── inlines │ │ ├── channel.py │ │ ├── membership.py │ │ ├── __init__.py │ │ └── message.py │ ├── __init__.py │ ├── membership.py │ ├── actions │ │ └── __init__.py │ ├── channel.py │ └── message.py ├── views │ ├── mixins │ │ ├── __init__.py │ │ └── ajaxable_response.py │ ├── routes.py │ ├── channel_create.py │ ├── __init__.py │ ├── message_list.py │ ├── channel_delete.py │ ├── index.py │ ├── membership_list.py │ ├── channel_list.py │ ├── membership_detail.py │ ├── message_detail.py │ ├── membership_create.py │ ├── message_delete.py │ ├── channel_detail.py │ └── message_create.py ├── search_indexes │ ├── __init__.py │ ├── message.py │ └── channel.py ├── serializers │ ├── tests │ │ ├── __init__.py │ │ ├── group_message.py │ │ ├── message.py │ │ └── channel.py │ ├── __init__.py │ ├── mixins │ │ ├── __init__.py │ │ ├── chat.py │ │ └── exclude.py │ ├── membership.py │ ├── channel.py │ └── message.py ├── api.py ├── urls.py ├── apps.py ├── requires.py ├── settings.py └── __init__.py ├── MANIFEST.in ├── .idea ├── vcs.xml ├── .gitignore ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── modules.xml ├── misc.xml ├── PipelineViewerConfig.xml └── vestlus.iml ├── setup.py ├── setup.cfg ├── .github └── workflows │ └── pythonpublish.yml ├── LICENSE.md ├── README.md └── .gitignore /vestlus/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vestlus/viewsets/mixin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vestlus/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:fixtures 2 | -------------------------------------------------------------------------------- /vestlus/templates/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:templates 2 | -------------------------------------------------------------------------------- /vestlus/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:middleware 2 | -------------------------------------------------------------------------------- /vestlus/forms/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:forms:helpers 2 | -------------------------------------------------------------------------------- /vestlus/models/validators/__init__.py: -------------------------------------------------------------------------------- 1 | # conversations:validators 2 | -------------------------------------------------------------------------------- /vestlus/admin/permissions/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:admin:permissions 2 | -------------------------------------------------------------------------------- /vestlus/middleware/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:middleware:helpers 2 | -------------------------------------------------------------------------------- /vestlus/views/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .ajaxable_response import AjaxableResponseMixin 2 | -------------------------------------------------------------------------------- /vestlus/models/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:models:tests 2 | from .message import MessageTestCase 3 | -------------------------------------------------------------------------------- /vestlus/templates/search/indexes/vestlus/message_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.sender }} 2 | {{ object.content }} -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.md 2 | include README.md 3 | recursive-include vestlus/templates * 4 | recursive-include docs * 5 | -------------------------------------------------------------------------------- /vestlus/templates/search/indexes/vestlus/groupmessage_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.sender }} 2 | {{ object.channel }} 3 | {{ object.content }} -------------------------------------------------------------------------------- /vestlus/views/routes.py: -------------------------------------------------------------------------------- 1 | # vestlus:views:routes 2 | 3 | # The routes for your CBVs (class-based views) go here 4 | routes = [] 5 | -------------------------------------------------------------------------------- /vestlus/templates/search/indexes/vestlus/privatemessage_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.sender }} 2 | {{ object.content }} 3 | {{ object.receiver }} -------------------------------------------------------------------------------- /vestlus/templates/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /vestlus/templates/search/indexes/vestlus/channel_text.txt: -------------------------------------------------------------------------------- 1 | {{ object.name }} 2 | {{ object.is_private }} 3 | {{ object.created_at }} 4 | {{ object.updated_at }} -------------------------------------------------------------------------------- /vestlus/search_indexes/__init__.py: -------------------------------------------------------------------------------- 1 | from .channel import ChannelIndex 2 | from .message import PrivateMessageIndex 3 | from .message import GroupMessageIndex 4 | -------------------------------------------------------------------------------- /vestlus/models/signals/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:models:signals 2 | from .create_membership import create_member 3 | from .update_index import update_search_indexes 4 | -------------------------------------------------------------------------------- /vestlus/viewsets/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:viewsets 2 | from .message import GroupMessageViewSet 3 | from .channel import ChannelViewSet 4 | from .membership import MembershipViewSet 5 | -------------------------------------------------------------------------------- /vestlus/admin/inlines/channel.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from ...models import Channel 3 | 4 | 5 | class ChannelInline(admin.StackedInline): 6 | model = Channel 7 | extra = 0 8 | -------------------------------------------------------------------------------- /vestlus/serializers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:serializers:tests 2 | from .message import MessageTestCase 3 | from .channel import ChannelTestCase 4 | from .group_message import GroupMessageTestCase 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /vestlus/admin/inlines/membership.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from ...models import Membership 3 | 4 | 5 | class MembershipInline(admin.StackedInline): 6 | model = Membership 7 | extra = 0 8 | -------------------------------------------------------------------------------- /vestlus/forms/channel.py: -------------------------------------------------------------------------------- 1 | from django.forms import forms 2 | from ..models import Channel 3 | 4 | 5 | class ChannelForm(forms.Form): 6 | class Meta: 7 | model = Channel 8 | fields = '__all__' 9 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /vestlus/forms/membership.py: -------------------------------------------------------------------------------- 1 | from django.forms import forms 2 | from ..models import Membership 3 | 4 | 5 | class MembershipForm(forms.Form): 6 | class Meta: 7 | model = Membership 8 | fields = '__all__' 9 | -------------------------------------------------------------------------------- /vestlus/templates/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /vestlus/api.py: -------------------------------------------------------------------------------- 1 | # urlpatterns for vestlus 2 | from django.urls import include, path 3 | from .viewsets.router import router 4 | 5 | app_name = 'vestlus' 6 | 7 | urlpatterns = [ 8 | path('', include(router.urls)), 9 | ] 10 | -------------------------------------------------------------------------------- /vestlus/templates/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /vestlus/urls.py: -------------------------------------------------------------------------------- 1 | # urlpatterns for vestlus 2 | from django.urls import path 3 | from django.shortcuts import redirect 4 | from .views.routes import routes 5 | 6 | app_name = 'vestlus' 7 | 8 | 9 | urlpatterns = [] + routes 10 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /vestlus/forms/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:forms 2 | from .channel import ChannelForm 3 | from .message import MessageForm 4 | from .message import PrivateMessageForm 5 | from .message import GroupMessageForm 6 | from .membership import MembershipForm 7 | -------------------------------------------------------------------------------- /vestlus/admin/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:admin 2 | from .message import MessageAdmin 3 | from .message import PrivateMessageAdmin 4 | from .message import GroupMessageAdmin 5 | from .channel import ChannelAdmin 6 | from .membership import MembershipAdmin 7 | -------------------------------------------------------------------------------- /vestlus/models/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:models 2 | from .message import Message 3 | from .message import PrivateMessage 4 | from .message import GroupMessage 5 | from .message import Reaction 6 | from .channel import Channel 7 | from .membership import Membership 8 | -------------------------------------------------------------------------------- /vestlus/viewsets/permissions/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:viewsets:permissions 2 | from .is_admin import IsChannelOwnerOrAdminOrReadOnly 3 | from .is_member import IsMemberOrNoAccess 4 | from .is_owner import IsOwnerOrReadOnly 5 | from .is_sender import IsSenderOrReadOnly 6 | -------------------------------------------------------------------------------- /vestlus/admin/inlines/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:admin:inlines 2 | from .message import MessageInline 3 | from .message import PrivateMessageInline 4 | from .message import GroupMessageInline 5 | from .channel import ChannelInline 6 | from .membership import MembershipInline 7 | -------------------------------------------------------------------------------- /vestlus/models/managers/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:models:managers 2 | from .message import MessageManager 3 | from .message import GroupMessageManager 4 | from .channel import ChannelManager 5 | from .membership import MembershipManager 6 | from .reaction import ReactionManager 7 | -------------------------------------------------------------------------------- /vestlus/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:serializers 2 | from .message import PrivateMessageSerializer 3 | from .message import GroupMessageSerializer 4 | from .message import ReactionSerializer 5 | from .channel import ChannelSerializer 6 | from .membership import MembershipSerializer 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /vestlus/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class VestlusConfig(AppConfig): 6 | name = 'vestlus' 7 | verbose_name = _('Vestlus Chat') 8 | 9 | def ready(self): 10 | from .models import signals 11 | -------------------------------------------------------------------------------- /vestlus/serializers/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:serializers:mixins 2 | from .chat import ChatRelatedField 3 | from .chat import ChatSerializerMixin 4 | from .exclude import ExcludeTimestampMixin 5 | from .exclude import ExcludeTimestampAndPolymorphicMixin 6 | from .exclude import ExcludePolymorphicMixin 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /vestlus/requires.py: -------------------------------------------------------------------------------- 1 | # vestlus:vestlus 2 | 3 | REQUIRED_APPS = [ 4 | 'crispy_forms', 5 | 'haystack', 6 | 'polymorphic', 7 | 'rest_framework', 8 | 'rest_framework_httpsignature', 9 | 'rest_framework_simplejwt', 10 | 11 | # Add this app ... 12 | 'vestlus.apps.VestlusConfig', 13 | ] 14 | -------------------------------------------------------------------------------- /vestlus/templates/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /vestlus/settings.py: -------------------------------------------------------------------------------- 1 | # vestlus:settings 2 | import os 3 | 4 | CRISPY_TEMPLATE_PACK = 'bootstrap4' 5 | 6 | HAYSTACK_CONNECTIONS = { 7 | 'default': { 8 | 'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine', 9 | 'URL': os.environ.get('ELASTICSEARCH_URL', 'http://127.0.0.1:9200/'), 10 | 'INDEX_NAME': 'haystack', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /vestlus/templates/membership_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | 3 | {% block content %} 4 |
5 |

Memberships

6 | 7 | 14 |
15 | {% endblock content %} 16 | -------------------------------------------------------------------------------- /.idea/PipelineViewerConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | -------------------------------------------------------------------------------- /vestlus/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | vestlus is a django app with support for private and public channels. 4 | """ 5 | from __future__ import unicode_literals 6 | from .requires import REQUIRED_APPS 7 | 8 | __version__ = "0.1.4" 9 | __license__ = 'BSD 3-Clause' 10 | __copyright__ = 'Copyright 2020 Lehvitus ÖU' 11 | 12 | VERSION = __version__ 13 | 14 | default_app_config = 'vestlus.apps.VestlusConfig' 15 | -------------------------------------------------------------------------------- /vestlus/viewsets/permissions/is_owner.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsOwnerOrReadOnly(permissions.IsAuthenticated): 5 | 6 | def has_object_permission(self, request, view, obj): 7 | 8 | # Non-owners can READ content, but will not be able to UPDATE or DELETE 9 | if request.method in permissions.SAFE_METHODS: 10 | return True 11 | return obj.owner == request.user 12 | -------------------------------------------------------------------------------- /vestlus/viewsets/permissions/is_sender.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsSenderOrReadOnly(permissions.IsAuthenticated): 5 | 6 | def has_object_permission(self, request, view, obj): 7 | 8 | # Non-owners can READ content, but will not be able to UPDATE or DELETE 9 | if request.method in permissions.SAFE_METHODS: 10 | return True 11 | return obj.sender == request.user 12 | -------------------------------------------------------------------------------- /vestlus/models/managers/reaction.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Q 3 | 4 | 5 | class ReactionManager(models.Manager): 6 | 7 | def get_queryset(self): 8 | return super().get_queryset().prefetch_related() 9 | 10 | def get_for_user(self, user): 11 | return self.get_queryset().filter( 12 | Q(user=user) | 13 | Q(message__sender=user) 14 | ).distinct() 15 | -------------------------------------------------------------------------------- /vestlus/serializers/membership.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from ..models import Membership 3 | from .mixins import ExcludeTimestampMixin 4 | 5 | 6 | class MembershipSerializer(serializers.ModelSerializer): 7 | 8 | class Meta(ExcludeTimestampMixin.Meta): 9 | model = Membership 10 | 11 | read_only_fields = ( 12 | 'invited_by', 13 | 'channel', 14 | 'uuid', 15 | 'created_at', 16 | 'updated_at' 17 | ) 18 | -------------------------------------------------------------------------------- /vestlus/serializers/mixins/chat.py: -------------------------------------------------------------------------------- 1 | # vestlus:serializers:mixins 2 | from rest_framework import serializers 3 | 4 | 5 | class ChatRelatedField(serializers.RelatedField): 6 | """ 7 | Query only chats for the logged-in user. 8 | """ 9 | def get_queryset(self): 10 | request = self.context['request'] 11 | user_id = request.user.id 12 | return super().get_queryset().filter(owner=user_id) 13 | 14 | 15 | class ChatSerializerMixin(object): 16 | serializer_related_field = ChatRelatedField 17 | -------------------------------------------------------------------------------- /vestlus/forms/message.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from ..models import Message, PrivateMessage, GroupMessage 3 | 4 | 5 | class MessageForm(forms.ModelForm): 6 | class Meta: 7 | model = Message 8 | fields = '__all__' 9 | 10 | 11 | class PrivateMessageForm(forms.ModelForm): 12 | class Meta: 13 | model = PrivateMessage 14 | fields = '__all__' 15 | 16 | 17 | class GroupMessageForm(forms.ModelForm): 18 | class Meta: 19 | model = GroupMessage 20 | fields = ('content',) 21 | -------------------------------------------------------------------------------- /vestlus/templates/.idea/templates.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /vestlus/migrations/0003_channel_photo.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-07-02 13:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('vestlus', '0002_auto_20200701_0012'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='channel', 15 | name='photo', 16 | field=models.ImageField(blank=True, upload_to='channels/photos/', verbose_name='photo'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /vestlus/admin/membership.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from ..models import Membership 3 | 4 | 5 | @admin.register(Membership) 6 | class MembershipAdmin(admin.ModelAdmin): 7 | list_display = [ 8 | 'slug', 9 | 'id', 10 | 'channel', 11 | 'channel_owner', 12 | 'user', 13 | 'is_admin', 14 | 'created_at', 15 | 'updated_at', 16 | ] 17 | 18 | def get_queryset(self, request): 19 | return Membership.objects.get_for_user( 20 | user=request.user 21 | ) 22 | -------------------------------------------------------------------------------- /vestlus/viewsets/mixin/detail_action.py: -------------------------------------------------------------------------------- 1 | from rest_framework.decorators import action 2 | from rest_framework.response import Response 3 | 4 | 5 | class DetailActionMixin: 6 | def detail_action(self, objects, klass_serializer): 7 | page = self.paginate_queryset(objects) 8 | if page is not None: 9 | serializer = klass_serializer(page, many=True) 10 | return self.get_paginated_response(serializer.data) 11 | 12 | serializer = klass_serializer(objects, many=True) 13 | return Response(serializer.data) 14 | -------------------------------------------------------------------------------- /vestlus/viewsets/mixin/non_detail_action.py: -------------------------------------------------------------------------------- 1 | from rest_framework.response import Response 2 | 3 | 4 | class NonDetailActionMixin: 5 | """ 6 | ... 7 | """ 8 | 9 | def non_detail_action(self, queryset): 10 | page = self.paginate_queryset(queryset) 11 | if page is not None: 12 | serializer = self.get_serializer(page, many=True) 13 | return self.get_paginated_response(serializer.data) 14 | 15 | serializer = self.get_serializer(queryset, many=True) 16 | return Response(serializer.data) 17 | -------------------------------------------------------------------------------- /vestlus/viewsets/router.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | """ 4 | Your router endpoints are automatically added to the urlpatterns in urls.py for this app. 5 | To make your app's urls available to the entire project, include your app urls in the urlpatterns for your project: 6 | 7 | In your project's urls.py file: 8 | 9 | from django.urls import include, path 10 | urlpatterns = [ 11 | # Other paths like admin and login stuff... 12 | path('social', include('.social.urls')) 13 | ] 14 | """ 15 | 16 | router = routers.SimpleRouter(trailing_slash=False) 17 | -------------------------------------------------------------------------------- /vestlus/viewsets/permissions/is_admin.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsChannelOwnerOrAdminOrReadOnly(permissions.IsAuthenticated): 5 | 6 | def has_object_permission(self, request, view, obj): 7 | 8 | # Non-owners and non-admins can READ content, but will not be able to UPDATE or DELETE 9 | if request.method in permissions.SAFE_METHODS: 10 | return True 11 | if obj.owner == request.user: 12 | return True 13 | membership = obj.members.get(user=request.user) 14 | return membership.is_admin 15 | -------------------------------------------------------------------------------- /vestlus/admin/inlines/message.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from ...models import ( 3 | Message, 4 | PrivateMessage, 5 | GroupMessage, 6 | Reaction 7 | ) 8 | 9 | 10 | class MessageInline(admin.StackedInline): 11 | model = Message 12 | extra = 0 13 | 14 | 15 | class PrivateMessageInline(admin.StackedInline): 16 | model = PrivateMessage 17 | extra = 0 18 | 19 | 20 | class GroupMessageInline(admin.StackedInline): 21 | model = GroupMessage 22 | extra = 0 23 | 24 | 25 | class ReactionInline(admin.StackedInline): 26 | model = Reaction 27 | extra = 0 28 | -------------------------------------------------------------------------------- /vestlus/migrations/0002_auto_20200701_0012.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-07-01 00:12 2 | 3 | from django.db import migrations 4 | import django.db.models.manager 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('vestlus', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelManagers( 15 | name='groupmessage', 16 | managers=[ 17 | ('custom_objects', django.db.models.manager.Manager()), 18 | ('objects', django.db.models.manager.Manager()), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /vestlus/viewsets/permissions/is_member.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | from ...models import Channel 3 | 4 | 5 | class IsMemberOrNoAccess(permissions.IsAuthenticated): 6 | 7 | def has_permission(self, request, view): 8 | channel_pk = view.kwargs['channels_pk'] 9 | 10 | try: 11 | channel = Channel.objects.get(pk=channel_pk) 12 | return (not channel.is_private) or \ 13 | request.user in channel.members.all() 14 | except Channel.DoesNotExist: 15 | return False 16 | 17 | def has_object_permission(self, request, view, obj): 18 | return request.user in obj.members 19 | -------------------------------------------------------------------------------- /vestlus/admin/actions/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:admin:actions 2 | 3 | 4 | def make_read(model, request, queryset): 5 | queryset.update(read=True) 6 | 7 | 8 | make_read.short_description = "Mark as read" 9 | 10 | 11 | def make_unread(model, request, queryset): 12 | queryset.update(read=False) 13 | 14 | 15 | make_unread.short_description = "Mark as unread" 16 | 17 | 18 | def make_private(model, request, queryset): 19 | queryset.update(is_private=True) 20 | 21 | 22 | make_private.short_description = "Make private" 23 | 24 | 25 | def make_public(model, request, queryset): 26 | queryset.update(is_private=False) 27 | 28 | 29 | make_public.short_description = "Make public" 30 | -------------------------------------------------------------------------------- /vestlus/models/signals/create_membership.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save 3 | from ...models import Membership, Channel 4 | 5 | 6 | @receiver(post_save, sender=Channel, dispatch_uid="create_channel_member") 7 | def create_member(sender, **kwargs): 8 | instance = kwargs.get('instance') 9 | 10 | # When a channel is created, ensure the owner of the channel is made a member of it. 11 | if kwargs.get('created', True): 12 | Membership.objects.get_or_create( 13 | user_id=instance.owner_id, 14 | channel_id=instance.id, 15 | invited_by_id=instance.owner_id, 16 | is_admin=True 17 | ) 18 | -------------------------------------------------------------------------------- /vestlus/views/channel_create.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.views.generic import CreateView 3 | from django.urls import path 4 | from .routes import routes 5 | from ..models import Channel 6 | from ..forms import ChannelForm 7 | 8 | 9 | class ChannelCreateView(CreateView): 10 | model = Channel 11 | fields = ('name', 'is_private',) 12 | template_name = 'channel_create.html' 13 | context_object_name = 'channel' 14 | 15 | def form_valid(self, form): 16 | form.instance.owner = self.request.user 17 | return super().form_valid(form) 18 | 19 | 20 | routes.append( 21 | path('channels/new/', ChannelCreateView.as_view(), name='channel-create') 22 | ) 23 | -------------------------------------------------------------------------------- /vestlus/views/__init__.py: -------------------------------------------------------------------------------- 1 | # vestlus:views 2 | from .index import IndexView 3 | # from .message_create import MessageCreateView 4 | from .message_create import ChannelMessageCreateView 5 | from .message_delete import MessageDeleteView 6 | from .message_detail import MessageDetailView 7 | from .message_list import MessageListView 8 | from .message_delete import ChannelMessageDeleteView 9 | from .channel_create import ChannelCreateView 10 | from .channel_detail import ChannelDetailView 11 | from .channel_list import ChannelListView 12 | from .channel_delete import ChannelDeleteView 13 | from .membership_create import MembershipCreateView 14 | from .membership_detail import MembershipDetailView 15 | from .membership_list import MembershipListView 16 | -------------------------------------------------------------------------------- /vestlus/models/signals/update_index.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save, post_delete 3 | from haystack.signals import RealtimeSignalProcessor 4 | from haystack.management.commands import update_index 5 | from ...models import Channel, Message, GroupMessage, PrivateMessage 6 | 7 | 8 | # @receiver([post_save, post_delete], sender=Channel, dispatch_uid='update_channel_index') 9 | def update_search_indexes(sender, **kwargs): 10 | """Call haystack.update_index to update backend search indexes""" 11 | instance = kwargs.get('instance') 12 | 13 | if kwargs.get('created', True): 14 | print('Created new instance...') 15 | elif kwargs.get('updated', True): 16 | print('Update instance...') 17 | -------------------------------------------------------------------------------- /vestlus/search_indexes/message.py: -------------------------------------------------------------------------------- 1 | # vestlus:models:search_indexes 2 | from vestlus.models import Message, PrivateMessage, GroupMessage 3 | from haystack import indexes 4 | 5 | 6 | class MessageIndex(indexes.BasicSearchIndex, indexes.Indexable): 7 | content_auto = indexes.EdgeNgramField(model_attr='content') 8 | rendered = indexes.CharField( 9 | use_template=True, 10 | indexed=False, 11 | template_name='search/indexes/vestlus/message_rendered.txt' 12 | ) 13 | 14 | def get_model(self): 15 | return Message 16 | 17 | 18 | class PrivateMessageIndex(MessageIndex): 19 | 20 | def get_model(self): 21 | return PrivateMessage 22 | 23 | 24 | class GroupMessageIndex(MessageIndex): 25 | 26 | def get_model(self): 27 | return GroupMessage 28 | -------------------------------------------------------------------------------- /vestlus/views/message_list.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.utils.decorators import method_decorator 3 | from django.contrib.auth.decorators import login_required 4 | from django.views.generic import ListView 5 | from django.urls import path 6 | from .routes import routes 7 | from ..models import Message 8 | 9 | 10 | @method_decorator([login_required], name='dispatch') 11 | class MessageListView(ListView): 12 | model = Message 13 | template_name = 'message_list.html' 14 | context_object_name = 'messages' 15 | paginate_by = 20 16 | 17 | def get_queryset(self): 18 | return Message.custom_objects.get_for_user( 19 | user=self.request.user 20 | ) 21 | 22 | 23 | routes.append( 24 | path('messages/', MessageListView.as_view(), name='message-list') 25 | ) 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | import re 4 | 5 | 6 | def read(f): 7 | return open(f, 'r', encoding='utf-8').read() 8 | 9 | 10 | def get_version(package): 11 | """ 12 | Return package version as listed in `__version__` in `init.py`. 13 | """ 14 | init_py = open(os.path.join(package, '__init__.py')).read() 15 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 16 | 17 | 18 | version = get_version('vestlus') 19 | 20 | setup( 21 | version=version, 22 | name='django-vestlus', 23 | url='https://www.github.com/lehvitus/vestlus', 24 | author='Leo Neto', 25 | author_email='leo@ekletik.com', 26 | description='A Django app for chat conversations', 27 | long_description=read('README.md'), 28 | long_description_content_type='text/markdown', 29 | ) 30 | -------------------------------------------------------------------------------- /.idea/vestlus.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 18 | 19 | 20 | 22 | -------------------------------------------------------------------------------- /vestlus/serializers/channel.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from ..models import Channel 3 | from .message import GroupMessageSerializer 4 | 5 | 6 | class ChannelSerializer(serializers.ModelSerializer): 7 | 8 | owner = serializers.PrimaryKeyRelatedField(read_only=True) 9 | 10 | conversations = GroupMessageSerializer(many=True, allow_null=True) 11 | 12 | class Meta: 13 | model = Channel 14 | fields = ( 15 | 'id', 16 | 'uuid', 17 | 'slug', 18 | 'owner', 19 | 'name', 20 | 'is_private', 21 | 'created_at', 22 | 'updated_at', 23 | 'total_messages', 24 | 'conversations', 25 | ) 26 | # exclude = ('access_code',) 27 | 28 | read_only_fields = ( 29 | 'owner', 30 | ) 31 | -------------------------------------------------------------------------------- /vestlus/serializers/mixins/exclude.py: -------------------------------------------------------------------------------- 1 | # vestlus:serializers:mixins 2 | from rest_framework import serializers 3 | 4 | 5 | class ExcludePolymorphicMixin(serializers.ModelSerializer): 6 | """Exclude polymorphic content type from serializer""" 7 | 8 | class Meta: 9 | exclude = ('polymorphic_ctype',) 10 | 11 | 12 | class ExcludeTimestampMixin(serializers.ModelSerializer): 13 | """Make uuid, created_at, and updated_at fields read-only""" 14 | 15 | class Meta: 16 | exclude = ( 17 | 'created_at', 18 | 'updated_at', 19 | ) 20 | 21 | 22 | class ExcludeTimestampAndPolymorphicMixin(serializers.ModelSerializer): 23 | """Exclude polymorphic content type from serializer""" 24 | 25 | class Meta(ExcludeTimestampMixin.Meta): 26 | exclude = ExcludeTimestampMixin.Meta.exclude + ('polymorphic_ctype',) 27 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 21 | -------------------------------------------------------------------------------- /vestlus/views/channel_delete.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.shortcuts import reverse 3 | from django.utils.decorators import method_decorator 4 | from django.contrib.auth.decorators import login_required 5 | from django.views.generic import DeleteView 6 | from django.urls import path 7 | from .routes import routes 8 | from ..models import Channel 9 | 10 | 11 | @method_decorator([login_required], name='dispatch') 12 | class ChannelDeleteView(DeleteView): 13 | model = Channel 14 | context_object_name = 'channel' 15 | template_name = 'channel_delete.html' 16 | 17 | def get_queryset(self): 18 | return Channel.objects.get_for_user(user=self.request.user) 19 | 20 | def get_success_url(self): 21 | return reverse('vestlus:channel-list') 22 | 23 | 24 | routes.append( 25 | path('channels//delete', ChannelDeleteView.as_view(), name='channel-delete') 26 | ) 27 | -------------------------------------------------------------------------------- /vestlus/views/index.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.utils import timezone 3 | from django.utils.decorators import method_decorator 4 | from django.contrib.auth.decorators import login_required 5 | from django.views.generic import TemplateView 6 | from django.urls import path 7 | from .routes import routes 8 | from ..models import Channel, Message 9 | 10 | 11 | @method_decorator([login_required], name='dispatch') 12 | class IndexView(TemplateView): 13 | template_name = 'index.html' 14 | 15 | def get_context_data(self, **kwargs): 16 | channels = Channel.objects.get_for_user(user=self.request.user) 17 | messages = Message.custom_objects.get_for_user(user=self.request.user) 18 | context = super().get_context_data(**kwargs) 19 | context['channels'] = channels 20 | context['messages'] = messages 21 | return context 22 | 23 | 24 | routes.append( 25 | path('', IndexView.as_view(), name='chat-index') 26 | ) 27 | -------------------------------------------------------------------------------- /vestlus/templates/search/indexes/vestlus/channel_rendered.txt: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 9 |
10 | {{ object.name }} 11 |
12 |
13 |
14 |

15 | 16 | Created {{ object.created_at|timesince }} ago 17 | 18 |

19 |
20 |
21 | 26 |
-------------------------------------------------------------------------------- /vestlus/views/membership_list.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.utils.decorators import method_decorator 3 | from django.contrib.auth.decorators import login_required 4 | from django.views.generic import ListView 5 | from django.urls import path 6 | from .routes import routes 7 | from ..models import Membership 8 | 9 | 10 | @method_decorator([login_required], name='dispatch') 11 | class MembershipListView(ListView): 12 | model = Membership 13 | template_name = 'membership_list.html' 14 | context_object_name = 'memberships' 15 | paginate_by = 20 16 | 17 | def get_queryset(self): 18 | return Membership.objects.get_for_user( 19 | user=self.request.user 20 | ) 21 | 22 | def get_context_data(self, **kwargs): 23 | context = super().get_context_data(**kwargs) 24 | context['now'] = timezone.now() 25 | return context 26 | 27 | 28 | routes.append( 29 | path('memberships/', MembershipListView.as_view(), name='membership-list') 30 | ) 31 | -------------------------------------------------------------------------------- /vestlus/templates/channel_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |
6 | 14 | 15 |
16 |

Create Channel

17 |
18 | 19 | 20 |
21 | {% csrf_token %} 22 |
23 | {{ form|crispy }} 24 |
25 | 26 |
27 | 28 | 29 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /vestlus/serializers/message.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from .mixins import ExcludePolymorphicMixin 3 | from ..models import PrivateMessage, GroupMessage, Reaction 4 | 5 | 6 | class ReactionSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Reaction 9 | read_only_fields = ('message', 'user') 10 | 11 | 12 | # Private and Group Messages 13 | 14 | class MessageSerializer(serializers.ModelSerializer): 15 | 16 | reactions = ReactionSerializer(many=True, required=False, allow_null=True) 17 | 18 | class Meta(ExcludePolymorphicMixin.Meta): 19 | 20 | read_only_fields = ( 21 | 'sender', 22 | ) 23 | 24 | 25 | class PrivateMessageSerializer(MessageSerializer): 26 | class Meta(MessageSerializer.Meta): 27 | model = PrivateMessage 28 | 29 | 30 | class GroupMessageSerializer(MessageSerializer): 31 | 32 | class Meta(MessageSerializer.Meta): 33 | model = GroupMessage 34 | 35 | read_only_fields = ( 36 | 'sender', 37 | 'channel', 38 | ) 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license = BSD-3-Clause 3 | license_file = LICENSE.md 4 | keywords = django chat messages 5 | classifiers = 6 | Environment :: Web Environment 7 | Framework :: Django 8 | Framework :: Django :: 2.2 9 | Framework :: Django :: 3.0 10 | Intended Audience :: Developers 11 | License :: OSI Approved :: BSD License 12 | Operating System :: OS Independent 13 | Programming Language :: Python 14 | Programming Language :: Python :: 3 :: Only 15 | Programming Language :: Python :: 3.4 16 | Programming Language :: Python :: 3.5 17 | Programming Language :: Python :: 3.6 18 | Programming Language :: Python :: 3.7 19 | Programming Language :: Python :: 3.8 20 | Topic :: Internet :: WWW/HTTP 21 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 22 | 23 | [options] 24 | include_package_data = true 25 | packages = find: 26 | python_requires = >= 3.4 27 | install_requires = 28 | django>=2.2 29 | django-polymorphic==2.1.2 30 | django-crispy-forms>=1.9.1 31 | django-haystack>=2.8.1 32 | djangorestframework>=3.7.7 33 | -------------------------------------------------------------------------------- /vestlus/views/mixins/ajaxable_response.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | 3 | 4 | class AjaxableResponseMixin: 5 | """ 6 | Mixin to add AJAX support to a form. 7 | Must be used with an object-based FormView (e.g. CreateView) 8 | """ 9 | def form_invalid(self, form): 10 | print("AJAX Invalid") 11 | response = super().form_invalid(form) 12 | if self.request.is_ajax(): 13 | return JsonResponse(form.errors, status=400) 14 | else: 15 | return response 16 | 17 | def form_valid(self, form): 18 | print("AJAX valid") 19 | # We make sure to call the parent's form_valid() method because 20 | # it might do some processing (in the case of CreateView, it will 21 | # call form.save() for example). 22 | response = super().form_valid(form) 23 | if self.request.is_ajax(): 24 | data = { 25 | 'slug': self.object.slug, 26 | } 27 | form.save() 28 | return JsonResponse(data) 29 | else: 30 | return response 31 | -------------------------------------------------------------------------------- /vestlus/search_indexes/channel.py: -------------------------------------------------------------------------------- 1 | # vestlus:models:search_indexes 2 | from django.utils.timezone import datetime 3 | from vestlus.models import Channel 4 | from haystack import indexes 5 | 6 | 7 | class ChannelIndex(indexes.SearchIndex, indexes.Indexable): 8 | text = indexes.CharField(document=True, use_template=True) 9 | name = indexes.CharField(model_attr='name') 10 | owner = indexes.CharField(model_attr='owner') 11 | creation_date = indexes.DateTimeField(model_attr='created_at') 12 | update_date = indexes.DateTimeField(model_attr='updated_at') 13 | messages = indexes.MultiValueField() 14 | content_auto = indexes.EdgeNgramField(model_attr='name') # autocomplete 15 | rendered = indexes.CharField(use_template=True, indexed=False) # html renderer 16 | 17 | def get_model(self): 18 | return Channel 19 | 20 | def prepare_messages(self, obj): 21 | # Store message content for filtering 22 | return [message.content for message in obj.conversations.all()] 23 | 24 | def index_queryset(self, using=None): 25 | return self.get_model().objects.filter(updated_at__lte=datetime.now()) 26 | -------------------------------------------------------------------------------- /vestlus/views/channel_list.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.utils.decorators import method_decorator 3 | from django.contrib.auth.decorators import login_required 4 | from django.views.generic import ListView 5 | from django.urls import path 6 | from .routes import routes 7 | from ..models import Channel 8 | 9 | 10 | @method_decorator([login_required], name='dispatch') 11 | class ChannelListView(ListView): 12 | model = Channel 13 | template_name = 'channel_list.html' 14 | context_object_name = 'channels' 15 | paginate_by = 20 16 | 17 | def get_queryset(self): 18 | return Channel.objects.get_for_user( 19 | user=self.request.user, 20 | ).filter(members__user=self.request.user) 21 | 22 | def get_context_data(self, **kwargs): 23 | recommended_channels = Channel.objects.get_suggestions_for_user(user=self.request.user) 24 | context = super().get_context_data(**kwargs) 25 | context['recommended_channels'] = recommended_channels 26 | return context 27 | 28 | 29 | routes.append( 30 | path('channels/', ChannelListView.as_view(), name='channel-list') 31 | ) 32 | -------------------------------------------------------------------------------- /vestlus/views/membership_detail.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.utils.decorators import method_decorator 3 | from django.contrib.auth.decorators import login_required 4 | from django.views.generic import DetailView 5 | from django.urls import path 6 | from .routes import routes 7 | from ..models import Membership, GroupMessage 8 | 9 | 10 | @method_decorator([login_required], name='dispatch') 11 | class MembershipDetailView(DetailView): 12 | model = Membership 13 | context_object_name = 'membership' 14 | template_name = 'membership_detail.html' 15 | 16 | # def get_queryset(self): 17 | # return Membership.objects.get_for_user(user=self.request.user) 18 | 19 | def get_context_data(self, **kwargs): 20 | messages = GroupMessage.custom_objects.get_for_channel( 21 | channel=self.object.channel, 22 | user=self.object.user 23 | ) 24 | 25 | context = super().get_context_data(**kwargs) 26 | context['messages'] = messages 27 | return context 28 | 29 | 30 | routes.append( 31 | path('memberships/', MembershipDetailView.as_view(), name='membership-detail') 32 | ) 33 | -------------------------------------------------------------------------------- /vestlus/templates/search/indexes/vestlus/message_rendered.txt: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{ object.preview }} 5 |

6 | 7 | Sent by 8 | 9 | 10 | {{ object.sender.first_name }} 11 | 12 | 13 | {% if object.channel %} 14 | in 15 | 16 | {{ object.channel.name }} 17 | 18 | {% endif %} 19 | {{ object.created_at|timesince }} ago 20 | 21 |

22 |
23 |
24 | 29 |
-------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | id: checkout_code 14 | uses: actions/checkout@master 15 | 16 | # Create a release 17 | - name: Create release 18 | id: create_release 19 | uses: actions/create-release@v1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref }} 24 | release_name: Release ${{ github.ref }} 25 | draft: false 26 | prerelease: false 27 | 28 | # Install dependencies and push to PyPI 29 | - name: Set up Python 30 | uses: actions/setup-python@v1 31 | with: 32 | python-version: '3.x' 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install setuptools wheel twine 37 | - name: Build and publish 38 | env: 39 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 40 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 41 | run: | 42 | python setup.py sdist bdist_wheel 43 | twine upload dist/* 44 | -------------------------------------------------------------------------------- /vestlus/views/message_detail.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.utils.decorators import method_decorator 3 | from django.contrib.auth.decorators import login_required 4 | from django.db.models import Q 5 | from django.views.generic import DetailView 6 | from django.contrib.auth.mixins import UserPassesTestMixin 7 | from django.urls import path 8 | from .routes import routes 9 | from ..models import Message 10 | 11 | 12 | @method_decorator([login_required], name='dispatch') 13 | class MessageDetailView(UserPassesTestMixin, DetailView): 14 | model = Message 15 | template_name = 'message_detail.html' 16 | context_object_name = 'message' 17 | 18 | def test_func(self): 19 | user = self.request.user 20 | message = self.get_object() 21 | return ( 22 | user == message.sender or 23 | user == message.receiver 24 | ) 25 | # return user == message.sender or user == message.receiver 26 | 27 | # def get_queryset(self): 28 | # queryset = super(MessageDetailView, self).get_queryset() 29 | # return queryset.get_for_user(user=self.request.user) 30 | 31 | 32 | routes.append( 33 | path('messages/', MessageDetailView.as_view(), name='message-detail') 34 | ) 35 | -------------------------------------------------------------------------------- /vestlus/fixtures/vestlus-channels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "vestlus.channel", 4 | "fields": { 5 | "id": 20, 6 | "name": "The Friends of Friends", 7 | "owner": 1, 8 | "access_code": "5ce47ca2-25f8-4bbb-9e7c-34c015eca229", 9 | "slug": "the-friends-of-friends-34c015eca229", 10 | "is_private": true, 11 | "created_at": "2020-02-01T18:34:16Z", 12 | "updated_at": "2019-02-18T14:23:30Z" 13 | } 14 | }, 15 | { 16 | "model": "vestlus.channel", 17 | "fields": { 18 | "id": 21, 19 | "name": "Vestibular Occupations", 20 | "owner": 2, 21 | "access_code": "09fca386-f285-4621-a47a-831b3df0f098", 22 | "slug": "vestibular-occupations-831b3df0f098", 23 | "is_private": false, 24 | "created_at": "2020-02-23T13:42:56Z", 25 | "updated_at": "2019-11-10T14:35:13Z" 26 | } 27 | }, 28 | { 29 | "model": "vestlus.channel", 30 | "fields": { 31 | "id": 22, 32 | "name": "Book Club", 33 | "owner": 1, 34 | "access_code": "f301791d-d8c2-40ba-b1bd-09a37dab167f", 35 | "slug": "book-club-occupations-09a37dab167f", 36 | "is_private": false, 37 | "created_at": "2020-02-23T13:42:56Z", 38 | "updated_at": "2019-11-10T14:35:13Z" 39 | } 40 | } 41 | ] -------------------------------------------------------------------------------- /vestlus/templates/membership_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |
6 | 20 | 21 |
22 |

Invite member to channel

23 |
24 | 25 | 26 |
27 | {% csrf_token %} 28 |
29 | {{ form|crispy }} 30 |
31 | 32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /vestlus/models/tests/message.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.contrib.auth import get_user_model 3 | from ..message import Message 4 | from ..message import PrivateMessage 5 | 6 | 7 | class MessageTestCase(TestCase): 8 | def setUp(self): 9 | self.alice = get_user_model().objects.create(id=10, username="alice", email="alice@example.org") 10 | self.bob = get_user_model().objects.create(id=55, username="bob", email="bob@example.org") 11 | 12 | def test_send_private_message(self): 13 | PrivateMessage.objects.create( 14 | sender=self.alice, 15 | recipient=self.bob, 16 | content='Hi friend!' 17 | ) 18 | self.assertEqual(Message.objects.last(), PrivateMessage.objects.last()) 19 | 20 | def test_save_private_note_without_recipient(self): 21 | message = PrivateMessage.objects.create( 22 | sender=self.alice, 23 | content='Note to self' 24 | ) 25 | self.assertEqual(message.recipient, None) 26 | 27 | def test_save_private_note_with_recipient(self): 28 | message = PrivateMessage.objects.create( 29 | sender=self.alice, 30 | recipient=self.alice, 31 | content='Note to self' 32 | ) 33 | self.assertEqual(message.recipient, message.sender) 34 | -------------------------------------------------------------------------------- /vestlus/models/managers/membership.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Q 3 | 4 | 5 | class MembershipManager(models.Manager): 6 | 7 | def get_queryset(self): 8 | return super().get_queryset().prefetch_related() 9 | 10 | def none(self): 11 | return self.get_queryset().filter(user_id=0) 12 | 13 | def me(self, user): 14 | return self.get_queryset().filter(user=user) 15 | 16 | def get_for_channel(self, channel): 17 | return self.get_queryset().filter( 18 | Q(channel=channel) & 19 | Q(channel__is_private=False) 20 | ).distinct() 21 | 22 | def get_for_user(self, user): 23 | return self.get_queryset().filter( 24 | Q(user=user) | 25 | Q(channel__members__user=user) 26 | ).distinct() 27 | 28 | # Get membership information if: 29 | # - User is member of channel or 30 | # - Channel is open to the public 31 | def get_for_channel_and_user(self, channel, user): 32 | return self.get_queryset().filter( 33 | ( 34 | Q(channel=channel) & 35 | Q(channel__members__user=user) 36 | ) | 37 | ( 38 | Q(channel=channel) & 39 | Q(channel__is_private=False) 40 | ) 41 | ).distinct() 42 | -------------------------------------------------------------------------------- /vestlus/admin/channel.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from ..models import Channel 3 | from .inlines import GroupMessageInline, MembershipInline 4 | from .actions import make_private, make_public 5 | 6 | 7 | @admin.register(Channel) 8 | class ChannelAdmin(admin.ModelAdmin): 9 | actions = [make_private, make_public] 10 | inlines = [GroupMessageInline, MembershipInline] 11 | list_display = [ 12 | 'slug', 13 | 'id', 14 | 'name', 15 | 'total_members', 16 | 'is_private', 17 | 'owner', 18 | 'created_at', 19 | 'updated_at', 20 | 'uuid', 21 | ] 22 | 23 | def get_queryset(self, request): 24 | return Channel.objects.get_for_user( 25 | user=request.user 26 | ) 27 | 28 | def has_view_or_change_permission(self, request, obj=None): 29 | if obj is not None: 30 | return obj.owner == request.user 31 | return True 32 | 33 | def has_delete_permission(self, request, obj=None): 34 | if obj is not None: 35 | return obj.owner == request.user 36 | return True 37 | 38 | # Ensure current user is assigned as owner of channel. 39 | def save_model(self, request, obj, form, change): 40 | if not obj.owner_id: 41 | obj.owner = request.user 42 | obj.save() 43 | -------------------------------------------------------------------------------- /vestlus/views/membership_create.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.shortcuts import reverse 3 | from django.utils.decorators import method_decorator 4 | from django.contrib.auth.decorators import login_required 5 | from django.views.generic import CreateView 6 | from django.urls import path 7 | from .routes import routes 8 | from ..models import Membership, Channel 9 | 10 | 11 | @method_decorator([login_required], name='dispatch') 12 | class MembershipCreateView(CreateView): 13 | model = Membership 14 | context_object_name = 'membership' 15 | template_name = 'membership_create.html' 16 | fields = ('user', 'is_admin',) 17 | 18 | def form_valid(self, form): 19 | channel = Channel.objects.get(slug=self.kwargs['slug']) 20 | form.instance.channel = channel 21 | form.instance.invited_by = self.request.user 22 | return super().form_valid(form) 23 | 24 | def get_context_data(self, **kwargs): 25 | channel = Channel.objects.get(slug=self.kwargs['slug']) 26 | context = super().get_context_data(**kwargs) 27 | context['channel'] = channel 28 | return context 29 | 30 | def get_success_url(self): 31 | return reverse(viewname='vestlus:channel-detail', kwargs=self.kwargs) 32 | 33 | 34 | routes.append( 35 | path('memberships//new/', MembershipCreateView.as_view(), name='membership-create') 36 | ) 37 | -------------------------------------------------------------------------------- /vestlus/views/message_delete.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.shortcuts import reverse 3 | from django.utils.decorators import method_decorator 4 | from django.contrib.auth.decorators import login_required 5 | from django.views.generic import DeleteView 6 | from django.urls import path 7 | from .routes import routes 8 | from ..models import Message, GroupMessage 9 | 10 | 11 | @method_decorator([login_required], name='dispatch') 12 | class MessageDeleteView(DeleteView): 13 | model = Message 14 | context_object_name = 'message' 15 | template_name = 'message_delete.html' 16 | 17 | def get_success_url(self): 18 | return reverse('vestlus:message-list') 19 | 20 | 21 | routes.append( 22 | path('messages//delete', MessageDeleteView.as_view(), name='message-delete') 23 | ) 24 | 25 | 26 | @method_decorator([login_required], name='dispatch') 27 | class ChannelMessageDeleteView(DeleteView): 28 | model = GroupMessage 29 | context_object_name = 'group_message' 30 | template_name = 'message_delete.html' 31 | 32 | def get_success_url(self): 33 | return reverse('vestlus:channel-detail', kwargs={'slug': self.kwargs['channel']}) 34 | 35 | 36 | routes.append( 37 | path( 38 | 'channels//messages//delete', 39 | ChannelMessageDeleteView.as_view(), 40 | name='channel-message-delete' 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /vestlus/models/managers/channel.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Q 3 | 4 | 5 | class ChannelManager(models.Manager): 6 | 7 | def get_queryset(self): 8 | return super().get_queryset().prefetch_related() 9 | 10 | def public(self): 11 | return self.get_queryset().filter( 12 | is_private=False 13 | ) 14 | 15 | def private(self): 16 | return self.get_queryset().filter( 17 | is_private=True 18 | ) 19 | 20 | def owned(self, user): 21 | return self.get_queryset().filter( 22 | owner=user 23 | ) 24 | 25 | # User can see channels that match any of the following: 26 | # - channel is owned by user 27 | # - user is member of channel 28 | # - channel is open to the public 29 | def get_for_user(self, user): 30 | return self.get_queryset().filter( 31 | Q(owner=user) | 32 | Q(members__user=user) | 33 | Q(is_private=False) 34 | ).distinct() 35 | 36 | # User can see channels that match any of the following: 37 | # - channel is public 38 | # - user is not in channel 39 | def get_suggestions_for_user(self, user): 40 | return self.get_queryset().filter( 41 | Q(is_private=False) & 42 | ( 43 | ~Q(owner=user) | 44 | ~Q(members__user=user) 45 | ) 46 | ).distinct() 47 | -------------------------------------------------------------------------------- /vestlus/templates/message_create.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |
6 | 19 | 20 |
21 |

Create Message

22 |
23 | 24 | 25 |
26 | {% csrf_token %} 27 |
28 | {{ form|crispy }} 29 | 30 | 31 |
32 | 33 |
34 | 35 | 36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /vestlus/views/channel_detail.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.urls import reverse 3 | from django.utils.decorators import method_decorator 4 | from django.contrib.auth.decorators import login_required 5 | from django.views.generic import DetailView, CreateView 6 | from django.urls import path 7 | from .routes import routes 8 | from ..models import Channel 9 | from ..forms import GroupMessageForm 10 | 11 | 12 | @method_decorator([login_required], name='dispatch') 13 | class ChannelDetailView(DetailView, CreateView): 14 | model = Channel 15 | context_object_name = 'channel' 16 | template_name = 'channel_detail.html' 17 | form_class = GroupMessageForm 18 | 19 | def form_valid(self, form): 20 | form.instance.channel = self.get_object() 21 | form.instance.sender = self.request.user 22 | return super().form_valid(form) 23 | 24 | def get_success_url(self): 25 | return reverse(viewname='vestlus:channel-detail', kwargs={'slug': self.get_object().slug}) 26 | 27 | def get_queryset(self): 28 | return Channel.objects.get_for_user(user=self.request.user) 29 | 30 | def get_context_data(self, **kwargs): 31 | context = super().get_context_data(**kwargs) 32 | if self.object is not None: 33 | context['user_in_admins'] = self.get_object().admins.filter(user=self.request.user) 34 | return context 35 | 36 | 37 | routes.append( 38 | path('channels/', ChannelDetailView.as_view(), name='channel-detail') 39 | ) 40 | -------------------------------------------------------------------------------- /vestlus/views/message_create.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.urls import reverse 3 | from django.utils.decorators import method_decorator 4 | from django.contrib.auth.decorators import login_required 5 | from django.db.models import Q 6 | from django.views.generic import CreateView, View, DetailView 7 | from django.urls import path 8 | from .mixins import AjaxableResponseMixin 9 | from .routes import routes 10 | from ..models import Message, GroupMessage, Channel 11 | from ..forms import GroupMessageForm 12 | 13 | 14 | @method_decorator([login_required], name='dispatch') 15 | class ChannelMessageCreateView(AjaxableResponseMixin, CreateView): 16 | form_class = GroupMessageForm 17 | template_name = 'message_create.html' 18 | context_object_name = 'message' 19 | 20 | def __init__(self, **kwargs): 21 | super().__init__(**kwargs) 22 | slug = kwargs.get('slug', None) 23 | 24 | if slug is not None: 25 | self.channel = Channel.objects.get(slug=slug) 26 | 27 | def get_success_url(self): 28 | return reverse(viewname='vestlus:channel-detail', kwargs={'slug': self.channel.slug}) 29 | 30 | def get_context_data(self, **kwargs): 31 | context = super().get_context_data(**kwargs) 32 | 33 | slug = self.kwargs['slug'] 34 | self.channel = Channel.objects.get(slug=slug) 35 | 36 | context['channel'] = self.channel 37 | return context 38 | 39 | 40 | routes.append( 41 | path('channels//messages/new/', ChannelMessageCreateView.as_view(), name='message-create') 42 | ) 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Leo Neto 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /vestlus/viewsets/channel.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from rest_framework.decorators import action 3 | from rest_framework.response import Response 4 | from .router import router 5 | from ..models import Channel 6 | from ..serializers import ChannelSerializer 7 | from .message import GroupMessage, GroupMessageViewSet, GroupMessageSerializer 8 | from .membership import MembershipViewSet 9 | from .permissions.is_admin import IsChannelOwnerOrAdminOrReadOnly 10 | from .mixin.detail_action import DetailActionMixin 11 | from .mixin.non_detail_action import NonDetailActionMixin 12 | 13 | 14 | class ChannelViewSet(viewsets.ModelViewSet, DetailActionMixin, NonDetailActionMixin): 15 | queryset = Channel.objects.public() 16 | serializer_class = ChannelSerializer 17 | permission_classes = [IsChannelOwnerOrAdminOrReadOnly] 18 | lookup_field = 'uuid' 19 | lookup_url_kwarg = 'uuid' 20 | 21 | def get_queryset(self): 22 | return Channel.objects.get_for_user( 23 | user=self.request.user 24 | ) 25 | 26 | def perform_create(self, serializer): 27 | owner = self.request.user 28 | channel = serializer.save(owner=owner) 29 | 30 | # When a channel is created, a membership is also created via a signal. 31 | 32 | @action(detail=False, methods=['get']) 33 | def me(self, request): 34 | return self.non_detail_action(self.get_queryset()) 35 | 36 | @action(detail=True) 37 | def messages(self, request, uuid): 38 | # note the change from pk to uuid in signature 39 | objects = GroupMessage.objects.filter(channel=self.get_object()) 40 | return self.detail_action(objects, GroupMessageSerializer) 41 | 42 | 43 | router.register('channels', ChannelViewSet) 44 | -------------------------------------------------------------------------------- /vestlus/templates/channel_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 | 14 | 15 |
16 | 27 | 28 |
29 |

30 | {{ channel.name }} 31 | 32 | 33 | {% if channel.is_private %} 34 | 35 | {% endif %} 36 | 37 |

38 |
39 | 40 | 41 |
42 | {% csrf_token %} 43 |
44 | Are you sure you want to delete {{ object.name }}? 45 | {% if object.total_messages %} 46 | This will also delete all of its {{ object.total_messages }} messages. 47 | {% endif %} 48 |
49 | 50 | Cancel 51 |
52 |
53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /vestlus/admin/message.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from polymorphic.admin import PolymorphicParentModelAdmin 3 | from polymorphic.admin import PolymorphicChildModelAdmin 4 | from polymorphic.admin import PolymorphicChildModelFilter 5 | from ..models.message import Message 6 | from ..models.message import PrivateMessage 7 | from ..models.message import GroupMessage 8 | from .inlines.message import MessageInline 9 | from .inlines.message import ReactionInline 10 | from .actions import make_read, make_unread 11 | 12 | 13 | @admin.register(PrivateMessage) 14 | class PrivateMessageAdmin(PolymorphicChildModelAdmin): 15 | base_model = PrivateMessage 16 | show_in_index = True 17 | inlines = [MessageInline, ReactionInline] 18 | actions = [make_read, make_unread] 19 | list_display = [ 20 | 'slug', 21 | 'sender', 22 | 'receiver', 23 | 'created_at', 24 | 'updated_at', 25 | ] 26 | 27 | 28 | @admin.register(GroupMessage) 29 | class GroupMessageAdmin(PolymorphicChildModelAdmin): 30 | base_model = GroupMessage 31 | show_in_index = True 32 | inlines = [MessageInline, ReactionInline] 33 | list_display = [ 34 | 'slug', 35 | 'sender', 36 | 'channel', 37 | 'parent', 38 | 'created_at', 39 | 'updated_at', 40 | ] 41 | 42 | 43 | @admin.register(Message) 44 | class MessageAdmin(PolymorphicParentModelAdmin): 45 | base_model = Message 46 | inlines = [MessageInline, ReactionInline] 47 | list_filter = (PolymorphicChildModelFilter,) 48 | child_models = (PrivateMessage, GroupMessage) 49 | list_display = [ 50 | 'slug', 51 | 'id', 52 | 'sender', 53 | 'parent', 54 | 'created_at', 55 | 'updated_at', 56 | ] 57 | 58 | # Ensure current user is assigned as sender. 59 | def save_model(self, request, obj, form, change): 60 | if not obj.sender_id: 61 | obj.sender = request.user 62 | obj.save() 63 | -------------------------------------------------------------------------------- /vestlus/templates/message_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | {% load crispy_forms_tags %} 3 | 4 | {% block content %} 5 |
6 | 22 | 23 |
24 |

25 | {{ object.preview }} 26 | 27 | {% if group_message %} 28 | 29 | {% if channel.is_private %} 30 | 31 | {% endif %} 32 | 33 | {% endif %} 34 |

35 |
36 | 37 | 38 | {% if group_message %} 39 |
40 | {% else %} 41 | 42 | {% endif %} 43 | {% csrf_token %} 44 |
45 | Are you sure you want to delete this message? 46 |
47 | 48 | Cancel 49 |
50 |
51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /vestlus/templates/vue-templates.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | 25 | 26 | 49 | 50 | 69 | -------------------------------------------------------------------------------- /vestlus/models/membership.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.contrib.auth import get_user_model 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.template.defaultfilters import slugify 6 | from django.urls import reverse 7 | from .managers import MembershipManager 8 | from .channel import Channel 9 | 10 | 11 | class Membership(models.Model): 12 | channel = models.ForeignKey( 13 | Channel, verbose_name=_('channel'), related_name='members', on_delete=models.CASCADE 14 | ) 15 | user = models.ForeignKey( 16 | get_user_model(), verbose_name=_('user'), related_name='memberships', on_delete=models.CASCADE 17 | ) 18 | invited_by = models.ForeignKey( 19 | get_user_model(), 20 | verbose_name=_('invited by'), related_name='invitees', on_delete=models.DO_NOTHING, 21 | blank=True, null=True, help_text='The user who invited this one' 22 | ) 23 | is_admin = models.BooleanField( 24 | default=False, verbose_name=_('is admin'), help_text='Defines if user has admin privileges' 25 | ) 26 | 27 | # Default fields. Used for record-keeping. 28 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 29 | slug = models.SlugField(_('slug'), max_length=250, unique=True, editable=False, blank=True) 30 | created_at = models.DateTimeField(_('created at'), auto_now_add=True, editable=False) 31 | updated_at = models.DateTimeField(_('uploaded at'), auto_now=True, editable=False) 32 | 33 | objects = MembershipManager() 34 | 35 | class Meta: 36 | db_table = 'vestlus_channel_members' 37 | indexes = [models.Index(fields=['channel', 'user', 'invited_by', 'slug', 'created_at'])] 38 | ordering = ['-created_at', 'user'] 39 | unique_together = ('channel', 'user') 40 | 41 | @property 42 | def channel_owner(self): 43 | return self.channel.owner 44 | 45 | def save(self, *args, **kwargs): 46 | self.slug = slugify(f'{self.user}-{str(self.uuid)[-12:]}') 47 | super().save(*args, **kwargs) 48 | 49 | def __str__(self): 50 | return f'{self.slug}' 51 | 52 | def get_absolute_url(self): 53 | return reverse('vestlus:membership-detail', kwargs={'slug': self.slug}) 54 | -------------------------------------------------------------------------------- /vestlus/templates/vestlus.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Django Vestlus 20 | 21 | 22 | 30 | 31 | 32 | 33 | 34 | {% block content %} 35 | {% endblock content %} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 44 | 47 | 50 | 55 | 56 | 57 | 58 | {% block script %} 59 | {% endblock %} 60 | 61 | 62 | -------------------------------------------------------------------------------- /vestlus/models/channel.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.contrib.auth import get_user_model 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.template.defaultfilters import slugify 6 | from django.urls import reverse 7 | from .managers import ChannelManager 8 | 9 | 10 | class Channel(models.Model): 11 | name = models.CharField(verbose_name=_('name'), max_length=255) 12 | owner = models.ForeignKey( 13 | get_user_model(), verbose_name=_('owner'), related_name='channels', on_delete=models.DO_NOTHING 14 | ) 15 | access_code = models.UUIDField(verbose_name=_('access code'), default=uuid.uuid4, editable=False) 16 | is_private = models.BooleanField(verbose_name=_('is private'), default=True) 17 | photo = models.ImageField(verbose_name=_('photo'), blank=True, upload_to='channels/photos/') 18 | 19 | # Default fields. Used for record-keeping. 20 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 21 | slug = models.SlugField(_('slug'), max_length=250, unique=True, editable=False, blank=True) 22 | created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True, editable=False) 23 | updated_at = models.DateTimeField(verbose_name=_('updated at'), auto_now=True, editable=False) 24 | 25 | objects = ChannelManager() 26 | 27 | class Meta: 28 | db_table = 'vestlus_channels' 29 | indexes = [models.Index(fields=['name', 'owner', 'slug', 'created_at'])] 30 | ordering = ['-created_at', 'name'] 31 | 32 | @property 33 | def preview(self): 34 | return f'{self.name}' 35 | 36 | @property 37 | def avatar(self): 38 | return self.photo.url if self.photo else 'https://github.com/octocat.png' 39 | 40 | @property 41 | def admins(self): 42 | return self.members.filter(is_admin=True) 43 | 44 | @property 45 | def is_public(self): 46 | return not self.is_private 47 | 48 | @property 49 | def total_members(self): 50 | return self.members.count() 51 | 52 | @property 53 | def total_messages(self): 54 | return self.conversations.count() 55 | 56 | def save(self, *args, **kwargs): 57 | self.slug = slugify(f'{self.name}-{str(self.uuid)[-12:]}') 58 | super().save(*args, **kwargs) 59 | 60 | def __str__(self): 61 | return f'{self.slug}' 62 | 63 | def get_absolute_url(self): 64 | return reverse('vestlus:channel-detail', kwargs={'slug': self.slug}) 65 | -------------------------------------------------------------------------------- /vestlus/templates/message_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | 3 | {% block content %} 4 |
5 | 16 | 17 |
18 |
19 |
20 | 40x40 25 |
26 | 27 | 28 | {{ message.sender.first_name }} {{ message.sender.last_name }} 29 | 30 | {{ message.created_at|timesince }} ago 31 | {% if message.channel %} 32 | in 33 | 34 | {{ message.channel.name }} 35 | 36 | 37 | {% endif %} 38 | 39 |
40 | {{ message.content }} 41 |
42 | 43 |
44 | {% for reaction in message.reactions.all %} 45 | 46 | {{ reaction.reaction }} 47 | 48 | {% endfor %} 49 |
50 |
51 |
52 |
53 |
54 |
55 | {% endblock content %} 56 | -------------------------------------------------------------------------------- /vestlus/templates/membership_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | 3 | {% block content %} 4 |
5 | 21 | 22 |

23 | 24 | {{ membership.user.first_name }} {{ membership.user.last_name }} 25 | 26 |

27 | 28 | 29 |
30 | {% if not channel.conversations.count %} 31 |

No messages yet

32 | {% else %} 33 | {% for message in channel.conversations.all %} 34 |
35 |
36 | 40x40 41 |
42 | 43 | 44 | {{ message.sender.first_name }} {{ message.sender.last_name }} 45 | 46 | {{ message.created_at|timesince }} ago 47 | 48 |
49 | {{ message.content }} 50 |
51 |
52 | Reply 53 |
54 |
55 |
56 |
57 | {% endfor %} 58 | {% endif %} 59 |
60 |
61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /vestlus/templates/message_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | 3 | {% block content %} 4 |
5 | 13 | 14 |

Messages

15 | 16 | {% if not messages.count %} 17 |

No messages yet

18 | {% else %} 19 | {% for message in messages %} 20 |
21 |
22 | 40x40 27 |
28 |
29 |
30 |
31 | 32 | {{ message.sender.first_name }} {{ message.sender.last_name }} 33 | 34 | {{ message.created_at|timesince }} ago 35 |
36 | 37 |
38 | View message 39 | {% if message.sender == request.user %} 40 | 41 | 42 | 43 | 44 | 45 | {% endif %} 46 |
47 |
48 |
49 |
50 | {{ message.content }} 51 |
52 |
53 |
54 |
55 | {% endfor %} 56 | {% endif %} 57 |
58 | {% endblock content %} 59 | -------------------------------------------------------------------------------- /vestlus/models/managers/message.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from polymorphic.managers import PolymorphicManager 3 | 4 | 5 | class MessageManager(PolymorphicManager): 6 | def get_queryset(self): 7 | qs = super(MessageManager, self).get_queryset().prefetch_related() 8 | return qs 9 | 10 | # `Private notes` are messages with no recipient or where the sender is the recipient 11 | def private_notes(self, user): 12 | return self.get_queryset().filter( 13 | Q(sender=user) & 14 | Q( 15 | Q(PrivateMessage___receiver=user) | 16 | Q(PrivateMessage___receiver__isnull=True) 17 | ) & 18 | Q(GroupMessage___channel__isnull=True) 19 | ) 20 | 21 | def get_private_messages_for_user(self, user): 22 | return self.get_queryset().filter( 23 | Q(PrivateMessage___sender=user) | 24 | Q(PrivateMessage___receiver=user) 25 | ) 26 | 27 | # Get messages that match the following criteria: 28 | # - Message was sent by user 29 | # - Message was received by user 30 | # - Message was sent to a group of which the user is a member 31 | def get_for_user(self, user): 32 | return self.get_queryset().filter( 33 | Q(sender=user) | 34 | Q( 35 | Q(PrivateMessage___receiver=user) | 36 | Q(GroupMessage___channel__members__user=user) 37 | ) 38 | ).distinct() 39 | 40 | 41 | class GroupMessageManager(PolymorphicManager): 42 | def get_queryset(self): 43 | return super().get_queryset().prefetch_related() 44 | 45 | def most_recent(self): 46 | return self.get_queryset()[:20] 47 | 48 | # Get messages that match the following criteria: 49 | # - Message was sent by user 50 | # - Message was received by user 51 | # - Message was sent to a group of which the user is a member 52 | def get_for_user(self, user): 53 | return self.get_queryset().filter( 54 | Q(sender=user) | 55 | Q(channel__members__user=user) 56 | ).distinct() 57 | 58 | # Get messages that match the following criteria: 59 | # - Message was sent to the channel and 60 | # - Channel is either public or 61 | # - Message was sent to a group of which the user is a member 62 | def get_for_channel(self, channel, user): 63 | return self.get_queryset().filter( 64 | Q(channel=channel) & 65 | ( 66 | Q(channel__is_private=False) | 67 | Q(channel__members__user=user) 68 | ) 69 | ).distinct() 70 | 71 | def public(self): 72 | return self.get_queryset().filter( 73 | Q(GroupMessage___channel__is_private=False) 74 | ) 75 | 76 | def private(self): 77 | return self.get_queryset().filter(is_private=True) 78 | -------------------------------------------------------------------------------- /vestlus/viewsets/membership.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from rest_framework.exceptions import PermissionDenied, NotFound, NotAcceptable 3 | from rest_framework import viewsets 4 | from rest_framework import permissions 5 | from .router import router 6 | from ..models import Channel 7 | from ..models import Membership 8 | from ..serializers import MembershipSerializer 9 | from .mixin.detail_action import DetailActionMixin 10 | from .mixin.non_detail_action import NonDetailActionMixin 11 | 12 | 13 | class MembershipViewSet(viewsets.ModelViewSet): 14 | queryset = Membership.objects.none() 15 | serializer_class = MembershipSerializer 16 | permission_classes = [permissions.IsAuthenticated] 17 | lookup_field = 'uuid' 18 | 19 | def get_queryset(self): 20 | return Membership.objects.get_for_user( 21 | user=self.request.user 22 | ) 23 | 24 | # def initial(self, request, *args, **kwargs): 25 | # user = request.user 26 | # get_object_or_404(Channel.objects.get_for_user(user=user), pk=kwargs['channels_pk']) 27 | # self.queryset = Membership.objects.get_for_channel_and_user( 28 | # channel=kwargs['channels_pk'], 29 | # user=user 30 | # ) 31 | # return super().initial(request, args, kwargs) 32 | 33 | def perform_create(self, serializer): 34 | 35 | user = self.request.user 36 | 37 | try: 38 | channel = Channel.objects.get( 39 | id=self.kwargs['channels_pk'] 40 | ) 41 | except Channel.DoesNotExist: 42 | raise NotFound() 43 | 44 | # Only members can add people to channels 45 | if not channel.members.filter(user=user).exists(): 46 | if channel.is_private: 47 | raise PermissionDenied("Only members can invite new members to private channels.") 48 | 49 | # Ensure memberships are not duplicated 50 | invitee = serializer.validated_data['user'] 51 | if channel.members.filter(user_id=invitee).exists(): 52 | raise NotAcceptable("User is already a member.") 53 | 54 | serializer.save( 55 | channel_id=channel.id, 56 | invited_by_id=user.id 57 | ) 58 | 59 | def perform_update(self, serializer): 60 | 61 | serializer = MembershipSerializer() 62 | 63 | user = self.request.user 64 | 65 | try: 66 | channel = Channel.objects.get( 67 | id=self.kwargs['channels_pk'] 68 | ) 69 | except Channel.DoesNotExist: 70 | raise NotFound() 71 | 72 | # Only members can update members 73 | if not channel.members.filter(user=user).exists(): 74 | raise PermissionDenied() 75 | 76 | # User id cannot be updated 77 | if 'user' in serializer.validated_data: 78 | raise NotAcceptable() 79 | 80 | # Owner will always be admin. Only admins can make admins 81 | if 'is_admin' in serializer.validated_data: 82 | membership_id = self.kwargs['pk'] 83 | 84 | updated_user_id = Membership.objects.get(id=membership_id).user_id 85 | wants_to_update_owner = channel.owner.id == updated_user_id 86 | requester_is_admin = channel.members.get(user=user).is_admin 87 | 88 | if not requester_is_admin: 89 | raise PermissionDenied() 90 | 91 | if wants_to_update_owner: 92 | raise PermissionDenied("Channel owner is always admin.") 93 | 94 | serializer.save() 95 | 96 | 97 | router.register('memberships', MembershipViewSet) 98 | -------------------------------------------------------------------------------- /vestlus/viewsets/message.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from rest_framework import viewsets 3 | from rest_framework import permissions 4 | from rest_framework.decorators import action 5 | from rest_framework.response import Response 6 | from rest_framework import serializers 7 | from .permissions.is_sender import IsSenderOrReadOnly 8 | from .permissions.is_member import IsMemberOrNoAccess 9 | from .router import router 10 | from ..models import Channel 11 | from ..models import Message 12 | from ..models import PrivateMessage 13 | from ..models import GroupMessage 14 | from ..models import Reaction 15 | from ..serializers import PrivateMessageSerializer 16 | from ..serializers import GroupMessageSerializer 17 | from ..serializers import ReactionSerializer 18 | 19 | 20 | class MessageViewSet(viewsets.ModelViewSet): 21 | queryset = PrivateMessage.objects.all() 22 | serializer_class = PrivateMessageSerializer 23 | permission_classes = [IsSenderOrReadOnly] 24 | search_fields = ['^content'] 25 | permit_list_expands = ['sender', 'receiver', 'parent'] 26 | filter_fields = ['id', 'sender', 'receiver', 'created_at'] 27 | # lookup_field = 'uuid' 28 | 29 | def get_queryset(self): 30 | user = self.request.user 31 | 32 | return self.filter_queryset( 33 | Message.objects.get_for_user( 34 | user=user 35 | ) 36 | ) 37 | 38 | def perform_create(self, serializer): 39 | serializer.save(sender=self.request.user) 40 | 41 | @action(detail=False, methods=['get']) 42 | def notes(self, request): 43 | user = request.user 44 | messages = Message.objects.private_notes(user=user) 45 | messages = self.filter_queryset(messages) 46 | 47 | page = self.paginate_queryset(messages) 48 | if page is not None: 49 | serializer = self.get_serializer(page, many=True) 50 | return self.get_paginated_response(serializer.data) 51 | 52 | serializer = self.get_serializer(messages, many=True) 53 | return Response(serializer.data) 54 | 55 | 56 | class GroupMessageViewSet(viewsets.ModelViewSet): 57 | queryset = GroupMessage.objects.all() 58 | serializer_class = GroupMessageSerializer 59 | permission_classes = [ 60 | permissions.IsAuthenticated, 61 | IsSenderOrReadOnly | 62 | IsMemberOrNoAccess 63 | ] 64 | search_fields = ['^content'] 65 | permit_list_expands = ['sender', 'parent'] 66 | filter_fields = ['id', 'sender', 'parent', 'created_at'] 67 | # lookup_field = 'uuid' 68 | 69 | def initial(self, request, *args, **kwargs): 70 | user = request.user 71 | get_object_or_404(Channel.objects.get_for_user(user=user), pk=kwargs['channels_pk']) 72 | return super().initial(request, args, kwargs) 73 | 74 | def get_queryset(self): 75 | user = self.request.user 76 | 77 | return GroupMessage.objects.get_for_channel( 78 | channel=self.kwargs['channels_pk'], 79 | user=user 80 | ) 81 | 82 | def perform_create(self, serializer): 83 | user = self.request.user 84 | 85 | channel = Channel.objects.get( 86 | id=self.kwargs['channels_pk'] 87 | ) 88 | 89 | serializer.save( 90 | channel_id=channel.id, 91 | sender=user 92 | ) 93 | 94 | 95 | class ReactionViewSet(viewsets.ModelViewSet): 96 | queryset = Reaction.objects.all() 97 | serializer_class = ReactionSerializer 98 | permission_classes = [permissions.IsAuthenticatedOrReadOnly] 99 | lookup_field = 'uuid' 100 | 101 | def get_queryset(self): 102 | return self.queryset.filter( 103 | message=self.kwargs['messages_pk'], 104 | user=self.request.user 105 | ) 106 | 107 | def perform_create(self, serializer): 108 | serializer.save( 109 | message_id=self.kwargs['messages_pk'], 110 | user=self.request.user, 111 | ) 112 | 113 | 114 | router.register('messages', MessageViewSet) 115 | 116 | router.register('group-messages', GroupMessageViewSet) 117 | -------------------------------------------------------------------------------- /vestlus/serializers/tests/group_message.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.test import APIClient, APITestCase 3 | from django.contrib.auth import get_user_model 4 | from django.db import transaction 5 | from ...models import Channel 6 | from ...models import GroupMessage 7 | 8 | 9 | class GroupMessageTestCase(APITestCase): 10 | 11 | # The client used to connect to the API 12 | client = APIClient() 13 | 14 | def setUp(self): 15 | """ 16 | Prepare database and client. 17 | """ 18 | 19 | # API endpoint 20 | self.namespace = '/v1/channels' 21 | 22 | @classmethod 23 | def setUpTestData(cls): 24 | # Retrieve users 25 | cls.alice = get_user_model().objects.create(id=10, username="alice", email="alice@example.org") 26 | cls.bob = get_user_model().objects.create(id=20, username="bob", email="bob@example.org") 27 | 28 | # Create channel 29 | cls.channel = Channel.objects.create(owner=cls.alice, name='My Private Channel') 30 | 31 | # Create group_messages 32 | cls.first_message = GroupMessage.objects.create(channel=cls.channel, sender=cls.alice, content='First message') 33 | cls.second_message = GroupMessage.objects.create(channel=cls.channel, sender=cls.alice, content='Second message') 34 | cls.third_message = GroupMessage.objects.create(channel=cls.channel, sender=cls.alice, content='Third message') 35 | 36 | def tearDown(self): 37 | try: 38 | with transaction.atomic(): 39 | Channel.objects.all().delete() 40 | GroupMessage.objects.all().delete() 41 | get_user_model().objects.all().delete() 42 | except transaction.Error: 43 | pass 44 | 45 | #################################################### 46 | # Require authentication 47 | def test_must_authenticate_to_read_group_messages(self): 48 | res = self.client.get(self.namespace) 49 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) 50 | 51 | def test_must_authenticate_to_create_group_messages(self): 52 | res = self.client.post(self.namespace) 53 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) 54 | 55 | #################################################### 56 | # Allowed operations 57 | def test_create_group_message(self): 58 | self.client.force_authenticate(user=self.alice) 59 | 60 | url = self.namespace + f'/{self.channel.id}/messages' 61 | res = self.client.post(url, data={'content': 'I am here now!'}) 62 | self.assertEqual(res.status_code, status.HTTP_201_CREATED) 63 | 64 | def test_list_group_message(self): 65 | self.client.force_authenticate(user=self.alice) 66 | 67 | url = self.namespace + f'/{self.channel.id}/messages' 68 | res = self.client.get(url) 69 | 70 | self.assertEqual(res.status_code, status.HTTP_200_OK) 71 | self.assertEqual(res.data['results'][0]['sender'], self.alice.id) 72 | self.assertEqual(res.data['results'][0]['channel'], self.channel.id) 73 | 74 | def test_retrieve_group_message(self): 75 | self.client.force_authenticate(user=self.alice) 76 | 77 | url = self.namespace + f'/{self.channel.id}/messages/{self.first_message.id}' 78 | res = self.client.get(url) 79 | 80 | self.assertEqual(res.status_code, status.HTTP_200_OK) 81 | self.assertEqual(res.data['sender'], self.alice.id) 82 | self.assertEqual(res.data['channel'], self.channel.id) 83 | 84 | def test_update_group_message(self): 85 | self.client.force_authenticate(user=self.alice) 86 | 87 | url = self.namespace + f'/{self.channel.id}/messages/{self.first_message.id}' 88 | res = self.client.patch(url, data={'content': 'Updated Message'}) 89 | 90 | self.assertEqual(res.status_code, status.HTTP_200_OK) # should return 202 Accepted 91 | self.assertEqual(res.data['sender'], self.alice.id) 92 | self.assertEqual(res.data['channel'], self.channel.id) 93 | 94 | def test_delete_group_message(self): 95 | self.client.force_authenticate(user=self.alice) 96 | 97 | url = self.namespace + f'/{self.channel.id}/messages/{self.first_message.id}' 98 | res = self.client.delete(url) 99 | self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Vestlus 2 | 3 | **vestlus** is a django chat app with support for private and public channels. 4 | 5 | ![PyPI - License](https://img.shields.io/pypi/l/django-vestlus) 6 | ![PyPI - Version](https://img.shields.io/pypi/v/django-vestlus) 7 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-vestlus) 8 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/django-vestlus) 9 | ![Github - Issues](https://img.shields.io/github/issues/lehvitus/vestlus) 10 | 11 | #### Dependencies 12 | Use of **vestlus** requires: 13 | - `django-polymorphic`: used to handle inheritance between message models. 14 | - `django-crispy-forms`: used for better formatting of forms in templates. 15 | - `django-restframework`: used to provide REST api support. 16 | - `django-haystack`: used to handle searching across channels and messages. 17 | 18 | Those apps will need to be installed in the ``INSTALLED_APPS`` tuple of your django project. 19 | 20 | 21 | #### Models 22 | The app is split into three main models: 23 | - [channel](vestlus/models/channel.py): channels allow for group conversations. Any message sent to a channel 24 | is visible to every member of the channel. Channels can be either public or private. 25 | - [membership](vestlus/models/membership.py): memberships allow users to join, leave, and administer channels. 26 | Owners and admins can manage channel memberships. 27 | - [message](vestlus/models/message.py): all messages are private (self to self); private (shared with somebody else); or 28 | 29 | 30 | #### Installation 31 | 1. Add **vestlus** to your `INSTALLED_APPS` setting like this:: 32 | ```python 33 | INSTALLED_APPS = [ 34 | # other apps... 35 | 'vestlus', 36 | ] 37 | ``` 38 | 39 | Alternatively, you can also add this app like so:: 40 | ```python 41 | INSTALLED_APPS = [ 42 | # other apps... 43 | 'vestlus.apps.VestlusConfig', 44 | ] 45 | ``` 46 | 47 | 2. Include the polls URLconf in your project urls.py like this:: 48 | ```python 49 | path('chat/', include('vestlus.urls', namespace='vestlus')), 50 | ``` 51 | 52 | 2.1. Optionally, you can also add the api endpoints in your project urls.py like so:: 53 | ```python 54 | path('api/', include('vestlus.api', namespace='vestlus_api')), 55 | ``` 56 | 57 | 3. Run ``python manage.py migrate`` to create the app models. 58 | 59 | 4. Start the development server and visit [`http://127.0.0.1:8000/admin/`](http://127.0.0.1:8000/admin/) 60 | to start a add chat groups and messages (you'll need the Admin app enabled). 61 | 62 | 5. Visit [`http://127.0.0.1:8000/chat/`](http://127.0.0.1:8000/admin/) to use the app. You should have the following urls added to your url schemes:: 63 | ``` 64 | http://127.0.0.1:8000/chat/ 65 | http://127.0.0.1:8000/chat/channels/ 66 | http://127.0.0.1:8000/chat/channels/new/ 67 | http://127.0.0.1:8000/chat/channels/ 68 | http://127.0.0.1:8000/chat/channels//messages//delete 69 | http://127.0.0.1:8000/chat/channels//delete 70 | http://127.0.0.1:8000/chat/channels//messages/new/ 71 | http://127.0.0.1:8000/chat/memberships/ 72 | http://127.0.0.1:8000/chat/memberships/ 73 | http://127.0.0.1:8000/chat/memberships//new/ 74 | http://127.0.0.1:8000/chat/messages/ 75 | http://127.0.0.1:8000/chat/messages/ 76 | http://127.0.0.1:8000/chat/messages//delete 77 | ``` 78 | 79 | 5.1. If you've included the api urls as well, you can visit the endpoints by visiting:: 80 | ``` 81 | http://127.0.0.1:8000/api/channels 82 | http://127.0.0.1:8000/api/channels/ 83 | http://127.0.0.1:8000/api/channels/ 84 | http://127.0.0.1:8000/api/channels//messages 85 | http://127.0.0.1:8000/api/channels//messages 86 | http://127.0.0.1:8000/api/channels/me 87 | http://127.0.0.1:8000/api/group-messages 88 | http://127.0.0.1:8000/api/group-messages/ 89 | http://127.0.0.1:8000/api/memberships 90 | http://127.0.0.1:8000/api/memberships/ 91 | http://127.0.0.1:8000/api/messages 92 | http://127.0.0.1:8000/api/messages/ 93 | http://127.0.0.1:8000/api/messages/notes 94 | ``` 95 | 96 | ## License 97 | **vestlus** is [BSD-Licensed](LICENSE.md). 98 | 99 | ------ 100 | 101 | Built with [django-clite](https://github.com/oleoneto/django-clite). 102 | 103 | Developed and maintained by [Leo Neto](https://github.com/oleoneto) 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # pytype static type analyzer 138 | .pytype/ 139 | 140 | # Cython debug symbols 141 | cython_debug/ 142 | 143 | ### JetBrains template 144 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 145 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 146 | 147 | # User-specific stuff 148 | .idea/**/workspace.xml 149 | .idea/**/tasks.xml 150 | .idea/**/usage.statistics.xml 151 | .idea/**/dictionaries 152 | .idea/**/shelf 153 | 154 | # Generated files 155 | .idea/**/contentModel.xml 156 | 157 | # Sensitive or high-churn files 158 | .idea/**/dataSources/ 159 | .idea/**/dataSources.ids 160 | .idea/**/dataSources.local.xml 161 | .idea/**/sqlDataSources.xml 162 | .idea/**/dynamic.xml 163 | .idea/**/uiDesigner.xml 164 | .idea/**/dbnavigator.xml 165 | 166 | # Gradle 167 | .idea/**/gradle.xml 168 | .idea/**/libraries 169 | 170 | # Gradle and Maven with auto-import 171 | # When using Gradle or Maven with auto-import, you should exclude module files, 172 | # since they will be recreated, and may cause churn. Uncomment if using 173 | # auto-import. 174 | # .idea/artifacts 175 | # .idea/compiler.xml 176 | # .idea/jarRepositories.xml 177 | # .idea/modules.xml 178 | # .idea/*.iml 179 | # .idea/modules 180 | # *.iml 181 | # *.ipr 182 | 183 | # CMake 184 | cmake-build-*/ 185 | 186 | # Mongo Explorer plugin 187 | .idea/**/mongoSettings.xml 188 | 189 | # File-based project format 190 | *.iws 191 | 192 | # IntelliJ 193 | out/ 194 | 195 | # mpeltonen/sbt-idea plugin 196 | .idea_modules/ 197 | 198 | # JIRA plugin 199 | atlassian-ide-plugin.xml 200 | 201 | # Cursive Clojure plugin 202 | .idea/replstate.xml 203 | 204 | # Crashlytics plugin (for Android Studio and IntelliJ) 205 | com_crashlytics_export_strings.xml 206 | crashlytics.properties 207 | crashlytics-build.properties 208 | fabric.properties 209 | 210 | # Editor-based Rest Client 211 | .idea/httpRequests 212 | 213 | # Android studio 3.1+ serialized cache file 214 | .idea/caches/build_file_checksums.ser 215 | 216 | -------------------------------------------------------------------------------- /vestlus/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | 3 | {% block content %} 4 |
5 |

6 | Vestlus Chat 7 |
8 |

9 | 10 | 11 |
12 |
13 |

Channels

14 | 15 | 16 | View channels 17 | 18 | 19 |
20 | 21 |
22 | {% for channel in channels|slice:6 %} 23 |
24 |
25 |
26 |
27 | 33 |
34 | 35 | {{ channel.name }} 36 | {% if channel.is_private %} 37 | 38 | {% endif %} 39 | 40 | 41 | 42 | {% if channel.owner == request.user %} 43 | You created this channel 44 | {% else %} 45 | Joined {{ channel.created_at|timesince }} ago 46 | {% endif %} 47 | 48 | 49 |
50 |
51 |
52 |
53 |
54 | {% endfor %} 55 |
56 |
57 |
58 | 59 | 60 |
61 |
62 |

Recent messages

63 | 64 | 65 | View messages 66 | 67 | 68 |
69 | 70 | {% if not messages.count %} 71 |

No messages yet

72 | {% else %} 73 | {% for message in messages|slice:3 %} 74 |
75 |
76 | 40x40 81 |
82 | 83 | 84 | {{ message.sender.first_name }} {{ message.sender.last_name }} 85 | 86 | {{ message.created_at|timesince }} ago 87 | {% if message.channel %} 88 | in 89 | 90 | {{ message.channel.name }} 91 | 92 | 93 | {% endif %} 94 | 95 |
96 | {{ message.content }} 97 |
98 |
99 |
100 |
101 | {% endfor %} 102 | {% endif %} 103 |
104 |
105 | {% endblock %} -------------------------------------------------------------------------------- /vestlus/models/message.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.contrib.auth import get_user_model 4 | from django.utils.translation import gettext_lazy as _ 5 | from django.template.defaultfilters import slugify, truncatewords 6 | from django.urls import reverse 7 | from polymorphic.models import PolymorphicModel 8 | from polymorphic.managers import PolymorphicManager 9 | from .channel import Channel 10 | from .managers import MessageManager 11 | from .managers import GroupMessageManager 12 | from .managers import ReactionManager 13 | 14 | 15 | class Message(PolymorphicModel): 16 | sender = models.ForeignKey( 17 | get_user_model(), 18 | verbose_name=_('sender'), 19 | related_name='sent_messages', 20 | on_delete=models.PROTECT, 21 | help_text='The user who authored the message' 22 | ) 23 | parent = models.ForeignKey( 24 | 'self', 25 | verbose_name=_('parent'), 26 | related_name='replies', 27 | on_delete=models.CASCADE, 28 | null=True, 29 | blank=True, 30 | help_text='The main message in a message thread' 31 | ) 32 | content = models.TextField(_('content')) 33 | 34 | # Default fields. Used for record-keeping. 35 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 36 | slug = models.SlugField(_('slug'), max_length=250, unique=True, editable=False, blank=True) 37 | created_at = models.DateTimeField(_('created at'), auto_now_add=True, editable=False) 38 | updated_at = models.DateTimeField(_('uploaded at'), auto_now=True, editable=False) 39 | 40 | objects = PolymorphicManager() 41 | custom_objects = MessageManager() 42 | 43 | class Meta: 44 | db_table = 'vestlus_messages' 45 | indexes = [models.Index(fields=['created_at', 'sender', 'parent'])] 46 | ordering = ['-created_at', 'sender'] 47 | 48 | @property 49 | def preview(self): 50 | return f'{truncatewords(self.content, 20)}...' 51 | 52 | @property 53 | def avatar(self): 54 | try: 55 | return self.sender.photo.url 56 | except AttributeError: 57 | return 'https://github.com/octocat.png' 58 | 59 | def save(self, *args, **kwargs): 60 | self.slug = slugify(f'{str(self.uuid)[-12:]}') 61 | super().save(*args, **kwargs) 62 | 63 | def __str__(self): 64 | return f'{self.slug}' 65 | 66 | def get_absolute_url(self): 67 | return reverse('vestlus:message-detail', kwargs={'slug': self.slug}) 68 | 69 | 70 | class PrivateMessage(Message): 71 | receiver = models.ForeignKey( 72 | get_user_model(), 73 | verbose_name=_('receiver'), 74 | related_name='received_messages', 75 | on_delete=models.PROTECT, 76 | null=True, 77 | blank=True, 78 | help_text='The user this message will be sent to' 79 | ) 80 | read = models.BooleanField( 81 | default=False, 82 | verbose_name=_('read'), 83 | help_text='Indicates whether or not the recipient has visualized the message' 84 | ) 85 | 86 | class Meta: 87 | db_table = 'vestlus_private_messages' 88 | indexes = [models.Index(fields=['receiver', 'read'])] 89 | 90 | 91 | class GroupMessage(Message): 92 | channel = models.ForeignKey( 93 | Channel, 94 | verbose_name=_('channel'), 95 | related_name='conversations', 96 | on_delete=models.CASCADE, 97 | help_text='The channel associated with this message' 98 | ) 99 | 100 | custom_objects = GroupMessageManager() 101 | 102 | class Meta: 103 | db_table = 'vestlus_group_messages' 104 | indexes = [models.Index(fields=['channel'])] 105 | ordering = ['-created_at', 'channel'] 106 | 107 | 108 | class Reaction(models.Model): 109 | message = models.ForeignKey( 110 | Message, 111 | verbose_name=_('message'), 112 | related_name='reactions', 113 | on_delete=models.CASCADE, 114 | ) 115 | user = models.ForeignKey( 116 | get_user_model(), 117 | verbose_name=_('user'), 118 | related_name='reactions', 119 | on_delete=models.CASCADE, 120 | help_text='The user who reacted to the message' 121 | ) 122 | reaction = models.CharField( 123 | max_length=32, 124 | verbose_name=_('reaction'), 125 | help_text='Emoji reaction code as unicode' 126 | ) 127 | 128 | # Default fields. Used for record-keeping. 129 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 130 | created_at = models.DateTimeField(_('created at'), auto_now_add=True, editable=False) 131 | updated_at = models.DateTimeField(_('uploaded at'), auto_now=True, editable=False) 132 | 133 | objects = ReactionManager() 134 | 135 | class Meta: 136 | db_table = 'vestlus_message_reactions' 137 | ordering = ['-created_at', 'message', 'user'] 138 | -------------------------------------------------------------------------------- /vestlus/serializers/tests/message.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.test import APIClient, APITestCase 3 | from django.contrib.auth import get_user_model 4 | from django.db import transaction 5 | from ...models import PrivateMessage 6 | 7 | 8 | class MessageTestCase(APITestCase): 9 | 10 | # The client used to connect to the API 11 | client = APIClient() 12 | 13 | def setUp(self): 14 | """ 15 | Prepare database and client. 16 | """ 17 | 18 | # API endpoint 19 | self.namespace = '/v1/messages' 20 | 21 | @classmethod 22 | def setUpTestData(cls): 23 | # Create users 24 | cls.alice = get_user_model().objects.create(id=10, username="alice", email="alice@example.org") 25 | cls.bob = get_user_model().objects.create(id=20, username="bob", email="bob@example.org") 26 | 27 | # Create messages 28 | cls.message1 = PrivateMessage.objects.create(sender=cls.alice, content="1 to 1") 29 | cls.message2 = PrivateMessage.objects.create(sender=cls.bob, content="2 to 2") 30 | cls.message3 = PrivateMessage.objects.create(sender=cls.bob, receiver=cls.alice, content="2 to 1") 31 | 32 | def tearDown(self): 33 | try: 34 | with transaction.atomic(): 35 | PrivateMessage.objects.all().delete() 36 | get_user_model().objects.all().delete() 37 | except transaction.Error: 38 | pass 39 | 40 | #################################################### 41 | # Require authentication 42 | def test_must_authenticate_to_read_messages(self): 43 | res = self.client.get(self.namespace) 44 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) 45 | 46 | def test_must_authenticate_to_create_messages(self): 47 | res = self.client.post(self.namespace) 48 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) 49 | 50 | #################################################### 51 | # Allowed operations 52 | def test_create_message(self): 53 | self.client.force_authenticate(user=self.alice) 54 | 55 | url = self.namespace 56 | res = self.client.post(url, data={'content': 'Message'}) 57 | 58 | self.assertEqual(res.status_code, status.HTTP_201_CREATED) 59 | self.assertEqual(res.data['sender'], self.alice.id) 60 | self.assertEqual(res.data['receiver'], None) 61 | 62 | def test_list_messages(self): 63 | self.client.force_authenticate(user=self.alice) 64 | 65 | res = self.client.get(self.namespace) 66 | 67 | # All messages for or by this user 68 | messages = [mine for mine in res.data['results'] 69 | if mine['sender'] == self.alice.id 70 | or mine['receiver'] == self.alice.id] 71 | 72 | self.assertEqual(res.status_code, status.HTTP_200_OK) 73 | self.assertEqual(res.data['results'], messages) # Ensure user is in all messages 74 | 75 | def test_list_personal_notes(self): 76 | self.client.force_authenticate(user=self.alice) 77 | 78 | url = self.namespace + f'/notes' 79 | res = self.client.get(url) 80 | 81 | # All messages for or by alice 82 | messages = [mine for mine in res.data['results'] 83 | if mine['sender'] == self.alice.id and 84 | (mine['receiver'] == self.alice.id or mine['receiver'] is None)] 85 | 86 | self.assertEqual(res.status_code, status.HTTP_200_OK) 87 | self.assertEqual(res.data['results'], messages) 88 | 89 | def test_read_message_sent_by_alice(self): 90 | self.client.force_authenticate(user=self.alice) 91 | 92 | url = self.namespace + f'/{self.message1.id}' 93 | res = self.client.get(url) 94 | 95 | self.assertEqual(res.status_code, status.HTTP_200_OK) 96 | self.assertEqual(res.data['sender'], self.alice.id) 97 | self.assertEqual(res.data['receiver'], None) 98 | self.assertEqual(res.data['content'], '1 to 1') 99 | 100 | def test_read_message_sent_to_alice(self): 101 | self.client.force_authenticate(user=self.bob) 102 | 103 | url = self.namespace + f'/{self.message3.id}' 104 | res = self.client.get(url) 105 | 106 | self.assertEqual(res.status_code, status.HTTP_200_OK) 107 | self.assertEqual(res.data['sender'], self.bob.id) 108 | self.assertEqual(res.data['receiver'], self.alice.id) 109 | self.assertEqual(res.data['content'], '2 to 1') 110 | 111 | def test_update_message(self): 112 | self.client.force_authenticate(user=self.alice) 113 | 114 | url = self.namespace + f'/{self.message1.id}' 115 | res = self.client.patch(url, data={'content': 'Updated message'}) 116 | self.assertEqual(res.status_code, status.HTTP_200_OK) # Should return 202 Accepted 117 | self.assertEqual(res.data['content'], 'Updated message') 118 | 119 | def test_delete_message(self): 120 | self.client.force_authenticate(user=self.alice) 121 | 122 | url = self.namespace + f'/{self.message1.id}' 123 | res = self.client.delete(url) 124 | self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) 125 | 126 | #################################################### 127 | # Forbidden operations 128 | def test_forbid_alice_from_reading_bobs_message(self): 129 | self.client.force_authenticate(user=self.alice) 130 | 131 | url = self.namespace + f'/{self.message2.id}' 132 | res = self.client.get(url) 133 | 134 | self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) 135 | 136 | def test_forbid_alice_from_updating_bobs_message(self): 137 | self.client.force_authenticate(user=self.alice) 138 | 139 | url = self.namespace + f'/{self.message2.id}' 140 | res = self.client.patch(url, data={'content': 'Updated message'}) 141 | self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) 142 | 143 | def test_forbid_alice_from_deleting_bobs_message(self): 144 | self.client.force_authenticate(user=self.alice) 145 | 146 | url = self.namespace + f'/{self.message2.id}' 147 | res = self.client.delete(url) 148 | self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) 149 | -------------------------------------------------------------------------------- /vestlus/templates/channel_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | 3 | {% block content %} 4 |
5 | 13 | 14 | 23 | 24 |
25 | {% for object in object_list %} 26 |
27 |
28 |
29 |
30 |
31 | 37 |
38 | 39 | {{ object.name }} 40 | {% if object.is_private %} 41 | 42 | {% endif %} 43 | 44 | 45 | 46 | {% if object.owner == request.user %} 47 | You created this channel 48 | {% else %} 49 | Joined {{ object.created_at|timesince }} ago 50 | {% endif %} 51 | 52 | 53 |
54 |
55 | 56 | 71 |
72 |
73 |
74 |
75 | {% endfor %} 76 |
77 | 78 | 79 | {% if recommended_channels.count %} 80 |
Available to Join
81 |
82 | {% for object in recommended_channels|slice:4 %} 83 |
84 |
85 |
86 |
87 |
88 | 94 |
95 | 96 | {{ object.name }} 97 | {% if object.is_private %} 98 | 99 | {% endif %} 100 | 101 | 102 | 103 | {% if object.owner == request.user %} 104 | You created this channel 105 | {% else %} 106 | Joined {{ object.created_at|timesince }} ago 107 | {% endif %} 108 | 109 | 110 |
111 |
112 | 113 | 128 |
129 |
130 |
131 |
132 | {% endfor %} 133 |
134 | {% endif %} 135 |
136 | {% endblock content %} 137 | -------------------------------------------------------------------------------- /vestlus/serializers/tests/channel.py: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.test import APIClient, APITestCase 3 | from django.contrib.auth import get_user_model 4 | from django.db import transaction 5 | from ...models import Channel, GroupMessage 6 | 7 | 8 | class ChannelTestCase(APITestCase): 9 | # The client used to connect to the API 10 | client = APIClient() 11 | 12 | def setUp(self): 13 | """ 14 | Prepare database and client. 15 | """ 16 | 17 | # API endpoint 18 | self.namespace = '/v1/channels' 19 | 20 | @classmethod 21 | def setUpTestData(cls): 22 | # Create users 23 | cls.alice = get_user_model().objects.create(id=10, username="alice", email="alice@example.org") 24 | cls.bob = get_user_model().objects.create(id=20, username="bob", email="bob@example.org") 25 | 26 | # Create channels 27 | cls.alice_channel = Channel.objects.create(name='Private Group by Alice', owner=cls.alice) 28 | cls.bob_channel = Channel.objects.create(name='Private Group by Bob', owner=cls.bob) 29 | cls.public_channel = Channel.objects.create(name='Public Group by Bob', owner=cls.bob, is_private=False) 30 | 31 | # Create messages in channels 32 | GroupMessage.objects.create(sender=cls.alice, channel=cls.alice_channel, content='I am Alice!') 33 | GroupMessage.objects.create(sender=cls.bob, channel=cls.bob_channel, content='I am Bob!') 34 | GroupMessage.objects.create(sender=cls.bob, channel=cls.public_channel, content='Public Forum') 35 | 36 | def tearDown(self): 37 | try: 38 | with transaction.atomic(): 39 | Channel.objects.all().delete() 40 | GroupMessage.objects.all().delete() 41 | get_user_model().objects.all().delete() 42 | except transaction.Error: 43 | pass 44 | 45 | #################################################### 46 | # Require authentication 47 | def test_must_authenticate_to_read_channel(self): 48 | res = self.client.get(self.namespace) 49 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) 50 | 51 | def test_must_authenticate_to_create_channel(self): 52 | res = self.client.post(self.namespace) 53 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN) 54 | 55 | #################################################### 56 | # Allowed operations: Channel 57 | def test_create_private_channel(self): 58 | self.client.force_authenticate(user=self.alice) 59 | 60 | res = self.client.post(self.namespace, data={'name': 'My Channel'}) 61 | self.assertEqual(res.status_code, status.HTTP_201_CREATED) 62 | self.assertEqual(res.data['name'], 'My Channel') 63 | self.assertEqual(res.data['owner'], self.alice.id) 64 | self.assertEqual(res.data['is_private'], True) 65 | 66 | def test_create_public_channel(self): 67 | self.client.force_authenticate(user=self.alice) 68 | 69 | res = self.client.post(self.namespace, data={'name': 'My Channel', 'is_private': False}) 70 | self.assertEqual(res.status_code, status.HTTP_201_CREATED) 71 | self.assertEqual(res.data['name'], 'My Channel') 72 | self.assertEqual(res.data['owner'], self.alice.id) 73 | self.assertEqual(res.data['is_private'], False) 74 | 75 | def test_retrieve_channel(self): 76 | self.client.force_authenticate(user=self.alice) 77 | 78 | url = self.namespace + f'/{self.alice_channel.id}' 79 | res = self.client.get(url) 80 | 81 | self.assertEqual(res.status_code, status.HTTP_200_OK) 82 | self.assertEqual(res.data['owner'], self.alice.id) 83 | self.assertEqual(res.data['id'], self.alice_channel.id) 84 | 85 | def test_list_channel(self): 86 | self.client.force_authenticate(user=self.alice) 87 | res = self.client.get(self.namespace) 88 | self.assertEqual(res.status_code, status.HTTP_200_OK) 89 | 90 | def test_update_channel(self): 91 | self.client.force_authenticate(user=self.alice) 92 | 93 | url = self.namespace + f'/{self.alice_channel.id}' 94 | res = self.client.patch(url, data={'name': 'Personal Channel'}) 95 | self.assertEqual(res.status_code, status.HTTP_200_OK) 96 | self.assertEqual(res.data['id'], self.alice_channel.id) 97 | self.assertEqual(res.data['name'], 'Personal Channel') 98 | 99 | def test_delete_channel(self): 100 | self.client.force_authenticate(user=self.alice) 101 | 102 | url = self.namespace + f'/{self.alice_channel.id}' 103 | res = self.client.delete(url) 104 | self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) 105 | 106 | #################################################### 107 | # Allowed operations: Channel Messages 108 | def test_read_messages_on_my_channel(self): 109 | self.client.force_authenticate(user=self.alice) 110 | 111 | url = self.namespace + f'/{self.alice_channel.id}/messages' 112 | res = self.client.get(url) 113 | 114 | self.assertEqual(res.status_code, status.HTTP_200_OK) 115 | 116 | def test_read_messages_on_public_channel(self): 117 | self.client.force_authenticate(user=self.alice) 118 | 119 | url = self.namespace + f'/{self.public_channel.id}/messages' 120 | res = self.client.get(url) 121 | 122 | self.assertEqual(res.status_code, status.HTTP_200_OK) 123 | self.assertEqual(res.data['results'][0]['channel'], self.public_channel.id) 124 | self.assertEqual(res.data['results'][0]['sender'], self.bob.id) 125 | 126 | #################################################### 127 | # Handle memberships 128 | def test_bob_can_add_alice(self): 129 | self.client.force_authenticate(user=self.bob) 130 | 131 | url = self.namespace + f'/{self.bob_channel.id}/members' 132 | res = self.client.post(url, data={'user': self.alice.id}) 133 | 134 | self.assertEqual(res.data['user'], self.alice.id) 135 | self.assertEqual(res.data['invited_by'], self.bob.id) 136 | 137 | def test_bob_can_add_alice_as_admin(self): 138 | self.client.force_authenticate(user=self.bob) 139 | 140 | url = self.namespace + f'/{self.bob_channel.id}/members' 141 | res = self.client.post(url, data={'user': self.alice.id, 'is_admin': True}) 142 | 143 | self.assertEqual(res.data['user'], self.alice.id) 144 | self.assertEqual(res.data['invited_by'], self.bob.id) 145 | self.assertEqual(res.data['is_admin'], True) 146 | 147 | def test_alice_can_post_to_a_public_channel(self): 148 | self.client.force_authenticate(user=self.alice) 149 | 150 | url = self.namespace + f'/{self.public_channel.id}/messages' 151 | res = self.client.post(url, data={'content': 'I can post on this public channel!'}) 152 | self.assertEqual(res.data['channel'], self.public_channel.id) 153 | 154 | #################################################### 155 | # Forbidden operations 156 | def test_prevent_bob_from_retrieving_alices_channel(self): 157 | self.client.force_authenticate(user=self.bob) 158 | 159 | url = self.namespace + f'/{self.alice_channel.id}' 160 | res = self.client.get(url) 161 | 162 | self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) 163 | 164 | def test_prevent_bob_from_updating_alices_channel(self): 165 | self.client.force_authenticate(user=self.bob) 166 | 167 | url = self.namespace + f'/{self.alice_channel.id}' 168 | res = self.client.patch(url, data={'name': 'Updated Channel'}) 169 | 170 | self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) 171 | -------------------------------------------------------------------------------- /vestlus/templates/channel_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'vestlus.html' %} 2 | {% load crispy_forms_tags %} 3 | {% load crispy_forms_field %} 4 | 5 | {% block content %} 6 | 15 | 16 |
17 | 28 | 29 |
30 |

31 | {{ channel.name }} 32 | 33 | {% if channel.is_private %} 34 | 35 | {% endif %} 36 | 37 |

38 | 39 | 40 |
41 | New message 42 | {% if object.owner == request.user %} 43 | 44 | 47 | 48 | {% endif %} 49 |
50 |
51 | 52 | {% if user_in_admins %} 53 |
54 | 55 | {% if channel.owner == request.user %} 56 | You own this channel. 57 | {% else %} 58 | You are an admin of this channel. 59 | {% endif %} 60 | 61 |
62 | {% endif %} 63 | 64 | 65 |
66 | {% if user_in_admins %} 67 |

68 | 69 | Invite member 70 | 71 |

72 | {% endif %} 73 | 74 |
75 | {% for membership in channel.members.all %} 76 |
77 |
78 |
79 | 91 |
92 |

93 | 94 | Joined {{ membership.created_at|timesince }} ago 95 | 96 |

97 |
98 |
99 |
100 |
101 | {% endfor %} 102 |
103 | 104 |
105 |
106 | 107 | 108 |
109 | {% if not channel.conversations.count %} 110 |

No messages yet

111 | {% else %} 112 | {% for message in channel.conversations.all %} 113 |
114 |
115 | 40x40 120 |
121 |
122 |
123 |
124 | 125 | {{ message.sender.first_name }} {{ message.sender.last_name }} 126 | 127 | {{ message.created_at|timesince }} ago 128 |
129 | {% if message.sender == request.user %} 130 | 131 | 132 | 133 | 134 | 135 | {% endif %} 136 |
137 |
138 |
139 | {{ message.content }} 140 |
141 |
142 |
143 |
144 | {% endfor %} 145 | {% endif %} 146 |
147 | 148 | 149 | 176 |
177 | {% endblock content %} 178 | 179 | 180 | {% block script %} 181 | 183 | {% endblock %} 184 | -------------------------------------------------------------------------------- /vestlus/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-05-27 23:34 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('contenttypes', '0002_remove_content_type_name'), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Channel', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('name', models.CharField(max_length=255, verbose_name='name')), 24 | ('access_code', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='access code')), 25 | ('is_private', models.BooleanField(default=True, verbose_name='is private')), 26 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), 27 | ('slug', models.SlugField(blank=True, editable=False, max_length=250, unique=True, verbose_name='slug')), 28 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 29 | ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), 30 | ('owner', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='channels', to=settings.AUTH_USER_MODEL, verbose_name='owner')), 31 | ], 32 | options={ 33 | 'db_table': 'vestlus_channels', 34 | 'ordering': ['-created_at', 'name'], 35 | }, 36 | ), 37 | migrations.CreateModel( 38 | name='Message', 39 | fields=[ 40 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 41 | ('content', models.TextField(verbose_name='content')), 42 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), 43 | ('slug', models.SlugField(blank=True, editable=False, max_length=250, unique=True, verbose_name='slug')), 44 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 45 | ('updated_at', models.DateTimeField(auto_now=True, verbose_name='uploaded at')), 46 | ('parent', models.ForeignKey(blank=True, help_text='The main message in a message thread', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='vestlus.Message', verbose_name='parent')), 47 | ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_vestlus.message_set+', to='contenttypes.ContentType')), 48 | ('sender', models.ForeignKey(help_text='The user who authored the message', on_delete=django.db.models.deletion.PROTECT, related_name='sent_messages', to=settings.AUTH_USER_MODEL, verbose_name='sender')), 49 | ], 50 | options={ 51 | 'db_table': 'vestlus_messages', 52 | 'ordering': ['-created_at', 'sender'], 53 | }, 54 | ), 55 | migrations.CreateModel( 56 | name='GroupMessage', 57 | fields=[ 58 | ('message_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vestlus.Message')), 59 | ], 60 | options={ 61 | 'db_table': 'vestlus_group_messages', 62 | 'ordering': ['-created_at', 'channel'], 63 | }, 64 | bases=('vestlus.message',), 65 | ), 66 | migrations.CreateModel( 67 | name='Reaction', 68 | fields=[ 69 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 70 | ('reaction', models.CharField(help_text='Emoji reaction code as unicode', max_length=32, verbose_name='reaction')), 71 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), 72 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 73 | ('updated_at', models.DateTimeField(auto_now=True, verbose_name='uploaded at')), 74 | ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='vestlus.Message', verbose_name='message')), 75 | ('user', models.ForeignKey(help_text='The user who reacted to the message', on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to=settings.AUTH_USER_MODEL, verbose_name='user')), 76 | ], 77 | options={ 78 | 'db_table': 'vestlus_message_reactions', 79 | 'ordering': ['-created_at', 'message', 'user'], 80 | }, 81 | ), 82 | migrations.CreateModel( 83 | name='Membership', 84 | fields=[ 85 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 86 | ('is_admin', models.BooleanField(default=False, help_text='Defines if user has admin privileges', verbose_name='is admin')), 87 | ('uuid', models.UUIDField(default=uuid.uuid4, editable=False)), 88 | ('slug', models.SlugField(blank=True, editable=False, max_length=250, unique=True, verbose_name='slug')), 89 | ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), 90 | ('updated_at', models.DateTimeField(auto_now=True, verbose_name='uploaded at')), 91 | ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='vestlus.Channel', verbose_name='channel')), 92 | ('invited_by', models.ForeignKey(blank=True, help_text='The user who invited this one', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='invitees', to=settings.AUTH_USER_MODEL, verbose_name='invited by')), 93 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL, verbose_name='user')), 94 | ], 95 | options={ 96 | 'db_table': 'vestlus_channel_members', 97 | 'ordering': ['-created_at', 'user'], 98 | }, 99 | ), 100 | migrations.CreateModel( 101 | name='PrivateMessage', 102 | fields=[ 103 | ('message_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='vestlus.Message')), 104 | ('read', models.BooleanField(default=False, help_text='Indicates whether or not the recipient has visualized the message', verbose_name='read')), 105 | ('receiver', models.ForeignKey(blank=True, help_text='The user this message will be sent to', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_messages', to=settings.AUTH_USER_MODEL, verbose_name='receiver')), 106 | ], 107 | options={ 108 | 'db_table': 'vestlus_private_messages', 109 | }, 110 | bases=('vestlus.message',), 111 | ), 112 | migrations.AddIndex( 113 | model_name='message', 114 | index=models.Index(fields=['created_at', 'sender', 'parent'], name='vestlus_mes_created_891831_idx'), 115 | ), 116 | migrations.AddIndex( 117 | model_name='membership', 118 | index=models.Index(fields=['channel', 'user', 'invited_by', 'slug', 'created_at'], name='vestlus_cha_channel_b4cdf7_idx'), 119 | ), 120 | migrations.AlterUniqueTogether( 121 | name='membership', 122 | unique_together={('channel', 'user')}, 123 | ), 124 | migrations.AddField( 125 | model_name='groupmessage', 126 | name='channel', 127 | field=models.ForeignKey(help_text='The channel associated with this message', on_delete=django.db.models.deletion.CASCADE, related_name='conversations', to='vestlus.Channel', verbose_name='channel'), 128 | ), 129 | migrations.AddIndex( 130 | model_name='channel', 131 | index=models.Index(fields=['name', 'owner', 'slug', 'created_at'], name='vestlus_cha_name_8c4e10_idx'), 132 | ), 133 | migrations.AddIndex( 134 | model_name='privatemessage', 135 | index=models.Index(fields=['receiver', 'read'], name='vestlus_pri_receive_6188e6_idx'), 136 | ), 137 | migrations.AddIndex( 138 | model_name='groupmessage', 139 | index=models.Index(fields=['channel'], name='vestlus_gro_channel_64ddc1_idx'), 140 | ), 141 | ] 142 | -------------------------------------------------------------------------------- /vestlus/templates/.idea/dbnavigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | --------------------------------------------------------------------------------