Coverage for an_website/quotes/quotes.py: 72.770%

213 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-25 13:53 +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/>. 

13 

14""" 

15A page with wrong quotes. 

16 

17It displays funny, but wrong, quotes. 

18""" 

19 

20import asyncio 

21import logging 

22import random 

23from asyncio import AbstractEventLoop, Future 

24from dataclasses import dataclass 

25from typing import Any, ClassVar, Final, Literal, TypeAlias 

26 

27import regex 

28from tornado.web import HTTPError 

29 

30from .. import EVENT_REDIS 

31from ..utils.data_parsing import parse_args 

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) 

46 

47LOGGER: Final = logging.getLogger(__name__) 

48 

49 

50RatingFilter: TypeAlias = Literal["w", "n", "unrated", "rated", "all", "smart"] 

51SMART_RATING_FILTERS: Final[tuple[RatingFilter, ...]] = ( 

52 *(("n",) * 1), 

53 *(("all",) * 5), 

54 *(("w",) * 5), 

55) 

56 

57 

58@dataclass(frozen=True) 

59class VoteArgument: 

60 """Voting matters.""" 

61 

62 vote: Literal[-1, 0, 1] 

63 

64 

65def parse_rating_filter(rating_filter_str: str) -> RatingFilter: 

66 """Get a rating filter.""" 

67 match rating_filter_str: 

68 case "w": 

69 return "w" 

70 case "n": 

71 return "n" 

72 case "unrated": 

73 return "unrated" 

74 case "rated": 

75 return "rated" 

76 case "all": 

77 return "all" 

78 return "smart" 

79 

80 

81# pylint: disable-next=too-complex 

82def get_next_id(rating_filter: RatingFilter) -> tuple[int, int]: 

83 """Get the id of the next quote.""" 

84 if rating_filter == "smart": 

85 rating_filter = random.choice(SMART_RATING_FILTERS) # nosec: B311 

86 

87 match rating_filter: 

88 case "unrated": 

89 # get a random quote, but filter out already rated quotes 

90 # pylint: disable=while-used 

91 while (ids := get_random_id()) in WRONG_QUOTES_CACHE: 

92 if WRONG_QUOTES_CACHE[ids].id == -1: 

93 # check for wrong quotes that are unrated but in the cache 

94 # they don't have a real wrong_quotes_id 

95 return ids 

96 return ids 

97 case "all": 

98 return get_random_id() 

99 case "w": 

100 wrong_quotes = get_wrong_quotes(lambda wq: wq.rating > 0) 

101 case "n": 

102 wrong_quotes = get_wrong_quotes(lambda wq: wq.rating < 0) 

103 case "rated": 

104 wrong_quotes = get_wrong_quotes(lambda wq: wq.id not in {-1, None}) 

105 case _: 

106 LOGGER.error("Invalid rating filter %s", rating_filter) 

107 return get_random_id() 

108 

109 if not wrong_quotes: 

110 # invalid rating filter or no wrong quotes with that filter 

111 return get_random_id() 

112 

113 return random.choice(wrong_quotes).get_id() # nosec: B311 

114 

115 

116class QuoteBaseHandler(QuoteReadyCheckHandler): 

117 """The base request handler for the quotes package.""" 

118 

119 RATELIMIT_GET_LIMIT = 20 

120 RATELIMIT_GET_COUNT_PER_PERIOD = 20 

121 RATELIMIT_GET_PERIOD = 10 

122 

123 FUTURES: set[Future[Any]] = set() 

124 

125 loop: AbstractEventLoop 

126 next_id: tuple[int, int] 

127 rating_filter: RatingFilter 

128 

129 def future_callback(self, future: Future[WrongQuote | None]) -> None: 

130 """Discard the future and log the exception if one occured.""" 

131 self.FUTURES.discard(future) 

132 if exc := future.exception(): 

