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 ? "" : "&emsp;" }}</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&nbsp;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 }}&nbsp;{{ 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 ? "" : "&emsp;" }}</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