Coverage for distro_tracker/vendor/debian/models.py: 81%

138 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2026-11-06 11:33 +0000

1# -*- coding: utf-8 -*- 

2 

3# Copyright 2013 The Distro Tracker Developers 

4# See the COPYRIGHT file at the top-level directory of this distribution and 

5# at https://deb.li/DTAuthors 

6# 

7# This file is part of Distro Tracker. It is subject to the license terms 

8# in the LICENSE file found in the top-level directory of this 

9# distribution and at https://deb.li/DTLicense. No part of Distro Tracker, 

10# including this file, may be copied, modified, propagated, or distributed 

11# except according to the terms contained in the LICENSE file. 

12 

13""" 

14Debian-specific models. 

15""" 

16 

17from django.core.exceptions import ObjectDoesNotExist 

18from django.db import models 

19from django.utils.http import urlencode 

20 

21from distro_tracker.core.models import ( 

22 BinaryPackageBugStats, 

23 BugDisplayManager, 

24 PackageBugStats, 

25 PackageName, 

26 SourcePackageName, 

27) 

28from distro_tracker.core.utils import SpaceDelimitedTextField, get_or_none 

29from distro_tracker.core.utils.packages import package_hashdir 

30 

31 

32class DebianContributor(models.Model): 

33 """ 

34 Model containing additional Debian-specific information about contributors. 

35 """ 

36 email = models.OneToOneField('django_email_accounts.UserEmail', 

37 on_delete=models.CASCADE) 

38 agree_with_low_threshold_nmu = models.BooleanField(default=False) 

39 is_debian_maintainer = models.BooleanField(default=False) 

40 allowed_packages = SpaceDelimitedTextField(blank=True) 

41 

42 def __str__(self): 

43 return 'Debian contributor <{email}>'.format(email=self.email) 

44 

45 

46class LintianStats(models.Model): 

47 """ 

48 Model for lintian stats of packages. 

49 """ 

50 package = models.OneToOneField(PackageName, related_name='lintian_stats', 

51 on_delete=models.CASCADE) 

52 stats = models.JSONField(default=dict) 

53 

54 def __str__(self): 

55 return 'Lintian stats for package {package}'.format( 

56 package=self.package) 

57 

58 def get_lintian_url(self): 

59 """ 

60 Returns the lintian URL for the package matching the 

61 :class:`LintianStats 

62 <distro_tracker.vendor.debian.models.LintianStats>`. 

63 """ 

64 package = get_or_none(SourcePackageName, pk=self.package.pk) 

65 if not package: 65 ↛ 66line 65 didn't jump to line 66, because the condition on line 65 was never true

66 return '' 

67 

68 return ( 

69 'https://udd.debian.org/lintian/?packages={pkg}'.format( 

70 pkg=self.package) 

71 ) 

72 

73 

74class PackageTransition(models.Model): 

75 package = models.ForeignKey(PackageName, related_name='package_transitions', 

76 on_delete=models.CASCADE) 

77 transition_name = models.CharField(max_length=120) 

78 status = models.CharField(max_length=50, blank=True, null=True) 

79 reject = models.BooleanField(default=False) 

80 

81 def __str__(self): 

82 return "Transition {name} ({status}) for package {pkg}".format( 

83 name=self.transition_name, status=self.status, pkg=self.package) 

84 

85 

86class PackageExcuses(models.Model): 

87 package = models.OneToOneField(PackageName, related_name='excuses', 

88 on_delete=models.CASCADE) 

89 excuses = models.JSONField(default=dict) 

90 

91 def __str__(self): 

92 return "Excuses for the package {pkg}".format(pkg=self.package) 

93 

94 

95class UbuntuPackage(models.Model): 

96 package = models.OneToOneField( 

97 PackageName, 

98 related_name='ubuntu_package', 

99 on_delete=models.CASCADE) 

100 version = models.TextField(max_length=100) 

101 bugs = models.JSONField(null=True) 

102 patch_diff = models.JSONField(null=True) 

103 

104 def __str__(self): 

105 return "Ubuntu package info for {pkg}".format(pkg=self.package) 

106 

107 

108class DebianBugDisplayManager(BugDisplayManager): 

109 table_field_template_name = 'debian/package-table-fields/bugs.html' 

110 panel_template_name = 'debian/bugs.html' 

111 # Map category names to their bug panel display names and descriptions 

