1# Copyright 2013-2017 The Distro Tracker Developers 

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

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

4# 

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

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

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

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

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

10"""Models for the :mod:`distro_tracker.core` app.""" 

11import hashlib 

12import logging 

13import os 

14import random 

15import re 

16import string 

17import warnings 

18from datetime import timedelta 

19from email.iterators import typed_subpart_iterator 

20from email.utils import getaddresses, parseaddr 

21 

22from debian import changelog as debian_changelog 

23from debian.debian_support import AptPkgVersion 

24 

25from django.conf import settings 

26from django.core.exceptions import ( 

27 MultipleObjectsReturned, 

28 ObjectDoesNotExist, 

29 ValidationError 

30) 

31from django.core.files.base import ContentFile 

32from django.db import connection, models 

33from django.db.models import Q 

34from django.db.utils import IntegrityError 

35from django.template.defaultfilters import slugify 

36from django.template.exceptions import TemplateDoesNotExist 

37from django.urls import reverse 

38from django.utils import timezone 

39from django.utils.encoding import force_str 

40from django.utils.functional import cached_property 

41from django.utils.html import escape, format_html 

42from django.utils.safestring import mark_safe 

43 

44from distro_tracker import vendor 

45from distro_tracker.core.utils import ( 

46 SpaceDelimitedTextField, 

47 distro_tracker_render_to_string, 

48 get_or_none, 

49 now, 

50 verify_signature 

51) 

52from distro_tracker.core.utils.email_messages import ( 

53 decode_header, 

54 get_decoded_message_payload, 

55 get_message_body, 

56 message_from_bytes 

57) 

58from distro_tracker.core.utils.linkify import linkify 

59from distro_tracker.core.utils.misc import get_data_checksum 

60from distro_tracker.core.utils.packages import package_hashdir 

61from distro_tracker.core.utils.plugins import PluginRegistry 

62 

63from django_email_accounts.models import UserEmail 

64 

65DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS = \ 

66 settings.DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS 

67 

68logger_input = logging.getLogger('distro_tracker.input') 

69 

70 

71class Keyword(models.Model): 

72 """ 

73 Describes a keyword which can be used to tag package messages. 

74 """ 

75 name = models.CharField(max_length=50, unique=True) 

76 default = models.BooleanField(default=False) 

77 description = models.CharField(max_length=256, blank=True) 

78 

79 def __str__(self): 

80 return self.name 

81 

82 

83class EmailSettings(models.Model): 

84 """ 

85 Settings for an email 

86 """ 

87 user_email = models.OneToOneField(UserEmail, on_delete=models.CASCADE) 

88 default_keywords = models.ManyToManyField(Keyword) 

89 

90 def __str__(self): 

91 return self.email 

92 

93 @cached_property 

94 def email(self): 

95 return self.user_email.email 

96 

97 @cached_property 

98 def user(self): 

99 return self.user_email.user 

100 

101 def save(self, *args, **kwargs): 

102 """ 

103 Overrides the default save method to add the set of default keywords to 

104 the user's own default keywords after creating an instance. 

105 """ 

106 new_object = not self.id 

107 models.Model.save(self, *args, **kwargs) 

108 if new_object: 

109 self.default_keywords.set(Keyword.objects.filter(default=True)) 

110 

111 def is_subscribed_to(self, package): 

112 """ 

113 Checks if the user is subscribed to the given package. 

114 

115 :param package: The package (or package name) 

116 :type package: :class:`Package` or string 

117 """ 

118 if not isinstance(package, PackageName): 

119 package = get_or_none(PackageName, name=package) 

120 if not package: 

121 return False 

122 

123 return package in ( 

124 subscription.package 

125 for subscription in self.subscription_set.all_active() 

126 ) 

127 

128 def unsubscribe_all(self): 

129 """ 

130 Terminates all of the user's subscriptions. 

131 """ 

132 self.subscription_set.all().delete() 

133 

134 

135class PackageManagerQuerySet(models.query.QuerySet): 

136 """ 

137 A custom :class:`PackageManagerQuerySet <django.db.models.query.QuerySet>` 

138 for the :class:`PackageManager` manager. It is needed in order to change 

139 the bulk delete behavior. 

140 """ 

141 def delete(self): 

142 """ 

143 In the bulk delete, the only cases when an item should be deleted is: 

144 - when the bulk delete is made directly from the PackageName class 

145 

146 Else, the field corresponding to the package type you want to delete 

147 should be set to False. 

148 """ 

149 if self.model.objects.type is None: 

150 # Means the bulk delete is done from the PackageName class 

151 super(PackageManagerQuerySet, self).delete() 

152 else: 

153 # Called from a proxy class: here, this is only a soft delete 

154 self.update(**{self.model.objects.type: False}) 

155 

156 

157class PackageManager(models.Manager): 

158 """ 

159 A custom :class:`Manager <django.db.models.Manager>` for the 

160 :class:`PackageName` model. 

161 """ 

162 def __init__(self, package_type=None, *args, **kwargs): 

163 super(PackageManager, self).__init__(*args, **kwargs) 

164 self.type = package_type 

165 

166 def get_queryset(self): 

167 """ 

168 Overrides the default query set of the manager to exclude any 

169 :class:`PackageName` objects with a type that does not match this 

170 manager instance's :attr:`type`. 

171 

172 If the instance does not have a :attr:`type`, then all 

173 :class:`PackageName` instances are returned. 

174 """ 

175 qs = PackageManagerQuerySet(self.model, using=self._db) 

176 if self.type is None: 

177 return qs 

178 return qs.filter(**{ 

179 self.type: True, 

180 }) 

181 

182 def exists_with_name(self, package_name): 

183 """ 

184 :param package_name: The name of the package 

185 :type package_name: string 

186 :returns True: If a package with the given name exists. 

187 """ 

188 return self.filter(name=package_name).exists() 

189 

190 def create(self, *args, **kwargs): 

191 """ 

192 Overrides the default :meth:`create <django.db.models.Manager.create>` 

193 method to inject a :attr:`package_type <PackageName.package_type>` to 

194 the instance being created. 

195 

196 The type is the type given in this manager instance's :attr:`type` 

197 attribute. 

198 """ 

199 if self.type not in kwargs and self.type is not None: 

200 kwargs[self.type] = True 

201 

202 return super(PackageManager, self).create(*args, **kwargs) 

203 

204 def get_or_create(self, *args, **kwargs): 

205 """ 

206 Overrides the default 

207 :meth:`get_or_create <django.db.models.Manager.get_or_create>` 

208 to set the correct package type. 

209 

210 The type is the type given in this manager instance's :attr:`type` 

211 attribute. 

212 """ 

213 defaults = kwargs.get('defaults', {}) 

214 if self.type is not None: 

215 defaults.update({self.type: True}) 

216 kwargs['defaults'] = defaults 

217 entry, created = PackageName.default_manager.get_or_create(*args, 

218 **kwargs) 

219 if self.type and getattr(entry, self.type) is False: 

220 created = True 

221 setattr(entry, self.type, True) 

222 entry.save() 

223 if isinstance(entry, self.model): 

224 return entry, created 

225 else: 

226 return self.get(pk=entry.pk), created 

227 

228 def all_with_subscribers(self): 

229 """ 

230 A method which filters the packages and returns a QuerySet 

231 containing only those which have at least one subscriber. 

232 

233 :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>` of 

234 :py:class:`PackageName` instances. 

235 """ 

236 qs = self.annotate(subscriber_count=models.Count('subscriptions')) 

237 return qs.filter(subscriber_count__gt=0) 

238 

239 def get_by_name(self, package_name): 

240 """ 

241 :returns: A package with the given name 

242 :rtype: :class:`PackageName` 

243 """ 

244 return self.get(name=package_name) 

245 

246 

247class PackageName(models.Model): 

248 """ 

249 A model describing package names. 

250 

251 Three different types of packages are supported: 

252 

253 - Source packages 

254 - Binary packages 

255 - Pseudo packages 

256 

257 PackageName associated to no source/binary/pseudo packages are 

258 referred to as "Subscription-only packages". 

259 """ 

260 name = models.CharField(max_length=100, unique=True) 

261 source = models.BooleanField(default=False) 

262 binary = models.BooleanField(default=False) 

263 pseudo = models.BooleanField(default=False) 

264 

265 subscriptions = models.ManyToManyField(EmailSettings, 

266 through='Subscription') 

267 

268 objects = PackageManager() 

269 source_packages = PackageManager('source') 

270 binary_packages = PackageManager('binary') 

271 pseudo_packages = PackageManager('pseudo') 

272 default_manager = models.Manager() 

273 

274 def __str__(self): 

275 return self.name 

276 

277 def get_absolute_url(self): 

278 return reverse('dtracker-package-page', kwargs={ 

279 'package_name': self.name, 

280 }) 

281 

282 def get_package_type_display(self): 

283 if self.source: 283 ↛ 284line 283 didn't jump to line 284, because the condition on line 283 was never true

284 return 'Source package' 

285 elif self.binary: 285 ↛ 286line 285 didn't jump to line 286, because the condition on line 285 was never true

286 return 'Binary package' 

287 elif self.pseudo: 287 ↛ 288line 287 didn't jump to line 288, because the condition on line 287 was never true

288 return 'Pseudo package' 

289 else: 

290 return 'Subscription-only package' 

291 

292 def get_action_item_for_type(self, action_item_type): 

293 """ 

294 :param: The name of the :class:`ActionItemType` of the 

295 :class:`ActionItem` which is to be returned or an 

296 :class:`ActionItemType` instance. 

297 :type param: :class:`ActionItemType` or :class:`string` 

298 

299 :returns: An action item with the given type name which is associated 

300 to this :class:`PackageName` instance. ``None`` if the package 

301 has no action items of that type. 

302 :rtype: :class:`ActionItem` or ``None`` 

303 """ 

304 if isinstance(action_item_type, ActionItemType): 

305 action_item_type = action_item_type.type_name 

306 return next(( 

307 item 

308 for item in self.action_items.all() 

309 if item.item_type.type_name == action_item_type), 

310 None) 

311 

312 def delete(self, *args, **kwargs): 

313 """ 

314 Custom delete method so that PackageName proxy classes 

315 do not remove the underlying PackageName. Instead they update 

316 their corresponding "type" field to False so that they 

317 no longer find the package name. 

318 

319 The delete method on PackageName keeps its default behaviour. 

320 """ 

321 if self.__class__.objects.type: 

322 setattr(self, self.__class__.objects.type, False) 

323 self.save() 

324 else: 

325 super(PackageName, self).delete(*args, **kwargs) 

326 

327 def save(self, *args, **kwargs): 

328 if not re.match('[0-9a-z][-+.0-9a-z]+$', self.name): 

329 raise ValidationError(format_html( 

330 'Invalid package name: {}', 

331 self.name, 

332 )) 

333 models.Model.save(self, *args, **kwargs) 

334 

335 

336class PseudoPackageName(PackageName): 

337 """ 

338 A convenience proxy model of the :class:`PackageName` model. 

339 

340 It returns only those :class:`PackageName` instances whose 

341 :attr:`pseudo <PackageName.pseudo>` attribute is True. 

342 """ 

343 class Meta: 

344 proxy = True 

345 

346 objects = PackageManager('pseudo') 

347 

348 

349class BinaryPackageName(PackageName): 

350 """ 

351 A convenience proxy model of the :class:`PackageName` model. 

352 

353 It returns only those :class:`PackageName` instances whose 

354 :attr:`binary <PackageName.binary>` attribute is True. 

355 """ 

356 class Meta: 

357 proxy = True 

358 

359 objects = PackageManager('binary') 

360 

361 def get_absolute_url(self): 

362 # Take the URL of its source package 

363 main_source_package = self.main_source_package_name 

364 if main_source_package: 

365 return main_source_package.get_absolute_url() 

366 else: 

367 return None 

368 

369 @property 

370 def main_source_package_name(self): 

371 """ 

372 Returns the main source package name to which this binary package 

373 name is mapped. 

374 

375 The "main source package" is defined as follows: 

376 

377 - If the binary package is found in the default repository, the returned 

378 source package name is the one which has the highest version. 

379 - If the binary package is not found in the default repository, the 

380 returned source package name is the one of the source package with 

381 the highest version. 

382 

383 :rtype: string 

384 

385 This is used for redirecting users who try to access a Web page for 

386 by giving this binary's name. 

387 """ 

