fcm-django 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fcm_django/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ __author__ = "xTrinch"
2
+ __email__ = "mojca.rojko@gmail.com"
3
+ __version__ = "3.2.0"
4
+
5
+
6
+ class NotificationError(Exception):
7
+ pass
8
+
9
+
10
+ try:
11
+ from django import VERSION as DJANGO_VERSION
12
+
13
+ if DJANGO_VERSION < (3, 2):
14
+ default_app_config = "fcm_django.apps.FcmDjangoConfig"
15
+ except ImportError:
16
+ pass
fcm_django/admin.py ADDED
@@ -0,0 +1,278 @@
1
+ from typing import Union
2
+
3
+ import swapper
4
+ from django.apps import apps
5
+ from django.contrib import admin, messages
6
+ from django.utils.translation import gettext_lazy as _
7
+ from django.utils.translation import ngettext_lazy
8
+ from firebase_admin.exceptions import FirebaseError
9
+ from firebase_admin.messaging import (
10
+ ErrorInfo,
11
+ Message,
12
+ Notification,
13
+ SendResponse,
14
+ TopicManagementResponse,
15
+ )
16
+
17
+ from fcm_django.models import fcm_error_list
18
+ from fcm_django.settings import FCM_DJANGO_SETTINGS as SETTINGS
19
+ from fcm_django.types import FirebaseResponseDict
20
+
21
+ User = apps.get_model(*SETTINGS["USER_MODEL"].split("."))
22
+
23
+ FCMDevice = swapper.load_model("fcm_django", "fcmdevice")
24
+
25
+
26
+ class DeviceAdmin(admin.ModelAdmin):
27
+ list_display = (
28
+ "__str__",
29
+ "device_id",
30
+ "name",
31
+ "type",
32
+ "user",
33
+ "active",
34
+ "date_created",
35
+ )
36
+ list_filter = (
37
+ "active",
38
+ "type",
39
+ )
40
+ actions = (
41
+ "send_message",
42
+ "send_bulk_message",
43
+ "subscribe_to_topic",
44
+ "bulk_subscribe_to_topic",
45
+ "unsubscribe_to_topic",
46
+ "bulk_unsubscribe_to_topic",
47
+ "send_topic_message",
48
+ "enable",
49
+ "disable",
50
+ )
51
+ raw_id_fields = ("user",)
52
+ list_select_related = ("user",)
53
+
54
+ def get_search_fields(self, request):
55
+ if hasattr(User, "USERNAME_FIELD"):
56
+ return "name", "device_id", f"user__{User.USERNAME_FIELD}"
57
+ else:
58
+ return "name", "device_id"
59
+
60
+ def _send_deactivated_message(
61
+ self,
62
+ request,
63
+ response: Union[
64
+ FirebaseResponseDict,
65
+ list[FirebaseResponseDict],
66
+ list[tuple[SendResponse, str]],
67
+ ],
68
+ total_failure: int,
69
+ is_topic: bool,
70
+ ):
71
+ if total_failure == 0:
72
+ return
73
+ if is_topic:
74
+ message = ngettext_lazy(
75
+ "A device failed to un/subscribe to topic. %(count)d device was "
76
+ "marked as inactive.",
77
+ "Some devices failed to un/subscribe to topic. %(count)d devices "
78
+ "were marked as inactive.",
79
+ total_failure,
80
+ )
81
+ else:
82
+ message = ngettext_lazy(
83
+ "A message failed to send. %(count)d device was marked as " "inactive.",
84
+ "Some messages failed to send. %(count)d devices were marked as "
85
+ "inactive.",
86
+ total_failure,
87
+ )
88
+ self.message_user(
89
+ request,
90
+ message % {"count": total_failure},
91
+ level=messages.WARNING,
92
+ )
93
+
94
+ def _get_to_str_obj(obj):
95
+ if isinstance(obj, SendResponse):
96
+ return obj.exception
97
+ elif isinstance(obj, TopicManagementResponse):
98
+ return obj.errors
99
+ return obj
100
+
101
+ def _print_responses(_response):
102
+ __error_list = fcm_error_list + [ErrorInfo]
103
+ # TODO Aggregate error response text. Each firebase error
104
+ # has multiple response texts too
105
+ [
106
+ self.message_user(
107
+ request,
108
+ (
109
+ _("%(response)s (Registration ID/Tokens: %(reg_id)s)")
110
+ % {"response": _get_to_str_obj(x), "reg_id": reg_id}
111
+ ),
112
+ level=messages.WARNING,
113
+ )
114
+ for x, reg_id in _response
115
+ if type(_get_to_str_obj(x)) in __error_list
116
+ ]
117
+
118
+ if isinstance(response, list):
119
+ # Our custom list of single responses
120
+ _print_responses(response)
121
+ elif isinstance(response, FirebaseResponseDict):
122
+ # technically, type should be: FirebaseResponseDict not just dict
123
+ _print_responses(
124
+ zip(
125
+ response.response.responses,
126
+ response.deactivated_registration_ids,
127
+ ),
128
+ )
129
+ else:
130
+ raise NotImplementedError
131
+
132
+ def send_messages(self, request, queryset, bulk=False):
133
+ """
134
+ Provides error handling for DeviceAdmin send_message and
135
+ send_bulk_message methods.
136
+ """
137
+ total_failure = 0
138
+ single_responses: list[tuple[SendResponse, str]] = []
139
+
140
+ try:
141
+ for device in queryset:
142
+ device: "FCMDevice"
143
+ if bulk:
144
+ response = queryset.send_message(
145
+ Message(
146
+ notification=Notification(
147
+ title="Test notification", body="Test bulk notification"
148
+ )
149
+ )
150
+ )
151
+ total_failure = len(response.deactivated_registration_ids)
152
+ return self._send_deactivated_message(
153
+ request, response, total_failure, False
154
+ )
155
+ else:
156
+ response = device.send_message(
157
+ Message(
158
+ notification=Notification(
159
+ title="Test notification",
160
+ body="Test single notification",
161
+ )
162
+ )
163
+ )
164
+ single_responses.append((response, device.registration_id))
165
+ if type(response) != SendResponse:
166
+ total_failure += 1
167
+ except FirebaseError as exc:
168
+ self.message_user(request, str(exc), level=messages.ERROR)
169
+ return
170
+
171
+ self._send_deactivated_message(request, single_responses, total_failure, False)
172
+
173
+ def send_message(self, request, queryset):
174
+ self.send_messages(request, queryset)
175
+
176
+ send_message.short_description = _("Send test notification")
177
+
178
+ def send_bulk_message(self, request, queryset):
179
+ self.send_messages(request, queryset, True)
180
+
181
+ send_bulk_message.short_description = _("Send test notification in bulk")
182
+
183
+ def handle_topic_subscription(
184
+ self, request, queryset, should_subscribe: bool, bulk: bool = False
185
+ ):
186
+ """
187
+ Provides error handling for DeviceAdmin bulk_un/subscribe_to_topic and
188
+ un/subscribe_to_topic methods.
189
+ """
190
+ total_failure = 0
191
+ single_responses = []
192
+
193
+ for device in queryset:
194
+ device: "FCMDevice"
195
+ if bulk:
196
+ response: "FirebaseResponseDict" = queryset.handle_topic_subscription(
197
+ should_subscribe,
198
+ "test-topic",
199
+ )
200
+ total_failure = response.response.failure_count
201
+ single_responses = [
202
+ (x, response.registration_ids_sent[x.index])
203
+ for x in response.response.errors
204
+ ]
205
+ break
206
+ else:
207
+ response = device.handle_topic_subscription(
208
+ should_subscribe,
209
+ "test-topic",
210
+ )
211
+ single_responses.append(
212
+ (
213
+ (
214
+ response.response.errors[0]
215
+ if len(response.response.errors) > 0
216
+ else "Success"
217
+ ),
218
+ device.registration_id,
219
+ )
220
+ )
221
+ total_failure += len(response.deactivated_registration_ids)
222
+
223
+ self._send_deactivated_message(request, single_responses, total_failure, True)
224
+
225
+ def subscribe_to_topic(self, request, queryset):
226
+ self.handle_topic_subscription(request, queryset, True)
227
+
228
+ subscribe_to_topic.short_description = _("Subscribe to test topic")
229
+
230
+ def bulk_subscribe_to_topic(self, request, queryset):
231
+ self.handle_topic_subscription(request, queryset, True, True)
232
+
233
+ bulk_subscribe_to_topic.short_description = _("Subscribe to test topic in bulk")
234
+
235
+ def unsubscribe_to_topic(self, request, queryset):
236
+ self.handle_topic_subscription(request, queryset, False)
237
+
238
+ unsubscribe_to_topic.short_description = _("Unsubscribe to test topic")
239
+
240
+ def bulk_unsubscribe_to_topic(self, request, queryset):
241
+ self.handle_topic_subscription(request, queryset, False, True)
242
+
243
+ bulk_unsubscribe_to_topic.short_description = _("Unsubscribe to test topic in bulk")
244
+
245
+ def handle_send_topic_message(self, request, queryset):
246
+ try:
247
+ FCMDevice.send_topic_message(
248
+ Message(
249
+ notification=Notification(
250
+ title="Test notification", body="Test single notification"
251
+ )
252
+ ),
253
+ "test-topic",
254
+ )
255
+ except FirebaseError as exc:
256
+ self.message_user(request, str(exc), level=messages.ERROR)
257
+
258
+ def send_topic_message(self, request, queryset):
259
+ self.handle_send_topic_message(request, queryset)
260
+
261
+ send_topic_message.short_description = _("Send message test topic")
262
+
263
+ def enable(self, request, queryset):
264
+ queryset.update(active=True)
265
+
266
+ enable.short_description = _("Enable selected devices")
267
+
268
+ def disable(self, request, queryset):
269
+ queryset.deactivate(
270
+ reason="manual_disable",
271
+ source="admin_action",
272
+ metadata={},
273
+ )
274
+
275
+ disable.short_description = _("Disable selected devices")
276
+
277
+
278
+ admin.site.register(FCMDevice, DeviceAdmin)
@@ -0,0 +1,10 @@
1
+ from django.conf import settings
2
+
3
+ if "tastypie" in settings.INSTALLED_APPS:
4
+ # Tastypie resources are importable from the api package level (backwards compatibility)
5
+ from .tastypie import APNSDeviceAuthenticatedResource, FCMDeviceResource
6
+
7
+ __all__ = [
8
+ "APNSDeviceAuthenticatedResource",
9
+ "FCMDeviceResource",
10
+ ]
@@ -0,0 +1,190 @@
1
+ import swapper
2
+ from django.db.models import Q
3
+ from rest_framework import permissions, status
4
+ from rest_framework.mixins import CreateModelMixin
5
+ from rest_framework.response import Response
6
+ from rest_framework.serializers import ModelSerializer, Serializer, ValidationError
7
+ from rest_framework.viewsets import GenericViewSet, ModelViewSet
8
+
9
+ from fcm_django.settings import FCM_DJANGO_SETTINGS as SETTINGS
10
+
11
+ FCMDevice = swapper.load_model("fcm_django", "fcmdevice")
12
+
13
+
14
+ # Serializers
15
+ class DeviceSerializerMixin(ModelSerializer):
16
+ class Meta:
17
+ fields = (
18
+ "id",
19
+ "name",
20
+ "registration_id",
21
+ "device_id",
22
+ "active",
23
+ "date_created",
24
+ "type",
25
+ )
26
+ read_only_fields = ("date_created",)
27
+
28
+ extra_kwargs = {"active": {"default": True}}
29
+
30
+
31
+ class UniqueRegistrationSerializerMixin(Serializer):
32
+ def validate(self, attrs):
33
+ devices = None
34
+ primary_key = None
35
+ request_method = None
36
+
37
+ if self.initial_data.get("registration_id", None):
38
+ if self.instance:
39
+ request_method = "update"
40
+ primary_key = self.instance.id
41
+ else:
42
+ request_method = "create"
43
+ else:
44
+ if self.context["request"].method in ["PUT", "PATCH"]:
45
+ request_method = "update"
46
+ primary_key = self.instance.id
47
+ elif self.context["request"].method == "POST":
48
+ request_method = "create"
49
+
50
+ Device = self.Meta.model
51
+ # if request authenticated, unique together with registration_id and
52
+ # user
53
+ user = self.context["request"].user
54
+ registration_id = attrs.get("registration_id")
55
+
56
+ if request_method == "update":
57
+ if registration_id:
58
+ if user is not None and user.is_authenticated:
59
+ devices = Device.objects.filter(
60
+ registration_id=registration_id
61
+ ).exclude(id=primary_key)
62
+ if attrs.get("active", False):
63
+ devices.filter(~Q(user=user)).deactivate(
64
+ reason="duplicate_registration_id",
65
+ source="serializer_update",
66
+ metadata={
67
+ "request_method": request_method,
68
+ "target_user_id": user.id,
69
+ },
70
+ )
71
+ devices = devices.filter(user=user)
72
+ else:
73
+ devices = Device.objects.filter(
74
+ registration_id=registration_id
75
+ ).exclude(id=primary_key)
76
+ elif request_method == "create":
77
+ if user is not None and user.is_authenticated:
78
+ devices = Device.objects.filter(registration_id=registration_id)
79
+ devices.filter(~Q(user=user)).deactivate(
80
+ reason="duplicate_registration_id",
81
+ source="serializer_create",
82
+ metadata={
83
+ "request_method": request_method,
84
+ "target_user_id": user.id,
85
+ },
86
+ )
87
+ devices = devices.filter(user=user, active=True)
88
+ else:
89
+ devices = Device.objects.filter(registration_id=registration_id)
90
+
91
+ if devices:
92
+ raise ValidationError({"registration_id": "This field must be unique."})
93
+ return attrs
94
+
95
+
96
+ class FCMDeviceSerializer(ModelSerializer, UniqueRegistrationSerializerMixin):
97
+ class Meta(DeviceSerializerMixin.Meta):
98
+ model = FCMDevice
99
+
100
+ extra_kwargs = {"id": {"read_only": True, "required": False}}
101
+ extra_kwargs.update(DeviceSerializerMixin.Meta.extra_kwargs)
102
+
103
+
104
+ # Permissions
105
+ class IsOwner(permissions.BasePermission):
106
+ def has_object_permission(self, request, view, obj):
107
+ # must be the owner to view the object
108
+ return obj.user == request.user
109
+
110
+
111
+ # Mixins
112
+ class DeviceViewSetMixin:
113
+ lookup_field = "registration_id"
114
+
115
+ def create(self, request, *args, **kwargs):
116
+ serializer = None
117
+ is_update = False
118
+ if (
119
+ SETTINGS.get("UPDATE_ON_DUPLICATE_REG_ID")
120
+ and "registration_id" in request.data
121
+ ):
122
+ instance = self.queryset.model.objects.filter(
123
+ registration_id=request.data["registration_id"]
124
+ ).first()
125
+ if instance:
126
+ serializer = self.get_serializer(instance, data=request.data)
127
+ is_update = True
128
+ if not serializer:
129
+ serializer = self.get_serializer(data=request.data)
130
+
131
+ serializer.is_valid(raise_exception=True)
132
+ if is_update:
133
+ self.perform_update(serializer)
134
+ return Response(serializer.data)
135
+ else:
136
+ self.perform_create(serializer)
137
+ headers = self.get_success_headers(serializer.data)
138
+ return Response(
139
+ serializer.data, status=status.HTTP_201_CREATED, headers=headers
140
+ )
141
+
142
+ def perform_create(self, serializer):
143
+ if self.request.user.is_authenticated:
144
+ if SETTINGS["ONE_DEVICE_PER_USER"] and self.request.data.get(
145
+ "active", True
146
+ ):
147
+ FCMDevice.objects.filter(user=self.request.user).deactivate(
148
+ reason="one_device_per_user",
149
+ source="perform_create",
150
+ metadata={"user_id": self.request.user.id},
151
+ )
152
+ return serializer.save(user=self.request.user)
153
+ return serializer.save()
154
+
155
+ def perform_update(self, serializer):
156
+ if self.request.user.is_authenticated:
157
+ if SETTINGS["ONE_DEVICE_PER_USER"] and self.request.data.get(
158
+ "active", False
159
+ ):
160
+ FCMDevice.objects.filter(user=self.request.user).deactivate(
161
+ reason="one_device_per_user",
162
+ source="perform_update",
163
+ metadata={"user_id": self.request.user.id},
164
+ )
165
+
166
+ return serializer.save(user=self.request.user)
167
+ return serializer.save()
168
+
169
+
170
+ class AuthorizedMixin:
171
+ permission_classes = (permissions.IsAuthenticated, IsOwner)
172
+
173
+ def get_queryset(self):
174
+ # filter all devices to only those belonging to the current user
175
+ return self.queryset.filter(user=self.request.user)
176
+
177
+
178
+ # ViewSets
179
+ class FCMDeviceViewSet(DeviceViewSetMixin, ModelViewSet):
180
+ queryset = FCMDevice.objects.order_by("-id")
181
+ serializer_class = FCMDeviceSerializer
182
+
183
+
184
+ class FCMDeviceCreateOnlyViewSet(DeviceViewSetMixin, CreateModelMixin, GenericViewSet):
185
+ queryset = FCMDevice.objects.all()
186
+ serializer_class = FCMDeviceSerializer
187
+
188
+
189
+ class FCMDeviceAuthorizedViewSet(AuthorizedMixin, FCMDeviceViewSet):
190
+ pass
@@ -0,0 +1,26 @@
1
+ import swapper
2
+ from tastypie.authentication import BasicAuthentication
3
+ from tastypie.authorization import Authorization
4
+ from tastypie.resources import ModelResource
5
+
6
+ FCMDevice = swapper.load_model("fcm_django", "fcmdevice")
7
+
8
+
9
+ class FCMDeviceResource(ModelResource):
10
+ class Meta:
11
+ authorization = Authorization()
12
+ queryset = FCMDevice.objects.all()
13
+ resource_name = "device/apns"
14
+
15
+
16
+ class APNSDeviceAuthenticatedResource(FCMDeviceResource):
17
+ # user = ForeignKey(UserResource, "user")
18
+
19
+ class Meta(FCMDeviceResource.Meta):
20
+ authentication = BasicAuthentication()
21
+
22
+ # authorization = SameUserAuthorization()
23
+
24
+ def obj_create(self, bundle, **kwargs):
25
+ # See https://github.com/toastdriven/django-tastypie/issues/854
26
+ return super().obj_create(bundle, user=bundle.request.user, **kwargs)
fcm_django/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+ from fcm_django.settings import FCM_DJANGO_SETTINGS as SETTINGS
4
+
5
+
6
+ class FcmDjangoConfig(AppConfig):
7
+ name = "fcm_django"
8
+ verbose_name = SETTINGS["APP_VERBOSE_NAME"]
fcm_django/fields.py ADDED
@@ -0,0 +1,127 @@
1
+ import re
2
+ import struct
3
+
4
+ from django import forms
5
+ from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
6
+ from django.db import connection, models
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ UNSIGNED_64BIT_INT_MIN_VALUE = 0
10
+ UNSIGNED_64BIT_INT_MAX_VALUE = 2**64 - 1
11
+
12
+ __all__ = ["HexadecimalField", "HexIntegerField"]
13
+
14
+ hex_re = re.compile(r"^(([0-9A-f])|(0x[0-9A-f]))+$")
15
+ signed_integer_engines = [
16
+ "django.db.backends.postgresql",
17
+ "django.db.backends.postgresql_psycopg2",
18
+ "django.contrib.gis.db.backends.postgis",
19
+ "django.db.backends.sqlite3",
20
+ ]
21
+
22
+
23
+ def _using_signed_storage():
24
+ return connection.settings_dict["ENGINE"] in signed_integer_engines
25
+
26
+
27
+ def _signed_to_unsigned_integer(value):
28
+ return struct.unpack("Q", struct.pack("q", value))[0]
29
+
30
+
31
+ def _unsigned_to_signed_integer(value):
32
+ return struct.unpack("q", struct.pack("Q", value))[0]
33
+
34
+
35
+ def _hex_string_to_unsigned_integer(value):
36
+ return int(value, 16)
37
+
38
+
39
+ def _unsigned_integer_to_hex_string(value):
40
+ return hex(value).rstrip("L")
41
+
42
+
43
+ class HexadecimalField(forms.CharField):
44
+ """
45
+ A form field that accepts only hexadecimal numbers
46
+ """
47
+
48
+ def __init__(self, *args, **kwargs):
49
+ self.default_validators = [
50
+ RegexValidator(hex_re, _("Enter a valid hexadecimal number"), "invalid")
51
+ ]
52
+ super().__init__(*args, **kwargs)
53
+
54
+ def prepare_value(self, value):
55
+ # converts bigint from db to hex before it is displayed in admin
56
+ if (
57
+ value
58
+ and not isinstance(value, str)
59
+ and connection.vendor in ("mysql", "sqlite")
60
+ ):
61
+ value = _unsigned_integer_to_hex_string(value)
62
+ return super(forms.CharField, self).prepare_value(value)
63
+
64
+
65
+ class HexIntegerField(models.BigIntegerField):
66
+ """
67
+ This field stores a hexadecimal *string* of up to 64 bits as an unsigned integer
68
+ on *all* backends including postgres.
69
+
70
+ Reasoning: Postgres only supports signed bigints. Since we don't care about
71
+ signedness, we store it as signed, and cast it to unsigned when we deal with
72
+ the actual value (with struct)
73
+
74
+ On sqlite and mysql, native unsigned bigint types are used. In all cases, the
75
+ value we deal with in python is always in hex.
76
+ """
77
+
78
+ validators = [
79
+ MinValueValidator(UNSIGNED_64BIT_INT_MIN_VALUE),
80
+ MaxValueValidator(UNSIGNED_64BIT_INT_MAX_VALUE),
81
+ ]
82
+
83
+ def db_type(self, connection):
84
+ engine = connection.settings_dict["ENGINE"]
85
+ if "mysql" in engine:
86
+ return "bigint unsigned"
87
+ elif "sqlite" in engine:
88
+ return "UNSIGNED BIG INT"
89
+ else:
90
+ return super().db_type(connection=connection)
91
+
92
+ def get_prep_value(self, value):
93
+ """Return the integer value to be stored from the hex string"""
94
+ if value is None or value == "":
95
+ return None
96
+ if isinstance(value, str):
97
+ value = _hex_string_to_unsigned_integer(value)
98
+ if _using_signed_storage():
99
+ value = _unsigned_to_signed_integer(value)
100
+ return value
101
+
102
+ def from_db_value(self, value, expression, connection, context):
103
+ """Return an unsigned int representation from all db backends"""
104
+ if value is None:
105
+ return value
106
+ if _using_signed_storage():
107
+ value = _signed_to_unsigned_integer(value)
108
+ return value
109
+
110
+ def to_python(self, value):
111
+ """Return a str representation of the hexadecimal"""
112
+ if isinstance(value, str):
113
+ return value
114
+ if value is None:
115
+ return value
116
+ return _unsigned_integer_to_hex_string(value)
117
+
118
+ def formfield(self, **kwargs):
119
+ defaults = {"form_class": HexadecimalField}
120
+ defaults.update(kwargs)
121
+ # yes, that super call is right
122
+ return super(models.IntegerField, self).formfield(**defaults)
123
+
124
+ def run_validators(self, value):
125
+ # make sure validation is performed on integer value not string value
126
+ value = _hex_string_to_unsigned_integer(value)
127
+ return super(models.BigIntegerField, self).run_validators(value)