I have created a real time notifier using web-socket, implemented using Django-Channels. It sends the "Post Author" a real time notification whenever any user likes the author's post.
The problem is, for single like button click, multiple notifications are being saved in database. This is because the function (like_notif()) defined in consumers.py which is saving notification data to database when the 'like button click' event happens is called multiple times (when it should be called only once) until the web-socket is disconnected.
In the following lines I will provide detail of what is actually happening(to the best of my understanding) behind the scene.
<a class="like-btn" id="like-btn-{{ post.pk }}" data-likes="{{ post.likes.count }}" href="{% url 'like_toggle' post.slug %}">
Like
</a>
$('.like-btn').click(function (event) {
event.preventDefault();
var this_ = $(this);
var url = this_.attr('href');
var likesCount = parseInt(this_.attr('data-likes')) || 0;
$.ajax({
url: url,
method: "GET",
data: {},
success: function (json) {
// DOM is manipulated accordingly.
},
error: function (json) {
// Error.
}
});
});
# In urls.py
urlpatterns = [
path('<slug:slug>/like/', views.post_like_toggle, name="like_toggle"),
]
# In views.py
@login_required
def post_like_toggle(request, slug):
"""
Function to like/unlike posts using AJAX.
"""
post = Post.objects.get(slug=slug)
user = request.user
if user in post.likes.all():
# Like removed
post.likes.remove(user)
else:
# Like Added
post.likes.add(user)
user_profile = get_object_or_404(UserProfile, user=user)
# If Post author is not same as user liking the post
if str(user_profile.user) != str(post.author):
# Sending notification to post author is the post is liked.
channel_layer = get_channel_layer()
text_dict = {
# Contains some data in JSON format to be sent.
}
async_to_sync(channel_layer.group_send)(
"like_notif", {
"type": "notif_like",
"text": json.dumps(text_dict),
}
)
response_data = {
# Data sent to AJAX success/error function.
}
return JsonResponse(response_data)
# In consumers.py
class LikeNotificationConsumer(AsyncConsumer):
async def websocket_connect(self, event):
print("connect", event)
await self.channel_layer.group_add("like_notif", self.channel_name)
await self.send({
"type": "websocket.accept",
})
async def websocket_receive(self, event):
print("receive", event)
async def websocket_disconnect(self, event):
print("disconnect", event)
await self.channel_layer.group_discard("like_notif", self.channel_name)
async def notif_like(self, event):
print("like_notif_send", event)
await self.send({
"type": "websocket.send",
"text": event.get('text')
})
json_dict = json.loads(event.get('text'))
recipient_username = json_dict.get('recipient_username')
recipient = await self.get_user(recipient_username)
sender_username = json_dict.get('sender_username')
sender = await self.get_user(sender_username)
post_pk = json_dict.get('post_pk', None)
post = await self.get_post(post_pk)
verb = json_dict.get('verb')
description = json_dict.get('description', None)
data_dict = json_dict.get('data', None)
data = json.dumps(data_dict)
await self.create_notif(recipient, sender, verb, post, description, data)
@database_sync_to_async
def get_user(self, username_):
return User.objects.get(username=username_)
@database_sync_to_async
def get_post(self, pk_):
return Post.objects.get(pk=pk_)
@database_sync_to_async
def create_notif(self, recipient, sender, verb, post=None, description=None, data=None, *args, **kwargs):
return Notification.objects.create(recipient=recipient, sender=sender, post=post, verb=verb, description=description, data=data)
Note: notif_like() function is being called multiple times and thus data is being saved multiple times in the database. Why is this function being called multiple times?
<script type="text/javascript">
var loc = window.location
var wsStart = 'ws://'
if(loc.protocol == 'https:') {
wsStart = 'wss://'
}
var endpoint = wsStart + loc.host + "/like_notification/"
var likeSocket = new ReconnectingWebSocket(endpoint)
likeSocket.onmessage = function (e) {
console.log("message LikeNotificationConsumer", e)
var data_dict = JSON.parse(e.data)
/*
DOM manipulation using data from data_dict.
NOTE: All the intended data is coming through perfectly fine.
*/
};
likeSocket.onopen = function (e) {
console.log("opened LikeNotificationConsumer", e)
}
likeSocket.onerror = function (e) {
console.log("error LikeNotificationConsumer", e)
}
likeSocket.onclose = function (e) {
console.log("close LikeNotificationConsumer", e)
}
</script>
# In models.py
class Post(models.Model):
title = models.CharField(max_length=255, blank=False, null=True, verbose_name='Post Title')
post_content = RichTextUploadingField(blank=False, null=True, verbose_name='Post Content')
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
default=None,
blank=True,
null=True
)
likes = models.ManyToManyField(User, blank=True, related_name='post_likes')
published = models.DateTimeField(default=datetime.now, blank=True)
tags = models.ManyToManyField(Tags, verbose_name='Post Tags', blank=True, default=None)
slug = models.SlugField(default='', blank=True)
def save(self, *args, **kwargs):
self.slug = slugify(self.title + str(self.pk))
super(Post, self).save()
def get_like_url(self):
return reverse("like_toggle", (), {'slug', self.slug})
def __str__(self):
return '%s' % self.title
# In models.py
class Notification(models.Model):
recipient = models.ForeignKey(User, blank=False, on_delete=models.CASCADE, related_name="Recipient",)
sender = models.ForeignKey(
User,
blank=False,
on_delete=models.CASCADE,
related_name="Sender"
)
# Post (If notification is attached to some post)
post = models.ForeignKey(
Post,
blank=True,
default=None,
on_delete=models.CASCADE,
related_name="Post",
)
verb = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True)
data = models.TextField(blank=True, null=True)
timestamp = models.DateTimeField(default=timezone.now)
unread = models.BooleanField(default=True, blank=False, db_index=True)
def __str__(self):
return self.verb
# In settings.py
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("localhost", 6379)]
}
}
}
I expect the notification to be saved only once per click on the like button but it is being saved multiple times as the notif_like() function defined in consumers.py is being called multiple times per click.
Moreover, web-socket gets disconnected abruptly after this and following warning is displayed in the terminal -
Application instance <Task pending coro=<SessionMiddlewareInstance.__call__() running at /home/pirateksh/DjangoProjects/ccwebsite/ccenv/lib/python3.7/site-packages/channels/sessions.py:183> wait_for=<Future pending cb=[<TaskWakeupMethWrapper object at 0x7fb7603af1c8>()]>> for connection <WebSocketProtocol client=['127.0.0.1', 52114] path=b'/like_notification/'> took too long to shut down and was killed.
I am struggling with this for quite a time now, so it will be really helpful if someone can guide me through this.
After almost a year I was working on a very similar project and I was able to reproduce this bug and also figured out what was causing this.
I am pretty sure that this is somehow being caused by ReconnectingWebSocket.
The reason for this is that when I switched back to using vanilla WebSocket, everything worked fine.
I have also opened an issue regarding this on the ReconnectingWebSocket's Github repository.