diff --git a/apimail/admin.py b/apimail/admin.py index a9382eb59b750d961ad0b5a0af92b153822a01f5..c9db40e95a0b6cd8c460d50c71f5adce6213e51a 100644 --- a/apimail/admin.py +++ b/apimail/admin.py @@ -6,9 +6,10 @@ from django.contrib import admin from .models import ( EmailAccount, EmailAccountAccess, - ComposedMessage, ComposedMessageAPIResponse, ComposedMessageAttachment, + AttachmentFile, + ComposedMessage, ComposedMessageAPIResponse, Event, - StoredMessage, StoredMessageAttachment, + StoredMessage, UserTag) @@ -24,22 +25,23 @@ class EmailAccountAdmin(admin.ModelAdmin): admin.site.register(EmailAccount, EmailAccountAdmin) -class ComposedMessageAPIResponseInline(admin.StackedInline): - model = ComposedMessageAPIResponse +admin.site.register(AttachmentFile) + + +class AttachmentFileInline(admin.StackedInline): + model = AttachmentFile extra = 0 min_num = 0 -class ComposedMessageAttachmentInline(admin.StackedInline): - model = ComposedMessageAttachment +class ComposedMessageAPIResponseInline(admin.StackedInline): + model = ComposedMessageAPIResponse extra = 0 min_num = 0 class ComposedMessageAdmin(admin.ModelAdmin): - inlines = [ - ComposedMessageAttachmentInline, - ComposedMessageAPIResponseInline,] + inlines = [ComposedMessageAPIResponseInline,] admin.site.register(ComposedMessage, ComposedMessageAdmin) @@ -50,14 +52,8 @@ class EventAdmin(admin.ModelAdmin): admin.site.register(Event, EventAdmin) -class StoredMessageAttachmentInline(admin.StackedInline): - model = StoredMessageAttachment - extra = 0 - min_num = 0 - - class StoredMessageAdmin(admin.ModelAdmin): - inlines = [StoredMessageAttachmentInline,] + pass admin.site.register(StoredMessage, StoredMessageAdmin) diff --git a/apimail/api/serializers.py b/apimail/api/serializers.py index dda328b202072f4e25eb385c456c5d9f46a0befb..8984fdcbee806ed220ae9fe380c9937261e773c6 100644 --- a/apimail/api/serializers.py +++ b/apimail/api/serializers.py @@ -6,10 +6,11 @@ from django.urls import reverse from rest_framework import serializers from ..models import ( + AttachmentFile, EmailAccount, EmailAccountAccess, - ComposedMessage, ComposedMessageAttachment, + ComposedMessage, Event, - StoredMessage, StoredMessageAttachment, + StoredMessage, UserTag) @@ -29,41 +30,32 @@ class EmailAccountAccessSerializer(serializers.ModelSerializer): fields = ['account', 'rights', 'date_from', 'date_until'] -class ComposedMessageAttachmentSerializer(serializers.ModelSerializer): - - class Meta: - model = ComposedMessageAttachment - fields = ['message', '_file'] - read_only_fields = ['message'] - - -class ComposedMessageAttachmentLinkSerializer(serializers.ModelSerializer): +class AttachmentFileSerializer(serializers.ModelSerializer): link = serializers.CharField(source='get_absolute_url', read_only=True) class Meta: - model = ComposedMessageAttachment - fields = ['message', '_file', 'link'] - read_only_fields = ['message', '_file', 'link'] + model = AttachmentFile + fields = ['uuid', 'data', 'file', 'link'] class ComposedMessageSerializer(serializers.ModelSerializer): - attachments = ComposedMessageAttachmentLinkSerializer(many=True, read_only=True) + attachment_files = AttachmentFileSerializer(many=True, read_only=True) class Meta: model = ComposedMessage fields = ['uuid', 'author', 'created_on', 'status', 'from_account', 'to_recipient', 'cc_recipients', 'bcc_recipients', 'subject', 'body_text', 'body_html', - 'attachments' + 'attachment_files' ] def create(self, validated_data): - print("Here1") + # TODO cm = super().create(validated_data) - print("Here2") return cm def update(self, instance, validated_data): + # TODO cm = super().update(instance, validated_data) return cm @@ -74,14 +66,6 @@ class EventSerializer(serializers.ModelSerializer): fields = ['uuid', 'data',] -class StoredMessageAttachmentLinkSerializer(serializers.ModelSerializer): - link = serializers.CharField(source='get_absolute_url', read_only=True) - - class Meta: - model = StoredMessageAttachment - fields = ['data', '_file', 'link'] - - class UserTagSerializer(serializers.ModelSerializer): class Meta: model = UserTag @@ -93,7 +77,7 @@ class UserTagSerializer(serializers.ModelSerializer): class StoredMessageSerializer(serializers.ModelSerializer): - attachments = StoredMessageAttachmentLinkSerializer(many=True) + attachment_files = AttachmentFileSerializer(many=True) event_set = EventSerializer(many=True) read = serializers.SerializerMethodField() tags = UserTagSerializer(many=True) @@ -103,4 +87,4 @@ class StoredMessageSerializer(serializers.ModelSerializer): class Meta: model = StoredMessage - fields = ['uuid', 'data', 'datetimestamp', 'attachments', 'event_set', 'read', 'tags'] + fields = ['uuid', 'data', 'datetimestamp', 'attachment_files', 'event_set', 'read', 'tags'] diff --git a/apimail/api/views.py b/apimail/api/views.py index 3f9348ce42310b3e79578d3b6d8425ef2b04340f..64e894c6ae2da78c4fbe8c18bbb8684763c1b6c3 100644 --- a/apimail/api/views.py +++ b/apimail/api/views.py @@ -17,7 +17,8 @@ from rest_framework.response import Response from rest_framework import filters, status from ..models import ( - ComposedMessage, ComposedMessageAttachment, + AttachmentFile, + ComposedMessage, Event, StoredMessage, UserTag) @@ -27,7 +28,8 @@ from ..permissions import ( from .serializers import ( EmailAccountSerializer, EmailAccountAccessSerializer, - ComposedMessageSerializer, ComposedMessageAttachmentSerializer, + AttachmentFileSerializer, + ComposedMessageSerializer, EventSerializer, StoredMessageSerializer, UserTagSerializer) @@ -101,21 +103,12 @@ class ComposedMessageListAPIView(ListAPIView): return queryset -class ComposedMessageAttachmentCreateView(CreateAPIView): +class AttachmentFileCreateView(CreateAPIView): permission_classes = (IsAuthenticated,) - queryset = ComposedMessageAttachment.objects.all() - serializer_class = ComposedMessageAttachmentSerializer + queryset = AttachmentFile.objects.all() + serializer_class = AttachmentFileSerializer parser_classes = [FormParser, MultiPartParser,] - def create(self, request, *args, **kwargs): - data = request.data - data['message'] = None - serializer = self.get_serializer(data=data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - class EventListAPIView(ListAPIView): permission_classes = (IsAdminUser,) diff --git a/apimail/management/commands/mailgun_get_stored_messages.py b/apimail/management/commands/mailgun_get_stored_messages.py index 8a7337a5db33d741a7aa63454a5de80a140a8dcf..eb417c42074f29a462a448054498080a59cb419b 100644 --- a/apimail/management/commands/mailgun_get_stored_messages.py +++ b/apimail/management/commands/mailgun_get_stored_messages.py @@ -12,7 +12,10 @@ from django.core.files import File from django.core.management import BaseCommand from ...exceptions import APIMailError -from ...models import Event, StoredMessage, StoredMessageAttachment +from ...models import ( + AttachmentFile, + Event, + StoredMessage) class Command(BaseCommand): @@ -63,9 +66,9 @@ class Command(BaseCommand): for chunk in r.iter_content(chunk_size=8192): tf.write(chunk) tf.seek(0) - sma = StoredMessageAttachment.objects.create( - message=sm, data=att_item) - sma._file.save(att_item['name'], File(tf)) + af = AttachmentFile.objects.create(data=att_item) + af.file.save(att_item['name'], File(tf)) + sm.attachment_files.add(af) # Finally add a FK relation to any event associated to this new message msgid = (sm.data['Message-Id'].lstrip('<')).rstrip('>') diff --git a/apimail/migrations/0016_auto_20200206_0515.py b/apimail/migrations/0016_auto_20200206_0515.py new file mode 100644 index 0000000000000000000000000000000000000000..46260da670d0e88fe7734bb976318fc985887d72 --- /dev/null +++ b/apimail/migrations/0016_auto_20200206_0515.py @@ -0,0 +1,53 @@ +# Generated by Django 2.1.8 on 2020-02-06 04:15 + +import apimail.validators +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import scipost.storage +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0015_composedmessageattachment'), + ] + + operations = [ + migrations.CreateModel( + name='AttachmentFile', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('file_upload', models.FileField(storage=scipost.storage.SecureFileStorage(), upload_to='uploads/mail/attachments/%Y/%m/%d/', validators=[apimail.validators.validate_max_email_attachment_file_size])), + ], + ), + migrations.CreateModel( + name='StoredMessageAttachmentFileBridge', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ('attachment_file', models.ManyToManyField(to='apimail.AttachmentFile')), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='apimail.StoredMessage')), + ], + ), + migrations.RemoveField( + model_name='composedmessageattachment', + name='message', + ), + migrations.RemoveField( + model_name='storedmessageattachment', + name='message', + ), + migrations.DeleteModel( + name='ComposedMessageAttachment', + ), + migrations.DeleteModel( + name='StoredMessageAttachment', + ), + migrations.AddField( + model_name='composedmessage', + name='attachments', + field=models.ManyToManyField(blank=True, to='apimail.AttachmentFile'), + ), + ] diff --git a/apimail/migrations/0017_auto_20200206_0554.py b/apimail/migrations/0017_auto_20200206_0554.py new file mode 100644 index 0000000000000000000000000000000000000000..b20a40f2e9677c0327fa254b6dd1aacdc71ded29 --- /dev/null +++ b/apimail/migrations/0017_auto_20200206_0554.py @@ -0,0 +1,39 @@ +# Generated by Django 2.1.8 on 2020-02-06 04:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0016_auto_20200206_0515'), + ] + + operations = [ + migrations.RemoveField( + model_name='storedmessageattachmentfilebridge', + name='attachment_file', + ), + migrations.RemoveField( + model_name='storedmessageattachmentfilebridge', + name='message', + ), + migrations.RenameField( + model_name='attachmentfile', + old_name='file_upload', + new_name='file', + ), + migrations.RenameField( + model_name='composedmessage', + old_name='attachments', + new_name='attachment_files', + ), + migrations.AddField( + model_name='storedmessage', + name='attachment_files', + field=models.ManyToManyField(blank=True, to='apimail.AttachmentFile'), + ), + migrations.DeleteModel( + name='StoredMessageAttachmentFileBridge', + ), + ] diff --git a/apimail/migrations/0018_attachmentfile_data.py b/apimail/migrations/0018_attachmentfile_data.py new file mode 100644 index 0000000000000000000000000000000000000000..c4f9311669083666d19cd14b0c8cb084e71ecdaa --- /dev/null +++ b/apimail/migrations/0018_attachmentfile_data.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.8 on 2020-02-06 07:01 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0017_auto_20200206_0554'), + ] + + operations = [ + migrations.AddField( + model_name='attachmentfile', + name='data', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict), + ), + ] diff --git a/apimail/models/__init__.py b/apimail/models/__init__.py index 07d51db2e362a0d1cbd0c651212d44a9c204c7a6..4ef781dbb2148bb2a03bf4a46e32342c3422c54a 100644 --- a/apimail/models/__init__.py +++ b/apimail/models/__init__.py @@ -4,10 +4,12 @@ __license__ = "AGPL v3" from .account import EmailAccount, EmailAccountAccess -from .composed_message import ComposedMessage, ComposedMessageAPIResponse, ComposedMessageAttachment +from .attachment import AttachmentFile + +from .composed_message import ComposedMessage, ComposedMessageAPIResponse from .event import Event -from .stored_message import StoredMessage, StoredMessageAttachment +from .stored_message import StoredMessage from .tag import UserTag diff --git a/apimail/models/attachment.py b/apimail/models/attachment.py new file mode 100644 index 0000000000000000000000000000000000000000..4c3790365864ed8cfb3c25f30ec7d18a5aae56ee --- /dev/null +++ b/apimail/models/attachment.py @@ -0,0 +1,34 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import uuid as uuid_lib + +from django.contrib.postgres.fields import JSONField +from django.db import models +from django.urls import reverse + +from scipost.storage import SecureFileStorage + +from ..validators import validate_max_email_attachment_file_size + + +class AttachmentFile(models.Model): + """ + File representing an attachment to an email message. + """ + + uuid = models.UUIDField( + primary_key=True, + default=uuid_lib.uuid4, + unique=True, + editable=False) + data = JSONField(default=dict) + file = models.FileField( + upload_to='uploads/mail/attachments/%Y/%m/%d/', + validators=[validate_max_email_attachment_file_size,], + storage=SecureFileStorage()) + + def get_absolute_url(self): + return reverse('apimail:attachment_file', + kwargs={'uuid': self.uuid}) diff --git a/apimail/models/composed_message.py b/apimail/models/composed_message.py index 2e2e32a5fa5ae40cb5fd4e5279c15d29ad82d022..ee5d964cca617d06a313f213aa02f3da68a4fdc6 100644 --- a/apimail/models/composed_message.py +++ b/apimail/models/composed_message.py @@ -67,6 +67,10 @@ class ComposedMessage(models.Model): body_text = models.TextField() body_html = models.TextField(blank=True) + attachment_files = models.ManyToManyField( + 'apimail.AttachmentFile', + blank=True) + objects = ComposedMessageQuerySet.as_manager() def __str__(self): @@ -92,19 +96,3 @@ class ComposedMessageAPIResponse(models.Model): class Meta: ordering = ['-datetime'] - - -class ComposedMessageAttachment(models.Model): - message = models.ForeignKey( - 'apimail.ComposedMessage', - on_delete=models.CASCADE, - related_name='attachments' - ) - _file = models.FileField( - upload_to='uploads/mail/composed_messages/attachments/%Y/%m/%d/', - validators=[validate_max_email_attachment_file_size,], - storage=SecureFileStorage()) - - def get_absolute_url(self): - return reverse('apimail:composed_message_attachment', - kwargs={'uuid': self.message.uuid, 'pk': self.id}) diff --git a/apimail/models/stored_message.py b/apimail/models/stored_message.py index 96da8db12eadc38312862929f57bd5106fc74e20..1f4422e30231fc949439dc98ae638e27d72fba82 100644 --- a/apimail/models/stored_message.py +++ b/apimail/models/stored_message.py @@ -34,6 +34,9 @@ class StoredMessage(models.Model): 'apimail.UserTag', blank=True, related_name='messages') + attachment_files = models.ManyToManyField( + 'apimail.AttachmentFile', + blank=True) objects = StoredMessageQuerySet.as_manager() @@ -49,20 +52,3 @@ class StoredMessage(models.Model): def get_absolute_url_api(self): return reverse('apimail:api_stored_message_retrieve', kwargs={'uuid': self.uuid}) - - -class StoredMessageAttachment(models.Model): - message = models.ForeignKey( - 'apimail.StoredMessage', - on_delete=models.CASCADE, - related_name='attachments' # doesn't collide with StoredMessage.data.attachments - ) - data = JSONField(default=dict) - _file = models.FileField( - upload_to='uploads/mail/stored_messages/attachments/%Y/%m/%d/', - validators=[validate_max_email_attachment_file_size,], - storage=SecureFileStorage()) - - def get_absolute_url(self): - return reverse('apimail:message_attachment', - kwargs={'uuid': self.message.uuid, 'pk': self.id}) diff --git a/apimail/static/apimail/assets/vue/components/MessageContent.vue b/apimail/static/apimail/assets/vue/components/MessageContent.vue index 6823393f4dd59e62e61d84d23e86ffd9cebd957b..ae901160a572b9c85734d6891b381b6a6addf069 100644 --- a/apimail/static/apimail/assets/vue/components/MessageContent.vue +++ b/apimail/static/apimail/assets/vue/components/MessageContent.vue @@ -87,10 +87,10 @@ </b-card-text> <template v-slot:footer> <div class="text-dark"> - <div v-if="message.attachments"> + <div v-if="message.attachment_files"> <h3>Attachments:</h3> <ul> - <li v-for="att in message.attachments"> + <li v-for="att in message.attachment_files"> <a :href="att.link" target="_blank" class="text-primary">{{ att.data.name }}</a>  {{ att.data["content-type"] }} ({{ att.data.size }} b) </li> diff --git a/apimail/urls.py b/apimail/urls.py index cfb68c000669f4c6553f85ce5d3e0852bb9f65ac..4047c664ee23ab1abe66ce03ec219fd43175210a 100644 --- a/apimail/urls.py +++ b/apimail/urls.py @@ -92,9 +92,9 @@ urlpatterns = [ TemplateView.as_view(template_name='apimail/message_list.html'), name='message_list' ), - path( # /mail/message/<uuid>/attachments/<int> - 'message/<uuid:uuid>/attachments/<int:pk>', + path( # /mail/attachment_file/<uuid> + 'attachment_file/<uuid:uuid>', views.attachment_file, - name='message_attachment' + name='attachment_file' ), ] diff --git a/apimail/views.py b/apimail/views.py index 67825ad1eddc33b34529094aa2de6194c048172b..cd54b1cb906b17da5aeca84bdeadf134f17cc4ff 100644 --- a/apimail/views.py +++ b/apimail/views.py @@ -7,23 +7,18 @@ import mimetypes from django.http import HttpResponse from django.shortcuts import get_object_or_404 -from .models import ComposedMessageAttachment, StoredMessageAttachment +from .models import AttachmentFile -def attachment_file(request, uuid, pk): +def attachment_file(request, uuid): """ - Return an attachment to either a Composed Message or a StoredMessage. + Return an attachment file. """ - try: - att = ComposedMessageAttachment.objects.get( - message__uuid=uuid, id=pk) - except ComposedMessageAttachment.DoesNotExist: - att = get_object_or_404(StoredMessageAttachment, - message__uuid=uuid, id=pk) - content_type, encoding = mimetypes.guess_type(att._file.path) + att = get_object_or_404(AttachmentFile, uuid=uuid) + content_type, encoding = mimetypes.guess_type(att.attachment_file.path) content_type = content_type or 'application/octet-stream' - response = HttpResponse(att._file.read(), content_type=content_type) + response = HttpResponse(att.attachment_file.read(), content_type=content_type) response['Content-Disposition'] = ( - 'filename=%s' % att._file.name.rpartition('/')[2]) + 'filename=%s' % att.attachment_file.name.rpartition('/')[2]) response["Content-Encoding"] = encoding return response