Coverage for an_website / quotes / create.py: 63.975%

161 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-24 17:35 +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"""A page to create new wrong quotes.""" 

15 

16import logging 

17from dataclasses import dataclass 

18from typing import Final, cast 

19 

20from tornado.web import HTTPError, MissingArgumentError 

21 

22from ..utils.data_parsing import parse_args 

23from ..utils.utils import bounded_edit_distance 

24from .utils import ( 

25 AUTHORS_CACHE, 

26 QUOTES_CACHE, 

27 Author, 

28 Quote, 

29 QuoteReadyCheckHandler, 

30 fix_author_name, 

31 fix_quote_str, 

32 get_wrong_quote, 

33 make_api_request, 

34 parse_author, 

35 parse_quote, 

36 parse_wrong_quote, 

37) 

38 

39LOGGER: Final = logging.getLogger(__name__) 

40 

41 

42async def create_quote(quote_str: str, author: Author) -> Quote: 

43 """Create a quote.""" 

44 quote_str = fix_quote_str(quote_str) 

45 

46 quote = get_quote_by_str(quote_str) 

47 if quote is not None: 

48 return quote 

49 

50 data = await make_api_request( 

51 "quotes", 

52 method="POST", 

53 body={ 

54 "author": str(author.id), 

55 "quote": quote_str, 

56 }, 

57 entity_should_exist=False, 

58 ) 

59 if data is None: 

60 LOGGER.error("Failed to create quote: „%s“ - %s", quote_str, author) 

61 raise HTTPError(500) 

62 

63 result = parse_quote(data) 

64 

65 LOGGER.info("Created quote %d: %r", result.id, result.quote) 

66 

67 return result 

68 

69 

70async def create_author(author_str: str) -> Author: 

71 """Create an author.""" 

72 author_str = fix_author_name(author_str) 

73 

74 author = get_author_by_name(author_str) 

75 if author is not None: 

76 return author 

77 

78 data = await make_api_request( 

79 "authors", 

80 method="POST", 

81 body={"author": author_str}, 

82 entity_should_exist=False, 

83 ) 

84 

85 if data is None: 

86 LOGGER.error("Failed to create author: %s", author_str) 

87 raise HTTPError(500) 

88 

89 result = parse_author(data) 

90 

91 LOGGER.info("Created author %d: %r", result.id, result.name) 

92 

93 return result 

94 

95 

96async def create_wrong_quote( 

97 real_author_param: Author | str, 

98 fake_author_param: Author | str, 

99 quote_param: Quote | str, 

100 *, 

101 contributed_by: str | None = None, 

102) -> str: 

103 """Create a wrong quote and return the id in the q_id-a_id format.""" 

104 if isinstance(fake_author_param, str): 

105 if not fake_author_param: 

106 raise HTTPError(400, "Fake author is needed, but empty.") 

107 fake_author = await create_author(fake_author_param) 

108 else: 

109 fake_author = fake_author_param 

110 

111 if isinstance(quote_param, str): 

112 if not quote_param: 

113 raise HTTPError(400, "Quote is needed, but empty.") 

114 

115 if isinstance(real_author_param, str): 

116 if not real_author_param: 

117 raise HTTPError(400, "Real author is needed, but empty.") 

118 real_author = await create_author(real_author_param) 

119 else: 

120 real_author = real_author_param 

121 quote = await create_quote(quote_param, real_author) 

122 else: 

123 quote = quote_param 

124 

125 wrong_quote = await get_wrong_quote( 

126 quote.id, fake_author.id, use_cache=True 

127 ) 

128 

129 if not wrong_quote: 

130 LOGGER.error( 

131 "%r and %r were just created, wronquote should exist.", 

132 quote, 

133 fake_author, 

134 ) 

135 raise HTTPError() 

136 

137 if wrong_quote.id == -1: 

138 result = await make_api_request( 

139 "wrongquotes", 

140 method="POST", 

141 body={ 

142 "author": fake_author.id, 

143 "quote": quote.id, 

144 "contributed_by": contributed_by or "Mithilfe von asozial.org", 

145 }, 

146 entity_should_exist=False, 

147 ) 

148 if result is None: 

149 LOGGER.error( 

150 "Failed to create wrong quote (%s).", 

151 wrong_quote.get_id_as_str(True), 

152 ) 

153 raise HTTPError(500) 

154 wrong_quote = parse_wrong_quote(result) 

155 LOGGER.info( 

156 "Successfully created wrong quote: %s\n\n%s", 

157 wrong_quote.get_id_as_str(True), 

158 wrong_quote, 

159 ) 

