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

162 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-04 20:05 +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 

16from __future__ import annotations 

17 

18import logging 

19from dataclasses import dataclass 

20from typing import Final, cast 

21 

22from tornado.web import HTTPError, MissingArgumentError 

23 

24from ..utils.data_parsing import parse_args 

25from ..utils.utils import bounded_edit_distance 

26from .utils import ( 

27 AUTHORS_CACHE, 

28 QUOTES_CACHE, 

29 Author, 

30 Quote, 

31 QuoteReadyCheckHandler, 

32 fix_author_name, 

33 fix_quote_str, 

34 get_wrong_quote, 

35 make_api_request, 

36 parse_author, 

37 parse_quote, 

38 parse_wrong_quote, 

39) 

40 

41LOGGER: Final = logging.getLogger(__name__) 

42 

43 

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

45 """Create a quote.""" 

46 quote_str = fix_quote_str(quote_str) 

47 

48 quote = get_quote_by_str(quote_str) 

49 if quote is not None: 

50 return quote 

51 

52 data = await make_api_request( 

53 "quotes", 

54 method="POST", 

55 body={ 

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

57 "quote": quote_str, 

58 }, 

59 entity_should_exist=False, 

60 ) 

61 if data is None: 

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

63 raise HTTPError(500) 

64 

65 result = parse_quote(data) 

66 

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

68 

69 return result 

70 

71 

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

73 """Create an author.""" 

74 author_str = fix_author_name(author_str) 

75 

76 author = get_author_by_name(author_str) 

77 if author is not None: 

78 return author 

79 

80 data = await make_api_request( 

81 "authors", 

82 method="POST", 

83 body={"author": author_str}, 

84 entity_should_exist=False, 

85 ) 

86 

87 if data is None: 

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

89 raise HTTPError(500) 

90 

91 result = parse_author(data) 

92 

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

94 

95 return result 

96 

97 

98async def create_wrong_quote( 

99 real_author_param: Author | str, 

100 fake_author_param: Author | str, 

101 quote_param: Quote | str, 

102 *, 

103 contributed_by: str | None = None, 

104) -> str: 

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

106 if isinstance(fake_author_param, str): 

107 if not fake_author_param: 

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

109 fake_author = await create_author(fake_author_param) 

110 else: 

111 fake_author = fake_author_param 

112 

113 if isinstance(quote_param, str): 

114 if not quote_param: 

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

116 

117 if isinstance(real_author_param, str): 

118 if not real_author_param: 

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

120 real_author = await create_author(real_author_param) 

121 else: 

122 real_author = real_author_param 

123 quote = await create_quote(quote_param, real_author) 

124 else: 

125 quote = quote_param 

126 

127 wrong_quote = await get_wrong_quote( 

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

129 ) 

130 

131 if not wrong_quote: 

132 LOGGER.error( 

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

134 quote, 

135 fake_author, 

136 ) 

137 raise HTTPError() 

138 

139 if wrong_quote.id == -1: 

140 result = await make_api_request( 

141 "wrongquotes", 

142 method="POST", 

143 body={ 

144 "author": fake_author.id, 

145 "quote": quote.id, 

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

147 }, 

148 entity_should_exist=False, 

149 ) 

150 if result is None: 

151 LOGGER.error( 

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

153 wrong_quote.get_id_as_str(True), 

154 ) 

155 raise HTTPError(500) 

156 wrong_quote = parse_wrong_quote(result) 

157 LOGGER.info( 

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

159 wrong_quote.get_id_as_str(True), 

160 wrong_quote, 

161 ) 

162 

163 return wrong_quote.get_id_as_str() 

164 

165 

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

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

168 author = get_author_by_name(author_name) 

169 if author is not None: 

170 return [author] 

171 

172 author_name_lower = author_name.lower() 

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

174 authors: list[Author | str] = [ 

175 *( 

176 author 

177 for author in AUTHORS_CACHE.values() 

178 if bounded_edit_distance( 

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

180 ) 

181 <= max_distance 

182 ), 

183 fix_author_name(author_name), 

184 ] 

185 # authors should be in most cases title case 

186 fixed_author = author_name.title() 

187 if fixed_author not in authors: 

188 authors.append(fixed_author) 

189 # no other fixes for authors that are less long 

190 if len(author_name) < 2: 

191 return authors 

192 # maybe only the first letter upper case 

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

194 if fixed_author_2 not in authors: 

195 authors.append(fixed_author_2) 

196 return authors 

197 

198 

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

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

201 lower_name = fix_author_name(name).lower() 

202 for author in AUTHORS_CACHE.values(): 

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

204 return author 

205 return None 

206 

207 

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

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

