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

161 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-10 18: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/>. 

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( 

66 "Created quote %d: %r - %r", 

67 result.id, 

68 result.quote, 

69 result.author.name, 

70 ) 

71 

72 return result 

73 

74 

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

76 """Create an author.""" 

77 author_str = fix_author_name(author_str) 

78 

79 author = get_author_by_name(author_str) 

80 if author is not None: 

81 return author 

82 

83 data = await make_api_request( 

84 "authors", 

85 method="POST", 

86 body={"author": author_str}, 

87 entity_should_exist=False, 

88 ) 

89 

90 if data is None: 

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

92 raise HTTPError(500) 

93 

94 result = parse_author(data) 

95 

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

97 

98 return result 

99 

100 

101async def create_wrong_quote( 

102 real_author_param: Author | str, 

103 fake_author_param: Author | str, 

104 quote_param: Quote | str, 

105 *, 

106 contributed_by: str | None = None, 

107) -> str: 

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

109 if isinstance(fake_author_param, str): 

110 if not fake_author_param: 

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

112 fake_author = await create_author(fake_author_param) 

113 else: 

114 fake_author = fake_author_param 

115 

116 if isinstance(quote_param, str): 

117 if not quote_param: 

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

119 

120 if isinstance(real_author_param, str): 

121 if not real_author_param: 

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

123 real_author = await create_author(real_author_param) 

124 else: 

125 real_author = real_author_param 

126 quote = await create_quote(quote_param, real_author) 

127 else: 

128 quote = quote_param 

129 

130 wrong_quote = await get_wrong_quote( 

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

132 ) 

133 

134 if not wrong_quote: 

135 LOGGER.error( 

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

137 quote, 

138 fake_author, 

139 ) 

140 raise HTTPError() 

141 

142 if wrong_quote.id == -1: 

143 result = await make_api_request( 

144 "wrongquotes", 

145 method="POST", 

146 body={ 

147 "author": fake_author.id, 

148 "quote": quote.id, 

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

150 }, 

151 entity_should_exist=False, 

152 ) 

153 if result is None: 

154 LOGGER.error( 

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

156 wrong_quote.get_id_as_str(True), 

157 ) 

158 raise HTTPError(500) 

159 wrong_quote = parse_wrong_quote(result) 

160 LOGGER.info( 

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

162 wrong_quote.get_id_as_str(True), 

163 wrong_quote, 

164 ) 

165 

166 return wrong_quote.get_id_as_str() 

167 

168 

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

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

171 author = get_author_by_name(author_name) 

172 if author is not None: 

173 return [author] 

174 

175 author_name_lower = author_name.lower() 

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

177 authors: list[Author | str] = [ 

178 *( 

179 author 

180 for author in AUTHORS_CACHE.values() 

181 if bounded_edit_distance( 

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

183 ) 

184 <= max_distance 

185 ), 

186 fix_author_name(author_name), 

187 ] 

188 # authors should be in most cases title case 

189 fixed_author = author_name.title() 

190 if fixed_author not in authors: 

191 authors.append(fixed_author) 

192 # no other fixes for authors that are less long 

193 if len(author_name) < 2: 

194 return authors 

195 # maybe only the first letter upper case 

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

197 if fixed_author_2 not in authors: 

198 authors.append(fixed_author_2) 

199 return authors 

200 

201 

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

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

204 lower_name = fix_author_name(name).lower() 

205 for author in AUTHORS_CACHE.values(): 

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

207 return author 

208 return None 

209 

210 

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

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

213 lower_quote = fix_quote_str(quote_str).lower() 

214 for quote in QUOTES_CACHE.values(): 

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

216 return quote 

217 return None 

218 

219 

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

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

222 quote: None | Quote = get_quote_by_str(quote_str) 

223 if isinstance(quote, Quote): 

224 return [quote] 

225 

226 lower_quote_str = quote_str.lower() 

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

228 quotes: list[Quote | str] = [ 

229 *( 

230 quote 

231 for quote in QUOTES_CACHE.values() 

232 if bounded_edit_distance( 

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

234 ) 

235 <= max_distance 

236 ), 

237 fix_quote_str(quote_str), 

238 ] 