133 LOGGER.error( 

134 "Failed to pre-fetch quote %d-%d (%s)", 

135 *self.next_id, 

136 exc, 

137 exc_info=(type(exc), exc, exc.__traceback__), 

138 ) 

139 else: 

140 LOGGER.debug("Pre-fetched quote %d-%d", *self.next_id) 

141 

142 def get_next_url(self) -> str: 

143 """Get the URL of the next quote.""" 

144 return self.fix_url( 

145 f"/zitate/{self.next_id[0]}-{self.next_id[1]}", 

146 query_args={ 

147 "r": ( 

148 None 

149 if self.rating_filter == "smart" 

150 else self.rating_filter 

151 ), 

152 "show-rating": self.get_show_rating() or None, 

153 }, 

154 ) 

155 

156 def get_show_rating(self) -> bool: 

157 """Return whether the user wants to see the rating.""" 

158 return self.get_bool_argument("show-rating", False) 

159 

160 def on_finish(self) -> None: 

161 """ 

162 Pre-fetch the data for the next quote. 

163 

164 This is done to show the users less out-of-date data. 

165 """ 

166 if len(self.FUTURES) > 1 or ( 

167 self.content_type 

168 and self.content_type.startswith("image/") 

169 or self.content_type 

170 in {"application/pdf", "application/vnd.ms-excel"} 

171 ): 

172 return # don't spam and don't do this for images 

173 

174 user_agent = self.request.headers.get("User-Agent") 

175 if not user_agent or "bot" in user_agent.lower(): 

176 return # don't do this for bots 

177 

178 if hasattr(self, "loop"): 

179 task = self.loop.create_task( 

180 get_wrong_quote(*self.next_id, use_cache=False) 

181 ) 

182 self.FUTURES.add(task) 

183 task.add_done_callback(self.future_callback) 

184 

185 async def prepare(self) -> None: 

186 """Set the id of the next wrong_quote to show.""" 

187 await super().prepare() 

188 self.loop = asyncio.get_running_loop() 

189 self.rating_filter = parse_rating_filter(self.get_argument("r", "")) 

190 self.next_id = get_next_id(self.rating_filter) 

191 

192 

193class QuoteMainPage(QuoteBaseHandler, QuoteOfTheDayBaseHandler): 

194 """The main quote page that explains everything and links to stuff.""" 

195 

196 async def get(self, *, head: bool = False) -> None: 

197 """Render the main quote page, with a few links.""" 

198 # pylint: disable=unused-argument 

199 quote = self.get_argument("quote", "") 

200 author = self.get_argument("author", "") 

201 if (quote or author) and regex.fullmatch(r"^[0-9]+$", quote + author): 

202 self.redirect(self.fix_url(f"/zitate/{quote}-{author}")) 

203 return 

204 

205 wrong_quotes = ( 

206 get_wrong_quotes(lambda wq: wq.rating > 0) or get_wrong_quotes() 

207 ) 

208 await self.render( 

209 "pages/quotes/main_page.html", 

210 funny_quote_url=self.id_to_url( 

211 *random.choice(wrong_quotes).get_id(), 

212 rating_param="w", 

213 ), 

214 random_quote_url=self.id_to_url(*self.next_id), 

215 quote_of_the_day=self.redis and await self.get_quote_of_today(), # type: ignore[truthy-bool] 

216 one_stone_url=self.get_author_url("Albert Einstein"), 

217 kangaroo_url=self.get_author_url("Das Känguru"), 

218 muk_url=self.get_author_url("Marc-Uwe Kling"), 

219 ) 

220 

221 def get_author_url(self, author_name: str) -> None | str: 

222 """Get the info URL of an author.""" 

223 authors = get_authors(lambda _a: _a.name.lower() == author_name.lower()) 

224 if not authors: 

225 return None 

226 return self.fix_url(f"/zitate/info/a/{authors[0].id}") 

227 

228 def id_to_url( 

229 self, quote_id: int, author_id: int, rating_param: None | str = None 

230 ) -> str: 