388 default_repo_sources_qs = self.sourcepackage_set.filter( 

389 repository_entries__repository__default=True) 

390 if default_repo_sources_qs.exists(): 

391 qs = default_repo_sources_qs 

392 else: 

393 qs = self.sourcepackage_set.all() 

394 

395 if qs.exists(): 395 ↛ 399line 395 didn't jump to line 399, because the condition on line 395 was never false

396 source_package = max(qs, key=lambda x: AptPkgVersion(x.version)) 

397 return source_package.source_package_name 

398 else: 

399 return None 

400 

401 

402class SourcePackageName(PackageName): 

403 """ 

404 A convenience proxy model of the :class:`PackageName` model. 

405 

406 It returns only those :class:`PackageName` instances whose 

407 :attr:`source <PackageName.source>` attribute is True. 

408 """ 

409 class Meta: 

410 proxy = True 

411 

412 objects = PackageManager('source') 

413 

414 @cached_property 

415 def main_version(self): 

416 """ 

417 Returns the main version of this :class:`SourcePackageName` instance. 

418 :rtype: string 

419 

420 It is defined as either the highest version found in the default 

421 repository, or if the package is not found in the default repository at 

422 all, the highest available version. 

423 """ 

424 default_repository_qs = self.source_package_versions.filter( 

425 repository_entries__repository__default=True) 

426 if default_repository_qs.exists(): 

427 qs = default_repository_qs 

428 else: 

429 qs = self.source_package_versions.all() 

430 

431 qs.select_related() 

432 try: 

433 return max(qs, key=lambda x: AptPkgVersion(x.version)) 

434 except ValueError: 

435 return None 

436 

437 @cached_property 

438 def main_entry(self): 

439 """ 

440 Returns the :class:`SourcePackageRepositoryEntry` which represents the 

441 package's entry in either the default repository (if the package is 

442 found there) or in the first repository (as defined by the repository 

443 order) which has the highest available package version. 

444 """ 

445 default_repository_qs = SourcePackageRepositoryEntry.objects.filter( 

446 repository__default=True, 

447 source_package__source_package_name=self 

448 ) 

449 if default_repository_qs.exists(): 

450 qs = default_repository_qs 

451 else: 

452 qs = SourcePackageRepositoryEntry.objects.filter( 

453 source_package__source_package_name=self) 

454 

455 qs = qs.select_related() 

456 try: 

457 return max( 

458 qs, 

459 key=lambda x: AptPkgVersion(x.source_package.version) 

460 ) 

461 except ValueError: 

462 return None 

463 

464 @cached_property 

465 def repositories(self): 

466 """ 

467 Returns all repositories which contain a source package with this name. 

468 

469 :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>` of 

470 :py:class:`Repository` instances. 

471 """ 

472 kwargs = { 

473 'source_entries' 

474 '__source_package' 

475 '__source_package_name': self 

476 } 

477 return Repository.objects.filter(**kwargs).distinct() 

478 

479 def short_description(self): 

480 """ 

481 Returns the most recent short description for a source package. If there 

482 is a binary package whose name matches the source package, its 

483 description will be used. If not, the short description for the first 

484 binary package will be used. 

485 """ 

486 if not self.main_version: 

487 return '' 

488 

489 binary_packages = self.main_version.binarypackage_set.all() 

490 

491 for pkg in binary_packages: 

492 if pkg.binary_package_name.name == self.name: 

493 return pkg.short_description 

494 

495 if len(binary_packages) == 1: 

496 return binary_packages[0].short_description 

497 

498 return '' 

499 

500 

501def get_web_package(package_name): 

502 """ 

503 Utility function mapping a package name to its most adequate Python 

504 representation (among :class:`SourcePackageName`, 

505 :class:`PseudoPackageName`, :class:`PackageName` and ``None``). 

506 

507 The rules are simple: a source package is returned as SourcePackageName, 

508 a pseudo-package is returned as PseudoPackageName, a binary package 

509 is turned into the corresponding SourcePackageName (which might have a 

510 different name!). 

511 

512 If the package name is known but is none of the above, it's only returned 

513 if it has associated :class:`News` since that proves that it used to be 

514 a former source package. 

515 

516 If that is not the case, then ``None`` is returned. You can use a "src:" 

517 or "bin:" prefix to the package name to restrict the lookup among source 

518 packages or binary packages, respectively. 

519 

520 :rtype: :class:`PackageName` or ``None`` 

521 

522 :param package_name: The name for which a package should be found. 

523 :type package_name: string 

524 """ 

525 

526 search_among = ( 

527 SourcePackageName, 

528 PseudoPackageName, 

529 BinaryPackageName, 

530 PackageName 

531 ) 

532 

533 if package_name.startswith("src:"): 

534 package_name = package_name[4:] 

535 search_among = (SourcePackageName, PackageName) 

536 elif package_name.startswith("bin:"): 

537 package_name = package_name[4:] 

538 search_among = (BinaryPackageName,) 

539 

540 for cls in search_among: 

541 if cls.objects.exists_with_name(package_name): 

542 pkg = cls.objects.get(name=package_name) 

543 if cls is BinaryPackageName: 

544 return pkg.main_source_package_name 

545 elif cls is PackageName: 

546 # This is not a current source or binary package, but if it has 

547 # associated news, then it's likely a former source package 

548 # where we can display something useful 

549 if pkg.news_set.count(): 

550 return pkg 

551 else: 

552 return pkg 

553 

554 return None 

555 

556 

557class SubscriptionManager(models.Manager): 

558 """ 

559 A custom :class:`Manager <django.db.models.Manager>` for the 

560 :class:`Subscription` class. 

561 """ 

562 def create_for(self, package_name, email, active=True): 

563 """ 

564 Creates a new subscription based on the given arguments. 

565 

566 :param package_name: The name of the subscription package 

567 :type package_name: string 

568 

569 :param email: The email address of the user subscribing to the package 

570 :type email: string 

571 

572 :param active: Indicates whether the subscription should be activated 

573 as soon as it is created. 

574 

575 :returns: The subscription for the given ``(email, package_name)`` pair. 

576 :rtype: :class:`Subscription` 

577 """ 

578 package = get_or_none(PackageName, name=package_name) 

579 if not package: 

580 # If the package did not previously exist, create a 

581 # "subscriptions-only" package. 

582 package = PackageName.objects.create( 

583 name=package_name) 

584 user_email, _ = UserEmail.objects.get_or_create(email=email) 

585 email_settings, _ = EmailSettings.objects.get_or_create( 

586 user_email=user_email) 

587 

588 subscription, _ = self.get_or_create(email_settings=email_settings, 

589 package=package) 

590 subscription.active = active 

591 subscription.save() 

592 

593 return subscription 

594 

595 def unsubscribe(self, package_name, email): 

596 """ 

597 Unsubscribes the given email from the given package. 

598 

599 :param email: The email of the user 

600 :param package_name: The name of the package the user should be 

601 unsubscribed from 

602 

603 :returns True: If the user was successfully unsubscribed 

604 :returns False: If the user was not unsubscribed, e.g. the subscription 

605 did not even exist. 

606 """ 

607 package = get_or_none(PackageName, name=package_name) 

608 user_email = get_or_none(UserEmail, email__iexact=email) 

609 email_settings = get_or_none(EmailSettings, user_email=user_email) 

610 if not package or not user_email or not email_settings: 610 ↛ 611line 610 didn't jump to line 611, because the condition on line 610 was never true

611 return False 

612 subscription = get_or_none( 

613 Subscription, email_settings=email_settings, package=package) 

614 if subscription: 614 ↛ 616line 614 didn't jump to line 616, because the condition on line 614 was never false

615 subscription.delete() 

616 return True 

617 

618 def get_for_email(self, email): 

619 """ 

620 Returns a list of active subscriptions for the given user. 

621 

622 :param email: The email address of the user 

623 :type email: string 

624 

625 :rtype: ``iterable`` of :class:`Subscription` instances 

626 

627 .. note:: 

628 Since this method is not guaranteed to return a 

629 :py:class:`QuerySet <django.db.models.query.QuerySet>` object, 

630 clients should not count on chaining additional filters to the 

631 result. 

632 """ 

633 user_email = get_or_none(UserEmail, email__iexact=email) 

634 email_settings = get_or_none(EmailSettings, user_email=user_email) 

635 if not user_email or not email_settings: 635 ↛ 636line 635 didn't jump to line 636, because the condition on line 635 was never true

636 return [] 

637 return email_settings.subscription_set.all_active() 

638 

639 def all_active(self, keyword=None): 

640 """ 

641 Returns all active subscriptions, optionally filtered on having the 

642 given keyword. 

643 

644 :rtype: ``iterable`` of :class:`Subscription` instances 

645 

646 .. note:: 

647 Since this method is not guaranteed to return a 

648 :py:class:`QuerySet <django.db.models.query.QuerySet>` object, 

649 clients should not count on chaining additional filters to the 

650 result. 

651 """ 

652 actives = self.filter(active=True) 

653 if keyword: 

654 keyword = get_or_none(Keyword, name=keyword) 

655 if not keyword: 

656 return self.none() 

657 actives = [ 

658 subscription 

659 for subscription in actives 

660 if keyword in subscription.keywords.all() 

661 ] 

662 return actives 

663 

664 

665class Subscription(models.Model): 

666 """ 

667 A model describing a subscription of a single :class:`EmailSettings` to a 

668 single :class:`PackageName`. 

669 """ 

670 email_settings = models.ForeignKey(EmailSettings, on_delete=models.CASCADE) 

671 package = models.ForeignKey(PackageName, on_delete=models.CASCADE) 

672 active = models.BooleanField(default=True) 

673 _keywords = models.ManyToManyField(Keyword) 

674 _use_user_default_keywords = models.BooleanField(default=True) 

675 

676 objects = SubscriptionManager() 

677 

678 class Meta: 

679 unique_together = ('email_settings', 'package') 

680 

681 class KeywordsAdapter(object): 

682 """ 

683 An adapter for accessing a :class:`Subscription`'s keywords. 

684 

685 When a :class:`Subscription` is initially created, it uses the default 

686 keywords of the user. Only after modifying the subscription-specific 

687 keywords, should it use a different set of keywords. 

688 

689 This class allows the clients of the class:`Subscription` class to 

690 access the :attr:`keywords <Subscription.keywords>` field without 

691 having to think about whether the subscription is using the user's 

692 keywords or not, rather the whole process is handled automatically and 

693 seamlessly. 

694 """ 

695 def __init__(self, subscription): 

696 #: Keep a reference to the original subscription object 

697 self._subscription = subscription 

698 

699 def __getattr__(self, name): 

700 # Methods which modify the set should cause it to become unlinked 

701 # from the user. 

702 if name in ('add', 'remove', 'create', 'clear', 'bulk_create'): 

703 self._unlink_from_user() 

704 return getattr(self._get_manager(), name) 

705 

706 def _get_manager(self): 

707 """ 

708 Helper method which returns the appropriate manager depending on 

709 whether the subscription is still using the user's keywords or not. 

710 """ 

711 if self._subscription._use_user_default_keywords: 

712 manager = self._subscription.email_settings.default_keywords 

713 else: 

714 manager = self._subscription._keywords 

715 return manager 

716 

717 def _unlink_from_user(self): 

718 """ 

719 Helper method which unlinks the subscription from the user's 

720 default keywords. 

721 """ 

722 if self._subscription._use_user_default_keywords: 

723 # Do not use the user's keywords anymore 

724 self._subscription._use_user_default_keywords = False 

725 # Copy the user's keywords 

726 email_settings = self._subscription.email_settings 

727 for keyword in email_settings.default_keywords.all(): 

728 self._subscription._keywords.add(keyword) 

729 self._subscription.save() 

730 

731 def __init__(self, *args, **kwargs): 

732 super(Subscription, self).__init__(*args, **kwargs) 

733 self.keywords = Subscription.KeywordsAdapter(self) 

734 

735 def __str__(self): 

736 return str(self.email_settings.user_email) + ' ' + str(self.package) 

