Coverage for an_website/quotes/quotes.py: 77.619%
210 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"""
15A page with wrong quotes.
17It displays funny, but wrong, quotes.
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import random
25from asyncio import AbstractEventLoop, Future
26from typing import Any, ClassVar, Final, Literal, TypeAlias, cast
28import regex
29from tornado.web import HTTPError
31from .. import EVENT_REDIS
32from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler
33from ..utils.utils import hash_ip
34from .image import IMAGE_CONTENT_TYPES, create_image
35from .quote_of_the_day import QuoteOfTheDayBaseHandler
36from .utils import (
37 WRONG_QUOTES_CACHE,
38 QuoteReadyCheckHandler,
39 WrongQuote,
40 create_wq_and_vote,
41 get_authors,
42 get_random_id,
43 get_wrong_quote,
44 get_wrong_quotes,
45)
47LOGGER: Final = logging.getLogger(__name__)
50def vote_to_int(vote: str) -> Literal[-1, 0, 1]:
51 """Parse a vote str to the corresponding int."""
52 if vote == "-1":
53 return -1
54 if vote in {"0", "", None}:
55 return 0
56 if vote == "1":
57 return 1
59 int_vote = int(vote)
60 if int_vote < 0:
61 return -1
62 if int_vote > 0:
63 return 1
65 return 0
68RatingFilter: TypeAlias = Literal["w", "n", "unrated", "rated", "all", "smart"]
69SMART_RATING_FILTERS: Final[tuple[RatingFilter, ...]] = (
70 *(("n",) * 1),
71 *(("all",) * 5),
72 *(("w",) * 5),
73)
76def parse_rating_filter(rating_filter_str: str) -> RatingFilter:
77 """Get a rating filter."""
78 match rating_filter_str:
79 case "w":
80 return "w"
81 case "n":
82 return "n"
83 case "unrated":
84 return "unrated"
85 case "rated":
86 return "rated"
87 case "all":
88 return "all"
89 return "smart"
92def get_next_id(rating_filter: RatingFilter) -> tuple[int, int]:
93 """Get the id of the next quote."""
94 if rating_filter == "smart":
95 rating_filter = random.choice(SMART_RATING_FILTERS) # nosec: B311
97 match rating_filter:
98 case "unrated":
99 # get a random quote, but filter out already rated quotes
100 # pylint: disable=while-used
101 while (ids := get_random_id()) in WRONG_QUOTES_CACHE:
102 if WRONG_QUOTES_CACHE[ids].id == -1:
103 # check for wrong quotes that are unrated but in the cache
104 # they don't have a real wrong_quotes_id
105 return ids
106 return ids
107 case "all":
108 return get_random_id()
109 case "w":
110 wrong_quotes = get_wrong_quotes(lambda wq: wq.rating > 0)
111 case "n":
112 wrong_quotes = get_wrong_quotes(lambda wq: wq.rating < 0)
113 case "rated":
114 wrong_quotes = get_wrong_quotes(lambda wq: wq.id not in {-1, None})
115 case _:
116 LOGGER.error("Invalid rating filter %s", rating_filter)
117 return get_random_id()
119 if not wrong_quotes:
120 # invalid rating filter or no wrong quotes with that filter
121 return get_random_id()
123 return random.choice(wrong_quotes).get_id() # nosec: B311
126class QuoteBaseHandler(QuoteReadyCheckHandler):
127 """The base request handler for the quotes package."""
129 RATELIMIT_GET_LIMIT = 20
130 RATELIMIT_GET_COUNT_PER_PERIOD = 20
131 RATELIMIT_GET_PERIOD = 10
133 FUTURES: set[Future[Any]] = set()
135 loop: AbstractEventLoop
136 next_id: tuple[int, int]
137 rating_filter: RatingFilter
139 def future_callback(self, future: Future[WrongQuote]) -> None:
140 """Discard the future and log the exception if one occured."""
141 self.FUTURES.discard(future)
142 if exc := future.exception():
143 LOGGER.error(
144 "Failed to pre-fetch quote %d-%d (%s)",
145 *self.next_id,
146 exc,
147 exc_info=(type(exc), exc, exc.__traceback__),
148 )
149 else:
150 LOGGER.debug("Pre-fetched quote %d-%d", *self.next_id)
152 def get_next_url(self) -> str:
153 """Get the URL of the next quote."""
154 return self.fix_url(
155 f"/zitate/{self.next_id[0]}-{self.next_id[1]}",
156 r=None if self.rating_filter == "smart" else self.rating_filter,
157 **{"show-rating": self.get_show_rating() or None}, # type: ignore[arg-type]
158 )
160 def get_show_rating(self) -> bool:
161 """Return whether the user wants to see the rating."""
162 return self.get_bool_argument("show-rating", False)
164 def on_finish(self) -> None:
165 """
166 Pre-fetch the data for the next quote.
168 This is done to show the users less out-of-date data.
169 """
170 if len(self.FUTURES) > 1 or (
171 self.content_type
172 and self.content_type.startswith("image/")
173 or self.content_type
174 in {"application/pdf", "application/vnd.ms-excel"}
175 ):
176 return # don't spam and don't do this for images
178 user_agent = self.request.headers.get("User-Agent")
179 if not user_agent or "bot" in user_agent.lower():
180 return # don't do this for bots
182 if hasattr(self, "loop"):
183 task = self.loop.create_task(
184 get_wrong_quote(*self.next_id, use_cache=False)
185 )
186 self.FUTURES.add(task)
187 task.add_done_callback(self.future_callback)
189 async def prepare(self) -> None:
190 """Set the id of the next wrong_quote to show."""
191 await super().prepare()
192 self.loop = asyncio.get_running_loop()
193 self.rating_filter = parse_rating_filter(self.get_argument("r", ""))
194 self.next_id = get_next_id(self.rating_filter)
197class QuoteMainPage(QuoteBaseHandler, QuoteOfTheDayBaseHandler):
198 """The main quote page that explains everything and links to stuff."""
200 async def get(self, *, head: bool = False) -> None:
201 """Render the main quote page, with a few links."""
202 # pylint: disable=unused-argument
203 quote = self.get_argument("quote", "")
204 author = self.get_argument("author", "")
205 if (quote or author) and regex.fullmatch(r"^[0-9]+$", quote + author):
206 self.redirect(self.fix_url(f"/zitate/{quote}-{author}"))
207 return
209 await self.render(
210 "pages/quotes/main_page.html",
211 funny_quote_url=self.id_to_url(
212 *(
213 get_wrong_quotes(lambda wq: wq.rating > 0, shuffle=True)[
214 0
215 ].get_id()
216 ),
217 rating_param="w",
218 ),
219 random_quote_url=self.id_to_url(*self.next_id),
220 quote_of_the_day=self.redis and await self.get_quote_of_today(), # type: ignore[truthy-bool]
221 one_stone_url=self.get_author_url("Albert Einstein"),
222 kangaroo_url=self.get_author_url("Das Känguru"),
223 muk_url=self.get_author_url("Marc-Uwe Kling"),
224 )
226 def get_author_url(self, author_name: str) -> None | str:
227 """Get the info URL of an author."""
228 authors = get_authors(lambda _a: _a.name.lower() == author_name.lower())
229 if not authors:
230 return None
231 return self.fix_url(f"/zitate/info/a/{authors[0].id}")
233 def id_to_url(
234 self, quote_id: int, author_id: int, rating_param: None | str = None
235 ) -> str:
236 """Get the URL of a quote."""
237 return self.fix_url(f"/zitate/{quote_id}-{author_id}", r=rating_param)
240class QuoteRedirectAPI(APIRequestHandler, QuoteBaseHandler):
241 """Redirect to the API for a random quote."""
243 async def get( # pylint: disable=unused-argument
244 self, suffix: str = "", *, head: bool = False
245 ) -> None:
246 """Redirect to a random funny quote."""
247 next_filter = parse_rating_filter(self.get_argument("r", "") or "w")
248 quote_id, author_id = get_next_id(next_filter)
249 kwargs: dict[str, str] = {"r": next_filter}
250 if self.get_show_rating():
251 kwargs["show-rating"] = "sure"
252 return self.redirect(
253 self.fix_url(
254 f"/api/zitate/{quote_id}-{author_id}{suffix}",
255 **kwargs,
256 )
257 )
260def wrong_quote_to_json(
261 wq_: WrongQuote, vote: int, next_: tuple[int, int], rating: str, full: bool
262) -> dict[str, Any]: # TODO: improve lazy return type typing
263 """Convert a wrong quote to a dict."""
264 next_q, next_a = next_
265 if full:
266 return {
267 "wrong_quote": wq_.to_json(),
268 "next": f"{next_q}-{next_a}",
269 "vote": vote,
270 }
271 return {
272 "id": wq_.get_id_as_str(),
273 "quote": str(wq_.quote),
274 "author": str(wq_.author),
275 "real_author": str(wq_.quote.author),
276 "real_author_id": wq_.quote.author_id,
277 "rating": rating,
278 "vote": vote,
279 "next": f"{next_q}-{next_a}",
280 }
283class QuoteById(QuoteBaseHandler):
284 """The page with a specified quote that then gets rendered."""
286 RATELIMIT_POST_LIMIT: ClassVar[int] = 10
287 RATELIMIT_POST_COUNT_PER_PERIOD: ClassVar[int] = 5
288 RATELIMIT_POST_PERIOD: ClassVar[int] = 10
290 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = (
291 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES,
292 *APIRequestHandler.POSSIBLE_CONTENT_TYPES,
293 *IMAGE_CONTENT_TYPES,
294 )
296 LONG_PATH: ClassVar[str] = "/zitate/%d-%d"
298 async def get(
299 self, quote_id: str, author_id: None | str = None, *, head: bool = False
300 ) -> None:
301 """Handle GET requests to this page and render the quote."""
302 int_quote_id = int(quote_id)
303 if author_id is None:
304 wqs = get_wrong_quotes(lambda wq: wq.id == int_quote_id)
305 if not wqs:
306 raise HTTPError(404, f"No wrong quote with id {quote_id}")
307 return self.redirect(self.fix_url(self.LONG_PATH % wqs[0].get_id()))
309 if head:
310 return
312 if (
313 self.content_type
314 and self.content_type.startswith("image/")
315 or self.content_type
316 in {"application/pdf", "application/vnd.ms-excel"}
317 ):
318 wrong_quote = await get_wrong_quote(int_quote_id, int(author_id))
319 return await self.finish(
320 await asyncio.to_thread(
321 create_image,
322 wrong_quote.quote.quote,
323 wrong_quote.author.name,
324 wrong_quote.rating,
325 f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}",
326 (
327 self.content_type.removeprefix("image/").removeprefix(
328 "x-"
329 )
330 if self.content_type.startswith("image/")
331 else {
332 "application/pdf": "pdf",
333 "application/vnd.ms-excel": "xlsx",
334 }[self.content_type]
335 ),
336 wq_id=wrong_quote.get_id_as_str(),
337 )
338 )
340 await self.render_quote(int_quote_id, int(author_id))
342 async def get_old_vote(
343 self, quote_id: int, author_id: int
344 ) -> Literal[-1, 0, 1]:
345 """Get the old vote from the saved vote."""
346 old_vote = await self.get_saved_vote(quote_id, author_id)
347 if old_vote is None:
348 return 0
349 return old_vote
351 async def get_rating_str(self, wrong_quote: WrongQuote) -> str:
352 """Get the rating str to display on the page."""
353 if wrong_quote.id in {None, -1}:
354 return "---"
355 if (
356 not self.get_show_rating() # don't hide the rating on wish of user
357 and self.rating_filter == "smart"
358 and self.request.method
359 and self.request.method.upper() == "GET"
360 and await self.get_saved_vote(
361 wrong_quote.quote_id, wrong_quote.author_id
362 )
363 is None
364 ):
365 return "???"
366 return str(wrong_quote.rating)
368 def get_redis_votes_key(self, quote_id: int, author_id: int) -> str:
369 """Get the key to save the votes with Redis."""
370 return (
371 f"{self.redis_prefix}:quote-votes:"
372 f"{self.get_user_id()}:{quote_id}-{author_id}"
373 )
375 async def get_saved_vote(
376 self, quote_id: int, author_id: int
377 ) -> None | Literal[-1, 0, 1]:
378 """
379 Get the vote of the current user saved with Redis.
381 Use the quote_id and author_id to query the vote.
382 Return None if nothing is saved.
383 """
384 if not EVENT_REDIS.is_set():
385 LOGGER.warning("No Redis connection")
386 return 0
387 result = await self.redis.get(
388 self.get_redis_votes_key(quote_id, author_id)
389 )
390 if result == "-1":
391 return -1
392 if result == "0":
393 return 0
394 if result == "1":
395 return 1
396 return None
398 async def post(self, quote_id_str: str, author_id_str: str) -> None:
399 """
400 Handle POST requests to this page and render the quote.
402 This is used to vote the quote, without changing the URL.
403 """
404 quote_id = int(quote_id_str)
405 author_id = int(author_id_str)
407 new_vote_str = self.get_argument("vote", None)
409 if not new_vote_str:
410 return await self.render_quote(quote_id, author_id)
412 old_vote: Literal[-1, 0, 1] = await self.get_old_vote(
413 quote_id, author_id
414 )
416 new_vote: Literal[-1, 0, 1] = vote_to_int(new_vote_str)
417 vote_diff: int = new_vote - old_vote
419 if not vote_diff: # == 0
420 return await self.render_quote(quote_id, author_id)
422 await self.update_saved_votes(quote_id, author_id, new_vote)
424 to_vote = cast(Literal[-1, 1], -1 if vote_diff < 0 else 1)
426 contributed_by = f"an-website_{hash_ip(self.request.remote_ip, 10)}"
428 # do the voting
429 wrong_quote = await create_wq_and_vote(
430 to_vote, quote_id, author_id, contributed_by
431 )
432 if abs(vote_diff) == 2:
433 await wrong_quote.vote(to_vote, lazy=True)
435 await self.render_wrong_quote(wrong_quote, new_vote)
437 async def render_quote(self, quote_id: int, author_id: int) -> None:
438 """Get and render a wrong quote based on author id and author id."""
439 await self.render_wrong_quote(
440 await get_wrong_quote(quote_id, author_id),
441 await self.get_old_vote(quote_id, author_id),
442 )
444 async def render_wrong_quote(
445 self, wrong_quote: WrongQuote, vote: int
446 ) -> None:
447 """Render the page with the wrong_quote and this vote."""
448 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES:
449 return await self.finish(
450 wrong_quote_to_json(
451 wrong_quote,
452 vote,
453 self.next_id,
454 await self.get_rating_str(wrong_quote),
455 self.get_bool_argument("full", False),
456 )
457 )
458 await self.render(
459 "pages/quotes/quotes.html",
460 wrong_quote=wrong_quote,
461 next_href=self.get_next_url(),
462 next_id=f"{self.next_id[0]}-{self.next_id[1]}",
463 description=str(wrong_quote),
464 rating_filter=self.rating_filter,
465 rating=await self.get_rating_str(wrong_quote),
466 show_rating=self.get_show_rating(),
467 vote=vote,
468 )
470 async def update_saved_votes(
471 self, quote_id: int, author_id: int, vote: int
472 ) -> None:
473 """Save the new vote in Redis."""
474 if not EVENT_REDIS.is_set():
475 raise HTTPError(503)
476 result = await self.redis.setex(
477 self.get_redis_votes_key(quote_id, author_id),
478 60 * 60 * 24 * 90, # time to live in seconds (3 months)
479 str(vote), # value to save (the vote)
480 )
481 if result:
482 return
483 LOGGER.warning("Could not save vote in Redis: %s", result)
484 raise HTTPError(500, "Could not save vote")
487# pylint: disable-next=too-many-ancestors
488class QuoteAPIHandler(APIRequestHandler, QuoteById):
489 """API request handler for the quotes page."""
491 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET", "POST")
493 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = (
494 *APIRequestHandler.POSSIBLE_CONTENT_TYPES,
495 *IMAGE_CONTENT_TYPES,
496 )
498 LONG_PATH: ClassVar[str] = "/api/zitate/%d-%d"
500 async def render_wrong_quote(
501 self, wrong_quote: WrongQuote, vote: int
502 ) -> None:
503 """Return the relevant data for the quotes page as JSON."""
504 if self.content_type == "text/plain":
505 return await self.finish(str(wrong_quote))
506 return await self.finish(
507 wrong_quote_to_json(
508 wrong_quote,
509 vote,
510 self.next_id,
511 await self.get_rating_str(wrong_quote),
512 self.request.path.endswith("/full"),
513 )
514 )