231 """Get the URL of a quote.""" 

232 return self.fix_url( 

233 f"/zitate/{quote_id}-{author_id}", 

234 query_args={"r": rating_param}, 

235 ) 

236 

237 

238def wrong_quote_to_json( 

239 wq_: WrongQuote, vote: int, next_: tuple[int, int], rating: str, full: bool 

240) -> dict[str, Any]: # TODO: improve lazy return type typing 

241 """Convert a wrong quote to a dict.""" 

242 next_q, next_a = next_ 

243 if full: 

244 return { 

245 "wrong_quote": wq_.to_json(), 

246 "next": f"{next_q}-{next_a}", 

247 "vote": vote, 

248 } 

249 return { 

250 "id": wq_.get_id_as_str(), 

251 "quote": str(wq_.quote), 

252 "author": str(wq_.author), 

253 "real_author": str(wq_.quote.author), 

254 "real_author_id": wq_.quote.author_id, 

255 "rating": rating, 

256 "vote": vote, 

257 "next": f"{next_q}-{next_a}", 

258 "path": wq_.get_path(), 

259 } 

260 

261 

262class QuoteById(QuoteBaseHandler): 

263 """The page with a specified quote that then gets rendered.""" 

264 

265 RATELIMIT_POST_LIMIT: ClassVar[int] = 10 

266 RATELIMIT_POST_COUNT_PER_PERIOD: ClassVar[int] = 5 

267 RATELIMIT_POST_PERIOD: ClassVar[int] = 10 

268 

269 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ( 

270 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES, 

271 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

272 *IMAGE_CONTENT_TYPES, 

273 ) 

274 

275 LONG_PATH: ClassVar[str] = "/zitate/%d-%d" 

276 

277 async def get( 

278 self, quote_id: str, author_id: None | str = None, *, head: bool = False 

279 ) -> None: 

280 """Handle GET requests to this page and render the quote.""" 

281 int_quote_id = int(quote_id) 

282 if author_id is None: 

283 wqs = get_wrong_quotes(lambda wq: wq.id == int_quote_id) 

284 if not wqs: 

285 raise HTTPError(404, f"No wrong quote with id {quote_id}") 

286 return self.redirect(self.fix_url(self.LONG_PATH % wqs[0].get_id())) 

287 

288 if head: 

289 return 

290 

291 if ( 

292 self.content_type 

293 and self.content_type.startswith("image/") 

294 or self.content_type 

295 in {"application/pdf", "application/vnd.ms-excel"} 

296 ): 

297 wrong_quote = await get_wrong_quote(int_quote_id, int(author_id)) 

298 if not wrong_quote: 

299 raise HTTPError(404, reason="Falsches Zitat nicht gefunden") 

300 return await self.finish( 

301 await asyncio.to_thread( 

302 create_image, 

303 ( 

304 self.sub_stanley(wrong_quote.quote.quote) 

305 if self.stanley() 

306 else wrong_quote.quote.quote 

307 ), 

308 ( 

309 self.sub_stanley(wrong_quote.author.name) 

310 if self.stanley() 

311 else wrong_quote.author.name 

312 ), 

313 wrong_quote.rating, 

314 f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}", 

315 ( 

316 self.content_type.removeprefix("image/").removeprefix( 

317 "x-" 

318 ) 

319 if self.content_type.startswith("image/") 

320 else { 

321 "application/pdf": "pdf", 

322 "application/vnd.ms-excel": "xlsx", 

323 }[self.content_type] 

324 ), 

325 wq_id=wrong_quote.get_id_as_str(), 

326 ) 

327 ) 

328 

329 await self.render_quote(int_quote_id, int(author_id)) 

330 

331 async def get_old_vote( 

332 self, quote_id: int, author_id: int 

333 ) -> Literal[-1, 0, 1]: 

334 """Get the old vote from the saved vote.""" 

335 old_vote = await self.get_saved_vote(quote_id, author_id) 

336 if old_vote is None: 