737 

738 

739class Architecture(models.Model): 

740 """ 

741 A model describing a single architecture. 

742 """ 

743 name = models.CharField(max_length=30, unique=True) 

744 

745 def __str__(self): 

746 return self.name 

747 

748 

749class RepositoryManager(models.Manager): 

750 """ 

751 A custom :class:`Manager <django.db.models.Manager>` for the 

752 :class:`Repository` model. 

753 """ 

754 def get_default(self): 

755 """ 

756 Returns the default :class:`Repository` instance. 

757 

758 If there is no default repository, returns an empty 

759 :py:class:`QuerySet <django.db.models.query.QuerySet>` 

760 

761 :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>` 

762 """ 

763 return self.filter(default=True) 

764 

765 

766class Repository(models.Model): 

767 """ 

768 A model describing Debian repositories. 

769 """ 

770 name = models.CharField(max_length=50, unique=True) 

771 shorthand = models.CharField(max_length=10, unique=True) 

772 

773 uri = models.CharField(max_length=200, verbose_name='URI') 

774 public_uri = models.URLField( 

775 max_length=200, 

776 blank=True, 

777 verbose_name='public URI' 

778 ) 

779 suite = models.CharField(max_length=50) 

780 codename = models.CharField(max_length=50, blank=True) 

781 components = SpaceDelimitedTextField() 

782 architectures = models.ManyToManyField(Architecture, blank=True) 

783 default = models.BooleanField(default=False) 

784 

785 optional = models.BooleanField(default=True) 

786 binary = models.BooleanField(default=True) 

787 source = models.BooleanField(default=True) 

788 

789 source_packages = models.ManyToManyField( 

790 'SourcePackage', 

791 through='SourcePackageRepositoryEntry' 

792 ) 

793 

794 position = models.IntegerField(default=0) 

795 

796 objects = RepositoryManager() 

797 

798 class Meta: 

799 verbose_name_plural = "repositories" 

800 ordering = ( 

801 'position', 

802 ) 

803 

804 def __str__(self): 

805 return self.name 

806 

807 @classmethod 

808 def find(cls, identifier): 

809 """ 

810 Looks up a repository, trying first with a match on "name"; if 

811 that fails, sequentially try "shorthand", "codename" and "suite". 

812 

813 Matching by "codename" and "suite" will only be used if they return 

814 a single match. 

815 

816 If no match is found, then raises a ValueError. 

817 """ 

818 try: 

819 return Repository.objects.get(name=identifier) 

820 except ObjectDoesNotExist: 

821 pass 

822 

823 try: 

824 return Repository.objects.get(shorthand=identifier) 

825 except ObjectDoesNotExist: 

826 pass 

827 

828 try: 

829 return Repository.objects.get(codename=identifier) 

830 except (ObjectDoesNotExist, MultipleObjectsReturned): 

831 pass 

832 

833 try: 

834 return Repository.objects.get(suite=identifier) 

835 except (ObjectDoesNotExist, MultipleObjectsReturned): 

836 pass 

837 

838 raise ValueError("%s does not uniquely identifies a repository" % 

839 identifier) 

840 

841 @property 

842 def sources_list_entry(self): 

843 """ 

844 Returns the sources.list entries based on the repository's attributes. 

845 

846 :rtype: string 

847 """ 

848 entry_common = ( 

849 '{uri} {suite} {components}'.format( 

850 uri=self.uri, 

851 suite=self.suite, 

852 components=' '.join(self.components) 

853 ) 

854 ) 

855 src_entry = 'deb-src ' + entry_common 

856 if not self.binary: 856 ↛ 859line 856 didn't jump to line 859, because the condition on line 856 was never false

857 return src_entry 

858 else: 

859 bin_entry = 'deb [arch={archs}] ' + entry_common 

860 archs = ','.join(map(str, self.architectures.all())) 

861 bin_entry = bin_entry.format(archs=archs) 

862 return '\n'.join((src_entry, bin_entry)) 

863 

864 @property 

865 def component_urls(self): 

866 """ 

867 Returns a list of URLs which represent full URLs for each of the 

868 components of the repository. 

869 

870 :rtype: list 

871 """ 

872 base_url = self.uri.rstrip('/') 

873 return [ 

874 base_url + '/' + self.suite + '/' + component 

875 for component in self.components 

876 ] 

877 

878 def get_source_package_entry(self, package_name): 

879 """ 

880 Returns the canonical :class:`SourcePackageRepositoryEntry` with the 

881 given name, if found in the repository. 

882 

883 This means the instance with the highest 

884 :attr:`version <SourcePackage.version>` is returned. 

885 

886 If there is no :class:`SourcePackageRepositoryEntry` for the given name 

887 in this repository, returns ``None``. 

888 

889 :param package_name: The name of the package for which the entry should 

890 be returned 

891 :type package_name: string or :class:`SourcePackageName` 

892 

893 :rtype: :class:`SourcePackageRepositoryEntry` or ``None`` 

894 """ 

895 if isinstance(package_name, SourcePackageName): 

896 package_name = package_name.name 

897 qs = self.source_entries.filter( 

898 source_package__source_package_name__name=package_name) 

899 qs = qs.select_related() 

900 try: 

901 return max( 

902 qs, 

903 key=lambda x: AptPkgVersion(x.source_package.version)) 

904 except ValueError: 

905 return None 

906 

907 def add_source_package(self, package, **kwargs): 

908 """ 

909 The method adds a new class:`SourcePackage` to the repository. 

910 

911 :param package: The source package to add to the repository 

912 :type package: :class:`SourcePackage` 

913 

914 The parameters needed for the corresponding 

915 :class:`SourcePackageRepositoryEntry` should be in the keyword 

916 arguments. 

917 

918 Returns the newly created :class:`SourcePackageRepositoryEntry` for the 

919 given :class:`SourcePackage`. 

920 

921 :rtype: :class:`SourcePackageRepositoryEntry` 

922 """ 

923 

924 entry = SourcePackageRepositoryEntry.objects.create( 

925 repository=self, 

926 source_package=package, 

927 **kwargs, 

928 ) 

929 return entry 

930 

931 def has_source_package_name(self, source_package_name): 

932 """ 

933 Checks whether this :class:`Repository` contains a source package with 

934 the given name. 

935 

936 :param source_package_name: The name of the source package 

937 :type source_package_name: string 

938 

939 :returns True: If it contains at least one version of the source package 

940 with the given name. 

941 :returns False: If it does not contain any version of the source package 

942 with the given name. 

943 """ 

944 qs = self.source_packages.filter( 

945 source_package_name__name=source_package_name) 

946 return qs.exists() 

947 

948 def has_source_package(self, source_package): 

949 """ 

950 Checks whether this :class:`Repository` contains the given 

951 :class:`SourcePackage`. 

952 

953 :returns True: If it does contain the given :class:`SourcePackage` 

954 :returns False: If it does not contain the given :class:`SourcePackage` 

955 """ 

956 return self.source_packages.filter(id=source_package.id).exists() 

957 

958 def has_binary_package(self, binary_package): 

959 """ 

960 Checks whether this :class:`Repository` contains the given 

961 :class:`BinaryPackage`. 

962 

963 :returns True: If it does contain the given :class:`SourcePackage` 

964 :returns False: If it does not contain the given :class:`SourcePackage` 

965 """ 

966 qs = self.binary_entries.filter(binary_package=binary_package) 

967 return qs.exists() 

968 

969 def add_binary_package(self, package, **kwargs): 

970 """ 

971 The method adds a new class:`BinaryPackage` to the repository. 

972 

973 :param package: The binary package to add to the repository 

974 :type package: :class:`BinaryPackage` 

975 

976 The parameters needed for the corresponding 

977 :class:`BinaryPackageRepositoryEntry` should be in the keyword 

978 arguments. 

979 

980 Returns the newly created :class:`BinaryPackageRepositoryEntry` for the 

981 given :class:`BinaryPackage`. 

982 

983 :rtype: :class:`BinaryPackageRepositoryEntry` 

984 """ 

985 return BinaryPackageRepositoryEntry.objects.create( 

986 repository=self, 

987 binary_package=package, 

988 **kwargs 

989 ) 

990 

991 @staticmethod 

992 def release_file_url(base_url, suite): 

993 """ 

994 Returns the URL of the Release file for a repository with the given 

995 base URL and suite name. 

996 

997 :param base_url: The base URL of the repository 

998 :type base_url: string 

999 

1000 :param suite: The name of the repository suite 

1001 :type suite: string 

1002 

1003 :rtype: string 

1004 """ 

1005 base_url = base_url.rstrip('/') 

1006 return base_url + '/dists/{suite}/Release'.format( 

1007 suite=suite) 

1008 

1009 def clean(self): 

1010 """ 

1011 A custom model :meth:`clean <django.db.models.Model.clean>` method 

1012 which enforces that only one :class:`Repository` can be set as the 

1013 default. 

1014 """ 

1015 super(Repository, self).clean() 

1016 if self.default: 

1017 # If this instance is not trying to set default to True, it is safe 

1018 qs = Repository.objects.filter(default=True).exclude(pk=self.pk) 

1019 if qs.exists(): 

1020 raise ValidationError( 

1021 "Only one repository can be set as the default") 

1022 

1023 def is_development_repository(self): 

1024 """Returns a boolean indicating whether the repository is used for 

1025 development. 

1026 

1027 A development repository is a repository where new 

1028 versions of packages tend to be uploaded. The list of development 

1029 repositories can be provided in the list 

1030 DISTRO_TRACKER_DEVEL_REPOSITORIES (it should contain codenames and/or 

1031 suite names). If that setting does not exist, then the default 

1032 repository is assumed to be the only development repository. 

1033 

1034 :rtype: bool 

1035 """ 

1036 if hasattr(settings, 'DISTRO_TRACKER_DEVEL_REPOSITORIES'): 

1037 for repo in settings.DISTRO_TRACKER_DEVEL_REPOSITORIES: 

1038 if self.codename == repo or self.suite == repo: 

1039 return True 

1040 else: 

1041 return self.default 

1042 return False 

1043 

1044 def get_flags(self): 

1045 """ 

1046 Returns a dict of existing flags and values. If no existing flag it 

1047 returns the default value. 

1048 """ 

1049 d = {} 

1050 for flag, defvalue in RepositoryFlag.FLAG_DEFAULT_VALUES.items(): 

1051 d[flag] = defvalue 

1052 for flag in self.flags.all(): 

1053 d[flag.name] = flag.value 

1054 return d 

1055 

1056 

1057class RepositoryFlag(models.Model): 

1058 """ 

1059 Boolean options associated to repositories. 

1060 """ 

1061 FLAG_NAMES = ( 

1062 ('hidden', 'Hidden repository'), 

1063 ) 

1064 FLAG_DEFAULT_VALUES = { 

1065 'hidden': False, 

1066 } 

1067 

1068 repository = models.ForeignKey(Repository, related_name='flags', 

1069 on_delete=models.CASCADE) 

1070 name = models.CharField(max_length=50, choices=FLAG_NAMES) 

1071 value = models.BooleanField(default=False) 

1072 

1073 class Meta: 

1074 unique_together = ('repository', 'name') 

1075 

1076 

1077class RepositoryRelation(models.Model): 

1078 """ 

1079 Relations between two repositories. The relations are to be interpreted 

1080 like "<repository> is a <relation> of <target_repository>". 

1081 """ 

1082 RELATION_NAMES = ( 

1083 ('derivative', 'Derivative repository (target=parent)'), 

1084 ('overlay', 'Overlay of target repository'), 

1085 ) 

1086 

1087 repository = models.ForeignKey(Repository, related_name='relations', 

1088 on_delete=models.CASCADE) 

1089 name = models.CharField(max_length=50, choices=RELATION_NAMES) 

1090 target_repository = models.ForeignKey( 

1091 Repository, related_name='reverse_relations', on_delete=models.CASCADE) 

1092 

1093 class Meta: 

1094 unique_together = ('repository', 'name') 

1095 

1096 

1097class ContributorName(models.Model): 

1098 """ 

1099 Represents a contributor. 

1100 

1101 A single contributor, identified by email address, may have 

1102 different written names in different contexts. 

1103 """ 

