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

125 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-22 15:59 +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 

18from dataclasses import dataclass 

19from typing import cast 

20 

21from tornado.web import HTTPError, MissingArgumentError 

22 

23from ..utils.data_parsing import parse_args 

24from ..utils.utils import bounded_edit_distance 

25from .utils import ( 

26 AUTHORS_CACHE, 

27 QUOTES_CACHE, 

28 Author, 

29 Quote, 

30 QuoteReadyCheckHandler, 

31 fix_author_name, 

32 fix_quote_str, 

33 make_api_request, 

34 parse_author, 

35 parse_quote, 

36) 

37 

38 

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

40 """Create a quote.""" 

41 quote_str = fix_quote_str(quote_str) 

42 

43 quote = get_quote_by_str(quote_str) 

44 if quote is not None: 

45 return quote 

46 

47 return parse_quote( 

48 await make_api_request( 

49 "quotes", 

50 method="POST", 

51 body={ 

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

53 "quote": quote_str, 

54 }, 

55 entity_should_exist=False, 

56 ) 

57 ) 

58 

59 

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

61 """Create an author.""" 

62 author_str = fix_author_name(author_str) 

63 

64 author = get_author_by_name(author_str) 

65 if author is not None: 

66 return author 

67 

68 return parse_author( 

69 await make_api_request( 

70 "authors", 

71 method="POST", 

72 body={"author": author_str}, 

73 entity_should_exist=False, 

74 ) 

75 ) 

76 

77 

78async def create_wrong_quote( 

79 real_author_param: Author | str, 

80 fake_author_param: Author | str, 

81 quote_param: Quote | str, 

82) -> str: 

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

84 if isinstance(fake_author_param, str): 

85 if not fake_author_param: 

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

87 fake_author = await create_author(fake_author_param) 

88 else: 

89 fake_author = fake_author_param 

90 

91 if isinstance(quote_param, str): 

92 if not quote_param: 

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

94 

95 if isinstance(real_author_param, str): 

96 if not real_author_param: 

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

98 real_author = await create_author(real_author_param) 

99 else: 

100 real_author = real_author_param 

101 quote = await create_quote(quote_param, real_author) 

102 else: 

103 quote = quote_param 

104 

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

106 

107 

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

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

110 author = get_author_by_name(author_name) 

111 if author is not None: 

112 return [author] 

113 

114 author_name_lower = author_name.lower() 

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

116 authors: list[Author | str] = [ 

117 *( 

118 author 

119 for author in AUTHORS_CACHE.values() 

120 if bounded_edit_distance( 

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

122 ) 

123 <= max_distance 

124 ), 

125 fix_author_name(author_name), 

126 ] 

127 # authors should be in most cases title case 

128 fixed_author = author_name.title() 

129 if fixed_author not in authors: 

130 authors.append(fixed_author) 

131 # no other fixes for authors that are less long 

132 if len(author_name) < 2: 

133 return authors 

134 # maybe only the first letter upper case 

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

136 if fixed_author_2 not in authors: 

137 authors.append(fixed_author_2) 

138 return authors 

139 

140 

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

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

143 lower_name = fix_author_name(name).lower() 

144 for author in AUTHORS_CACHE.values(): 

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

146 return author 

147 return None 

148 

149 

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

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

152 lower_quote = fix_quote_str(quote_str).lower() 

153 for quote in QUOTES_CACHE.values(): 

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

155 return quote 

156 return None 

157 

158 

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

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

161 quote: None | Quote = get_quote_by_str(quote_str) 

162 if isinstance(quote, Quote): 

163 return [quote] 

164 

165 lower_quote_str = quote_str.lower() 

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

167 quotes: list[Quote | str] = [ 

168 *( 

169 quote 

170 for quote in QUOTES_CACHE.values() 

171 if bounded_edit_distance( 

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

173 ) 

174 <= max_distance 

175 ), 

176 fix_quote_str(quote_str), 

177 ] 

178 if not ( 

179 quote_str.endswith("?") 

180 or quote_str.endswith(".") 

181 or quote_str.endswith("!") 

182 ): 

183 quotes.append(quote_str + ".") 

184 return quotes 

185 

186 

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

188class QuoteInfoArgs: 

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

190 

191 quote: int | None = None 

192 author: int | None = None 

193 

194 

195class CreatePage1(QuoteReadyCheckHandler): 

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

197 

198 RATELIMIT_POST_LIMIT = 5 

199 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

200 

201 @parse_args(type_=QuoteInfoArgs) 

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

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

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

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

206 

207 if head: 

208 return 

209 

210 await self.render( 

211 "pages/quotes/create1.html", 

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

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

214 selected_quote=( 

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

216 ), 

217 selected_author=( 

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

219 ), 

220 ) 

221 

222 async def post(self) -> None: 

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

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

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

226 if not (quote_str and fake_author_str): 

227 raise HTTPError( 

228 400, 

229 reason=( 

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

231 ), 

232 ) 

233 quote: None | Quote = get_quote_by_str(quote_str) 

234 fake_author: None | Author = get_author_by_name(fake_author_str) 

235 

236 if quote and fake_author: 

237 # if selected existing quote and existing 

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

239 return self.redirect( 

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

241 ) 

242 

243 if not quote: 

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

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

246 if not real_author_str: 

247 raise HTTPError( 

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

249 ) 

250 

251 quotes: list[Quote | str] = ( 

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

253 ) 

254 real_authors: list[Author | str] = ( 

255 [quote.author] 

256 if quote 

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

258 ) 

259 fake_authors: list[Author | str] = ( 

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

261 ) 

262 

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

264 wq_id = await create_wrong_quote( 

265 real_authors[0], 

266 fake_authors[0], 

267 quotes[0], 

268 ) 

269 return self.redirect(self.fix_url(f"/zitate/{wq_id}")) 

270 

271 await self.render( 

272 "pages/quotes/create2.html", 

273 quotes=quotes, 

274 real_authors=real_authors, 

275 fake_authors=fake_authors, 

276 ) 

277 

278 

279class CreatePage2(QuoteReadyCheckHandler): 

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

281 

282 RATELIMIT_POST_LIMIT = 5 

283 RATELIMIT_POST_COUNT_PER_PERIOD = 10 

284 

285 async def post(self) -> None: 

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

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

288 if not quote_str: 

289 raise MissingArgumentError("quote-2") 

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

291 if not fake_author_str: 

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

293 

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

295 fake_author := get_author_by_name(fake_author_str) 

296 ): 

297 # if selected existing quote and existing 

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

299 return self.redirect( 

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

301 ) 

302 

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

304 if not real_author: 

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

306 

307 wq_id = await create_wrong_quote( 

308 real_author, 

309 fake_author_str, 

310 quote_str, 

311 ) 

312 return self.redirect( 

313 self.fix_url( 

314 f"/zitate/{wq_id}", 

315 ) 

316 )