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

68 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-10 18: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 

16import abc 

17from datetime import date, datetime, timezone 

18from typing import ClassVar, Final 

19 

20from redis.asyncio import Redis 

21from tornado.web import HTTPError 

22 

23from ... import EVENT_REDIS 

24 

25QUOTE_COUNT_TO_SHOW_IN_FEED: Final[int] = 8 

26 

27 

28class QuoteOfTheDayStore(abc.ABC): 

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

30 

31 __slots__ = () 

32 

33 @abc.abstractmethod 

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

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

36 raise NotImplementedError 

37 

38 @abc.abstractmethod 

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

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

41 raise NotImplementedError 

42 

43 @abc.abstractmethod 

44 async def set_quote_id_by_date( 

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

46 ) -> None: 

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

48 raise NotImplementedError 

49 

50 @abc.abstractmethod 

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

52 """Set the quote as used.""" 

53 raise NotImplementedError 

54 

55 

56class QuoteOfTheDayStoreWithCache(QuoteOfTheDayStore, abc.ABC): 

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

58 

59 # pylint: disable=abstract-method 

60 

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

62 

63 @classmethod 

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

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

66 return cls.CACHE.get(date_) 

67 

68 @classmethod 

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

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

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

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

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

74 cls.CACHE[date_] = quote_id 

75 

76 for key in tuple(cls.CACHE): 

77 # remove old entries from cache to save memory 

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

79 del cls.CACHE[key] 

80 

81 

82class RedisQuoteOfTheDayStore(QuoteOfTheDayStoreWithCache): 

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

84 

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

86 

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

88 

89 redis_prefix: str 

90 redis: Redis[str] 

91 

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

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

94 super().__init__() 

95 self.redis = redis 

96 self.redis_prefix = redis_prefix 

97 

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

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

100 if date_ in self.CACHE: 

101 return self.CACHE[date_] 

102 if not EVENT_REDIS.is_set(): 

103 raise HTTPError(503) 

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

105 if not wq_id: 

106 return None 

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

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

109 self._populate_cache(date_, quote_id) 

110 return quote_id 

111 

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

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

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

115 

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

117 """Get the Redis used key.""" 

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

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

120 

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

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

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

124 return True 

125 if not EVENT_REDIS.is_set(): 

126 raise HTTPError(503) 

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

128 

129 async def set_quote_id_by_date( 

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

131 ) -> None: 

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

133 if not EVENT_REDIS.is_set(): 

134 raise HTTPError(503) 

135 await self.redis.setex( 

136 self.get_redis_quote_date_key(date_), 

137 60 * 60 * 24 * 420, # TTL 

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

139 ) 

140 self._populate_cache(date_, quote_id) 

141 

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

143 """Set the quote as used.""" 

144 if not EVENT_REDIS.is_set(): 

145 return 

146 

147 await self.redis.setex( 

148 self.get_redis_used_key(quote_id), 

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

150 60 * 60 * 24 * 420, # TTL 

151 1, # True 

152 )