Skip to content

Commit

Permalink
[api] Added API endpoints for notifications
Browse files Browse the repository at this point in the history
Added API endpoint for listing and marking notifications
as read.
  • Loading branch information
pandafy committed Jun 19, 2020
1 parent 7edb1a1 commit f6a69c2
Show file tree
Hide file tree
Showing 15 changed files with 421 additions and 2 deletions.
87 changes: 87 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ Setup (integrate into an existing Django project)
'allauth',
'allauth.account',
'allauth.socialaccount',
# rest framework
'rest_framework',
'drf_yasg',
'openwisp_users',
'django.contrib.admin',
# notifications module
Expand Down Expand Up @@ -348,6 +351,73 @@ This setting takes the URL of the logo to be displayed on email notification.
**Note**: Provide a URL which points to the logo on your own web server. Ensure that the URL provided is
publicly accessible from the internet. Otherwise, the logo may not be displayed in email.

REST API
--------

Live documentation
~~~~~~~~~~~~~~~~~~

.. image:: docs/images/api-docs.png

A general live API documentation (following the OpenAPI specification) is available at ``/api/v1/docs/``.

Browsable web interface
~~~~~~~~~~~~~~~~~~~~~~~

.. image:: docs/images/api-ui.png

Additionally, opening any of the endpoints `listed below <#list-of-endpoints>`_
directly in the browser will show the `browsable API interface of Django-REST-Framework
<https://www.django-rest-framework.org/topics/browsable-api/>`_,
which makes it even easier to find out the details of each endpoint.

Authentication
~~~~~~~~~~~~~~

The API authentication is based on session based authentication via REST framework.
This authentication scheme uses Django's default session backend for authentication.

.. code-block:: text
http -a username:password <HTTP verb> <api url>
When browsing the API via the `Live documentation <#live-documentation>`_
or the `Browsable web interface <#browsable-web-interface>`_, you can use
the session authentication by logging in the django admin.

Pagination
~~~~~~~~~~

The *list* endpoint support the ``page_size`` parameter that allows paginating
the results in conjunction with the ``page`` parameter.

.. code-block:: text
GET /api/v1/openwisp_notifications/notifications/?page_size=10
GET api/v1/openwisp_notifications/notifications/?page_size=10&page=2
List of endpoints
~~~~~~~~~~~~~~~~~

Since the detailed explanation is contained in the `Live documentation <#live-documentation>`_
and in the `Browsable web page <#browsable-web-interface>`_ of each endpoint,
here we'll provide just a list of the available endpoints,
for further information please open the URL of the endpoint in your browser.

List user's notifications
#########################

.. code-block:: text
GET /api/v1/openwisp_notifications/notifications/
Mark notification as read
#########################

.. code-block:: text
PATCH /api/v1/openwisp_notifications/notifications/{id}/
Installing for development
--------------------------

Expand Down Expand Up @@ -660,6 +730,23 @@ to find out how to do this.
**Note**: Some tests will fail if ``templatetags`` and ``admin/base.html`` are not configured properly.
See preceeding sections to configure them properly.

Other base classes that can be inherited and extended
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following steps are not required and are intended for more advanced customization.

API views
#########

The API view classes can be extended into other django applications as well. Note
that it is not required for extending openwisp-notifications to your app and this change
is required only if you plan to make changes to the API views.

Create a view file as done in `views.py <https://github.com/openwisp/openwisp-notifications/blob/master/tests/openwisp2/sample_notifications/views.py>`_

For more information regarding Django REST Framework API views, please refer to the
`"Generic views" section in the Django REST Framework documentation <https://www.django-rest-framework.org/api-guide/generic-views/>`_.

Contributing
------------

Expand Down
Binary file added docs/images/api-docs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/api-ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
21 changes: 21 additions & 0 deletions openwisp_notifications/api/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.urls import reverse
from openwisp_notifications.swapper import load_model
from openwisp_notifications.utils import _get_absolute_url
from rest_framework import serializers

Notification = load_model('Notification')


