From c78f86d48f67a7c94502bcc06609e9962a72063e Mon Sep 17 00:00:00 2001 From: "J.-S. Caux" <J.S.Caux@uva.nl> Date: Sat, 18 Jan 2020 12:11:52 +0100 Subject: [PATCH] Add tag functionalities --- apimail/admin.py | 12 +- apimail/api/serializers.py | 16 ++- apimail/api/views.py | 35 +++++- apimail/managers.py | 1 - apimail/migrations/0008_usertag.py | 26 ++++ apimail/migrations/0009_storedmessage_tags.py | 18 +++ apimail/migrations/0010_auto_20200118_0806.py | 22 ++++ apimail/migrations/0011_auto_20200118_0843.py | 18 +++ apimail/models/__init__.py | 2 + apimail/models/stored_message.py | 4 + apimail/models/tag.py | 37 ++++++ apimail/permissions.py | 28 +++++ .../assets/vue/components/MessageContent.vue | 9 +- .../assets/vue/components/MessagesTable.vue | 113 ++++++++++++++---- apimail/urls.py | 10 ++ 15 files changed, 320 insertions(+), 31 deletions(-) create mode 100644 apimail/migrations/0008_usertag.py create mode 100644 apimail/migrations/0009_storedmessage_tags.py create mode 100644 apimail/migrations/0010_auto_20200118_0806.py create mode 100644 apimail/migrations/0011_auto_20200118_0843.py create mode 100644 apimail/models/tag.py create mode 100644 apimail/permissions.py diff --git a/apimail/admin.py b/apimail/admin.py index ca2bc4cb1..920e6fd68 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 158009842..1f070fe0e 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 761ec872f..259bbc78f 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 22151d4e1..de9054fe5 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 000000000..f5c57f7a1 --- /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 000000000..03872f07f --- /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 000000000..93c0d1f5b --- /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 000000000..d2635df9a --- /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 2f0645e9d..21b2984b3 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 ffcf29055..96da8db12 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 000000000..12c05c427 --- /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 000000000..e75d205f6 --- /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 b42c2db01..adeb5f529 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 2745c838c..763faa57e 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 fa427596a..fdbfc9d55 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' + ), ])), -- GitLab