210 lower_quote = fix_quote_str(quote_str).lower() 

211 for quote in QUOTES_CACHE.values(): 

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

213 return quote 

214 return None 

215 

216 

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

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

219 quote: None | Quote = get_quote_by_str(quote_str) 

220 if isinstance(quote, Quote): 

221 return [quote] 

222 

223 lower_quote_str = quote_str.lower() 

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

225 quotes: list[Quote | str] = [ 

226 *( 

227 quote 

228 for quote in QUOTES_CACHE.values() 

229 if bounded_edit_distance( 

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

231 ) 

232 <= max_distance 

233 ), 

234 fix_quote_str(quote_str), 

235 ] 

236 if not ( 

237 quote_str.endswith("?") 

238 or quote_str.endswith(".") 

239 or quote_str.endswith("!") 

240 ): 

241 quotes.append(quote_str + ".") 

242 return quotes 

243 

244 

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

246class QuoteInfoArgs: 

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

248 

249 quote: int | None = None 

250 author: int | None = None 

251 

252 

253class CreatePage1(QuoteReadyCheckHandler): 

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

255 

256 RATELIMIT_POST_LIMIT = 5 

257 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

258 

259 @parse_args(type_=QuoteInfoArgs) 

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

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

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

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

264 

265 if head: 

266 return 

267 

268 await self.render( 

269 "pages/quotes/create1.html", 

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

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

272 selected_quote=( 

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

274 ), 

275 selected_author=( 

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

277 ), 

278 ) 

279 

280 async def post(self) -> None: 

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

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

283 LOGGER.info( 

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

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

286 self.request.path, 

287 self.request.body, 

288 ) 

289 await self.render( 

290 "pages/empty.html", 

291 text="Kein Zitat erstellt.", 

292 ) 

293 return 

294 

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

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

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

298 if not (quote_str and fake_author_str): 

299 raise HTTPError( 

300 400, 

301 reason=( 

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

303 ), 

304 ) 

305 quote: None | Quote = get_quote_by_str(quote_str) 

306 fake_author: None | Author = get_author_by_name(fake_author_str) 

307 

308 if quote and fake_author: 

309 wq_id = await create_wrong_quote( 

310 real_author_param=quote.author, 

311 fake_author_param=fake_author, 

312 quote_param=quote, 

313 contributed_by=user_name, 

314 ) 

315 return self.redirect( 

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

317 status=303, 

318 ) 

319 

320 if not quote: 

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

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

323 if not real_author_str: 

324 raise HTTPError( 

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

326 ) 

327 

328 quotes: list[Quote | str] = ( 

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

330 ) 

331 real_authors: list[Author | str] = ( 

332 [quote.author] 

333 if quote 

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

335 ) 

336 fake_authors: list[Author | str] = ( 

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

338 ) 

339 

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

341 LOGGER.info( 

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

343 quotes[0], 

344 real_authors[0], 

345 fake_authors[0], 

346 ) 

347 

348 wq_id = await create_wrong_quote( 

349 real_authors[0], 

350 fake_authors[0], 

351 quotes[0], 

352 contributed_by=user_name, 

353 ) 

354 return self.redirect( 

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

356 status=303, 

357 ) 

358 

359 await self.render( 

360 "pages/quotes/create2.html", 

361 quotes=quotes, 

362 real_authors=real_authors, 

363 fake_authors=fake_authors, 

364 user_name=user_name, 

365 ) 

366 

367 

368class CreatePage2(QuoteReadyCheckHandler): 

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

370 

371 RATELIMIT_POST_LIMIT = 2 

372 RATELIMIT_POST_COUNT_PER_PERIOD = 1 

373 RATELIMIT_POST_PERIOD = 45 

374 

375 async def post(self) -> None: 

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

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

378 LOGGER.info( 

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

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

381 self.request.path, 

382 self.request.body, 

383 ) 

384 await self.render( 

385 "pages/empty.html", 

386 text="Kein Zitat erstellt.", 

387 ) 

388 return 

389 

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

391 if not quote_str: 

392 raise MissingArgumentError("quote-2") 

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

394 if not fake_author_str: 

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

396 

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

398 fake_author := get_author_by_name(fake_author_str) 

399 ): 

400 # if selected existing quote and existing 

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

402 return self.redirect( 

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

404 status=303, 

405 ) 

406 

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

408 if not real_author: 

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

410 

411 wq_id = await create_wrong_quote( 

412 real_author, 

413 fake_author_str, 

414 quote_str, 

415 contributed_by=self.get_argument( 

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

417 ), 

418 ) 

419 return self.redirect( 

420 self.fix_url( 

421 f"/zitate/{wq_id}", 

422 ), 

423 status=303, 

424 )