337 return 0 

338 return old_vote 

339 

340 async def get_rating_str(self, wrong_quote: WrongQuote) -> str: 

341 """Get the rating str to display on the page.""" 

342 if wrong_quote.id in {None, -1}: 

343 return "---" 

344 if ( 

345 not self.get_show_rating() # don't hide the rating on wish of user 

346 and self.rating_filter == "smart" 

347 and self.request.method 

348 and self.request.method.upper() == "GET" 

349 and await self.get_saved_vote( 

350 wrong_quote.quote_id, wrong_quote.author_id 

351 ) 

352 is None 

353 ): 

354 return "???" 

355 return str(wrong_quote.rating) 

356 

357 def get_redis_votes_key(self, quote_id: int, author_id: int) -> str: 

358 """Get the key to save the votes with Redis.""" 

359 return ( 

360 f"{self.redis_prefix}:quote-votes:" 

361 f"{self.get_user_id()}:{quote_id}-{author_id}" 

362 ) 

363 

364 async def get_saved_vote( 

365 self, quote_id: int, author_id: int 

366 ) -> None | Literal[-1, 0, 1]: 

367 """ 

368 Get the vote of the current user saved with Redis. 

369 

370 Use the quote_id and author_id to query the vote. 

371 Return None if nothing is saved. 

372 """ 

373 if not EVENT_REDIS.is_set(): 

374 LOGGER.warning("No Redis connection") 

375 return 0 

376 result = await self.redis.get( 

377 self.get_redis_votes_key(quote_id, author_id) 

378 ) 

379 if result == "-1": 

380 return -1 

381 if result == "0": 

382 return 0 

383 if result == "1": 

384 return 1 

385 return None 

386 

387 @parse_args(type_=VoteArgument) 

388 async def post( 

389 self, 

390 quote_id_str: str, 

391 author_id_str: str | None = None, 

392 *, 

393 args: VoteArgument, 

394 ) -> None: 

395 """ 

396 Handle POST requests to this page and render the quote. 

397 

398 This is used to vote the quote, without changing the URL. 

399 """ 

400 quote_id = int(quote_id_str) 

401 if author_id_str is None: 

402 wqs = get_wrong_quotes(lambda wq: wq.id == quote_id) 

403 if not wqs: 

404 raise HTTPError(404, f"No wrong quote with id {quote_id}") 

405 return self.redirect(self.fix_url(self.LONG_PATH % wqs[0].get_id())) 

406 

407 author_id = int(author_id_str) 

408 

409 new_vote_str = self.get_argument("vote", None) 

410 

411 if not new_vote_str: 

412 return await self.render_quote(quote_id, author_id) 

413 

414 old_vote: Literal[-1, 0, 1] = await self.get_old_vote( 

415 quote_id, author_id 

416 ) 

417 

418 new_vote: Literal[-1, 0, 1] = args.vote 

419 vote_diff: int = new_vote - old_vote 

420 

421 if not vote_diff: # == 0 

422 return await self.render_quote(quote_id, author_id) 

423 

424 await self.update_saved_votes(quote_id, author_id, new_vote) 

425 

426 to_vote: Literal[-1, 1] = -1 if vote_diff < 0 else 1 

427 

428 contributed_by = f"an-website_{hash_ip(self.request.remote_ip, 10)}" 

429 

430 # do the voting 

431 wrong_quote = await create_wq_and_vote( 

432 to_vote, quote_id, author_id, contributed_by 

433 ) 

434 if abs(vote_diff) == 2: 

435 await wrong_quote.vote(to_vote, lazy=True) 

436 

437 await self.render_wrong_quote(wrong_quote, new_vote) 

438 

439 async def render_quote(self, quote_id: int, author_id: int) -> None: 

440 """Get and render a wrong quote based on author id and author id.""" 

441 await self.render_wrong_quote( 

442 await get_wrong_quote(quote_id, author_id), 

443 await self.get_old_vote(quote_id, author_id), 

444 ) 

