diff --git a/apimail/admin.py b/apimail/admin.py index ca2bc4cb1b27c070d19eaac59a89ed6d473ef4eb..920e6fd680babfda9a3c74362544e9882a1d7d3f 100644 --- a/apimail/admin.py +++ b/apimail/admin.py @@ -4,7 +4,11 @@ __license__ = "AGPL v3" from django.contrib import admin -from .models import EmailAccount, EmailAccountAccess, Event, StoredMessage, StoredMessageAttachment +from .models import ( + EmailAccount, EmailAccountAccess, + Event, + StoredMessage, StoredMessageAttachment, + UserTag) class EmailAccountAccessInline(admin.StackedInline): @@ -35,3 +39,9 @@ class StoredMessageAdmin(admin.ModelAdmin): inlines = [StoredMessageAttachmentInline,] admin.site.register(StoredMessage, StoredMessageAdmin) + + +class UserTagAdmin(admin.ModelAdmin): + pass + +admin.site.register(UserTag, UserTagAdmin) diff --git a/apimail/api/serializers.py b/apimail/api/serializers.py index 1580098422b31f827c64713ae1412401426492e1..1f070fe0ee18d7fc958be10358d085aa2649f2ce 100644 --- a/apimail/api/serializers.py +++ b/apimail/api/serializers.py @@ -8,7 +8,8 @@ from rest_framework import serializers from ..models import ( EmailAccount, EmailAccountAccess, Event, - StoredMessage, StoredMessageAttachment) + StoredMessage, StoredMessageAttachment, + UserTag) class EmailAccountSerializer(serializers.ModelSerializer): @@ -41,14 +42,25 @@ class StoredMessageAttachmentLinkSerializer(serializers.ModelSerializer): fields = ['data', '_file', 'link'] +class UserTagSerializer(serializers.ModelSerializer): + class Meta: + model = UserTag + fields = ['pk', 'label', 'unicode_symbol', 'variant'] + + def get_queryset(self): + user = self.request.user + return UserTag.objects.filter(user=user) + + class StoredMessageSerializer(serializers.ModelSerializer): attachments = StoredMessageAttachmentLinkSerializer(many=True) event_set = EventSerializer(many=True) read = serializers.SerializerMethodField() + tags = UserTagSerializer(many=True) def get_read(self, obj): return self.context['request'].user in obj.read_by.all() class Meta: model = StoredMessage - fields = ['uuid', 'data', 'datetimestamp', 'attachments', 'event_set', 'read'] + fields = ['uuid', 'data', 'datetimestamp', 'attachments', 'event_set', 'read', 'tags'] diff --git a/apimail/api/views.py b/apimail/api/views.py index 761ec872f1716e894a12d7f88b6535b0a6d1dfd0..259bbc78f98532867be37a89eb1e6e6be50d5be2 100644 --- a/apimail/api/views.py +++ b/apimail/api/views.py @@ -5,18 +5,21 @@ __license__ = "AGPL v3" import datetime from django.db.models import Q +from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework.generics import ListAPIView, RetrieveAPIView, UpdateAPIView -from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework import filters -from ..models import EmailAccount, EmailAccountAccess, Event, StoredMessage +from ..models import EmailAccount, EmailAccountAccess, Event, StoredMessage, UserTag +from ..permissions import CanHandleMessage from .serializers import ( EmailAccountSerializer, EmailAccountAccessSerializer, EventSerializer, - StoredMessageSerializer) + StoredMessageSerializer, + UserTagSerializer) class EmailAccountListAPIView(ListAPIView): @@ -120,3 +123,29 @@ class StoredMessageUpdateReadAPIView(UpdateAPIView): instance.read_by.add(request.user) instance.save() return Response() + + +class UserTagListAPIView(ListAPIView): + serializer_class = UserTagSerializer + + def get_queryset(self): + return self.request.user.email_tags.all() + + +class StoredMessageUpdateTagAPIView(UpdateAPIView): + """Adds or removes a user tag on a StoredMessage.""" + queryset = StoredMessage.objects.all() + permission_classes = [IsAuthenticated, CanHandleMessage] + serializer_class = StoredMessageSerializer + lookup_field = 'uuid' + + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + tag = get_object_or_404(UserTag, pk=self.request.data.get('tagpk')) + action = self.request.data.get('action') + if action == 'add': + instance.tags.add(tag) + elif action == 'remove': + instance.tags.remove(tag) + instance.save() + return Response() diff --git a/apimail/managers.py b/apimail/managers.py index 22151d4e13133bdd94840e29cf52b085add65158..de9054fe5bef37787df9a5dcdcd23e8e588890d0 100644 --- a/apimail/managers.py +++ b/apimail/managers.py @@ -22,7 +22,6 @@ class StoredMessageQuerySet(models.QuerySet): if user.email_account_accesses.filter(account__email=email).exists(): queryfilter = models.Q() for access in user.email_account_accesses.filter(account__email=email): - print("access found: %s" % access.account.email) queryfilter = queryfilter | ( (models.Q(data__sender__icontains=access.account.email) | models.Q(data__recipients__icontains=access.account.email)) diff --git a/apimail/migrations/0008_usertag.py b/apimail/migrations/0008_usertag.py new file mode 100644 index 0000000000000000000000000000000000000000..f5c57f7a1595c6a48a8942ca4de89f581cbbcd41 --- /dev/null +++ b/apimail/migrations/0008_usertag.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.8 on 2020-01-16 20:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('apimail', '0007_auto_20200116_1955'), + ] + + operations = [ + migrations.CreateModel( + name='UserTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=64)), + ('unicode_symbol', models.CharField(blank=True, max_length=1)), + ('hex_color_code', models.CharField(max_length=6)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_tags', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apimail/migrations/0009_storedmessage_tags.py b/apimail/migrations/0009_storedmessage_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..03872f07ff1a693e379c94a6a34373d1e59952e3 --- /dev/null +++ b/apimail/migrations/0009_storedmessage_tags.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.8 on 2020-01-17 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0008_usertag'), + ] + + operations = [ + migrations.AddField( + model_name='storedmessage', + name='tags', + field=models.ManyToManyField(blank=True, related_name='messages', to='apimail.UserTag'), + ), + ] diff --git a/apimail/migrations/0010_auto_20200118_0806.py b/apimail/migrations/0010_auto_20200118_0806.py new file mode 100644 index 0000000000000000000000000000000000000000..93c0d1f5bf1eec43e5149c052b1fddc9605c913e --- /dev/null +++ b/apimail/migrations/0010_auto_20200118_0806.py @@ -0,0 +1,22 @@ +# Generated by Django 2.1.8 on 2020-01-18 07:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0009_storedmessage_tags'), + ] + + operations = [ + migrations.RemoveField( + model_name='usertag', + name='hex_color_code', + ), + migrations.AddField( + model_name='usertag', + name='variant', + field=models.CharField(default='info', max_length=16), + ), + ] diff --git a/apimail/migrations/0011_auto_20200118_0843.py b/apimail/migrations/0011_auto_20200118_0843.py new file mode 100644 index 0000000000000000000000000000000000000000..d2635df9a2bdc059cb6875afeb61781905f12d0e --- /dev/null +++ b/apimail/migrations/0011_auto_20200118_0843.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.8 on 2020-01-18 07:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0010_auto_20200118_0806'), + ] + + operations = [ + migrations.AlterField( + model_name='usertag', + name='variant', + field=models.CharField(choices=[('primary', 'primary'), ('secondary', 'secondary'), ('success', 'success'), ('warning', 'warning'), ('danger', 'danger'), ('info', 'info'), ('light', 'light'), ('dark', 'dark')], default='info', max_length=16), + ), + ] diff --git a/apimail/models/__init__.py b/apimail/models/__init__.py index 2f0645e9dabb98ba881e44b63346708e542fb9a6..21b2984b3e39b6942a6b1a1b4f920ab0a6dff244 100644 --- a/apimail/models/__init__.py +++ b/apimail/models/__init__.py @@ -7,3 +7,5 @@ from .account import EmailAccount, EmailAccountAccess from .event import Event from .stored_message import StoredMessage, StoredMessageAttachment + +from .tag import UserTag diff --git a/apimail/models/stored_message.py b/apimail/models/stored_message.py index ffcf290553c50c3f5a16582cee091cbb4b4668e6..96da8db12eadc38312862929f57bd5106fc74e20 100644 --- a/apimail/models/stored_message.py +++ b/apimail/models/stored_message.py @@ -30,6 +30,10 @@ class StoredMessage(models.Model): settings.AUTH_USER_MODEL, blank=True, related_name='+') + tags = models.ManyToManyField( + 'apimail.UserTag', + blank=True, + related_name='messages') objects = StoredMessageQuerySet.as_manager() diff --git a/apimail/models/tag.py b/apimail/models/tag.py new file mode 100644 index 0000000000000000000000000000000000000000..12c05c4270a2a67ff07f1f82f8e5a1d2a083da6e --- /dev/null +++ b/apimail/models/tag.py @@ -0,0 +1,37 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf import settings +from django.db import models + + +class UserTag(models.Model): + VARIANT_PRIMARY = 'primary' + VARIANT_SECONDARY = 'secondary' + VARIANT_SUCCESS = 'success' + VARIANT_WARNING = 'warning' + VARIANT_DANGER = 'danger' + VARIANT_INFO = 'info' + VARIANT_LIGHT = 'light' + VARIANT_DARK = 'dark' + VARIANT_CHOICES = ( + (VARIANT_PRIMARY, 'primary'), + (VARIANT_SECONDARY, 'secondary'), + (VARIANT_SUCCESS, 'success'), + (VARIANT_WARNING, 'warning'), + (VARIANT_DANGER, 'danger'), + (VARIANT_INFO, 'info'), + (VARIANT_LIGHT, 'light'), + (VARIANT_DARK, 'dark'), + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='email_tags', + on_delete=models.CASCADE) + label = models.CharField(max_length=64) + unicode_symbol = models.CharField(max_length=1, blank=True) + variant = models.CharField( + max_length=16, + choices=VARIANT_CHOICES, + default=VARIANT_INFO) diff --git a/apimail/permissions.py b/apimail/permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..e75d205f6cad96f9caafa5effecc374449a3123c --- /dev/null +++ b/apimail/permissions.py @@ -0,0 +1,28 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from rest_framework import permissions + +from .models import EmailAccountAccess + + +class CanHandleMessage(permissions.BasePermission): + """ + Object-level permission on StoredMessage, specifying whether the user + can take editing actions. + """ + + def has_object_permission(self, request, view, obj): + if request.user.is_superuser or request.user.is_admin: + return True + + # Check, based on account accesses + for access in request.user.email_account_accesses.filter( + rights=EmailAccountAccess.CRUD): + if ((access.account.email == obj.data.sender or + access.account.email in obj.data.recipients) + and access.date_from < obj.datetimestamp + and access.data_until > obj.datetimestamp): + return True + return False diff --git a/apimail/static/apimail/assets/vue/components/MessageContent.vue b/apimail/static/apimail/assets/vue/components/MessageContent.vue index b42c2db01507753352493ac66234fa746417bd53..adeb5f5296ab46e7bf4d0260f6a5e8ffe47b11c6 100644 --- a/apimail/static/apimail/assets/vue/components/MessageContent.vue +++ b/apimail/static/apimail/assets/vue/components/MessageContent.vue @@ -80,10 +80,11 @@ export default { if (!this.message.read) { console.log('uuid: ' + this.message.uuid) fetch('/mail/api/stored_message/' + this.message.uuid + '/mark_as_read', - { method: 'PATCH', - headers: { - "X-CSRFToken": csrftoken, - } + { + method: 'PATCH', + headers: { + "X-CSRFToken": csrftoken, + } } ).then(function(response) { if (!response.ok) { diff --git a/apimail/static/apimail/assets/vue/components/MessagesTable.vue b/apimail/static/apimail/assets/vue/components/MessagesTable.vue index 2745c838caa7460f3066120f4a4ef836e9767ab3..763faa57ecb126d4fa782f33357d6d36e0f8cc2d 100644 --- a/apimail/static/apimail/assets/vue/components/MessagesTable.vue +++ b/apimail/static/apimail/assets/vue/components/MessagesTable.vue @@ -2,17 +2,6 @@ <div> <h2>Click on an account to view messages</h2> - <!-- <b-list-group> --> - <!-- <b-list-group-item --> - <!-- v-for="access in accesses" --> - <!-- v-bind:class="{'active': isSelected(access.account.email)}" --> - <!-- v-on:click="accountSelected = access.account.email" --> - <!-- v-on:change="" --> - <!-- class="p-2 m-0" --> - <!-- > --> - <!-- {{ access.account.email }} --> - <!-- </b-list-group-item> --> - <!-- </b-list-group> --> <table class="table"> <tr> @@ -125,14 +114,57 @@ :per-page="perPage" :current-page="currentPage" > + <template v-slot:cell(read)="row"> + <b-badge variant="primary">{{ row.item.read ? "" : " " }}</b-badge> + </template> + <template v-slot:cell(tags)="row"> + <ul class="list-inline"> + <li class="list-inline-item m-0" v-for="tag in row.item.tags"> + <b-button + size="sm" + class="p-1" + @click="tagMessage(row.item, tag, 'remove')" + :variant="tag.variant" + > + {{ tag.unicode_symbol }} + </b-button> + </li> + </ul> + </template> + <template v-slot:cell(addtag)="row"> + <b-button + size="sm" + v-b-toggle="'collapse-tags' + row.item.uuid" + variant="primary" + > + Add tag + </b-button> + <b-collapse :id="'collapse-tags' + row.item.uuid"> + <b-card> + <ul class="list-unstyled"> + <li v-for="tag in tags"> + <b-button + size="sm" + class="p-1" + @click="tagMessage(row.item, tag, 'add')" + :variant="tag.variant" + > + {{ tag.unicode_symbol }} {{ tag.label }} + </b-button> + </li> + </ul> + </b-card> + </b-collapse> + </template> <template v-slot:cell(actions)="row"> - <b-button size="sm" @click="row.toggleDetails"> + <b-button + size="sm" + variant="primary" + @click="row.toggleDetails" + > {{ row.detailsShowing ? 'Hide' : 'Show' }} </b-button> </template> - <template v-slot:cell(read)="row"> - <b-badge variant="primary">{{ row.item.read ? "" : " " }}</b-badge> - </template> <template v-slot:row-details="row"> <message-content :message=row.item class="m-2 mb-4"></message-content> </template> @@ -144,8 +176,12 @@ <script> +import Cookies from 'js-cookie' + import MessageContent from './MessageContent.vue' +var csrftoken = Cookies.get('csrftoken'); + export default { name: "messages-table", components: { @@ -164,16 +200,18 @@ export default { { key: 'data.subject', label: 'Subject' }, { key: 'data.from', label: 'From' }, { key: 'data.recipients', label: 'Recipients' }, - { key: 'actions', label: 'Actions' } + { key: 'tags', label: 'Tags' }, + { key: 'addtag', label: '' }, + { key: 'actions', label: '' } ], filter: null, filterOn: [], timePeriod: 'any', timePeriodOptions: [ - { 'text': 'Last week', value: 'week'}, - { 'text': 'Last month', value: 'month'}, - { 'text': 'Last year', value: 'year'}, - { 'text': 'Any time', value: 'any'}, + { text: 'Last week', value: 'week'}, + { text: 'Last month', value: 'month'}, + { text: 'Last year', value: 'year'}, + { text: 'Any time', value: 'any'}, ] } }, @@ -184,6 +222,40 @@ export default { .then(data => this.accesses = data.results) .catch(error => console.error(error)) }, + fetchTags () { + fetch('/mail/api/user_tags') + .then(stream => stream.json()) + .then(data => this.tags = data.results) + .catch(error => console.error(error)) + }, + tagMessage (message, tag, action) { + fetch('/mail/api/stored_message/' + message.uuid + '/tag', + { + method: 'PATCH', + headers: { + "X-CSRFToken": csrftoken, + "Content-Type": "application/json; charset=utf-8" + }, + body: JSON.stringify({ + 'tagpk': tag.pk, + 'action': action + }) + } + ).then(function(response) { + if (!response.ok) { + throw new Error('HTTP error, status = ' + response.status); + } + }); + + if (action == 'add') { + // Prevent doubling by removing first, then (re)adding + message.tags = message.tags.filter(function (item) { return item.pk !== tag.pk }) + message.tags.push(tag) + } + else if (action == 'remove') { + message.tags.splice(message.tags.indexOf(tag), 1) + } + }, isSelected: function (selection) { return selection === this.accountSelected }, @@ -220,6 +292,7 @@ export default { }, mounted() { this.fetchAccounts() + this.fetchTags() }, } diff --git a/apimail/urls.py b/apimail/urls.py index fa427596a46bf05533a8ef71106ed0d7be826027..fdbfc9d553e3d4a65e12d1dcae7d0d92a1b37277 100644 --- a/apimail/urls.py +++ b/apimail/urls.py @@ -51,6 +51,16 @@ urlpatterns = [ apiviews.StoredMessageUpdateReadAPIView.as_view(), name='api_stored_message_mark_as_read' ), + path( # /mail/api/stored_message/<uuid>/tag + 'stored_message/<uuid:uuid>/tag', + apiviews.StoredMessageUpdateTagAPIView.as_view(), + name='api_stored_message_tag' + ), + path( # /mail/api/user_tags + 'user_tags', + apiviews.UserTagListAPIView.as_view(), + name='api_user_tags' + ), ])),