From 797c8614788e8cccb25588d8a132bbf9495b5fcb Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 14 Oct 2020 19:02:23 +0300 Subject: [PATCH 1/5] resent push message and ability to put a device in sandbox mode --- README.md | 53 +++++++++++++++++++++++++---- django_sloop/admin.py | 70 +++++++++++++++++++++++++++++++++++++- django_sloop/handlers.py | 9 +++-- django_sloop/models.py | 8 ++++- django_sloop/settings.py | 2 +- django_sloop/tests.py | 38 +++++++++++++++++++-- test_app/settings/local.py | 2 +- 7 files changed, 165 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b294124..64908a2 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,15 @@ class Device(AbstractSNSDevice): pass ``` -4. Make sure that you fill necessary information at the settings file: +4. Add your Device model to django admin. + +```python +from django_sloop.admin import DeviceAdmin + +admin.site.register(Device, DeviceAdmin) +``` + +5. Make sure that you fill necessary information at the settings file: ```python # settings.py @@ -37,7 +45,7 @@ DJANGO_SLOOP_SETTINGS = { "AWS_ACCESS_KEY_ID": "", "AWS_SECRET_ACCESS_KEY": "", "SNS_IOS_APPLICATION_ARN": "test_ios_arn", - "SNS_IOS_SANDBOX_ENABLED": False, + "SNS_IOS_SANDBOX_APPLICATION_ARN": "test_ios_sandbox_arn", "SNS_ANDROID_APPLICATION_ARN": "test_android_arn", "LOG_SENT_MESSAGES": False, # False by default. "DEFAULT_SOUND": "", @@ -48,7 +56,7 @@ DJANGO_SLOOP_SETTINGS = { You cannot change the DEVICE_MODEL setting during the lifetime of a project (i.e. once you have made and migrated models that depend on it) without serious effort. The model it refers to must be available in the first migration of the app that it lives in. -5. Create migrations for newly created Device model and migrate. +6. Create migrations for newly created Device model and migrate. **Note:** django_sloop's migrations must run after your Device is created. If you run into a problem while running migrations add following to the your migration file where the Device is created. ``` @@ -57,7 +65,7 @@ run_before = [ ] ``` -6. Add django_sloop.models.PushNotificationMixin to your User model. +7. Add django_sloop.models.PushNotificationMixin to your User model. ```python class User(PushNotificationMixin, ...): pass @@ -67,7 +75,7 @@ user.send_push_notification_async(message="Sample push notification.") ``` -7. Add django_sloop.admin.SloopAdminMixin to your UserAdmin to enable sending push messages to users from Django admin panel. +8. Add django_sloop.admin.SloopAdminMixin to your UserAdmin to enable sending push messages to users from Django admin panel. ```python # admin.py @@ -81,7 +89,7 @@ class UserAdmin(SloopAdminMixin, admin.ModelAdmin): ``` -8. Add django rest framework urls to create and delete device. +9. Add django rest framework urls to create and delete device. ```python # urls.py @@ -95,4 +103,35 @@ urlpatterns = [ ] ``` -Done! \ No newline at end of file +Done! + + +## Mobile Client Integration + +- Mobile clients should call the `create-or-update-device` endpoint in the following cases. + 1. First time you access the push token. + 2. If you already have push token you should send the same request to update last time the device is used. It's best to call this endpoint each time app is opened or come back from background. + + Request: **POST /api/devices/** + Payload: + ``` + { + "push_token": "required string", + "platform": "required ios or android", + "model": "optional string", + "locale": "optional string default to `en_US`", + } + ``` + +- Mobile clients should call the `delete-device` endpoint when user log outs. + + Request: **DELETE /api/devices/** + Payload: + ``` + { + "push_token": "required string", + } + ``` + + +**Endpoint details will be available in the projects api documentation as well. There can be project level changes so please go to projects api documentation.** diff --git a/django_sloop/admin.py b/django_sloop/admin.py index e9dcb1d..2863b07 100644 --- a/django_sloop/admin.py +++ b/django_sloop/admin.py @@ -9,7 +9,9 @@ import json +from django_sloop.handlers import SNSHandler from django_sloop.models import PushMessage +from django.utils.translation import ugettext_lazy as _ class PushNotificationForm(forms.Form): @@ -121,15 +123,81 @@ def send_push_notification(self, request, queryset): return TemplateResponse(request, 'django_sloop/push_notification.html', context=context) +class DeviceAdmin(admin.ModelAdmin): + + list_display = ["user", "platform", "model", "is_sandbox_enabled", "deleted_at", "date_created", "date_updated"] + readonly_fields = ["user", "platform", "model", "deleted_at", "date_created", "date_updated"] + search_fields = ["user_id"] + + def save_model(self, request, obj, form, change): + # Clear sns_platform_endpoint_arn if sandbox mode is changed. + # https://docs.djangoproject.com/en/2.2/ref/models/instances/#refreshing-objects-from-database + new_value = obj.is_sandbox_enabled + delattr(obj, "is_sandbox_enabled") + # Fetch field from database. + old_value = obj.is_sandbox_enabled + + if new_value != old_value: + obj.sns_platform_endpoint_arn = "" + + obj.is_sandbox_enabled = new_value + + super(DeviceAdmin, self).save_model(request, obj, form, change) + + class PushMessageAdmin(admin.ModelAdmin): search_fields = ["body", "sns_message_id"] list_display = ["id", "body", "error_message", "device", "sns_message_id", "date_created", "date_updated"] - readonly_fields = ["id", "device", "body", "data", "sns_message_id", "sns_response", "date_created", "date_updated"] + readonly_fields = ["id", "device", "body", "data", "sns_message_id", "sns_response", "payload", "date_created", "date_updated"] + + actions = ["resend_push_notification"] def error_message(self, obj): error = json.loads(obj.sns_response).get("Error") if error: return error.get("Message") + def resend_push_notification(self, request, queryset): + if queryset.count() > 1: + messages.add_message(request, messages.ERROR, _("You can only send one push message at a time.")) + push_message = queryset.get() + + try: + handler = SNSHandler(device=push_message.device) + handler._send_payload(data=json.loads(push_message.data)) + messages.add_message(request, messages.SUCCESS, _("Push message has been sent.")) + except Exception as exc: + messages.add_message(request, messages.ERROR, str(exc)) + + def resend_push_notification(self, request, queryset): + if queryset.count() > 1: + messages.add_message(request, messages.ERROR, _("You can only send one push message at a time.")) + push_message = queryset.get() + device = push_message.device + try: + payload = self.payload(push_message) + payload = payload.get("data") or payload.get("aps") + + message = payload.get("alert") + sound = payload.get("sound") + extra = payload.get("custom") + badge_count = payload.get("badge") + category = payload.get("category") + if message: + device.send_push_notification(message, url=extra.get("url"), badge_count=badge_count, sound=sound, extra=extra, category=category) + messages.add_message(request, messages.SUCCESS, _("Push message has been sent.")) + else: + device.send_silent_push_notification(extra=extra, badge_count=badge_count, content_available=None) + messages.add_message(request, messages.SUCCESS, _("Silent push message has been sent.")) + except Exception as exc: + messages.add_message(request, messages.ERROR, str(exc)) + + def payload(self, push_message): + payload = json.loads(push_message.data) + payload = payload.get("GCM") or payload.get("APNS") or payload.get("APNS_SANDBOX") + payload = json.loads(payload) + return payload + + admin.site.register(PushMessage, PushMessageAdmin) diff --git a/django_sloop/handlers.py b/django_sloop/handlers.py index 2789ec2..674a169 100644 --- a/django_sloop/handlers.py +++ b/django_sloop/handlers.py @@ -33,7 +33,10 @@ def get_client(self): @property def application_arn(self): if self.device.platform == AbstractSNSDevice.PLATFORM_IOS: - application_arn = DJANGO_SLOOP_SETTINGS.get("SNS_IOS_APPLICATION_ARN") + if self.device.is_sandbox_enabled: + application_arn = DJANGO_SLOOP_SETTINGS.get("SNS_IOS_SANDBOX_APPLICATION_ARN") + else: + application_arn = DJANGO_SLOOP_SETTINGS.get("SNS_IOS_APPLICATION_ARN") elif self.device.platform == AbstractSNSDevice.PLATFORM_ANDROID: application_arn = DJANGO_SLOOP_SETTINGS.get("SNS_ANDROID_APPLICATION_ARN") else: @@ -125,7 +128,7 @@ def generate_apns_push_notification_message(self, message, url, badge_count, sou } apns_string = json.dumps(apns_bundle, ensure_ascii=False) - if DJANGO_SLOOP_SETTINGS.get("SNS_IOS_SANDBOX_ENABLED"): + if self.device.is_sandbox_enabled: return { 'APNS_SANDBOX': apns_string } @@ -149,7 +152,7 @@ def generate_apns_silent_push_notification_message(self, extra, badge_count, con } apns_string = json.dumps(apns_bundle, ensure_ascii=False) - if DJANGO_SLOOP_SETTINGS.get("SNS_IOS_SANDBOX_ENABLED"): + if self.device.is_sandbox_enabled: return { 'APNS_SANDBOX': apns_string } diff --git a/django_sloop/models.py b/django_sloop/models.py index 2a5fed8..228eeb9 100644 --- a/django_sloop/models.py +++ b/django_sloop/models.py @@ -2,7 +2,7 @@ from django.conf import settings from django.contrib.gis.db import models -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils import timezone from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ @@ -91,6 +91,8 @@ class AbstractSNSDevice(models.Model): model = models.CharField(max_length=255, blank=True) sns_platform_endpoint_arn = models.CharField(_("SNS Platform Endpoint"), max_length=255, null=True, blank=True, unique=True) + is_sandbox_enabled = models.BooleanField(default=False, help_text="Changing this will clear the sns_platform_endpoint_arn") + deleted_at = models.DateTimeField(null=True, blank=True) date_created = models.DateTimeField(default=timezone.now) @@ -110,6 +112,10 @@ def __str__(self): "push_token": self.push_token, }) + def clean(self): + if self.is_sandbox_enabled and self.platform != self.PLATFORM_IOS: + raise ValidationError({"is_sandbox_enabled": _("Sandbox can be enabled for iOS platforms")}) + def invalidate(self): self.deleted_at = timezone.now() self.save() diff --git a/django_sloop/settings.py b/django_sloop/settings.py index 2336d47..423a5b3 100644 --- a/django_sloop/settings.py +++ b/django_sloop/settings.py @@ -7,7 +7,7 @@ DJANGO_SLOOP_SETTINGS.setdefault("AWS_ACCESS_KEY_ID", None) DJANGO_SLOOP_SETTINGS.setdefault("AWS_SECRET_ACCESS_KEY", None) DJANGO_SLOOP_SETTINGS.setdefault("SNS_IOS_APPLICATION_ARN", None) -DJANGO_SLOOP_SETTINGS.setdefault("SNS_IOS_SANDBOX_ENABLED", False) +DJANGO_SLOOP_SETTINGS.setdefault("SNS_IOS_SANDBOX_APPLICATION_ARN", None) DJANGO_SLOOP_SETTINGS.setdefault("SNS_ANDROID_APPLICATION_ARN", None) DJANGO_SLOOP_SETTINGS.setdefault("LOG_SENT_MESSAGES", False) DJANGO_SLOOP_SETTINGS.setdefault("DEFAULT_SOUND", None) diff --git a/django_sloop/tests.py b/django_sloop/tests.py index d1c6618..44a18b8 100644 --- a/django_sloop/tests.py +++ b/django_sloop/tests.py @@ -37,6 +37,9 @@ def test_get_ios_application_arn(self): handler = SNSHandler(self.ios_device) self.assertEqual(handler.application_arn, DJANGO_SLOOP_SETTINGS["SNS_IOS_APPLICATION_ARN"]) + self.ios_device.is_sandbox_enabled = True + self.assertEqual(handler.application_arn, DJANGO_SLOOP_SETTINGS["SNS_IOS_SANDBOX_APPLICATION_ARN"]) + def test_get_android_application_arn(self): sns_client = Mock() SNSHandler.client = sns_client @@ -82,7 +85,7 @@ def setUp(self): self.ios_device = Device.objects.create(user=self.user, push_token=TEST_IOS_PUSH_TOKEN, platform=Device.PLATFORM_IOS) self.android_device = Device.objects.create(user=self.user, push_token=TEST_ANDROID_PUSH_TOKEN, platform=Device.PLATFORM_ANDROID) - def test_send_ios_push_notification(self): + def check_send_ios_push_notification(self, is_sandbox=True): sns_client = Mock() sns_test_message_id = "test_message_" + str(randint(0, 9999)) sns_client.publish.return_value = { @@ -90,6 +93,12 @@ def test_send_ios_push_notification(self): } SNSHandler.client = sns_client + if is_sandbox: + self.ios_device.is_sandbox_enabled = True + payload_key = "APNS_SANDBOX" + else: + payload_key = "APNS" + self.ios_device.sns_platform_endpoint_arn = "test_ios_arn" self.ios_device.save() @@ -100,7 +109,7 @@ def test_send_ios_push_notification(self): self.assertEqual(call_kwargs["TargetArn"], self.ios_device.sns_platform_endpoint_arn) self.assertEqual(call_kwargs["MessageStructure"], "json") expected_message = { - 'APNS': { + payload_key: { 'aps': { 'alert': "test_message", 'sound': 'test_sound', @@ -113,7 +122,7 @@ def test_send_ios_push_notification(self): } actual_message = json.loads(call_kwargs["Message"]) - actual_message["APNS"] = json.loads(actual_message["APNS"]) + actual_message[payload_key] = json.loads(actual_message[payload_key]) self.assertDictEqual(expected_message, actual_message) @@ -123,6 +132,12 @@ def test_send_ios_push_notification(self): "MessageId": sns_test_message_id })) + def test_send_ios_push_notification(self): + self.check_send_ios_push_notification(is_sandbox=False) + + def test_send_ios_sandbox_push_notification(self): + self.check_send_ios_push_notification(is_sandbox=True) + def test_send_android_push_notification(self): sns_client = Mock() sns_test_message_id = "test_message_" + str(randint(0, 9999)) @@ -309,6 +324,23 @@ def test_api_create_device(self): device = self.user.devices.get(**data) self.assertEqual(response.data, DeviceSerializer(device).data) + def test_api_update_device(self): + from .serializers import DeviceSerializer + + data = { + "push_token": "test_ios_push_token2", + "platform": Device.PLATFORM_IOS + } + response = self.client.post(self.create_delete_url, data=data) + self.assertEqual(response.status_code, self.status.HTTP_201_CREATED) + device = self.user.devices.get(**data) + self.assertEqual(response.data, DeviceSerializer(device).data) + date_updated = device.date_updated + # Update device. + self.client.post(self.create_delete_url, data=data) + device.refresh_from_db() + self.assertGreater(device.date_updated, date_updated) + def test_api_delete_device(self): non_device_owner_client, non_device_owner = self.create_test_user_client() diff --git a/test_app/settings/local.py b/test_app/settings/local.py index f96161f..7a44a8d 100644 --- a/test_app/settings/local.py +++ b/test_app/settings/local.py @@ -13,7 +13,7 @@ "AWS_ACCESS_KEY_ID": "", "AWS_SECRET_ACCESS_KEY": "", "SNS_IOS_APPLICATION_ARN": "test_ios_arn", - "SNS_IOS_SANDBOX_ENABLED": False, + "SNS_IOS_SANDBOX_APPLICATION_ARN": "test_ios_sandbox_arn", "SNS_ANDROID_APPLICATION_ARN": "test_android_arn", "DEFAULT_SOUND": "", "DEVICE_MODEL": "devices.Device", From 0d912ef5821a100ba7bb0743dfeebf12c3c0e375 Mon Sep 17 00:00:00 2001 From: Ali Date: Wed, 14 Oct 2020 19:27:44 +0300 Subject: [PATCH 2/5] change travis dist --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f9f3305..cb61693 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python -dist: xenial +dist: bionic python: - "2.7" From 4b86dc83794ab9c9e9299c037aec1924b7b4bc7f Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 15 Oct 2020 16:11:44 +0300 Subject: [PATCH 3/5] improve testing environment --- Dockerfile | 9 +--- README.md | 41 +++++++++++++++++++ django_sloop/admin.py | 15 +------ requirements.txt | 5 ++- requirements_dev.txt | 6 +-- .../0002_add_field_is_sandbox_enabled.py | 18 ++++++++ tox.ini | 35 +++++++--------- 7 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 test_app/devices/migrations/0002_add_field_is_sandbox_enabled.py diff --git a/Dockerfile b/Dockerfile index 26d2508..663e9b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,16 +8,11 @@ RUN apt-get update && apt-get install -y \ python3.5 \ python3.6 \ python3.7 \ - libpq-dev \ + python3.8 \ gdal-bin \ - python3-distutils \ python3-pip WORKDIR /app -COPY requirements.txt . -COPY requirements_dev.txt . - RUN pip3 install --upgrade pip -RUN pip3 install tox -RUN pip3 install -r requirements_dev.txt \ No newline at end of file +RUN pip3 install tox \ No newline at end of file diff --git a/README.md b/README.md index 64908a2..a090d2f 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,44 @@ Done! **Endpoint details will be available in the projects api documentation as well. There can be project level changes so please go to projects api documentation.** + +CONTRIBUTION +================= + +**TESTS** +- Make sure that you add the test for contributed field to test/test_fields.py +and run with command before sending a pull request: + +```bash +$ pip install tox # if not already installed +$ tox +``` + +Or, if you prefer using Docker (recommended): + +```bash +docker build -t django-sloop . +docker run -v $(pwd):/app -it django-sloop /bin/bash +tox +``` + +**README** +- Make sure that you add the documentation for the field added to README.md + + +LICENSE +==================== + +Copyright DRF EXTRA FIELDS HIPO + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/django_sloop/admin.py b/django_sloop/admin.py index 2863b07..b664fa4 100644 --- a/django_sloop/admin.py +++ b/django_sloop/admin.py @@ -9,7 +9,6 @@ import json -from django_sloop.handlers import SNSHandler from django_sloop.models import PushMessage from django.utils.translation import ugettext_lazy as _ @@ -149,7 +148,7 @@ class PushMessageAdmin(admin.ModelAdmin): search_fields = ["body", "sns_message_id"] list_display = ["id", "body", "error_message", "device", "sns_message_id", "date_created", "date_updated"] - readonly_fields = ["id", "device", "body", "data", "sns_message_id", "sns_response", "payload", "date_created", "date_updated"] + readonly_fields = ["id", "device", "body", "data", "sns_message_id", "sns_response", "payload", "date_created", "date_updated"] actions = ["resend_push_notification"] @@ -158,18 +157,6 @@ def error_message(self, obj): if error: return error.get("Message") - def resend_push_notification(self, request, queryset): - if queryset.count() > 1: - messages.add_message(request, messages.ERROR, _("You can only send one push message at a time.")) - push_message = queryset.get() - - try: - handler = SNSHandler(device=push_message.device) - handler._send_payload(data=json.loads(push_message.data)) - messages.add_message(request, messages.SUCCESS, _("Push message has been sent.")) - except Exception as exc: - messages.add_message(request, messages.ERROR, str(exc)) - def resend_push_notification(self, request, queryset): if queryset.count() > 1: messages.add_message(request, messages.ERROR, _("You can only send one push message at a time.")) diff --git a/requirements.txt b/requirements.txt index fc7699a..58238ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -Django >= 1.11 +djangorestframework>=3 boto3==1.9.178 -celery >= 4 \ No newline at end of file +psycopg2-binary +celery>=4 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 3549d35..f9457b5 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -djangorestframework>=3 -mock -psycopg2-binary ipython +pytest-django +pytest-cov +mock diff --git a/test_app/devices/migrations/0002_add_field_is_sandbox_enabled.py b/test_app/devices/migrations/0002_add_field_is_sandbox_enabled.py new file mode 100644 index 0000000..2f4e5f2 --- /dev/null +++ b/test_app/devices/migrations/0002_add_field_is_sandbox_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2020-10-13 12:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('devices', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='is_sandbox_enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/tox.ini b/tox.ini index 5c66114..214792e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,34 +1,29 @@ [tox] - -# https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django envlist = - py27-drf3-django111, - py{35,36,37}-drf3-django{111,20,21,22}, - lint + py{36,37}-drf3-django{22}, + py{36,37,38}-drf3-django{30,31} + flake8 [testenv] deps = - django111: Django>=1.11,<2.0 - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 django22: Django>=2.2,<2.3 + django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.2 drf3: djangorestframework>=3 - pytest-django - pytest-cov - boto3==1.9.178 - celery >= 4 - mock - psycopg2-binary + -r requirements_dev.txt + commands = - pytest {posargs} --cov-report=xml --cov + py.test {posargs} --cov-report=xml --cov passenv = CI TRAVIS TRAVIS_* -[testenv:lint] -skip_install = true -deps = - flake8 +[testenv:flake8] +deps = flake8 commands = - flake8 django_sloop test_app setup.py \ No newline at end of file + flake8 django_sloop test_app +passenv = + CI + TRAVIS + TRAVIS_* \ No newline at end of file From c19c8915c8e987ecd30f07e4d63c0e70f4389c83 Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 15 Oct 2020 16:13:21 +0300 Subject: [PATCH 4/5] improve tox.ini --- requirements.txt | 1 + tox.ini | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 58238ac..3cdee49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +Django>=2.2 djangorestframework>=3 boto3==1.9.178 psycopg2-binary diff --git a/tox.ini b/tox.ini index 214792e..9e3cc2b 100644 --- a/tox.ini +++ b/tox.ini @@ -10,8 +10,12 @@ deps = django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 drf3: djangorestframework>=3 - -r requirements_dev.txt - + pytest-django + pytest-cov + boto3==1.9.178 + celery >= 4 + mock + psycopg2-binary commands = py.test {posargs} --cov-report=xml --cov passenv = From 55aa16a688f24c7185e69d45d253f033efc9a564 Mon Sep 17 00:00:00 2001 From: Ali Date: Thu, 15 Oct 2020 16:17:02 +0300 Subject: [PATCH 5/5] change travis python versions --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index cb61693..bde1cf6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,9 @@ language: python dist: bionic python: - - "2.7" - - "3.5" - "3.6" - "3.7" + - "3.8" install: - pip install tox-travis