Coverage for seedboxsync/front/apis/uploads.py: 90%

83 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-26 17:14 +0000

1# 

2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr> 

3# 

4# For the full copyright and license information, please view the LICENSE 

5# file that was distributed with this source code. 

6# 

7"""SeedboxSync api uploads view.""" 

8 

9from datetime import date 

10from typing import Any 

11from flask_restx import Namespace, fields, inputs, reqparse 

12from peewee import fn 

13from seedboxsync.core.database.models import Torrent 

14from seedboxsync.front.apis import Resource, parser_period 

15from seedboxsync.front.cache import cache 

16from seedboxsync.front.login_manager import login_required 

17 

18api = Namespace("uploads", description="Operations related to uploaded torrents management") 

19 

20 

21# ========================== 

22# Models 

23# ========================== 

24upload_model = api.model( 

25 "Upload", 

26 { 

27 "id": fields.Integer(required=True, description="Unique identifier of the uploaded torrent", example=99), 

28 "name": fields.String(required=True, description="Torrent file name", example="Justo.torrent"), 

29 "announce": fields.String( 

30 required=False, 

31 description="Announce URL or tracker information from the torrent file", 

32 example="https://serversecret.com/anounce", 

33 ), 

34 "announcer": fields.String( 

35 required=False, 

36 description="Tracker announce domain of the torrent", 

37 example="serversecret.com", 

38 ), 

39 "source": fields.String( 

40 required=False, 

41 description="Source or provenance of the torrent file", 

42 example="serversecret", 

43 ), 

44 "files": fields.Integer( 

45 required=False, 

46 description="Total number of files contained in the torrent", 

47 example=2, 

48 ), 

49 "size": fields.Integer( 

50 required=False, 

51 description="Total size of all files in bytes", 

52 example=3337353289, 

53 ), 

54 "human_size": fields.String( 

55 required=False, 

56 description="Total size of all files in with related humanization", 

57 example="3.1 GiB", 

58 ), 

59 "private": fields.Boolean( 

60 required=False, 

61 description="Flag indicating if the torrent is private", 

62 example=True, 

63 ), 

64 "sent": fields.DateTime( 

65 dt_format="iso8601", 

66 required=True, 

67 description="Timestamp when the torrent was uploaded", 

68 ), 

69 }, 

70) 

71upload_list_envelope = Resource.build_envelope_model(api, "UploadList", nested_model=upload_model) 

72upload_envelope = Resource.build_envelope_model(api, "Upload", nested_model=upload_model, as_list=False) 

73upload_message_envelope = Resource.build_envelope_model(api, "UploadMessage", as_message=True) 

74 

75stats_source_model = api.model( 

76 "StatsSource", 

77 { 

78 "source": fields.String( 

79 required=True, 

80 description="Source of the torrent, falback based on announcer", 

81 example="torrenter", 

82 ), 

83 "total": fields.Integer( 

84 required=True, 

85 description="Number or size of files for this source", 

86 example=4989, 

87 ), 

88 "total_size": fields.Integer( 

89 required=True, 

90 description="Size of files for this source", 

91 example=21678643250867, 

92 ), 

93 "human_total_size": fields.String( 

94 required=True, 

95 description="Total size of files with related source", 

96 example="19.7 Tio", 

97 ), 

98 }, 

99) 

100stats_source_envelope = Resource.build_envelope_model(api, "StatsSource", nested_model=stats_source_model) 

101 

102 

103# ========================== 

104# Request parser 

105# ========================== 

106parser = reqparse.RequestParser() 

107parser.add_argument( 

108 "offset", 

109 type=int, 

110 default=0, 

111 location="args", 

112 help="Number of items to skip before starting to collect the result set (default: 0)", 

113) 

114parser.add_argument( 

115 "limit", 

116 type=int, 

117 default=50, 

118 location="args", 

119 help="Maximum number of items to return (min=5, max=1000)", 

120) 

121parser.add_argument( 

122 "start_date", 

123 type=inputs.date_from_iso8601, 

124 location="args", 

125 help="Start date for filtering in ISO 8601 format (e.g. YYYY-MM-DD)", 

126) 

127parser.add_argument( 

128 "end_date", 

129 type=inputs.date_from_iso8601, 

130 location="args", 

131 help="End date for filtering in ISO 8601 format (e.g. YYYY-MM-DD)", 

132) 

133parser.add_argument("search", type=str, required=False, help="Optional search string to filter items") 

134 

135 

136# ========================== 

137# Endpoints 

138# ========================== 

139@api.route("") 

140class UploadsList(Resource): 

141 """ 

142 Endpoint to manage uploaded torrents. 

143 

144 Provides a list of uploaded torrents with optional limit on the number of items returned. 

145 """ 

146 

147 @api.doc("list_uploads") # type: ignore[untyped-decorator] 

148 @api.expect(parser) # type: ignore[untyped-decorator] 

149 @api.marshal_with(upload_list_envelope, code=200, description="List of uploaded torrents") # type: ignore[untyped-decorator] 

150 @login_required # type: ignore[untyped-decorator] 

151 def get(self) -> dict[str, Any]: 

152 """ 

153 Retrieve the most recent uploaded torrents. 

154 

155 Query Parameters: 

156 - offset: Number of items to skip before starting to collect the result set (default: 0) 

157 - limit: Maximum number of downloads to return (default=50) 

158 - search: Optional search string to filter items 

159 """ 

160 args = parser.parse_args() 

161 offset = args.get("offset") 

162 limit = self.set_limit(args.get("limit", 50)) 

163 search = args.get("search") 

164 start_date = args.get("start_date") 

165 end_date = args.get("end_date") 

166 

167 count = Torrent.select() 