112 category_descriptions = { 

113 'rc': { 

114 'display_name': 'RC', 

115 'description': 'Release Critical', 

116 }, 

117 'normal': { 

118 'display_name': 'I&N', 

119 'description': 'Important and Normal', 

120 }, 

121 'wishlist': { 

122 'display_name': 'M&W', 

123 'description': 'Minor and Wishlist', 

124 }, 

125 'fixed': { 

126 'display_name': 'F&P', 

127 'description': 'Fixed and Pending', 

128 }, 

129 'patch': { 

130 'display_name': 'patch', 

131 'description': 'Patch', 

132 }, 

133 'help': { 

134 'display_name': 'help', 

135 'description': 'Help needed', 

136 }, 

137 'newcomer': { 

138 'display_name': 'NC', 

139 'description': 'newcomer', 

140 'link': 'https://wiki.debian.org/BTS/NewcomerTag', 

141 } 

142 } 

143 

144 def get_bug_tracker_url(self, package_name, package_type, category_name, 

145 archived=False): 

146 """ 

147 Returns a URL to the BTS for the given package for the given bug 

148 category name. 

149 

150 The following categories are recognized for Debian's implementation: 

151 

152 - ``all`` - all bugs for the package 

153 - ``all-merged`` - all bugs, including the merged ones 

154 - ``rc`` - release critical bugs 

155 - ``rc-merged`` - release critical bugs, including the merged ones 

156 - ``normal`` - bugs tagged as normal and important 

157 - ``normal`` - bugs tagged as normal and important, including the merged 

158 ones 

159 - ``wishlist`` - bugs tagged as wishlist and minor 

160 - ``wishlist-merged`` - bugs tagged as wishlist and minor, including the 

161 merged ones 

162 - ``fixed`` - bugs tagged as fixed and pending 

163 - ``fixed-merged`` - bugs tagged as fixed and pending, including the 

164 merged ones 

165 

166 :param package_name: The name of the package for which the BTS link 

167 should be provided. 

168 :param package_type: The type of the package for which the BTS link 

169 should be provided. For Debian this is one of: ``source``, 

170 ``pseudo``, ``binary``. 

171 :param category_name: The name of the bug category for which the BTS 

172 link should be provided. It is one of the categories listed above. 

173 :param archived: Whether the link should also list the archived bugs. 

174 

175 :rtype: :class:`string` or ``None`` if there is no BTS bug for the given 

176 category. 

177 """ 

178 URL_PARAMETERS = { 

179 'all': ( 

180 ('repeatmerged', 'no'), 

181 ), 

182 'rc': ( 

183 ('archive', 'no'), 

184 ('pend-exc', 'pending-fixed'), 

185 ('pend-exc', 'fixed'), 

186 ('pend-exc', 'done'), 

187 ('sev-inc', 'critical'), 

188 ('sev-inc', 'grave'), 

189 ('sev-inc', 'serious'), 

190 ('repeatmerged', 'no'), 

191 ), 

192 'normal': ( 

193 ('archive', 'no'), 

194 ('pend-exc', 'pending-fixed'), 

195 ('pend-exc', 'fixed'), 

196 ('pend-exc', 'done'), 

197 ('sev-inc', 'important'), 

198 ('sev-inc', 'normal'), 

199 ('repeatmerged', 'no'), 

200 ), 

201 'wishlist': ( 

202 ('archive', 'no'), 

203 ('pend-exc', 'pending-fixed'), 

204 ('pend-exc', 'fixed'), 

205 ('pend-exc', 'done'), 

206 ('sev-inc', 'minor'), 

207 ('sev-inc', 'wishlist'), 

208 ('repeatmerged', 'no'), 

209 ), 

210 'fixed': ( 

211 ('archive', 'no'), 

212 ('pend-inc', 'pending-fixed'), 

213 ('pend-inc', 'fixed'), 

214 ('repeatmerged', 'no'), 

215 ), 

216 'patch': ( 

217 ('include', 'tags:patch'), 

218 ('exclude', 'tags:pending'), 

219 ('pend-exc', 'done'), 

220 ('repeatmerged', 'no'), 

221 ), 

222 'help': ( 

223 ('tag', 'help'), 

224 ('pend-exc', 'pending-fixed'), 

225 ('pend-exc', 'fixed'), 

226 ('pend-exc', 'done'), 

227 ), 

228 'newcomer': ( 

229 ('tag', 'newcomer'), 

230 ('pend-exc', 'pending-fixed'), 

231 ('pend-exc', 'fixed'), 

232 ('pend-exc', 'done'), 

233 ), 

234 'all-merged': ( 

235 ('repeatmerged', 'yes'), 

236 ), 

237 'rc-merged': ( 

238 ('archive', 'no'), 

239 ('pend-exc', 'pending-fixed'), 

240 ('pend-exc', 'fixed'), 

241 ('pend-exc', 'done'), 

242 ('sev-inc', 'critical'), 

243 ('sev-inc', 'grave'), 

244 ('sev-inc', 'serious'), 

245 ('repeatmerged', 'yes'), 

246 ), 

247 'normal-merged': ( 

248 ('archive', 'no'), 

249 ('pend-exc', 'pending-fixed'), 

250 ('pend-exc', 'fixed'), 

251 ('pend-exc', 'done'), 

252 ('sev-inc', 'important'), 

253 ('sev-inc', 'normal'), 

254 ('repeatmerged', 'yes'), 

255 ), 

256 'wishlist-merged': ( 

257 ('archive', 'no'), 

258 ('pend-exc', 'pending-fixed'), 

259 ('pend-exc', 'fixed'), 

260 ('pend-exc', 'done'), 

261 ('sev-inc', 'minor'), 

262 ('sev-inc', 'wishlist'), 

263 ('repeatmerged', 'yes'), 

264 ), 

265 'fixed-merged': ( 

266 ('archive', 'no'), 

267 ('pend-inc', 'pending-fixed'), 

268 ('pend-inc', 'fixed'), 

269 ('repeatmerged', 'yes'), 

270 ), 

271 'patch-merged': ( 

272 ('include', 'tags:patch'), 

273 ('exclude', 'tags:pending'), 

274 ('pend-exc', 'done'), 

275 ('repeatmerged', 'yes'), 

276 ), 

277 } 

