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
« 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/>.
14"""Stores that contain the ids of old quotes of the day."""
16import abc
17from datetime import date, datetime, timezone
18from typing import ClassVar, Final
20from redis.asyncio import Redis
21from tornado.web import HTTPError
23from ... import EVENT_REDIS
25QUOTE_COUNT_TO_SHOW_IN_FEED: Final[int] = 8
28class QuoteOfTheDayStore(abc.ABC):
29 """The class representing the store for the quote of the day."""
31 __slots__ = ()
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
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
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
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
56class QuoteOfTheDayStoreWithCache(QuoteOfTheDayStore, abc.ABC):
57 """Quote of the day store with an in memory cache."""
59 # pylint: disable=abstract-method
61 CACHE: ClassVar[dict[date, tuple[int, int]]]
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_)
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
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]
82class RedisQuoteOfTheDayStore(QuoteOfTheDayStoreWithCache):
83 """A quote of the day store that stores the quote of the day in Redis."""
85 __slots__ = ("redis", "redis_prefix")
87 CACHE: ClassVar[dict[date, tuple[int, int]]] = {}
89 redis_prefix: str
90 redis: Redis[str]
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
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
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()}"
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}"
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)))
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)
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
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 )