class NotificationSerializer(serializers.ModelSerializer):
notification_url = serializers.SerializerMethodField()

class Meta:
model = Notification
fields = ['id', 'message', 'unread', 'notification_url', 'email_subject']

def get_notification_url(self, obj):
url = reverse(
f'admin:{Notification._meta.app_label}_{Notification._meta.model_name}_change',
args=(obj.id,),
)
return _get_absolute_url(url)
17 changes: 17 additions & 0 deletions openwisp_notifications/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.conf.urls import url
from openwisp_notifications.api import views

app_name = 'openwisp_notifications'

urlpatterns = [
url(
r'^openwisp_notifications/notifications/$',
views.list_notifications,
name='api_list_notifications',
),
url(
r'^openwisp_notifications/notifications/(?P<notification_id>[0-9A-Fa-f-]+)/$',
views.read_notification,
name='api_read_notifications',
),
]
64 changes: 64 additions & 0 deletions openwisp_notifications/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import uuid

from django.utils.translation import gettext_lazy as _
from openwisp_notifications.api.serializers import NotificationSerializer
from openwisp_notifications.swapper import load_model
from rest_framework import status
from rest_framework.authentication import BasicAuthentication, SessionAuthentication
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.response import Response

Notification = load_model('Notification')


class NotificationPaginator(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100


class BaseNotificationView(GenericAPIView):
model = Notification
authentication_classes = [SessionAuthentication, BasicAuthentication]
permission_classes = [DjangoModelPermissions]
queryset = Notification.objects.all()
serializer_class = NotificationSerializer

def get_queryset(self):
return self.queryset.filter(recipient=self.request.user)


class NotificationListView(BaseNotificationView, ListModelMixin):
pagination_class = NotificationPaginator

def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)


class NotificationReadView(BaseNotificationView):
def patch(self, request, notification_id):
return self._mark_notification_read(notification_id)

def _mark_notification_read(self, notification_id):
queryset = self.get_queryset()
try:
notification_id = uuid.UUID(notification_id)
notification = queryset.get(id=notification_id)
except ValueError as e:
data = {'error': _(str(e))}
status_code = status.HTTP_400_BAD_REQUEST
except Notification.DoesNotExist as e:
data = {'error': _(str(e))}
status_code = status.HTTP_404_NOT_FOUND
else:
notification.mark_as_read()
data = {'detail': _('Notification marked as read.')}
status_code = status.HTTP_200_OK
return Response(data, status_code)


list_notifications = NotificationListView.as_view()
read_notification = NotificationReadView.as_view()
136 changes: 136 additions & 0 deletions openwisp_notifications/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import uuid

from django.test import TestCase
from django.urls import reverse
from openwisp_notifications.signals import notify
from openwisp_notifications.swapper import load_model

from openwisp_users.tests.utils import TestOrganizationMixin

Notification = load_model('Notification')


class TestNotificationApi(TestCase, TestOrganizationMixin):
app_label = 'openwisp_notifications'

def setUp(self):
self.admin = self._get_admin(self)
self.client.force_login(self.admin)

def test_list_notification_api(self):
number_of_notifications = 20
url = reverse(f'{self.app_label}:api_list_notifications')
for _ in range(number_of_notifications):
notify.send(sender=self.admin, type='default', target=self.admin)

with self.subTest('Test page query in notification list view'):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], number_of_notifications)
self.assertEqual(
response.data['next'],
f'http://testserver/api/v1/{self.app_label}/notifications/?page=2',
)
self.assertEqual(response.data['previous'], None)
self.assertEqual(len(response.data['results']), 10)

next_response = self.client.get(response.data['next'])
self.assertEqual(next_response.status_code, 200)
self.assertEqual(next_response.data['count'], number_of_notifications)
self.assertEqual(
next_response.data['next'], None,
)
self.assertEqual(
next_response.data['previous'],
f'http://testserver/api/v1/{self.app_label}/notifications/',
)
self.assertEqual(len(next_response.data['results']), 10)

