diff --git a/.travis.yml b/.travis.yml index f9f3305..bde1cf6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,11 @@ language: python -dist: xenial +dist: bionic python: - - "2.7" - - "3.5" - "3.6" - "3.7" + - "3.8" install: - pip install tox-travis 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 b294124..a090d2f 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,76 @@ 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.** + +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 e9dcb1d..b664fa4 100644 --- a/django_sloop/admin.py +++ b/django_sloop/admin.py @@ -10,6 +10,7 @@ import json from django_sloop.models import PushMessage +from django.utils.translation import ugettext_lazy as _ class PushNotificationForm(forms.Form): @@ -121,15 +122,69 @@ 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() + 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/requirements.txt b/requirements.txt index fc7699a..3cdee49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ -Django >= 1.11 +Django>=2.2 +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/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", diff --git a/tox.ini b/tox.ini index 5c66114..9e3cc2b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,14 @@ [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 @@ -20,15 +17,17 @@ deps = mock psycopg2-binary 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