160 

161 return wrong_quote.get_id_as_str() 

162 

163 

164async def get_authors(author_name: str) -> list[Author | str]: 

165 """Get the possible meant authors based on the str.""" 

166 author = get_author_by_name(author_name) 

167 if author is not None: 

168 return [author] 

169 

170 author_name_lower = author_name.lower() 

171 max_distance = min(5, len(author_name) // 2 + 1) 

172 authors: list[Author | str] = [ 

173 *( 

174 author 

175 for author in AUTHORS_CACHE.values() 

176 if bounded_edit_distance( 

177 author.name.lower(), author_name_lower, max_distance + 1 

178 ) 

179 <= max_distance 

180 ), 

181 fix_author_name(author_name), 

182 ] 

183 # authors should be in most cases title case 

184 fixed_author = author_name.title() 

185 if fixed_author not in authors: 

186 authors.append(fixed_author) 

187 # no other fixes for authors that are less long 

188 if len(author_name) < 2: 

189 return authors 

190 # maybe only the first letter upper case 

191 fixed_author_2 = author_name[0].upper() + author_name[1:] 

192 if fixed_author_2 not in authors: 

193 authors.append(fixed_author_2) 

194 return authors 

195 

196 

197def get_author_by_name(name: str) -> None | Author: 

198 """Get an author by its name.""" 

199 lower_name = fix_author_name(name).lower() 

200 for author in AUTHORS_CACHE.values(): 

201 if author.name.lower() == lower_name: 

202 return author 

203 return None 

204 

205 

206def get_quote_by_str(quote_str: str) -> None | Quote: 

207 """Get an author by its name.""" 

208 lower_quote = fix_quote_str(quote_str).lower() 

209 for quote in QUOTES_CACHE.values(): 

210 if quote.quote.lower() == lower_quote: 

211 return quote 

212 return None 

213 

214 

215async def get_quotes(quote_str: str) -> list[Quote | str]: 

216 """Get the possible meant quotes based on the str.""" 

217 quote: None | Quote = get_quote_by_str(quote_str) 

218 if isinstance(quote, Quote): 

219 return [quote] 

220 

221 lower_quote_str = quote_str.lower() 

222 max_distance = min(16, len(quote_str) // 2 + 1) 

223 quotes: list[Quote | str] = [ 

224 *( 

225 quote 

226 for quote in QUOTES_CACHE.values() 

227 if bounded_edit_distance( 

228 quote.quote.lower(), lower_quote_str, max_distance + 1 

229 ) 

230 <= max_distance 

231 ), 

232 fix_quote_str(quote_str), 

233 ] 

234 if not ( 

235 quote_str.endswith("?") 

236 or quote_str.endswith(".") 

237 or quote_str.endswith("!") 

238 ): 

239 quotes.append(quote_str + ".") 

240 return quotes 

241 

242 

243@dataclass(slots=True, frozen=True) 

244class QuoteInfoArgs: 

245 """Class representing a quote id and an author id.""" 

246 

247 quote: int | None = None 

248 author: int | None = None 

249 

250 

251class CreatePage1(QuoteReadyCheckHandler): 

252 """The request handler for the create page.""" 

253 

254 RATELIMIT_POST_LIMIT = 5 

255 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

256 

257 @parse_args(type_=QuoteInfoArgs) 

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

259 """Handle GET requests to the create page.""" 

260 if args.quote is not None and args.author is not None: 

261 return self.redirect(f"/zitate/{args.quote}-{args.author}") 

262 

263 if head: 

264 return 

265 

266 await self.render( 

267 "pages/quotes/create1.html", 

268 quotes=tuple(QUOTES_CACHE.values()), 

269 authors=tuple(AUTHORS_CACHE.values()), 

270 selected_quote=( 

271 None if args.quote is None else QUOTES_CACHE.get(args.quote) 

272 ), 

273 selected_author=( 

274 None if args.author is None else AUTHORS_CACHE.get(args.author) 

275 ), 

276 ) 

277 

278 async def post(self) -> None: 

279 """Handle POST requests to the create page.""" 

280 if self.get_argument("keine-aktion", ""): 

281 LOGGER.info( 

282 "Versuch Zitat zu erstellen mit keine-aktion Parameter.\n" 

283 "Pfad: %s\nBody: %s", 

284 self.request.path, 

285 self.request.body, 

286 ) 

287 await self.render( 

288 "pages/empty.html", 

289 text="Kein Zitat erstellt.", 

290 ) 

291 return 

292 

293 user_name = self.get_argument("user-name", None) 

294 quote_str = self.get_argument("quote-1", None) 

295 fake_author_str = self.get_argument("fake-author-1", None) 

296 if not (quote_str and fake_author_str): 

297 raise HTTPError( 

298 400, 

299 reason=( 

300 "Missing arguments. quote-1 and fake-author-1 are needed." 

301 ), 

302 ) 

303 quote: None | Quote = get_quote_by_str(quote_str) 

304 fake_author: None | Author = get_author_by_name(fake_author_str) 

305 

306 if quote and fake_author: 

307 wq_id = await create_wrong_quote( 

308 real_author_param=quote.author, 

309 fake_author_param=fake_author, 

310 quote_param=quote, 

311 contributed_by=user_name, 

312 ) 

313 return self.redirect( 

314 self.fix_url(f"/zitate/{wq_id}"), 

315 status=303, 

316 ) 

317 

318 if not quote: 

319 # TODO: search for real author, to reduce work for users 

320 real_author_str = self.get_argument("real-author-1", None) 

321 if not real_author_str: 

322 raise HTTPError( 

323 400, reason="Missing arguments. real-author-1 is needed." 

324 ) 

325 

326 quotes: list[Quote | str] = ( 

327 [quote] if quote else await get_quotes(quote_str) 

328 ) 

329 real_authors: list[Author | str] = ( 

330 [quote.author] 

331 if quote 

332 else await get_authors(cast(str, real_author_str)) # type: ignore[possibly-undefined] # noqa: B950 

333 ) 

334 fake_authors: list[Author | str] = ( 

335 [fake_author] if fake_author else await get_authors(fake_author_str) 

336 ) 

337 

338 if 1 == len(quotes) == len(real_authors) == len(fake_authors): 

339 LOGGER.info( 

340 "Creating wrong quote using existing: %r (%r) - %r", 

341 quotes[0], 

342 real_authors[0], 

343 fake_authors[0], 

344 ) 

345 

346 wq_id = await create_wrong_quote( 

347 real_authors[0], 

348 fake_authors[0], 

349 quotes[0], 

350 contributed_by=user_name, 

351 ) 

352 return self.redirect( 

353 self.fix_url(f"/zitate/{wq_id}"), 

354 status=303, 

355 ) 

356 

357 await self.render( 

358 "pages/quotes/create2.html", 

359 quotes=quotes, 

360 real_authors=real_authors, 

361 fake_authors=fake_authors, 

362 user_name=user_name, 

363 ) 

364 

365 

366class CreatePage2(QuoteReadyCheckHandler): 

367 """The request handler for the second part of the create page.""" 

368 

369 RATELIMIT_POST_LIMIT = 2 

370 RATELIMIT_POST_COUNT_PER_PERIOD = 1 

371 RATELIMIT_POST_PERIOD = 45 

372 

373 async def post(self) -> None: 

374 """Handle POST requests to the create page.""" 

375 if self.get_argument("nicht-erstellen", ""): 

376 LOGGER.info( 

377 "Versuch Zitat zu erstellen mit nicht-erstellen Parameter.\n" 

378 "Pfad: %s\nBody: %s", 

379 self.request.path, 

380 self.request.body, 

381 ) 

382 await self.render( 

383 "pages/empty.html", 

384 text="Kein Zitat erstellt.", 

385 ) 

386 return 

387 

388 quote_str = self.get_argument("quote-2", None) 

389 if not quote_str: 

390 raise MissingArgumentError("quote-2") 

391 fake_author_str = self.get_argument("fake-author-2", None) 

392 if not fake_author_str: 

393 raise MissingArgumentError("fake-author-2") 

394 

395 if (quote := get_quote_by_str(quote_str)) and ( 

396 fake_author := get_author_by_name(fake_author_str) 

397 ): 

398 # if selected existing quote and existing 

399 # fake author just redirect to the page of this quote 

400 return self.redirect( 

401 self.fix_url(f"/zitate/{quote.id}-{fake_author.id}"), 

402 status=303, 

403 ) 

404 

405 real_author = self.get_argument("real-author-2", None) 

406 if not real_author: 

407 raise MissingArgumentError("real-author-2") 

408 

409 wq_id = await create_wrong_quote( 

410 real_author, 

411 fake_author_str, 

412 quote_str, 

413 contributed_by=self.get_argument( 

414 "user-name", "Nutzer von asozial.org" 

415 ), 

416 ) 

417 return self.redirect( 

418 self.fix_url( 

419 f"/zitate/{wq_id}", 

420 ), 

421 status=303, 

422 )