Coverage for seedboxsync/front/apis/downloads_stats.py: 86%

86 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-11 07:37 +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 error module.""" 

8 

9from datetime import date 

10from typing import Any 

11from flask_restx import Namespace, fields 

12from peewee import fn 

13from seedboxsync.core import utils 

14from seedboxsync.core.database.models import Download, typed_peewee_dicts 

15from seedboxsync.front.apis import Resource, parser_period 

16from seedboxsync.front.cache import cache 

17from seedboxsync.front.login_manager import login_required 

18 

19api = Namespace("downloads_stats", path="/downloads/stats", description="Stats related to download") 

20 

21 

22# ========================== 

23# Models 

24# ========================== 

25stats_month_model = api.model( 

26 "DownloadStatsByMonth", 

27 { 

28 "files": fields.Integer( 

29 required=True, 

30 description="Number of files downloaded in the month", 

31 example=135, 

32 ), 

33 "month": fields.String( 

34 required=True, 

35 description="Year and month of the statistics (format: yyyy-mm)", 

36 pattern=r"^\d{4}-(0[1-9]|1[0-2])$", 

37 example="2025-08", 

38 ), 

39 "total_size": fields.String( 

40 required=True, 

41 description="Total size of files downloaded", 

42 example="427.8GiB", 

43 ), 

44 }, 

45) 

46stats_month_envelope = Resource.build_envelope_model(api, "DownloadStatsByMonthList", nested_model=stats_month_model) 

47 

48stats_year_model = api.model( 

49 "DownloadStatsByYear", 

50 { 

51 "files": fields.Integer( 

52 required=True, 

53 description="Number of files downloaded in the year", 

54 example=4989, 

55 ), 

56 "year": fields.String( 

57 required=True, 

58 description="Year of the statistics (format: yyyy)", 

59 pattern=r"^\d{4}$", 

60 example="2018", 

61 ), 

62 "total_size": fields.String( 

63 required=True, 

64 description="Total size of files downloaded", 

65 example="1476.5GiB", 

66 ), 

67 }, 

68) 

69stats_year_envelope = Resource.build_envelope_model(api, "DownloadStatsByYearList", nested_model=stats_year_model) 

70 

71stats_mimetype_model = api.model( 

72 "DownloadStatsByMimeType", 

73 { 

74 "mime_type": fields.String( 

75 required=True, 

76 description="MIME type identifier detected for the files", 

77 pattern=r"^[a-zA-Z0-9!#$&^_\-\+\.]+/[a-zA-Z0-9!#$&^_\-\+\.]+$", 

78 example="video/x-matroska", 

79 ), 

80 "total": fields.Integer( 

81 required=True, 

82 description="Number or size of files downloaded for this MIME type", 

83 example=4989, 

84 ), 

85 "total_size": fields.Integer( 

86 required=True, 

87 description="Size of files downloaded for this MIME type", 

88 example=21678643250867, 

89 ), 

90 "human_total_size": fields.String( 

91 required=True, 

92 description="Total size of files downloaded with related humanization", 

93 example="19.7 Tio", 

94 ), 

95 }, 

96) 

97stats_mimetype_envelope = Resource.build_envelope_model(api, "DownloadStatsByMimeTypeList", nested_model=stats_mimetype_model) 

98 

99stats_item_model = api.model( 

100 "DownloadStatsItem", 

101 { 

102 "total": fields.Integer( 

103 description="Number of downloads", 

104 example=12, 

105 ), 

106 "size": fields.Integer( 

107 description="Total size of downloads in bytes", 

108 example=123456789, 

109 ), 

110 "human_size": fields.String( 

111 description="Human-readable total size of downloads", 

112 example="117.74 MB", 

113 ), 

114 "trend_total": fields.String( 

115 description="Trends vs. prior period for number of downloads", 

116 example="+10.0", 

117 ), 

118 "trend_size": fields.String( 

119 description="Trends vs. prior period for size of downloads", 

120 example="+15.2", 

121 ), 

122 }, 

123) 

124stats_model = api.model( 

125 "DownloadStats", 

126 { 

127 "day": fields.Nested( 

128 stats_item_model, 

129 description="Statistics for the current day", 

130 ), 

131 "week": fields.Nested( 

132 stats_item_model, 

133 description="Statistics for the current calendar week", 

134 ), 

135 "month": fields.Nested( 

136 stats_item_model, 

137 description="Statistics for the current month", 

138 ), 

139 "year": fields.Nested( 

140 stats_item_model, 

141 description="Statistics for the current year", 

142 ), 

143 "last24h": fields.Nested( 

144 stats_item_model, 

145 description="Statistics for the last 24 hours", 

146 ), 

147 "last7d": fields.Nested( 

148 stats_item_model, 

149 description="Statistics for the last 7 days", 

150 ), 

151 "last30d": fields.Nested( 

152 stats_item_model, 

153 description="Statistics for the last 30 days", 

154 ), 

155 }, 

156) 

157stats_envelope = Resource.build_envelope_model(api, "DownloadStats", nested_model=stats_model, as_list=False) 

158 

159 

160# ========================== 

161# Endpoints 

162# ========================== 

163@api.route("") 

164class DownloadsStats(Resource): 

165 """Resource endpoint to retrieve download statistics.""" 

166 

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

168 @api.marshal_with(stats_envelope, code=200, description="Download statistics") # type: ignore[untyped-decorator] 

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

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

171 """ 

172 Fetch overall download statistics. 

173 

174 Retrieves aggregated download metrics and wraps them within 

175 a standard API response envelope[cite: 1, 2]. 

176 

177 Returns: 

178 dict[str, Any]: Formatted envelope response containing download statistics[cite: 1, 2]. 