445 

446 async def render_wrong_quote( 

447 self, wrong_quote: WrongQuote | None, vote: int 

448 ) -> None: 

449 """Render the page with the wrong_quote and this vote.""" 

450 if not wrong_quote: 

451 # TODO: maybe show 404 inside of the quotes ui 

452 self.set_status(404, reason="Zitat nicht gefunden") 

453 self.write_error(404) 

454 return 

455 

456 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES: 

457 return await self.finish( 

458 wrong_quote_to_json( 

459 wrong_quote, 

460 vote, 

461 self.next_id, 

462 await self.get_rating_str(wrong_quote), 

463 self.get_bool_argument("full", False), 

464 ) 

465 ) 

466 

467 await self.render( 

468 "pages/quotes/quotes.html", 

469 wrong_quote=wrong_quote, 

470 next_href=self.get_next_url(), 

471 next_id=f"{self.next_id[0]}-{self.next_id[1]}", 

472 description=str(wrong_quote), 

473 rating_filter=self.rating_filter, 

474 rating=await self.get_rating_str(wrong_quote), 

475 show_rating=self.get_show_rating(), 

476 vote=vote, 

477 ) 

478 

479 async def update_saved_votes( 

480 self, quote_id: int, author_id: int, vote: int 

481 ) -> None: 

482 """Save the new vote in Redis.""" 

483 if not EVENT_REDIS.is_set(): 

484 raise HTTPError(503) 

485 result = await self.redis.setex( 

486 self.get_redis_votes_key(quote_id, author_id), 

487 60 * 60 * 24 * 90, # time to live in seconds (3 months) 

488 str(vote), # value to save (the vote) 

489 ) 

490 if result: 

491 return 

492 LOGGER.warning("Could not save vote in Redis: %s", result) 

493 raise HTTPError(500, "Could not save vote") 

494 

495 

496# pylint: disable-next=too-many-ancestors 

497class QuoteAPIHandler(APIRequestHandler, QuoteById): 

498 """API request handler for the quotes page.""" 

499 

500 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET", "POST") 

501 

502 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ( 

503 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

504 *IMAGE_CONTENT_TYPES, 

505 ) 

506 

507 LONG_PATH: ClassVar[str] = "/api/zitate/%d-%d" 

508 

509 async def render_wrong_quote( 

510 self, wrong_quote: WrongQuote | None, vote: int 

511 ) -> None: 

512 """Return the relevant data for the quotes page as JSON.""" 

513 if not wrong_quote: 

514 return await super().render_wrong_quote(wrong_quote, vote) 

515 if self.content_type == "text/plain": 

516 return await self.finish(str(wrong_quote)) 

517 return await self.finish( 

518 wrong_quote_to_json( 

519 wrong_quote, 

520 vote, 

521 self.next_id, 

522 await self.get_rating_str(wrong_quote), 

523 self.request.path.endswith("/full"), 

524 ) 

525 ) 

526 

527 

528class QuoteRedirectAPI(APIRequestHandler, QuoteBaseHandler): 

529 """Redirect to the API for a random quote.""" 

530 

531 POSSIBLE_CONTENT_TYPES = QuoteAPIHandler.POSSIBLE_CONTENT_TYPES 

532 

533 async def get( # pylint: disable=unused-argument 

534 self, suffix: str = "", *, head: bool = False 

535 ) -> None: 

536 """Redirect to a random funny quote.""" 

537 next_filter = parse_rating_filter(self.get_argument("r", "") or "w") 

538 quote_id, author_id = get_next_id(next_filter) 

539 kwargs: dict[str, str] = {"r": next_filter} 

540 if self.get_show_rating(): 

541 kwargs["show-rating"] = "sure" 

542 return self.redirect( 

543 self.fix_url( 

544 f"/api/zitate/{quote_id}-{author_id}{suffix}", 

545 query_args=kwargs, 

546 ) 

547 )