278 if category_name not in URL_PARAMETERS: 278 ↛ 279line 278 didn't jump to line 279, because the condition on line 278 was never true

279 return 

280 

281 domain = 'https://bugs.debian.org/' 

282 query_parameters = URL_PARAMETERS[category_name] 

283 if archived: 

284 query_parameters = tuple( 

285 param for param in query_parameters if param[0] != 'archive') 

286 query_parameters += (('archive', 'both'),) 

287 

288 if package_type == 'source': 288 ↛ 290line 288 didn't jump to line 290, because the condition on line 288 was never false

289 query_parameters += (('src', package_name),) 

290 elif package_type == 'binary': 

291 if category_name == 'all': 

292 # All bugs for a binary package don't follow the same pattern as 

293 # the rest of the URLs. 

294 return domain + package_name 

295 query_parameters += (('which', 'pkg'),) 

296 query_parameters += (('data', package_name),) 

297 

298 return ( 

299 domain + 

300 'cgi-bin/pkgreport.cgi?' + 

301 urlencode(query_parameters) 

302 ) 

303 

304 @staticmethod 

305 def _is_gone(package): 

306 """ 

307 A package that left all repositories is neither a source nor a 

308 pseudo package anymore. Its bugs end up archived. 

309 """ 

310 return not (package.source or package.pseudo) 

311 

312 def get_bugs_categories_list(self, stats, package, archived=False): 

313 # Some bug categories should not be included in the count. 

314 exclude_from_count = ('patch', 'help', 'newcomer') 

315 

316 categories = [] 

317 total, total_merged = 0, 0 

318 # From all known bug stats, extract only the ones relevant for the panel 

319 for category in stats: 

320 category_name = category['category_name'] 

321 if category_name not in self.category_descriptions.keys(): 321 ↛ 322line 321 didn't jump to line 322, because the condition on line 321 was never true

322 continue 

323 # Add main bug count 

324 category_stats = { 

325 'category_name': category['category_name'], 

326 'bug_count': category['bug_count'], 

327 } 

328 # Add merged bug count 

329 if 'merged_count' in category: 329 ↛ 335line 329 didn't jump to line 335, because the condition on line 329 was never false

330 if category['merged_count'] != category['bug_count']: 

331 category_stats['merged'] = { 

332 'bug_count': category['merged_count'], 

333 } 

334 # Add descriptions 

335 category_stats.update(self.category_descriptions[category_name]) 

336 categories.append(category_stats) 

337 

338 # Keep a running total of all and all-merged bugs 

339 if category_name not in exclude_from_count: 

340 total += category['bug_count'] 

341 total_merged += category.get('merged_count', 0) 

342 

343 # Add another "category" with the bug totals. 

344 all_category = { 

345 'category_name': 'all', 

346 'display_name': 'all', 

347 'bug_count': total, 

348 } 

349 if total != total_merged: 

350 all_category['merged'] = { 

351 'bug_count': total_merged, 

352 } 

353 # The totals are the first displayed row. 

354 categories.insert(0, all_category) 

355 

356 # Add URLs for all categories 

357 for category in categories: 

358 # URL for the non-merged category 

359 url = self.get_bug_tracker_url( 

360 package.name, 'source', category['category_name'], archived) 

361 category['url'] = url 

362 

363 # URL for the merged category 

364 if 'merged' in category: 

365 url_merged = self.get_bug_tracker_url( 

366 package.name, 'source', 

367 category['category_name'] + '-merged', 

368 archived 

369 ) 