1104 contributor_email = models.ForeignKey(UserEmail, on_delete=models.CASCADE) 

1105 name = models.CharField(max_length=60, blank=True) 

1106 

1107 class Meta: 

1108 unique_together = ('contributor_email', 'name') 

1109 

1110 @cached_property 

1111 def email(self): 

1112 return self.contributor_email.email 

1113 

1114 def __str__(self): 

1115 return "{name} <{email}>".format( 

1116 name=self.name, 

1117 email=self.contributor_email) 

1118 

1119 def to_dict(self): 

1120 """ 

1121 Returns a dictionary representing a :class:`ContributorName` 

1122 instance. 

1123 

1124 :rtype: dict 

1125 """ 

1126 return { 

1127 'name': self.name, 

1128 'email': self.contributor_email.email, 

1129 } 

1130 

1131 

1132class SourcePackage(models.Model): 

1133 """ 

1134 A model representing a single Debian source package. 

1135 

1136 This means it holds any information regarding a (package_name, version) 

1137 pair which is independent from the repository in which the package is 

1138 found. 

1139 """ 

1140 id = models.BigAutoField(primary_key=True) # noqa 

1141 source_package_name = models.ForeignKey( 

1142 SourcePackageName, 

1143 related_name='source_package_versions', 

1144 on_delete=models.CASCADE) 

1145 version = models.CharField(max_length=200) 

1146 

1147 standards_version = models.CharField(max_length=550, blank=True) 

1148 architectures = models.ManyToManyField(Architecture, blank=True) 

1149 binary_packages = models.ManyToManyField(BinaryPackageName, blank=True) 

1150 

1151 maintainer = models.ForeignKey( 

1152 ContributorName, 

1153 related_name='source_package', 

1154 null=True, 

1155 on_delete=models.CASCADE) 

1156 uploaders = models.ManyToManyField( 

1157 ContributorName, 

1158 related_name='source_packages_uploads_set' 

1159 ) 

1160 

1161 dsc_file_name = models.CharField(max_length=255, blank=True) 

1162 directory = models.CharField(max_length=255, blank=True) 

1163 homepage = models.URLField(max_length=255, blank=True) 

1164 vcs = models.JSONField(null=True) 

1165 

1166 class Meta: 

1167 unique_together = ('source_package_name', 'version') 

1168 

1169 def __str__(self): 

1170 return '{pkg}, version {ver}'.format( 

1171 pkg=self.source_package_name, ver=self.version) 

1172 

1173 @cached_property 

1174 def name(self): 

1175 """ 

1176 A convenience property returning the name of the package as a string. 

1177 

1178 :rtype: string 

1179 """ 

1180 return self.source_package_name.name 

1181 

1182 @cached_property 

1183 def main_entry(self): 

1184 """ 

1185 Returns the 

1186 :class:`SourcePackageRepositoryEntry 

1187 <distro_tracker.core.models.SourcePackageRepositoryEntry>` 

1188 found in the instance's :attr:`repository_entries` which should be 

1189 considered the main entry for this version. 

1190 

1191 If the version is found in the default repository, the entry for the 

1192 default repository is returned. 

1193 

1194 Otherwise, the entry for the repository with the highest 

1195 :attr:`position <distro_tracker.core.models.Repository.position>` 

1196 field is returned. 

1197 

1198 If the source package version is not found in any repository, 

1199 ``None`` is returned. 

1200 """ 

1201 default_repository_entry_qs = self.repository_entries.filter( 

1202 repository__default=True) 

1203 try: 

1204 return default_repository_entry_qs[0] 

1205 except IndexError: 

1206 pass 

1207 

1208 # Return the entry in the repository with the highest position number 

1209 try: 

1210 return self.repository_entries.order_by('-repository__position')[0] 

1211 except IndexError: 

1212 return None 

1213 

1214 def get_changelog_entry(self): 

1215 """ 

1216 Retrieve the changelog entry which corresponds to this package version. 

1217 

1218 If there is no changelog associated with the version returns ``None`` 

1219 

1220 :rtype: :class:`string` or ``None`` 

1221 """ 

1222 # If there is no changelog, return immediately 

1223 try: 

1224 extracted_changelog = \ 

1225 self.extracted_source_files.get(name='changelog') 

1226 except ExtractedSourceFile.DoesNotExist: 

1227 return 

1228 

1229 extracted_changelog.extracted_file.open() 

1230 # Let the File context manager close the file 

1231 with extracted_changelog.extracted_file as changelog_file: 

1232 changelog_content = changelog_file.read() 

1233 

1234 changelog = debian_changelog.Changelog(changelog_content.splitlines()) 

1235 # Return the entry corresponding to the package version, or ``None`` 

1236 return next(( 1236 ↛ exitline 1236 didn't finish the generator expression on line 1236

1237 force_str(entry).strip() 

1238 for entry in changelog 

1239 if entry.version == self.version), 

1240 None) 

1241 

1242 def update(self, **kwargs): 

1243 """ 

1244 The method updates all of the instance attributes based on the keyword 

1245 arguments. 

1246 

1247 >>> src_pkg = SourcePackage() 

1248 >>> src_pkg.update(version='1.0.0', homepage='http://example.com') 

1249 >>> str(src_pkg.version) 

1250 '1.0.0' 

1251 >>> str(src_pkg.homepage) 

1252 'http://example.com' 

1253 """ 

1254 for key, value in kwargs.items(): 

1255 if hasattr(self, key): 

1256 attr = getattr(self, key) 

1257 if hasattr(attr, 'set'): 

1258 attr.set(value) 

1259 else: 

1260 setattr(self, key, value) 

1261 

1262 

1263class BinaryPackage(models.Model): 

1264 """ 

1265 The method represents a particular binary package. 

1266 

1267 All information regarding a (binary-package-name, version) which is 

1268 independent from the repository in which the package is found. 

1269 """ 

1270 id = models.BigAutoField(primary_key=True) # noqa 

1271 binary_package_name = models.ForeignKey( 

1272 BinaryPackageName, 

1273 related_name='binary_package_versions', 

1274 on_delete=models.CASCADE 

1275 ) 

1276 version = models.CharField(max_length=200) 

1277 source_package = models.ForeignKey(SourcePackage, on_delete=models.CASCADE) 

1278 

1279 short_description = models.CharField(max_length=300, blank=True) 

1280 long_description = models.TextField(blank=True) 

1281 

1282 class Meta: 

1283 unique_together = ('binary_package_name', 'version') 

1284 

1285 def __str__(self): 

1286 return 'Binary package {pkg}, version {ver}'.format( 

1287 pkg=self.binary_package_name, ver=self.version) 

1288 

1289 def update(self, **kwargs): 

1290 """ 

1291 The method updates all of the instance attributes based on the keyword 

1292 arguments. 

1293 """ 

1294 for key, value in kwargs.items(): 

1295 if hasattr(self, key): 1295 ↛ 1294line 1295 didn't jump to line 1294, because the condition on line 1295 was never false

1296 setattr(self, key, value) 

1297 

1298 @cached_property 

1299 def name(self): 

1300 """Returns the name of the package""" 

1301 return self.binary_package_name.name 

1302 

1303 

1304class BinaryPackageRepositoryEntryManager(models.Manager): 

1305 def filter_by_package_name(self, names): 

1306 """ 

1307 :returns: A set of :class:`BinaryPackageRepositoryEntry` instances 

1308 which are associated to a binary package with one of the names 

1309 given in the ``names`` parameter. 

1310 :rtype: :class:`QuerySet <django.db.models.query.QuerySet>` 

1311 """ 

1312 return self.filter(binary_package__binary_package_name__name__in=names) 

1313 

1314 

1315class BinaryPackageRepositoryEntry(models.Model): 

1316 """ 

1317 A model representing repository specific information for a given binary 

1318 package. 

1319 

1320 It links a :class:`BinaryPackage` instance with the :class:`Repository` 

1321 instance. 

1322 """ 

1323 id = models.BigAutoField(primary_key=True) # noqa 

1324 binary_package = models.ForeignKey( 

1325 BinaryPackage, 

1326 related_name='repository_entries', 

1327 on_delete=models.CASCADE, 

1328 ) 

1329 repository = models.ForeignKey( 

1330 Repository, 

1331 related_name='binary_entries', 

1332 on_delete=models.CASCADE, 

1333 ) 

1334 architecture = models.ForeignKey(Architecture, on_delete=models.CASCADE) 

1335 

1336 priority = models.CharField(max_length=50, blank=True) 

1337 section = models.CharField(max_length=50, blank=True) 

1338 

1339 objects = BinaryPackageRepositoryEntryManager() 

1340 

1341 class Meta: 

1342 unique_together = ('binary_package', 'repository', 'architecture') 

1343 

1344 def __str__(self): 

1345 return '{pkg} ({arch}) in the repository {repo}'.format( 

1346 pkg=self.binary_package, arch=self.architecture, 

1347 repo=self.repository) 

1348 

1349 @property 

1350 def name(self): 

1351 """The name of the binary package""" 

1352 return self.binary_package.name 

1353 

1354 @cached_property 

1355 def version(self): 

1356 """The version of the binary package""" 

1357 return self.binary_package.version 

1358 

1359 

1360class SourcePackageRepositoryEntryManager(models.Manager): 

1361 def filter_by_package_name(self, names): 

1362 """ 

1363 :returns: A set of :class:`SourcePackageRepositoryEntry` instances 

1364 which are associated to a source package with one of the names 

1365 given in the ``names`` parameter. 

1366 :rtype: :class:`QuerySet <django.db.models.query.QuerySet>` 

1367 """ 

1368 return self.filter(source_package__source_package_name__name__in=names) 

1369 

1370 

1371class SourcePackageRepositoryEntry(models.Model): 

1372 """ 

1373 A model representing source package data that is repository specific. 

1374 

1375 It links a :class:`SourcePackage` instance with the :class:`Repository` 

1376 instance. 

1377 """ 

1378 id = models.BigAutoField(primary_key=True) # noqa 

1379 source_package = models.ForeignKey( 

1380 SourcePackage, 

1381 related_name='repository_entries', 

1382 on_delete=models.CASCADE, 

1383 ) 

1384 repository = models.ForeignKey(Repository, related_name='source_entries', 

1385 on_delete=models.CASCADE) 

1386 

1387 component = models.CharField(max_length=50, blank=True) 

1388 

1389 objects = SourcePackageRepositoryEntryManager() 

1390 

1391 class Meta: 

1392 unique_together = ('source_package', 'repository') 

1393 

1394 def __str__(self): 

1395 return "Source package {pkg} in the repository {repo}".format( 

1396 pkg=self.source_package, 

1397 repo=self.repository) 

1398 

1399 @property 

1400 def dsc_file_url(self): 

1401 """ 

1402 Returns the URL where the .dsc file of this entry can be found. 

1403 

1404 :rtype: string 

1405 """ 

1406 if self.source_package.directory and self.source_package.dsc_file_name: 

1407 base_url = self.repository.public_uri.rstrip('/') or \ 

1408 self.repository.uri.rstrip('/') 

1409 return '/'.join(( 

1410 base_url, 

1411 self.source_package.directory, 

1412 self.source_package.dsc_file_name, 

1413 )) 

1414 else: 

1415 return None 

1416 

1417 @property 

1418 def directory_url(self): 

1419 """ 

1420 Returns the URL of the package's directory. 

1421 

1422 :rtype: string 

1423 """ 

1424 if self.source_package.directory: 

1425 base_url = self.repository.public_uri.rstrip('/') or \ 

1426 self.repository.uri.rstrip('/') 

1427 return base_url + '/' + self.source_package.directory 

1428 else: 

1429 return None 

1430 

1431 @property 

1432 def name(self): 

1433 """The name of the source package""" 

1434 return self.source_package.name 

1435 

1436 @cached_property 

1437 def version(self): 

1438 """ 

1439 Returns the version of the associated source package. 

1440 """ 

1441 return self.source_package.version 

1442 

1443 

1444def _extracted_source_file_upload_path(instance, filename): 

1445 return '/'.join(( 

1446 'packages', 

1447 package_hashdir(instance.source_package.name), 

1448 instance.source_package.name, 

1449 os.path.basename(filename) + '-' + instance.source_package.version 

1450 )) 

