2010-02-25 20:00:03 +03:00
|
|
|
# -*- coding: utf-8 -*-
|
2010-11-12 19:51:52 +03:00
|
|
|
import os
|
2010-04-14 02:00:03 +04:00
|
|
|
|
2010-11-12 19:51:52 +03:00
|
|
|
from django.conf import settings
|
2009-10-23 02:38:11 +04:00
|
|
|
from django.db import models
|
2011-04-13 04:36:55 +04:00
|
|
|
import django.dispatch
|
2010-12-16 23:01:15 +03:00
|
|
|
import jinja2
|
2009-10-23 02:38:11 +04:00
|
|
|
|
2010-11-12 20:42:36 +03:00
|
|
|
import commonware.log
|
2010-02-04 08:13:44 +03:00
|
|
|
import caching.base
|
|
|
|
|
2010-11-04 23:22:57 +03:00
|
|
|
import amo
|
2010-01-29 04:59:26 +03:00
|
|
|
import amo.models
|
2011-01-12 23:03:39 +03:00
|
|
|
import amo.utils
|
2010-11-04 23:22:57 +03:00
|
|
|
from amo.urlresolvers import reverse
|
2010-01-23 03:52:41 +03:00
|
|
|
from applications.models import Application, AppVersion
|
2010-11-20 00:27:56 +03:00
|
|
|
from files import utils
|
2010-12-09 02:18:30 +03:00
|
|
|
from files.models import File, Platform
|
2010-10-05 04:06:39 +04:00
|
|
|
from translations.fields import (TranslatedField, PurifiedField,
|
|
|
|
LinkifiedField)
|
2010-02-04 08:13:44 +03:00
|
|
|
from users.models import UserProfile
|
2009-10-23 02:38:11 +04:00
|
|
|
|
2010-02-25 21:46:19 +03:00
|
|
|
from . import compare
|
|
|
|
|
2010-11-12 20:42:36 +03:00
|
|
|
log = commonware.log.getLogger('z.versions')
|
|
|
|
|
2009-10-23 02:38:11 +04:00
|
|
|
|
2010-01-29 04:59:26 +03:00
|
|
|
class Version(amo.models.ModelBase):
|
2010-04-14 02:00:03 +04:00
|
|
|
addon = models.ForeignKey('addons.Addon', related_name='versions')
|
2010-01-20 20:45:05 +03:00
|
|
|
license = models.ForeignKey('License', null=True)
|
2010-02-23 22:47:16 +03:00
|
|
|
releasenotes = PurifiedField()
|
2010-03-06 04:37:13 +03:00
|
|
|
approvalnotes = models.TextField(default='', null=True)
|
2010-11-20 06:18:56 +03:00
|
|
|
version = models.CharField(max_length=255, default='0.1')
|
2011-02-23 02:10:45 +03:00
|
|
|
version_int = models.BigIntegerField(null=True, editable=False)
|
2009-10-23 02:38:11 +04:00
|
|
|
|
2011-03-08 00:19:23 +03:00
|
|
|
nomination = models.DateTimeField(null=True)
|
2011-03-03 01:52:57 +03:00
|
|
|
reviewed = models.DateTimeField(null=True)
|
|
|
|
|
2010-01-29 04:59:26 +03:00
|
|
|
class Meta(amo.models.ModelBase.Meta):
|
2009-10-23 02:38:11 +04:00
|
|
|
db_table = 'versions'
|
2010-03-04 00:30:00 +03:00
|
|
|
ordering = ['-created', '-modified']
|
2010-02-02 23:22:24 +03:00
|
|
|
|
2010-02-25 21:46:19 +03:00
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
super(Version, self).__init__(*args, **kwargs)
|
|
|
|
self.__dict__.update(compare.version_dict(self.version or ''))
|
|
|
|
|
2010-02-18 02:56:17 +03:00
|
|
|
def __unicode__(self):
|
2010-12-16 23:01:15 +03:00
|
|
|
return jinja2.escape(self.version)
|
2010-02-18 02:56:17 +03:00
|
|
|
|
2011-02-23 02:10:45 +03:00
|
|
|
def save(self, *args, **kw):
|
|
|
|
if not self.version_int and self.version:
|
|
|
|
version_int = compare.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 version_int < 9223372036854775807:
|
|
|
|
self.version_int = version_int
|
|
|
|
else:
|
|
|
|
log.error('No version_int written for version %s, %s' %
|
|
|
|
(self.pk, self.version))
|
|
|
|
return super(Version, self).save(*args, **kw)
|
|
|
|
|
2010-11-12 20:42:36 +03:00
|
|
|
@classmethod
|
2011-01-04 21:13:24 +03:00
|
|
|
def from_upload(cls, upload, addon, platforms):
|
2010-12-09 02:18:30 +03:00
|
|
|
data = utils.parse_addon(upload.path, addon)
|
2010-11-16 04:47:15 +03:00
|
|
|
try:
|
|
|
|
license = addon.versions.latest().license_id
|
|
|
|
except Version.DoesNotExist:
|
|
|
|
license = None
|
|
|
|
v = cls.objects.create(addon=addon, version=data['version'],
|
|
|
|
license_id=license)
|
2010-11-12 20:42:36 +03:00
|
|
|
log.debug('New version: %r (%s) from %r' % (v, v.id, upload))
|
|
|
|
# appversions
|
|
|
|
AV = ApplicationsVersions
|
2010-12-09 02:18:30 +03:00
|
|
|
for app in data.get('apps', []):
|
2010-11-12 20:42:36 +03:00
|
|
|
AV(version=v, min=app.min, max=app.max,
|
|
|
|
application_id=app.id).save()
|
2010-12-09 02:18:30 +03:00
|
|
|
if addon.type == amo.ADDON_SEARCH:
|
2011-01-05 03:37:26 +03:00
|
|
|
# Search extensions are always for all platforms.
|
|
|
|
platforms = [Platform.objects.get(id=amo.PLATFORM_ALL.id)]
|
2011-03-24 21:48:57 +03:00
|
|
|
else:
|
2011-04-15 00:26:08 +04:00
|
|
|
platforms = cls._make_safe_platform_files(platforms)
|
2011-03-24 21:48:57 +03:00
|
|
|
|
2011-01-04 21:13:24 +03:00
|
|
|
for platform in platforms:
|
|
|
|
File.from_upload(upload, v, platform, parse_data=data)
|
2011-03-22 03:27:48 +03:00
|
|
|
|
|
|
|
v.disable_old_files()
|
2011-01-04 21:13:24 +03:00
|
|
|
# After the upload has been copied to all
|
|
|
|
# platforms, remove the upload.
|
|
|
|
upload.path.unlink()
|
2011-04-13 04:36:55 +04:00
|
|
|
version_uploaded.send(sender=v)
|
2010-11-12 20:42:36 +03:00
|
|
|
return v
|
|
|
|
|
2011-04-15 00:26:08 +04:00
|
|
|
@classmethod
|
|
|
|
def _make_safe_platform_files(cls, platforms):
|
|
|
|
"""Make file platform translations until all download pages
|
|
|
|
support desktop ALL + mobile ALL. See bug 646268.
|
|
|
|
"""
|
|
|
|
pl_set = set([p.id for p in platforms])
|
|
|
|
|
|
|
|
if pl_set == set([amo.PLATFORM_ALL_MOBILE.id, amo.PLATFORM_ALL.id]):
|
|
|
|
# Make it really ALL:
|
|
|
|
return [Platform.objects.get(id=amo.PLATFORM_ALL.id)]
|
|
|
|
|
|
|
|
has_mobile = any(p in amo.MOBILE_PLATFORMS for p in pl_set)
|
|
|
|
has_desktop = any(p in amo.DESKTOP_PLATFORMS for p in pl_set)
|
|
|
|
has_all = any(p in (amo.PLATFORM_ALL_MOBILE.id,
|
|
|
|
amo.PLATFORM_ALL.id) for p in pl_set)
|
|
|
|
is_mixed = has_mobile and has_desktop
|
|
|
|
if (is_mixed and has_all) or has_mobile:
|
|
|
|
# Mixing desktop and mobile w/ ALL is not safe;
|
|
|
|
# we have to split the files into exact platforms.
|
|
|
|
# Additionally, it is not safe to use all-mobile.
|
|
|
|
new_plats = []
|
|
|
|
for p in platforms:
|
|
|
|
if p.id == amo.PLATFORM_ALL_MOBILE.id:
|
|
|
|
new_plats.extend(list(Platform.objects
|
|
|
|
.filter(id__in=amo.MOBILE_PLATFORMS)
|
|
|
|
.exclude(id=amo.PLATFORM_ALL_MOBILE.id)))
|
|
|
|
elif p.id == amo.PLATFORM_ALL.id:
|
|
|
|
new_plats.extend(list(Platform.objects
|
|
|
|
.filter(id__in=amo.DESKTOP_PLATFORMS)
|
|
|
|
.exclude(id=amo.PLATFORM_ALL.id)))
|
|
|
|
else:
|
|
|
|
new_plats.append(p)
|
|
|
|
return new_plats
|
|
|
|
|
|
|
|
# Platforms are safe as is
|
|
|
|
return platforms
|
|
|
|
|
2010-11-12 19:51:52 +03:00
|
|
|
@property
|
|
|
|
def path_prefix(self):
|
|
|
|
return os.path.join(settings.ADDONS_PATH, str(self.addon_id))
|
|
|
|
|
2011-01-28 01:53:53 +03:00
|
|
|
@property
|
|
|
|
def mirror_path_prefix(self):
|
|
|
|
return os.path.join(settings.MIRROR_STAGE_PATH, str(self.addon_id))
|
|
|
|
|
2010-10-23 04:30:58 +04:00
|
|
|
def license_url(self):
|
2010-12-20 23:47:01 +03:00
|
|
|
return reverse('addons.license', args=[self.addon.slug, self.version])
|
2010-10-23 04:30:58 +04:00
|
|
|
|
2010-08-13 20:24:36 +04:00
|
|
|
def flush_urls(self):
|
|
|
|
return self.addon.flush_urls()
|
|
|
|
|
2010-11-06 00:48:05 +03:00
|
|
|
def get_url_path(self):
|
2010-12-20 23:47:01 +03:00
|
|
|
return reverse('addons.versions', args=[self.addon.slug, self.version])
|
2010-11-06 00:48:05 +03:00
|
|
|
|
2010-11-04 23:22:57 +03:00
|
|
|
def delete(self):
|
2010-11-20 00:51:36 +03:00
|
|
|
amo.log(amo.LOG.DELETE_VERSION, self.addon, str(self.version))
|
2010-11-04 23:22:57 +03:00
|
|
|
super(Version, self).delete()
|
|
|
|
|
2010-04-14 02:00:03 +04:00
|
|
|
@amo.cached_property(writable=True)
|
2010-02-04 09:15:02 +03:00
|
|
|
def compatible_apps(self):
|
|
|
|
"""Get a mapping of {APP: ApplicationVersion}."""
|
2010-04-17 03:19:49 +04:00
|
|
|
avs = self.apps.select_related(depth=1)
|
2010-04-14 02:00:03 +04:00
|
|
|
return self._compat_map(avs)
|
|
|
|
|
2011-02-24 22:01:35 +03:00
|
|
|
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.
|
|
|
|
"""
|
|
|
|
apps = set([a.application.id for a in self.apps.all()])
|
|
|
|
targets_mobile = amo.MOBILE.id in apps
|
|
|
|
targets_other = any((a != amo.MOBILE.id) for a in apps)
|
|
|
|
all_plats = {}
|
|
|
|
if targets_other:
|
2011-03-24 21:48:57 +03:00
|
|
|
all_plats.update(amo.DESKTOP_PLATFORMS)
|
2011-02-24 22:01:35 +03:00
|
|
|
if targets_mobile:
|
|
|
|
all_plats.update(amo.MOBILE_PLATFORMS)
|
|
|
|
return all_plats
|
|
|
|
|
2010-04-14 02:00:03 +04:00
|
|
|
@amo.cached_property(writable=True)
|
|
|
|
def all_files(self):
|
|
|
|
"""Shortcut for list(self.files.all()). Heavily cached."""
|
|
|
|
return list(self.files.all())
|
2010-02-04 09:15:02 +03:00
|
|
|
|
|
|
|
# TODO(jbalogh): Do we want names or Platforms?
|
2010-02-06 01:45:23 +03:00
|
|
|
@amo.cached_property
|
2010-02-04 09:15:02 +03:00
|
|
|
def supported_platforms(self):
|
|
|
|
"""Get a list of supported platform names."""
|
2010-03-04 00:30:00 +03:00
|
|
|
return list(set(amo.PLATFORMS[f.platform_id]
|
2010-04-14 02:00:03 +04:00
|
|
|
for f in self.all_files))
|
2009-12-31 03:57:36 +03:00
|
|
|
|
2010-11-30 02:36:12 +03:00
|
|
|
def is_allowed_upload(self):
|
|
|
|
"""Check that a file can be uploaded based on the files
|
|
|
|
per platform for that type of addon."""
|
2011-03-24 21:48:57 +03:00
|
|
|
num_files = len(self.all_files)
|
2010-11-30 02:36:12 +03:00
|
|
|
if self.addon.type == amo.ADDON_SEARCH:
|
2011-03-24 21:48:57 +03:00
|
|
|
return num_files == 0
|
|
|
|
elif num_files == 0:
|
|
|
|
return True
|
2010-12-21 01:45:02 +03:00
|
|
|
elif amo.PLATFORM_ALL in self.supported_platforms:
|
|
|
|
return False
|
2011-03-24 21:48:57 +03:00
|
|
|
elif amo.PLATFORM_ALL_MOBILE in self.supported_platforms:
|
|
|
|
return False
|
2010-11-30 02:36:12 +03:00
|
|
|
else:
|
2011-03-24 21:48:57 +03:00
|
|
|
compatible = (v for k, v in self.compatible_platforms().items()
|
|
|
|
if k not in (amo.PLATFORM_ALL.id,
|
|
|
|
amo.PLATFORM_ALL_MOBILE.id))
|
|
|
|
return bool(set(compatible) - set(self.supported_platforms))
|
2010-11-30 02:36:12 +03:00
|
|
|
|
2011-01-05 22:44:27 +03:00
|
|
|
@property
|
2010-03-02 16:34:07 +03:00
|
|
|
def has_files(self):
|
2010-04-14 02:00:03 +04:00
|
|
|
return bool(self.all_files)
|
2010-03-02 16:34:07 +03:00
|
|
|
|
2011-01-05 22:44:27 +03:00
|
|
|
@property
|
2010-03-09 22:50:56 +03:00
|
|
|
def is_unreviewed(self):
|
2010-06-24 20:22:48 +04:00
|
|
|
return filter(lambda f: f.status in amo.UNREVIEWED_STATUSES,
|
2010-04-14 02:00:03 +04:00
|
|
|
self.all_files)
|
|
|
|
|
2011-05-05 05:18:27 +04:00
|
|
|
@property
|
|
|
|
def is_all_unreviewed(self):
|
|
|
|
return not bool([f for f in self.all_files if f.status not in
|
|
|
|
amo.UNREVIEWED_STATUSES])
|
|
|
|
|
2011-01-05 22:44:27 +03:00
|
|
|
@property
|
2010-06-23 22:44:16 +04:00
|
|
|
def is_beta(self):
|
2011-01-05 22:44:27 +03:00
|
|
|
return filter(lambda f: f.status == amo.STATUS_BETA, self.all_files)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def is_lite(self):
|
|
|
|
return filter(lambda f: f.status in amo.LITE_STATUSES, self.all_files)
|
2010-06-23 22:44:16 +04:00
|
|
|
|
2011-05-17 01:47:53 +04:00
|
|
|
@property
|
|
|
|
def is_jetpack(self):
|
|
|
|
return all(f.jetpack for f in self.all_files)
|
|
|
|
|
2010-04-14 02:00:03 +04:00
|
|
|
@classmethod
|
|
|
|
def _compat_map(cls, avs):
|
|
|
|
apps = {}
|
|
|
|
for av in avs:
|
|
|
|
app_id = av.application_id
|
|
|
|
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."""
|
2011-01-12 23:03:39 +03:00
|
|
|
ids = set(v.id for v in versions)
|
2010-04-14 02:00:03 +04:00
|
|
|
if not versions:
|
|
|
|
return
|
|
|
|
|
2011-01-12 23:03:39 +03:00
|
|
|
avs = (ApplicationsVersions.objects.filter(version__in=ids)
|
2011-01-13 22:34:13 +03:00
|
|
|
.select_related(depth=1).no_cache())
|
|
|
|
files = (File.objects.filter(version__in=ids)
|
|
|
|
.select_related('version').no_cache())
|
2010-04-14 02:00:03 +04:00
|
|
|
|
|
|
|
def rollup(xs):
|
2011-01-12 23:03:39 +03:00
|
|
|
groups = amo.utils.sorted_groupby(xs, 'version_id')
|
2010-04-14 02:00:03 +04:00
|
|
|
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, [])
|
2010-03-09 22:50:56 +03:00
|
|
|
|
2011-03-22 03:27:48 +03:00
|
|
|
def disable_old_files(self):
|
|
|
|
if not self.files.filter(status=amo.STATUS_BETA).exists():
|
|
|
|
qs = File.objects.filter(version__addon=self.addon_id,
|
|
|
|
version__lt=self,
|
|
|
|
status=amo.STATUS_UNREVIEWED)
|
|
|
|
# Use File.update so signals are triggered.
|
|
|
|
for f in qs:
|
|
|
|
f.update(status=amo.STATUS_DISABLED)
|
|
|
|
|
2009-12-31 03:57:36 +03:00
|
|
|
|
2010-12-03 02:35:53 +03:00
|
|
|
def update_status(sender, instance, **kw):
|
|
|
|
if not kw.get('raw'):
|
|
|
|
try:
|
|
|
|
instance.addon.update_status(using='default')
|
2011-02-26 04:12:11 +03:00
|
|
|
instance.addon.update_version()
|
2010-12-03 02:35:53 +03:00
|
|
|
except models.ObjectDoesNotExist:
|
|
|
|
pass
|
2011-02-11 04:17:31 +03:00
|
|
|
|
|
|
|
|
2011-04-16 00:56:08 +04:00
|
|
|
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
|
|
|
|
if (instance.nomination is None
|
|
|
|
and instance.addon.status in (amo.STATUS_NOMINATED,
|
|
|
|
amo.STATUS_LITE_AND_NOMINATED)
|
|
|
|
and not instance.is_beta):
|
|
|
|
last_ver = (Version.objects.filter(addon=instance.addon)
|
|
|
|
.exclude(nomination=None).order_by('-nomination'))
|
|
|
|
if last_ver.exists():
|
|
|
|
instance.update(nomination=last_ver[0].nomination)
|
|
|
|
|
|
|
|
|
2011-04-13 04:36:55 +04:00
|
|
|
version_uploaded = django.dispatch.Signal()
|
2010-12-03 02:35:53 +03:00
|
|
|
models.signals.post_save.connect(update_status, sender=Version,
|
|
|
|
dispatch_uid='version_update_status')
|
2011-04-16 00:56:08 +04:00
|
|
|
models.signals.post_save.connect(inherit_nomination, sender=Version,
|
|
|
|
dispatch_uid='version_inherit_nomination')
|
2010-12-03 02:35:53 +03:00
|
|
|
models.signals.post_delete.connect(update_status, sender=Version,
|
|
|
|
dispatch_uid='version_update_status')
|
|
|
|
|
|
|
|
|
2010-10-05 04:06:39 +04:00
|
|
|
class LicenseManager(amo.models.ManagerBase):
|
2010-03-01 22:21:09 +03:00
|
|
|
|
2010-10-05 04:06:39 +04:00
|
|
|
def builtins(self):
|
|
|
|
return self.filter(builtin__gt=0).order_by('builtin')
|
2009-12-31 03:57:36 +03:00
|
|
|
|
2010-10-05 04:06:39 +04:00
|
|
|
|
|
|
|
class License(amo.models.ModelBase):
|
|
|
|
OTHER = 0
|
|
|
|
|
|
|
|
name = TranslatedField(db_column='name')
|
2010-12-31 00:30:32 +03:00
|
|
|
url = models.URLField(null=True, verify_exists=False)
|
2010-10-05 04:06:39 +04:00
|
|
|
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.')
|
|
|
|
|
|
|
|
objects = LicenseManager()
|
|
|
|
|
|
|
|
class Meta:
|
2009-12-31 03:57:36 +03:00
|
|
|
db_table = 'licenses'
|
2010-01-14 03:01:23 +03:00
|
|
|
|
2010-03-01 22:21:09 +03:00
|
|
|
def __unicode__(self):
|
2010-12-01 20:56:49 +03:00
|
|
|
return unicode(self.name)
|
2010-03-01 22:21:09 +03:00
|
|
|
|
2010-01-14 03:01:23 +03:00
|
|
|
|
2010-01-29 04:59:26 +03:00
|
|
|
class VersionComment(amo.models.ModelBase):
|
2010-01-14 03:01:23 +03:00
|
|
|
"""Editor comments for version discussion threads."""
|
|
|
|
version = models.ForeignKey(Version)
|
2010-01-18 13:51:34 +03:00
|
|
|
user = models.ForeignKey(UserProfile)
|
2010-08-05 00:25:09 +04:00
|
|
|
reply_to = models.ForeignKey(Version, related_name="reply_to",
|
|
|
|
db_column='reply_to', null=True)
|
2010-01-14 03:01:23 +03:00
|
|
|
subject = models.CharField(max_length=1000)
|
|
|
|
comment = models.TextField()
|
|
|
|
|
2010-01-29 04:59:26 +03:00
|
|
|
class Meta(amo.models.ModelBase.Meta):
|
2010-01-14 03:01:23 +03:00
|
|
|
db_table = 'versioncomments'
|
|
|
|
|
|
|
|
|
2010-02-04 08:13:44 +03:00
|
|
|
class ApplicationsVersions(caching.base.CachingMixin, models.Model):
|
2010-01-23 03:52:41 +03:00
|
|
|
|
|
|
|
application = models.ForeignKey(Application)
|
2010-04-17 03:19:49 +04:00
|
|
|
version = models.ForeignKey(Version, related_name='apps')
|
2010-01-27 01:44:12 +03:00
|
|
|
min = models.ForeignKey(AppVersion, db_column='min',
|
|
|
|
related_name='min_set')
|
|
|
|
max = models.ForeignKey(AppVersion, db_column='max',
|
|
|
|
related_name='max_set')
|
2010-01-23 03:52:41 +03:00
|
|
|
|
2010-02-04 08:13:44 +03:00
|
|
|
objects = caching.base.CachingManager()
|
|
|
|
|
2010-01-23 03:52:41 +03:00
|
|
|
class Meta:
|
|
|
|
db_table = u'applications_versions'
|
2010-02-02 23:22:24 +03:00
|
|
|
unique_together = (("application", "version"),)
|
2010-02-25 20:00:03 +03:00
|
|
|
|
|
|
|
def __unicode__(self):
|
2010-06-18 22:24:18 +04:00
|
|
|
return u'%s %s - %s' % (self.application, self.min, self.max)
|