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

211 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-22 15:59 +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 

20from __future__ import annotations 

21 

22import asyncio 

23import logging 

24import random 

25from asyncio import AbstractEventLoop, Future 

26from typing import Any, ClassVar, Final, Literal, TypeAlias, cast 

27 

28import regex 

29from tornado.web import HTTPError 

30 

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) 

46 

47LOGGER: Final = logging.getLogger(__name__) 

48 

49 

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 

58 

59 int_vote = int(vote) 

60 if int_vote < 0: 

61 return -1 

62 if int_vote > 0: 

63 return 1 

64 

65 return 0 

66 

67 

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) 

74 

75 

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" 

90 

91 

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 

96 

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() 

118 

119 if not wrong_quotes: 

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

121 return get_random_id() 

122 

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

124 

125 

126class QuoteBaseHandler(QuoteReadyCheckHandler): 

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

128 

129 RATELIMIT_GET_LIMIT = 20 

130 RATELIMIT_GET_COUNT_PER_PERIOD = 20 

131 RATELIMIT_GET_PERIOD = 10 

132 

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

134 

135 loop: AbstractEventLoop 

136 next_id: tuple[int, int] 

137 rating_filter: RatingFilter 

138 

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) 

151 

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 ) 

159 

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) 

163 

164 def on_finish(self) -> None: 

165 """ 

166 Pre-fetch the data for the next quote. 

167 

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 

177 

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 

181 

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) 

188 

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) 

195 

196 

197class QuoteMainPage(QuoteBaseHandler, QuoteOfTheDayBaseHandler): 

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

199 

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 

208 

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 ) 

225 

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}") 

232 

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) 

238 

239 

240def wrong_quote_to_json( 

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

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

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

244 next_q, next_a = next_ 

245 if full: 

246 return { 

247 "wrong_quote": wq_.to_json(), 

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

249 "vote": vote, 

250 } 

251 return { 

252 "id": wq_.get_id_as_str(), 

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

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

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

256 "real_author_id": wq_.quote.author_id, 

257 "rating": rating, 

258 "vote": vote, 

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

260 } 

261 

262 

263class QuoteById(QuoteBaseHandler): 

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

265 

266 RATELIMIT_POST_LIMIT: ClassVar[int] = 10 

267 RATELIMIT_POST_COUNT_PER_PERIOD: ClassVar[int] = 5 

268 RATELIMIT_POST_PERIOD: ClassVar[int] = 10 

269 

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

271 *HTMLRequestHandler.POSSIBLE_CONTENT_TYPES, 

272 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

273 *IMAGE_CONTENT_TYPES, 

274 ) 

275 

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

277 

278 async def get( 

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

280 ) -> None: 

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

282 int_quote_id = int(quote_id) 

283 if author_id is None: 

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

285 if not wqs: 

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

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

288 

289 if head: 

290 return 

291 

292 if ( 

293 self.content_type 

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

295 or self.content_type 

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

297 ): 

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

299 return await self.finish( 

300 await asyncio.to_thread( 

301 create_image, 

302 wrong_quote.quote.quote, 

303 wrong_quote.author.name, 

304 wrong_quote.rating, 

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

306 ( 

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

308 "x-" 

309 ) 

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

311 else { 

312 "application/pdf": "pdf", 

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

314 }[self.content_type] 

315 ), 

316 wq_id=wrong_quote.get_id_as_str(), 

317 ) 

318 ) 

319 

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

321 

322 async def get_old_vote( 

323 self, quote_id: int, author_id: int 

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

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

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

327 if old_vote is None: 

328 return 0 

329 return old_vote 

330 

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

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

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

334 return "---" 

335 if ( 

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

337 and self.rating_filter == "smart" 

338 and self.request.method 

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

340 and await self.get_saved_vote( 

341 wrong_quote.quote_id, wrong_quote.author_id 

342 ) 

343 is None 

344 ): 

345 return "???" 

346 return str(wrong_quote.rating) 

347 

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

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

350 return ( 

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

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

353 ) 

354 

355 async def get_saved_vote( 

356 self, quote_id: int, author_id: int 

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

358 """ 

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

360 

