2021-04-05 20:49:21 +00:00
|
|
|
""" store recommended follows in redis """
|
2021-04-23 23:34:04 +00:00
|
|
|
import math
|
2021-04-05 20:49:21 +00:00
|
|
|
from django.dispatch import receiver
|
2021-04-24 01:26:48 +00:00
|
|
|
from django.db.models import signals, Count, Q
|
2021-04-05 20:49:21 +00:00
|
|
|
|
|
|
|
from bookwyrm import models
|
|
|
|
from bookwyrm.redis_store import RedisStore, r
|
|
|
|
|
|
|
|
|
|
|
|
class SuggestedUsers(RedisStore):
|
|
|
|
""" suggested users for a user """
|
|
|
|
|
2021-04-24 01:26:48 +00:00
|
|
|
max_length = 30
|
2021-04-05 20:49:21 +00:00
|
|
|
|
|
|
|
def get_rank(self, obj):
|
|
|
|
""" get computed rank """
|
|
|
|
return obj.mutuals + (1.0 - (1.0 / (obj.shared_books + 1)))
|
|
|
|
|
|
|
|
def store_id(self, user): # pylint: disable=no-self-use
|
|
|
|
""" the key used to store this user's recs """
|
|
|
|
return "{:d}-suggestions".format(user.id)
|
|
|
|
|
|
|
|
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
|
|
|
|
""" calculate mutuals count and shared books count from rank """
|
|
|
|
return {
|
|
|
|
"mutuals": math.floor(rank),
|
2021-04-24 01:26:48 +00:00
|
|
|
"shared_books": int(1 / (-1 * (1 % rank - 1))) if rank else 0,
|
2021-04-05 20:49:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
def get_objects_for_store(self, store):
|
|
|
|
""" a list of potential follows for a user """
|
|
|
|
user = models.User.objects.get(id=store.split("-")[0])
|
|
|
|
|
|
|
|
return get_annotated_users(
|
|
|
|
user,
|
|
|
|
~Q(id=user.id),
|
|
|
|
~Q(followers=user),
|
|
|
|
~Q(follower_requests=user),
|
|
|
|
bookwyrm_user=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
def get_stores_for_object(self, obj):
|
2021-04-23 23:34:04 +00:00
|
|
|
return [self.store_id(u) for u in self.get_users_for_object(obj)]
|
|
|
|
|
|
|
|
def get_users_for_object(self, obj): # pylint: disable=no-self-use
|
2021-04-05 20:49:21 +00:00
|
|
|
""" given a user, who might want to follow them """
|
|
|
|
return models.User.objects.filter(
|
|
|
|
local=True,
|
2021-04-23 23:34:04 +00:00
|
|
|
).exclude(following=obj)
|
2021-04-05 20:49:21 +00:00
|
|
|
|
|
|
|
def rerank_obj(self, obj):
|
|
|
|
""" update all the instances of this user with new ranks """
|
|
|
|
pipeline = r.pipeline()
|
2021-04-23 23:34:04 +00:00
|
|
|
for store_user in self.get_users_for_object(obj):
|
|
|
|
annotated_user = get_annotated_users(
|
|
|
|
store_user,
|
|
|
|
id=obj.id,
|
|
|
|
).first()
|
|
|
|
|
|
|
|
pipeline.zadd(
|
|
|
|
self.store_id(store_user),
|
|
|
|
self.get_value(annotated_user),
|
|
|
|
xx=True
|
|
|
|
)
|
2021-04-05 20:49:21 +00:00
|
|
|
pipeline.execute()
|
|
|
|
|
|
|
|
def rerank_user_suggestions(self, user):
|
|
|
|
""" update the ranks of the follows suggested to a user """
|
|
|
|
self.populate_store(self.store_id(user))
|
|
|
|
|
2021-04-06 15:31:18 +00:00
|
|
|
def get_suggestions(self, user):
|
|
|
|
""" get suggestions """
|
|
|
|
values = self.get_store(self.store_id(user), withscores=True)
|
|
|
|
results = []
|
|
|
|
# annotate users with mutuals and shared book counts
|
|
|
|
for user_id, rank in values[:5]:
|
|
|
|
counts = self.get_counts_from_rank(rank)
|
|
|
|
user = models.User.objects.get(id=user_id)
|
|
|
|
user.mutuals = counts["mutuals"]
|
|
|
|
user.shared_books = counts["shared_books"]
|
|
|
|
results.append(user)
|
|
|
|
return results
|
|
|
|
|
2021-04-05 20:49:21 +00:00
|
|
|
|
2021-04-24 01:26:48 +00:00
|
|
|
def get_annotated_users(viewer, *args, **kwargs):
|
|
|
|
""" Users, annotated with things they have in common """
|
|
|
|
return (
|
|
|
|
models.User.objects.filter(discoverable=True, is_active=True, *args, **kwargs)
|
|
|
|
.exclude(Q(id__in=viewer.blocks.all()) | Q(blocks=viewer))
|
|
|
|
.annotate(
|
|
|
|
mutuals=Count(
|
|
|
|
"following",
|
|
|
|
filter=Q(
|
|
|
|
~Q(id=viewer.id),
|
|
|
|
~Q(id__in=viewer.following.all()),
|
|
|
|
following__in=viewer.following.all(),
|
|
|
|
),
|
|
|
|
distinct=True,
|
|
|
|
),
|
|
|
|
shared_books=Count(
|
|
|
|
"shelfbook",
|
|
|
|
filter=Q(
|
|
|
|
~Q(id=viewer.id),
|
|
|
|
shelfbook__book__parent_work__in=[
|
|
|
|
s.book.parent_work for s in viewer.shelfbook_set.all()
|
|
|
|
],
|
|
|
|
),
|
|
|
|
distinct=True,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2021-04-05 20:49:21 +00:00
|
|
|
suggested_users = SuggestedUsers()
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(signals.post_save, sender=models.UserFollows)
|
|
|
|
# pylint: disable=unused-argument
|
|
|
|
def update_suggestions_on_follow(sender, instance, created, *args, **kwargs):
|
|
|
|
""" remove a follow from the recs and update the ranks"""
|
|
|
|
if (
|
|
|
|
not created
|
|
|
|
or not instance.user_subject.local
|
|
|
|
or not instance.user_object.discoverable
|
|
|
|
):
|
|
|
|
return
|
|
|
|
suggested_users.bulk_remove_objects_from_store(
|
|
|
|
[instance.user_object], instance.user_subject
|
|
|
|
)
|
|
|
|
suggested_users.rerank_obj(instance.user_object)
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(signals.post_save, sender=models.ShelfBook)
|
|
|
|
@receiver(signals.post_delete, sender=models.ShelfBook)
|
|
|
|
# pylint: disable=unused-argument
|
|
|
|
def update_rank_on_shelving(sender, instance, *args, **kwargs):
|
|
|
|
""" when a user shelves or unshelves a book, re-compute their rank """
|
|
|
|
if not instance.user.discoverable:
|
|
|
|
return
|
|
|
|
suggested_users.rerank_obj(instance.user)
|
|
|
|
|
|
|
|
|
|
|
|
@receiver(signals.post_save, sender=models.User)
|
|
|
|
# pylint: disable=unused-argument, too-many-arguments
|
|
|
|
def add_or_remove_on_discoverability_change(
|
|
|
|
sender, instance, created, raw, using, update_fields, **kwargs
|
|
|
|
):
|
|
|
|
""" make a user (un)discoverable """
|
2021-04-23 23:34:04 +00:00
|
|
|
if not update_fields or not "discoverable" in update_fields:
|
2021-04-05 20:49:21 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
if created:
|
|
|
|
suggested_users.rerank_user_suggestions(instance)
|
|
|
|
|
|
|
|
if instance.discoverable:
|
2021-04-23 23:34:04 +00:00
|
|
|
suggested_users.rerank_obj(instance)
|
2021-04-05 20:49:21 +00:00
|
|
|
elif not created and not instance.discoverable:
|
|
|
|
suggested_users.remove_object_from_related_stores(instance)
|