1451 

1452 

1453class ExtractedSourceFile(models.Model): 

1454 """ 

1455 Model representing a single file extracted from a source package archive. 

1456 """ 

1457 id = models.BigAutoField(primary_key=True) # noqa 

1458 source_package = models.ForeignKey( 

1459 SourcePackage, 

1460 related_name='extracted_source_files', 

1461 on_delete=models.CASCADE) 

1462 extracted_file = models.FileField( 

1463 upload_to=_extracted_source_file_upload_path) 

1464 name = models.CharField(max_length=100) 

1465 date_extracted = models.DateTimeField(auto_now_add=True) 

1466 

1467 class Meta: 

1468 unique_together = ('source_package', 'name') 

1469 

1470 def __str__(self): 

1471 return 'Extracted file {extracted_file} of package {package}'.format( 

1472 extracted_file=self.extracted_file, package=self.source_package) 

1473 

1474 

1475class PackageData(models.Model): 

1476 """ 

1477 A model representing a quasi key-value store for package information 

1478 extracted from other models in order to speed up its rendering on 

1479 Web pages. 

1480 """ 

1481 id = models.BigAutoField(primary_key=True) # noqa 

1482 package = models.ForeignKey(PackageName, 

1483 on_delete=models.CASCADE, 

1484 related_name="data") 

1485 key = models.CharField(max_length=50) 

1486 value = models.JSONField(default=dict) 

1487 

1488 def __str__(self): 

1489 return '{key}: {value} for package {package}'.format( 

1490 key=self.key, value=self.value, package=self.package) 

1491 

1492 class Meta: 

1493 unique_together = ('key', 'package') 

1494 

1495 

1496class MailingListManager(models.Manager): 

1497 """ 

1498 A custom :class:`Manager <django.db.models.Manager>` for the 

1499 :class:`MailingList` class. 

1500 """ 

1501 def get_by_email(self, email): 

1502 """ 

1503 Returns a :class:`MailingList` instance which matches the given email. 

1504 This means that the email's domain matches exactly the MailingList's 

1505 domain field. 

1506 """ 

1507 if '@' not in email: 

1508 return None 

1509 domain = email.rsplit('@', 1)[1] 

1510 

1511 qs = self.filter(domain=domain) 

1512 if qs.exists(): 

1513 return qs[0] 

1514 else: 

1515 return None 

1516 

1517 

1518def validate_archive_url_template(value): 

1519 """ 

1520 Custom validator for :class:`MailingList`'s 

1521 :attr:`archive_url_template <MailingList.archive_url_template>` field. 

1522 

1523 :raises ValidationError: If there is no {user} parameter in the value. 

1524 """ 

1525 if '{user}' not in value: 

1526 raise ValidationError( 

1527 "The archive URL template must have a {user} parameter") 

1528 

1529 

1530class MailingList(models.Model): 

1531 """ 

1532 Describes a known mailing list. 

1533 

1534 This provides Distro Tracker users to define the known mailing lists 

1535 through the admin panel in order to support displaying their archives in the 

1536 package pages without modifying any code. 

1537 

1538 Instances should have the :attr:`archive_url_template` field set to the 

1539 template which archive URLs should follow where a mandatory parameter is 

1540 {user}. 

1541 """ 

1542 

1543 name = models.CharField(max_length=100) 

1544 domain = models.CharField(max_length=255, unique=True) 

1545 archive_url_template = models.CharField(max_length=255, validators=[ 

1546 validate_archive_url_template, 

1547 ]) 

1548 

1549 objects = MailingListManager() 

1550 

1551 def __str__(self): 

1552 return self.name 

1553 

1554 def archive_url(self, user): 

1555 """ 

1556 Returns the archive URL for the given user. 

1557 

1558 :param user: The user for whom the archive URL should be returned 

1559 :type user: string 

1560 

1561 :rtype: string 

1562 """ 

1563 return self.archive_url_template.format(user=user) 

1564 

1565 def archive_url_for_email(self, email): 

1566 """ 

1567 Returns the archive URL for the given email. 

1568 

1569 Similar to :meth:`archive_url`, but extracts the user name from the 

1570 email first. 

1571 

1572 :param email: The email of the user for whom the archive URL should be 

1573 returned 

1574 :type user: string 

1575 

1576 :rtype: string 

1577 """ 

1578 if '@' not in email: 

1579 return None 

1580 user, domain = email.rsplit('@', 1) 

1581 

1582 if domain != self.domain: 

1583 return None 

1584 

1585 return self.archive_url(user) 

1586 

1587 

1588class NewsManager(models.Manager): 

1589 """ 

1590 A custom :class:`Manager <django.db.models.Manager>` for the 

1591 :class:`News` model. 

1592 """ 

1593 def create(self, **kwargs): 

1594 """ 

1595 Overrides the default create method to allow for easier creation of 

1596 News with different content backing (DB or file). 

1597 

1598 If there is a ``content`` parameter in the kwargs, the news content is 

1599 saved to the database. 

1600 

1601 If there is a ``file_content`` parameter in the kwargs, the news content 

1602 is saved to a file. 

1603 

1604 If none of those parameters are given, the method works as expected. 

1605 """ 

1606 if 'content' in kwargs: 

1607 db_content = kwargs.pop('content') 

1608 kwargs['_db_content'] = db_content 

1609 if 'file_content' in kwargs: 

1610 file_content = kwargs.pop('file_content') 

1611 kwargs['news_file'] = ContentFile(file_content, name='news-file') 

1612 

1613 return super(NewsManager, self).create(**kwargs) 

1614 

1615 

1616def news_upload_path(instance, filename): 

1617 """Compute the path where to store a news.""" 

1618 return '/'.join(( 

1619 'news', 

1620 package_hashdir(instance.package.name), 

1621 instance.package.name, 

1622 filename 

1623 )) 

1624 

1625 

1626class News(models.Model): 

1627 """ 

1628 A model used to describe a news item regarding a package. 

1629 """ 

1630 id = models.BigAutoField(primary_key=True) # noqa 

1631 package = models.ForeignKey(PackageName, on_delete=models.CASCADE) 

1632 title = models.CharField(max_length=255) 

1633 content_type = models.CharField(max_length=100, default='text/plain') 

1634 _db_content = models.TextField(blank=True, null=True) 

1635 news_file = models.FileField(upload_to=news_upload_path, blank=True) 

1636 created_by = models.CharField(max_length=100, blank=True) 

1637 datetime_created = models.DateTimeField(auto_now_add=True) 

1638 signed_by = models.ManyToManyField( 

1639 ContributorName, 

1640 related_name='signed_news_set') 

1641 

1642 objects = NewsManager() 

1643 

1644 def __str__(self): 

1645 return self.title 

1646 

1647 @cached_property 

1648 def content(self): 

1649 """ 

1650 Returns either the content of the message saved in the database or 

1651 retrieves it from the news file found in the filesystem. 

1652 

1653 The property is cached so that a single instance of :class:`News` does 

1654 not have to read a file every time its content is accessed. 

1655 """ 

1656 if self._db_content: 

1657 return self._db_content 

1658 elif self.news_file: 

1659 self.news_file.open('rb') 

1660 content = self.news_file.read() 

1661 self.news_file.close() 

1662 return content 

1663 

1664 def save(self, *args, **kwargs): 

1665 super(News, self).save(*args, **kwargs) 

1666 

1667 signers = verify_signature(self.get_signed_content()) 

1668 if signers is None: 

1669 # No signature 

1670 return 

1671 

1672 signed_by = [] 

1673 for name, email in signers: 

1674 try: 

1675 signer_email, _ = UserEmail.objects.get_or_create( 

1676 email=email) 

1677 signer_name, _ = ContributorName.objects.get_or_create( 

1678 name=name, 

1679 contributor_email=signer_email) 

1680 signed_by.append(signer_name) 

1681 except ValidationError: 

1682 logger_input.warning('News "%s" has signature with ' 

1683 'invalid email (%s).', self.title, email) 

1684 

1685 self.signed_by.set(signed_by) 

1686 

1687 def get_signed_content(self): 

1688 return self.content 

1689 

1690 def get_absolute_url(self): 

1691 return reverse('dtracker-news-page', kwargs={ 

1692 'news_id': self.pk, 

1693 'slug': slugify(self.title) 

1694 }) 

1695 

1696 

1697class EmailNewsManager(NewsManager): 

1698 """ 

1699 A custom :class:`Manager <django.db.models.Manager>` for the 

1700 :class:`EmailNews` model. 

1701 """ 

1702 def create_email_news(self, message, package, **kwargs): 

1703 """ 

1704 The method creates a news item from the given email message. 

1705 

1706 If a title of the message is not given, it automatically generates it 

1707 based on the sender of the email. 

1708 

1709 :param message: The message based on which a news item should be 

1710 created. 

1711 :type message: :class:`Message <email.message.Message>` 

1712 :param package: The package to which the news item refers 

1713 :type: :class:`PackageName` 

1714 """ 

1715 create_kwargs = EmailNews.get_email_news_parameters(message) 

1716 # The parameters given to the method directly by the client have 

1717 # priority over what is extracted from the email message. 

1718 create_kwargs.update(kwargs) 

1719 

1720 return self.create(package=package, **create_kwargs) 

1721 

1722 def get_queryset(self): 

1723 return super(EmailNewsManager, self).get_queryset().filter( 

1724 content_type='message/rfc822') 

1725 

1726 

1727class EmailNews(News): 

1728 objects = EmailNewsManager() 

1729 

1730 class Meta: 

1731 proxy = True 

1732 

1733 def get_signed_content(self): 

1734 msg = message_from_bytes(self.content) 

1735 if msg.is_multipart(): 

1736 for part in typed_subpart_iterator(msg, 'text', 'plain'): 1736 ↛ exitline 1736 didn't return from function 'get_signed_content', because the loop on line 1736 didn't complete

1737 return get_decoded_message_payload(part) 

1738 else: 

1739 return get_decoded_message_payload(msg) 

1740 

1741 @staticmethod 

1742 def get_from_email(message): 

1743 """ 

1744 Analyzes the content of the message in order to get the name 

1745 of the person that triggered the news event. The function 

1746 returns the mail in "Changed-By" if possible. 

1747 """ 

1748 x_dak = decode_header(message.get('X-DAK', 'unknown')) 

1749 from_email = decode_header(message.get('From', 'unknown')) 

1750 if x_dak == 'dak process-upload': 

1751 search_result = re.search( 

1752 r'^Changed-By: (.*)$', 

1753 get_message_body(message), 

1754 re.MULTILINE | re.IGNORECASE, 

1755 ) 

1756 if search_result: 

1757 from_email = search_result.group(1) 

1758 

1759 return from_email 

1760 

1761 @staticmethod 

1762 def get_email_news_parameters(message): 

1763 """ 

1764 Returns a dict representing default values for some :class:`EmailNews` 

1765 fields based on the given email message. 

1766 """ 

1767 kwargs = {} 

1768 from_email = EmailNews.get_from_email(message) 

1769 

1770 kwargs['created_by'], _ = parseaddr(from_email) 

1771 if 'Subject' in message: 

1772 kwargs['title'] = decode_header(message['Subject']) 

1773 else: 

1774 kwargs['title'] = \ 

1775 'Email news from {sender}'.format(sender=from_email) 

1776 if hasattr(message, 'as_bytes'): 1776 ↛ 1779line 1776 didn't jump to line 1779, because the condition on line 1776 was never false

1777 kwargs['file_content'] = message.as_bytes() 

1778 else: 

1779 kwargs['file_content'] = message.as_string() 

1780 kwargs['content_type'] = 'message/rfc822' 

1781 

1782 return kwargs 

1783 

1784 

1785class NewsRenderer(metaclass=PluginRegistry): 

1786 """ 

1787 Base class which is used to register subclasses to render a :class:`News` 

1788 instance's contents into an HTML page. 

1789 

1790 Each :class:`News` instance has a :attr:`News.content_type` field which 

1791 is used to select the correct renderer for its type. 

1792 """ 

1793 #: Each :class:`NewsRenderer` subclass sets a content type that it can 

1794 #: render into HTML 

1795 content_type = None 

1796 #: A renderer can define a template name which will be included when its 