370 category['merged']['url'] = url_merged 

371 

372 return categories 

373 

374 def table_field_context(self, package): 

375 """ 

376 :returns: The context data for package's bug stats with RC bugs data to 

377 be highlighted in the template, as well as providing proper links 

378 for Debian BTS. 

379 """ 

380 try: 

381 stats = package.bug_stats.stats 

382 except ObjectDoesNotExist: 

383 stats = [] 

384 

385 data = {} 

386 data['bugs'] = self.get_bugs_categories_list(stats, package) 

387 

388 total = 0 

389 for category in data['bugs']: 389 ↛ 393line 389 didn't jump to line 393, because the loop on line 389 didn't complete

390 if category['category_name'] == 'all': 390 ↛ 389line 390 didn't jump to line 389, because the condition on line 390 was never false

391 total = category['bug_count'] 

392 break 

393 data['all'] = total 

394 data['bts_url'] = self.get_bug_tracker_url( 

395 package.name, 'source', 'all') 

396 

397 # Highlights RC bugs and set text color based on the bug category 

398 data['text_color'] = 'text-default' 

399 for bug in data['bugs']: 

400 if bug['category_name'] == 'rc' and bug['bug_count'] > 0: 

401 data['text_color'] = 'text-danger' 

402 data['rc_bugs'] = bug['bug_count'] 

403 elif bug['category_name'] == 'normal' and bug['bug_count'] > 0: 

404 if data['text_color'] != 'text-danger': 

405 data['text_color'] = 'text-warning' 

406 elif bug['category_name'] == 'patch' and bug['bug_count'] > 0: 

407 if (data['text_color'] != 'text-warning' and 

408 data['text_color'] != 'text-danger'): 

409 data['text_color'] = 'text-info' 

410 return data 

411 

412 def panel_context(self, package): 

413 """ 

414 Returns bug statistics which are to be displayed in the bugs panel 

415 (:class:`BugsPanel <distro_tracker.core.panels.BugsPanel>`). 

416 

417 Debian wants to include the merged bug count for each bug category 

418 (but only if the count is different than non-merged bug count) so this 

419 function is used in conjunction with a custom bug panel template which 

420 displays this bug count in parentheses next to the non-merged count. 

421 

422 Each bug category count (merged and non-merged) is linked to a URL in 

423 the BTS which displays more information about the bugs found in that 

424 category. 

425 

426 A verbose name is included for each of the categories. 

427 

428 The function includes a URL to a bug history graph which is displayed in 

429 the rendered template. 

430 """ 

431 bug_stats = get_or_none(PackageBugStats, package=package) 

432 

433 if bug_stats: 

434 stats = bug_stats.stats 

435 else: 

436 stats = [] 

437 

438 categories = self.get_bugs_categories_list( 

439 stats, package, self._is_gone(package)) 

440 

441 # Debian also includes a custom graph of bug history 

442 graph_url = ( 

443 'https://qa.debian.org/data/bts/graphs/' 

444 '{package_hash}/{package_name}.png' 

445 ) 

446 

447 # Final context variables which are available in the template 

448 return { 

449 'categories': categories, 

450 'graph_url': graph_url.format( 

451 package_hash=package_hashdir(package.name), 

452 package_name=package.name), 

453 } 

454 

455 def get_binary_bug_stats(self, binary_name): 

456 """ 

457 Returns the bug statistics for the given binary package. 

458 

459 Debian's implementation filters out some of the stored bug category 

460 stats. It also provides a different, more verbose, display name for each 

461 of them. The included categories and their names are: 

462 

463 - rc - critical, grave serious 

464 - normal - important and normal 

465 - wishlist - wishlist and minor 

466 - fixed - pending and fixed 

467 """ 

468 stats = get_or_none(BinaryPackageBugStats, package__name=binary_name) 

469 if stats is None: 

470 return 

471 category_descriptions = { 

472 'rc': { 

473 'display_name': 'critical, grave and serious', 

474 }, 

475 'normal': { 

476 'display_name': 'important and normal', 

477 }, 

478 'wishlist': { 

479 'display_name': 'wishlist and minor', 

480 }, 

481 'fixed': { 

482 'display_name': 'pending and fixed', 

483 }, 

484 } 

485 

486 def extend_category(category, extra_parameters): 

487 category.update(extra_parameters) 

488 return category 

489 

490 # Filter the bug stats to only include some categories and add a custom 

491 # display name for each of them. 

492 return [ 

493 extend_category(category, 

494 category_descriptions[category['category_name']]) 

495 for category in stats.stats 

496 if category['category_name'] in category_descriptions.keys() 

497 ]