diff --git a/README.rst b/README.rst index 648ea8d8..dfa52c11 100644 --- a/README.rst +++ b/README.rst @@ -380,6 +380,74 @@ logins on the same device, you do not wish the old user to receive messages whil Via DRF, any creation of device with an already existing registration ID will be transformed into an update. If done manually, you are responsible for deleting the old device entry. +Using custom FCMDevice model +---------------------------- + +If there's a need to store additional information or change type of fields in the FCMDevice model. +You could simple override this model. To do this, inherit your model from the AbstractFCMDevice class. + +In your ``your_app/models.py``: + +.. code-block:: python + + import uuid + from django.db import models + from fcm_django.models import AbstractFCMDevice + + + class CustomDevice(AbstractFCMDevice): + # fields could be overwritten + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # could be added new fields + updated_at = models.DateTimeField(auto_now=True) + +In your ``settings.py``: + +.. code-block:: python + + FCM_DJANGO_FCMDEVICE_MODEL = "your_app.CustomDevice" + + +In the DB will be two tables one that was created by this package and other your own. New data will appears only in your own table. +If you don't want default table appears in the DB then you should remove ``fcm_django`` out of ``INSTALLED_APPS`` at ``settings.py``: + +.. code-block:: python + + INSTALLED_APPS = ( + ... + # "fcm_django", - remove this line + "your_app", # your app should appears + ... + ) + +After setup your own ``Model`` don't forget to create ``migrations`` for your app and call ``migrate`` command. + +After removing ``"fcm_django"`` out of ``INSTALLED_APPS``. You will need to re-register the Device in order to see it in the admin panel. +This can be accomplished as follows at ``your_app/admin.py``: + +.. code-block:: python + + from django.contrib import admin + + from fcm_django.admin import DeviceAdmin + from your_app.models import CustomDevice + + + admin.site.unregister(CustomDevice) + admin.site.register(CustomDevice, DeviceAdmin) + + +If you choose to move forward with swapped models then: + +1. On existed project you have to keep in mind there are required manual work to move data from one table to anther. +2. If there's any tables with FK to swapped model then you have to deal with them on your own. + +Note: This functionality based on `Swapper `_ that based on functionality +that allow to use a `custom User model `_. +So this functionality have the same limitations. +The most is important limitation it is that is difficult to start out with a default (non-swapped) model +and then later to switch to a swapped implementation without doing some migration hacking. + Python 3 support ---------------- - ``fcm-django`` is fully compatible with Python 3.7+ @@ -407,3 +475,17 @@ Contributing To setup the development environment, simply do ``pip install -r requirements_dev.txt`` To manually run the pre-commit hook, run `pre-commit run --all-files`. + +Because there's possibility to use swapped models therefore tests contains two config files: + +1. with default settings and non swapped models ``settings/default.py`` +2. and with overwritten settings only that required by swapper - ``settings/swap.py`` + +To run tests locally you could use ``pytest``, and if you need to check migrations on different DB then you have to specify environment variable ``DATABASE_URL`` ie + +.. code-block:: console + + export DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres + export DJANGO_SETTINGS_MODULE=tests.settings.default + # or export DJANGO_SETTINGS_MODULE=tests.settings.swap + pytest diff --git a/docs/index.rst b/docs/index.rst index 43dbeef4..6d5f3450 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -201,6 +201,14 @@ Sending messages in bulk # Or (send_message parameters include: messages, dry_run, app) FCMDevice.objects.send_message(Message(...)) + Sending messages raises all the errors that ``firebase-admin`` raises, so make sure +they are caught and dealt with in your application code: + +- ``FirebaseError`` – If an error occurs while sending the message to the FCM service. +- ``ValueError`` – If the input arguments are invalid. + +For more info, see https://firebase.google.com/docs/reference/admin/python/firebase_admin.messaging#firebase_admin.messaging.BatchResponse + Subscribing or Unsubscribing Users to topic ------------------------------------------- @@ -372,6 +380,74 @@ logins on the same device, you do not wish the old user to receive messages whil Via DRF, any creation of device with an already existing registration ID will be transformed into an update. If done manually, you are responsible for deleting the old device entry. +Using custom FCMDevice model +---------------------------- + +If there's a need to store additional information or change type of fields in the FCMDevice model. +You could simple override this model. To do this, inherit your model from the AbstractFCMDevice class. + +In your ``your_app/models.py``: + +.. code-block:: python + + import uuid + from django.db import models + from fcm_django.models import AbstractFCMDevice + + + class CustomDevice(AbstractFCMDevice): + # fields could be overwritten + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # could be added new fields + updated_at = models.DateTimeField(auto_now=True) + +In your ``settings.py``: + +.. code-block:: python + + FCM_DJANGO_FCMDEVICE_MODEL = "your_app.CustomDevice" + + +In the DB will be two tables one that was created by this package and other your own. New data will appears only in your own table. +If you don't want default table appears in the DB then you should remove ``fcm_django`` out of ``INSTALLED_APPS`` at ``settings.py``: + +.. code-block:: python + + INSTALLED_APPS = ( + ... + # "fcm_django", - remove this line + "your_app", # your app should appears + ... + ) + +After setup your own ``Model`` don't forget to create ``migrations`` for your app and call ``migrate`` command. + +After removing ``"fcm_django"`` out of ``INSTALLED_APPS``. You will need to re-register the Device in order to see it in the admin panel. +This can be accomplished as follows at ``your_app/admin.py``: + +.. code-block:: python + + from django.contrib import admin + + from fcm_django.admin import DeviceAdmin + from your_app.models import CustomDevice + + + admin.site.unregister(CustomDevice) + admin.site.register(CustomDevice, DeviceAdmin) + + +If you choose to move forward with swapped models then: + +1. On existed project you have to keep in mind there are required manual work to move data from one table to anther. +2. If there's any tables with FK to swapped model then you have to deal with them on your own. + +Note: This functionality based on `Swapper `_ that based on functionality +that allow to use a `custom User model `_. +So this functionality have the same limitations. +The most is important limitation it is that is difficult to start out with a default (non-swapped) model +and then later to switch to a swapped implementation without doing some migration hacking. + Python 3 support ---------------- - ``fcm-django`` is fully compatible with Python 3.7+ @@ -399,3 +475,17 @@ Contributing To setup the development environment, simply do ``pip install -r requirements_dev.txt`` To manually run the pre-commit hook, run `pre-commit run --all-files`. + +Because there's possibility to use swapped models therefore tests contains two config files: + +1. with default settings and non swapped models ``settings/default.py`` +2. and with overwritten settings only that required by swapper - ``settings/swap.py`` + +To run tests locally you could use ``pytest``, and if you need to check migrations on different DB then you have to specify environment variable ``DATABASE_URL`` ie + +.. code-block:: console + + export DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres + export DJANGO_SETTINGS_MODULE=tests.settings.default + # or export DJANGO_SETTINGS_MODULE=tests.settings.swap + pytest diff --git a/fcm_django/admin.py b/fcm_django/admin.py index f00d1be2..f3a6338d 100644 --- a/fcm_django/admin.py +++ b/fcm_django/admin.py @@ -1,5 +1,6 @@ from typing import List, Tuple, Union +import swapper from django.apps import apps from django.contrib import admin, messages from django.utils.translation import gettext_lazy as _ @@ -12,11 +13,13 @@ TopicManagementResponse, ) -from fcm_django.models import FCMDevice, FirebaseResponseDict, fcm_error_list +from fcm_django.models import FirebaseResponseDict, fcm_error_list from fcm_django.settings import FCM_DJANGO_SETTINGS as SETTINGS User = apps.get_model(*SETTINGS["USER_MODEL"].split(".")) +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") + class DeviceAdmin(admin.ModelAdmin): list_display = ( diff --git a/fcm_django/api/rest_framework.py b/fcm_django/api/rest_framework.py index 17c7a619..8fe9ac28 100644 --- a/fcm_django/api/rest_framework.py +++ b/fcm_django/api/rest_framework.py @@ -1,3 +1,4 @@ +import swapper from django.db.models import Q from rest_framework import permissions, status from rest_framework.mixins import CreateModelMixin @@ -5,9 +6,10 @@ from rest_framework.serializers import ModelSerializer, Serializer, ValidationError from rest_framework.viewsets import GenericViewSet, ModelViewSet -from fcm_django.models import FCMDevice from fcm_django.settings import FCM_DJANGO_SETTINGS as SETTINGS +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") + # Serializers class DeviceSerializerMixin(ModelSerializer): diff --git a/fcm_django/api/tastypie.py b/fcm_django/api/tastypie.py index 67aceb2a..def0dde5 100644 --- a/fcm_django/api/tastypie.py +++ b/fcm_django/api/tastypie.py @@ -1,8 +1,9 @@ +import swapper from tastypie.authentication import BasicAuthentication from tastypie.authorization import Authorization from tastypie.resources import ModelResource -from fcm_django.models import FCMDevice +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") class FCMDeviceResource(ModelResource): diff --git a/fcm_django/models.py b/fcm_django/models.py index 56eb4120..9a0038c5 100644 --- a/fcm_django/models.py +++ b/fcm_django/models.py @@ -1,6 +1,7 @@ from copy import copy from typing import List, NamedTuple, Sequence, Union +import swapper from django.db import models from django.utils.translation import gettext_lazy as _ from firebase_admin import messaging @@ -392,3 +393,6 @@ class Meta: indexes = [ models.Index(fields=["registration_id", "user"]), ] + + app_label = "fcm_django" + swappable = swapper.swappable_setting("fcm_django", "fcmdevice") diff --git a/pyproject.toml b/pyproject.toml index d21ad318..35865cee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [tool.pytest.ini_options] pythonpath = ["."] -DJANGO_SETTINGS_MODULE= "tests.testing_settings" +DJANGO_SETTINGS_MODULE = "tests.settings.default" diff --git a/requirements.txt b/requirements.txt index 06f5bb5e..e4049ccc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Django>=3.2 django-tastypie>=0.14.0 djangorestframework>=3.9.2 firebase-admin>=6.2,<7 +swapper>=1.3.0 diff --git a/setup.py b/setup.py index 33aa680c..33afd4d9 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ "fcm_django/migrations", ], python_requires=">=3.7", - install_requires=["firebase-admin>=6.2,<7", "Django"], + install_requires=["firebase-admin>=6.2,<7", "Django", "swapper"], author=fcm_django.__author__, author_email=fcm_django.__email__, classifiers=CLASSIFIERS, diff --git a/tests/conftest.py b/tests/conftest.py index d8557c3b..b0086e0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,14 @@ from unittest.mock import sentinel import pytest +import swapper from firebase_admin.exceptions import FirebaseError from firebase_admin.messaging import Message from pytest_mock import MockerFixture -from fcm_django.models import DeviceType, FCMDevice +from fcm_django.models import DeviceType + +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") @pytest.fixture @@ -59,11 +62,3 @@ def mock_firebase_send(mocker: MockerFixture, firebase_message_id_send): mock = mocker.patch("fcm_django.models.messaging.send") mock.return_value = firebase_message_id_send return mock - - -@pytest.fixture -def mock_fcm_device_deactivate(mocker: MockerFixture): - return mocker.patch( - "fcm_django.models.FCMDevice.deactivate_devices_with_error_result", - autospec=True, - ) diff --git a/tests/settings/__init__.py b/tests/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/testing_settings.py b/tests/settings/base.py similarity index 91% rename from tests/testing_settings.py rename to tests/settings/base.py index ed68f7b6..e2094300 100644 --- a/tests/testing_settings.py +++ b/tests/settings/base.py @@ -1,16 +1,13 @@ import dj_database_url -from firebase_admin import initialize_app SECRET_KEY = "ToP SeCrEt" - INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - "fcm_django", ] MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", @@ -19,7 +16,6 @@ ] DATABASES = {"default": dj_database_url.config(default="sqlite://")} - USE_TZ = True ROOT_URLCONF = "tests.urls" TEMPLATES = [ @@ -37,4 +33,3 @@ }, }, ] -FIREBASE_APP = initialize_app() diff --git a/tests/settings/default.py b/tests/settings/default.py new file mode 100644 index 00000000..234bd365 --- /dev/null +++ b/tests/settings/default.py @@ -0,0 +1,7 @@ +from .base import * + +INSTALLED_APPS += [ + "fcm_django", +] + +IS_SWAP = False # Only to distinguish which model is used in the tests diff --git a/tests/settings/swap.py b/tests/settings/swap.py new file mode 100644 index 00000000..4e0c84a8 --- /dev/null +++ b/tests/settings/swap.py @@ -0,0 +1,9 @@ +from .base import * + +INSTALLED_APPS += [ + "tests.swapped_models", +] + +FCM_DJANGO_FCMDEVICE_MODEL = "swapped_models.CustomDevice" + +IS_SWAP = True # Only to distinguish which model is used in the tests diff --git a/tests/swapped_models/__init__.py b/tests/swapped_models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/swapped_models/admin.py b/tests/swapped_models/admin.py new file mode 100644 index 00000000..6df5a006 --- /dev/null +++ b/tests/swapped_models/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from fcm_django.admin import DeviceAdmin + +from .models import CustomDevice + +admin.site.unregister(CustomDevice) +admin.site.register(CustomDevice, DeviceAdmin) diff --git a/tests/swapped_models/migrations/0001_initial.py b/tests/swapped_models/migrations/0001_initial.py new file mode 100644 index 00000000..ed553d08 --- /dev/null +++ b/tests/swapped_models/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# IMPORTANT +# This migration required to check functionality with swapped model. +# So if there are any changes in the Swapped Model then you have to regenerate this migration. + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CustomDevice", + fields=[ + ( + "name", + models.CharField( + blank=True, max_length=255, null=True, verbose_name="Name" + ), + ), + ( + "active", + models.BooleanField( + default=True, + help_text="Inactive devices will not be sent notifications", + verbose_name="Is active", + ), + ), + ( + "date_created", + models.DateTimeField( + auto_now_add=True, null=True, verbose_name="Creation date" + ), + ), + ( + "device_id", + models.CharField( + blank=True, + db_index=True, + help_text="Unique device identifier", + max_length=255, + null=True, + verbose_name="Device ID", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("ios", "ios"), + ("android", "android"), + ("web", "web"), + ], + max_length=10, + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("registration_id", models.CharField(max_length=515, unique=True)), + ("more_data", models.TextField()), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_query_name="fcmdevice", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["registration_id", "user"], + name="swapped_mod_registr_2cb0a2_idx", + ) + ], + }, + ), + ] diff --git a/tests/swapped_models/migrations/__init__.py b/tests/swapped_models/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/swapped_models/models.py b/tests/swapped_models/models.py new file mode 100644 index 00000000..5bbd3a43 --- /dev/null +++ b/tests/swapped_models/models.py @@ -0,0 +1,22 @@ +import uuid + +from django.db import models + +from fcm_django.models import AbstractFCMDevice + + +class CustomDevice(AbstractFCMDevice): + # fields could be overwritten + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # NOTE: the max_length could not be supported or enforced on the database level + # https://docs.djangoproject.com/en/4.2/ref/models/fields/#django.db.models.CharField.max_length + registration_id = models.CharField(unique=True, max_length=515) + # could be added new fields + more_data = models.TextField() + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + # could be added custom indexes + models.Index(fields=["registration_id", "user"]), + ] diff --git a/tests/test_admin.py b/tests/test_admin.py index c3d56afe..4e310388 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -3,7 +3,10 @@ @pytest.fixture def base_admin_url(settings) -> str: - return "/admin/fcm_django/fcmdevice/" + if settings.IS_SWAP: + return "/admin/swapped_models/customdevice/" + else: + return "/admin/fcm_django/fcmdevice/" @pytest.fixture(autouse=True) diff --git a/tests/test_api_rest_framework.py b/tests/test_api_rest_framework.py index 4c64b7d8..a2fc91c9 100644 --- a/tests/test_api_rest_framework.py +++ b/tests/test_api_rest_framework.py @@ -1,6 +1,11 @@ +import base64 + import pytest +import swapper + +from fcm_django.models import DeviceType -from fcm_django.models import DeviceType, FCMDevice +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") @pytest.mark.django_db diff --git a/tests/test_api_tastypie.py b/tests/test_api_tastypie.py index 528d33a2..b6129656 100644 --- a/tests/test_api_tastypie.py +++ b/tests/test_api_tastypie.py @@ -1,8 +1,11 @@ import base64 import pytest +import swapper -from fcm_django.models import DeviceType, FCMDevice +from fcm_django.models import DeviceType + +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") @pytest.mark.django_db diff --git a/tests/test_models.py b/tests/test_models.py index 2a6eece7..690b6c1d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,17 @@ from typing import Any, Optional from unittest.mock import MagicMock, sentinel +from uuid import UUID import pytest -from firebase_admin.exceptions import FirebaseError +import swapper +from django.conf import settings +from django.utils import timezone +from firebase_admin.exceptions import FirebaseError, InvalidArgumentError from firebase_admin.messaging import Message, SendResponse -from fcm_django.models import DeviceType, FCMDevice +from fcm_django.models import DeviceType + +FCMDevice = swapper.load_model("fcm_django", "fcmdevice") @pytest.mark.django_db @@ -23,6 +29,20 @@ def test_registration_id_size(): device.save() +@pytest.mark.django_db +def test_fields_on_the_device_can_be_redefined_by_swapped_model(fcm_device: FCMDevice): + assert isinstance(fcm_device.id, UUID if settings.IS_SWAP else int) + + +@pytest.mark.django_db +def test_fields_on_the_device_can_be_added_by_swapped_model(fcm_device: FCMDevice): + assert hasattr(fcm_device, "more_data") == settings.IS_SWAP + if settings.IS_SWAP: + before_update = timezone.now() + fcm_device.save() + assert before_update < fcm_device.updated_at < timezone.now() + + @pytest.mark.django_db class TestFCMDeviceSendMessage: def assert_sent_successfully( @@ -99,19 +119,41 @@ def test_firebase_error( message: Message, mock_firebase_send: MagicMock, firebase_error: FirebaseError, - mock_fcm_device_deactivate: MagicMock, ): """ - Ensure we call deactivate_devices_with_error_result and raise the FirebaseError + Ensure when happened unknown firebase error device is still active and raised the FirebaseError """ mock_firebase_send.side_effect = firebase_error with pytest.raises(FirebaseError, match=str(firebase_error)): fcm_device.send_message(message) - mock_fcm_device_deactivate.assert_called_once_with( - fcm_device, fcm_device.registration_id, firebase_error + fcm_device.refresh_from_db() + # device is still active because error is unknown + assert fcm_device.active + + def test_firebase_invalid_registration_error( + self, + fcm_device: FCMDevice, + message: Message, + mock_firebase_send: MagicMock, + ): + """ + Ensure when Invalid registration firebase error device is still active and raised the FirebaseError + """ + firebase_invalid_registration_error = InvalidArgumentError( + message="Error", cause="Invalid registration" ) + mock_firebase_send.side_effect = firebase_invalid_registration_error + + with pytest.raises( + FirebaseError, match=str(firebase_invalid_registration_error) + ): + fcm_device.send_message(message) + + fcm_device.refresh_from_db() + # ensure that device deactivated + assert not fcm_device.active class TestFCMDeviceSendTopicMessage: diff --git a/tox.ini b/tox.ini index ffa1cd20..a509e985 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,6 @@ python = 3.7: py37 3.8: py38 3.9: py39 - 3.9: py39 - 3.10: py310 3.10: py310 3.11: py311 @@ -27,6 +25,8 @@ deps = postgres: psycopg2-binary>=2.9.6 mariadb: mysqlclient>=2.1.1 setenv = + noswap: DJANGO_SETTINGS_MODULE=tests.settings.default + swap: DJANGO_SETTINGS_MODULE=tests.settings.swap postgres: DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres mariadb: DATABASE_URL=mysql://root@127.0.0.1:3306/mysql commands =