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

142 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-01 02:01 +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 make_api_request, 

35 parse_author, 

36 parse_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 result = parse_quote( 

51 await make_api_request( 

52 "quotes", 

53 method="POST", 

54 body={ 

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

56 "quote": quote_str, 

57 }, 

58 entity_should_exist=False, 

59 ) 

60 ) 

61 

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

63 

64 return result 

65 

66 

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

68 """Create an author.""" 

69 author_str = fix_author_name(author_str) 

70 

71 author = get_author_by_name(author_str) 

72 if author is not None: 

73 return author 

74 

75 result = parse_author( 

76 await make_api_request( 

77 "authors", 

78 method="POST", 

79 body={"author": author_str}, 

80 entity_should_exist=False, 

81 ) 

82 ) 

83 

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

85 

86 return result 

87 

88 

89async def create_wrong_quote( 

90 real_author_param: Author | str, 

91 fake_author_param: Author | str, 

92 quote_param: Quote | str, 

93) -> str: 

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

95 if isinstance(fake_author_param, str): 

96 if not fake_author_param: 

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

98 fake_author = await create_author(fake_author_param) 

99 else: 

100 fake_author = fake_author_param 

101 

102 if isinstance(quote_param, str): 

103 if not quote_param: 

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

105 

106 if isinstance(real_author_param, str): 

107 if not real_author_param: 

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

109 real_author = await create_author(real_author_param) 

110 else: 

111 real_author = real_author_param 

112 quote = await create_quote(quote_param, real_author) 

113 else: 

114 quote = quote_param 

115 

116 return f"{quote.id}-{fake_author.id}" 

117 

118 

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

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

121 author = get_author_by_name(author_name) 

122 if author is not None: 

123 return [author] 

124 

125 author_name_lower = author_name.lower() 

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

127 authors: list[Author | str] = [ 

128 *( 

129 author 

130 for author in AUTHORS_CACHE.values() 

131 if bounded_edit_distance( 

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

133 ) 

134 <= max_distance 

135 ), 

136 fix_author_name(author_name), 

137 ] 

138 # authors should be in most cases title case 

139 fixed_author = author_name.title() 

140 if fixed_author not in authors: 

141 authors.append(fixed_author) 

142 # no other fixes for authors that are less long 

143 if len(author_name) < 2: 

144 return authors 

145 # maybe only the first letter upper case 

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

147 if fixed_author_2 not in authors: 

148 authors.append(fixed_author_2) 

149 return authors 

150 

151 

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

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

154 lower_name = fix_author_name(name).lower() 

155 for author in AUTHORS_CACHE.values(): 

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

157 return author 

158 return None 

159 

160 

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

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

163 lower_quote = fix_quote_str(quote_str).lower() 

164 for quote in QUOTES_CACHE.values(): 

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

166 return quote 

167 return None 

168 

169 

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

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

172 quote: None | Quote = get_quote_by_str(quote_str) 

173 if isinstance(quote, Quote): 

174 return [quote] 

175 

176 lower_quote_str = quote_str.lower() 

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

178 quotes: list[Quote | str] = [ 

179 *( 

180 quote 

181 for quote in QUOTES_CACHE.values() 

182 if bounded_edit_distance( 

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

184 ) 

185 <= max_distance 

186 ), 

187 fix_quote_str(quote_str), 

188 ] 

189 if not ( 

190 quote_str.endswith("?") 

191 or quote_str.endswith(".") 

192 or quote_str.endswith("!") 

193 ): 

194 quotes.append(quote_str + ".") 

195 return quotes 

196 

197 

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

199class QuoteInfoArgs: 

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

201 

202 quote: int | None = None 

203 author: int | None = None 

204 

205 

206class CreatePage1(QuoteReadyCheckHandler): 

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

208 

209 RATELIMIT_POST_LIMIT = 5 

210 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

211 

212 @parse_args(type_=QuoteInfoArgs) 

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

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

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

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

217 

218 if head: 

219 return 

220 

221 await self.render( 

222 "pages/quotes/create1.html", 

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

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

225 selected_quote=( 

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

227 ), 

228 selected_author=( 

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

230 ), 

231 ) 

232 

233 async def post(self) -> None: 

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

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

236 LOGGER.info( 

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

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

239 self.request.path, 

240 self.request.body, 

241 ) 

242 await self.render( 

243 "pages/empty.html", 

244 text="Kein Zitat erstellt.", 

245 ) 

246 return 

247 

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

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

250 if not (quote_str and fake_author_str): 

251 raise HTTPError( 

252 400, 

253 reason=( 

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

255 ), 

256 ) 

257 quote: None | Quote = get_quote_by_str(quote_str) 

258 fake_author: None | Author = get_author_by_name(fake_author_str) 

259 

260 if quote and fake_author: 

261 # if selected existing quote and existing 

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

263 return self.redirect( 

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

265 status=303, 

266 ) 

267 

268 if not quote: 

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

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

271 if not real_author_str: 

272 raise HTTPError( 

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

274 ) 

275 

276 quotes: list[Quote | str] = ( 

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

278 ) 

279 real_authors: list[Author | str] = ( 

280 [quote.author] 

281 if quote 

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

283 ) 

284 fake_authors: list[Author | str] = ( 

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

286 ) 

287 

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

289 LOGGER.info( 

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

291 quotes[0], 

292 real_authors[0], 

293 fake_authors[0], 

294 ) 

295 

296 wq_id = await create_wrong_quote( 

297 real_authors[0], 

298 fake_authors[0], 

299 quotes[0], 

300 ) 

301 return self.redirect( 

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

303 status=303, 

304 ) 

305 

306 await self.render( 

307 "pages/quotes/create2.html", 

308 quotes=quotes, 

309 real_authors=real_authors, 

310 fake_authors=fake_authors, 

311 ) 

312 

313 

314class CreatePage2(QuoteReadyCheckHandler): 

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

316 

317 RATELIMIT_POST_LIMIT = 2 

318 RATELIMIT_POST_COUNT_PER_PERIOD = 1 

319 RATELIMIT_POST_PERIOD = 45 

320 

321 async def post(self) -> None: 

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

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

324 LOGGER.info( 

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

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

327 self.request.path, 

328 self.request.body, 

329 ) 

330 await self.render( 

331 "pages/empty.html", 

332 text="Kein Zitat erstellt.", 

333 ) 

334 return 

335 

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

337 if not quote_str: 

338 raise MissingArgumentError("quote-2") 

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

340 if not fake_author_str: 

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

342 

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

344 fake_author := get_author_by_name(fake_author_str) 

345 ): 

346 # if selected existing quote and existing 

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

348 return self.redirect( 

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

350 status=303, 

351 ) 

352 

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

354 if not real_author: 

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

356 

357 LOGGER.info( 

358 "Creating wrong quote: %r (%r) - %r", 

359 quote_str, 

360 real_author, 

361 fake_author_str, 

362 ) 

363 

364 wq_id = await create_wrong_quote( 

365 real_author, 

366 fake_author_str, 

367 quote_str, 

368 ) 

369 return self.redirect( 

370 self.fix_url( 

371 f"/zitate/{wq_id}", 

372 ), 

373 status=303, 

374 )