Coverage for an_website/quotes/quote_of_the_day/store.py: 86.111%

72 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""Stores that contain the ids of old quotes of the day.""" 

15 

16from __future__ import annotations 

17 

18import abc 

19from datetime import date, datetime, timezone 

20from typing import ClassVar, Final 

21 

22from redis.asyncio import Redis 

23from tornado.web import HTTPError 

24 

25from ... import EVENT_REDIS 

26 

27QUOTE_COUNT_TO_SHOW_IN_FEED: Final[int] = 8 

28 

29 

30class QuoteOfTheDayStore(abc.ABC): 

31 """The class representing the store for the quote of the day.""" 

32 

33 __slots__ = () 

34 

35 @abc.abstractmethod 

36 async def get_quote_id_by_date(self, date_: date) -> tuple[int, int] | None: 

37 """Get the quote id for the given date.""" 

38 raise NotImplementedError 

39 

40 @abc.abstractmethod 

41 async def has_quote_been_used(self, quote_id: tuple[int, int]) -> bool: 

42 """Check if the quote has been used already.""" 

43 raise NotImplementedError 

44 

45 @abc.abstractmethod 

46 async def set_quote_id_by_date( 

47 self, date_: date, quote_id: tuple[int, int] 

48 ) -> None: 

49 """Set the quote id for the given date.""" 

50 raise NotImplementedError 

51 

52 @abc.abstractmethod 

53 async def set_quote_to_used(self, quote_id: tuple[int, int]) -> None: 

54 """Set the quote as used.""" 

55 raise NotImplementedError 

56 

57 

58class QuoteOfTheDayStoreWithCache(QuoteOfTheDayStore, abc.ABC): 

59 """Quote of the day store with an in memory cache.""" 

60 

61 # pylint: disable=abstract-method 

62 

63 CACHE: ClassVar[dict[date, tuple[int, int]]] 

64 

65 @classmethod 

66 def _get_quote_id_from_cache(cls, date_: date) -> None | tuple[int, int]: 

67 """Get a quote_id from the cache if it is present.""" 

68 return cls.CACHE.get(date_) 

69 

70 @classmethod 

71 def _populate_cache(cls, date_: date, quote_id: tuple[int, int]) -> None: 

72 """Populate the cache for the quote of today.""" 

73 today = datetime.now(timezone.utc).date() 

74 # old entries are rarely used, they don't need to be cached 

75 if (today - date_).days > QUOTE_COUNT_TO_SHOW_IN_FEED: 

76 cls.CACHE[date_] = quote_id 

77 

78 for key in tuple(cls.CACHE): 

79 # remove old entries from cache to save memory 

80 if (today - key).days > QUOTE_COUNT_TO_SHOW_IN_FEED: 

81 del cls.CACHE[key] 

82 

83 

84class RedisQuoteOfTheDayStore(QuoteOfTheDayStoreWithCache): 

85 """A quote of the day store that stores the quote of the day in Redis.""" 

86 

87 __slots__ = ("redis", "redis_prefix") 

88 

89 CACHE: ClassVar[dict[date, tuple[int, int]]] = {} 

90 

91 redis_prefix: str 

92 redis: Redis[str] 

93 

94 def __init__(self, redis: Redis[str], redis_prefix: str) -> None: 

95 """Initialize the Redis quote of the day store.""" 

96 super().__init__() 

97 self.redis = redis 

98 self.redis_prefix = redis_prefix 

99 

100 async def get_quote_id_by_date(self, date_: date) -> tuple[int, int] | None: 

101 """Get the quote id for the given date.""" 

102 if date_ in self.CACHE: 

103 return self.CACHE[date_] 

104 if not EVENT_REDIS.is_set(): 

105 raise HTTPError(503) 

106 wq_id = await self.redis.get(self.get_redis_quote_date_key(date_)) 

107 if not wq_id: 

108 return None 

109 quote, author = wq_id.split("-") 

110 quote_id = int(quote), int(author) 

111 self._populate_cache(date_, quote_id) 

112 return quote_id 

113 

114 def get_redis_quote_date_key(self, wq_date: date) -> str: 

115 """Get the Redis key for getting quotes by date.""" 

116 return f"{self.redis_prefix}:quote-of-the-day:by-date:{wq_date.isoformat()}" 

117 

118 def get_redis_used_key(self, wq_id: tuple[int, int]) -> str: 

119 """Get the Redis used key.""" 

120 str_id = "-".join(map(str, wq_id)) # pylint: disable=bad-builtin 

121 return f"{self.redis_prefix}:quote-of-the-day:used:{str_id}" 

122 

123 async def has_quote_been_used(self, quote_id: tuple[int, int]) -> bool: 

124 """Check if the quote has been used already.""" 

125 if quote_id in self.CACHE.values(): 

126 return True 

127 if not EVENT_REDIS.is_set(): 

128 raise HTTPError(503) 

129 return bool(await self.redis.get(self.get_redis_used_key(quote_id))) 

130 

131 async def set_quote_id_by_date( 

132 self, date_: date, quote_id: tuple[int, int] 

133 ) -> None: 

134 """Set the quote id for the given date.""" 

135 if not EVENT_REDIS.is_set(): 

136 raise HTTPError(503) 

137 await self.redis.setex( 

138 self.get_redis_quote_date_key(date_), 

139 60 * 60 * 24 * 420, # TTL 

140 "-".join(map(str, quote_id)), # pylint: disable=bad-builtin 

141 ) 

142 self._populate_cache(date_, quote_id) 

143 

144 async def set_quote_to_used(self, quote_id: tuple[int, int]) -> None: 

145 """Set the quote as used.""" 

146 if not EVENT_REDIS.is_set(): 

147 return 

148 

149 await self.redis.setex( 

150 self.get_redis_used_key(quote_id), 

151 # we have over 720 funny wrong quotes, so 420 should be ok 

152 60 * 60 * 24 * 420, # TTL 

153 1, # True 

154 )