361 Use the quote_id and author_id to query the vote. 

362 Return None if nothing is saved. 

363 """ 

364 if not EVENT_REDIS.is_set(): 

365 LOGGER.warning("No Redis connection") 

366 return 0 

367 result = await self.redis.get( 

368 self.get_redis_votes_key(quote_id, author_id) 

369 ) 

370 if result == "-1": 

371 return -1 

372 if result == "0": 

373 return 0 

374 if result == "1": 

375 return 1 

376 return None 

377 

378 async def post(self, quote_id_str: str, author_id_str: str) -> None: 

379 """ 

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

381 

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

383 """ 

384 quote_id = int(quote_id_str) 

385 author_id = int(author_id_str) 

386 

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

388 

389 if not new_vote_str: 

390 return await self.render_quote(quote_id, author_id) 

391 

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

393 quote_id, author_id 

394 ) 

395 

396 new_vote: Literal[-1, 0, 1] = vote_to_int(new_vote_str) 

397 vote_diff: int = new_vote - old_vote 

398 

399 if not vote_diff: # == 0 

400 return await self.render_quote(quote_id, author_id) 

401 

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

403 

404 to_vote = cast(Literal[-1, 1], -1 if vote_diff < 0 else 1) 

405 

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

407 

408 # do the voting 

409 wrong_quote = await create_wq_and_vote( 

410 to_vote, quote_id, author_id, contributed_by 

411 ) 

412 if abs(vote_diff) == 2: 

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

414 

415 await self.render_wrong_quote(wrong_quote, new_vote) 

416 

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

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

419 await self.render_wrong_quote( 

420 await get_wrong_quote(quote_id, author_id), 

421 await self.get_old_vote(quote_id, author_id), 

422 ) 

423 

424 async def render_wrong_quote( 

425 self, wrong_quote: WrongQuote, vote: int 

426 ) -> None: 

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

428 if self.content_type in APIRequestHandler.POSSIBLE_CONTENT_TYPES: 

429 return await self.finish( 

430 wrong_quote_to_json( 

431 wrong_quote, 

432 vote, 

433 self.next_id, 

434 await self.get_rating_str(wrong_quote), 

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

436 ) 

437 ) 

438 await self.render( 

439 "pages/quotes/quotes.html", 

440 wrong_quote=wrong_quote, 

441 next_href=self.get_next_url(), 

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

443 description=str(wrong_quote), 

444 rating_filter=self.rating_filter, 

445 rating=await self.get_rating_str(wrong_quote), 

446 show_rating=self.get_show_rating(), 

447 vote=vote, 

448 ) 

449 

450 async def update_saved_votes( 

451 self, quote_id: int, author_id: int, vote: int 

452 ) -> None: 

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

454 if not EVENT_REDIS.is_set(): 

455 raise HTTPError(503) 

456 result = await self.redis.setex( 

457 self.get_redis_votes_key(quote_id, author_id), 

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

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

460 ) 

461 if result: 

462 return 

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

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

465 

466 

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

468class QuoteAPIHandler(APIRequestHandler, QuoteById): 

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

470 

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

472 

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

474 *APIRequestHandler.POSSIBLE_CONTENT_TYPES, 

475 *IMAGE_CONTENT_TYPES, 

476 ) 

477 

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

479 

480 async def render_wrong_quote( 

481 self, wrong_quote: WrongQuote, vote: int 

482 ) -> None: 

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

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

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

486 return await self.finish( 

487 wrong_quote_to_json( 

488 wrong_quote, 

489 vote, 

490 self.next_id, 

491 await self.get_rating_str(wrong_quote), 

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

493 ) 

494 ) 

495 

496 

497class QuoteRedirectAPI(APIRequestHandler, QuoteBaseHandler): 

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

499 

500 POSSIBLE_CONTENT_TYPES = QuoteAPIHandler.POSSIBLE_CONTENT_TYPES 

501 

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

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

504 ) -> None: 

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

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

507 quote_id, author_id = get_next_id(next_filter) 

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

509 if self.get_show_rating(): 

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

511 return self.redirect( 

512 self.fix_url( 

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

514 **kwargs, 

515 ) 

516 )