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