239 if not ( 

240 quote_str.endswith("?") 

241 or quote_str.endswith(".") 

242 or quote_str.endswith("!") 

243 ): 

244 quotes.append(quote_str + ".") 

245 return quotes 

246 

247 

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

249class QuoteInfoArgs: 

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

251 

252 quote: int | None = None 

253 author: int | None = None 

254 

255 

256class CreatePage1(QuoteReadyCheckHandler): 

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

258 

259 RATELIMIT_POST_LIMIT = 5 

260 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

261 

262 @parse_args(type_=QuoteInfoArgs) 

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

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

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

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

267 

268 if head: 

269 return 

270 

271 await self.render( 

272 "pages/quotes/create1.html", 

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

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

275 selected_quote=( 

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

277 ), 

278 selected_author=( 

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

280 ), 

281 ) 

282 

283 async def post(self) -> None: 

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

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

286 LOGGER.info( 

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

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

289 self.request.path, 

290 self.request.body, 

291 ) 

292 await self.render( 

293 "pages/empty.html", 

294 text="Kein Zitat erstellt.", 

295 ) 

296 return 

297 

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

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

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

301 if not (quote_str and fake_author_str): 

302 raise HTTPError( 

303 400, 

304 reason=( 

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

306 ), 

307 ) 

308 quote: None | Quote = get_quote_by_str(quote_str) 

309 fake_author: None | Author = get_author_by_name(fake_author_str) 

310 

311 if quote and fake_author: 

312 wq_id = await create_wrong_quote( 

313 real_author_param=quote.author, 

314 fake_author_param=fake_author, 

315 quote_param=quote, 

316 contributed_by=user_name, 

317 ) 

318 return self.redirect( 

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

320 status=303, 

321 ) 

322 

323 if not quote: 

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

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

326 if not real_author_str: 

327 raise HTTPError( 

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

329 ) 

330 

331 quotes: list[Quote | str] = ( 

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

333 ) 

334 real_authors: list[Author | str] = ( 

335 [quote.author] 

336 if quote 

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

338 ) 

339 fake_authors: list[Author | str] = ( 

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

341 ) 

342 

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

344 LOGGER.info( 

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

346 quotes[0], 

347 real_authors[0], 

348 fake_authors[0], 

349 ) 

350 

351 wq_id = await create_wrong_quote( 

352 real_authors[0], 

353 fake_authors[0], 

354 quotes[0], 

355 contributed_by=user_name, 

356 ) 

357 return self.redirect( 

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

359 status=303, 

360 ) 

361 

362 await self.render( 

363 "pages/quotes/create2.html", 

364 quotes=quotes, 

365 real_authors=real_authors, 

366 fake_authors=fake_authors, 

367 user_name=user_name, 

368 ) 

369 

370 

371class CreatePage2(QuoteReadyCheckHandler): 

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

373 

374 RATELIMIT_POST_LIMIT = 2 

375 RATELIMIT_POST_COUNT_PER_PERIOD = 1 

376 RATELIMIT_POST_PERIOD = 45 

377 

378 async def post(self) -> None: 

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

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

381 LOGGER.info( 

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

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

384 self.request.path, 

385 self.request.body, 

386 ) 

387 await self.render( 

388 "pages/empty.html", 

389 text="Kein Zitat erstellt.", 

390 ) 

391 return 

392 

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

394 if not quote_str: 

395 raise MissingArgumentError("quote-2") 

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

397 if not fake_author_str: 

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

399 

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

401 fake_author := get_author_by_name(fake_author_str) 

402 ): 

403 # if selected existing quote and existing 

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

405 return self.redirect( 

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

407 status=303, 

408 ) 

409 

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

411 if not real_author: 

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

413 

414 wq_id = await create_wrong_quote( 

415 real_author, 

416 fake_author_str, 

417 quote_str, 

418 contributed_by=self.get_argument( 

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

420 ), 

421 ) 

422 return self.redirect( 

423 self.fix_url( 

424 f"/zitate/{wq_id}", 

425 ), 

426 status=303, 

427 )