179 """ 

180 stats = _get_stats() 

181 return self.build_envelope(stats, data_total=len(stats), type="DownloadStats") 

182 

183 

184@api.route("/month") 

185class DownloadsStatsByMonth(Resource): 

186 """Endpoint to retrieve monthly download statistics.""" 

187 

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

189 @api.marshal_with(stats_month_envelope, code=200, description="Download statistics aggregated by month") # type: ignore[untyped-decorator] 

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

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

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

193 """ 

194 Return download statistics grouped by month. 

195 

196 Returns the number of files downloaded and total size per month. 

197 """ 

198 args = parser_period.parse_args() 

199 start_date = args.get("start_date") 

200 end_date = args.get("end_date") 

201 

202 stats = stats_by_period("month", start_date, end_date) 

203 

204 return self.build_envelope(stats, data_total=len(stats), type="DownloadStatsByMonthList") 

205 

206 

207@api.route("/year") 

208class DownloadsStatsByYear(Resource): 

209 """Endpoint to retrieve yearly download statistics.""" 

210 

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

212 @api.marshal_with(stats_year_envelope, code=200, description="Download statistics aggregated by year") # type: ignore[untyped-decorator] 

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

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

215 """ 

216 Return download statistics grouped by year. 

217 

218 Returns the number of files downloaded and total size per year. 

219 """ 

220 stats = stats_by_period("year") 

221 

222 return self.build_envelope(stats, data_total=len(stats), type="DownloadStatsByYearList") 

223 

224 

225@api.route("/mimetype") 

226class DownloadsStatsByMimeType(Resource): 

227 """Resource endpoint to retrieve download MIME type statistics.""" 

228 

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

230 @api.marshal_with(stats_mimetype_envelope, code=200, description="Download statistics aggregated by mimetype") # type: ignore[untyped-decorator] 

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

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

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

234 """ 

235 Retrieve download statistics grouped by MIME type. 

236 

237 Fetches aggregated file counts and total sizes per MIME type from the cache 

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

239 

240 Returns: 

241 dict[str, Any]: Envelope containing MIME type statistics, metadata, 

242 and total element count. 

243 """ 

244 args = parser_period.parse_args() 

245 start_date = args.get("start_date") 

246 end_date = args.get("end_date") 

247 

248 stats = _get_stats_by_mime_type(start_date, end_date) 

249 

250 return self.build_envelope(stats, data_total=len(stats), type="DownloadStatsByMimeTypeList") 

251 

252 

253# ========================== 

254# Utility functions 

255# ========================== 

256@cache.memoize(timeout=300) 

257def stats_by_period(period: str, start_date: date | None = None, end_date: date | None = None) -> list[dict[str, str | float]]: 

258 """ 

259 Compute aggregated download statistics by period (month or year). 

260 

261 Args: 

262 period (str): Aggregation period, either 'month' or 'year'. 

263 start_date (datetime.date | None): Optional start date filter. 

264 end_date (datetime.date | None): Optional end date filter. 

265 

266 Returns: 

267 list[dict[str, str | float]]: List of statistics including period, number of files, 

268 and total size. 

269 """ 

270 strftime_format = "%Y-%m" if period == "month" else "%Y" 

271 # Build "where" expression 

272 conditions = [] 

273 conditions.append(Download.finished != 0) 

274 if start_date: 

275 conditions.append(Download.finished >= start_date) 

276 if end_date: 

277 conditions.append(Download.finished <= end_date) 

278 

279 data = typed_peewee_dicts( 

280 Download.select( 

281 Download.id, 

282 Download.finished, 

283 fn.strftime(strftime_format, Download.finished).alias(period), 

284 Download.seedbox_size, 

285 ) 

286 .where(*conditions) 

287 .order_by(Download.finished.desc()) 

288 .dicts() 

289 ) 

290 

291 tmp = {} 

292 for download in data: 

293 key = download[period] 

294 size = download["seedbox_size"] 

295 if not key or not size: 

296 continue 

297 if key not in tmp: 

298 tmp[key] = {"files": 0, "total_size": 0.0} 

299 tmp[key]["files"] += 1 

300 tmp[key]["total_size"] += size 

301 

302 return [ 

303 { 

304 period: key, 

305 "files": tmp[key]["files"], 

306 "total_size": utils.byte_to_gi(tmp[key]["total_size"]), 

307 } 

308 for key in sorted(tmp) 

309 ] 

310 

311 

312@cache.memoize(timeout=300) 

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

314 """ 

315 Fetch file download counts and total sizes grouped by MIME type. 

316 

317 Executes the database query to aggregate finished downloads count and sum up 

318 their local sizes by MIME type, then caches the result using Flask-Caching memoization[cite: 3]. 

319 

320 Args: 

321 start_date (datetime.date | None): Optional start date filter. 

322 end_date (datetime.date | None): Optional end date filter. 

323 

324 Returns: 

325 list[dict[str, object]]: A list of dictionaries containing MIME types, 

326 their associated total file counts, and total sizes in bytes[cite: 3]. 

327 """ 

328 return Download.get_stats_by_mime_type(start_date, end_date) 

329 

330 

331@cache.memoize(timeout=300) 

332def _get_stats() -> dict[str, object]: 

333 """ 

334 Fetch raw download statistics within an optional date range from the database. 

335 

336 Executes the underlying DAO query and caches the result for 5 minutes (300 seconds) 

337 using Flask-Caching memoization[cite: 1, 2, 3]. 

338 

339 Args: 

340 start_date (date | None): Optional start date filter for range querying[cite: 1]. 

341 end_date (date | None): Optional end date filter for range querying[cite: 1]. 

342 

343 Returns: 

344 list[dict[str, object]]: A list of dictionaries representing download statistics data[cite: 1, 2]. 

345 """ 

346 return Download.get_stats()