moviewyrm/bookwyrm/suggested_users.py

278 lines
9.8 KiB
Python
Raw Normal View History

2021-04-05 20:49:21 +00:00
""" store recommended follows in redis """
2021-04-23 23:34:04 +00:00
import math
import logging
2021-04-05 20:49:21 +00:00
from django.dispatch import receiver
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
2021-05-22 22:53:07 +00:00
from bookwyrm.tasks import app
2021-04-05 20:49:21 +00:00
logger = logging.getLogger(__name__)
2021-04-05 20:49:21 +00:00
class SuggestedUsers(RedisStore):
"""suggested users for a user"""
2021-04-05 20:49:21 +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)))
2021-04-05 20:49:21 +00:00
def store_id(self, user): # pylint: disable=no-self-use
"""the key used to store this user's recs"""
2021-05-22 22:53:07 +00:00
if isinstance(user, int):
2021-09-18 04:39:18 +00:00
return f"{user}-suggestions"
return f"{user.id}-suggestions"
2021-04-05 20:49:21 +00:00
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
"""calculate mutuals count and shared books count from rank"""
2021-04-05 20:49:21 +00:00
return {
"mutuals": math.floor(rank),
# "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1,
2021-04-05 20:49:21 +00:00
}
def get_objects_for_store(self, store):
"""a list of potential follows for a user"""
2021-04-05 20:49:21 +00:00
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),
2021-09-08 19:06:23 +00:00
bookwyrm_user=True,
2021-04-05 20:49:21 +00:00
)
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
"""given a user, who might want to follow them"""
2021-05-22 22:57:08 +00:00
return models.User.objects.filter(local=True,).exclude(
2021-05-22 22:53:07 +00:00
Q(id=obj.id) | Q(followers=obj) | Q(id__in=obj.blocks.all()) | Q(blocks=obj)
)
2021-04-05 20:49:21 +00:00
def rerank_obj(self, obj, update_only=True):
"""update all the instances of this user with new ranks"""
2021-04-05 20:49:21 +00:00
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()
2021-05-22 22:53:07 +00:00
if not annotated_user:
continue
2021-04-23 23:34:04 +00:00
pipeline.zadd(
self.store_id(store_user),
self.get_value(annotated_user),
xx=update_only,
2021-04-23 23:34:04 +00:00
)
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"""
2021-04-05 20:49:21 +00:00
self.populate_store(self.store_id(user))
2021-05-22 19:56:37 +00:00
def remove_suggestion(self, user, suggested_user):
"""take a user out of someone's suggestions"""
self.bulk_remove_objects_from_store([suggested_user], self.store_id(user))
2021-04-06 15:31:18 +00:00
def get_suggestions(self, user):
"""get suggestions"""
2021-04-06 15:31:18 +00:00
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:
2021-04-06 15:31:18 +00:00
counts = self.get_counts_from_rank(rank)
try:
user = models.User.objects.get(
id=user_id, is_active=True, bookwyrm_user=True
)
except models.User.DoesNotExist as err:
# if this happens, the suggestions are janked way up
logger.exception(err)
continue
2021-04-06 15:31:18 +00:00
user.mutuals = counts["mutuals"]
# user.shared_books = counts["shared_books"]
2021-04-06 15:31:18 +00:00
results.append(user)
if len(results) >= 5:
break
2021-04-06 15:31:18 +00:00
return results
2021-04-05 20:49:21 +00:00
def get_annotated_users(viewer, *args, **kwargs):
"""Users, annotated with things they have in common"""
return (
2021-09-08 19:06:23 +00:00
models.User.objects.filter(discoverable=True, is_active=True, *args, **kwargs)
.exclude(Q(id__in=viewer.blocks.all()) | Q(blocks=viewer))
.annotate(
mutuals=Count(
"followers",
filter=Q(
~Q(id=viewer.id),
~Q(id__in=viewer.following.all()),
followers__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"""
2021-05-22 19:56:37 +00:00
if not created or not instance.user_object.discoverable:
2021-04-05 20:49:21 +00:00
return
2021-05-22 19:56:37 +00:00
if instance.user_subject.local:
2021-05-22 22:53:07 +00:00
remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id)
rerank_user_task.delay(instance.user_object.id, update_only=False)
2021-04-05 20:49:21 +00:00
2021-10-04 18:40:50 +00:00
@receiver(signals.post_save, sender=models.UserFollowRequest)
# pylint: disable=unused-argument
def update_suggestions_on_follow_request(sender, instance, created, *args, **kwargs):
"""remove a follow from the recs and update the ranks"""
if not created or not instance.user_object.discoverable:
return
if instance.user_subject.local:
remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id)
2021-05-22 21:05:59 +00:00
@receiver(signals.post_save, sender=models.UserBlocks)
# pylint: disable=unused-argument
def update_suggestions_on_block(sender, instance, *args, **kwargs):
"""remove blocked users from recs"""
2021-08-03 17:25:53 +00:00
if instance.user_subject.local and instance.user_object.discoverable:
2021-05-22 22:53:07 +00:00
remove_suggestion_task.delay(instance.user_subject.id, instance.user_object.id)
2021-08-03 17:25:53 +00:00
if instance.user_object.local and instance.user_subject.discoverable:
2021-05-22 22:53:07 +00:00
remove_suggestion_task.delay(instance.user_object.id, instance.user_subject.id)
2021-05-22 21:05:59 +00:00
2021-05-22 19:56:37 +00:00
@receiver(signals.post_delete, sender=models.UserFollows)
# pylint: disable=unused-argument
def update_suggestions_on_unfollow(sender, instance, **kwargs):
"""update rankings, but don't re-suggest because it was probably intentional"""
if instance.user_object.discoverable:
rerank_user_task.delay(instance.user_object.id, update_only=False)
2021-05-22 19:56:37 +00:00
# @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 it's a local user, re-calculate who is rec'ed to them
# if instance.user.local:
# rerank_suggestions_task.delay(instance.user.id)
#
# # if the user is discoverable, update their rankings
# if instance.user.discoverable:
# rerank_user_task.delay(instance.user.id)
2021-04-05 20:49:21 +00:00
@receiver(signals.post_save, sender=models.User)
# pylint: disable=unused-argument, too-many-arguments
def update_user(sender, instance, created, update_fields=None, **kwargs):
2021-09-08 18:14:41 +00:00
"""an updated user, neat"""
2021-05-22 22:53:07 +00:00
# a new user is found, create suggestions for them
2021-05-22 19:10:14 +00:00
if created and instance.local:
2021-05-22 22:53:07 +00:00
rerank_suggestions_task.delay(instance.id)
2021-09-08 18:14:41 +00:00
# we know what fields were updated and discoverability didn't change
if not instance.bookwyrm_user or (
update_fields and not "discoverable" in update_fields
):
2021-08-03 14:43:03 +00:00
return
2021-09-08 18:14:41 +00:00
# deleted the user
2021-09-08 19:06:23 +00:00
if not created and not instance.is_active:
2021-09-08 18:14:41 +00:00
remove_user_task.delay(instance.id)
return
2021-05-22 21:05:59 +00:00
# this happens on every save, not just when discoverability changes, annoyingly
if instance.discoverable:
2021-05-22 22:53:07 +00:00
rerank_user_task.delay(instance.id, update_only=False)
2021-05-22 19:10:14 +00:00
elif not created:
2021-05-22 22:53:07 +00:00
remove_user_task.delay(instance.id)
2021-09-08 18:38:22 +00:00
@receiver(signals.post_save, sender=models.FederatedServer)
def domain_level_update(sender, instance, created, update_fields=None, **kwargs):
"""remove users on a domain block"""
2021-09-08 18:47:36 +00:00
if (
not update_fields
or "status" not in update_fields
or instance.application_type != "bookwyrm"
):
2021-09-08 18:38:22 +00:00
return
if instance.status == "blocked":
2021-09-08 18:47:36 +00:00
bulk_remove_instance_task.delay(instance.id)
2021-09-08 18:38:22 +00:00
return
2021-09-08 18:47:36 +00:00
bulk_add_instance_task.delay(instance.id)
2021-09-08 18:38:22 +00:00
# ------------------- TASKS
2021-09-08 00:09:44 +00:00
@app.task(queue="low_priority")
2021-05-22 22:53:07 +00:00
def rerank_suggestions_task(user_id):
"""do the hard work in celery"""
suggested_users.rerank_user_suggestions(user_id)
2021-09-08 00:09:44 +00:00
@app.task(queue="low_priority")
2021-05-22 22:53:07 +00:00
def rerank_user_task(user_id, update_only=False):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.rerank_obj(user, update_only=update_only)
2021-09-08 00:09:44 +00:00
@app.task(queue="low_priority")
2021-05-22 22:53:07 +00:00
def remove_user_task(user_id):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.remove_object_from_related_stores(user)
2021-09-08 00:09:44 +00:00
@app.task(queue="medium_priority")
2021-05-22 22:53:07 +00:00
def remove_suggestion_task(user_id, suggested_user_id):
"""remove a specific user from a specific user's suggestions"""
suggested_user = models.User.objects.get(id=suggested_user_id)
suggested_users.remove_suggestion(user_id, suggested_user)
2021-09-08 18:38:22 +00:00
@app.task(queue="low_priority")
2021-09-08 18:47:36 +00:00
def bulk_remove_instance_task(instance_id):
2021-09-08 18:38:22 +00:00
"""remove a bunch of users from recs"""
2021-09-08 18:47:36 +00:00
for user in models.User.objects.filter(federated_server__id=instance_id):
2021-09-08 18:38:22 +00:00
suggested_users.remove_object_from_related_stores(user)
@app.task(queue="low_priority")
2021-09-08 18:47:36 +00:00
def bulk_add_instance_task(instance_id):
2021-09-08 18:38:22 +00:00
"""remove a bunch of users from recs"""
2021-09-08 18:47:36 +00:00
for user in models.User.objects.filter(federated_server__id=instance_id):
2021-09-08 18:38:22 +00:00
suggested_users.rerank_obj(user, update_only=False)