diff --git a/SciPost_v1/settings/base.py b/SciPost_v1/settings/base.py index 9c0d6f7b087fa982ce9cb92148465abc45d7c22f..98add02ea1bbf1165c0717c5c6e9d8bef02b4d9d 100644 --- a/SciPost_v1/settings/base.py +++ b/SciPost_v1/settings/base.py @@ -331,6 +331,10 @@ MAX_UPLOAD_SIZE = "2097152" # Default max attachment size in Bytes; 2MB MEDIA_ROOT = 'local_files/media/' MEDIA_ROOT_SECURE = 'local_files/secure/media/' +# Secure storage for apimail +APIMAIL_MEDIA_ROOT_SECURE = 'local_files/secure/apimail/' +APIMAIL_MEDIA_URL_SECURE = '/apimail/files/secure/' + # Static files (CSS, JavaScript, Images) STATIC_URL = '/static/' STATIC_ROOT = 'local_files/static/' @@ -489,7 +493,6 @@ ED_ASSIGMENT_DT_DELTA = timedelta(hours=6) # Mailgun credentials -MAILGUN_DOMAIN_NAME = '' MAILGUN_API_KEY = '' # Pawning verification token diff --git a/SciPost_v1/settings/local_JSC.py b/SciPost_v1/settings/local_JSC.py index 03c966d65b8e5a36535260179180af6940ae08ba..98bf0c4dbbe35dc1258649fdaf1ed283104b43e3 100644 --- a/SciPost_v1/settings/local_JSC.py +++ b/SciPost_v1/settings/local_JSC.py @@ -34,7 +34,6 @@ CSP_REPORT_URI = get_secret('CSP_SENTRY') CSP_REPORT_ONLY = True # Mailgun credentials -MAILGUN_DOMAIN_NAME = get_secret('MAILGUN_DOMAIN_NAME') MAILGUN_API_KEY = get_secret('MAILGUN_API_KEY') # CORS headers diff --git a/SciPost_v1/settings/staging_do1.py b/SciPost_v1/settings/staging_do1.py index 707705b3351097a504e0a5287815dd33e0a39615..b2812d090e6789b81ce8a67b8ff059cc7580388d 100644 --- a/SciPost_v1/settings/staging_do1.py +++ b/SciPost_v1/settings/staging_do1.py @@ -38,5 +38,4 @@ WSGI_APPLICATION = 'SciPost_v1.wsgi_staging_do1.application' #MONGO_DATABASE['port'] = get_secret('MONGO_DB_PORT') # Mailgun credentials -MAILGUN_DOMAIN_NAME = get_secret('MAILGUN_DOMAIN_NAME') MAILGUN_API_KEY = get_secret('MAILGUN_API_KEY') diff --git a/apimail/admin.py b/apimail/admin.py index c9db40e95a0b6cd8c460d50c71f5adce6213e51a..6ea48c9d3e78691675e90ac96297cc8bfc46672b 100644 --- a/apimail/admin.py +++ b/apimail/admin.py @@ -5,6 +5,7 @@ __license__ = "AGPL v3" from django.contrib import admin from .models import ( + Domain, EmailAccount, EmailAccountAccess, AttachmentFile, ComposedMessage, ComposedMessageAPIResponse, @@ -13,6 +14,9 @@ from .models import ( UserTag) +admin.site.register(Domain) + + class EmailAccountAccessInline(admin.StackedInline): model = EmailAccountAccess extra = 0 diff --git a/apimail/api/serializers.py b/apimail/api/serializers.py index c3c7a9cba87d2b96bc928b3ab0eae394459fbeab..115a3dc096bae8754dc4a510ecbe5e1102141ac8 100644 --- a/apimail/api/serializers.py +++ b/apimail/api/serializers.py @@ -118,7 +118,7 @@ class UserTagSerializer(serializers.ModelSerializer): fields = ['pk', 'user', 'label', 'unicode_symbol', 'variant'] def get_queryset(self): - user = self.request.user + user = self.context['request'].user return UserTag.objects.filter(user=user) @@ -126,11 +126,17 @@ class StoredMessageSerializer(serializers.ModelSerializer): attachment_files = AttachmentFileSerializer(many=True) event_set = EventSerializer(many=True) read = serializers.SerializerMethodField() - tags = UserTagSerializer(many=True) + tags = serializers.SerializerMethodField() def get_read(self, obj): return self.context['request'].user in obj.read_by.all() + def get_tags(self, obj): + return UserTagSerializer( + obj.tags.filter(user=self.context['request'].user), + many=True + ).data + class Meta: model = StoredMessage fields = ['uuid', 'data', 'datetimestamp', 'attachment_files', 'event_set', 'read', 'tags'] diff --git a/apimail/api/views.py b/apimail/api/views.py index 250465d09b99cc76e4caea7dab96df75b0371abc..6fa4bddd86caf1dc502ec4a87ddce09963f7b391 100644 --- a/apimail/api/views.py +++ b/apimail/api/views.py @@ -225,7 +225,6 @@ class StoredMessageUpdateReadAPIView(UpdateAPIView): queryset = StoredMessage.objects.all() serializer_class = StoredMessageSerializer lookup_field = 'uuid' - filter_backends = [StoredMessageFilterBackend,] def partial_update(self, request, *args, **kwargs): instance = self.get_object() @@ -270,7 +269,7 @@ class StoredMessageUpdateTagAPIView(UpdateAPIView): Adds or removes a user tag on a StoredMessage. """ - permission_classes = [IsAuthenticated, CanHandleStoredMessage] + permission_classes = [IsAuthenticated, CanReadStoredMessage] queryset = StoredMessage.objects.all() serializer_class = StoredMessageSerializer lookup_field = 'uuid' diff --git a/apimail/management/commands/mailgun_get_events.py b/apimail/management/commands/mailgun_get_events.py index da257277805684d39c62b4505371d720b64b8a41..9440010a0faada844bd6d28112540343743b1283 100644 --- a/apimail/management/commands/mailgun_get_events.py +++ b/apimail/management/commands/mailgun_get_events.py @@ -2,51 +2,81 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import time import requests from django.conf import settings from django.core.management import BaseCommand from ...exceptions import APIMailError -from ...models import Event +from ...models import Domain, Event -def get_and_save_events(url=None): +def get_and_save_events(url=None, domain_name=None, nr_minutes=2): """ - Get events from Mailgun Events API. + For the given domain, get events from Mailgun Events API. This method treats a single page and saves new Events to the database. If no url is given, get the first page. Returns the paging JSON, if present, so traversing can be performed. """ - response = requests.get( - url if url else "https://api.eu.mailgun.net/v3/%s/events" % settings.MAILGUN_DOMAIN_NAME, - auth=("api", settings.MAILGUN_API_KEY) - ).json() - events = response['items'] - for item in events: - if not Event.objects.filter(data__timestamp=item['timestamp'], - data__id=item['id']).exists(): - Event.objects.create(data=item) - info = {'nitems': len(events)} - if 'paging' in response: - info['paging'] = response['paging'] - return info + response = {} + if url is None and domain_name is None: + raise APIMailError('Please provide either a url or domain_name to get_and_save_events.') + elif url: + response = requests.get( + url, + auth=("api", settings.MAILGUN_API_KEY) + ).json() + else: + print("Fetching items for the last %d minutes" % nr_minutes) + begin_time = int(time.time()) - 60*nr_minutes + response = requests.get( + "https://api.eu.mailgun.net/v3/%s/events" % domain_name, + auth=("api", settings.MAILGUN_API_KEY), + params={ + "begin": begin_time, + "ascending": "yes" + } + ).json() + print(response) + try: + events = response['items'] + print("Retrieved %d events" % len(response['items'])) + for item in events: + if not Event.objects.filter(data__timestamp=item['timestamp'], + data__id=item['id']).exists(): + Event.objects.create(data=item) + info = {'nitems': len(events)} + if 'paging' in response: + info['paging'] = response['paging'] + return info + except KeyError: + print('No items found for domain %s\nresponse: %s' % (domain_name, response)) + return {'nitems': 0} class Command(BaseCommand): """ Perform a GET request to harvest Events from the Mailgun API, saving them to the DB. """ - help = 'Gets Events from the Mailgun Events API and saves them to the DB.' + def add_arguments(self, parser): + parser.add_argument( + '--nr_minutes', action='store', dest='nr_minutes', type=int, + help='number of minutes in the past where events are to be retrieved' + ) + def handle(self, *args, **kwargs): - info = get_and_save_events() - ctr = 1 # Safety: ensure no runaway requests - while ctr < 100 and info['nitems'] > 0: - info = get_and_save_events(url=info['paging']['next']) - ctr += 1 - if ctr == 100: - raise APIMailError('Hard stop of mailgun_get_events: ' - 'harvested above 100 pages from Mailgun Events API') + nr_minutes = kwargs.get('nr_minutes', 2) + print("Getting events for the last %d minutes" % nr_minutes) + for domain in Domain.objects.active(): + info = get_and_save_events(domain_name=domain.name, nr_minutes=nr_minutes) + ctr = 1 # Safety: ensure no runaway requests + while ctr < 100 and info['nitems'] > 0: + info = get_and_save_events(url=info['paging']['next']) + ctr += 1 + if ctr == 100: + raise APIMailError('Hard stop of mailgun_get_events: ' + 'harvested above 100 pages from Mailgun Events API') diff --git a/apimail/management/commands/mailgun_get_stored_messages.py b/apimail/management/commands/mailgun_get_stored_messages.py index 51078e22eb83d21f28c9fe0f9b764f09944d4c75..59427f612476707e75bcb2c3ac54d775ae06cc13 100644 --- a/apimail/management/commands/mailgun_get_stored_messages.py +++ b/apimail/management/commands/mailgun_get_stored_messages.py @@ -44,7 +44,6 @@ class Command(BaseCommand): orphan.save() except StoredMessage.DoesNotExist: - # Need to get and create the message try: storage_url = orphan.data['storage']['url'] diff --git a/apimail/management/commands/mailgun_send_messages.py b/apimail/management/commands/mailgun_send_messages.py index aa4589bfa955309a34141bf2b86c024a2f01b053..790fffcf65c261f0f5c9a336f9d03dda23edaa6f 100644 --- a/apimail/management/commands/mailgun_send_messages.py +++ b/apimail/management/commands/mailgun_send_messages.py @@ -32,7 +32,7 @@ class Command(BaseCommand): for att in msg.attachment_files.all()] response = requests.post( - "https://api.eu.mailgun.net/v3/%s/messages" % settings.MAILGUN_DOMAIN_NAME, + "https://api.eu.mailgun.net/v3/%s/messages" % msg.from_account.domain.name, auth=("api", settings.MAILGUN_API_KEY), files=files, data=data) diff --git a/apimail/management/commands/mailgun_send_test_email.py b/apimail/management/commands/mailgun_send_test_email.py index 76c40f2163a39bbc5de10ff0ede31a4939d56a2e..777c289146dc977b82d1a0af247b184255b6d1cd 100644 --- a/apimail/management/commands/mailgun_send_test_email.py +++ b/apimail/management/commands/mailgun_send_test_email.py @@ -7,24 +7,37 @@ import requests from django.conf import settings from django.core.management import BaseCommand +from ...exceptions import APIMailError +from ...models import Domain + class Command(BaseCommand): def add_arguments(self, parser): + parser.add_argument( + '--from', type=str, required=True, + help='from address') parser.add_argument( '--to', type=str, required=True, help='to address') def handle(self, *args, **options): - data = { - 'to': options.get('to'), - 'from': 'techsupport@%s' % settings.MAILGUN_DOMAIN_NAME, - 'subject': 'Test outgoing email', - 'text': 'Testing outgoing email.' - } - response = requests.post( - "https://api.eu.mailgun.net/v3/%s/messages" % settings.MAILGUN_DOMAIN_NAME, - auth=("api", settings.MAILGUN_API_KEY), - data=data) - print(data) - print(response) + domain_name = options.get('from').rpartition('@')[2] + try: + Domain.objects.active().get(name=domain_name) + data = { + 'from': options.get('from'), + 'to': options.get('to'), + 'subject': 'Test outgoing email', + 'text': 'Testing outgoing email.' + } + response = requests.post( + "https://api.eu.mailgun.net/v3/%s/messages" % domain_name, + auth=("api", settings.MAILGUN_API_KEY), + data=data) + print(data) + print(response) + except Domain.MultipleObjectsReturned: + raise APIMailError('Multiple domains found in mailgun_send_test_email') + except Domain.DoesNotExist: + raise APIMailError('The sending domain was not recognized in mailgun_send_test_email') diff --git a/apimail/managers.py b/apimail/managers.py index f37512358243889b5e0b9a992ac4740d665663e0..c25d3c97fc3c197f5b94e0d7a94fe5f373285dae 100644 --- a/apimail/managers.py +++ b/apimail/managers.py @@ -7,6 +7,12 @@ import datetime from django.db import models +class DomainQuerySet(models.QuerySet): + def active(self): + from apimail.models import Domain + return self.filter(status=Domain.STATUS_ACTIVE) + + class EmailAccountAccessQuerySet(models.QuerySet): def current(self): today = datetime.date.today() diff --git a/apimail/migrations/0021_auto_20201017_1016.py b/apimail/migrations/0021_auto_20201017_1016.py new file mode 100644 index 0000000000000000000000000000000000000000..01efa3ea1549c47d830d495cb25ac7be581989da --- /dev/null +++ b/apimail/migrations/0021_auto_20201017_1016.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.16 on 2020-10-17 08:16 + +import apimail.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0020_auto_20200214_1159'), + ] + + operations = [ + migrations.CreateModel( + name='Domain', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, validators=[apimail.validators._simple_domain_name_validator])), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='emailaccount', + name='domain', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='email_accounts', to='apimail.Domain'), + ), + ] diff --git a/apimail/migrations/0022_auto_20201017_1018.py b/apimail/migrations/0022_auto_20201017_1018.py new file mode 100644 index 0000000000000000000000000000000000000000..3230748f828d7b533e809795d1f4515d7e3e0383 --- /dev/null +++ b/apimail/migrations/0022_auto_20201017_1018.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.16 on 2020-10-17 08:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0021_auto_20201017_1016'), + ] + + operations = [ + migrations.AlterField( + model_name='emailaccount', + name='domain', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_accounts', to='apimail.Domain'), + ), + ] diff --git a/apimail/migrations/0023_domain_status.py b/apimail/migrations/0023_domain_status.py new file mode 100644 index 0000000000000000000000000000000000000000..615221464335fde1fa7824c86217f28ec28a8006 --- /dev/null +++ b/apimail/migrations/0023_domain_status.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.16 on 2020-10-17 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0022_auto_20201017_1018'), + ] + + operations = [ + migrations.AddField( + model_name='domain', + name='status', + field=models.CharField(choices=[('active', 'Active'), ('archived', 'Archived')], default='active', max_length=16), + ), + ] diff --git a/apimail/migrations/0024_auto_20201017_1658.py b/apimail/migrations/0024_auto_20201017_1658.py new file mode 100644 index 0000000000000000000000000000000000000000..6d3f13b8a85b8091e5dd5ddb44a36cb5653929d6 --- /dev/null +++ b/apimail/migrations/0024_auto_20201017_1658.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.16 on 2020-10-17 14:58 + +import apimail.storage +import apimail.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0023_domain_status'), + ] + + operations = [ + migrations.AlterField( + model_name='attachmentfile', + name='file', + field=models.FileField(storage=apimail.storage.APIMailSecureFileStorage(), upload_to='uploads/mail/attachments/%Y/%m/%d/', validators=[apimail.validators.validate_max_email_attachment_file_size]), + ), + ] diff --git a/apimail/models/__init__.py b/apimail/models/__init__.py index 4ef781dbb2148bb2a03bf4a46e32342c3422c54a..4f8395d53ee2ec0bc63bbef7012e0b5bfe279768 100644 --- a/apimail/models/__init__.py +++ b/apimail/models/__init__.py @@ -8,6 +8,8 @@ from .attachment import AttachmentFile from .composed_message import ComposedMessage, ComposedMessageAPIResponse +from .domain import Domain + from .event import Event from .stored_message import StoredMessage diff --git a/apimail/models/account.py b/apimail/models/account.py index c0537002de32642e3b76e77a7a4134fc05fa8a5d..fb59da3d1db1ef86471d81717800aad1dcf081f6 100644 --- a/apimail/models/account.py +++ b/apimail/models/account.py @@ -3,6 +3,7 @@ __license__ = "AGPL v3" from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from ..managers import EmailAccountAccessQuerySet @@ -14,6 +15,11 @@ class EmailAccount(models.Model): Access is specified on a per-user basis through the related EmailAccountAccess model. """ + domain = models.ForeignKey( + 'apimail.Domain', + related_name='email_accounts', + on_delete=models.CASCADE + ) name = models.CharField(max_length=256) email = models.EmailField(unique=True) description = models.TextField() @@ -24,6 +30,10 @@ class EmailAccount(models.Model): def __str__(self): return('%s <%s>' % (self.name, self.email)) + def clean(self): + if self.email.rpartition('@')[2] != self.domain.name: + raise ValidationError("Email domain does not match domain name.") + class EmailAccountAccess(models.Model): """ diff --git a/apimail/models/attachment.py b/apimail/models/attachment.py index 891609ac5c509cc2758e2b0e7d76d6f682d39627..75ff1f3fc0af0e849e1754d0a9f399f4a4c9729d 100644 --- a/apimail/models/attachment.py +++ b/apimail/models/attachment.py @@ -8,7 +8,7 @@ from django.contrib.postgres.fields import JSONField from django.db import models from django.urls import reverse -from scipost.storage import SecureFileStorage +from ..storage import APIMailSecureFileStorage from ..validators import validate_max_email_attachment_file_size @@ -27,7 +27,7 @@ class AttachmentFile(models.Model): file = models.FileField( upload_to='uploads/mail/attachments/%Y/%m/%d/', validators=[validate_max_email_attachment_file_size,], - storage=SecureFileStorage()) + storage=APIMailSecureFileStorage()) def __str__(self): return '%s (%s, %s)' % (self.data['name'], self.data['content-type'], self.file.size) diff --git a/apimail/models/composed_message.py b/apimail/models/composed_message.py index 82505f1482c8c31989a96302e83ea3480d61c550..491d6ea7a48b096501040f3221d6845123bc811b 100644 --- a/apimail/models/composed_message.py +++ b/apimail/models/composed_message.py @@ -10,10 +10,7 @@ from django.db import models from django.urls import reverse from django.utils import timezone -from scipost.storage import SecureFileStorage - from ..managers import ComposedMessageQuerySet -from ..validators import validate_max_email_attachment_file_size class ComposedMessage(models.Model): diff --git a/apimail/models/domain.py b/apimail/models/domain.py new file mode 100644 index 0000000000000000000000000000000000000000..d278d8fc229f3af4d16eaff34cf672cdaed50707 --- /dev/null +++ b/apimail/models/domain.py @@ -0,0 +1,39 @@ +_copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models + +from ..managers import DomainQuerySet +from ..validators import _simple_domain_name_validator + + +class Domain(models.Model): + """ + Domain name information. + """ + STATUS_ACTIVE = 'active' + STATUS_ARCHIVED = 'archived' + STATUS_CHOICES = ( + (STATUS_ACTIVE, 'Active'), + (STATUS_ARCHIVED, 'Archived') + ) + + name = models.CharField( + max_length=100, + validators=[_simple_domain_name_validator], + unique=True, + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_ACTIVE + ) + + objects = DomainQuerySet.as_manager() + + class Meta: + ordering = ('name',) + + def __str__(self): + return self.name diff --git a/apimail/models/stored_message.py b/apimail/models/stored_message.py index 1f4422e30231fc949439dc98ae638e27d72fba82..63f65740bde1f0b6b80cd055c899b6ea46f738b4 100644 --- a/apimail/models/stored_message.py +++ b/apimail/models/stored_message.py @@ -10,10 +10,7 @@ from django.db import models from django.urls import reverse from django.utils import timezone -from scipost.storage import SecureFileStorage - from ..managers import StoredMessageQuerySet -from ..validators import validate_max_email_attachment_file_size class StoredMessage(models.Model): diff --git a/apimail/permissions.py b/apimail/permissions.py index e732cc28e65a88702f1197755411fbf04ac5e7eb..ef0d2a528a5665d15be934b39a53c592232417bb 100644 --- a/apimail/permissions.py +++ b/apimail/permissions.py @@ -33,10 +33,10 @@ class CanHandleStoredMessage(permissions.BasePermission): # 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): + if ((access.account.email == obj.data['sender'] or + access.account.email in obj.data['recipients']) + and access.date_from < obj.datetimestamp.date() + and access.date_until > obj.datetimestamp.date()): return True return False @@ -53,9 +53,9 @@ class CanReadStoredMessage(permissions.BasePermission): # Check, based on account accesses for access in request.user.email_account_accesses.all(): - 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): + if ((access.account.email == obj.data['sender'] or + access.account.email in obj.data['recipients']) + and access.date_from < obj.datetimestamp.date() + and access.date_until > obj.datetimestamp.date()): return True return False diff --git a/apimail/static/apimail/assets/vue/components/AttachmentListItem.vue b/apimail/static/apimail/assets/vue/components/AttachmentListItem.vue index 5b5c86a9e4dcf8f68374337551ff8cf380009906..365d8bfd4a776fb9968beb6014e7edb36b280365 100644 --- a/apimail/static/apimail/assets/vue/components/AttachmentListItem.vue +++ b/apimail/static/apimail/assets/vue/components/AttachmentListItem.vue @@ -3,7 +3,9 @@ <a :href="attachment.file" target="_blank">{{ attachment.data.name }}</a>  ({{ content_type }}, {{ attachment.data.size }} bytes) <b-button class="bg-danger p-1" size="sm" @click.stop="$emit('remove')"> - <i class="small fa fa-times text-white"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x-circle-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/> + </svg> </b-button> </li> </template> diff --git a/apimail/static/apimail/assets/vue/components/EmailListItem.vue b/apimail/static/apimail/assets/vue/components/EmailListItem.vue index 0216e42e6b57a1f9ae14501c1a44d4d71f15b7d2..99be1e2fc09f5ab942719a9e483444598e71f81b 100644 --- a/apimail/static/apimail/assets/vue/components/EmailListItem.vue +++ b/apimail/static/apimail/assets/vue/components/EmailListItem.vue @@ -1,7 +1,11 @@ <template> <li> {{ email }}  - <b-button class="bg-danger p-0" size="sm" @click.stop="$emit('remove')"><i class="small fa fa-times text-white"></i></b-button> + <b-button class="bg-danger p-0" size="sm" @click.stop="$emit('remove')"> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-x-circle-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/> + </svg> + </b-button> </li> </template> diff --git a/apimail/static/apimail/assets/vue/components/MessageComposer.vue b/apimail/static/apimail/assets/vue/components/MessageComposer.vue index 159ea372ad0c4135c529b9cc207eecfba7564fa5..bf1442d5e30d8f95d4af4f9178bc29536054491e 100644 --- a/apimail/static/apimail/assets/vue/components/MessageComposer.vue +++ b/apimail/static/apimail/assets/vue/components/MessageComposer.vue @@ -88,35 +88,47 @@ :pressed.sync="isActive.bold()" @click.stop.prevent="commands.bold" > - <i class="fa fa-bold"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-type-bold" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="italics" :pressed.sync="isActive.italic()" @click.stop.prevent="commands.italic" > - <i class="fa fa-italic"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-type-italic" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M7.991 11.674L9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="strikethrough" :pressed.sync="isActive.strike()" @click.stop.prevent="commands.strike" > - <i class="fa fa-strikethrough"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-type-strikethrough" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M8.527 13.164c-2.153 0-3.589-1.107-3.705-2.81h1.23c.144 1.06 1.129 1.703 2.544 1.703 1.34 0 2.31-.705 2.31-1.675 0-.827-.547-1.374-1.914-1.675L8.046 8.5h3.45c.468.437.675.994.675 1.697 0 1.826-1.436 2.967-3.644 2.967zM6.602 6.5H5.167a2.776 2.776 0 0 1-.099-.76c0-1.627 1.436-2.768 3.48-2.768 1.969 0 3.39 1.175 3.445 2.85h-1.23c-.11-1.08-.964-1.743-2.25-1.743-1.23 0-2.18.602-2.18 1.607 0 .31.083.581.27.814z"/> + <path fill-rule="evenodd" d="M15 8.5H1v-1h14v1z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="underline" :pressed.sync="isActive.underline()" @click.stop.prevent="commands.underline" > - <i class="fa fa-underline"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-type-underline" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M5.313 3.136h-1.23V9.54c0 2.105 1.47 3.623 3.917 3.623s3.917-1.518 3.917-3.623V3.136h-1.23v6.323c0 1.49-.978 2.57-2.687 2.57-1.709 0-2.687-1.08-2.687-2.57V3.136z"/> + <path fill-rule="evenodd" d="M12.5 15h-9v-1h9v1z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="inline code" :pressed.sync="isActive.code()" @click.stop.prevent="commands.code" > - <i class="fa fa-code"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-code" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M5.854 4.146a.5.5 0 0 1 0 .708L2.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm4.292 0a.5.5 0 0 0 0 .708L13.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z"/> + </svg> </b-button> <b-button class="menubar__b-button" @@ -124,7 +136,10 @@ :pressed.sync="isActive.paragraph()" @click.stop.prevent="commands.paragraph" > - <i class="fa fa-paragraph"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-paragraph" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M8 1h4.5a.5.5 0 0 1 0 1H11v12.5a.5.5 0 0 1-1 0V2H9v12.5a.5.5 0 0 1-1 0V1z"/> + <path d="M9 1v8H7a4 4 0 1 1 0-8h2z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="level 1 heading" @@ -152,28 +167,38 @@ :pressed.sync="isActive.bullet_list()" @click.stop.prevent="commands.bullet_list" > - <i class="fa fa-list-ul"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-list-ul" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm-3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="numbered list" :pressed.sync="isActive.ordered_list()" @click.stop.prevent="commands.ordered_list" > - <i class="fa fa-list-ol"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-list-ol" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5z"/> + <path d="M1.713 11.865v-.474H2c.217 0 .363-.137.363-.317 0-.185-.158-.31-.361-.31-.223 0-.367.152-.373.31h-.59c.016-.467.373-.787.986-.787.588-.002.954.291.957.703a.595.595 0 0 1-.492.594v.033a.615.615 0 0 1 .569.631c.003.533-.502.8-1.051.8-.656 0-1-.37-1.008-.794h.582c.008.178.186.306.422.309.254 0 .424-.145.422-.35-.002-.195-.155-.348-.414-.348h-.3zm-.004-4.699h-.604v-.035c0-.408.295-.844.958-.844.583 0 .96.326.96.756 0 .389-.257.617-.476.848l-.537.572v.03h1.054V9H1.143v-.395l.957-.99c.138-.142.293-.304.293-.508 0-.18-.147-.32-.342-.32a.33.33 0 0 0-.342.338v.041zM2.564 5h-.635V2.924h-.031l-.598.42v-.567l.629-.443h.635V5z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="blockquote" :pressed.sync="isActive.blockquote()" @click.stop.prevent="commands.blockquote" > - <i class="fa fa-quote-right"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-blockquote-left" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm5 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm0 3a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5zm-5 3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5z"/> + <path d="M3.734 6.352a6.586 6.586 0 0 0-.445.275 1.94 1.94 0 0 0-.346.299 1.38 1.38 0 0 0-.252.369c-.058.129-.1.295-.123.498h.282c.242 0 .431.06.568.182.14.117.21.29.21.521a.697.697 0 0 1-.187.463c-.12.14-.289.21-.503.21-.336 0-.577-.108-.721-.327C2.072 8.619 2 8.328 2 7.969c0-.254.055-.485.164-.692.11-.21.242-.398.398-.562.16-.168.33-.31.51-.428.18-.117.33-.213.451-.287l.211.352zm2.168 0a6.588 6.588 0 0 0-.445.275 1.94 1.94 0 0 0-.346.299c-.113.12-.199.246-.257.375a1.75 1.75 0 0 0-.118.492h.282c.242 0 .431.06.568.182.14.117.21.29.21.521a.697.697 0 0 1-.187.463c-.12.14-.289.21-.504.21-.335 0-.576-.108-.72-.327-.145-.223-.217-.514-.217-.873 0-.254.055-.485.164-.692.11-.21.242-.398.398-.562.16-.168.33-.31.51-.428.18-.117.33-.213.451-.287l.211.352z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="code block" :pressed.sync="isActive.code_block()" @click.stop.prevent="commands.code_block" > - <i class="fa fa-code"></i> block + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-code" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M5.854 4.146a.5.5 0 0 1 0 .708L2.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm4.292 0a.5.5 0 0 0 0 .708L13.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z"/> + </svg> block </b-button> <b-button v-b-tooltip.hover title="horizontal rule" @@ -183,12 +208,18 @@ <b-button v-b-tooltip.hover title="undo" @click.stop.prevent="commands.undo"> - <i class="fa fa-undo"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-arrow-counterclockwise" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2v1z"/> + <path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466z"/> + </svg> </b-button> <b-button v-b-tooltip.hover title="redo" @click.stop.prevent="commands.redo"> - <i class="fa fa-repeat"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-arrow-clockwise" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/> + <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/> + </svg> </b-button> </div> </editor-menu-bar> diff --git a/apimail/static/apimail/assets/vue/components/MessagesTable.vue b/apimail/static/apimail/assets/vue/components/MessagesTable.vue index 95eb1b9041920fe53c311286d04860814b870120..e97dd857f0d72dc017e4c2be4a1c7f0c5e2597bd 100644 --- a/apimail/static/apimail/assets/vue/components/MessagesTable.vue +++ b/apimail/static/apimail/assets/vue/components/MessagesTable.vue @@ -1,7 +1,7 @@ <template> <div> - <div v-if="accesses" class="m-2 mb-4"> + <div v-if="currentSendingAccesses && currentSendingAccesses.length > 0" class="m-2 mb-4"> <b-button v-b-modal.modal-newdraft variant="primary" @@ -56,7 +56,7 @@ </b-modal> - <div v-if="draftMessages.length > 0" class="m-2 mb-4"> + <div v-if="draftMessages && draftMessages.length > 0" class="m-2 mb-4"> <h2>Message drafts to complete</h2> <table class="table"> <tr> @@ -123,6 +123,19 @@ <b-row> <b-col class="col-lg-6"> <h2>Messages for <strong>{{ accountSelected.email }}</strong></h2> + <!-- <b-form-group --> + <!-- label="Auto refresh (minutes): " --> + <!-- label-cols-sm="4" --> + <!-- label-align-sm="right" --> + <!-- label-size="sm" --> + <!-- > --> + <!-- <b-form-radio-group --> + <!-- v-model="refreshMinutes" --> + <!-- :options="refreshMinutesOptions" --> + <!-- class="float-center" --> + <!-- > --> + <!-- </b-form-radio-group> --> + <!-- </b-form-group> --> <small class="p-2">Last loaded: {{ lastLoaded }}</small> <b-badge class="p-2" @@ -130,7 +143,7 @@ variant="primary" @click="refreshMessages" > - Refresh + Refresh now </b-badge> </b-col> <b-col class="col-lg-2"> @@ -311,10 +324,14 @@ </template> <template v-slot:cell(actions)="row"> <span v-if="row.detailsShowing"> - <i class="fa fa-angle-down"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-caret-down-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M7.247 11.14L2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/> + </svg> </span> <span v-else> - <i class="fa fa-angle-right"></i> + <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-caret-right-fill" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + <path d="M12.14 8.753l-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z"/> + </svg> </span> </template> <template v-slot:row-details="row"> @@ -353,6 +370,7 @@ export default { data() { return { accesses: null, + currentSendingAccesses: null, accountSelected: null, draftMessages: [], draftMessageSelected: null, @@ -387,6 +405,9 @@ export default { { text: 'read', value: true }, { text: 'all', value: null }, ], + refreshInterval: null, + refreshMinutes: 1, + refreshMinutesOptions: [ 1, 5, 15, 60 ], tags: null, tagRequired: 'any', } @@ -398,6 +419,12 @@ export default { .then(data => this.accesses = data.results) .catch(error => console.error(error)) }, + fetchCurrentSendingAccounts () { + fetch('/mail/api/user_account_accesses?current=true&cansend=true') + .then(stream => stream.json()) + .then(data => this.currentSendingAccesses = data.results) + .catch(error => console.error(error)) + }, fetchTags () { fetch('/mail/api/user_tags') .then(stream => stream.json()) @@ -493,6 +520,7 @@ export default { }, mounted() { this.fetchAccounts() + this.fetchCurrentSendingAccounts() this.fetchTags() this.fetchDrafts() this.$root.$on('bv::modal::hide', (bvEvent, modalId) => { @@ -503,7 +531,11 @@ export default { this.fetchDrafts() } }) + // this.refreshInterval = setInterval(this.refreshMessages, this.refreshMinutes * 1000) }, + // beforeDestroy() { + // clearInterval(this.refreshInterval) + // }, watch: { accountSelected: function () { this.$root.$emit('bv::refresh::table', 'my-table') @@ -519,7 +551,11 @@ export default { }, tagRequired: function () { this.$root.$emit('bv::refresh::table', 'my-table') - } + }, + // refreshMinutes: function () { + // clearInterval(this.refreshInterval) + // this.refreshInterval = setInterval(this.refreshMessages, this.refreshMinutes * 1000) + // } } } diff --git a/apimail/storage.py b/apimail/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..01ab996f3fdd2f74cfd38ec18b3844844accb6c0 --- /dev/null +++ b/apimail/storage.py @@ -0,0 +1,29 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf import settings +from django.core.files.storage import FileSystemStorage +from django.utils.functional import cached_property + + +class APIMailSecureFileStorage(FileSystemStorage): + """ + Inherit default FileStorage system to prevent files from being publicly accessible + from a server location that is opened without this permission having been explicitly given. + """ + @cached_property + def location(self): + """ + This method determines the storage location for a new file. To secure the file from + public access, it is stored outside the default MEDIA_ROOT folder. + + This also means you need to explicitly handle the file reading/opening! + """ + if hasattr(settings, 'APIMAIL_MEDIA_ROOT_SECURE'): + return self._value_or_setting(self._location, settings.APIMAIL_MEDIA_ROOT_SECURE) + return super().location + + @cached_property + def base_url(self): + return settings.APIMAIL_MEDIA_URL_SECURE diff --git a/apimail/templates/apimail/message_list.html b/apimail/templates/apimail/message_list.html index 01f15eeffe0a3d72634d36758871541f4d803702..51de5cb60f8d402d96d048f916927d4cf4c95040 100644 --- a/apimail/templates/apimail/message_list.html +++ b/apimail/templates/apimail/message_list.html @@ -4,6 +4,7 @@ {% load static %} {% block headsup %} + {% render_bundle 'vendors~apimail' 'css' %} {% render_bundle 'apimail' 'css' %} {% endblock headsup %} @@ -16,5 +17,6 @@ {% endblock content %} {% block footer_script %} + {% render_bundle 'vendors~apimail' 'js' %} {% render_bundle 'apimail' 'js' %} {% endblock footer_script %} diff --git a/apimail/validators.py b/apimail/validators.py index 2c5581249aedefa5434ef6b9b0d4b3b1d616872b..8bd1602f02242c48f9f0d19f23a61fa60d619724 100644 --- a/apimail/validators.py +++ b/apimail/validators.py @@ -2,11 +2,27 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import string + from django.conf import settings from django.core.exceptions import ValidationError from django.template.defaultfilters import filesizeformat +def _simple_domain_name_validator(value): + """ + Validate that the given value contains no whitespaces to prevent common typos. + + Taken from django.contrib.sites.models + """ + checks = ((s in value) for s in string.whitespace) + if any(checks): + raise ValidationError( + "The domain name cannot contain any spaces or tabs.", + code='invalid', + ) + + def validate_max_email_attachment_file_size(value): if value.size > int(settings.MAX_EMAIL_ATTACHMENT_FILE_SIZE): raise ValidationError( diff --git a/scipost/static/scipost/assets/css/_pagination.scss b/scipost/static/scipost/assets/css/_pagination.scss index b4345311d366c61e1ca4ead057df4d5c3ee871db..e4467f012490d64d0a4d7bda68532eff29544591 100644 --- a/scipost/static/scipost/assets/css/_pagination.scss +++ b/scipost/static/scipost/assets/css/_pagination.scss @@ -4,6 +4,10 @@ @include border-radius(); } +.pagination li { // for DRF API pagination, file tepmlates/rest_framework/api.html + margin: 0.2rem; +} + .page-link { position: relative; display: block; diff --git a/scipost/urls.py b/scipost/urls.py index 3c7b093f18612d400a1dbeee8307a68a1c45b73c..7727ee33b944c1ebf41b102a0850af7943f1464a 100644 --- a/scipost/urls.py +++ b/scipost/urls.py @@ -32,7 +32,6 @@ app_name = 'scipost' urlpatterns = [ - # redirect for favicon re_path(r'^favicon\.ico$', favicon_view), diff --git a/templates/rest_framework/api.html b/templates/rest_framework/api.html index cda6f8219768ee9ae3abd6f4b831f7d52b3f3c77..260d689d178a867fd586c05b3295870428040537 100644 --- a/templates/rest_framework/api.html +++ b/templates/rest_framework/api.html @@ -37,11 +37,10 @@ <title>SciPost API</title> {% block style %} - <link href="https://fonts.googleapis.com/css?family=Merriweather+Sans:300,400,700" rel="stylesheet"> - <link rel="stylesheet" type="text/css" href="{% static 'scipost/SciPost.css' %}" /> - <link rel="stylesheet" type="text/css" href="{% static 'fa/css/font-awesome.min.css' %}" /> <link rel="stylesheet" href="{% static 'flags/sprite-hq.css' %}"> + {% render_bundle 'vendors~homepage~main~qr' %} + {% render_bundle 'vendors~main' %} {% render_bundle 'main' %} <link rel="shortcut icon" href="{% static 'scipost/images/scipost_favicon.png' %}"/> @@ -85,82 +84,78 @@ {% block content %} <div class="region" aria-label="{% trans "request form" %}"> - {% block request_forms %} - - {% if 'GET' in allowed_methods %} - <form id="get-form" class="pull-right"> - <fieldset> - {% if api_settings.URL_FORMAT_OVERRIDE %} - <div class="btn-group format-selection"> - <a class="btn btn-primary js-tooltip" href="{{ request.get_full_path }}" rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a> - - <button class="btn btn-primary dropdown-toggle js-tooltip" data-toggle="dropdown" title="Specify a format for the GET request"> - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - {% for format in available_formats %} - <li> - <a class="js-tooltip format-option" href="{% add_query_param request api_settings.URL_FORMAT_OVERRIDE format %}" rel="nofollow" title="Make a GET request on the {{ name }} resource with the format set to `{{ format }}`">{{ format }}</a> - </li> - {% endfor %} - </ul> - </div> - {% else %} + {% if 'GET' in allowed_methods %} + <form id="get-form" class="float-right"> + <fieldset> + {% if api_settings.URL_FORMAT_OVERRIDE %} + <div class="btn-group format-selection"> <a class="btn btn-primary js-tooltip" href="{{ request.get_full_path }}" rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a> - {% endif %} - </fieldset> - </form> - {% endif %} - - {% if options_form %} - <form class="button-form" action="{{ request.get_full_path }}" data-method="OPTIONS"> - <button class="btn btn-primary js-tooltip" title="Make an OPTIONS request on the {{ name }} resource">OPTIONS</button> - </form> - {% endif %} - - {% if delete_form %} - <button class="btn btn-danger button-form js-tooltip" title="Make a DELETE request on the {{ name }} resource" data-toggle="modal" data-target="#deleteModal">DELETE</button> - - <!-- Delete Modal --> - <div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"> - <div class="modal-dialog"> - <div class="modal-content"> - <div class="modal-body"> - <h4 class="text-center">Are you sure you want to delete this {{ name }}?</h4> - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> - <form class="button-form" action="{{ request.get_full_path }}" data-method="DELETE"> - <button class="btn btn-danger">Delete</button> - </form> - </div> + + <button class="btn btn-primary dropdown-toggle js-tooltip" data-toggle="dropdown" title="Specify a format for the GET request"> + <span class="caret"></span> + </button> + <ul class="dropdown-menu"> + {% for format in available_formats %} + <li> + <a class="js-tooltip format-option" href="{% add_query_param request api_settings.URL_FORMAT_OVERRIDE format %}" rel="nofollow" title="Make a GET request on the {{ name }} resource with the format set to `{{ format }}`">{{ format }}</a> + </li> + {% endfor %} + </ul> + </div> + {% else %} + <a class="btn btn-primary js-tooltip" href="{{ request.get_full_path }}" rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a> + {% endif %} + </fieldset> + </form> + {% endif %} + + {% if options_form %} + <form class="button-form" action="{{ request.get_full_path }}" data-method="OPTIONS"> + <button class="btn btn-primary js-tooltip" title="Make an OPTIONS request on the {{ name }} resource">OPTIONS</button> + </form> + {% endif %} + + {% if delete_form %} + <button class="btn btn-danger button-form js-tooltip" title="Make a DELETE request on the {{ name }} resource" data-toggle="modal" data-target="#deleteModal">DELETE</button> + + <!-- Delete Modal --> + <div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-body"> + <h4 class="text-center">Are you sure you want to delete this {{ name }}?</h4> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> + <form class="button-form" action="{{ request.get_full_path }}" data-method="DELETE"> + <button class="btn btn-danger">Delete</button> + </form> </div> </div> </div> - {% endif %} - - {% if extra_actions %} - <div class="dropdown" style="float: right; margin-right: 10px"> - <button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true"> - {% trans "Extra Actions" %} - <span class="caret"></span> - </button> - <ul class="dropdown-menu" aria-labelledby="extra-actions-menu"> - {% for action_name, url in extra_actions|items %} - <li><a href="{{ url }}">{{ action_name }}</a></li> - {% endfor %} - </ul> - </div> - {% endif %} + </div> + {% endif %} - {% if filter_form %} - <button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default"> - <span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> - {% trans "Filters" %} + {% if extra_actions %} + <div class="dropdown" style="float: right; margin-right: 10px"> + <button class="btn btn-default" id="extra-actions-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true"> + {% trans "Extra Actions" %} + <span class="caret"></span> </button> - {% endif %} + <ul class="dropdown-menu" aria-labelledby="extra-actions-menu"> + {% for action_name, url in extra_actions|items %} + <li><a href="{{ url }}">{{ action_name }}</a></li> + {% endfor %} + </ul> + </div> + {% endif %} - {% endblock request_forms %} + {% if filter_form %} + <button style="float: right; margin-right: 10px" data-toggle="modal" data-target="#filtersModal" class="btn btn-default"> + <span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> + {% trans "Filters" %} + </button> + {% endif %} </div> <div class="content-main" role="main" aria-label="{% trans "main content" %}"> @@ -184,10 +179,7 @@ </div> <div class="response-info" aria-label="{% trans "response info" %}"> - <pre class="prettyprint"><span class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% for key, val in response_headers|items %} - <b>{{ key }}:</b> <span class="lit">{{ val|break_long_headers|urlize_quoted_links }}</span>{% endfor %} - - </span>{{ content|urlize_quoted_links }}</pre> + <pre class="prettyprint"><span class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b><br>{% autoescape off %}{% for key, val in response_headers|items %}<b>{{ key }}:</b> <span class="lit">{{ val|break_long_headers|urlize_quoted_links }}</span><br>{% endfor %}</span><br>{{ content|urlize_quoted_links }}</pre>{% endautoescape %} </div> </div> diff --git a/webpack.dev.config.js b/webpack.dev.config.js index a65ccde50eb955cf57bc7fd487772c19d4819356..159747be7678925f89f37afb5fa3bb6138e1d4f4 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -20,6 +20,7 @@ module.exports = { ], apimail: [ "./apimail/static/apimail/assets/vue/messages_table.js", + ], qr: [ "./scipost/static/scipost/assets/js/activate_qr.js", ],