168 select = ( 

169 Torrent.select( 

170 Torrent.id, 

171 Torrent.name, 

172 Torrent.announce, 

173 Torrent.announcer, 

174 Torrent.source, 

175 fn.coalesce(Torrent.total_files, None).alias("files"), 

176 fn.coalesce(Torrent.total_size, None).alias("size"), 

177 fn.humanize(Torrent.total_size).alias("human_size"), 

178 Torrent.private, 

179 Torrent.sent, 

180 ) 

181 .limit(limit) 

182 .offset(offset) 

183 .order_by(Torrent.sent.desc()) 

184 ) 

185 

186 if search: 

187 count = count.where(Torrent.name.contains(search)) 

188 select = select.where(Torrent.name.contains(search)) 

189 

190 if start_date: 

191 count = count.where(Torrent.sent >= start_date) 

192 select = select.where(Torrent.sent >= start_date) 

193 

194 if end_date: 

195 count = count.where(Torrent.sent <= end_date) 

196 select = select.where(Torrent.sent <= end_date) 

197 

198 return self.build_envelope(list(select.dicts()), data_total=count.count(), type="Upload") 

199 

200 

201@api.route("/<int:id>") 

202@api.response(404, "Upload not found") 

203@api.param("id", "The upload identifier") 

204class Uploads(Resource): 

205 """ 

206 Endpoint for managing upload. 

207 

208 Provides upload operations. 

209 """ 

210 

211 @api.doc("get_upload") # type: ignore[untyped-decorator] 

212 @api.marshal_with(upload_envelope, skip_none=True, code=200, description="Upload element") # type: ignore[untyped-decorator] 

213 @login_required # type: ignore[untyped-decorator] 

214 def get(self, id: int) -> dict[str, Any]: # noqa: A002 

215 """ 

216 Retrieve an uploaded torrent. 

217 

218 Args: 

219 id (int): Uploaded torrent identifier. 

220 

221 Returns: 

222 dict[str, Any]: API response envelope containing the upload. 

223 """ 

224 select: Torrent | None = None 

225 try: 

226 select = ( 

227 Torrent.select( 

228 Torrent.id, 

229 Torrent.name, 

230 Torrent.announce, 

231 Torrent.announcer, 

232 Torrent.source, 

233 fn.coalesce(Torrent.total_files, None).alias("files"), 

234 fn.coalesce(Torrent.total_size, None).alias("size"), 

235 fn.humanize(Torrent.total_size).alias("human_size"), 

236 Torrent.private, 

237 Torrent.sent, 

238 ) 

239 .where(Torrent.id == id) 

240 .dicts() 

241 .get() 

242 ) 

243 

244 except Torrent.DoesNotExist: # type: ignore[attr-defined] 

245 api.abort(404, f"Upload {id} doesn't exist") 

246 

247 return self.build_envelope(select, type="Upload") 

248 

249 @api.doc("delete_upload") # type: ignore[untyped-decorator] 

250 @api.marshal_with(upload_message_envelope, code=200, description="Delete upload element") # type: ignore[untyped-decorator] 

251 @login_required # type: ignore[untyped-decorator] 

252 def delete(self, id: int) -> dict[str, Any]: # noqa: A002 

253 """ 

254 Delete an uploaded torrent. 

255 

256 Args: 

257 id (int): Uploaded torrent identifier. 

258 

259 Returns: 

260 dict[str, Any]: API response envelope containing a status message. 

261 """ 

262 count = Torrent.delete().where(Torrent.id == id).execute() 

263 if count == 0: 

264 api.abort(404, f"Upload {id} doesn't exist") 

265 

266 return self.build_envelope(None, type="Upload", message=f"Upload {id} deleted.") 

267 

268 

269@api.route("/stats/source") 

270class UploadsStatsBySource(Resource): 

271 """Resource endpoint to retrieve torrent source statistics.""" 

272 

273 @api.doc("stats_uploads_by_source") # type: ignore[untyped-decorator] 

274 @api.marshal_with(stats_source_envelope, code=200, description="Upload statistics aggregated by source") # type: ignore[untyped-decorator] 

275 @api.expect(parser_period) # type: ignore[untyped-decorator] 

276 @login_required # type: ignore[untyped-decorator] 

277 def get(self) -> dict[str, Any]: 

278 """ 

279 Retrieve torrent statistics grouped by source. 

280 

281 Fetches aggregated file counts and total sizes per source from the cache 

282 or database, then wraps the dataset into a standard API response envelope. 

283 

284 Returns: 

285 dict[str, Any]: Envelope containing source statistics, metadata, 

286 and total element count. 

287 """ 

288 args = parser_period.parse_args() 

289 start_date = args.get("start_date") 

290 end_date = args.get("end_date") 

291 

292 stats = _get_stats_by_source(start_date, end_date) 

293 

294 return self.build_envelope(stats, data_total=len(stats), type="StatsSource") 

295 

296 

297@cache.memoize(timeout=300) 

298def _get_stats_by_source(start_date: date | None, end_date: date | None) -> list[dict[str, object]]: 

299 """ 

300 Fetch torrent statistics grouped by source within an optional date range. 

301 

302 Executes the database query to aggregate torrent statistics by source domain 

303 filtered by date boundaries if provided, then caches the result using Flask-Caching memoization[cite: 2]. 

304 

305 Args: 

306 start_date (date | None, optional): Optional lower date boundary for filtering. Defaults to None. 

307 end_date (date | None, optional): Optional upper date boundary for filtering. Defaults to None. 

308 

309 Returns: 

310 list[dict[str, object]]: A list of dictionaries containing source statistics, 

311 including total counts and associated sizes[cite: 2]. 

312 """ 

313 return Torrent.get_stats_by_source(start_date, end_date)