from collections import namedtuple from django.db import models from django.db.models.signals import pre_save from django.contrib.admin.models import ADDITION, CHANGE, DELETION from django.contrib.auth.models import User from main.models import Arch, Repo, Package from main.utils import set_created_field, database_vendor from packages.alpm import AlpmAPI class PackageRelation(models.Model): ''' Represents maintainership (or interest) in a package by a given developer. It is not a true foreign key to packages as we want to key off pkgbase/pkgname instead, as well as preserve this information across package deletes, adds, and in all repositories. ''' MAINTAINER = 1 WATCHER = 2 TYPE_CHOICES = ( (MAINTAINER, 'Maintainer'), (WATCHER, 'Watcher'), ) pkgbase = models.CharField(max_length=255) user = models.ForeignKey(User, related_name="package_relations", on_delete=models.CASCADE) type = models.PositiveIntegerField(choices=TYPE_CHOICES, default=MAINTAINER) created = models.DateTimeField(editable=False) class Meta: unique_together = (('pkgbase', 'user', 'type'),) def get_associated_packages(self): return Package.objects.normal().filter(pkgbase=self.pkgbase) def repositories(self): packages = self.get_associated_packages() return sorted({p.repo for p in packages}) def last_update(self): return Update.objects.filter(pkgbase=self.pkgbase).latest() def __str__(self): return '%s: %s (%s)' % ( self.pkgbase, self.user, self.get_type_display()) class SignoffSpecificationManager(models.Manager): def get_from_package(self, pkg): '''Utility method to pull all relevant name-version fields from a package and get a matching signoff specification.''' return self.get( pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel, epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo) def get_or_default_from_package(self, pkg): '''utility method to pull all relevant name-version fields from a package and get a matching signoff specification, or return the default base case.''' try: return self.get( pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel, epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo) except SignoffSpecification.DoesNotExist: return fake_signoff_spec(pkg.arch) class SignoffSpecification(models.Model): ''' A specification for the signoff policy for this particular revision of a package. The default is requiring two signoffs for a given package. These are created only if necessary; e.g., if one wanted to override the required=2 attribute, otherwise a sane default object is used. ''' pkgbase = models.CharField(max_length=255, db_index=True) pkgver = models.CharField(max_length=255) pkgrel = models.CharField(max_length=255) epoch = models.PositiveIntegerField(default=0) arch = models.ForeignKey(Arch, on_delete=models.CASCADE) repo = models.ForeignKey(Repo, on_delete=models.CASCADE) user = models.ForeignKey(User, null=True, on_delete=models.CASCADE) created = models.DateTimeField(editable=False) required = models.PositiveIntegerField(default=2, help_text="How many signoffs are required for this package?") enabled = models.BooleanField(default=True, help_text="Is this package eligible for signoffs?") known_bad = models.BooleanField(default=False, help_text="Is this package known to be broken in some way?") comments = models.TextField(null=True, blank=True) objects = SignoffSpecificationManager() @property def full_version(self): if self.epoch > 0: return '%d:%s-%s' % (self.epoch, self.pkgver, self.pkgrel) return '%s-%s' % (self.pkgver, self.pkgrel) def __str__(self): return '%s-%s' % (self.pkgbase, self.full_version) # Fake signoff specs for when we don't have persisted ones in the database. # These have all necessary attributes of the real thing but are lighter weight # and have no chance of being persisted. FakeSignoffSpecification = namedtuple('FakeSignoffSpecification', ('required', 'enabled', 'known_bad', 'comments')) def fake_signoff_spec(arch): return FakeSignoffSpecification(arch.required_signoffs, True, False, '') class SignoffManager(models.Manager): def get_from_package(self, pkg, user, revoked=False): '''Utility method to pull all relevant name-version fields from a package and get a matching signoff.''' not_revoked = not revoked return self.get( pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel, epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo, revoked__isnull=not_revoked, user=user) def get_or_create_from_package(self, pkg, user): '''Utility method to pull all relevant name-version fields from a package and get or create a matching signoff.''' return self.get_or_create( pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel, epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo, revoked=None, user=user) def for_package(self, pkg): return self.select_related('user').filter( pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel, epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo) class Signoff(models.Model): ''' A signoff for a package (by pkgbase) at a given point in time. These are not keyed directly to a Package object so they don't ever get deleted when Packages come and go from testing repositories. ''' pkgbase = models.CharField(max_length=255, db_index=True) pkgver = models.CharField(max_length=255) pkgrel = models.CharField(max_length=255) epoch = models.PositiveIntegerField(default=0) arch = models.ForeignKey(Arch, on_delete=models.CASCADE) repo = models.ForeignKey(Repo, on_delete=models.CASCADE) user = models.ForeignKey(User, related_name="package_signoffs", on_delete=models.CASCADE) created = models.DateTimeField(editable=False, db_index=True) revoked = models.DateTimeField(null=True) comments = models.TextField(null=True, blank=True) objects = SignoffManager() @property def packages(self): return Package.objects.normal().filter(pkgbase=self.pkgbase, pkgver=self.pkgver, pkgrel=self.pkgrel, epoch=self.epoch, arch=self.arch, repo=self.repo) @property def full_version(self): if self.epoch > 0: return '%d:%s-%s' % (self.epoch, self.pkgver, self.pkgrel) return '%s-%s' % (self.pkgver, self.pkgrel) def __str__(self): revoked = '' if self.revoked: revoked = ' (revoked)' return '%s-%s: %s%s' % ( self.pkgbase, self.full_version, self.user, revoked) class FlagRequest(models.Model): ''' A notification the package is out-of-date submitted through the web site. ''' user = models.ForeignKey(User, blank=True, null=True, on_delete=models.CASCADE) user_email = models.EmailField('email address') created = models.DateTimeField(editable=False, db_index=True) ip_address = models.GenericIPAddressField('IP address', unpack_ipv4=True) pkgbase = models.CharField(max_length=255, db_index=True) pkgver = models.CharField(max_length=255) pkgrel = models.CharField(max_length=255) epoch = models.PositiveIntegerField(default=0) repo = models.ForeignKey(Repo, on_delete=models.CASCADE) num_packages = models.PositiveIntegerField('number of packages', default=1) message = models.TextField('message to developer', blank=True) is_spam = models.BooleanField(default=False, help_text="Is this comment from a real person?") is_legitimate = models.BooleanField(default=True, help_text="Is this actually an out-of-date flag request?") class Meta: get_latest_by = 'created' def who(self): if self.user: return self.user.get_full_name() return self.user_email @property def full_version(self): # Difference here from other implementations at the moment: we need to # handle the case of pkgver and pkgrel being null as this table didn't # originally have version columns. if self.pkgver == '' and self.pkgrel == '': return '' if self.epoch > 0: return '%d:%s-%s' % (self.epoch, self.pkgver, self.pkgrel) return '%s-%s' % (self.pkgver, self.pkgrel) def get_associated_packages(self): return Package.objects.normal().filter( pkgbase=self.pkgbase, repo__testing=self.repo.testing, repo__staging=self.repo.staging).order_by( 'pkgname', 'repo__name', 'arch__name') def __str__(self): return '%s from %s on %s' % (self.pkgbase, self.who(), self.created) class UpdateManager(models.Manager): def log_update(self, old_pkg, new_pkg): '''Utility method to help log an update. This will determine the type based on how many packages are passed in, and will pull the relevant necessary fields off the given packages. Note that in some cases, this is a no-op if we know this database type supports triggers to add these rows instead.''' if database_vendor(Package, 'write') in ('sqlite', 'postgresql'): # we log updates using database triggers for these backends return update = Update() if new_pkg: update.action_flag = ADDITION update.package = new_pkg update.arch = new_pkg.arch update.repo = new_pkg.repo update.pkgname = new_pkg.pkgname update.pkgbase = new_pkg.pkgbase update.new_pkgver = new_pkg.pkgver update.new_pkgrel = new_pkg.pkgrel update.new_epoch = new_pkg.epoch if old_pkg: if new_pkg: update.action_flag = CHANGE # ensure we should even be logging this if (old_pkg.pkgver == new_pkg.pkgver and old_pkg.pkgrel == new_pkg.pkgrel and old_pkg.epoch == new_pkg.epoch): # all relevant fields were the same; e.g. a force update return else: update.action_flag = DELETION update.arch = old_pkg.arch update.repo = old_pkg.repo update.pkgname = old_pkg.pkgname update.pkgbase = old_pkg.pkgbase update.old_pkgver = old_pkg.pkgver update.old_pkgrel = old_pkg.pkgrel update.old_epoch = old_pkg.epoch update.save(force_insert=True) return update class Update(models.Model): UPDATE_ACTION_CHOICES = ( (ADDITION, 'Addition'), (CHANGE, 'Change'), (DELETION, 'Deletion'), ) package = models.ForeignKey(Package, related_name="updates", null=True, on_delete=models.SET_NULL) repo = models.ForeignKey(Repo, related_name="updates", on_delete=models.CASCADE) arch = models.ForeignKey(Arch, related_name="updates", on_delete=models.CASCADE) pkgname = models.CharField(max_length=255, db_index=True) pkgbase = models.CharField(max_length=255) action_flag = models.PositiveSmallIntegerField('action flag', choices=UPDATE_ACTION_CHOICES) created = models.DateTimeField(editable=False, db_index=True) old_pkgver = models.CharField(max_length=255, null=True) old_pkgrel = models.CharField(max_length=255, null=True) old_epoch = models.PositiveIntegerField(null=True) new_pkgver = models.CharField(max_length=255, null=True) new_pkgrel = models.CharField(max_length=255, null=True) new_epoch = models.PositiveIntegerField(null=True) objects = UpdateManager() class Meta: get_latest_by = 'created' def is_addition(self): return self.action_flag == ADDITION def is_change(self): return self.action_flag == CHANGE def is_deletion(self): return self.action_flag == DELETION @property def old_version(self): if self.action_flag == ADDITION: return None if self.old_epoch > 0: return '%d:%s-%s' % (self.old_epoch, self.old_pkgver, self.old_pkgrel) return '%s-%s' % (self.old_pkgver, self.old_pkgrel) @property def new_version(self): if self.action_flag == DELETION: return None if self.new_epoch > 0: return '%d:%s-%s' % (self.new_epoch, self.new_pkgver, self.new_pkgrel) return '%s-%s' % (self.new_pkgver, self.new_pkgrel) def elsewhere(self): return Package.objects.normal().filter( pkgname=self.pkgname, arch=self.arch) def replacements(self): pkgs = Package.objects.normal().filter( replaces__name=self.pkgname) if not self.arch.agnostic: # make sure we match architectures if possible arches = set(Arch.objects.filter(agnostic=True)) arches.add(self.arch) pkgs = pkgs.filter(arch__in=arches) return pkgs def get_absolute_url(self): return '/packages/%s/%s/%s/' % (self.repo.name.lower(), self.arch.name, self.pkgname) def __str__(self): return '%s of %s on %s' % (self.get_action_flag_display(), self.pkgname, self.created) class PackageGroup(models.Model): ''' Represents a group a package is in. There is no actual group entity, only names that link to given packages. ''' pkg = models.ForeignKey(Package, related_name='groups', on_delete=models.CASCADE) name = models.CharField(max_length=255, db_index=True) def __str__(self): return "%s: %s" % (self.name, self.pkg) class Meta: ordering = ('name',) class License(models.Model): pkg = models.ForeignKey(Package, related_name='licenses', on_delete=models.CASCADE) name = models.CharField(max_length=255) def __str__(self): return self.name class Meta: ordering = ('name',) class RelatedToBase(models.Model): '''A base class for conflicts/provides/replaces/etc.''' name = models.CharField(max_length=255, db_index=True) version = models.CharField(max_length=255, default='') def get_best_satisfier(self): '''Find a satisfier for this related package that best matches the given criteria. It will not search provisions, but will find packages named and matching repo characteristics if possible.''' pkgs = Package.objects.normal().filter(pkgname=self.name) # TODO: this may in fact be faster- select only the fields we know will # actually get used, saving us some bandwidth and hopefully query # construction time. However, reality hasn't quite proved it out yet. #pkgs = Package.objects.select_related('repo', 'arch').only( # 'id', 'pkgname', 'epoch', 'pkgver', 'pkgrel', # 'repo__id', 'repo__name', 'repo__testing', 'repo__staging', # 'arch__id', 'arch__name').filter(pkgname=self.name) if not self.pkg.arch.agnostic: # make sure we match architectures if possible arches = self.pkg.applicable_arches() pkgs = pkgs.filter(arch__in=arches) # if we have a comparison operation, make sure the packages we grab # actually satisfy the requirements if self.comparison and self.version: alpm = AlpmAPI() pkgs = [pkg for pkg in pkgs if not alpm.available or alpm.compare_versions(pkg.full_version, self.comparison, self.version)] if len(pkgs) == 0: # couldn't find a package in the DB # it should be a virtual depend (or a removed package) return None if len(pkgs) == 1: return pkgs[0] # more than one package, see if we can't shrink it down # grab the first though in case we fail pkg = pkgs[0] # prevents yet more DB queries, these lists should be short; # after each grab the best available in case we remove all entries pkgs = [p for p in pkgs if p.repo.staging == self.pkg.repo.staging] if len(pkgs) > 0: pkg = pkgs[0] pkgs = [p for p in pkgs if p.repo.testing == self.pkg.repo.testing] if len(pkgs) > 0: pkg = pkgs[0] return pkg def get_providers(self): '''Return providers of this related package. Does *not* include exact matches as it checks the Provision names only, use get_best_satisfier() instead for exact matches.''' pkgs = Package.objects.normal().filter( provides__name=self.name).order_by().distinct() if not self.pkg.arch.agnostic: # make sure we match architectures if possible arches = self.pkg.applicable_arches() pkgs = pkgs.filter(arch__in=arches) # If we have a comparison operation, make sure the packages we grab # actually satisfy the requirements. alpm = AlpmAPI() if alpm.available and self.comparison and self.version: pkgs = pkgs.prefetch_related('provides') new_pkgs = [] for package in pkgs: for provide in package.provides.all(): if provide.name != self.name: continue if alpm.compare_versions(provide.version, self.comparison, self.version): new_pkgs.append(package) pkgs = new_pkgs # Sort providers by preference. We sort those in same staging/testing # combination first, followed by others. We sort by a (staging, # testing) match tuple that will be (True, True) in the best case. key_func = lambda x: (x.repo.staging == self.pkg.repo.staging, x.repo.testing == self.pkg.repo.testing) return sorted(pkgs, key=key_func, reverse=True) def __str__(self): if self.version: return '%s%s%s' % (self.name, self.comparison, self.version) return self.name class Meta: abstract = True ordering = ('name',) class Depend(RelatedToBase): DEPTYPE_CHOICES = ( ('D', 'Depend'), ('O', 'Optional Depend'), ('M', 'Make Depend'), ('C', 'Check Depend'), ) pkg = models.ForeignKey(Package, related_name='depends', on_delete=models.CASCADE) comparison = models.CharField(max_length=255, default='') description = models.TextField(null=True, blank=True) deptype = models.CharField(max_length=1, default='D', choices=DEPTYPE_CHOICES) def __str__(self): '''For depends, we may also have a description and a modifier.''' to_str = super(Depend, self).__str__() if self.description: return '%s: %s' % (to_str, self.description) return to_str class Conflict(RelatedToBase): pkg = models.ForeignKey(Package, related_name='conflicts', on_delete=models.CASCADE) comparison = models.CharField(max_length=255, default='') class Provision(RelatedToBase): pkg = models.ForeignKey(Package, related_name='provides', on_delete=models.CASCADE) # comparison must be '=' for provides @property def comparison(self): if self.version is not None and self.version != '': return '=' return None class Replacement(RelatedToBase): pkg = models.ForeignKey(Package, related_name='replaces', on_delete=models.CASCADE) comparison = models.CharField(max_length=255, default='') # hook up some signals for sender in (FlagRequest, PackageRelation, SignoffSpecification, Signoff, Update): pre_save.connect(set_created_field, sender=sender, dispatch_uid="packages.models") # vim: set ts=4 sw=4 et: