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

151 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 19:37 +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 result = parse_quote( 

53 await make_api_request( 

54 "quotes", 

55 method="POST", 

56 body={ 

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

58 "quote": quote_str, 

59 }, 

60 entity_should_exist=False, 

61 ) 

62 ) 

63 

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

65 

66 return result 

67 

68 

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

70 """Create an author.""" 

71 author_str = fix_author_name(author_str) 

72 

73 author = get_author_by_name(author_str) 

74 if author is not None: 

75 return author 

76 

77 result = parse_author( 

78 await make_api_request( 

79 "authors", 

80 method="POST", 

81 body={"author": author_str}, 

82 entity_should_exist=False, 

83 ) 

84 ) 

85 

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

87 

88 return result 

89 

90 

91async def create_wrong_quote( 

92 real_author_param: Author | str, 

93 fake_author_param: Author | str, 

94 quote_param: Quote | str, 

95 *, 

96 contributed_by: str | None = None, 

97) -> str: 

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

99 if isinstance(fake_author_param, str): 

100 if not fake_author_param: 

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

102 fake_author = await create_author(fake_author_param) 

103 else: 

104 fake_author = fake_author_param 

105 

106 if isinstance(quote_param, str): 

107 if not quote_param: 

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

109 

110 if isinstance(real_author_param, str): 

111 if not real_author_param: 

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

113 real_author = await create_author(real_author_param) 

114 else: 

115 real_author = real_author_param 

116 quote = await create_quote(quote_param, real_author) 

117 else: 

118 quote = quote_param 

119 

120 wrong_quote = await get_wrong_quote( 

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

122 ) 

123 

124 if not wrong_quote: 

125 LOGGER.error( 

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

127 quote, 

128 fake_author, 

129 ) 

130 raise HTTPError() 

131 

132 if wrong_quote.id == -1: 

133 result = await make_api_request( 

134 "wrongquotes", 

135 method="POST", 

136 body={ 

137 "author": fake_author.id, 

138 "quote": quote.id, 

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

140 }, 

141 entity_should_exist=False, 

142 ) 

143 wrong_quote = parse_wrong_quote(result) 

144 LOGGER.info( 

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

146 wrong_quote.get_id_as_str(True), 

147 wrong_quote, 

148 ) 

149 

150 return wrong_quote.get_id_as_str() 

151 

152 

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

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

155 author = get_author_by_name(author_name) 

156 if author is not None: 

157 return [author] 

158 

159 author_name_lower = author_name.lower() 

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

161 authors: list[Author | str] = [ 

162 *( 

163 author 

164 for author in AUTHORS_CACHE.values() 

165 if bounded_edit_distance( 

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

167 ) 

168 <= max_distance 

169 ), 

170 fix_author_name(author_name), 

171 ] 

172 # authors should be in most cases title case 

173 fixed_author = author_name.title() 

174 if fixed_author not in authors: 

175 authors.append(fixed_author) 

176 # no other fixes for authors that are less long 

177 if len(author_name) < 2: 

178 return authors 

179 # maybe only the first letter upper case 

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

181 if fixed_author_2 not in authors: 

182 authors.append(fixed_author_2) 

183 return authors 

184 

185 

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

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

188 lower_name = fix_author_name(name).lower() 

189 for author in AUTHORS_CACHE.values(): 

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

191 return author 

192 return None 

193 

194 

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

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

197 lower_quote = fix_quote_str(quote_str).lower() 

198 for quote in QUOTES_CACHE.values(): 

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

200 return quote 

201 return None 

202 

203 

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

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

206 quote: None | Quote = get_quote_by_str(quote_str) 

207 if isinstance(quote, Quote): 

208 return [quote] 

209 

210 lower_quote_str = quote_str.lower() 

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

212 quotes: list[Quote | str] = [ 

213 *( 

214 quote 

215 for quote in QUOTES_CACHE.values() 

216 if bounded_edit_distance( 

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

218 ) 

219 <= max_distance 

220 ), 

221 fix_quote_str(quote_str), 

222 ] 

223 if not ( 

224 quote_str.endswith("?") 

225 or quote_str.endswith(".") 

226 or quote_str.endswith("!") 

227 ): 

228 quotes.append(quote_str + ".") 

229 return quotes 

230 

231 

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

233class QuoteInfoArgs: 

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

235 

236 quote: int | None = None 

237 author: int | None = None 

238 

239 

240class CreatePage1(QuoteReadyCheckHandler): 

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

242 

243 RATELIMIT_POST_LIMIT = 5 

244 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

245 

246 @parse_args(type_=QuoteInfoArgs) 

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

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

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

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

251 

252 if head: 

253 return 

254 

255 await self.render( 

256 "pages/quotes/create1.html", 

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

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

259 selected_quote=( 

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

261 ), 

262 selected_author=( 

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

264 ), 

265 ) 

266 

267 async def post(self) -> None: 

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

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

270 LOGGER.info( 

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

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

273 self.request.path, 

274 self.request.body, 

275 ) 

276 await self.render( 

277 "pages/empty.html", 

278 text="Kein Zitat erstellt.", 

279 ) 

280 return 

281 

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

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

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

285 if not (quote_str and fake_author_str): 

286 raise HTTPError( 

287 400, 

288 reason=( 

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

290 ), 

291 ) 

292 quote: None | Quote = get_quote_by_str(quote_str) 

293 fake_author: None | Author = get_author_by_name(fake_author_str) 

294 

295 if quote and fake_author: 

296 wq_id = await create_wrong_quote( 

297 real_author_param=quote.author, 

298 fake_author_param=fake_author, 

299 quote_param=quote, 

300 contributed_by=user_name, 

301 ) 

302 return self.redirect( 

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

304 status=303, 

305 ) 

306 

307 if not quote: 

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

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

310 if not real_author_str: 

311 raise HTTPError( 

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

313 ) 

314 

315 quotes: list[Quote | str] = ( 

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

317 ) 

318 real_authors: list[Author | str] = ( 

319 [quote.author] 

320 if quote 

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

322 ) 

323 fake_authors: list[Author | str] = ( 

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

325 ) 

326 

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

328 LOGGER.info( 

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

330 quotes[0], 

331 real_authors[0], 

332 fake_authors[0], 

333 ) 

334 

335 wq_id = await create_wrong_quote( 

336 real_authors[0], 

337 fake_authors[0], 

338 quotes[0], 

339 contributed_by=user_name, 

340 ) 

341 return self.redirect( 

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

343 status=303, 

344 ) 

345 

346 await self.render( 

347 "pages/quotes/create2.html", 

348 quotes=quotes, 

349 real_authors=real_authors, 

350 fake_authors=fake_authors, 

351 user_name=user_name, 

352 ) 

353 

354 

355class CreatePage2(QuoteReadyCheckHandler): 

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

357 

358 RATELIMIT_POST_LIMIT = 2 

359 RATELIMIT_POST_COUNT_PER_PERIOD = 1 

360 RATELIMIT_POST_PERIOD = 45 

361 

362 async def post(self) -> None: 

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

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

365 LOGGER.info( 

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

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

368 self.request.path, 

369 self.request.body, 

370 ) 

371 await self.render( 

372 "pages/empty.html", 

373 text="Kein Zitat erstellt.", 

374 ) 

375 return 

376 

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

378 if not quote_str: 

379 raise MissingArgumentError("quote-2") 

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

381 if not fake_author_str: 

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

383 

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

385 fake_author := get_author_by_name(fake_author_str) 

386 ): 

387 # if selected existing quote and existing 

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

389 return self.redirect( 

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

391 status=303, 

392 ) 

393 

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

395 if not real_author: 

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

397 

398 wq_id = await create_wrong_quote( 

399 real_author, 

400 fake_author_str, 

401 quote_str, 

402 contributed_by=self.get_argument( 

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

404 ), 

405 ) 

406 return self.redirect( 

407 self.fix_url( 

408 f"/zitate/{wq_id}", 

409 ), 

410 status=303, 

411 )