1797 #: output is required 

1798 template_name = None 

1799 

1800 #: The context is made available to the renderer's template, if available. 

1801 #: By default this is only the news instance which should be rendered. 

1802 @property 

1803 def context(self): 

1804 return { 

1805 'news': self.news 

1806 } 

1807 #: Pure HTML which is included when the renderer's output is required. 

1808 #: Must be marked safe with :func:`django.utils.safestring.mark_safe` 

1809 #: or else it will be HTML encoded! 

1810 html_output = None 

1811 

1812 def __init__(self, news): 

1813 """ 

1814 :type news: :class:`distro_tracker.core.models.News` 

1815 """ 

1816 self.news = news 

1817 

1818 @classmethod 

1819 def get_renderer_for_content_type(cls, content_type): 

1820 """ 

1821 Returns one of the :class:`NewsRenderer` subclasses which implements 

1822 rendering the given content type. If there is more than one such class, 

1823 it is undefined which one is returned from this method. If there is 

1824 not renderer for the given type, ``None`` is returned. 

1825 

1826 :param content_type: The Content-Type for which a renderer class should 

1827 be returned. 

1828 :type content_type: string 

1829 

1830 :rtype: :class:`NewsRenderer` subclass or ``None`` 

1831 """ 

1832 for news_renderer in cls.plugins: 1832 ↛ 1836line 1832 didn't jump to line 1836, because the loop on line 1832 didn't complete

1833 if news_renderer.content_type == content_type: 

1834 return news_renderer 

1835 

1836 return None 

1837 

1838 def render_to_string(self): 

1839 """ 

1840 :returns: A safe string representing the rendered HTML output. 

1841 """ 

1842 if self.template_name: 1842 ↛ 1846line 1842 didn't jump to line 1846, because the condition on line 1842 was never false

1843 return mark_safe(distro_tracker_render_to_string( 

1844 self.template_name, 

1845 {'ctx': self.context, })) 

1846 elif self.html_output: 

1847 return mark_safe(self.html_output) 

1848 else: 

1849 return '' 

1850 

1851 

1852class PlainTextNewsRenderer(NewsRenderer): 

1853 """ 

1854 Renders a text/plain content type by placing the text in a <pre> HTML block 

1855 """ 

1856 content_type = 'text/plain' 

1857 template_name = 'core/news-plain.html' 

1858 

1859 

1860class HtmlNewsRenderer(NewsRenderer): 

1861 """ 

1862 Renders a text/html content type by simply emitting it to the output. 

1863 

1864 When creating news with a text/html type, you must be careful to properly 

1865 santize any user-provided data or risk security vulnerabilities. 

1866 """ 

1867 content_type = 'text/html' 

1868 

1869 @property 

1870 def html_output(self): 

1871 return mark_safe(self.news.content) 

1872 

1873 

1874class EmailNewsRenderer(NewsRenderer): 

1875 """ 

1876 Renders news content as an email message. 

1877 """ 

1878 content_type = 'message/rfc822' 

1879 template_name = 'core/news-email.html' 

1880 

1881 @cached_property 

1882 def context(self): 

1883 msg = message_from_bytes(self.news.content) 

1884 # Extract headers first 

1885 DEFAULT_HEADERS = ( 

1886 'From', 

1887 'To', 

1888 'Subject', 

1889 ) 

1890 EMAIL_HEADERS = ( 

1891 'from', 

1892 'to', 

1893 'cc', 

1894 'bcc', 

1895 'resent-from', 

1896 'resent-to', 

1897 'resent-cc', 

1898 'resent-bcc', 

1899 ) 

1900 USER_DEFINED_HEADERS = getattr(settings, 

1901 'DISTRO_TRACKER_EMAIL_NEWS_HEADERS', ()) 

1902 ALL_HEADERS = [ 

1903 header.lower() 

1904 for header in DEFAULT_HEADERS + USER_DEFINED_HEADERS 

1905 ] 

1906 

1907 headers = {} 

1908 for header_name, header_value in msg.items(): 

1909 if header_name.lower() not in ALL_HEADERS: 

1910 continue 

1911 header_value = decode_header(header_value) 

1912 if header_name.lower() in EMAIL_HEADERS: 

1913 headers[header_name] = { 

1914 'emails': [ 

1915 { 

1916 'email': email, 

1917 'name': name, 

1918 } 

1919 for name, email in getaddresses([header_value]) 

1920 ] 

1921 } 

1922 if header_name.lower() == 'from': 

1923 from_name = headers[header_name]['emails'][0]['name'] 

1924 else: 

1925 headers[header_name] = {'value': header_value} 

1926 

1927 signers = list(self.news.signed_by.select_related()) 

1928 if signers and signers[0].name == from_name: 1928 ↛ 1929line 1928 didn't jump to line 1929, because the condition on line 1928 was never true

1929 signers = [] 

1930 

1931 plain_text_payloads = [] 

1932 for part in typed_subpart_iterator(msg, 'text', 'plain'): 

1933 message = linkify(escape(get_decoded_message_payload(part))) 

1934 plain_text_payloads.append(message) 

1935 

1936 return { 

1937 'headers': headers, 

1938 'parts': plain_text_payloads, 

1939 'signed_by': signers, 

1940 } 

1941 

1942 

1943class PackageBugStats(models.Model): 

1944 """ 

1945 Model for bug statistics of source and pseudo packages (packages modelled 

1946 by the :class:`PackageName` model). 

1947 """ 

1948 id = models.BigAutoField(primary_key=True) # noqa 

1949 package = models.OneToOneField(PackageName, related_name='bug_stats', 

1950 on_delete=models.CASCADE) 

1951 stats = models.JSONField(default=dict) 

1952 

1953 def __str__(self): 

1954 return '{package} bug stats: {stats}'.format( 

1955 package=self.package, stats=self.stats) 

1956 

1957 

1958class BinaryPackageBugStats(models.Model): 

1959 """ 

1960 Model for bug statistics of binary packages (:class:`BinaryPackageName`). 

1961 """ 

1962 id = models.BigAutoField(primary_key=True) # noqa 

1963 package = models.OneToOneField(BinaryPackageName, 

1964 related_name='binary_bug_stats', 

1965 on_delete=models.CASCADE) 

1966 stats = models.JSONField(default=dict) 

1967 

1968 def __str__(self): 

1969 return '{package} bug stats: {stats}'.format( 

1970 package=self.package, stats=self.stats) 

1971 

1972 

1973class ActionItemTypeManager(models.Manager): 

1974 """ 

1975 A custom :class:`Manager <django.db.models.Manager>` for the 

1976 :class:`ActionItemType` model. 

1977 """ 

1978 def create_or_update(self, type_name, full_description_template): 

1979 """ 

1980 Method either creates the template with the given name and description 

1981 template or makes sure to update an existing instance of that name 

1982 to have the given template. 

1983 

1984 :param type_name: The name of the :class:`ActionItemType` instance to 

1985 create. 

1986 :type type_name: string 

1987 :param full_description_template: The description template that the 

1988 returned :class:`ActionItemType` instance should have. 

1989 :type full_description_template: string 

1990 

1991 :returns: :class:`ActionItemType` instance 

1992 """ 

1993 item_type, created = self.get_or_create(type_name=type_name, defaults={ 

1994 'full_description_template': full_description_template 

1995 }) 

1996 if created: 

1997 return item_type 

1998 # If it wasn't just created check if the template needs to be updated 

1999 if item_type.full_description_template != full_description_template: 

2000 item_type.full_description_template = full_description_template 

2001 item_type.save() 

2002 

2003 return item_type 

2004 

2005 

2006class ActionItemType(models.Model): 

2007 type_name = models.TextField(max_length=100, unique=True) 

2008 full_description_template = models.CharField( 

2009 max_length=255, blank=True, null=True) 

2010 

2011 objects = ActionItemTypeManager() 

2012 

2013 def __str__(self): 

2014 return self.type_name 

2015 

2016 

2017class ActionItemManager(models.Manager): 

2018 """ 

2019 A custom :class:`Manager <django.db.models.Manager>` for the 

2020 :class:`ActionItem` model. 

2021 """ 

2022 def delete_obsolete_items(self, item_types, non_obsolete_packages): 

2023 """ 

2024 The method removes :class:`ActionItem` instances which have one of the 

2025 given types and are not associated to one of the non obsolete packages. 

2026 

2027 :param item_types: A list of action item types to be considered for 

2028 removal. 

2029 :type item_types: list of :class:`ActionItemType` instances 

2030 :param non_obsolete_packages: A list of package names whose items are 

2031 not to be removed. 

2032 :type non_obsolete_packages: list of strings 

2033 """ 

2034 if len(item_types) == 1: 

2035 qs = self.filter(item_type=item_types[0]) 

2036 else: 

2037 qs = self.filter(item_type__in=item_types) 

2038 qs = qs.exclude(package__name__in=non_obsolete_packages) 

2039 qs.delete() 

2040 

2041 def create_from(self, package, type_name, **data): 

2042 pkgname = PackageName.objects.get(name=package) 

2043 item_type = ActionItemType.objects.get(type_name=type_name) 

2044 return self.create(package=pkgname, item_type=item_type, **data) 

2045 

2046 

2047class ActionItem(models.Model): 

2048 """ 

2049 Model for entries of the "action needed" panel. 

2050 """ 

2051 #: All available severity levels 

2052 SEVERITY_WISHLIST = 0 

2053 SEVERITY_LOW = 1 

2054 SEVERITY_NORMAL = 2 

2055 SEVERITY_HIGH = 3 

2056 SEVERITY_CRITICAL = 4 

2057 SEVERITIES = ( 

2058 (SEVERITY_WISHLIST, 'wishlist'), 

2059 (SEVERITY_LOW, 'low'), 

2060 (SEVERITY_NORMAL, 'normal'), 

2061 (SEVERITY_HIGH, 'high'), 

2062 (SEVERITY_CRITICAL, 'critical'), 

2063 ) 

2064 id = models.BigAutoField(primary_key=True) # noqa 

2065 package = models.ForeignKey(PackageName, related_name='action_items', 

2066 on_delete=models.CASCADE) 

2067 item_type = models.ForeignKey(ActionItemType, related_name='action_items', 

2068 on_delete=models.CASCADE) 

2069 short_description = models.TextField() 

2070 severity = models.IntegerField(choices=SEVERITIES, default=SEVERITY_NORMAL) 

2071 created_timestamp = models.DateTimeField(auto_now_add=True) 

2072 last_updated_timestamp = models.DateTimeField(auto_now=True) 

2073 extra_data = models.JSONField(default=dict) 

2074 

2075 objects = ActionItemManager() 

2076 

2077 class Meta: 

2078 unique_together = ('package', 'item_type') 

2079 

2080 def __str__(self): 

2081 return '{package} - {desc} ({severity})'.format( 

2082 package=self.package, 

2083 desc=self.short_description, 

2084 severity=self.get_severity_display()) 

2085 

2086 def get_absolute_url(self): 

2087 return reverse('dtracker-action-item', kwargs={ 

2088 'item_pk': self.pk, 

2089 }) 

2090 

2091 @property 

2092 def type_name(self): 

2093 return self.item_type.type_name 

2094 

2095 @property 

2096 def full_description_template(self): 

2097 return self.item_type.full_description_template 

2098 

2099 @cached_property 

2100 def full_description(self): 

2101 if not self.full_description_template: 

2102 return '' 

2103 try: 

2104 return mark_safe( 

2105 distro_tracker_render_to_string( 

2106 self.full_description_template, 

2107 {'item': self, })) 

2108 except TemplateDoesNotExist: 

2109 return '' 

2110 

2111 def to_dict(self): 

2112 return { 

2113 'short_description': self.short_description, 

2114 'package': { 

2115 'name': self.package.name, 

2116 'id': self.package.id, 

2117 }, 

2118 'type_name': self.item_type.type_name, 

2119 'full_description': self.full_description, 

2120 'severity': { 

2121 'name': self.get_severity_display(), 

2122 'level': self.severity, 

2123 'label_type': { 

2124 0: 'info', 

2125 3: 'warning', 

2126 4: 'danger', 

2127 }.get(self.severity, 'default') 

2128 }, 

2129 'created': self.created_timestamp.strftime('%Y-%m-%d'), 

2130 'updated': self.last_updated_timestamp.strftime('%Y-%m-%d'), 

2131 } 

