Coverage for an_website/quotes/utils.py: 64.793%
338 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"""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, datetime, timezone
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, emojify, ratelimit
60DIR: Final = ROOT_DIR / "quotes"
62LOGGER: Final = logging.getLogger(__name__)
64API_URL: Final[str] = "https://zitate.prapsschnalinen.de/api"
67# pylint: disable-next=undefined-variable,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 (
121 emojify(self.name.strip())
122 if (now := datetime.now(timezone.utc)).day == 1 and now.month == 4
123 else self.name.strip()
124 )
126 async def fetch_new_data(self) -> Author:
127 """Fetch new data from the API."""
128 return parse_author(
129 await make_api_request(
130 f"authors/{self.id}", entity_should_exist=True
131 )
132 )
134 def get_path(self) -> str:
135 """Return the path to the author info."""
136 return f"/zitate/info/a/{self.id}"
138 def to_json(self) -> dict[str, Any]:
139 """Get the author as JSON."""
140 return {
141 "id": self.id,
142 "name": str(self),
143 "path": self.get_path(),
144 "info": (
145 {
146 "source": self.info[0],
147 "text": self.info[1],
148 "date": self.info[2].isoformat(),
149 }
150 if self.info
151 else None
152 ),
153 }
156@dataclass(slots=True)
157class Quote(QuotesObjBase):
158 """The quote object with a quote text and an author."""
160 quote: str
161 author_id: int
163 def __str__(self) -> str:
164 """Return the content of the quote."""
165 return (
166 emojify(self.quote.strip())
167 if (now := datetime.now(timezone.utc)).day == 1 and now.month == 4
168 else self.quote.strip()
169 )
171 @property
172 def author(self) -> Author:
173 """Get the corresponding author object."""
174 return AUTHORS_CACHE[self.author_id]
176 async def fetch_new_data(self) -> Quote:
177 """Fetch new data from the API."""
178 return parse_quote(
179 await make_api_request(
180 f"quotes/{self.id}", entity_should_exist=True
181 ),
182 self,
183 )
185 def get_path(self) -> str:
186 """Return the path to the quote info."""
187 return f"/zitate/info/z/{self.id}"
189 def to_json(self) -> dict[str, Any]:
190 """Get the quote as JSON."""
191 return {
192 "id": self.id,
193 "quote": str(self),
194 "author": self.author.to_json(),
195 "path": self.get_path(),
196 }
199@dataclass(slots=True)
200class WrongQuote(QuotesObjBase):
201 """The wrong quote object with a quote, an author and a rating."""
203 quote_id: int
204 author_id: int
205 rating: int
207 def __str__(self) -> str:
208 r"""
209 Return the wrong quote.
211 like: '»quote« - author'.
212 """
213 return f"»{self.quote}« - {self.author}"
215 @property
216 def author(self) -> Author:
217 """Get the corresponding author object."""
218 return AUTHORS_CACHE[self.author_id]
220 async def fetch_new_data(self) -> WrongQuote:
221 """Fetch new data from the API."""
222 if self.id == -1:
223 api_data = await make_api_request(
224 "wrongquotes",
225 {
226 "quote": str(self.quote_id),
227 "simulate": "true",
228 "author": str(self.author_id),
229 },
230 entity_should_exist=True,
231 )
232 if api_data:
233 api_data = api_data[0]
234 else:
235 api_data = await make_api_request(
236 f"wrongquotes/{self.id}", entity_should_exist=True
237 )
238 if not api_data:
239 return self
240 return parse_wrong_quote(api_data, self)
242 def get_id(self) -> tuple[int, int]:
243 """
244 Get the id of the quote and the author in a tuple.
246 :return tuple(quote_id, author_id)
247 """
248 return self.quote_id, self.author_id
250 def get_id_as_str(self, minify: bool = False) -> str:
251 """
252 Get the id of the wrong quote as a string.
254 Format: quote_id-author_id
255 """
256 if minify and self.id != -1:
257 return str(self.id)
258 return f"{self.quote_id}-{self.author_id}"
260 def get_path(self) -> str:
261 """Return the path to the wrong quote."""
262 return f"/zitate/{self.get_id_as_str()}"
264 @property
265 def quote(self) -> Quote:
266 """Get the corresponding quote object."""
267 return QUOTES_CACHE[self.quote_id]
269 def to_json(self) -> dict[str, Any]:
270 """Get the wrong quote as JSON."""
271 return {
272 "id": self.get_id_as_str(),
273 "quote": self.quote.to_json(),
274 "author": self.author.to_json(),
275 "rating": self.rating,
276 "path": self.get_path(),
277 }
279 async def vote(
280 # pylint: disable=unused-argument
281 self,
282 vote: Literal[-1, 1],
283 lazy: bool = False,
284 ) -> WrongQuote:
285 """Vote for the wrong quote."""
286 if self.id == -1:
287 raise ValueError("Can't vote for a not existing quote.")
288 # if lazy: # simulate the vote and do the actual voting later
289 # self.rating += vote
290 # asyncio.get_running_loop().call_soon_threadsafe(
291 # self.vote,
292 # vote,
293 # )
294 # return self
295 # do the voting
296 return parse_wrong_quote(
297 await make_api_request(
298 f"wrongquotes/{self.id}",
299 method="POST",
300 body={"vote": str(vote)},
301 entity_should_exist=True,
302 ),
303 self,
304 )
307def get_wrong_quotes(
308 filter_fun: None | Callable[[WrongQuote], bool] = None,
309 *,
310 sort: bool = False, # sorted by rating
311 filter_real_quotes: bool = True,
312 shuffle: bool = False,
313) -> Sequence[WrongQuote]:
314 """Get cached wrong quotes."""
315 if shuffle and sort:
316 raise ValueError("Sort and shuffle can't be both true.")
317 wqs: list[WrongQuote] = list(WRONG_QUOTES_CACHE.values())
318 if filter_fun or filter_real_quotes:
319 for i in reversed(range(len(wqs))):
320 if (filter_fun and not filter_fun(wqs[i])) or (
321 filter_real_quotes
322 and wqs[i].quote.author_id == wqs[i].author_id
323 ):
324 del wqs[i]
325 if shuffle:
326 random.shuffle(wqs)
327 elif sort:
328 wqs.sort(key=lambda wq: wq.rating, reverse=True)
329 return wqs
332def get_quotes(
333 filter_fun: None | Callable[[Quote], bool] = None,
334 shuffle: bool = False,
335) -> list[Quote]:
336 """Get cached quotes."""
337 quotes: list[Quote] = list(QUOTES_CACHE.values())
338 if filter_fun:
339 for i in reversed(range(len(quotes))):
340 if not filter_fun(quotes[i]):
341 del quotes[i]
342 if shuffle:
343 random.shuffle(quotes)
344 return quotes
347def get_authors(
348 filter_fun: None | Callable[[Author], bool] = None,
349 shuffle: bool = False,
350) -> list[Author]:
351 """Get cached authors."""
352 authors: list[Author] = list(AUTHORS_CACHE.values())
353 if filter_fun:
354 for i in reversed(range(len(authors))):
355 if not filter_fun(authors[i]):
356 del authors[i]
357 if shuffle:
358 random.shuffle(authors)
359 return authors
362async def make_api_request(
363 endpoint: str,
364 args: Mapping[str, str] | None = None,
365 *,
366 entity_should_exist: bool,
367 method: Literal["GET", "POST"] = "GET",
368 body: None | Mapping[str, str] = None,
369) -> Any: # TODO: list[dict[str, Any]] | dict[str, Any]:
370 """Make API request and return the result as dict."""
371 if pytest_is_running():
372 return None
373 query = f"?{urlencode(args)}" if args else ""
374 url = f"{API_URL}/{endpoint}{query}"
375 body_str = urlencode(body) if body else body
376 response = await AsyncHTTPClient().fetch(
377 url,
378 method=method,
379 headers={"Content-Type": "application/x-www-form-urlencoded"},
380 body=body_str,
381 raise_error=False,
382 ca_certs=CA_BUNDLE_PATH,
383 )
384 if response.code != 200:
385 normed_response_code = (
386 400
387 if not entity_should_exist and response.code == 500
388 else response.code
389 )
390 LOGGER.log(
391 logging.ERROR if normed_response_code >= 500 else logging.WARNING,
392 "%s request to %r with body=%r failed with code=%d and reason=%r",
393 method,
394 url,
395 body_str,
396 response.code,
397 response.reason,
398 )
399 raise HTTPError(
400 normed_response_code if normed_response_code in {400, 404} else 503,
401 reason=(
402 f"{API_URL}/{endpoint} returned: "
403 f"{response.code} {response.reason}"
404 ),
405 )
406 return json.loads(response.body)
409def fix_author_name(name: str) -> str:
410 """Fix common mistakes in authors."""
411 if len(name) > 2 and name.startswith("(") and name.endswith(")"):
412 # remove () from author name, that shouldn't be there
413 name = name[1:-1]
414 return name.strip()
417def parse_author(json_data: Mapping[str, Any]) -> Author:
418 """Parse an author from JSON data."""
419 id_ = int(json_data["id"])
420 name = fix_author_name(json_data["author"])
422 with AUTHORS_CACHE.lock:
423 author = AUTHORS_CACHE.get(id_)
424 if author is None:
425 # pylint: disable-next=too-many-function-args
426 author = Author(id_, name, None)
427 MAX_AUTHORS_ID.value = max(MAX_AUTHORS_ID.value, id_)
428 elif author.name != name:
429 author.name = name
430 author.info = None # reset info
432 AUTHORS_CACHE[author.id] = author
434 return author
437def fix_quote_str(quote_str: str) -> str:
438 """Fix common mistakes in quotes."""
439 if (
440 len(quote_str) > 2
441 and quote_str.startswith(('"', "„", "“"))
442 and quote_str.endswith(('"', "“", "”"))
443 ):
444 # remove quotation marks from quote, that shouldn't be there
445 quote_str = quote_str[1:-1]
447 return quote_str.strip()
450def parse_quote(
451 json_data: Mapping[str, Any], quote: None | Quote = None
452) -> Quote:
453 """Parse a quote from JSON data."""
454 quote_id = int(json_data["id"])
455 author = parse_author(json_data["author"]) # update author
456 quote_str = fix_quote_str(json_data["quote"])
458 with QUOTES_CACHE.lock:
459 if quote is None: # no quote supplied, try getting it from cache
460 quote = QUOTES_CACHE.get(quote_id)
461 if quote is None: # new quote
462 # pylint: disable=too-many-function-args
463 quote = Quote(quote_id, quote_str, author.id)
464 MAX_QUOTES_ID.value = max(MAX_QUOTES_ID.value, quote.id)
465 else: # quote was already saved
466 quote.quote = quote_str
467 quote.author_id = author.id
469 QUOTES_CACHE[quote.id] = quote
471 return quote
474def parse_wrong_quote(
475 json_data: Mapping[str, Any], wrong_quote: None | WrongQuote = None
476) -> WrongQuote:
477 """Parse a wrong quote and update the cache."""
478 quote = parse_quote(json_data["quote"])
479 author = parse_author(json_data["author"])
481 id_tuple = (quote.id, author.id)
482 rating = json_data["rating"]
483 wrong_quote_id = int(json_data.get("id") or -1)
485 if wrong_quote is None:
486 with WRONG_QUOTES_CACHE.lock:
487 wrong_quote = WRONG_QUOTES_CACHE.get(id_tuple)
488 if wrong_quote is None:
489 wrong_quote = (
490 WrongQuote( # pylint: disable=unexpected-keyword-arg
491 id=wrong_quote_id,
492 quote_id=quote.id,
493 author_id=author.id,
494 rating=rating,
495 )
496 )
497 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
498 return wrong_quote
500 # make sure the wrong quote is the correct one
501 if (wrong_quote.quote_id, wrong_quote.author_id) != id_tuple:
502 raise HTTPError(reason="ERROR: -41")
504 # update the data of the wrong quote
505 if wrong_quote.rating != rating:
506 wrong_quote.rating = rating
507 if wrong_quote.id != wrong_quote_id:
508 wrong_quote.id = wrong_quote_id
510 WRONG_QUOTES_CACHE[id_tuple] = wrong_quote
512 return wrong_quote
515async def parse_list_of_quote_data(
516 json_list: str | Iterable[Mapping[str, Any]],
517 parse_fun: Callable[[Mapping[str, Any]], QuotesObjBase],
518) -> tuple[QuotesObjBase, ...]:
519 """Parse a list of quote data."""
520 if not json_list:
521 return ()
522 if isinstance(json_list, str):
523 json_list = cast(list[dict[str, Any]], json.loads(json_list))
524 return_list = []
525 for json_data in json_list:
526 _ = parse_fun(json_data)
527 await asyncio.sleep(0)
528 return_list.append(_)
529 return tuple(return_list)
532async def update_cache_periodically(
533 app: Application, worker: int | None
534) -> None:
535 """Start updating the cache every hour."""
536 # pylint: disable=too-complex, too-many-branches
537 if "/troet" in typed_stream.Stream(
538 cast(Iterable[ModuleInfo], app.settings.get("MODULE_INFOS", ()))
539 ).map(lambda m: m.path):
540 app.settings["SHOW_SHARING_ON_MASTODON"] = True
541 if worker:
542 return
543 with contextlib.suppress(asyncio.TimeoutError):
544 await asyncio.wait_for(EVENT_REDIS.wait(), 5)
545 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS"))
546 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev")
547 apm: None | elasticapm.Client
548 if EVENT_REDIS.is_set(): # pylint: disable=too-many-nested-blocks
549 await parse_list_of_quote_data(
550 await redis.get(f"{prefix}:cached-quote-data:authors"), # type: ignore[arg-type] # noqa: B950
551 parse_author,
552 )
553 await parse_list_of_quote_data(
554 await redis.get(f"{prefix}:cached-quote-data:quotes"), # type: ignore[arg-type] # noqa: B950
555 parse_quote,
556 )
557 await parse_list_of_quote_data(
558 await redis.get(f"{prefix}:cached-quote-data:wrongquotes"), # type: ignore[arg-type] # noqa: B950
559 parse_wrong_quote,
560 )
561 if QUOTES_CACHE and AUTHORS_CACHE and WRONG_QUOTES_CACHE:
562 last_update = await redis.get(
563 f"{prefix}:cached-quote-data:last-update"
564 )
565 if last_update:
566 last_update_int = int(last_update)
567 since_last_update = int(time.time()) - last_update_int
568 if 0 <= since_last_update < 60 * 60:
569 # wait until the last update is at least one hour old
570 update_cache_in = 60 * 60 - since_last_update
571 if not sys.flags.dev_mode and update_cache_in > 60:
572 # if in production mode update wrong quotes just to be sure
573 try:
574 await update_cache(
575 app, update_quotes=False, update_authors=False
576 )
577 except Exception: # pylint: disable=broad-except
578 LOGGER.exception("Updating quotes cache failed")
579 apm = app.settings.get("ELASTIC_APM", {}).get(
580 "CLIENT"
581 )
582 if apm:
583 apm.capture_exception() # type: ignore[no-untyped-call]
584 else:
585 LOGGER.info("Updated quotes cache successfully")
586 LOGGER.info(
587 "Next update of quotes cache in %d seconds",
588 update_cache_in,
589 )
590 await asyncio.sleep(update_cache_in)
592 # update the cache every hour
593 failed = 0
594 while not EVENT_SHUTDOWN.is_set(): # pylint: disable=while-used
595 try:
596 await update_cache(app)
597 except Exception: # pylint: disable=broad-except
598 LOGGER.exception("Updating quotes cache failed")
599 if apm := app.settings.get("ELASTIC_APM", {}).get("CLIENT"):
600 apm.capture_exception()
601 failed += 1
602 await asyncio.sleep(pow(min(failed * 2, 60), 2)) # 4,16,...,60*60
603 else:
604 LOGGER.info("Updated quotes cache successfully")
605 failed = 0
606 await asyncio.sleep(60 * 60)
609async def update_cache(
610 app: Application,
611 update_wrong_quotes: bool = True,
612 update_quotes: bool = True,
613 update_authors: bool = True,
614) -> None:
615 """Fill the cache with all data from the API."""
616 LOGGER.info("Updating quotes cache")
617 redis: Redis[str] = cast("Redis[str]", app.settings.get("REDIS"))
618 prefix: str = app.settings.get("REDIS_PREFIX", NAME).removesuffix("-dev")
619 redis_available = EVENT_REDIS.is_set()
621 if update_wrong_quotes:
622 await parse_list_of_quote_data(
623 wq_data := await make_api_request(
624 "wrongquotes", entity_should_exist=True
625 ),
626 parse_wrong_quote,
627 )
628 if wq_data and redis_available:
629 await redis.setex(
630 f"{prefix}:cached-quote-data:wrongquotes",
631 60 * 60 * 24 * 30,
632 json.dumps(wq_data, option=ORJSON_OPTIONS),
633 )
635 if update_quotes:
636 await parse_list_of_quote_data(
637 quotes_data := await make_api_request(
638 "quotes", entity_should_exist=True
639 ),
640 parse_quote,
641 )
642 if quotes_data and redis_available:
643 await redis.setex(
644 f"{prefix}:cached-quote-data:quotes",
645 60 * 60 * 24 * 30,
646 json.dumps(quotes_data, option=ORJSON_OPTIONS),
647 )
649 if update_authors:
650 await parse_list_of_quote_data(
651 authors_data := await make_api_request(
652 "authors", entity_should_exist=True
653 ),
654 parse_author,
655 )
656 if authors_data and redis_available:
657 await redis.setex(
658 f"{prefix}:cached-quote-data:authors",
659 60 * 60 * 24 * 30,
660 json.dumps(authors_data, option=ORJSON_OPTIONS),
661 )
663 if (
664 redis_available
665 and update_wrong_quotes
666 and update_quotes
667 and update_authors
668 ):
669 await redis.setex(
670 f"{prefix}:cached-quote-data:last-update",
671 60 * 60 * 24 * 30,
672 int(time.time()),
673 )
676async def get_author_by_id(author_id: int) -> Author:
677 """Get an author by its id."""
678 author = AUTHORS_CACHE.get(author_id)
679 if author is not None:
680 return author
681 return parse_author(
682 await make_api_request(
683 f"authors/{author_id}", entity_should_exist=False
684 )
685 )
688async def get_quote_by_id(quote_id: int) -> Quote:
689 """Get a quote by its id."""
690 quote = QUOTES_CACHE.get(quote_id)
691 if quote is not None:
692 return quote
693 return parse_quote(
694 await make_api_request(f"quotes/{quote_id}", entity_should_exist=False)
695 )
698async def get_wrong_quote(
699 quote_id: int, author_id: int, use_cache: bool = True
700) -> WrongQuote:
701 """Get a wrong quote with a quote id and an author id."""
702 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id))
703 if wrong_quote:
704 if use_cache:
705 return wrong_quote
706 # do not use cache, so update the wrong quote data
707 return await wrong_quote.fetch_new_data()
708 # wrong quote not in cache
709 if use_cache and quote_id in QUOTES_CACHE and author_id in AUTHORS_CACHE:
710 # we don't need to request anything, as the wrong_quote probably has
711 # no ratings just use the cached quote and author
712 # pylint: disable-next=too-many-function-args
713 return WrongQuote(-1, quote_id, author_id, 0)
714 # request the wrong quote from the API
715 result = await make_api_request(
716 "wrongquotes",
717 {
718 "quote": str(quote_id),
719 "simulate": "true",
720 "author": str(author_id),
721 },
722 entity_should_exist=False,
723 )
724 if result:
725 return parse_wrong_quote(result[0])
727 raise HTTPError(404, reason="Falsches Zitat nicht gefunden")
730async def get_rating_by_id(quote_id: int, author_id: int) -> int:
731 """Get the rating of a wrong quote."""
732 return (await get_wrong_quote(quote_id, author_id)).rating
735def get_random_quote_id() -> int:
736 """Get random quote id."""
737 return random.randint(1, MAX_QUOTES_ID.value) # nosec: B311
740def get_random_author_id() -> int:
741 """Get random author id."""
742 return random.randint(1, MAX_AUTHORS_ID.value) # nosec: B311
745def get_random_id() -> tuple[int, int]:
746 """Get random wrong quote id."""
747 return (
748 get_random_quote_id(),
749 get_random_author_id(),
750 )
753async def create_wq_and_vote(
754 vote: Literal[-1, 1],
755 quote_id: int,
756 author_id: int,
757 contributed_by: str,
758 fast: bool = False,
759) -> WrongQuote:
760 """
761 Vote for the wrong_quote with the API.
763 If the wrong_quote doesn't exist yet, create it.
764 """
765 wrong_quote = WRONG_QUOTES_CACHE.get((quote_id, author_id))
766 if wrong_quote and wrong_quote.id != -1:
767 return await wrong_quote.vote(vote, fast)
768 # we don't know the wrong_quote_id, so we have to create the wrong_quote
769 wrong_quote = parse_wrong_quote(
770 await make_api_request(
771 "wrongquotes",
772 method="POST",
773 body={
774 "quote": str(quote_id),
775 "author": str(author_id),
776 "contributed_by": contributed_by,
777 },
778 entity_should_exist=False,
779 )
780 )
781 return await wrong_quote.vote(vote, lazy=True)
784class QuoteReadyCheckHandler(HTMLRequestHandler):
785 """Class that checks if quotes have been loaded."""
787 async def check_ready(self) -> None:
788 """Fail if quotes aren't ready yet."""
789 if not WRONG_QUOTES_CACHE:
790 # should work in a few seconds, the quotes just haven't loaded yet
791 self.set_header("Retry-After", "5")
792 raise HTTPError(503, reason="Service available in a few seconds")
794 async def prepare(self) -> None: # noqa: D102
795 await super().prepare()
796 if self.request.method != "OPTIONS":
797 await self.check_ready()
799 if ( # pylint: disable=too-many-boolean-expressions
800 self.settings.get("RATELIMITS")
801 and self.request.method not in {"HEAD", "OPTIONS"}
802 and not self.is_authorized(Permission.RATELIMITS)
803 and not self.crawler
804 and (
805 self.request.path.endswith(".xlsx")
806 or self.content_type == "application/vnd.ms-excel"
807 )
808 ):
809 if self.settings.get("UNDER_ATTACK") or not EVENT_REDIS.is_set():
810 raise HTTPError(503)
812 ratelimited, headers = await ratelimit(
813 self.redis,
814 self.redis_prefix,
815 str(self.request.remote_ip),
816 bucket="quotes:image:xlsx",
817 max_burst=4,
818 count_per_period=1,
819 period=60,
820 tokens=1 if self.request.method != "HEAD" else 0,
821 )
823 for header, value in headers.items():
824 self.set_header(header, value)
826 if ratelimited:
827 if self.now.date() == date(self.now.year, 4, 20):
828 self.set_status(420)
829 self.write_error(420)
830 else:
831 self.set_status(429)
832 self.write_error(429)