diff --git a/apps/collection/management/commands/check_guide_dependencies.py b/apps/collection/management/commands/check_guide_dependencies.py new file mode 100644 index 00000000..1f2afb9d --- /dev/null +++ b/apps/collection/management/commands/check_guide_dependencies.py @@ -0,0 +1,120 @@ +import re +from pprint import pprint + +from django.core.management.base import BaseCommand +from tqdm import tqdm + +from establishment.models import Establishment +from location.models import City +from location.models import WineRegion +from product.models import Product +from review.models import Review +from tag.models import Tag +from transfer.models import GuideElements + + +def decorator(f): + def decorate(self): + print(f'{"-"*20}start {f.__name__}{"-"*20}') + f(self) + print(f'{"-"*20}end {f.__name__}{"-"*20}\n') + return decorate + + +class Command(BaseCommand): + help = """Check guide dependencies.""" + + @decorator + def count_of_guide_relative_dependencies(self): + for field in GuideElements._meta.fields: + if field.name not in ['id', 'lft', 'rgt', 'depth', + 'children_count', 'parent', 'order_number']: + filters = {f'{field.name}__isnull': False, } + qs = GuideElements.objects.filter(**filters).values_list(field.name, flat=True) + print(f"COUNT OF {field.name}'s: {len(set(qs))}") + + @decorator + def check_regions(self): + wine_region_old_ids = set(GuideElements.objects.filter(wine_region_id__isnull=False) + .values_list('wine_region_id', flat=True)) + not_existed_wine_regions = [] + for old_id in tqdm(wine_region_old_ids): + if not WineRegion.objects.filter(old_id=old_id).exists(): + not_existed_wine_regions.append(old_id) + print(f'NOT EXISTED WINE REGIONS: {len(not_existed_wine_regions)}') + pprint(f'{not_existed_wine_regions}') + + @decorator + def check_establishments(self): + establishment_old_ids = set(GuideElements.objects.filter(establishment_id__isnull=False) + .values_list('establishment_id', flat=True)) + not_existed_establishments = [] + for old_id in tqdm(establishment_old_ids): + if not Establishment.objects.filter(old_id=old_id).exists(): + not_existed_establishments.append(old_id) + print(f'NOT EXISTED ESTABLISHMENTS: {len(not_existed_establishments)}') + pprint(f'{not_existed_establishments}') + + @decorator + def check_reviews(self): + review_old_ids = set(GuideElements.objects.filter(review_id__isnull=False) + .values_list('review_id', flat=True)) + not_existed_reviews = [] + for old_id in tqdm(review_old_ids): + if not Review.objects.filter(old_id=old_id).exists(): + not_existed_reviews.append(old_id) + print(f'NOT EXISTED REVIEWS: {len(not_existed_reviews)}') + pprint(f'{not_existed_reviews}') + + @decorator + def check_wines(self): + wine_old_ids = set(GuideElements.objects.filter(wine_id__isnull=False) + .values_list('wine_id', flat=True)) + not_existed_wines = [] + for old_id in tqdm(wine_old_ids): + if not Product.objects.filter(old_id=old_id).exists(): + not_existed_wines.append(old_id) + print(f'NOT EXISTED WINES: {len(not_existed_wines)}') + pprint(f'{not_existed_wines}') + + @decorator + def check_wine_color(self): + raw_wine_color_nodes = set(GuideElements.objects.exclude(color__iexact='') + .filter(color__isnull=False) + .values_list('color', flat=True)) + raw_wine_colors = [i[:-11] for i in raw_wine_color_nodes] + raw_wine_color_index_names = [] + re_exp = '[A-Z][^A-Z]*' + for raw_wine_color in tqdm(raw_wine_colors): + result = re.findall(re_exp, rf'{raw_wine_color}') + if result and len(result) >= 2: + wine_color = '-'.join(result) + else: + wine_color = result[0] + raw_wine_color_index_names.append(wine_color.lower()) + not_existed_wine_colors = [] + for index_name in raw_wine_color_index_names: + if not Tag.objects.filter(value=index_name).exists(): + not_existed_wine_colors.append(index_name) + print(f'NOT EXISTED WINE COLOR: {len(not_existed_wine_colors)}') + pprint(f'{not_existed_wine_colors}') + + @decorator + def check_cities(self): + city_old_ids = set(GuideElements.objects.filter(city_id__isnull=False) + .values_list('city_id', flat=True)) + not_existed_cities = [] + for old_id in tqdm(city_old_ids): + if not City.objects.filter(old_id=old_id).exists(): + not_existed_cities.append(old_id) + print(f'NOT EXISTED CITIES: {len(not_existed_cities)}') + pprint(f'{not_existed_cities}') + + def handle(self, *args, **kwargs): + self.count_of_guide_relative_dependencies() + self.check_regions() + self.check_establishments() + self.check_reviews() + self.check_wines() + self.check_wine_color() + self.check_cities() diff --git a/apps/collection/migrations/0018_auto_20191127_1047.py b/apps/collection/migrations/0018_auto_20191127_1047.py new file mode 100644 index 00000000..c6f54ee2 --- /dev/null +++ b/apps/collection/migrations/0018_auto_20191127_1047.py @@ -0,0 +1,78 @@ +# Generated by Django 2.2.7 on 2019-11-27 10:47 + +import django.contrib.postgres.fields.jsonb +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0039_sitefeature_old_id'), + ('collection', '0017_collection_old_id'), + ] + + operations = [ + migrations.CreateModel( + name='GuideType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')), + ('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('name', models.SlugField(max_length=255, unique=True, verbose_name='code')), + ], + options={ + 'verbose_name': 'guide type', + 'verbose_name_plural': 'guide types', + }, + ), + migrations.RemoveField( + model_name='guide', + name='advertorials', + ), + migrations.RemoveField( + model_name='guide', + name='collection', + ), + migrations.RemoveField( + model_name='guide', + name='parent', + ), + migrations.AddField( + model_name='guide', + name='old_id', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='guide', + name='site', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='main.SiteSettings', verbose_name='site settings'), + ), + migrations.AddField( + model_name='guide', + name='slug', + field=models.SlugField(max_length=255, null=True, unique=True, verbose_name='slug'), + ), + migrations.AddField( + model_name='guide', + name='state', + field=models.PositiveSmallIntegerField(choices=[(0, 'built'), (1, 'waiting'), (2, 'removing'), (3, 'building')], default=1, verbose_name='state'), + ), + migrations.AddField( + model_name='guide', + name='vintage', + field=models.IntegerField(null=True, validators=[django.core.validators.MinValueValidator(1900), django.core.validators.MaxValueValidator(2100)], verbose_name='guide vintage year'), + ), + migrations.AddField( + model_name='guide', + name='guide_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='collection.GuideType', verbose_name='type'), + ), + migrations.AlterField( + model_name='guide', + name='start', + field=models.DateTimeField(null=True, verbose_name='start'), + ), + ] diff --git a/apps/collection/models.py b/apps/collection/models.py index d98f8a59..af8b2a33 100644 --- a/apps/collection/models.py +++ b/apps/collection/models.py @@ -1,6 +1,8 @@ from django.contrib.contenttypes.fields import ContentType from django.contrib.postgres.fields import JSONField +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +import re from django.utils.translation import gettext_lazy as _ from utils.models import ProjectBaseMixin, URLImageMixin @@ -85,26 +87,64 @@ class Collection(ProjectBaseMixin, CollectionDateMixin, verbose_name_plural = _('collections') +class GuideTypeQuerySet(models.QuerySet): + """QuerySet for model GuideType.""" + + +class GuideType(ProjectBaseMixin): + """GuideType model.""" + + name = models.SlugField(max_length=255, unique=True, + verbose_name=_('code')) + + objects = GuideTypeQuerySet.as_manager() + + class Meta: + """Meta class.""" + verbose_name = _('guide type') + verbose_name_plural = _('guide types') + + def __str__(self): + """Overridden str dunder method.""" + return self.name + + class GuideQuerySet(models.QuerySet): """QuerySet for Guide.""" - def by_collection_id(self, collection_id): - """Filter by collection id""" - return self.filter(collection=collection_id) - class Guide(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin): """Guide model.""" - parent = models.ForeignKey( - 'self', verbose_name=_('parent'), on_delete=models.CASCADE, - null=True, blank=True, default=None + BUILT = 0 + WAITING = 1 + REMOVING = 2 + BUILDING = 3 + + STATE_CHOICES = ( + (BUILT, 'built'), + (WAITING, 'waiting'), + (REMOVING, 'removing'), + (BUILDING, 'building'), + ) - advertorials = JSONField( - _('advertorials'), null=True, blank=True, - default=None, help_text='{"key":"value"}') - collection = models.ForeignKey(Collection, on_delete=models.CASCADE, - null=True, blank=True, default=None, - verbose_name=_('collection')) + + start = models.DateTimeField(null=True, + verbose_name=_('start')) + vintage = models.IntegerField(validators=[MinValueValidator(1900), + MaxValueValidator(2100)], + null=True, + verbose_name=_('guide vintage year')) + slug = models.SlugField(max_length=255, unique=True, null=True, + verbose_name=_('slug')) + guide_type = models.ForeignKey('GuideType', on_delete=models.PROTECT, + null=True, + verbose_name=_('type')) + site = models.ForeignKey('main.SiteSettings', on_delete=models.SET_NULL, + null=True, + verbose_name=_('site settings')) + state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, + verbose_name=_('state')) + old_id = models.IntegerField(blank=True, null=True) objects = GuideQuerySet.as_manager() @@ -116,3 +156,71 @@ class Guide(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin): def __str__(self): """String method.""" return f'{self.name}' + + +class AdvertorialQuerySet(models.QuerySet): + """QuerySet for model Advertorial.""" + + +class Advertorial(ProjectBaseMixin): + """Guide advertorial model.""" + number_of_pages = models.PositiveIntegerField( + verbose_name=_('number of pages'), + help_text=_('the total number of reserved pages')) + right_pages = models.PositiveIntegerField( + verbose_name=_('number of right pages'), + help_text=_('the number of right pages (which are part of total number).')) + old_id = models.IntegerField(blank=True, null=True) + + objects = AdvertorialQuerySet.as_manager() + + class Meta: + """Meta class.""" + verbose_name = _('advertorial') + verbose_name_plural = _('advertorials') + + +class GuideFilterQuerySet(models.QuerySet): + """QuerySet for model GuideFilter.""" + + +class GuideFilter(ProjectBaseMixin): + """Guide filter model.""" + establishment_type_json = JSONField(blank=True, null=True, + verbose_name='establishment types') + country_code_json = JSONField(blank=True, null=True, + verbose_name='countries') + region_code_json = JSONField(blank=True, null=True, + verbose_name='regions') + sub_region_code_json = JSONField(blank=True, null=True, + verbose_name='sub regions') + wine_region_json = JSONField(blank=True, null=True, + verbose_name='wine regions') + wine_classification_json = JSONField(blank=True, null=True, + verbose_name='wine classifications') + wine_color_json = JSONField(blank=True, null=True, + verbose_name='wine colors') + wine_type_json = JSONField(blank=True, null=True, + verbose_name='wine types') + with_mark = models.BooleanField(default=True, + verbose_name=_('with mark'), + help_text=_('exclude empty marks?')) + locale_json = JSONField(blank=True, null=True, + verbose_name='locales') + max_mark = models.PositiveSmallIntegerField(verbose_name=_('max mark'), + help_text=_('mark under')) + min_mark = models.PositiveSmallIntegerField(verbose_name=_('min mark'), + help_text=_('mark over')) + review_vintage_json = JSONField(verbose_name='review vintage years') + review_state_json = JSONField(blank=True, null=True, + verbose_name='review states') + guide = models.OneToOneField(Guide, on_delete=models.CASCADE, + verbose_name=_('guide')) + old_id = models.IntegerField(blank=True, null=True) + + objects = GuideFilterQuerySet.as_manager() + + class Meta: + """Meta class.""" + verbose_name = _('guide filter') + verbose_name_plural = _('guide filters') diff --git a/apps/collection/transfer_data.py b/apps/collection/transfer_data.py new file mode 100644 index 00000000..9307e6a0 --- /dev/null +++ b/apps/collection/transfer_data.py @@ -0,0 +1,37 @@ +from pprint import pprint +from transfer.models import Guides, GuideFilters +from transfer.serializers.guide import GuideSerializer, GuideFilterSerializer + + +def transfer_guide(): + """Transfer Guide model.""" + queryset = Guides.objects.exclude(title__icontains='test') + serialized_data = GuideSerializer( + data=list(queryset.values()), + many=True) + if serialized_data.is_valid(): + serialized_data.save() + else: + pprint(f"transfer guide errors: {serialized_data.errors}") + + +def transfer_guide_filter(): + """Transfer GuideFilter model.""" + queryset = GuideFilters.objects.all() + serialized_data = GuideFilterSerializer( + data=list(queryset.values()), + many=True) + if serialized_data.is_valid(): + serialized_data.save() + else: + pprint(f"transfer guide filter errors: {serialized_data.errors}") + + +data_types = { + 'guides': [ + transfer_guide, + ], + 'guide_filters': [ + transfer_guide_filter, + ] +} diff --git a/apps/transfer/management/commands/transfer.py b/apps/transfer/management/commands/transfer.py index 2d0ce399..3b562b0d 100644 --- a/apps/transfer/management/commands/transfer.py +++ b/apps/transfer/management/commands/transfer.py @@ -41,6 +41,8 @@ class Command(BaseCommand): 'newsletter_subscriber', # подписчики на рассылку - переносить после переноса пользователей №1 'purchased_plaques', # №6 - перенос купленных тарелок 'fill_city_gallery', # №3 - перенос галереи городов + 'guides', + 'guide_filters', ] def handle(self, *args, **options): diff --git a/apps/transfer/models.py b/apps/transfer/models.py index 37f9217a..1ac40e80 100644 --- a/apps/transfer/models.py +++ b/apps/transfer/models.py @@ -5,11 +5,19 @@ # * Make sure each ForeignKey has `on_delete` set to the desired behavior. # * Remove `managed = False` lines if you wish to allow Django to create, modify, and delete the table # Feel free to rename the models, but don't rename db_table values or field names. +import yaml from django.contrib.gis.db import models from transfer.mixins import MigrateMixin +def convert_entry(loader, node): + return {e[0]: e[1] for e in loader.construct_pairs(node)} + + +yaml.add_constructor('!ruby/hash:ActiveSupport::HashWithIndifferentAccess', convert_entry) + + # models.ForeignKey(ForeignModel, models.DO_NOTHING, blank=True, null=True) class Sites(MigrateMixin): diff --git a/apps/transfer/serializers/guide.py b/apps/transfer/serializers/guide.py new file mode 100644 index 00000000..5accbe64 --- /dev/null +++ b/apps/transfer/serializers/guide.py @@ -0,0 +1,283 @@ +from itertools import chain + +import yaml +from pycountry import countries, subdivisions +from rest_framework import serializers + +from collection.models import Guide, GuideType, GuideFilter +from establishment.models import EstablishmentType +from location.models import Country, Region +from main.models import SiteSettings +from transfer.mixins import TransferSerializerMixin + + +class GuideSerializer(TransferSerializerMixin): + id = serializers.IntegerField() + title = serializers.CharField() + vintage = serializers.IntegerField() + slug = serializers.CharField() + state = serializers.CharField() + site_id = serializers.IntegerField() + inserter_field = serializers.CharField() + + class Meta: + model = Guide + fields = ( + 'id', + 'title', + 'vintage', + 'slug', + 'state', + 'site_id', + 'inserter_field', + ) + + def validate(self, attrs): + """Overridden validate method.""" + attrs['old_id'] = attrs.pop('id') + attrs['name'] = attrs.pop('title') + attrs['vintage'] = int(attrs.pop('vintage')) + attrs['state'] = self.get_state(attrs.pop('state')) + attrs['site'] = self.get_site(attrs.pop('site_id')) + attrs['guide_type'] = self.get_guide_type(attrs.pop('inserter_field')) + return attrs + + def get_state(self, state: str): + if state == 'built': + return Guide.BUILT + elif state == 'removing': + return Guide.REMOVING + elif state == 'building': + return Guide.BUILDING + else: + return Guide.WAITING + + def get_site(self, site_id): + qs = SiteSettings.objects.filter(old_id=site_id) + if qs.exists(): + return qs.first() + + def get_guide_type(self, inserter_field): + guide_type, _ = GuideType.objects.get_or_create(name=inserter_field) + return guide_type + + +class GuideFilterSerializer(TransferSerializerMixin): + id = serializers.IntegerField() + year = serializers.CharField() + establishment_type = serializers.CharField(allow_null=True) + countries = serializers.CharField(allow_null=True) + regions = serializers.CharField(allow_null=True) + subregions = serializers.CharField(allow_null=True) + wine_regions = serializers.CharField(allow_null=True) + wine_classifications = serializers.CharField(allow_null=True) + wine_colors = serializers.CharField(allow_null=True) + wine_types = serializers.CharField(allow_null=True) + locales = serializers.CharField(allow_null=True) + states = serializers.CharField(allow_null=True) + + max_mark = serializers.IntegerField(allow_null=True) + min_mark = serializers.IntegerField(allow_null=True) + marks_only = serializers.NullBooleanField() + guide_id = serializers.IntegerField() + + class Meta: + model = GuideFilter + fields = ( + 'id', + 'year', + 'establishment_type', + 'countries', + 'regions', + 'subregions', + 'wine_regions', + 'wine_classifications', + 'wine_colors', + 'wine_types', + 'max_mark', + 'min_mark', + 'marks_only', + 'locales', + 'states', + 'guide_id', + ) + + @staticmethod + def parse_ruby_helper(raw_value: str): + """Parse RubyActiveSupport records""" + def convert_entry(loader, node): + return {e[0]: e[1] for e in loader.construct_pairs(node)} + + loader = yaml.Loader + loader.add_constructor('!ruby/hash:ActiveSupport::HashWithIndifferentAccess', convert_entry) + return yaml.load(raw_value, Loader=loader) if raw_value else None + + @staticmethod + def get_country_alpha_2(country_code_alpha3: str): + country = countries.get(alpha_3=country_code_alpha3.upper()) + return {'code_alpha_2': country.alpha2.lower() if country else None, + 'name': country.name if country else None,} + + @staticmethod + def parse_dictionary(dictionary: dict): + """ + Exclude root values from dictionary. + Convert {key_2: [value_1, value_2]} into + [value_1, value_2] + """ + return list(chain.from_iterable(dictionary.values())) + + @staticmethod + def parse_nested_dictionary(dictionary: dict): + """ + Exclude root values from dictionary. + Convert {key_1: {key_2: [value_1, value_2]}} into + [value_1, value_2] + """ + l = [] + for i in dictionary: + l.append(list(chain.from_iterable(list(dictionary[i].values())))) + return list(chain.from_iterable(l)) + + def get_country(self, code_alpha_3: str) -> Country: + country = self.get_country_alpha_2(code_alpha_3) + country_name = country['name'] + country_code = country['code_alpha_2'] + if country_name and country_code: + country, _ = Country.objects.get_or_create( + code__icontains=country_code, + name__contains={'en-GB': country_name}, + defaults={ + 'code': country_code, + 'name': {'en-GB': country_name} + } + ) + return country + + def get_region(self, region_code_alpha_3: str, + country_code_alpha_3: str, + sub_region_code_alpha_3: str = None): + country = self.get_country(country_code_alpha_3) + country_code_alpha_2 = country.code.upper() + region_qs = Region.objects.filter(code__iexact=region_code_alpha_3, + country__code__iexact=country_code_alpha_2) + + # If region isn't existed, check sub region for parent_code (region code) + if not region_qs.exists() and sub_region_code_alpha_3: + # sub region + subdivision = subdivisions.get( + code=f"{country_code_alpha_2}-{sub_region_code_alpha_3}") + if subdivision: + subdivision_region = subdivisions.get(parent_code=subdivision.parent_code) + obj = Region.objects.create( + name=subdivision_region.name, + code=subdivision_region.code, + country=country) + return obj + else: + return region_qs.first() + + def validate_year(self, value): + return self.parse_ruby_helper(value) + + def validate_establishment_type(self, value): + return self.parse_ruby_helper(value) + + def validate_countries(self, value): + return self.parse_ruby_helper(value) + + def validate_regions(self, value): + return self.parse_ruby_helper(value) + + def validate_sub_regions(self, value): + return self.parse_ruby_helper(value) + + def validate_wine_regions(self, value): + return self.parse_ruby_helper(value) + + def validate_wine_classifications(self, value): + return self.parse_ruby_helper(value) + + def validate_wine_colors(self, value): + return self.parse_ruby_helper(value) + + def validate_wine_types(self, value): + return self.parse_ruby_helper(value) + + def validate_locales(self, value): + return self.parse_ruby_helper(value) + + def validate_states(self, value): + return self.parse_ruby_helper(value) + + def validate(self, attrs): + sub_regions = attrs.pop('subregions') + regions = attrs.pop('regions') + + attrs['old_id'] = attrs.pop('id') + attrs['review_vintage_json'] = self.get_review_vintage(self.attrs.pop('year')) + attrs['establishment_type_json'] = self.get_establishment_type_ids( + self.attrs.pop('establishment_type')) + attrs['country_code_json'] = self.get_country_ids(attrs.pop('country_json')) + attrs['region_json'] = self.get_region_ids(regions, sub_regions) + attrs['sub_region_json'] = self.get_sub_region_ids(regions, sub_regions) + return attrs + + def get_review_vintage(self, year): + return {'vintage': [int(i) for i in set(year) if i.isdigit()]} + + def get_establishment_type_ids(self, establishment_types): + establishment_type_ids = [] + for establishment_type in establishment_types: + establishment_type_qs = EstablishmentType.objects.filter(index_name__iexact=establishment_type) + if not establishment_type_qs.exists(): + obj = EstablishmentType.objects.create( + name={'en-GB': establishment_type.capitalize}, + index_name=establishment_type.lower()) + establishment_type_ids.append(obj.id) + return {'id': establishment_type_ids} + + def get_country_ids(self, country_codes_alpha_3): + country_ids = [] + for code_alpha_3 in country_codes_alpha_3: + country_ids.append(self.get_country(code_alpha_3).id) + return {'id': country_ids} + + def get_region_ids(self, regions, sub_regions): + region_ids = [] + for country_code_alpha_3 in regions: + region_codes = regions[country_code_alpha_3] + for region_code_alpha_3 in region_codes: + region = self.get_region( + region_code_alpha_3=region_code_alpha_3, + country_code_alpha_3=country_code_alpha_3, + sub_region_code_alpha_3=sub_regions[country_code_alpha_3][region_code_alpha_3]) + if region: + region_ids.append(region.id) + return {'id': region_ids} + + def get_sub_region_ids(self, sub_regions): + sub_region_ids = [] + for country_code_alpha_3 in sub_regions: + for region_code_alpha_3 in sub_regions[country_code_alpha_3]: + region_codes = sub_regions[country_code_alpha_3][region_code_alpha_3] + for sub_region_code_alpha_3 in region_codes: + region = self.get_region( + region_code_alpha_3=region_code_alpha_3, + country_code_alpha_3=country_code_alpha_3, + sub_region_code_alpha_3=sub_region_code_alpha_3) + if region: + subdivision = subdivisions.get(parent_code=region.code.upper()) + if subdivision: + sub_region = Region.objects.create( + name=subdivision.name, + code=subdivisions.code, + parent_region=region, + country=region.country) + sub_region_ids.append(sub_region.id) + + return {'id': sub_region_ids} + + +# {'FRA': ['H', 'U']} REGIONS +# {'FRA': {'N': ['32']}} SUB REGIONS diff --git a/requirements/base.txt b/requirements/base.txt index 94e7ca27..aadc7301 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -57,3 +57,6 @@ redis==3.2.0 django_redis==4.10.0 # used byes indexing cache kombu==4.6.6 celery==4.3.0 + +# country information +pycountry==19.8.18