2132 

2133 

2134class ConfirmationException(Exception): 

2135 """ 

2136 An exception which is raised when the :py:class:`ConfirmationManager` 

2137 is unable to generate a unique key for a given identifier. 

2138 """ 

2139 pass 

2140 

2141 

2142class ConfirmationManager(models.Manager): 

2143 """ 

2144 A custom manager for the :py:class:`Confirmation` model. 

2145 """ 

2146 def generate_key(self, identifier): 

2147 """ 

2148 Generates a random key for the given identifier. 

2149 

2150 :param identifier: A string representation of an identifier for the 

2151 confirmation instance. 

2152 """ 

2153 chars = string.ascii_letters + string.digits 

2154 random_string = ''.join(random.choice(chars) for _ in range(16)) 

2155 random_string = random_string.encode('ascii') 

2156 salt = hashlib.sha1(random_string).hexdigest() 

2157 hash_input = (salt + identifier).encode('ascii') 

2158 return hashlib.sha1(hash_input).hexdigest() 

2159 

2160 def create_confirmation(self, identifier='', **kwargs): 

2161 """ 

2162 Creates a :py:class:`Confirmation` object with the given identifier and 

2163 all the given keyword arguments passed. 

2164 

2165 :param identifier: A string representation of an identifier for the 

2166 confirmation instance. 

2167 :raises distro_tracker.mail.models.ConfirmationException: If it is 

2168 unable to generate a unique key. 

2169 """ 

2170 MAX_TRIES = 10 

2171 errors = 0 

2172 while errors < MAX_TRIES: 2172 ↛ 2179line 2172 didn't jump to line 2179, because the condition on line 2172 was never false

2173 confirmation_key = self.generate_key(identifier) 

2174 try: 

2175 return self.create(confirmation_key=confirmation_key, **kwargs) 

2176 except IntegrityError: 

2177 errors += 1 

2178 

2179 raise ConfirmationException( 

2180 'Unable to generate a confirmation key for {identifier}'.format( 

2181 identifier=identifier)) 

2182 

2183 def clean_up_expired(self): 

2184 """ 

2185 Removes all expired confirmation keys. 

2186 """ 

2187 for confirmation in self.all(): 

2188 if confirmation.is_expired(): 

2189 confirmation.delete() 

2190 

2191 def get(self, *args, **kwargs): 

2192 """ 

2193 Overrides the default :py:class:`django.db.models.Manager` method so 

2194 that expired :py:class:`Confirmation` instances are never 

2195 returned. 

2196 

2197 :rtype: :py:class:`Confirmation` or ``None`` 

2198 """ 

2199 instance = super(ConfirmationManager, self).get(*args, **kwargs) 

2200 return instance if not instance.is_expired() else None 

2201 

2202 

2203class Confirmation(models.Model): 

2204 """ 

2205 An abstract model allowing its subclasses to store and create confirmation 

2206 keys. 

2207 """ 

2208 confirmation_key = models.CharField(max_length=40, unique=True) 

2209 date_created = models.DateTimeField(auto_now_add=True) 

2210 

2211 objects = ConfirmationManager() 

2212 

2213 class Meta: 

2214 abstract = True 

2215 

2216 def __str__(self): 

2217 return self.confirmation_key 

2218 

2219 def is_expired(self): 

2220 """ 

2221 :returns True: if the confirmation key has expired 

2222 :returns False: if the confirmation key is still valid 

2223 """ 

2224 delta = timezone.now() - self.date_created 

2225 return delta.days >= DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS 

2226 

2227 

2228class SourcePackageDeps(models.Model): 

2229 id = models.BigAutoField(primary_key=True) # noqa 

2230 source = models.ForeignKey(SourcePackageName, 

2231 related_name='source_dependencies', 

2232 on_delete=models.CASCADE) 

2233 dependency = models.ForeignKey(SourcePackageName, 

2234 related_name='source_dependents', 

2235 on_delete=models.CASCADE) 

2236 repository = models.ForeignKey(Repository, on_delete=models.CASCADE) 

2237 build_dep = models.BooleanField(default=False) 

2238 binary_dep = models.BooleanField(default=False) 

2239 details = models.JSONField(default=dict) 

2240 

2241 class Meta: 

2242 unique_together = ('source', 'dependency', 'repository') 

2243 

2244 def __str__(self): 

2245 return '{} depends on {}'.format(self.source, self.dependency) 

2246 

2247 

2248class TeamManager(models.Manager): 

2249 """ 

2250 A custom :class:`Manager <django.db.models.Manager>` for the 

2251 :class:`Team` model. 

2252 """ 

2253 def create_with_slug(self, **kwargs): 

2254 """ 

2255 A variant of the create method which automatically populates the 

2256 instance's slug field by slugifying the name. 

2257 """ 

2258 if 'slug' not in kwargs: 2258 ↛ 2260line 2258 didn't jump to line 2260, because the condition on line 2258 was never false

2259 kwargs['slug'] = slugify(kwargs['name']) 

2260 if 'maintainer_email' in kwargs: 

2261 if not isinstance(kwargs['maintainer_email'], UserEmail): 2261 ↛ 2265line 2261 didn't jump to line 2265, because the condition on line 2261 was never false

2262 kwargs['maintainer_email'] = UserEmail.objects.get_or_create( 

2263 email=kwargs['maintainer_email'])[0] 

2264 

2265 return self.create(**kwargs) 

2266 

2267 

2268class Team(models.Model): 

2269 name = models.CharField(max_length=100, unique=True) 

2270 slug = models.SlugField( 

2271 unique=True, 

2272 verbose_name='Identifier', 

2273 help_text='Used in the URL (/teams/<em>identifier</em>/) and in the ' 

2274 'associated email address ' 

2275 'team+<em>identifier</em>@<em>domain</em>.', 

2276 ) 

2277 maintainer_email = models.ForeignKey( 

2278 UserEmail, 

2279 null=True, 

2280 blank=True, 

2281 on_delete=models.SET_NULL) 

2282 description = models.TextField(blank=True, null=True) 

2283 url = models.URLField(max_length=255, blank=True, null=True) 

2284 public = models.BooleanField(default=True) 

2285 

2286 owner = models.ForeignKey( 

2287 'accounts.User', 

2288 null=True, 

2289 on_delete=models.SET_NULL, 

2290 related_name='owned_teams') 

2291 

2292 packages = models.ManyToManyField( 

2293 PackageName, 

2294 related_name='teams') 

2295 members = models.ManyToManyField( 

2296 UserEmail, 

2297 related_name='teams', 

2298 through='TeamMembership') 

2299 

2300 objects = TeamManager() 

2301 

2302 def __str__(self): 

2303 return self.name 

2304 

2305 def get_absolute_url(self): 

2306 return reverse('dtracker-team-page', kwargs={ 

2307 'slug': self.slug, 

2308 }) 

2309 

2310 def add_members(self, users, muted=False): 

2311 """ 

2312 Adds the given users to the team. 

2313 

2314 It automatically creates the intermediary :class:`TeamMembership` 

2315 models. 

2316 

2317 :param users: The users to be added to the team. 

2318 :type users: an ``iterable`` of :class:`UserEmail` instances 

2319 

2320 :param muted: If set to True, the membership will be muted before the 

2321 user excplicitely unmutes it. 

2322 :type active: bool 

2323 

2324 :returns: :class:`TeamMembership` instances for each user added to 

2325 the team 

2326 :rtype: list 

2327 """ 

2328 users = [ 

2329 user 

2330 if isinstance(user, UserEmail) else 

2331 UserEmail.objects.get_or_create(email=user)[0] 

2332 for user in users 

2333 ] 

2334 return [ 

2335 self.team_membership_set.create(user_email=user, muted=muted) 

2336 for user in users 

2337 ] 

2338 

2339 def remove_members(self, users): 

2340 """ 

2341 Removes the given users from the team. 

2342 

2343 :param users: The users to be removed from the team. 

2344 :type users: an ``iterable`` of :class:`UserEmail` instances 

2345 """ 

2346 self.team_membership_set.filter(user_email__in=users).delete() 

2347 

2348 def user_is_member(self, user): 

2349 """ 

2350 Checks whether the given user is a member of the team. 

2351 :param user: The user which should be checked for membership 

2352 :type user: :class:`distro_tracker.accounts.models.User` 

2353 """ 

2354 return ( 

2355 user == self.owner or 

2356 self.members.filter(pk__in=user.emails.all()).exists() 

2357 ) 

2358 

2359 

2360class TeamMembership(models.Model): 

2361 """ 

2362 Represents the intermediary model for the many-to-many association of 

2363 team members to a :class:`Team`. 

2364 """ 

2365 user_email = models.ForeignKey(UserEmail, related_name='membership_set', 

2366 on_delete=models.CASCADE) 

2367 team = models.ForeignKey(Team, related_name='team_membership_set', 

2368 on_delete=models.CASCADE) 

2369 

2370 muted = models.BooleanField(default=False) 

2371 default_keywords = models.ManyToManyField(Keyword) 

2372 has_membership_keywords = models.BooleanField(default=False) 

2373 

2374 class Meta: 

2375 unique_together = ('user_email', 'team') 

2376 

2377 def __str__(self): 

2378 return '{} member of {}'.format(self.user_email, self.team) 

2379 

2380 def is_muted(self, package_name): 

2381 """ 

2382 Checks if the given package is muted in the team membership. 

2383 A package is muted if the team membership itself is muted as a whole or 

2384 if :class:`MembershipPackageSpecifics` for the package indicates that 

2385 the package is muted. 

2386 

2387 :param package_name: The name of the package. 

2388 :type package_name: :class:`PackageName` or :class:`str` 

2389 """ 

2390 if not isinstance(package_name, PackageName): 

2391 package_name = get_or_none(PackageName, name=package_name) 

2392 if self.muted: 

2393 return True 

2394 try: 

2395 package_specifics = self.membership_package_specifics.get( 

2396 package_name=package_name) 

2397 except MembershipPackageSpecifics.DoesNotExist: 

2398 return False 

2399 

2400 return package_specifics.muted 

2401 

2402 def set_mute_package(self, package_name, mute): 

2403 """ 

2404 Sets whether the given package should be considered muted for the team 

2405 membership. 

2406 """ 

2407 if not isinstance(package_name, PackageName): 2407 ↛ 2408line 2407 didn't jump to line 2408, because the condition on line 2407 was never true

2408 package_name = PackageName.objects.get(package_name) 

2409 package_specifics, _ = self.membership_package_specifics.get_or_create( 

2410 package_name=package_name) 

2411 package_specifics.muted = mute 

2412 package_specifics.save() 

2413 

2414 def mute_package(self, package_name): 

2415 """ 

2416 The method mutes only the given package in the user's team membership. 

2417 

2418 :param package_name: The name of the package. 

2419 :type package_name: :class:`PackageName` or :class:`str` 

2420 """ 

2421 self.set_mute_package(package_name, True) 

2422 

2423 def unmute_package(self, package_name): 

2424 """ 

2425 The method unmutes only the given package in the user's team membership. 

2426 

2427 :param package_name: The name of the package. 

2428 :type package_name: :class:`PackageName` or :class:`str` 

2429 """ 

2430 self.set_mute_package(package_name, False) 

2431 

2432 def set_keywords(self, package_name, keywords): 

2433 """ 

2434 Sets the membership-specific keywords for the given package. 

2435 

2436 :param package_name: The name of the package for which the keywords 

2437 should be set 

2438 :type package_name: :class:`PackageName` or :class:`str` 

2439 :param keywords: The keywords to be set for the membership-specific 

2440 keywords for the given package. 

2441 :type keywords: an ``iterable`` of keyword names - as strings 

2442 """ 

2443 if not isinstance(package_name, PackageName): 2443 ↛ 2444line 2443 didn't jump to line 2444, because the condition on line 2443 was never true

2444 package_name = PackageName.objects.get(package_name) 

2445 new_keywords = Keyword.objects.filter(name__in=keywords) 

2446 membership_package_specifics, _ = ( 

2447 self.membership_package_specifics.get_or_create( 

2448 package_name=package_name)) 

