text: # -*- coding: utf-8 -*-
  import datetime
  import os
  from base64 import b64encode
  
  import django.dispatch
  
  from django.core.exceptions import ObjectDoesNotExist
  from django.core.files.storage import default_storage as storage
  from django.db import models
  from django.utils.functional import cached_property
  from django.utils.translation import ugettext
  
  import jinja2
  import waffle
  
  from django_extensions.db.fields.json import JSONField
  from django_statsd.clients import statsd
  
  import olympia.core.logger
  
  from olympia import activity, amo
  from olympia.amo.fields import PositiveAutoField
  from olympia.amo.decorators import use_primary_db
  from olympia.amo.models import (
      BasePreview, ManagerBase, ModelBase, OnChangeMixin)
  from olympia.amo.urlresolvers import reverse
  from olympia.amo.utils import sorted_groupby, utc_millesecs_from_epoch
  from olympia.applications.models import AppVersion
  from olympia.constants.licenses import LICENSES_BY_BUILTIN
  from olympia.files import utils
  from olympia.files.models import File, cleanup_file
  from olympia.translations.fields import (
      LinkifiedField, PurifiedField, TranslatedField, save_signal)
  from olympia.lib.git import AddonGitRepository
  
  from .compare import version_dict, version_int
  
  
  log = olympia.core.logger.getLogger('z.versions')
  
  
  # Valid source extensions. Used in error messages to the user and to skip
  # early in source_upload_path() if necessary, but the actual validation is
  # more complex and done in olympia.devhub.WithSourceMixin.clean_source
  VALID_SOURCE_EXTENSIONS = ('.zip', '.tar.gz', '.tar.bz2',)
  
  
  class VersionManager(ManagerBase):
  
      def __init__(self, include_deleted=False):
          ManagerBase.__init__(self)
          self.include_deleted = include_deleted
  
      def get_queryset(self):
          qs = super(VersionManager, self).get_queryset()
          if not self.include_deleted:
              qs = qs.exclude(deleted=True)
          return qs.transform(Version.transformer)
  
      def valid(self):
          return self.filter(
              files__status__in=amo.VALID_FILE_STATUSES).distinct()
  
      def latest_public_compatible_with(self, application, appversions):
          """Return a queryset filtering the versions so that they are public,
          listed, and compatible with the application and appversions parameters
          passed. The queryset is ordered by creation date descending, allowing
          the caller to get the latest compatible version available.
  
          application is an application id
          appversions is a dict containing min and max values, as version ints.
          """
          return Version.objects.filter(
              apps__application=application,
              apps__min__version_int__lte=appversions['min'],
              apps__max__version_int__gte=appversions['max'],
              channel=amo.RELEASE_CHANNEL_LISTED,
              files__status=amo.STATUS_PUBLIC,
          ).order_by('-created')
  
  
  def source_upload_path(instance, filename):
      # At this point we already know that ext is one of VALID_SOURCE_EXTENSIONS
      # because we already checked for that in
      # /src/olympia/devhub/forms.py#WithSourceMixin.clean_source.
      for ext in VALID_SOURCE_EXTENSIONS:
          if filename.endswith(ext):
              break
  
      return os.path.join(
          u'version_source',
          utils.id_to_path(instance.pk),
          u'{0}-{1}-src{2}'.format(
              instance.addon.slug,
              instance.version,
              ext)
      )
  
  
  class VersionCreateError(ValueError):
      pass
  
  
  class Version(OnChangeMixin, ModelBase):
      id = PositiveAutoField(primary_key=True)
      addon = models.ForeignKey(
          'addons.Addon', related_name='versions', on_delete=models.CASCADE)
      license = models.ForeignKey('License', null=True)
      releasenotes = PurifiedField()
      approvalnotes = models.TextField(default='', null=True)
      version = models.CharField(max_length=255, default='0.1')
      version_int = models.BigIntegerField(null=True, editable=False)
  
      nomination = models.DateTimeField(null=True)
      reviewed = models.DateTimeField(null=True)
  
      deleted = models.BooleanField(default=False)
  
      source = models.FileField(
          upload_to=source_upload_path, null=True, blank=True)
  
      channel = models.IntegerField(choices=amo.RELEASE_CHANNEL_CHOICES,
                                    default=amo.RELEASE_CHANNEL_LISTED)
  
      git_hash = models.CharField(max_length=40, blank=True)
      source_git_hash = models.CharField(max_length=40, blank=True)
  
      # The order of those managers is very important: please read the lengthy
      # comment above the Addon managers declaration/instantiation.
      unfiltered = VersionManager(include_deleted=True)
      objects = VersionManager()
  
      class Meta(ModelBase.Meta):
          db_table = 'versions'
          # This is very important: please read the lengthy comment in Addon.Meta
          # description
          base_manager_name = 'unfiltered'
          ordering = ['-created', '-modified']
  
      def __init__(self, *args, **kwargs):
          super(Version, self).__init__(*args, **kwargs)
          self.__dict__.update(version_dict(self.version or ''))
  
      def __unicode__(self):
          return jinja2.escape(self.version)
  
      def save(self, *args, **kw):
          if not self.version_int and self.version:
              v_int = version_int(self.version)
              # Magic number warning, this is the maximum size
              # of a big int in MySQL to prevent version_int overflow, for
              # people who have rather crazy version numbers.
              # http://dev.mysql.com/doc/refman/5.5/en/numeric-types.html
              if v_int < 9223372036854775807:
                  self.version_int = v_int
              else:
                  log.error('No version_int written for version %s, %s' %
                            (self.pk, self.version))
          super(Version, self).save(*args, **kw)
          return self
  
      @classmethod
      def from_upload(cls, upload, addon, selected_apps, channel,
                      parsed_data=None):
          """
          Create a Version instance and corresponding File(s) from a
          FileUpload, an Addon, a list of compatible app ids, a channel id and
          the parsed_data generated by parse_addon().
  
          Note that it's the caller's responsability to ensure the file is valid.
          We can't check for that here because an admin may have overridden the
          validation results.
          """
          assert parsed_data is not None
  
          from olympia.addons.models import AddonFeatureCompatibility
  
          if addon.status == amo.STATUS_DISABLED:
              raise VersionCreateError(
                  'Addon is Mozilla Disabled; no new versions are allowed.')
  
          license_id = None
          if channel == amo.RELEASE_CHANNEL_LISTED:
              previous_version = addon.find_latest_version(
                  channel=channel, exclude=())
              if previous_version and previous_version.license_id:
                  license_id = previous_version.license_id
          approvalnotes = None
          if parsed_data.get('is_mozilla_signed_extension'):
              approvalnotes = (u'This version has been signed with '
                               u'Mozilla internal certificate.')
          version = cls.objects.create(
              addon=addon,
              approvalnotes=approvalnotes,
              version=parsed_data['version'],
              license_id=license_id,
              channel=channel,
          )
          log.info(
              'New version: %r (%s) from %r' % (version, version.id, upload))
          activity.log_create(amo.LOG.ADD_VERSION, version, addon)
          # Update the add-on e10s compatibility since we're creating a new
          # version that may change that.
          e10s_compatibility = parsed_data.get('e10s_compatibility')
          if e10s_compatibility is not None:
              feature_compatibility = (
                  AddonFeatureCompatibility.objects.get_or_create(addon=addon)[0]
              )
              feature_compatibility.update(e10s=e10s_compatibility)
  
          if addon.type == amo.ADDON_STATICTHEME:
              # We don't let developers select apps for static themes
              selected_apps = [app.id for app in amo.APP_USAGE]
  
          compatible_apps = {}
          for app in parsed_data.get('apps', []):
              if app.id not in selected_apps:
                  # If the user chose to explicitly deselect Firefox for Android
                  # we're not creating the respective `ApplicationsVersions`
                  # which will have this add-on then be listed only for
                  # Firefox specifically.
                  continue
  
              compatible_apps[app.appdata] = ApplicationsVersions(
                  version=version, min=app.min, max=app.max, application=app.id)
              compatible_apps[app.appdata].save()
  
          # See #2828: sometimes when we generate the filename(s) below, in
          # File.from_upload(), cache-machine is confused and has trouble
          # fetching the ApplicationsVersions that were just created. To work
          # around this we pre-generate version.compatible_apps and avoid the
          # queries completely.
          version._compatible_apps = compatible_apps
  
          # For backwards compatibility. We removed specific platform
          # support during submission but we don't handle it any different
          # beyond that yet. That means, we're going to simply set it
          # to `PLATFORM_ALL` and also have the backend create separate
          # files for each platform. Cleaning that up is another step.
          # Given the timing on this, we don't care about updates to legacy
          # add-ons as well.
          # Create relevant file and update the all_files cached property on the
          # Version, because we might need it afterwards.
          version.all_files = [File.from_upload(
              upload=upload, version=version, platform=amo.PLATFORM_ALL.id,
              parsed_data=parsed_data
          )]
  
          version.inherit_nomination(from_statuses=[amo.STATUS_AWAITING_REVIEW])
          version.disable_old_files()
          # After the upload has been copied to all platforms, remove the upload.
          storage.delete(upload.path)
          version_uploaded.send(sender=version)
  
          if waffle.switch_is_active('enable-uploads-commit-to-git-storage'):
              # Extract into git repository
              AddonGitRepository.extract_and_commit_from_version(
                  version=version, author=upload.user)
  
          # Generate a preview and icon for listed static themes
          if (addon.type == amo.ADDON_STATICTHEME and
                  channel == amo.RELEASE_CHANNEL_LISTED):
              theme_data = parsed_data.get('theme', {})
              generate_static_theme_preview(theme_data, version.pk)
  
          # Track the time it took from first upload through validation
          # (and whatever else) until a version was created.
          upload_start = utc_millesecs_from_epoch(upload.created)
          now = datetime.datetime.now()
          now_ts = utc_millesecs_from_epoch(now)
          upload_time = now_ts - upload_start
  
          log.info('Time for version {version} creation from upload: {delta}; '
                   'created={created}; now={now}'
                   .format(delta=upload_time, version=version,
                           created=upload.created, now=now))
          statsd.timing('devhub.version_created_from_upload', upload_time)
  
          return version
  
      def license_url(self, impala=False):
          return reverse('addons.license', args=[self.addon.slug, self.version])
  
      def get_url_path(self):
          if self.channel == amo.RELEASE_CHANNEL_UNLISTED:
              return ''
          return reverse('addons.versions', args=[self.addon.slug, self.version])
  
      def delete(self, hard=False):
          # To avoid a circular import
          from .tasks import delete_preview_files
  
          log.info(u'Version deleted: %r (%s)' % (self, self.id))
          activity.log_create(amo.LOG.DELETE_VERSION, self.addon,
                              str(self.version))
  
          if hard:
              super(Version, self).delete()
          else:
              # By default we soft delete so we can keep the files for comparison
              # and a record of the version number.
              self.files.update(status=amo.STATUS_DISABLED)
              self.deleted = True
              self.save()
  
              previews_pks = list(
                  VersionPreview.objects.filter(version__id=self.id)
                                .values_list('id', flat=True))
  
              for preview_pk in previews_pks:
                  delete_preview_files.delay(preview_pk)
  
      @property
      def is_user_disabled(self):
          return self.files.filter(status=amo.STATUS_DISABLED).exclude(
              original_status=amo.STATUS_NULL).exists()
  
      @is_user_disabled.setter
      def is_user_disabled(self, disable):
          # User wants to disable (and the File isn't already).
          if disable:
              activity.log_create(amo.LOG.DISABLE_VERSION, self.addon, self)
              for file in self.files.exclude(status=amo.STATUS_DISABLED).all():
                  file.update(original_status=file.status,
                              status=amo.STATUS_DISABLED)
          # User wants to re-enable (and user did the disable, not Mozilla).
          else:
              activity.log_create(amo.LOG.ENABLE_VERSION, self.addon, self)
              for file in self.files.exclude(
                      original_status=amo.STATUS_NULL).all():
                  file.update(status=file.original_status,
                              original_status=amo.STATUS_NULL)
  
      @property
      def current_queue(self):
          """Return the current queue, or None if not in a queue."""
          from olympia.reviewers.models import (
              ViewFullReviewQueue, ViewPendingQueue)
  
          if self.channel == amo.RELEASE_CHANNEL_UNLISTED:
              # Unlisted add-ons and their updates are automatically approved so
              # they don't get a queue.
              # TODO: when we've finished with unlisted/listed versions the
              # status of an all-unlisted addon will be STATUS_NULL so we won't
              # need this check.
              return None
  
          if self.addon.status == amo.STATUS_NOMINATED:
              return ViewFullReviewQueue
          elif self.addon.status == amo.STATUS_PUBLIC:
              return ViewPendingQueue
  
          return None
  
      @cached_property
      def all_activity(self):
          from olympia.activity.models import VersionLog  # yucky
          al = (VersionLog.objects.filter(version=self.id).order_by('created')
                .select_related('activity_log', 'version'))
          return al
  
      @property
      def compatible_apps(self):
          # Dicts, search providers and personas don't have compatibility info.
          # Fake one for them.
          if self.addon and self.addon.type in amo.NO_COMPAT:
              return {app: None for app in amo.APP_TYPE_SUPPORT[self.addon.type]}
          # Otherwise, return _compatible_apps which is a cached property that
          # is filled by the transformer, or simply calculated from the related
          # compat instances.
          return self._compatible_apps
  
      @cached_property
      def _compatible_apps(self):
          """Get a mapping of {APP: ApplicationsVersions}."""
          avs = self.apps.select_related('version')
          return self._compat_map(avs)
  
      @cached_property
      def compatible_apps_ordered(self):
          apps = self.compatible_apps.items()
          return sorted(apps, key=lambda v: v[0].short)
  
      def compatible_platforms(self):
          """Returns a dict of compatible file platforms for this version.
  
          The result is based on which app(s) the version targets.
          """
          app_ids = [a.application for a in self.apps.all()]
          targets_mobile = amo.ANDROID.id in app_ids
          targets_other = any((id_ != amo.ANDROID.id) for id_ in app_ids)
          all_plats = {}
          if targets_other:
              all_plats.update(amo.DESKTOP_PLATFORMS)
          if targets_mobile:
              all_plats.update(amo.MOBILE_PLATFORMS)
          return all_plats
  
      @cached_property
      def is_compatible_by_default(self):
          """Returns whether or not the add-on is considered compatible by
          default."""
          # Use self.all_files directly since that's cached and more potentially
          # prefetched through a transformer already
          return not any([
              file for file in self.all_files
              if file.binary_components or file.strict_compatibility])
  
      def is_compatible_app(self, app):
          """Returns True if the provided app passes compatibility conditions."""
          if self.addon.type in amo.NO_COMPAT:
              return True
          appversion = self.compatible_apps.get(app)
          if appversion and app.id in amo.D2C_MIN_VERSIONS:
              return (version_int(appversion.max.version) >=
                      version_int(amo.D2C_MIN_VERSIONS.get(app.id, '*')))
          return False
  
      def compat_override_app_versions(self):
          """Returns the incompatible app versions range(s).
  
          If not ranges, returns empty list.  Otherwise, this will return all
          the app version ranges that this particular version is incompatible
          with.
          """
          overrides = list(self.addon.compatoverride_set.all())
  
          if not overrides:
              return []
  
          app_versions = []
          for co in overrides:
              for range in co.collapsed_ranges():
                  if (version_int(range.min) <= version_int(self.version) <=
                          version_int(range.max)):
                      app_versions.extend([(a.min, a.max) for a in range.apps])
          return app_versions
  
      @cached_property
      def all_files(self):
          """Shortcut for list(self.files.all()).  Heavily cached."""
          return list(self.files.all())
  
      @cached_property
      def supported_platforms(self):
          """Get a list of supported platform names."""
          return list(set(amo.PLATFORMS[f.platform] for f in self.all_files))
  
      @property
      def status(self):
          return [
              f.STATUS_CHOICES.get(f.status, ugettext('[status:%s]') % f.status)
              for f in self.all_files]
  
      @property
      def statuses(self):
          """Unadulterated statuses, good for an API."""
          return [(f.id, f.status) for f in self.all_files]
  
      def is_public(self):
          # To be public, a version must not be deleted, must belong to a public
          # addon, and all its attached files must have public status.
          try:
              return (not self.deleted and self.addon.is_public() and
                      all(f.status == amo.STATUS_PUBLIC for f in self.all_files))
          except ObjectDoesNotExist:
              return False
  
      @property
      def is_restart_required(self):
          return any(file_.is_restart_required for file_ in self.all_files)
  
      @property
      def is_webextension(self):
          return any(file_.is_webextension for file_ in self.all_files)
  
      @property
      def is_mozilla_signed(self):
          """Is the file a special "Mozilla Signed Extension"
  
          See https://wiki.mozilla.org/Add-ons/InternalSigning for more details.
          We use that information to workaround compatibility limits for legacy
          add-ons and to avoid them receiving negative boosts compared to
          WebExtensions.
  
          See https://github.com/mozilla/addons-server/issues/6424
          """
          return all(
              file_.is_mozilla_signed_extension for file_ in self.all_files)
  
      @property
      def has_files(self):
          return bool(self.all_files)
  
      @property
      def is_unreviewed(self):
          return filter(lambda f: f.status in amo.UNREVIEWED_FILE_STATUSES,
                        self.all_files)
  
      @property
      def is_all_unreviewed(self):
          return not bool([f for f in self.all_files if f.status not in
                           amo.UNREVIEWED_FILE_STATUSES])
  
      @property
      def is_jetpack(self):
          return all(f.jetpack_version for f in self.all_files)
  
      @property
      def sources_provided(self):
          return bool(self.source)
  
      @classmethod
      def _compat_map(cls, avs):
          apps = {}
          for av in avs:
              app_id = av.application
              if app_id in amo.APP_IDS:
                  apps[amo.APP_IDS[app_id]] = av
          return apps
  
      @classmethod
      def transformer(cls, versions):
          """Attach all the compatible apps and files to the versions."""
          if not versions:
              return
  
          ids = set(v.id for v in versions)
          avs = (ApplicationsVersions.objects.filter(version__in=ids)
                 .select_related('min', 'max'))
          files = File.objects.filter(version__in=ids)
  
          def rollup(xs):
              groups = sorted_groupby(xs, 'version_id')
              return dict((k, list(vs)) for k, vs in groups)
  
          av_dict, file_dict = rollup(avs), rollup(files)
  
          for version in versions:
              v_id = version.id
              version._compatible_apps = cls._compat_map(av_dict.get(v_id, []))
              version.all_files = file_dict.get(v_id, [])
              for f in version.all_files:
                  f.version = version
  
      @classmethod
      def transformer_activity(cls, versions):
          """Attach all the activity to the versions."""
          from olympia.activity.models import VersionLog  # yucky
  
          ids = set(v.id for v in versions)
          if not versions:
              return
  
          al = (VersionLog.objects.filter(version__in=ids).order_by('created')
                .select_related('activity_log', 'version'))
  
          def rollup(xs):
              groups = sorted_groupby(xs, 'version_id')
              return dict((k, list(vs)) for k, vs in groups)
  
          al_dict = rollup(al)
  
          for version in versions:
              v_id = version.id
              version.all_activity = al_dict.get(v_id, [])
  
      def disable_old_files(self):
          """
          Disable files from versions older than the current one and awaiting
          review. Used when uploading a new version.
  
          Does nothing if the current instance is unlisted.
          """
          if self.channel == amo.RELEASE_CHANNEL_LISTED:
              qs = File.objects.filter(version__addon=self.addon_id,
                                       version__lt=self.id,
                                       version__deleted=False,
                                       status__in=[amo.STATUS_AWAITING_REVIEW,
                                                   amo.STATUS_PENDING])
              # Use File.update so signals are triggered.
              for f in qs:
                  f.update(status=amo.STATUS_DISABLED)
  
      def reset_nomination_time(self, nomination=None):
          if not self.nomination or nomination:
              nomination = nomination or datetime.datetime.now()
              # We need signal=False not to call update_status (which calls us).
              self.update(nomination=nomination, _signal=False)
  
      def inherit_nomination(self, from_statuses=None):
          last_ver = (Version.objects.filter(addon=self.addon,
                                             channel=amo.RELEASE_CHANNEL_LISTED)
                      .exclude(nomination=None).exclude(id=self.pk)
                      .order_by('-nomination'))
          if from_statuses:
              last_ver = last_ver.filter(files__status__in=from_statuses)
          if last_ver.exists():
              self.reset_nomination_time(nomination=last_ver[0].nomination)
  
      @property
      def unreviewed_files(self):
          """A File is unreviewed if its status is amo.STATUS_AWAITING_REVIEW."""
          return self.files.filter(status=amo.STATUS_AWAITING_REVIEW)
  
      @property
      def is_ready_for_auto_approval(self):
          """Return whether or not this version could be *considered* for
          auto-approval.
  
          Does not necessarily mean that it would be auto-approved, just that it
          passes the most basic criteria to be considered a candidate by the
          auto_approve command."""
          return (
              self.addon.status in (amo.STATUS_PUBLIC, amo.STATUS_NOMINATED) and
              self.addon.type in (
                  amo.ADDON_EXTENSION, amo.ADDON_LPAPP, amo.ADDON_DICT) and
              self.is_webextension and
              self.is_unreviewed and
              self.channel == amo.RELEASE_CHANNEL_LISTED)
  
      @property
      def was_auto_approved(self):
          """Return whether or not this version was auto-approved."""
          from olympia.reviewers.models import AutoApprovalSummary
          try:
              return self.is_public() and AutoApprovalSummary.objects.filter(
                  version=self).get().verdict == amo.AUTO_APPROVED
          except AutoApprovalSummary.DoesNotExist:
              pass
          return False
  
      def get_background_images_encoded(self, header_only=False):
          if not self.has_files:
              return {}
          file_obj = self.all_files[0]
          return {
              name: b64encode(background)
              for name, background in utils.get_background_images(
                  file_obj, theme_data=None, header_only=header_only).items()}
  
  
  def generate_static_theme_preview(theme_data, version_pk):
      """This redirection is so we can mock generate_static_theme_preview, where
      needed, in tests."""
      # To avoid a circular import
      from . import tasks
      tasks.generate_static_theme_preview.delay(theme_data, version_pk)
  
  
  class VersionPreview(BasePreview, ModelBase):
      version = models.ForeignKey(Version, related_name='previews')
      position = models.IntegerField(default=0)
      sizes = JSONField(default={})
      media_folder = 'version-previews'
  
      class Meta:
          db_table = 'version_previews'
          ordering = ('position', 'created')
  
      @cached_property
      def caption(self):
          """We only don't support defining a caption for previews because
          they're auto-generated.  This is for compatibility with Addon Preview
          objects. (it's a cached_property so it can be set transparently)"""
          return None
  
  
  models.signals.post_delete.connect(VersionPreview.delete_preview_files,
                                     sender=VersionPreview,
                                     dispatch_uid='delete_preview_files')
  
  
  @use_primary_db
  def update_status(sender, instance, **kw):
      if not kw.get('raw'):
          try:
              instance.addon.reload()
              instance.addon.update_status()
          except models.ObjectDoesNotExist:
              log.info('Got ObjectDoesNotExist processing Version change signal',
                       exc_info=True)
              pass
  
  
  def inherit_nomination(sender, instance, **kw):
      """
      For new versions pending review, ensure nomination date
      is inherited from last nominated version.
      """
      if kw.get('raw'):
          return
      addon = instance.addon
      if (instance.nomination is None and
              addon.status in amo.UNREVIEWED_ADDON_STATUSES):
          instance.inherit_nomination()
  
  
  def update_incompatible_versions(sender, instance, **kw):
      """
      When a new version is added or deleted, send to task to update if it
      matches any compat overrides.
      """
      try:
          if not instance.addon.type == amo.ADDON_EXTENSION:
              return
      except ObjectDoesNotExist:
          return
  
      from olympia.addons import tasks
      tasks.update_incompatible_appversions.delay([instance.id])
  
  
  def cleanup_version(sender, instance, **kw):
      """On delete of the version object call the file delete and signals."""
      if kw.get('raw'):
          return
      for file_ in instance.files.all():
          cleanup_file(file_.__class__, file_)
  
  
  def clear_compatversion_cache_on_save(sender, instance, created, **kw):
      """Clears compatversion cache if new Version created."""
      try:
          if not instance.addon.type == amo.ADDON_EXTENSION:
              return
      except ObjectDoesNotExist:
          return
  
      if not kw.get('raw') and (created or instance.deleted):
          instance.addon.invalidate_d2c_versions()
  
  
  def clear_compatversion_cache_on_delete(sender, instance, **kw):
      """Clears compatversion cache when Version deleted."""
      try:
          if not instance.addon.type == amo.ADDON_EXTENSION:
              return
      except ObjectDoesNotExist:
          return
  
      if not kw.get('raw'):
          instance.addon.invalidate_d2c_versions()
  
  
  version_uploaded = django.dispatch.Signal()
  models.signals.pre_save.connect(
      save_signal, sender=Version, dispatch_uid='version_translations')
  models.signals.post_save.connect(
      update_status, sender=Version, dispatch_uid='version_update_status')
  models.signals.post_save.connect(
      inherit_nomination, sender=Version,
      dispatch_uid='version_inherit_nomination')
  models.signals.post_save.connect(
      update_incompatible_versions, sender=Version,
      dispatch_uid='version_update_incompat')
  models.signals.post_save.connect(
      clear_compatversion_cache_on_save, sender=Version,
      dispatch_uid='clear_compatversion_cache_save')
  
  models.signals.pre_delete.connect(
      cleanup_version, sender=Version, dispatch_uid='cleanup_version')
  models.signals.post_delete.connect(
      update_status, sender=Version, dispatch_uid='version_update_status')
  models.signals.post_delete.connect(
      update_incompatible_versions, sender=Version,
      dispatch_uid='version_update_incompat')
  models.signals.post_delete.connect(
      clear_compatversion_cache_on_delete, sender=Version,
      dispatch_uid='clear_compatversion_cache_del')
  
  
  class LicenseManager(ManagerBase):
      def builtins(self, cc=False):
          return self.filter(
              builtin__gt=0, creative_commons=cc).order_by('builtin')
  
  
  class License(ModelBase):
      OTHER = 0
  
      id = PositiveAutoField(primary_key=True)
      name = TranslatedField()
      url = models.URLField(null=True)
      builtin = models.PositiveIntegerField(default=OTHER)
      text = LinkifiedField()
      on_form = models.BooleanField(
          default=False, help_text='Is this a license choice in the devhub?')
      some_rights = models.BooleanField(
          default=False,
          help_text='Show "Some Rights Reserved" instead of the license name?')
      icons = models.CharField(
          max_length=255, null=True,
          help_text='Space-separated list of icon identifiers.')
      creative_commons = models.BooleanField(default=False)
  
      objects = LicenseManager()
  
      class Meta:
          db_table = 'licenses'
  
      def __unicode__(self):
          license = self._constant or self
          return unicode(license.name)
  
      @property
      def _constant(self):
          return LICENSES_BY_BUILTIN.get(self.builtin)
  
  
  models.signals.pre_save.connect(
      save_signal, sender=License, dispatch_uid='license_translations')
  
  
  class ApplicationsVersions(models.Model):
      id = PositiveAutoField(primary_key=True)
      application = models.PositiveIntegerField(choices=amo.APPS_CHOICES,
                                                db_column='application_id')
      version = models.ForeignKey(
          Version, related_name='apps', on_delete=models.CASCADE)
      min = models.ForeignKey(AppVersion, db_column='min',
                              related_name='min_set')
      max = models.ForeignKey(AppVersion, db_column='max',
                              related_name='max_set')
  
      class Meta:
          db_table = u'applications_versions'
          unique_together = (("application", "version"),)
  
      def get_application_display(self):
          return unicode(amo.APPS_ALL[self.application].pretty)
  
      def __unicode__(self):
          if (self.version.is_compatible_by_default and
                  self.version.is_compatible_app(amo.APP_IDS[self.application])):
              return ugettext(u'{app} {min} and later').format(
                  app=self.get_application_display(),
                  min=self.min
              )
          return u'%s %s - %s' % (self.get_application_display(),
                                  self.min, self.max)
  