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
22from debian import changelog as debian_changelog
23from debian.debian_support import AptPkgVersion
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
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
63from django_email_accounts.models import UserEmail
65DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS = \
66 settings.DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS
68logger_input = logging.getLogger('distro_tracker.input')
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)
79 def __str__(self):
80 return self.name
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)
90 def __str__(self):
91 return self.email
93 @cached_property
94 def email(self):
95 return self.user_email.email
97 @cached_property
98 def user(self):
99 return self.user_email.user
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))
111 def is_subscribed_to(self, package):
112 """
113 Checks if the user is subscribed to the given package.
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
123 return package in (
124 subscription.package
125 for subscription in self.subscription_set.all_active()
126 )
128 def unsubscribe_all(self):
129 """
130 Terminates all of the user's subscriptions.
131 """
132 self.subscription_set.all().delete()
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
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})
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
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`.
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 })
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()
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.
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
202 return super(PackageManager, self).create(*args, **kwargs)
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.
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
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.
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)
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)
247class PackageName(models.Model):
248 """
249 A model describing package names.
251 Three different types of packages are supported:
253 - Source packages
254 - Binary packages
255 - Pseudo packages
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)
265 subscriptions = models.ManyToManyField(EmailSettings,
266 through='Subscription')
268 objects = PackageManager()
269 source_packages = PackageManager('source')
270 binary_packages = PackageManager('binary')
271 pseudo_packages = PackageManager('pseudo')
272 default_manager = models.Manager()
274 def __str__(self):
275 return self.name
277 def get_absolute_url(self):
278 return reverse('dtracker-package-page', kwargs={
279 'package_name': self.name,
280 })
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'
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`
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)
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.
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)
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)
336class PseudoPackageName(PackageName):
337 """
338 A convenience proxy model of the :class:`PackageName` model.
340 It returns only those :class:`PackageName` instances whose
341 :attr:`pseudo <PackageName.pseudo>` attribute is True.
342 """
343 class Meta:
344 proxy = True
346 objects = PackageManager('pseudo')
349class BinaryPackageName(PackageName):
350 """
351 A convenience proxy model of the :class:`PackageName` model.
353 It returns only those :class:`PackageName` instances whose
354 :attr:`binary <PackageName.binary>` attribute is True.
355 """
356 class Meta:
357 proxy = True
359 objects = PackageManager('binary')
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
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.
375 The "main source package" is defined as follows:
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.
383 :rtype: string
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()
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
402class SourcePackageName(PackageName):
403 """
404 A convenience proxy model of the :class:`PackageName` model.
406 It returns only those :class:`PackageName` instances whose
407 :attr:`source <PackageName.source>` attribute is True.
408 """
409 class Meta:
410 proxy = True
412 objects = PackageManager('source')
414 @cached_property
415 def main_version(self):
416 """
417 Returns the main version of this :class:`SourcePackageName` instance.
418 :rtype: string
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()
431 qs.select_related()
432 try:
433 return max(qs, key=lambda x: AptPkgVersion(x.version))
434 except ValueError:
435 return None
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)
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
464 @cached_property
465 def repositories(self):
466 """
467 Returns all repositories which contain a source package with this name.
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()
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 ''
489 binary_packages = self.main_version.binarypackage_set.all()
491 for pkg in binary_packages:
492 if pkg.binary_package_name.name == self.name:
493 return pkg.short_description
495 if len(binary_packages) == 1:
496 return binary_packages[0].short_description
498 return ''
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``).
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!).
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.
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.
520 :rtype: :class:`PackageName` or ``None``
522 :param package_name: The name for which a package should be found.
523 :type package_name: string
524 """
526 search_among = (
527 SourcePackageName,
528 PseudoPackageName,
529 BinaryPackageName,
530 PackageName
531 )
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,)
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
554 return None
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.
566 :param package_name: The name of the subscription package
567 :type package_name: string
569 :param email: The email address of the user subscribing to the package
570 :type email: string
572 :param active: Indicates whether the subscription should be activated
573 as soon as it is created.
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)
588 subscription, _ = self.get_or_create(email_settings=email_settings,
589 package=package)
590 subscription.active = active
591 subscription.save()
593 return subscription
595 def unsubscribe(self, package_name, email):
596 """
597 Unsubscribes the given email from the given package.
599 :param email: The email of the user
600 :param package_name: The name of the package the user should be
601 unsubscribed from
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
618 def get_for_email(self, email):
619 """
620 Returns a list of active subscriptions for the given user.
622 :param email: The email address of the user
623 :type email: string
625 :rtype: ``iterable`` of :class:`Subscription` instances
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()
639 def all_active(self, keyword=None):
640 """
641 Returns all active subscriptions, optionally filtered on having the
642 given keyword.
644 :rtype: ``iterable`` of :class:`Subscription` instances
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
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)
676 objects = SubscriptionManager()
678 class Meta:
679 unique_together = ('email_settings', 'package')
681 class KeywordsAdapter(object):
682 """
683 An adapter for accessing a :class:`Subscription`'s keywords.
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.
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
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)
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
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()
731 def __init__(self, *args, **kwargs):
732 super(Subscription, self).__init__(*args, **kwargs)
733 self.keywords = Subscription.KeywordsAdapter(self)
735 def __str__(self):
736 return str(self.email_settings.user_email) + ' ' + str(self.package)
739class Architecture(models.Model):
740 """
741 A model describing a single architecture.
742 """
743 name = models.CharField(max_length=30, unique=True)
745 def __str__(self):
746 return self.name
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.
758 If there is no default repository, returns an empty
759 :py:class:`QuerySet <django.db.models.query.QuerySet>`
761 :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>`
762 """
763 return self.filter(default=True)
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)
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)
785 optional = models.BooleanField(default=True)
786 binary = models.BooleanField(default=True)
787 source = models.BooleanField(default=True)
789 source_packages = models.ManyToManyField(
790 'SourcePackage',
791 through='SourcePackageRepositoryEntry'
792 )
794 position = models.IntegerField(default=0)
796 objects = RepositoryManager()
798 class Meta:
799 verbose_name_plural = "repositories"
800 ordering = (
801 'position',
802 )
804 def __str__(self):
805 return self.name
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".
813 Matching by "codename" and "suite" will only be used if they return
814 a single match.
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
823 try:
824 return Repository.objects.get(shorthand=identifier)
825 except ObjectDoesNotExist:
826 pass
828 try:
829 return Repository.objects.get(codename=identifier)
830 except (ObjectDoesNotExist, MultipleObjectsReturned):
831 pass
833 try:
834 return Repository.objects.get(suite=identifier)
835 except (ObjectDoesNotExist, MultipleObjectsReturned):
836 pass
838 raise ValueError("%s does not uniquely identifies a repository" %
839 identifier)
841 @property
842 def sources_list_entry(self):
843 """
844 Returns the sources.list entries based on the repository's attributes.
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))
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.
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 ]
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.
883 This means the instance with the highest
884 :attr:`version <SourcePackage.version>` is returned.
886 If there is no :class:`SourcePackageRepositoryEntry` for the given name
887 in this repository, returns ``None``.
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`
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
907 def add_source_package(self, package, **kwargs):
908 """
909 The method adds a new class:`SourcePackage` to the repository.
911 :param package: The source package to add to the repository
912 :type package: :class:`SourcePackage`
914 The parameters needed for the corresponding
915 :class:`SourcePackageRepositoryEntry` should be in the keyword
916 arguments.
918 Returns the newly created :class:`SourcePackageRepositoryEntry` for the
919 given :class:`SourcePackage`.
921 :rtype: :class:`SourcePackageRepositoryEntry`
922 """
924 entry = SourcePackageRepositoryEntry.objects.create(
925 repository=self,
926 source_package=package,
927 **kwargs,
928 )
929 return entry
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.
936 :param source_package_name: The name of the source package
937 :type source_package_name: string
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()
948 def has_source_package(self, source_package):
949 """
950 Checks whether this :class:`Repository` contains the given
951 :class:`SourcePackage`.
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()
958 def has_binary_package(self, binary_package):
959 """
960 Checks whether this :class:`Repository` contains the given
961 :class:`BinaryPackage`.
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()
969 def add_binary_package(self, package, **kwargs):
970 """
971 The method adds a new class:`BinaryPackage` to the repository.
973 :param package: The binary package to add to the repository
974 :type package: :class:`BinaryPackage`
976 The parameters needed for the corresponding
977 :class:`BinaryPackageRepositoryEntry` should be in the keyword
978 arguments.
980 Returns the newly created :class:`BinaryPackageRepositoryEntry` for the
981 given :class:`BinaryPackage`.
983 :rtype: :class:`BinaryPackageRepositoryEntry`
984 """
985 return BinaryPackageRepositoryEntry.objects.create(
986 repository=self,
987 binary_package=package,
988 **kwargs
989 )
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.
997 :param base_url: The base URL of the repository
998 :type base_url: string
1000 :param suite: The name of the repository suite
1001 :type suite: string
1003 :rtype: string
1004 """
1005 base_url = base_url.rstrip('/')
1006 return base_url + '/dists/{suite}/Release'.format(
1007 suite=suite)
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")
1023 def is_development_repository(self):
1024 """Returns a boolean indicating whether the repository is used for
1025 development.
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.
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
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
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 }
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)
1073 class Meta:
1074 unique_together = ('repository', 'name')
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 )
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)
1093 class Meta:
1094 unique_together = ('repository', 'name')
1097class ContributorName(models.Model):
1098 """
1099 Represents a contributor.
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)
1107 class Meta:
1108 unique_together = ('contributor_email', 'name')
1110 @cached_property
1111 def email(self):
1112 return self.contributor_email.email
1114 def __str__(self):
1115 return "{name} <{email}>".format(
1116 name=self.name,
1117 email=self.contributor_email)
1119 def to_dict(self):
1120 """
1121 Returns a dictionary representing a :class:`ContributorName`
1122 instance.
1124 :rtype: dict
1125 """
1126 return {
1127 'name': self.name,
1128 'email': self.contributor_email.email,
1129 }
1132class SourcePackage(models.Model):
1133 """
1134 A model representing a single Debian source package.
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)
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)
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 )
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)
1166 class Meta:
1167 unique_together = ('source_package_name', 'version')
1169 def __str__(self):
1170 return '{pkg}, version {ver}'.format(
1171 pkg=self.source_package_name, ver=self.version)
1173 @cached_property
1174 def name(self):
1175 """
1176 A convenience property returning the name of the package as a string.
1178 :rtype: string
1179 """
1180 return self.source_package_name.name
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.
1191 If the version is found in the default repository, the entry for the
1192 default repository is returned.
1194 Otherwise, the entry for the repository with the highest
1195 :attr:`position <distro_tracker.core.models.Repository.position>`
1196 field is returned.
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
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
1214 def get_changelog_entry(self):
1215 """
1216 Retrieve the changelog entry which corresponds to this package version.
1218 If there is no changelog associated with the version returns ``None``
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
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()
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)
1242 def update(self, **kwargs):
1243 """
1244 The method updates all of the instance attributes based on the keyword
1245 arguments.
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)
1263class BinaryPackage(models.Model):
1264 """
1265 The method represents a particular binary package.
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)
1279 short_description = models.CharField(max_length=300, blank=True)
1280 long_description = models.TextField(blank=True)
1282 class Meta:
1283 unique_together = ('binary_package_name', 'version')
1285 def __str__(self):
1286 return 'Binary package {pkg}, version {ver}'.format(
1287 pkg=self.binary_package_name, ver=self.version)
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)
1298 @cached_property
1299 def name(self):
1300 """Returns the name of the package"""
1301 return self.binary_package_name.name
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)
1315class BinaryPackageRepositoryEntry(models.Model):
1316 """
1317 A model representing repository specific information for a given binary
1318 package.
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)
1336 priority = models.CharField(max_length=50, blank=True)
1337 section = models.CharField(max_length=50, blank=True)
1339 objects = BinaryPackageRepositoryEntryManager()
1341 class Meta:
1342 unique_together = ('binary_package', 'repository', 'architecture')
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)
1349 @property
1350 def name(self):
1351 """The name of the binary package"""
1352 return self.binary_package.name
1354 @cached_property
1355 def version(self):
1356 """The version of the binary package"""
1357 return self.binary_package.version
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)
1371class SourcePackageRepositoryEntry(models.Model):
1372 """
1373 A model representing source package data that is repository specific.
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)
1387 component = models.CharField(max_length=50, blank=True)
1389 objects = SourcePackageRepositoryEntryManager()
1391 class Meta:
1392 unique_together = ('source_package', 'repository')
1394 def __str__(self):
1395 return "Source package {pkg} in the repository {repo}".format(
1396 pkg=self.source_package,
1397 repo=self.repository)
1399 @property
1400 def dsc_file_url(self):
1401 """
1402 Returns the URL where the .dsc file of this entry can be found.
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
1417 @property
1418 def directory_url(self):
1419 """
1420 Returns the URL of the package's directory.
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
1431 @property
1432 def name(self):
1433 """The name of the source package"""
1434 return self.source_package.name
1436 @cached_property
1437 def version(self):
1438 """
1439 Returns the version of the associated source package.
1440 """
1441 return self.source_package.version
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 ))
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)
1467 class Meta:
1468 unique_together = ('source_package', 'name')
1470 def __str__(self):
1471 return 'Extracted file {extracted_file} of package {package}'.format(
1472 extracted_file=self.extracted_file, package=self.source_package)
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)
1488 def __str__(self):
1489 return '{key}: {value} for package {package}'.format(
1490 key=self.key, value=self.value, package=self.package)
1492 class Meta:
1493 unique_together = ('key', 'package')
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]
1511 qs = self.filter(domain=domain)
1512 if qs.exists():
1513 return qs[0]
1514 else:
1515 return None
1518def validate_archive_url_template(value):
1519 """
1520 Custom validator for :class:`MailingList`'s
1521 :attr:`archive_url_template <MailingList.archive_url_template>` field.
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")
1530class MailingList(models.Model):
1531 """
1532 Describes a known mailing list.
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.
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 """
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 ])
1549 objects = MailingListManager()
1551 def __str__(self):
1552 return self.name
1554 def archive_url(self, user):
1555 """
1556 Returns the archive URL for the given user.
1558 :param user: The user for whom the archive URL should be returned
1559 :type user: string
1561 :rtype: string
1562 """
1563 return self.archive_url_template.format(user=user)
1565 def archive_url_for_email(self, email):
1566 """
1567 Returns the archive URL for the given email.
1569 Similar to :meth:`archive_url`, but extracts the user name from the
1570 email first.
1572 :param email: The email of the user for whom the archive URL should be
1573 returned
1574 :type user: string
1576 :rtype: string
1577 """
1578 if '@' not in email:
1579 return None
1580 user, domain = email.rsplit('@', 1)
1582 if domain != self.domain:
1583 return None
1585 return self.archive_url(user)
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).
1598 If there is a ``content`` parameter in the kwargs, the news content is
1599 saved to the database.
1601 If there is a ``file_content`` parameter in the kwargs, the news content
1602 is saved to a file.
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')
1613 return super(NewsManager, self).create(**kwargs)
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 ))
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')
1642 objects = NewsManager()
1644 def __str__(self):
1645 return self.title
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.
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
1664 def save(self, *args, **kwargs):
1665 super(News, self).save(*args, **kwargs)
1667 signers = verify_signature(self.get_signed_content())
1668 if signers is None:
1669 # No signature
1670 return
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)
1685 self.signed_by.set(signed_by)
1687 def get_signed_content(self):
1688 return self.content
1690 def get_absolute_url(self):
1691 return reverse('dtracker-news-page', kwargs={
1692 'news_id': self.pk,
1693 'slug': slugify(self.title)
1694 })
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.
1706 If a title of the message is not given, it automatically generates it
1707 based on the sender of the email.
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)
1720 return self.create(package=package, **create_kwargs)
1722 def get_queryset(self):
1723 return super(EmailNewsManager, self).get_queryset().filter(
1724 content_type='message/rfc822')
1727class EmailNews(News):
1728 objects = EmailNewsManager()
1730 class Meta:
1731 proxy = True
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)
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)
1759 return from_email
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)
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'
1782 return kwargs
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.
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
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
1812 def __init__(self, news):
1813 """
1814 :type news: :class:`distro_tracker.core.models.News`
1815 """
1816 self.news = news
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.
1826 :param content_type: The Content-Type for which a renderer class should
1827 be returned.
1828 :type content_type: string
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
1836 return None
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 ''
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'
1860class HtmlNewsRenderer(NewsRenderer):
1861 """
1862 Renders a text/html content type by simply emitting it to the output.
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'
1869 @property
1870 def html_output(self):
1871 return mark_safe(self.news.content)
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'
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 ]
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}
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 = []
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)
1936 return {
1937 'headers': headers,
1938 'parts': plain_text_payloads,
1939 'signed_by': signers,
1940 }
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)
1953 def __str__(self):
1954 return '{package} bug stats: {stats}'.format(
1955 package=self.package, stats=self.stats)
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)
1968 def __str__(self):
1969 return '{package} bug stats: {stats}'.format(
1970 package=self.package, stats=self.stats)
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.
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
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()
2003 return item_type
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)
2011 objects = ActionItemTypeManager()
2013 def __str__(self):
2014 return self.type_name
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.
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()
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)
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)
2075 objects = ActionItemManager()
2077 class Meta:
2078 unique_together = ('package', 'item_type')
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())
2086 def get_absolute_url(self):
2087 return reverse('dtracker-action-item', kwargs={
2088 'item_pk': self.pk,
2089 })
2091 @property
2092 def type_name(self):
2093 return self.item_type.type_name
2095 @property
2096 def full_description_template(self):
2097 return self.item_type.full_description_template
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 ''
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 }
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
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.
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()
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.
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
2179 raise ConfirmationException(
2180 'Unable to generate a confirmation key for {identifier}'.format(
2181 identifier=identifier))
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()
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.
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
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)
2211 objects = ConfirmationManager()
2213 class Meta:
2214 abstract = True
2216 def __str__(self):
2217 return self.confirmation_key
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
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)
2241 class Meta:
2242 unique_together = ('source', 'dependency', 'repository')
2244 def __str__(self):
2245 return '{} depends on {}'.format(self.source, self.dependency)
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]
2265 return self.create(**kwargs)
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)
2286 owner = models.ForeignKey(
2287 'accounts.User',
2288 null=True,
2289 on_delete=models.SET_NULL,
2290 related_name='owned_teams')
2292 packages = models.ManyToManyField(
2293 PackageName,
2294 related_name='teams')
2295 members = models.ManyToManyField(
2296 UserEmail,
2297 related_name='teams',
2298 through='TeamMembership')
2300 objects = TeamManager()
2302 def __str__(self):
2303 return self.name
2305 def get_absolute_url(self):
2306 return reverse('dtracker-team-page', kwargs={
2307 'slug': self.slug,
2308 })
2310 def add_members(self, users, muted=False):
2311 """
2312 Adds the given users to the team.
2314 It automatically creates the intermediary :class:`TeamMembership`
2315 models.
2317 :param users: The users to be added to the team.
2318 :type users: an ``iterable`` of :class:`UserEmail` instances
2320 :param muted: If set to True, the membership will be muted before the
2321 user excplicitely unmutes it.
2322 :type active: bool
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 ]
2339 def remove_members(self, users):
2340 """
2341 Removes the given users from the team.
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()
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 )
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)
2370 muted = models.BooleanField(default=False)
2371 default_keywords = models.ManyToManyField(Keyword)
2372 has_membership_keywords = models.BooleanField(default=False)
2374 class Meta:
2375 unique_together = ('user_email', 'team')
2377 def __str__(self):
2378 return '{} member of {}'.format(self.user_email, self.team)
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.
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
2400 return package_specifics.muted
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()
2414 def mute_package(self, package_name):
2415 """
2416 The method mutes only the given package in the user's team membership.
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)
2423 def unmute_package(self, package_name):
2424 """
2425 The method unmutes only the given package in the user's team membership.
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)
2432 def set_keywords(self, package_name, keywords):
2433 """
2434 Sets the membership-specific keywords for the given package.
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)
2451 def set_membership_keywords(self, keywords):
2452 """
2453 Sets the membership default keywords.
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()
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')
2470 def get_keywords(self, package_name):
2471 """
2472 Returns the keywords that are associated to a particular package of
2473 this team membership.
2475 The first set of keywords that exists in the order given below is
2476 returned:
2478 - Membership package-specific keywords
2479 - Membership default keywords
2480 - UserEmail default keywords
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`
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)
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
2504 if self.has_membership_keywords:
2505 return self.default_keywords.all()
2507 email_settings, _ = \
2508 EmailSettings.objects.get_or_create(user_email=self.user_email)
2509 return email_settings.default_keywords.all()
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)
2523 keywords = models.ManyToManyField(Keyword)
2524 _has_keywords = models.BooleanField(default=False)
2526 muted = models.BooleanField(default=False)
2528 class Meta:
2529 unique_together = ('membership', 'package_name')
2531 def __str__(self):
2532 return "Membership ({}) specific keywords for {} package".format(
2533 self.membership, self.package_name)
2535 def set_keywords(self, keywords):
2536 self.keywords.set(keywords)
2537 self._has_keywords = True
2538 self.save()
2541class MembershipConfirmation(Confirmation):
2542 membership = models.ForeignKey(TeamMembership, on_delete=models.CASCADE)
2544 def __str__(self):
2545 return "Confirmation for {}".format(self.membership)
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'
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.
2566 It should return a dict with the following keys:
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:
2572 * ``category_name`` - the name of the bug category
2573 * ``bug_count`` - the number of known bugs for the given package and
2574 category
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'] = []
2584 # Also adds a total of all those bugs
2585 total = sum(category['bug_count'] for category in stats['bugs'])
2586 stats['all'] = total
2588 return stats
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.
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:
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
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
2617 def get_bug_tracker_url(self, package_name, package_type, category_name):
2618 pass
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.
2627 It should return a list of dicts where each element describes a single
2628 bug category for the given package.
2630 Each dict has to provide at minimum the following keys:
2632 - ``category_name`` - the name of the bug category
2633 - ``bug_count`` - the number of known bugs for the given package and
2634 category
2636 Optionally, the following keys can be provided:
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
2648 return
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
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)
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.
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)
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.
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
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.
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
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.
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
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))
2756 self.run_lock += timedelta(seconds=delay)
2757 self.save(update_fields=['run_lock'])