with self.subTest('Test page_size query'):
page_size = 5
url = f'{url}?page_size={page_size}'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], number_of_notifications)
self.assertEqual(
response.data['next'],
f'http://testserver/api/v1/{self.app_label}/notifications/'
f'?page=2&page_size={page_size}',
)
self.assertEqual(response.data['previous'], None)
self.assertEqual(len(response.data['results']), page_size)

next_response = self.client.get(response.data['next'])
self.assertEqual(next_response.status_code, 200)
self.assertEqual(next_response.data['count'], number_of_notifications)
self.assertEqual(
next_response.data['next'],
f'http://testserver/api/v1/{self.app_label}/notifications/'
f'?page=3&page_size={page_size}',
)
self.assertEqual(
next_response.data['previous'],
f'http://testserver/api/v1/{self.app_label}/notifications/'
f'?page_size={page_size}',
)
self.assertEqual(len(next_response.data['results']), page_size)

with self.subTest('Test individual result object'):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
n = response.data['results'][0]
self.assertIn('id', n)
self.assertIn('message', n)
self.assertTrue(n['unread'])
self.assertIn('notification_url', n)
self.assertEqual(
n['email_subject'], '[example.com] Default Notification Subject'
)

def test_read_notification_api(self):
notify.send(sender=self.admin, type='default', target=self.admin)
n = Notification.objects.first()
self.assertEqual(n.unread, True)

with self.subTest('Test for invalid UUID'):
url = reverse(f'{self.app_label}:api_read_notifications', args=(0,))
response = self.client.patch(url)
self.assertEqual(response.status_code, 400)
self.assertDictEqual(
response.data, {'error': 'badly formed hexadecimal UUID string'}
)

with self.subTest('Test for non-existent notification'):
url = reverse(
f'{self.app_label}:api_read_notifications', args=(uuid.uuid4(),)
)
response = self.client.patch(url)
self.assertEqual(response.status_code, 404)
self.assertDictEqual(
response.data, {'error': 'Notification matching query does not exist.'}
)

with self.subTest('Test for valid notificaton'):
url = reverse(f'{self.app_label}:api_read_notifications', args=(n.id,))
response = self.client.patch(url)
self.assertEqual(response.status_code, 200)
self.assertDictEqual(
response.data, {'detail': 'Notification marked as read.'}
)
n = Notification.objects.first()
self.assertEqual(n.unread, False)

def test_annonymous_user(self):
self.client.logout()

with self.subTest('Test for list notifications api'):
url = reverse(f'{self.app_label}:api_list_notifications')
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
self.assertIn('detail', response.data)

with self.subTest('Test for read notifications api'):
url = reverse(f'{self.app_label}:api_read_notifications', args=(0,))
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
self.assertIn('detail', response.data)
8 changes: 8 additions & 0 deletions openwisp_notifications/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.conf.urls import include, url

urlpatterns = [
url(
r'^api/v1/',
include('openwisp_notifications.api.urls', namespace='openwisp_notifications'),
),
]
8 changes: 6 additions & 2 deletions openwisp_notifications/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@


def _get_object_link(obj, field, html=True, url_only=False, absolute_url=False):
site = Site.objects.get_current()
content_type = getattr(obj, f'{field}_content_type', None)
object_id = getattr(obj, f'{field}_object_id', None)
try:
Expand All @@ -13,7 +12,7 @@ def _get_object_link(obj, field, html=True, url_only=False, absolute_url=False):
args=[object_id],
)
if absolute_url:
url = f'http://{site.domain}{url}'
url = _get_absolute_url(url)
if not html:
return url
return format_html(f'<a href="{url}" id="{field}-object-url">{object_id}</a>')
Expand All @@ -24,3 +23,8 @@ def _get_object_link(obj, field, html=True, url_only=False, absolute_url=False):
if url_only:
return '#'
return fallback_content


def _get_absolute_url(url):
site = Site.objects.get_current()
return f'http://{site.domain}{url}'
Loading

0 comments on commit f6a69c2

Please sign in to comment.