Coverage for an_website/quotes/utils.py: 64.793%
338 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-01 14:47 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-01 14:47 +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"""A page with wrong quotes."""
16from __future__ import annotations
18import abc
19import asyncio
20import contextlib
21import logging
22import multiprocessing.synchronize
23import random
24import sys
25import time
26from collections.abc import (
27 Callable,
28 Iterable,
29 Mapping,
30 MutableMapping,
31 Sequence,
32)
33from dataclasses import dataclass
34from datetime import date
35from multiprocessing import Value
36from typing import Any, Final, Literal, cast
37from urllib.parse import urlencode
39import dill # type: ignore[import-untyped] # nosec: B403
40import elasticapm
41import orjson as json
42import typed_stream
43from redis.asyncio import Redis
44from tornado.httpclient import AsyncHTTPClient
45from tornado.web import Application, HTTPError
46from UltraDict import UltraDict # type: ignore[import-untyped]
48from .. import (
49 CA_BUNDLE_PATH,
50 DIR as ROOT_DIR,
51 EVENT_REDIS,
52 EVENT_SHUTDOWN,
53 NAME,
54 ORJSON_OPTIONS,
55 pytest_is_running,
56)
57from ..utils.request_handler import HTMLRequestHandler
58from ..utils.utils import ModuleInfo, Permission, ratelimit
60DIR: Final = ROOT_DIR / "quotes"
62LOGGER: Final = logging.getLogger(__name__)
64API_URL: Final[str] = "https://zitate.prapsschnalinen.de/api"
67# pylint: disable-next=too-few-public-methods
68class UltraDictType[K, V](MutableMapping[K, V], abc.ABC):
69 """The type of the shared dictionaries."""
71 lock: multiprocessing.synchronize.RLock
74QUOTES_CACHE: Final[UltraDictType[int, Quote]] = UltraDict(
75 buffer_size=1024**2, serializer=dill
76)
77AUTHORS_CACHE: Final[UltraDictType[int, Author]] = UltraDict(
78 buffer_size=1024**2, serializer=dill
79)
80WRONG_QUOTES_CACHE: Final[UltraDictType[tuple[int, int], WrongQuote]] = (
81 UltraDict(buffer_size=1024**2, serializer=dill)
82)
84MAX_QUOTES_ID = Value("Q", 0)
85MAX_AUTHORS_ID = Value("Q", 0)
88@dataclass(init=False, slots=True)
89class QuotesObjBase(abc.ABC):
90 """An object with an id."""
92 id: int
94 @abc.abstractmethod
95 async def fetch_new_data(self) -> QuotesObjBase:
96 """Fetch new data from the API."""
97 raise NotImplementedError
99 # pylint: disable=unused-argument
100 def get_id_as_str(self, minify: bool = False) -> str:
101 """Get the id of the object as a string."""
102 return str(self.id)
104 @abc.abstractmethod
105 def get_path(self) -> str:
106 """Return the path to the Object."""
107 raise NotImplementedError
110@dataclass(slots=True)
111class Author(QuotesObjBase):
112 """The author object with a name."""
114 name: str
115 # tuple(url_to_info, info_str, creation_date)
116 info: None | tuple[str, None | str, date]
118 def __str__(self) -> str:
119 """Return the name of the author."""
120 return self.name
122 async def fetch_new_data(self) -> Author:
123 """Fetch new data from the API."""
124 return parse_author(
125 await make_api_request(
126 f"authors/{self.id}", entity_should_exist=True
127 )
128 )
130 def get_path(self) -> str:
131 """Return the path to the author info."""
132 return f"/zitate/info/a/{self.id}"
134 def to_json(self) -> dict[str, Any]:
135 """Get the author as JSON."""
136 return {
137 "id": self.id,
138 "name": str(self),
139 "path": self.get_path(),
140 "info": (
141 {
142 "source": self.info[0],
143 "text": self.info[1],
144 "date": self.info[2].isoformat(),
145 }
146 if self.info
147 else None
148 ),
149 }
152@dataclass(slots=True)
153class Quote(QuotesObjBase):
154 """The quote object with a quote text and an author."""
156 quote: str
157 author_id: int
159 def __str__(self) -> str:
160 """Return the content of the quote."""
161 return self.quote.strip()
163 @property
164 def author(self) -> Author:
165 """Get the corresponding author object."""
166 return AUTHORS_CACHE[self.author_id]
168 async def fetch_new_data(self) -> Quote:
169 """Fetch new data from the API."""
170 return parse_quote(
171 await make_api_request(
172 f"quotes/{self.id}", entity_should_exist=True
173 ),
174 self,
175 )
177 def get_path(self) -> str:
178 """Return the path to the quote info."""
179 return f"/zitate/info/z/{self.id}"
181 def to_json(self) -> dict[str, Any]:
182 """Get the quote as JSON."""
183 return {
184 "id": self.id,
185 "quote": str(self),
186 "author": self.author.to_json(),
187 "path": self.get_path(),
188 }
191@dataclass(slots=True)
192class WrongQuote(QuotesObjBase):
193 """The wrong quote object with a quote, an author and a rating."""
195 quote_id: int
196 author_id: int
197 rating: int
199 def __str__(self) -> str:
200 r"""
201 Return the wrong quote.
203 like: '»quote« - author'.
204 """
205 return f"»{self.quote}« - {self.author}"
207 @property
208 def author(self) -> Author:
209 """Get the corresponding author object."""
210 return AUTHORS_CACHE[self.author_id]
212 async def fetch_new_data(self) -> WrongQuote:
213 """Fetch new data from the API."""
214 if self.id == -1:
215 api_data = await make_api_request(
216 "wrongquotes",
217 {
218 "quote": str(self.quote_id),
219 "simulate": "true",
220 "author": str(self.author_id),
221 },
222 entity_should_exist=True,
223 )
224 if api_data:
225 api_data = api_data[0]
226 else:
227 api_data = await make_api_request(
228 f"wrongquotes/{self.id}", entity_should_exist=True
229 )
230 if not api_data:
231 return self
232 return parse_wrong_quote(api_data, self)
234 def get_id(self) -> tuple[int, int]:
235 """
236 Get the id of the quote and the author in a tuple.
238 :return tuple(quote_id, author_id)
239 """
240 return self.quote_id, self.author_id
242 def get_id_as_str(self, minify: bool = False) -> str:
243 """
244 Get the id of the wrong quote as a string.
246 Format: quote_id-author_id
247 """
248 if minify and self.id != -1:
249 return str(self.id)
250 return f"{self.quote_id}-{self.author_id}"
252 def get_path(self) -> str:
253 """Return the path to the wrong quote."""
254 return f"/zitate/{self.get_id_as_str()}"
256 @property
257 def quote(self) -> Quote:
258 """Get the corresponding quote object."""
259 return QUOTES_CACHE[self.quote_id]
261 def to_json(self) -> dict[str, Any]:
262 """Get the wrong quote as JSON."""
263 return {
264 "id": self.get_id_as_str(),
265 "quote": self.quote.to_json(),
266 "author": self.author.to_json(),
267 "rating": self.rating,
268 "path": self.get_path(),
269 }
271 async def vote(
272 # pylint: disable=unused-argument
273 self,
274 vote: Literal[-1, 1],
275 lazy: bool = False,
276 ) -> WrongQuote:
277 """Vote for the wrong quote."""
278 if self.id == -1:
279 raise ValueError("Can't vote for a not existing quote.")
280 # if lazy: # simulate the vote and do the actual voting later
281 # self.rating += vote
282 # asyncio.get_running_loop().call_soon_threadsafe(
283 # self.vote,
284 # vote,
285 # )
286 # return self
287 # do the voting
288 return parse_wrong_quote(
289 await make_api_request(
290 f"wrongquotes/{self.id}",
291 method="POST",
292 body={"vote": str(vote)},
293 entity_should_exist=True,
294 ),
295 self,
296 )
299def get_wrong_quotes(
300 filter_fun: None | Callable[[WrongQuote], bool] = None,
301 *,
302 sort: bool = False, # sorted by rating
303 filter_real_quotes: bool = True,
304 shuffle: bool = False,
305) -> Sequence[WrongQuote]:
306 """Get cached wrong quotes."""
307 if shuffle and sort:
308 raise ValueError("Sort and shuffle can't be both true.")
309 wqs: list[WrongQuote] = list(WRONG_QUOTES_CACHE.values())
310 if filter_fun or filter_real_quotes:
311 for i in reversed(range(len(wqs))):
312 if (filter_fun and not filter_fun(wqs[i])) or (
313 filter_real_quotes
314 and wqs[i].quote.author_id == wqs[i].author_id
315 ):
316 del wqs[i]
317 if shuffle:
318 random.shuffle(wqs)
319 elif sort:
320 wqs.sort(key=lambda wq: wq.rating, reverse=True)
321 return wqs
324def get_quotes(
325 filter_fun: None | Callable[[Quote], bool] = None,
326 shuffle: bool = False,
327) -> list[Quote]:
328 """Get cached quotes."""
329 quotes: list[Quote] = list(QUOTES_CACHE.values())
330 if filter_fun:
331 for i in reversed(range(len(quotes))):
332 if not filter_fun(quotes[i]):
333 del quotes[i]
334 if shuffle:
335 random.shuffle(quotes)
336 return quotes
339def get_authors(
340 filter_fun: None | Callable[[Author], bool] = None,
341 shuffle: bool = False,
342) -> list[Author]:
343 """Get cached authors."""
344 authors: list[Author] = list(AUTHORS_CACHE.values())
345 if filter_fun:
346 for i in reversed(range(len(authors))):
347 if not filter_fun(authors[i]):
348 del authors[i]
349 if shuffle:
350 random.shuffle(authors)
351 return authors
354async def make_api_request(
355 endpoint: str,
356 args: Mapping[str, str] | None = None,
357 *,
358 entity_should_exist: bool,
359 method: Literal["GET", "POST"] = "GET",
360 body: None | Mapping[str, str] = None,
361) -> Any: # TODO: list[dict[str, Any]] | dict[str, Any]:
362 """Make API request and return the result as dict."""
363 if pytest_is_running():
364 return None
365 query = f"?{urlencode(args)}" if args else ""
366 url = f"{API_URL}/{endpoint}{query}"
367 body_str = urlencode(body) if body else body
368 response = await AsyncHTTPClient().fetch(
369 url,
370 method=method,
371 headers={"Content-Type": "application/x-www-form-urlencoded"},
372 body=body_str,
373 raise_error=False,
374 ca_certs=CA_BUNDLE_PATH,
375 )
376 if response.code != 200:
377 normed_response_code = (
378 400
379 if not entity_should_exist and response.code == 500
380 else response.code
381 )
382 LOGGER.log(
383 logging.ERROR if normed_response_code >= 500 else logging.WARNING,
384 "%s request to %r with body=%r failed with code=%d and reason=%r",
385 method,
386 url,
387 body_str,
388 response.code,
389 response.reason,
390 )
391 raise HTTPError(
392 normed_response_code if normed_response_code in {400, 404} else 503,
393 reason=(
394 f"{API_URL}/{endpoint} returned: "
395 f"{response.code} {response.reason}"
396 ),
397 )
398 return json.loads(response.body)
401def fix_author_name(name: str) -> str:
402 """Fix common mistakes in authors."""
403 if len(name) > 2 and name.startswith("(") and name.endswith(")"):
404 # remove () from author name, that shouldn't be there
405 name = name[1:-1]
406 return name.strip()
409def parse_author(json_data: Mapping[str, Any]) -> Author:
410 """Parse an author from JSON data."""
411 id_ = int(json_data["id"])
412 name = fix_author_name(json_data["author"])
414 with AUTHORS_CACHE.lock:
415 author = AUTHORS_CACHE.get(id_)
416 if author is None:
417 # pylint: disable-next=too-many-function-args
418 author = Author(id_, name, None)
419 MAX_AUTHORS_ID.value = max(MAX_AUTHORS_ID.value, id_)
420 elif author.name != name:
421 author.name = name
422 author.info = None # reset info
424 AUTHORS_CACHE[author.id] = author
426 return author
429def fix_quote_str(quote_str: str) -> str:
430 """Fix common mistakes in quotes."""
431 if (
432 len(quote_str) > 2
433 and quote_str.startswith(('"', "„", "“"))
434 and quote_str.endswith(('"', "“", "”"))
435 ):
436 # remove quotation marks from quote, that shouldn't be there
437 quote_str = quote_str[1:-1]
439 return quote_str.strip()
442def parse_quote(
443 json_data: Mapping[str, Any], quote: None | Quote = None
444) -> Quote:
445 """Parse a quote from JSON data."""
446 quote_id = int(json_data["id"])
447 author = parse_author(json_data["author"]) # update author
448 quote_str = fix_quote_str(json_data["quote"])
450 with QUOTES_CACHE.lock:
451 if quote is None: # no quote supplied, try getting it from cache
452 quote = QUOTES_CACHE.get(quote_id)
453 if quote is None: # new quote
454 # pylint: disable=too-many-function-args
455 quote = Quote(quote_id, quote_str, author.id)
456 MAX_QUOTES_ID.value = max(MAX_QUOTES_ID.value, quote.id)
457 else: # quote was already saved
458 quote.quote = quote_str
459 quote.author_id = author.id
461 QUOTES_CACHE[quote.id] = quote
463 return quote
466def parse_wrong_quote(
467 json_data: Mapping[str, Any], wrong_quote: None | WrongQuote = None
468) -> WrongQuote:
469 """Parse a wrong quote and update the cache."""
470 quote = parse_quote(json_data["quote"])
471 author = parse_author(json_data["author"])
473 id_tuple = (quote.id, author.id)
474 rating = json_data["rating"]
475 wrong_quote_id = int(json_data.get("id") or -1)
477 if wrong_quote is None:
478 with WRONG_QUOTES_CACHE.lock:
479 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple)
480 if wrong_quote is None:
481 wrong_quote = (
482 WrongQuote( # pylint: disable=unexpected-keyword-arg
483 id=wrong_quote_id,
484 quote_id=quote.id,
485 author_id=author.id,
486 rating=rating,
487 )
488 )
489 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
490 return wrong_quote
492 # make sure the wrong quote is the correct one
493 if (wrong_quote.quote_id, wrong_quote.author_id) != id_tuple:
494 raise HTTPError(reason="ERROR: -41")
496 # update the data of the wrong quote
497 if wrong_quote.rating != rating:
498 wrong_quote.rating = rating
499 if wrong_quote.id != wrong_quote_id:
500 wrong_quote.id = wrong_quote_id
502 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
504 return wrong_quote
507async def parse_list_of_quote_data(
508 json_list: str | Iterable[Mapping[str, Any]],
509 parse_fun: Callable[[Mapping[str, Any]], QuotesObjBase],
510) -> tuple[QuotesObjBase, ...]:
511 """Parse a list of quote data."""
512 if not json_list:
513 return ()
514 if isinstance(json_list, str):
515 json_list = cast(list[dict[str, Any]], json.loads(json_list))
516 return_list = []
517 for json_data in json_list:
518 _ = parse_fun(json_data)
519 await asyncio.sleep(0)
520 return_list.append(_)
521 return tuple(return_list)
524async def update_cache_periodically(
525 app: Application, worker: int | None
526) -> None:
527 """Start updating the cache every hour."""
528 # pylint: disable=too-complex, too-many-branches
529 if "/troet" in typed_stream.Stream(
530 cast(Iterable[ModuleInfo], app.settings.get("MODULE_INFOS", ()))
531 ).map(lambda m: m.path):
532 app.settings["SHOW_SHARING_ON_MASTODON"] = True
533 if worker:
534 return
535 with contextlib.suppress(asyncio.TimeoutError):
536 await asyncio.wait_for(EVENT_REDIS.wait(), 5)
537 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS"))
538 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev")
539 apm: None | elasticapm.Client
540 if EVENT_REDIS.is_set(): # pylint: disable=too-many-nested-blocks
541 await parse_list_of_quote_data(
542 await redis.get(f"{prefix}:cached-quote-data:authors"), # type: ignore[arg-type] # noqa: B950
543 parse_author,
544 )
545 await parse_list_of_quote_data(
546 await redis.get(f"{prefix}:cached-quote-data:quotes"), # type: ignore[arg-type] # noqa: B950
547 parse_quote,
548 )
549 await parse_list_of_quote_data(
550 await redis.get(f"{prefix}:cached-quote-data:wrongquotes"), # type: ignore[arg-type] # noqa: B950
551 parse_wrong_quote,
552 )
553 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE:
554 last_update = await redis.get(
555 f"{prefix}:cached-quote-data:last-update"
556 )
557 if last_update:
558 last_update_int = int(last_update)
559 since_last_update = int(time.time()) - last_update_int
560 if 0 <= since_last_update < 60 * 60:
561 # wait until the last update is at least one hour old
562 update_cache_in = 60 * 60 - since_last_update
563 if not sys.flags.dev_mode and update_cache_in > 60:
564 # if in production mode update wrong quotes just to be sure
565 try:
566 await update_cache(
567 app, update_quotes=False, update_authors=False
568 )
569 except Exception: # pylint: disable=broad-except
570 LOGGER.exception("Updating quotes cache failed")
571 apm = app.settings.get("ELASTIC_APM", {}).get(
572 "CLIENT"
573 )
574 if apm:
575 apm.capture_exception() # type: ignore[no-untyped-call]
576 else:
577 LOGGER.info("Updated quotes cache successfully")
578 LOGGER.info(
579 "Next update of quotes cache in %d seconds",
580 update_cache_in,
581 )
582 await asyncio.sleep(update_cache_in)
584 # update the cache every hour
585 failed = 0
586 while not EVENT_SHUTDOWN.is_set(): # pylint: disable=while-used
587 try:
588 await update_cache(app)
589 except Exception: # pylint: disable=broad-except
590 LOGGER.exception("Updating quotes cache failed")
591 if apm := app.settings.get("ELASTIC_APM", {}).get("CLIENT"):
592 apm.capture_exception()
593 failed += 1
594 await asyncio.sleep(pow(min(failed * 2, 60), 2)) # 4,16,...,60*60
595 else:
596 LOGGER.info("Updated quotes cache successfully")
597 failed = 0
598 await asyncio.sleep(60 * 60)
601async def update_cache(
602 app: Application,
603 update_wrong_quotes: bool = True,
604 update_quotes: bool = True,
605 update_authors: bool = True,
606) -> None:
607 """Fill the cache with all data from the API."""
608 LOGGER.info("Updating quotes cache")
609 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS"))
610 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev")
611 redis_available = EVENT_REDIS.is_set()
613 if update_wrong_quotes:
614 await parse_list_of_quote_data(
615 wq_data := await make_api_request(
616 "wrongquotes", entity_should_exist=True
617 ),
618 parse_wrong_quote,
619 )
620 if wq_data and redis_available:
621 await redis.setex(
622 f"{prefix}:cached-quote-data:wrongquotes",
623 60 * 60 * 24 * 30,
624 json.dumps(wq_data, option=ORJSON_OPTIONS),
625 )
627 if update_quotes:
628 await parse_list_of_quote_data(
629 quotes_data := await make_api_request(
630 "quotes", entity_should_exist=True
631 ),
632 parse_quote,
633 )
634 if quotes_data and redis_available:
635 await redis.setex(
636 f"{prefix}:cached-quote-data:quotes",
637 60 * 60 * 24 * 30,
638 json.dumps(quotes_data, option=ORJSON_OPTIONS),
639 )
641 if update_authors:
642 await parse_list_of_quote_data(
643 authors_data := await make_api_request(
644 "authors", entity_should_exist=True
645 ),
646 parse_author,
647 )
648 if authors_data and redis_available:
649 await redis.setex(
650 f"{prefix}:cached-quote-data:authors",
651 60 * 60 * 24 * 30,
652 json.dumps(authors_data, option=ORJSON_OPTIONS),
653 )
655 if (
656 redis_available
657 and update_wrong_quotes
658 and update_quotes
659 and update_authors
660 ):
661 await redis.setex(
662 f"{prefix}:cached-quote-data:last-update",
663 60 * 60 * 24 * 30,
664 int(time.time()),
665 )
668async def get_author_by_id(author_id: int) -> Author:
669 """Get an author by its id."""
670 author = AUTHORS_CACHE.get(author_id)
671 if author is not None:
672 return author
673 return parse_author(
674 await make_api_request(
675 f"authors/{author_id}", entity_should_exist=False
676 )
677 )
680async def get_quote_by_id(quote_id: int) -> Quote:
681 """Get a quote by its id."""
682 quote = QUOTES_CACHE.get(quote_id)
683 if quote is not None:
684 return quote
685 return parse_quote(
686 await make_api_request(f"quotes/{quote_id}", entity_should_exist=False)
687 )
690async def get_wrong_quote(
691 quote_id: int, author_id: int, use_cache: bool = True
692) -> WrongQuote:
693 """Get a wrong quote with a quote id and an author id."""
694 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id))
695 if wrong_quote:
696 if use_cache:
697 return wrong_quote
698 # do not use cache, so update the wrong quote data
699 return await wrong_quote.fetch_new_data()
700 # wrong quote not in cache
701 if use_cache and quote_id in QUOTES_CACHE and author_id in AUTHORS_CACHE:
702 # we don't need to request anything, as the wrong_quote probably has
703 # no ratings just use the cached quote and author
704 # pylint: disable-next=too-many-function-args
705 return WrongQuote(-1, quote_id, author_id, 0)
706 # request the wrong quote from the API
707 result = await make_api_request(
708 "wrongquotes",
709 {
710 "quote": str(quote_id),
711 "simulate": "true",
712 "author": str(author_id),
713 },
714 entity_should_exist=False,
715 )
716 if result:
717 return parse_wrong_quote(result[0])
719 raise HTTPError(404, reason="Falsches Zitat nicht gefunden")
722async def get_rating_by_id(quote_id: int, author_id: int) -> int:
723 """Get the rating of a wrong quote."""
724 return (await get_wrong_quote(quote_id, author_id)).rating
727def get_random_quote_id() -> int:
728 """Get random quote id."""
729 return random.randint(1, MAX_QUOTES_ID.value) # nosec: B311
732def get_random_author_id() -> int:
733 """Get random author id."""
734 return random.randint(1, MAX_AUTHORS_ID.value) # nosec: B311
737def get_random_id() -> tuple[int, int]:
738 """Get random wrong quote id."""
739 return (
740 get_random_quote_id(),
741 get_random_author_id(),
742 )
745async def create_wq_and_vote(
746 vote: Literal[-1, 1],
747 quote_id: int,
748 author_id: int,
749 contributed_by: str,
750 fast: bool = False,
751) -> WrongQuote:
752 """
753 Vote for the wrong_quote with the API.
755 If the wrong_quote doesn't exist yet, create it.
756 """
757 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id))
758 if wrong_quote and wrong_quote.id != -1:
759 return await wrong_quote.vote(vote, fast)
760 # we don't know the wrong_quote_id, so we have to create the wrong_quote
761 wrong_quote = parse_wrong_quote(
762 await make_api_request(
763 "wrongquotes",
764 method="POST",
765 body={
766 "quote": str(quote_id),
767 "author": str(author_id),
768 "contributed_by": contributed_by,
769 },
770 entity_should_exist=False,
771 )
772 )
773 return await wrong_quote.vote(vote, lazy=True)
776class QuoteReadyCheckHandler(HTMLRequestHandler):
777 """Class that checks if quotes have been loaded."""
779 async def check_ready(self) -> None:
780 """Fail if quotes aren't ready yet."""
781 if not WRONG_QUOTES_CACHE:
782 # should work in a few seconds, the quotes just haven't loaded yet
783 self.set_header("Retry-After", "5")
784 raise HTTPError(503, reason="Service available in a few seconds")
786 async def prepare(self) -> None: # noqa: D102
787 await super().prepare()
788 if self.request.method != "OPTIONS":
789 await self.check_ready()
791 if ( # pylint: disable=too-many-boolean-expressions
792 self.settings.get("RATELIMITS")
793 and self.request.method not in {"HEAD", "OPTIONS"}
794 and not self.is_authorized(Permission.RATELIMITS)
795 and not self.crawler
796 and (
797 self.request.path.endswith(".xlsx")
798 or self.content_type == "application/vnd.ms-excel"
799 )
800 ):
801 if self.settings.get("UNDER_ATTACK") or not EVENT_REDIS.is_set():
802 raise HTTPError(503)
804 ratelimited, headers = await ratelimit(
805 self.redis,
806 self.redis_prefix,
807 str(self.request.remote_ip),
808 bucket="quotes:image:xlsx",
809 max_burst=4,
810 count_per_period=1,
811 period=60,
812 tokens=1 if self.request.method != "HEAD" else 0,
813 )
815 for header, value in headers.items():
816 self.set_header(header, value)
818 if ratelimited:
819 if self.now.date() == date(self.now.year, 4, 20):
820 self.set_status(420)
821 self.write_error(420)
822 else:
823 self.set_status(429)
824 self.write_error(429)