Commands to alter Android compatibilty to prepare for general availability (#21190)
* Commands to alter Android compatibilty to prepare for general availability
This commit is contained in:
Родитель
eb127a420f
Коммит
edb43b3743
|
@ -359,6 +359,9 @@ DEFAULT_WEBEXT_MIN_VERSION_GECKO_ANDROID = '113.0'
|
|||
# First version we consider as "Fenix".
|
||||
MIN_VERSION_FENIX = '79.0a1'
|
||||
|
||||
# Last version we consider as "Fennec"
|
||||
MAX_VERSION_FENNEC = '68.*'
|
||||
|
||||
# The minimum version of Fenix where extensions are all available. Expect this
|
||||
# to be bumped to 120.0 later.
|
||||
MIN_VERSION_FENIX_GENERAL_AVAILABILITY = '119.0a1'
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
from django.db.transaction import atomic
|
||||
|
||||
import olympia.core.logger
|
||||
from olympia import amo
|
||||
from olympia.applications.models import AppVersion
|
||||
from olympia.constants.promoted import PROMOTED_GROUPS
|
||||
from olympia.files.models import File
|
||||
from olympia.versions.models import ApplicationsVersions
|
||||
|
||||
|
||||
log = olympia.core.logger.getLogger('z.versions.force_max_android_compatibility')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Force *all* versions of add-ons compatible with Firefox for Android with a
|
||||
minimum version lower than <MIN_VERSION_FENIX_GENERAL_AVAILABILITY> and not
|
||||
recommended or line for Android to have a max version of 68.*, or to have
|
||||
their compatibility with Firefox for Android dropped it their min was
|
||||
higher than 68.* already.
|
||||
"""
|
||||
|
||||
help = (
|
||||
'Force add-ons not already compatible with '
|
||||
f'{amo.MIN_VERSION_FENIX_GENERAL_AVAILABILITY} and higher to be '
|
||||
'compatible with Firefox for Android 68.* or lower'
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
min_version_fenix = AppVersion.objects.get(
|
||||
application=amo.ANDROID.id,
|
||||
version=amo.MIN_VERSION_FENIX_GENERAL_AVAILABILITY,
|
||||
)
|
||||
max_version_fennec = AppVersion.objects.get(
|
||||
application=amo.ANDROID.id, version=amo.MAX_VERSION_FENNEC
|
||||
)
|
||||
promoted_groups_ids = [
|
||||
p.id for p in PROMOTED_GROUPS if p.can_be_compatible_with_fenix
|
||||
]
|
||||
qs = (
|
||||
# We only care about listed extensions already marked as compatible
|
||||
# for Android.
|
||||
ApplicationsVersions.objects.filter(application=amo.ANDROID.id)
|
||||
.filter(version__addon__type=amo.ADDON_EXTENSION)
|
||||
.filter(version__channel=amo.CHANNEL_LISTED)
|
||||
.filter(
|
||||
# They need to be either:
|
||||
Q(version__addon__promotedaddon__isnull=True) # Not promoted at all
|
||||
| ~Q(
|
||||
version__addon__promotedaddon__group_id__in=promoted_groups_ids
|
||||
) # Promoted, but not for line / recommended
|
||||
| Q(
|
||||
version__addon__promotedaddon__application_id=amo.FIREFOX.id
|
||||
) # Promoted, but for Firefox only (not Android / not both)
|
||||
)
|
||||
# If they are already marked as compatible with 119.0a1, we don't
|
||||
# care.
|
||||
.filter(min__version_int__lt=min_version_fenix.version_int)
|
||||
)
|
||||
# If the min is also over 68.* then it means the developer marked it
|
||||
# as compatible with Fenix only but that's unlikely to be correct, so
|
||||
# we drop that compatibility information completely.
|
||||
qs_to_drop = qs.filter(min__version_int__gt=max_version_fennec.version_int)
|
||||
# Otherwise we'll update it, setting the max to 68.*.
|
||||
qs_to_update = qs.exclude(min__version_int__gte=max_version_fennec.version_int)
|
||||
with atomic():
|
||||
count_versions_compat_updated = qs_to_update.update(
|
||||
max=max_version_fennec,
|
||||
originated_from=amo.APPVERSIONS_ORIGINATED_FROM_MIGRATION,
|
||||
)
|
||||
with atomic():
|
||||
count_versions_compat_dropped, _ = qs_to_drop.delete()
|
||||
with atomic():
|
||||
count_files = File.objects.filter(
|
||||
version__apps__application=amo.ANDROID.id,
|
||||
version__apps__max__version=max_version_fennec,
|
||||
).update(strict_compatibility=True)
|
||||
log.info(
|
||||
'Done forcing max Android compatibility: '
|
||||
'Dropped compat on %d versions, '
|
||||
'Updated compat for %d versions, '
|
||||
'Set strict compatibility on %d files',
|
||||
count_versions_compat_dropped,
|
||||
count_versions_compat_updated,
|
||||
count_files,
|
||||
)
|
|
@ -0,0 +1,90 @@
|
|||
import csv
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.transaction import atomic
|
||||
|
||||
import olympia.core.logger
|
||||
from olympia import amo
|
||||
from olympia.addons.models import Addon
|
||||
from olympia.applications.models import AppVersion
|
||||
from olympia.versions.models import ApplicationsVersions
|
||||
|
||||
|
||||
log = olympia.core.logger.getLogger('z.versions.force_min_android_compatibility')
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Force current version of add-ons in the specified csv to be compatible with
|
||||
Firefox for Android <MIN_VERSION_FENIX_GENERAL_AVAILABILITY> and higher.
|
||||
|
||||
Should not affect compatibility of add-ons recommended/line for Android.
|
||||
"""
|
||||
|
||||
help = (
|
||||
'Force add-ons to be compatible with Firefox for Android '
|
||||
f'{amo.MIN_VERSION_FENIX_GENERAL_AVAILABILITY} and higher'
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'CSVFILE',
|
||||
help='Path to CSV file containing add-on ids.',
|
||||
)
|
||||
|
||||
def read_csv(self, path):
|
||||
with open(path) as file_:
|
||||
csv_reader = csv.reader(file_)
|
||||
# Format should be a single column with the add-on id.
|
||||
# Ignore non-decimal to avoid the column header.
|
||||
return [
|
||||
int(row[0])
|
||||
for row in csv_reader
|
||||
if row[0] and row[0].strip().isdecimal()
|
||||
]
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
addon_ids = self.read_csv(kwargs['CSVFILE'])
|
||||
min_version_fenix = AppVersion.objects.get(
|
||||
application=amo.ANDROID.id,
|
||||
version=amo.MIN_VERSION_FENIX_GENERAL_AVAILABILITY,
|
||||
)
|
||||
max_version_fenix = AppVersion.objects.get(
|
||||
application=amo.ANDROID.id, version=amo.DEFAULT_WEBEXT_MAX_VERSION
|
||||
)
|
||||
addons = (
|
||||
Addon.objects.filter(pk__in=addon_ids)
|
||||
.no_transforms()
|
||||
.select_related(
|
||||
'promotedaddon', '_current_version', '_current_version__file'
|
||||
)
|
||||
)
|
||||
count = 0
|
||||
skipped = 0
|
||||
for addon in addons:
|
||||
if (
|
||||
addon.promoted
|
||||
and addon.promoted.group.can_be_compatible_with_fenix
|
||||
and amo.ANDROID in addon.promoted.approved_applications
|
||||
):
|
||||
log.info(
|
||||
'Skipping add-on id %d because of its promoted group.', addon.pk
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
with atomic():
|
||||
ApplicationsVersions.objects.update_or_create(
|
||||
version=addon.current_version,
|
||||
application=amo.ANDROID.id,
|
||||
defaults={
|
||||
'min': min_version_fenix,
|
||||
'max': max_version_fenix,
|
||||
'originated_from': amo.APPVERSIONS_ORIGINATED_FROM_MIGRATION,
|
||||
},
|
||||
)
|
||||
count += 1
|
||||
log.info(
|
||||
'Done forcing Android compatibility on %d add-ons (%d skipped)',
|
||||
count,
|
||||
skipped,
|
||||
)
|
|
@ -0,0 +1,367 @@
|
|||
import csv
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from olympia import amo
|
||||
from olympia.amo.tests import TestCase, addon_factory
|
||||
from olympia.applications.models import AppVersion
|
||||
from olympia.constants.promoted import LINE, RECOMMENDED, SPOTLIGHT
|
||||
from olympia.promoted.models import PromotedAddon
|
||||
from olympia.versions.management.commands.force_min_android_compatibility import (
|
||||
Command as ForceMinAndroidCompatibility,
|
||||
)
|
||||
from olympia.versions.models import ApplicationsVersions
|
||||
|
||||
|
||||
class TestForceMinAndroidCompatibility(TestCase):
|
||||
def setUp(self):
|
||||
self.min_version_fenix = AppVersion.objects.get_or_create(
|
||||
application=amo.ANDROID.id,
|
||||
version=amo.MIN_VERSION_FENIX_GENERAL_AVAILABILITY,
|
||||
)[0]
|
||||
self.max_version_fenix = AppVersion.objects.get_or_create(
|
||||
application=amo.ANDROID.id, version=amo.DEFAULT_WEBEXT_MAX_VERSION
|
||||
)[0]
|
||||
|
||||
def _create_csv(self, contents):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w', suffix='.csv', delete=False
|
||||
) as csv_file:
|
||||
self.addCleanup(os.remove, csv_file.name)
|
||||
writer = csv.writer(csv_file)
|
||||
writer.writerows(contents)
|
||||
return csv_file.name
|
||||
|
||||
def test_missing_csv_path(self):
|
||||
with self.assertRaises(CommandError):
|
||||
call_command('force_min_android_compatibility')
|
||||
|
||||
def test_init_csv_parsing(self):
|
||||
file_working_name = self._create_csv(
|
||||
[['addon_id'], ['123456789'], ['4815162342'], ['007'], ['42'], [' 57 ']]
|
||||
)
|
||||
command = ForceMinAndroidCompatibility()
|
||||
assert command.read_csv(file_working_name) == [123456789, 4815162342, 7, 42, 57]
|
||||
|
||||
def test_full(self):
|
||||
addons_to_modify = [
|
||||
addon_factory(name='Not yet compatible with Android'),
|
||||
addon_factory(
|
||||
name='Already compatible with Android with strict_compatibility',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '68.*',
|
||||
},
|
||||
file_kw={'strict_compatibility': True},
|
||||
),
|
||||
addon_factory(
|
||||
name='Already compatible with Android',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '120.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
),
|
||||
]
|
||||
addons_to_ignore_promoted = [
|
||||
addon_factory(
|
||||
name='Recommended for Android',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=RECOMMENDED,
|
||||
),
|
||||
addon_factory(
|
||||
name='Line for all',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=LINE,
|
||||
),
|
||||
]
|
||||
addons_to_ignore_not_in_csv = [addon_factory(name='Not in csv')]
|
||||
csv_path = self._create_csv(
|
||||
[
|
||||
['addon_id'],
|
||||
*[[str(addon.pk)] for addon in addons_to_modify],
|
||||
*[[str(addon.pk) for addon in addons_to_ignore_promoted]],
|
||||
]
|
||||
)
|
||||
|
||||
call_command('force_min_android_compatibility', csv_path)
|
||||
|
||||
for addon in addons_to_modify:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID in addon.current_version.compatible_apps
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].min.version
|
||||
== '119.0a1'
|
||||
)
|
||||
assert addon.current_version.compatible_apps[amo.ANDROID].max.version == '*'
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_MIGRATION
|
||||
)
|
||||
assert not addon.current_version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons_to_ignore_promoted:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID in addon.current_version.compatible_apps
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].min.version == '48.0'
|
||||
)
|
||||
assert addon.current_version.compatible_apps[amo.ANDROID].max.version == '*'
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_UNKNOWN
|
||||
)
|
||||
assert not addon.current_version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons_to_ignore_not_in_csv:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID not in addon.current_version.compatible_apps
|
||||
|
||||
|
||||
class TestForceMaxAndroidCompatibility(TestCase):
|
||||
def setUp(self):
|
||||
self.min_version_fenix = AppVersion.objects.get_or_create(
|
||||
application=amo.ANDROID.id,
|
||||
version=amo.MIN_VERSION_FENIX_GENERAL_AVAILABILITY,
|
||||
)[0]
|
||||
self.max_version_fenix = AppVersion.objects.get_or_create(
|
||||
application=amo.ANDROID.id, version=amo.MAX_VERSION_FENNEC
|
||||
)[0]
|
||||
self.some_fenix_version = AppVersion.objects.get_or_create(
|
||||
application=amo.ANDROID.id, version='99.0'
|
||||
)[0]
|
||||
|
||||
def test_full(self):
|
||||
addons_to_ignore_promoted = [
|
||||
addon_factory(
|
||||
# Actually recommended for both apps to start with, modified
|
||||
# below to be only for Android.
|
||||
name='Recommended for Android',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=RECOMMENDED,
|
||||
),
|
||||
addon_factory(
|
||||
name='Recommended for All',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=RECOMMENDED,
|
||||
),
|
||||
addon_factory(
|
||||
name='Line for all',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=LINE,
|
||||
),
|
||||
]
|
||||
addons_to_ignore_119 = [
|
||||
addon_factory(
|
||||
name='Recommended for Android 119',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '119.0a1',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=RECOMMENDED,
|
||||
),
|
||||
addon_factory(
|
||||
name='Normal for Android 119',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '119.0a1',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=RECOMMENDED,
|
||||
),
|
||||
]
|
||||
addons_to_ignore_not_even_listed_extension = [
|
||||
addon_factory(
|
||||
name='Theme for Android (!)',
|
||||
type=amo.ADDON_STATICTHEME,
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
),
|
||||
addon_factory(
|
||||
name='Unlisted Extension for Android',
|
||||
version_kw={
|
||||
'channel': amo.CHANNEL_UNLISTED,
|
||||
# Can't be set like that, fixed below.
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
),
|
||||
]
|
||||
addons_to_ignore_not_even_compatible_with_android = [
|
||||
addon_factory(
|
||||
name='Extension for Firefox',
|
||||
version_kw={
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
),
|
||||
]
|
||||
addons = [
|
||||
addon_factory(
|
||||
name='Normal extension for Android 48',
|
||||
version_kw={
|
||||
# Can't be set like that, fixed below.
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
),
|
||||
addon_factory(
|
||||
# Actually recommended for both apps to start with, modified
|
||||
# below to be only for Desktop.
|
||||
name='Recommended extension for Desktop compatible with Android 48',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=RECOMMENDED,
|
||||
),
|
||||
addon_factory(
|
||||
name='Spotlight extension compatible with Android 48',
|
||||
version_kw={
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
promoted=SPOTLIGHT,
|
||||
),
|
||||
]
|
||||
addons_to_drop = [
|
||||
addon_factory(
|
||||
name='Normal extension for Android 99.0',
|
||||
version_kw={
|
||||
# Can't be set like that, fixed below.
|
||||
'application': amo.ANDROID.id,
|
||||
'min_app_version': '48.0',
|
||||
'max_app_version': '*',
|
||||
},
|
||||
),
|
||||
]
|
||||
# Manually update the promoted add-ons we want to only recommend for a
|
||||
# single app..
|
||||
PromotedAddon.objects.get(addon=addons_to_ignore_promoted[0]).update(
|
||||
application_id=amo.ANDROID.id
|
||||
)
|
||||
PromotedAddon.objects.get(addon=addons[1]).update(application_id=amo.FIREFOX.id)
|
||||
# Directly creating an add-on compatible with Firefox for Android 99.0
|
||||
# is no longer possible without being recommended, so manually update
|
||||
# some ApplicationsVersions that we couldn't set.
|
||||
ApplicationsVersions.objects.filter(
|
||||
pk=addons_to_drop[0].current_version.compatible_apps[amo.ANDROID].pk
|
||||
).update(min=self.some_fenix_version)
|
||||
ApplicationsVersions.objects.filter(
|
||||
pk=addons_to_ignore_not_even_listed_extension[1]
|
||||
.versions.get()
|
||||
.compatible_apps[amo.ANDROID]
|
||||
.pk
|
||||
).update(min=AppVersion.objects.get(application=amo.ANDROID.id, version='48.0'))
|
||||
ApplicationsVersions.objects.filter(version__addon__in=addons).update(
|
||||
min=AppVersion.objects.get(application=amo.ANDROID.id, version='48.0')
|
||||
)
|
||||
|
||||
call_command('force_max_android_compatibility')
|
||||
|
||||
for addon in addons_to_ignore_promoted:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID in addon.current_version.compatible_apps
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].min.version == '48.0'
|
||||
)
|
||||
assert addon.current_version.compatible_apps[amo.ANDROID].max.version == '*'
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_UNKNOWN
|
||||
)
|
||||
assert not addon.current_version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons_to_ignore_119:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID in addon.current_version.compatible_apps
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].min.version
|
||||
== '119.0a1'
|
||||
)
|
||||
assert addon.current_version.compatible_apps[amo.ANDROID].max.version == '*'
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_UNKNOWN
|
||||
)
|
||||
assert not addon.current_version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons_to_ignore_not_even_listed_extension:
|
||||
version = addon.versions.get()
|
||||
assert amo.ANDROID in version.compatible_apps
|
||||
assert version.compatible_apps[amo.ANDROID].min.version == '48.0'
|
||||
assert version.compatible_apps[amo.ANDROID].max.version == '*'
|
||||
assert (
|
||||
version.compatible_apps[amo.ANDROID].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_UNKNOWN
|
||||
)
|
||||
assert not version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons_to_ignore_not_even_compatible_with_android:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID not in addon.current_version.compatible_apps
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.FIREFOX].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_UNKNOWN
|
||||
)
|
||||
assert not addon.current_version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID in addon.current_version.compatible_apps
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].min.version == '48.0'
|
||||
)
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].max.version == '68.*'
|
||||
)
|
||||
assert (
|
||||
addon.current_version.compatible_apps[amo.ANDROID].originated_from
|
||||
== amo.APPVERSIONS_ORIGINATED_FROM_MIGRATION
|
||||
)
|
||||
assert addon.current_version.file.reload().strict_compatibility
|
||||
|
||||
for addon in addons_to_drop:
|
||||
if hasattr(addon.current_version, '_compatible_apps'):
|
||||
del addon.current_version._compatible_apps
|
||||
assert amo.ANDROID not in addon.current_version.compatible_apps
|
||||
assert not addon.current_version.file.reload().strict_compatibility
|
Загрузка…
Ссылка в новой задаче