Coverage for api/utils/moderation_lock.py: 36%
63 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 06:14 +0000
1import functools
2import time
4import django_redis
5import structlog
6from redis.exceptions import ConnectionError
8from api.models.moderation import get_moderators
11LOCK_PREFIX = "moderation_lock"
12TTL = 10 # seconds
14logger = structlog.get_logger(__name__)
17def handle_redis_exception(func):
18 @functools.wraps(func)
19 def wrapper(*args, **kwargs):
20 try:
21 return func(*args, **kwargs)
22 except ConnectionError:
23 return None
25 return wrapper
28class LockManager:
29 """
30 Kudos to this Google Group discussion for the solution using a
31 ranked-set:
32 https://web.archive.org/web/20211205091916/https://groups.google.com/g/redis-db/c/rXXMCLNkNSs
33 """
35 def __init__(self, media_type):
36 self.media_type = media_type
38 @handle_redis_exception
39 def prune(self) -> dict[str, set[str]]:
40 """
41 Delete all expired locks and get a mapping of usernames to
42 media items that have active locks.
44 :return: a mapping of moderators to media items they are viewing
45 """
47 redis = django_redis.get_redis_connection("default")
48 valid_locks = {}
50 now = int(time.time())
51 pipe = redis.pipeline()
52 for username in get_moderators().values_list("username", flat=True):
53 key = f"{LOCK_PREFIX}:{username}"
54 for value, score in redis.zrange(key, 0, -1, withscores=True):
55 if score <= now:
56 logger.info("Deleting expired lock", key=key, value=value)
57 pipe.zrem(key, value)
58 else:
59 logger.info("Keeping valid lock", key=key, value=value)
60 valid_locks.setdefault(username, set()).add(value.decode())
61 pipe.execute()
63 return valid_locks
65 @handle_redis_exception
66 def add_locks(self, username, object_id) -> int:
67 """
68 Add a soft-lock for a given media item to the given moderator.
70 :param username: the username of the moderator viewing a media item
71 :param object_id: the ID of the media item being viewed
72 """
74 redis = django_redis.get_redis_connection("default")
76 object = f"{self.media_type}:{object_id}"
78 expiration = int(time.time()) + TTL
79 logger.info("Adding lock", object=object, user=username, expiration=expiration)
80 redis.zadd(f"{LOCK_PREFIX}:{username}", {object: expiration})
81 return expiration
83 @handle_redis_exception
84 def remove_locks(self, username, object_id):
85 """
86 Remove the soft-lock for a given media item from the given moderator.
88 :param username: the username of the moderator not viewing a media item
89 :param object_id: the ID of the media item not being viewed
90 """
92 redis = django_redis.get_redis_connection("default")
94 object = f"{self.media_type}:{object_id}"
96 logger.info("Removing lock", object=object, user=username)
97 redis.zrem(f"{LOCK_PREFIX}:{username}", object)
99 def moderator_set(self, object_id) -> set[str]:
100 """
101 Get the list of moderators on a particular item.
103 :param object_id: the ID of the media item being viewed
104 :return: the list of moderators on a particular item
105 """
107 valid_locks = self.prune() or {}
109 object = f"{self.media_type}:{object_id}"
110 mods = {mod for mod, objects in valid_locks.items() if object in objects}
111 logger.info("Retrieved moderators", object=object, mods=mods)
112 return mods
114 @handle_redis_exception
115 def score(self, username, object_id) -> int:
116 """
117 Get the score of a particular moderator on a particular item.
119 :param username: the username of the moderator viewing a media item
120 :param object_id: the ID of the media item being viewed
121 :return: the score of a particular moderator on a particular item
122 """
124 redis = django_redis.get_redis_connection("default")
126 object = f"{self.media_type}:{object_id}"
127 score = redis.zscore(f"{LOCK_PREFIX}:{username}", object)
128 logger.info("Retrieved score", object=object, user=username, score=score)
129 return score