2449 membership_package_specifics.set_keywords(new_keywords) 

2450 

2451 def set_membership_keywords(self, keywords): 

2452 """ 

2453 Sets the membership default keywords. 

2454 

2455 :param keywords: The keywords to be set for the membership 

2456 :type keywords: an ``iterable`` of keyword names - as strings 

2457 """ 

2458 new_keywords = Keyword.objects.filter(name__in=keywords) 

2459 self.default_keywords.set(new_keywords) 

2460 self.has_membership_keywords = True 

2461 self.save() 

2462 

2463 def get_membership_keywords(self): 

2464 if self.has_membership_keywords: 

2465 return self.default_keywords.order_by('name') 

2466 else: 

2467 return self.user_email.emailsettings.default_keywords.order_by( 

2468 'name') 

2469 

2470 def get_keywords(self, package_name): 

2471 """ 

2472 Returns the keywords that are associated to a particular package of 

2473 this team membership. 

2474 

2475 The first set of keywords that exists in the order given below is 

2476 returned: 

2477 

2478 - Membership package-specific keywords 

2479 - Membership default keywords 

2480 - UserEmail default keywords 

2481 

2482 :param package_name: The name of the package for which the keywords 

2483 should be returned 

2484 :type package_name: :class:`PackageName` or :class:`str` 

2485 

2486 :return: The keywords which should be used when forwarding mail 

2487 regarding the given package to the given user for the team 

2488 membership. 

2489 :rtype: :class:`QuerySet <django.db.models.query.QuerySet>` of 

2490 :class:`Keyword` instances. 

2491 """ 

2492 if not isinstance(package_name, PackageName): 

2493 package_name = get_or_none(PackageName, name=package_name) 

2494 

2495 try: 

2496 membership_package_specifics = \ 

2497 self.membership_package_specifics.get( 

2498 package_name=package_name) 

2499 if membership_package_specifics._has_keywords: 

2500 return membership_package_specifics.keywords.all() 

2501 except MembershipPackageSpecifics.DoesNotExist: 

2502 pass 

2503 

2504 if self.has_membership_keywords: 

2505 return self.default_keywords.all() 

2506 

2507 email_settings, _ = \ 

2508 EmailSettings.objects.get_or_create(user_email=self.user_email) 

2509 return email_settings.default_keywords.all() 

2510 

2511 

2512class MembershipPackageSpecifics(models.Model): 

2513 """ 

2514 Represents a model for keeping information regarding a pair of 

2515 (membership, package) instances. 

2516 """ 

2517 membership = models.ForeignKey( 

2518 TeamMembership, 

2519 related_name='membership_package_specifics', 

2520 on_delete=models.CASCADE) 

2521 package_name = models.ForeignKey(PackageName, on_delete=models.CASCADE) 

2522 

2523 keywords = models.ManyToManyField(Keyword) 

2524 _has_keywords = models.BooleanField(default=False) 

2525 

2526 muted = models.BooleanField(default=False) 

2527 

2528 class Meta: 

2529 unique_together = ('membership', 'package_name') 

2530 

2531 def __str__(self): 

2532 return "Membership ({}) specific keywords for {} package".format( 

2533 self.membership, self.package_name) 

2534 

2535 def set_keywords(self, keywords): 

2536 self.keywords.set(keywords) 

2537 self._has_keywords = True 

2538 self.save() 

2539 

2540 

2541class MembershipConfirmation(Confirmation): 

2542 membership = models.ForeignKey(TeamMembership, on_delete=models.CASCADE) 

2543 

2544 def __str__(self): 

2545 return "Confirmation for {}".format(self.membership) 

2546 

2547 

2548class BugDisplayManager(object): 

2549 """ 

2550 A class that aims at implementing the logic to handle the multiple ways 

2551 of displaying bugs data. More specifically, it defines the logic for: 

2552 * rendering :class:`BugsPanel <distro_tracker.core.panels.BugsPanel>` 

2553 * rendering :class:`BugStatsTableField 

2554 <distro_tracker.core.package_tables.BugStatsTableField>` 

2555 """ 

2556 table_field_template_name = 'core/package-table-fields/bugs.html' 

2557 panel_template_name = 'core/panels/bugs.html' 

2558 

2559 def table_field_context(self, package): 

2560 """ 

2561 This function is used by the 

2562 :class:`BugStatsTableField 

2563 <distro_tracker.core.package_tables.BugStatsTableField>` 

2564 to display the bug information for packages in tables. 

2565 

2566 It should return a dict with the following keys: 

2567 

2568 * ``bugs`` - a list of dicts where each element describes a single 

2569 bug category for the given package. Each dict has to provide at 

2570 minimum the following keys: 

2571 

2572 * ``category_name`` - the name of the bug category 

2573 * ``bug_count`` - the number of known bugs for the given package and 

2574 category 

2575 

2576 * ``all`` - the total number of bugs for that package 

2577 """ 

2578 stats = {} 

2579 try: 

2580 stats['bugs'] = package.bug_stats.stats 

2581 except ObjectDoesNotExist: 

2582 stats['bugs'] = [] 

2583 

2584 # Also adds a total of all those bugs 

2585 total = sum(category['bug_count'] for category in stats['bugs']) 

2586 stats['all'] = total 

2587 

2588 return stats 

2589 

2590 def panel_context(self, package): 

2591 """ 

2592 This function is used by the 

2593 :class:`BugsPanel <distro_tracker.core.panels.BugsPanel>` 

2594 to display the bug information for a given package. 

2595 

2596 It should return a list of dicts where each element describes a 

2597 single bug category for the given package. Each dict has to provide at 

2598 minimum the following keys: 

2599 

2600 * ``category_name``: the name of the bug category 

2601 * ``bug_count``: the number of known bugs for the given package and 

2602 category 

2603 """ 

2604 try: 

2605 stats = package.bug_stats.stats 

2606 except ObjectDoesNotExist: 

2607 return 

2608 

2609 # Also adds a total of all those bugs 

2610 total = sum(category['bug_count'] for category in stats) 

2611 stats.insert(0, { 

2612 'category_name': 'all', 

2613 'bug_count': total, 

2614 }) 

2615 return stats 

2616 

2617 def get_bug_tracker_url(self, package_name, package_type, category_name): 

2618 pass 

2619 

2620 def get_binary_bug_stats(self, binary_name): 

2621 """ 

2622 This function is used by the 

2623 :class:`BinariesInformationPanel 

2624 <distro_tracker.core.panels.BinariesInformationPanel>` 

2625 to display the bug information next to the binary name. 

2626 

2627 It should return a list of dicts where each element describes a single 

2628 bug category for the given package. 

2629 

2630 Each dict has to provide at minimum the following keys: 

2631 

2632 - ``category_name`` - the name of the bug category 

2633 - ``bug_count`` - the number of known bugs for the given package and 

2634 category 

2635 

2636 Optionally, the following keys can be provided: 

2637 

2638 - ``display_name`` - a name for the bug category. It is used by the 

2639 :class:`BinariesInformationPanel 

2640 <distro_tracker.core.panels.BinariesInformationPanel>` 

2641 to display a tooltip when mousing over the bug count number. 

2642 """ 

2643 stats = get_or_none( 

2644 BinaryPackageBugStats, package__name=binary_name) 

2645 if stats is not None: 2645 ↛ 2648line 2645 didn't jump to line 2648, because the condition on line 2645 was never false

2646 return stats.stats 

2647 

2648 return 

2649 

2650 

2651class BugDisplayManagerMixin(object): 

2652 """ 

2653 Mixin to another class to provide access to an object of class 

2654 :class:`BugDisplayManager <distro_tracker.core.models.BugDisplayManager>`. 

2655 """ 

2656 @property 

2657 def bug_manager(self): 

2658 """ 

2659 This function returns the appropriate class for managing 

2660 the presentation of bugs data. 

2661 """ 

2662 if not hasattr(self, '_bug_class'): 2662 ↛ 2669line 2662 didn't jump to line 2669, because the condition on line 2662 was never false

2663 bug_manager_class, implemented = vendor.call( 

2664 'get_bug_display_manager_class') 

2665 if implemented: 

2666 self._bug_manager = bug_manager_class() 

2667 else: 

2668 self._bug_manager = BugDisplayManager() 

2669 return self._bug_manager 

2670 

2671 

2672class TaskData(models.Model): 

2673 """ 

2674 Stores runtime data about tasks to help schedule them and store 

2675 list of things to process once they have been identified. 

2676 """ 

2677 task_name = models.CharField(max_length=250, unique=True, 

2678 blank=False, null=False) 

2679 task_is_pending = models.BooleanField(default=False) 

2680 run_lock = models.DateTimeField(default=None, null=True) 

2681 last_attempted_run = models.DateTimeField(default=None, null=True) 

2682 last_completed_run = models.DateTimeField(default=None, null=True) 

2683 data = models.JSONField(default=dict) 

2684 data_checksum = models.CharField(max_length=40, default=None, null=True) 

2685 version = models.IntegerField(default=0, null=False) 

2686 

2687 def save(self, update_checksum=False, *args, **kwargs): 

2688 """ 

2689 Like the usual 'save' method except that you can pass a supplementary 

2690 keyword parameter to update the 'data_checksum' field. 

2691 

2692 :param bool update_checksum: Computes the checksum of the 'data' field 

2693 and stores it in the 'data_checksum' field. 

2694 """ 

2695 if update_checksum: 

2696 self.data_checksum = get_data_checksum(self.data) 

2697 return super(TaskData, self).save(*args, **kwargs) 

2698 

2699 def versioned_update(self, **kwargs): 

2700 """ 

2701 Update the fields as requested through the keyword parameters 

2702 but do it in a way that avoids corruption through concurrent writes. 

2703 We rely on the 'version' field to update the data only if the version 

2704 in the database matches the version we loaded. 

2705 

2706 :return: True if the update worked, False otherwise 

2707 :rtype: bool 

2708 """ 

2709 kwargs['version'] = self.version + 1 

2710 if 'data' in kwargs and 'data_checksum' not in kwargs: 

2711 kwargs['data_checksum'] = get_data_checksum(kwargs['data']) 

2712 updated = TaskData.objects.filter( 

2713 pk=self.pk, version=self.version).update(**kwargs) 

2714 if updated: 

2715 self.refresh_from_db(fields=kwargs.keys()) 

2716 return True if updated else False 

2717 

2718 def get_run_lock(self, timeout=1800): 

2719 """ 

2720 Try to grab the run lock associated to the task. Once acquired, the 

2721 lock will be valid for the number of seconds specified in the 'timeout' 

2722 parameter. 

2723 

2724 :param int timeout: the number of seconds of validity of the lock 

2725 :return: True if the lock has been acquired, False otherwise. 

2726 :rtype: bool 

2727 """ 

2728 timestamp = now() 

2729 locked_until = timestamp + timedelta(seconds=timeout) 

2730 # By matching on run_lock=NULL we ensure that we have the right 

2731 # to take the lock. If the lock is already taken, the update query 

2732 # will not match any line. 

2733 updated = TaskData.objects.filter( 

2734 Q(run_lock=None) | Q(run_lock__lt=timestamp), 

2735 id=self.id 

2736 ).update(run_lock=locked_until) 

2737 self.refresh_from_db(fields=['run_lock']) 

2738 return True if updated else False 

2739 

2740 def extend_run_lock(self, delay=1800): 

2741 """ 

2742 Extend the duration of the lock for the given delay. Calling this 

2743 method when the lock is not yet acquired will raise an exception. 

2744 

2745 Note that you should always run this outside of any transaction so 

2746 that the new expiration time is immediately visible, otherwise 

2747 it might only be committed much later when the transaction ends. 

2748 The 

2749 

2750 :param int delay: the number of seconds to add to lock expiration date 

2751 """ 

2752 if connection.in_atomic_block: 2752 ↛ 2756line 2752 didn't jump to line 2756, because the condition on line 2752 was never false

2753 m = 'extend_run_lock() should be called outside of any transaction' 

2754 warnings.warn(RuntimeWarning(m)) 

2755 

2756 self.run_lock += timedelta(seconds=delay) 

2757 self.save(update_fields=['run_lock'])