diff --git a/apps/account/migrations/0009_auto_20191002_0648.py b/apps/account/migrations/0009_auto_20191002_0648.py new file mode 100644 index 00000000..d9734907 --- /dev/null +++ b/apps/account/migrations/0009_auto_20191002_0648.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-02 06:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0008_auto_20190912_1325'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(default=None, max_length=254, null=True, unique=True, verbose_name='email address'), + ), + ] diff --git a/apps/account/migrations/0011_merge_20191011_1336.py b/apps/account/migrations/0011_merge_20191011_1336.py new file mode 100644 index 00000000..6a031068 --- /dev/null +++ b/apps/account/migrations/0011_merge_20191011_1336.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-11 13:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0009_auto_20191002_0648'), + ('account', '0010_user_password_confirmed'), + ] + + operations = [ + ] diff --git a/apps/account/migrations/0012_merge_20191015_0912.py b/apps/account/migrations/0012_merge_20191015_0912.py new file mode 100644 index 00000000..558940ce --- /dev/null +++ b/apps/account/migrations/0012_merge_20191015_0912.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-15 09:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0011_merge_20191014_1258'), + ('account', '0011_merge_20191011_1336'), + ] + + operations = [ + ] diff --git a/apps/account/migrations/0014_merge_20191023_0959.py b/apps/account/migrations/0014_merge_20191023_0959.py new file mode 100644 index 00000000..07ab850e --- /dev/null +++ b/apps/account/migrations/0014_merge_20191023_0959.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-23 09:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0009_auto_20191002_0648'), + ('account', '0013_auto_20191016_0810'), + ] + + operations = [ + ] diff --git a/apps/account/migrations/0015_merge_20191023_1317.py b/apps/account/migrations/0015_merge_20191023_1317.py new file mode 100644 index 00000000..f014afb2 --- /dev/null +++ b/apps/account/migrations/0015_merge_20191023_1317.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-23 13:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0014_merge_20191023_0959'), + ('account', '0012_merge_20191015_0912'), + ] + + operations = [ + ] diff --git a/apps/account/migrations/0016_auto_20191024_0830.py b/apps/account/migrations/0016_auto_20191024_0830.py new file mode 100644 index 00000000..63373499 --- /dev/null +++ b/apps/account/migrations/0016_auto_20191024_0830.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.4 on 2019-10-24 08:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0015_merge_20191023_1317'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='role', + field=models.PositiveIntegerField(choices=[(1, 'Standard user'), (2, 'Comments moderator'), (3, 'Country admin'), (4, 'Content page manager'), (5, 'Establishment manager'), (6, 'Reviewer manager'), (7, 'Restaurant reviewer')], verbose_name='Role'), + ), + migrations.AlterField( + model_name='userrole', + name='establishment', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='establishment.Establishment', verbose_name='Establishment'), + ), + ] diff --git a/apps/account/models.py b/apps/account/models.py index a9f739bd..2f8c97cc 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -89,7 +89,7 @@ class User(AbstractUser): blank=True, null=True, default=None) cropped_image_url = models.URLField(verbose_name=_('Cropped image URL path'), blank=True, null=True, default=None) - email = models.EmailField(_('email address'), blank=True, + email = models.EmailField(_('email address'), unique=True, null=True, default=None) unconfirmed_email = models.EmailField(_('unconfirmed email'), blank=True, null=True, default=None) email_confirmed = models.BooleanField(_('email status'), default=False) @@ -214,6 +214,15 @@ class User(AbstractUser): template_name=settings.RESETTING_TOKEN_TEMPLATE, context=context) + def notify_password_changed_template(self, country_code): + """Get notification email template""" + context = {'contry_code': country_code} + context.update(self.base_template) + return render_to_string( + template_name=settings.NOTIFICATION_PASSWORD_TEMPLATE, + context=context, + ) + def confirm_email_template(self, country_code): """Get confirm email template""" context = {'token': self.confirm_email_token, diff --git a/apps/account/serializers/common.py b/apps/account/serializers/common.py index ad232eae..d68cfe56 100644 --- a/apps/account/serializers/common.py +++ b/apps/account/serializers/common.py @@ -127,6 +127,14 @@ class ChangePasswordSerializer(serializers.ModelSerializer): except serializers.ValidationError as e: raise serializers.ValidationError({'detail': e.detail}) else: + if settings.USE_CELERY: + tasks.send_password_changed_email( + user_id=self.instance.id, + country_code=self.context.get('request').country_code) + else: + tasks.send_password_changed_email( + user_id=self.instance.id, + country_code=self.context.get('request').country_code) return attrs def update(self, instance, validated_data): diff --git a/apps/account/serializers/web.py b/apps/account/serializers/web.py index 8be73afa..2f04b31f 100644 --- a/apps/account/serializers/web.py +++ b/apps/account/serializers/web.py @@ -1,8 +1,9 @@ """Serializers for account web""" +from django.conf import settings from django.contrib.auth import password_validation as password_validators from rest_framework import serializers -from account import models +from account import models, tasks from utils import exceptions as utils_exceptions from utils.methods import username_validator @@ -68,4 +69,12 @@ class PasswordResetConfirmSerializer(serializers.ModelSerializer): # Update user password from instance instance.set_password(validated_data.get('password')) instance.save() + if settings.USE_CELERY: + tasks.send_password_changed_email( + user_id=instance.id, + country_code=self.context.get('request').country_code) + else: + tasks.send_password_changed_email( + user_id=instance.id, + country_code=self.context.get('request').country_code) return instance diff --git a/apps/account/tasks.py b/apps/account/tasks.py index 03a231b3..d9fa7bb7 100644 --- a/apps/account/tasks.py +++ b/apps/account/tasks.py @@ -1,47 +1,46 @@ """Account app celery tasks.""" +import inspect import logging from celery import shared_task from django.utils.translation import gettext_lazy as _ -from . import models +from account.models import User logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) logger = logging.getLogger(__name__) +def send_email(user_id: int, subject: str, message_prop: str, country_code: str): + try: + user = User.objects.get(id=user_id) + user.send_email(subject=_(subject), + message=getattr(user, message_prop, lambda _: '')(country_code)) + except: + cur_frame = inspect.currentframe() + cal_frame = inspect.getouterframes(cur_frame, 2) + logger.error(f'METHOD_NAME: {cal_frame[1][3]}\n' + f'DETAIL: Exception occurred for user: {user_id}') + @shared_task def send_reset_password_email(user_id, country_code): """Send email to user for reset password.""" - try: - user = models.User.objects.get(id=user_id) - user.send_email(subject=_('Password resetting'), - message=user.reset_password_template(country_code)) - except: - logger.error(f'METHOD_NAME: {send_reset_password_email.__name__}\n' - f'DETAIL: Exception occurred for reset password: ' - f'{user_id}') + send_email(user_id, 'Password_resetting', 'reset_password_template', country_code) @shared_task def confirm_new_email_address(user_id, country_code): """Send email to user new email.""" - try: - user = models.User.objects.get(id=user_id) - user.send_email(subject=_('Validate new email address'), - message=user.confirm_email_template(country_code)) - except: - logger.error(f'METHOD_NAME: {confirm_new_email_address.__name__}\n' - f'DETAIL: Exception occurred for user: {user_id}') + send_email(user_id, 'Confirm new email address', 'confirm_email_template', country_code) @shared_task def change_email_address(user_id, country_code): """Send email to user new email.""" - try: - user = models.User.objects.get(id=user_id) - user.send_email(subject=_('Validate new email address'), - message=user.change_email_template(country_code)) - except: - logger.error(f'METHOD_NAME: {change_email_address.__name__}\n' - f'DETAIL: Exception occurred for user: {user_id}') + send_email(user_id, 'Validate new email address', 'change_email_template', country_code) + + +@shared_task +def send_password_changed_email(user_id, country_code): + """Send email which notifies user that his password had changed""" + send_email(user_id, 'Notify password changed', 'notify_password_changed_template', country_code) diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index ed68ba9f..13121f78 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -18,12 +18,6 @@ from utils.tokens import GMRefreshToken # Serializers class SignupSerializer(serializers.ModelSerializer): """Signup serializer serializer mixin""" - # REQUEST - username = serializers.CharField(write_only=True) - password = serializers.CharField(write_only=True) - email = serializers.EmailField(write_only=True) - newsletter = serializers.BooleanField(write_only=True) - class Meta: model = account_models.User fields = ( @@ -32,6 +26,12 @@ class SignupSerializer(serializers.ModelSerializer): 'email', 'newsletter' ) + extra_kwargs = { + 'username': {'write_only': True}, + 'password': {'write_only': True}, + 'email': {'write_only': True}, + 'newsletter': {'write_only': True} + } def validate_username(self, value): """Custom username validation""" diff --git a/apps/collection/migrations/0014_auto_20191022_1242.py b/apps/collection/migrations/0014_auto_20191022_1242.py new file mode 100644 index 00000000..d70c0cfa --- /dev/null +++ b/apps/collection/migrations/0014_auto_20191022_1242.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.4 on 2019-10-22 12:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0013_collection_slug'), + ] + + operations = [ + migrations.AlterField( + model_name='collection', + name='end', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='end'), + ), + migrations.AlterField( + model_name='guide', + name='end', + field=models.DateTimeField(blank=True, default=None, null=True, verbose_name='end'), + ), + ] diff --git a/apps/collection/migrations/0015_auto_20191023_0715.py b/apps/collection/migrations/0015_auto_20191023_0715.py new file mode 100644 index 00000000..53bfdc2d --- /dev/null +++ b/apps/collection/migrations/0015_auto_20191023_0715.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.4 on 2019-10-23 07:15 + +from django.db import migrations + +import utils.models + + +def fill_title_json_from_title(apps, schema_editor): + # We can't import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + Collection = apps.get_model('collection', 'Collection') + for collection in Collection.objects.all(): + collection.name_json = {'en-GB': collection.name} + collection.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0014_auto_20191022_1242'), + ] + + operations = [ + migrations.AddField( + model_name='collection', + name='name_json', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='name'), + ), + migrations.RunPython(fill_title_json_from_title, migrations.RunPython.noop), + migrations.RemoveField( + model_name='collection', + name='name', + ), + migrations.RenameField( + model_name='collection', + old_name='name_json', + new_name='name', + ), + migrations.AlterField( + model_name='collection', + name='name', + field=utils.models.TJSONField(help_text='{"en-GB":"some text"}', verbose_name='name'), + ), + ] diff --git a/apps/collection/models.py b/apps/collection/models.py index 25bf1ef9..0a2700bd 100644 --- a/apps/collection/models.py +++ b/apps/collection/models.py @@ -1,13 +1,11 @@ -from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.fields import ContentType - -from utils.models import TJSONField +from django.contrib.postgres.fields import JSONField from django.db import models from django.utils.translation import gettext_lazy as _ from utils.models import ProjectBaseMixin, URLImageMixin +from utils.models import TJSONField from utils.models import TranslatedFieldsMixin - from utils.querysets import RelatedObjectsCountMixin @@ -24,7 +22,8 @@ class CollectionNameMixin(models.Model): class CollectionDateMixin(models.Model): """CollectionDate mixin""" start = models.DateTimeField(_('start')) - end = models.DateTimeField(_('end')) + end = models.DateTimeField(blank=True, null=True, default=None, + verbose_name=_('end')) class Meta: """Meta class""" @@ -44,9 +43,11 @@ class CollectionQuerySet(RelatedObjectsCountMixin): return self.filter(is_publish=True) -class Collection(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin, +class Collection(ProjectBaseMixin, CollectionDateMixin, TranslatedFieldsMixin, URLImageMixin): """Collection model.""" + STR_FIELD_NAME = 'name' + ORDINARY = 0 # Ordinary collection POP = 1 # POP collection @@ -55,6 +56,8 @@ class Collection(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin, (POP, _('Pop')), ) + name = TJSONField(verbose_name=_('name'), + help_text='{"en-GB":"some text"}') collection_type = models.PositiveSmallIntegerField(choices=COLLECTION_TYPES, default=ORDINARY, verbose_name=_('Collection type')) @@ -80,10 +83,6 @@ class Collection(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin, verbose_name = _('collection') verbose_name_plural = _('collections') - def __str__(self): - """String method.""" - return f'{self.name}' - class GuideQuerySet(models.QuerySet): """QuerySet for Guide.""" diff --git a/apps/collection/serializers/common.py b/apps/collection/serializers/common.py index 78612a55..846236d5 100644 --- a/apps/collection/serializers/common.py +++ b/apps/collection/serializers/common.py @@ -2,18 +2,19 @@ from rest_framework import serializers from collection import models from location import models as location_models +from utils.serializers import TranslatedField class CollectionBaseSerializer(serializers.ModelSerializer): """Collection base serializer""" - # RESPONSE - description_translated = serializers.CharField(read_only=True, allow_null=True) + name_translated = TranslatedField() + description_translated = TranslatedField() class Meta: model = models.Collection fields = [ 'id', - 'name', + 'name_translated', 'description_translated', 'image_url', 'slug', @@ -35,8 +36,7 @@ class CollectionSerializer(CollectionBaseSerializer): queryset=location_models.Country.objects.all(), write_only=True) - class Meta: - model = models.Collection + class Meta(CollectionBaseSerializer.Meta): fields = CollectionBaseSerializer.Meta.fields + [ 'start', 'end', diff --git a/apps/establishment/filters.py b/apps/establishment/filters.py index 20ece644..91670031 100644 --- a/apps/establishment/filters.py +++ b/apps/establishment/filters.py @@ -1,6 +1,7 @@ """Establishment app filters.""" from django.core.validators import EMPTY_VALUES from django_filters import rest_framework as filters + from establishment import models @@ -10,10 +11,10 @@ class EstablishmentFilter(filters.FilterSet): tag_id = filters.NumberFilter(field_name='tags__metadata__id',) award_id = filters.NumberFilter(field_name='awards__id',) search = filters.CharFilter(method='search_text') - est_type = filters.ChoiceFilter(choices=models.EstablishmentType.INDEX_NAME_TYPES, - method='by_type') - est_subtype = filters.ChoiceFilter(choices=models.EstablishmentSubType.INDEX_NAME_TYPES, - method='by_subtype') + type = filters.ChoiceFilter(choices=models.EstablishmentType.INDEX_NAME_TYPES, + method='by_type') + subtype = filters.ChoiceFilter(choices=models.EstablishmentSubType.INDEX_NAME_TYPES, + method='by_subtype') class Meta: """Meta class.""" @@ -23,8 +24,8 @@ class EstablishmentFilter(filters.FilterSet): 'tag_id', 'award_id', 'search', - 'est_type', - 'est_subtype', + 'type', + 'subtype', ) def search_text(self, queryset, name, value): @@ -34,10 +35,14 @@ class EstablishmentFilter(filters.FilterSet): return queryset def by_type(self, queryset, name, value): - return queryset.by_type(value) + if value not in EMPTY_VALUES: + return queryset.by_type(value) + return queryset def by_subtype(self, queryset, name, value): - return queryset.by_subtype(value) + if value not in EMPTY_VALUES: + return queryset.by_subtype(value) + return queryset class EstablishmentTypeTagFilter(filters.FilterSet): diff --git a/apps/products/__init__.py b/apps/establishment/management/__init__.py similarity index 100% rename from apps/products/__init__.py rename to apps/establishment/management/__init__.py diff --git a/apps/products/migrations/__init__.py b/apps/establishment/management/commands/__init__.py similarity index 100% rename from apps/products/migrations/__init__.py rename to apps/establishment/management/commands/__init__.py diff --git a/apps/establishment/management/commands/attach_establishments_tz.py b/apps/establishment/management/commands/attach_establishments_tz.py new file mode 100644 index 00000000..58be04cb --- /dev/null +++ b/apps/establishment/management/commands/attach_establishments_tz.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand +from pytz import timezone as py_tz +from timezonefinder import TimezoneFinder +from establishment.models import Establishment + + +class Command(BaseCommand): + help = 'Attach correct timestamps according to coordinates to existing establishments' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tf = TimezoneFinder(in_memory=True) + + def handle(self, *args, **options): + for establishment in Establishment.objects.prefetch_related('address').all(): + if establishment.address and establishment.address.latitude and establishment.address.longitude: + establishment.tz = py_tz(self.tf.certain_timezone_at(lng=establishment.address.longitude, + lat=establishment.address.latitude)) + establishment.save() + self.stdout.write(self.style.SUCCESS(f'Attached timezone {establishment.tz} to {establishment}')) + else: + self.stdout.write(self.style.WARNING(f'Establishment {establishment} has no coordinates' + f'passing...')) diff --git a/apps/establishment/migrations/0039_establishmentsubtype_index_name.py b/apps/establishment/migrations/0039_establishmentsubtype_index_name.py index a29b1ae0..cf35010a 100644 --- a/apps/establishment/migrations/0039_establishmentsubtype_index_name.py +++ b/apps/establishment/migrations/0039_establishmentsubtype_index_name.py @@ -8,7 +8,7 @@ def fill_establishment_subtype(apps, schema_editor): # version than this migration expects. We use the historical version. EstablishmentSubType = apps.get_model('establishment', 'EstablishmentSubType') for n, et in enumerate(EstablishmentSubType.objects.all()): - et.index_name = f'Type {n}' + et.index_name = 'Type %s' % n et.save() diff --git a/apps/establishment/migrations/0040_employee_tags.py b/apps/establishment/migrations/0040_employee_tags.py new file mode 100644 index 00000000..9f9405f5 --- /dev/null +++ b/apps/establishment/migrations/0040_employee_tags.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-10-22 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tag', '0004_tag_priority'), + ('establishment', '0039_establishmentsubtype_index_name'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='tags', + field=models.ManyToManyField(related_name='employees', to='tag.Tag', verbose_name='Tags'), + ), + ] diff --git a/apps/establishment/migrations/0041_auto_20191023_0920.py b/apps/establishment/migrations/0041_auto_20191023_0920.py new file mode 100644 index 00000000..dc5b2e02 --- /dev/null +++ b/apps/establishment/migrations/0041_auto_20191023_0920.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-23 09:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('establishment', '0040_employee_tags'), + ] + + operations = [ + migrations.AlterField( + model_name='establishment', + name='slug', + field=models.SlugField(max_length=255, null=True, unique=True, verbose_name='Establishment slug'), + ), + ] diff --git a/apps/establishment/migrations/0042_establishment_tz.py b/apps/establishment/migrations/0042_establishment_tz.py new file mode 100644 index 00000000..e804242f --- /dev/null +++ b/apps/establishment/migrations/0042_establishment_tz.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.4 on 2019-10-21 17:33 + +import timezone_field.fields +from django.db import migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + + dependencies = [ + ('establishment', '0041_auto_20191023_0920'), + ] + + operations = [ + migrations.AddField( + model_name='establishment', + name='tz', + field=timezone_field.fields.TimeZoneField(default=settings.TIME_ZONE), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 4f7a73a5..7de88d91 100644 --- a/apps/establishment/models.py +++ b/apps/establishment/models.py @@ -1,6 +1,8 @@ """Establishment models.""" +from datetime import datetime from functools import reduce +import elasticsearch_dsl from django.conf import settings from django.contrib.contenttypes import fields as generic from django.contrib.gis.db.models.functions import Distance @@ -19,6 +21,7 @@ from main.models import Award from review.models import Review from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin, TranslatedFieldsMixin, BaseAttributes) +from timezone_field import TimeZoneField # todo: establishment type&subtypes check @@ -128,15 +131,15 @@ class EstablishmentQuerySet(models.QuerySet): else: return self.none() - # def es_search(self, value, locale=None): - # """Search text via ElasticSearch.""" - # from search_indexes.documents import EstablishmentDocument - # search = EstablishmentDocument.search().filter( - # Elastic_Q('match', name=value) | - # Elastic_Q('match', **{f'description.{locale}': value}) - # ).execute() - # ids = [result.meta.id for result in search] - # return self.filter(id__in=ids) + def es_search(self, value, locale=None): + """Search text via ElasticSearch.""" + from search_indexes.documents import EstablishmentDocument + search = EstablishmentDocument.search().filter( + elasticsearch_dsl.Q('match', name=value) | + elasticsearch_dsl.Q('match', **{f'description.{locale}': value}) + ).execute() + ids = [result.meta.id for result in search] + return self.filter(id__in=ids) def by_country_code(self, code): """Return establishments by country code""" @@ -204,7 +207,7 @@ class EstablishmentQuerySet(models.QuerySet): .filter(image_url__isnull=False, public_mark__gte=10) .has_published_reviews() .annotate_distance(point=establishment.location) - .order_by('distance')[:settings.LIMITING_QUERY_NUMBER] + .order_by('distance')[:settings.LIMITING_QUERY_OBJECTS] .values('id') ) return self.filter(id__in=subquery_filter_by_distance) \ @@ -224,7 +227,7 @@ class EstablishmentQuerySet(models.QuerySet): self.filter(image_url__isnull=False, public_mark__gte=10) .has_published_reviews() .annotate_distance(point=point) - .order_by('distance')[:settings.LIMITING_QUERY_NUMBER] + .order_by('distance')[:settings.LIMITING_QUERY_OBJECTS] .values('id') ) return self.filter(id__in=subquery_filter_by_distance) \ @@ -349,15 +352,13 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): verbose_name=_('Collections')) preview_image_url = models.URLField(verbose_name=_('Preview image URL path'), blank=True, null=True, default=None) - slug = models.SlugField(unique=True, max_length=50, null=True, - verbose_name=_('Establishment slug'), editable=True) + slug = models.SlugField(unique=True, max_length=255, null=True, + verbose_name=_('Establishment slug')) + tz = TimeZoneField(default=settings.TIME_ZONE) awards = generic.GenericRelation(to='main.Award', related_query_name='establishment') - # todo: remove after data merge - # tags = generic.GenericRelation(to='main.MetaDataContent') tags = models.ManyToManyField('tag.Tag', related_name='establishments', verbose_name=_('Tag')) - old_tags = generic.GenericRelation(to='main.MetaDataContent') reviews = generic.GenericRelation(to='review.Review') comments = generic.GenericRelation(to='comment.Comment') favorites = generic.GenericRelation(to='favorites.Favorites') @@ -419,6 +420,27 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): def best_price_carte(self): return 200 + @property + def works_noon(self): + """ Used for indexing working by day """ + return [ret.weekday for ret in self.schedule.all() if ret.works_at_noon] + + @property + def works_evening(self): + """ Used for indexing working by day """ + return [ret.weekday for ret in self.schedule.all() if ret.works_at_afternoon] + + @property + def works_now(self): + """ Is establishment working now """ + now_at_est_tz = datetime.now(tz=self.tz) + current_week = now_at_est_tz.weekday() + schedule_for_today = self.schedule.filter(weekday=current_week).first() + if schedule_for_today is None or schedule_for_today.closed_at is None or schedule_for_today.opening_at is None: + return False + time_at_est_tz = now_at_est_tz.time() + return schedule_for_today.closed_at > time_at_est_tz > schedule_for_today.opening_at + @property def tags_indexing(self): return [{'id': tag.metadata.id, @@ -439,8 +461,8 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): @property def the_most_recent_award(self): - return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)).latest( - field_name='vintage_year') + return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)) \ + .latest(field_name='vintage_year') @property def country_id(self): @@ -511,7 +533,8 @@ class Employee(BaseAttributes): establishments = models.ManyToManyField(Establishment, related_name='employees', through=EstablishmentEmployee,) awards = generic.GenericRelation(to='main.Award', related_query_name='employees') - tags = generic.GenericRelation(to='main.MetaDataContent') + tags = models.ManyToManyField('tag.Tag', related_name='employees', + verbose_name=_('Tags')) class Meta: """Meta class.""" diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index 59725710..714dd62d 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -41,6 +41,7 @@ class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): 'is_publish', 'guestonline_id', 'lastable_id', + 'tz', ] diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index 8ee9ddf7..0699d9d0 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -8,10 +8,10 @@ from comment import models as comment_models from establishment import filters from establishment import models, serializers from main import methods -from main.models import MetaDataContent from utils.pagination import EstablishmentPortionPagination from utils.permissions import IsCountryAdmin + class EstablishmentMixinView: """Establishment mixin.""" @@ -19,9 +19,12 @@ class EstablishmentMixinView: def get_queryset(self): """Overridden method 'get_queryset'.""" - return models.Establishment.objects.published() \ - .with_base_related() \ - .annotate_in_favorites(self.request.user) + qs = models.Establishment.objects.published() \ + .with_base_related() \ + .annotate_in_favorites(self.request.user) + if self.request.country_code: + qs = qs.by_country_code(self.request.country_code) + return qs class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView): @@ -30,13 +33,6 @@ class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView): filter_class = filters.EstablishmentFilter serializer_class = serializers.EstablishmentBaseSerializer - def get_queryset(self): - """Overridden method 'get_queryset'.""" - qs = super(EstablishmentListView, self).get_queryset() - if self.request.country_code: - qs = qs.by_country_code(self.request.country_code) - return qs - class EstablishmentRetrieveView(EstablishmentMixinView, generics.RetrieveAPIView): """Resource for getting a establishment.""" diff --git a/apps/gallery/admin.py b/apps/gallery/admin.py index fc20b0ee..e325a3ed 100644 --- a/apps/gallery/admin.py +++ b/apps/gallery/admin.py @@ -5,4 +5,9 @@ from gallery.models import Image @admin.register(Image) class ImageModelAdmin(admin.ModelAdmin): - """Image model admin""" + """Image model admin.""" + list_display = ['id', 'title', 'orientation_display', 'image_tag', ] + + def orientation_display(self, obj): + """Get image orientation name.""" + return obj.get_orientation_display() if obj.orientation else None diff --git a/apps/gallery/migrations/0002_auto_20190930_0714.py b/apps/gallery/migrations/0002_auto_20190930_0714.py new file mode 100644 index 00000000..8423910f --- /dev/null +++ b/apps/gallery/migrations/0002_auto_20190930_0714.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.4 on 2019-09-30 07:14 + +from django.db import migrations, models +import django.db.models.deletion +import easy_thumbnails.fields +import utils.methods + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='orientation', + field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'Horizontal'), (1, 'Vertical')], default=None, null=True, verbose_name='image orientation'), + ), + migrations.AddField( + model_name='image', + name='parent', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='parent_image', to='gallery.Image', verbose_name='parent image'), + ), + migrations.AddField( + model_name='image', + name='source', + field=models.PositiveSmallIntegerField(choices=[(0, 'Mobile'), (1, 'Web'), (2, 'All')], default=0, verbose_name='Source'), + ), + migrations.AddField( + model_name='image', + name='title', + field=models.CharField(default='', max_length=255, verbose_name='title'), + ), + migrations.AlterField( + model_name='image', + name='image', + field=easy_thumbnails.fields.ThumbnailerImageField(upload_to=utils.methods.image_path, verbose_name='image file'), + ), + ] diff --git a/apps/gallery/migrations/0003_auto_20191003_1228.py b/apps/gallery/migrations/0003_auto_20191003_1228.py new file mode 100644 index 00000000..4d054a29 --- /dev/null +++ b/apps/gallery/migrations/0003_auto_20191003_1228.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.4 on 2019-10-03 12:28 + +from django.db import migrations +import sorl.thumbnail.fields +import utils.methods + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_auto_20190930_0714'), + ] + + operations = [ + migrations.RemoveField( + model_name='image', + name='parent', + ), + migrations.AlterField( + model_name='image', + name='image', + field=sorl.thumbnail.fields.ImageField(upload_to=utils.methods.image_path, verbose_name='image file'), + ), + ] diff --git a/apps/gallery/models.py b/apps/gallery/models.py index 022a24e0..baed48fc 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -1,15 +1,34 @@ +from django.db import models from django.utils.translation import gettext_lazy as _ -from easy_thumbnails.fields import ThumbnailerImageField +from sorl.thumbnail import delete +from sorl.thumbnail.fields import ImageField as SORLImageField from utils.methods import image_path -from utils.models import ProjectBaseMixin, ImageMixin +from utils.models import ProjectBaseMixin, SORLImageMixin, PlatformMixin -class Image(ProjectBaseMixin, ImageMixin): +class ImageQuerySet(models.QuerySet): + """QuerySet for model Image.""" + + +class Image(ProjectBaseMixin, SORLImageMixin, PlatformMixin): """Image model.""" + HORIZONTAL = 0 + VERTICAL = 1 - image = ThumbnailerImageField(upload_to=image_path, - verbose_name=_('Image file')) + ORIENTATIONS = ( + (HORIZONTAL, _('Horizontal')), + (VERTICAL, _('Vertical')), + ) + + image = SORLImageField(upload_to=image_path, + verbose_name=_('image file')) + orientation = models.PositiveSmallIntegerField(choices=ORIENTATIONS, + blank=True, null=True, default=None, + verbose_name=_('image orientation')) + title = models.CharField(_('title'), max_length=255, default='') + + objects = ImageQuerySet.as_manager() class Meta: """Meta class.""" @@ -18,4 +37,22 @@ class Image(ProjectBaseMixin, ImageMixin): def __str__(self): """String representation""" - return str(self.id) + return f'{self.id}' + + def delete_image(self, completely: bool = True): + """ + Deletes an instance and crops of instance from media storage. + :param completely: if set to False then removed only crop neither original image. + """ + try: + # Delete from remote storage + delete(file_=self.image.file, delete_file=completely) + except FileNotFoundError: + pass + finally: + if completely: + # Delete an instance of image + super().delete() + + + diff --git a/apps/gallery/serializers.py b/apps/gallery/serializers.py index a7e3f0e1..e817cbd8 100644 --- a/apps/gallery/serializers.py +++ b/apps/gallery/serializers.py @@ -12,13 +12,20 @@ class ImageSerializer(serializers.ModelSerializer): # RESPONSE url = serializers.ImageField(source='image', read_only=True) + orientation_display = serializers.CharField(source='get_orientation_display', + read_only=True) class Meta: """Meta class""" model = models.Image - fields = ( + fields = [ 'id', 'file', - 'url' - ) - + 'url', + 'orientation', + 'orientation_display', + 'title', + ] + extra_kwargs = { + 'orientation': {'write_only': True} + } diff --git a/apps/gallery/tasks.py b/apps/gallery/tasks.py new file mode 100644 index 00000000..1a64d297 --- /dev/null +++ b/apps/gallery/tasks.py @@ -0,0 +1,23 @@ +"""Gallery app celery tasks.""" +import logging + +from celery import shared_task + +from . import models + +logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) +logger = logging.getLogger(__name__) + + +@shared_task +def delete_image(image_id: int, completely: bool = True): + """Delete an image from remote storage.""" + image_qs = models.Image.objects.filter(id=image_id) + if image_qs.exists(): + try: + image = image_qs.first() + image.delete_image(completely=completely) + except: + logger.error(f'TASK_NAME: delete_image\n' + f'DETAIL: Exception occurred while deleting an image ' + f'and related crops from remote storage: image_id - {image_id}') diff --git a/apps/gallery/urls.py b/apps/gallery/urls.py index 53d1c097..8258092c 100644 --- a/apps/gallery/urls.py +++ b/apps/gallery/urls.py @@ -6,5 +6,6 @@ from . import views app_name = 'gallery' urlpatterns = [ - path('upload/', views.ImageUploadView.as_view(), name='upload-image') + path('', views.ImageListCreateView.as_view(), name='list-create-image'), + path('/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy-image'), ] diff --git a/apps/gallery/views.py b/apps/gallery/views.py index 8a9195c3..2b155035 100644 --- a/apps/gallery/views.py +++ b/apps/gallery/views.py @@ -1,10 +1,30 @@ -from rest_framework import generics +from django.conf import settings +from django.db.transaction import on_commit +from rest_framework import generics, status +from rest_framework.response import Response -from . import models, serializers +from . import tasks, models, serializers -class ImageUploadView(generics.CreateAPIView): - """Upload image to gallery""" +class ImageBaseView(generics.GenericAPIView): + """Base Image view.""" model = models.Image queryset = models.Image.objects.all() serializer_class = serializers.ImageSerializer + + +class ImageListCreateView(ImageBaseView, generics.ListCreateAPIView): + """List/Create Image view.""" + + +class ImageRetrieveDestroyView(ImageBaseView, generics.RetrieveDestroyAPIView): + """Destroy view for model Image""" + + def delete(self, request, *args, **kwargs): + """Override destroy view""" + instance = self.get_object() + if settings.USE_CELERY: + on_commit(lambda: tasks.delete_image.delay(image_id=instance.id)) + else: + on_commit(lambda: tasks.delete_image(image_id=instance.id)) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/location/models.py b/apps/location/models.py index 2b7aa363..da645de6 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -5,8 +5,9 @@ from django.db.models.signals import post_save from django.db.transaction import on_commit from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ -from utils.models import ProjectBaseMixin, SVGImageMixin, TranslatedFieldsMixin, TJSONField + from translation.models import Language +from utils.models import ProjectBaseMixin, SVGImageMixin, TranslatedFieldsMixin, TJSONField class Country(TranslatedFieldsMixin, SVGImageMixin, ProjectBaseMixin): @@ -53,6 +54,14 @@ class Region(models.Model): return self.name +class CityQuerySet(models.QuerySet): + """Extended queryset for City model.""" + + def by_country_code(self, code): + """Return establishments by country code""" + return self.filter(country__code=code) + + class City(models.Model): """Region model.""" @@ -68,6 +77,8 @@ class City(models.Model): is_island = models.BooleanField(_('is island'), default=False) + objects = CityQuerySet.as_manager() + class Meta: verbose_name_plural = _('cities') verbose_name = _('city') @@ -77,7 +88,6 @@ class City(models.Model): class Address(models.Model): - """Address model.""" city = models.ForeignKey(City, verbose_name=_('city'), on_delete=models.CASCADE) street_name_1 = models.CharField( diff --git a/apps/location/urls/mobile.py b/apps/location/urls/mobile.py index b808d5b6..879ded79 100644 --- a/apps/location/urls/mobile.py +++ b/apps/location/urls/mobile.py @@ -1,7 +1,6 @@ """Location app mobile urlconf.""" from location.urls.common import urlpatterns as common_urlpatterns - urlpatterns = [] -urlpatterns.extend(common_urlpatterns) \ No newline at end of file +urlpatterns.extend(common_urlpatterns) diff --git a/apps/location/views/common.py b/apps/location/views/common.py index 792fce91..b4a3f1cb 100644 --- a/apps/location/views/common.py +++ b/apps/location/views/common.py @@ -10,7 +10,7 @@ class CountryViewMixin(generics.GenericAPIView): """View Mixin for model Country""" serializer_class = serializers.CountrySerializer - permission_classes = (permissions.AllowAny, ) + permission_classes = (permissions.AllowAny,) queryset = models.Country.objects.all() @@ -56,7 +56,7 @@ class RegionRetrieveView(RegionViewMixin, generics.RetrieveAPIView): class RegionListView(RegionViewMixin, generics.ListAPIView): """List view for model Country""" - permission_classes = (permissions.AllowAny, ) + permission_classes = (permissions.AllowAny,) serializer_class = serializers.CountrySerializer @@ -83,9 +83,15 @@ class CityRetrieveView(CityViewMixin, generics.RetrieveAPIView): class CityListView(CityViewMixin, generics.ListAPIView): """List view for model City""" - permission_classes = (permissions.AllowAny, ) + permission_classes = (permissions.AllowAny,) serializer_class = serializers.CitySerializer + def get_queryset(self): + qs = super().get_queryset() + if self.request.country_code: + qs = qs.by_country_code(self.request.country_code) + return qs + class CityDestroyView(CityViewMixin, generics.DestroyAPIView): """Destroy view for model City""" @@ -110,7 +116,5 @@ class AddressRetrieveView(AddressViewMixin, generics.RetrieveAPIView): class AddressListView(AddressViewMixin, generics.ListAPIView): """List view for model Address""" - permission_classes = (permissions.AllowAny, ) + permission_classes = (permissions.AllowAny,) serializer_class = serializers.AddressDetailSerializer - - diff --git a/apps/main/migrations/0019_auto_20191022_1359.py b/apps/main/migrations/0019_auto_20191022_1359.py new file mode 100644 index 00000000..127ac55d --- /dev/null +++ b/apps/main/migrations/0019_auto_20191022_1359.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.4 on 2019-10-22 13:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0018_feature_source'), + ] + + operations = [ + migrations.RemoveField( + model_name='metadatacategory', + name='content_type', + ), + migrations.RemoveField( + model_name='metadatacategory', + name='country', + ), + migrations.RemoveField( + model_name='metadatacontent', + name='content_type', + ), + migrations.RemoveField( + model_name='metadatacontent', + name='metadata', + ), + migrations.DeleteModel( + name='MetaData', + ), + migrations.DeleteModel( + name='MetaDataCategory', + ), + migrations.DeleteModel( + name='MetaDataContent', + ), + ] diff --git a/apps/main/migrations/0019_award_image_url.py b/apps/main/migrations/0019_award_image_url.py new file mode 100644 index 00000000..9f5a73ed --- /dev/null +++ b/apps/main/migrations/0019_award_image_url.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-22 14:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0018_feature_source'), + ] + + operations = [ + migrations.AddField( + model_name='award', + name='image_url', + field=models.URLField(blank=True, default=None, null=True, verbose_name='Image URL path'), + ), + ] diff --git a/apps/main/migrations/0020_merge_20191023_0750.py b/apps/main/migrations/0020_merge_20191023_0750.py new file mode 100644 index 00000000..eac41bfc --- /dev/null +++ b/apps/main/migrations/0020_merge_20191023_0750.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-23 07:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0019_award_image_url'), + ('main', '0019_auto_20191022_1359'), + ] + + operations = [ + ] diff --git a/apps/main/migrations/0021_auto_20191023_0924.py b/apps/main/migrations/0021_auto_20191023_0924.py new file mode 100644 index 00000000..6bd6e1d6 --- /dev/null +++ b/apps/main/migrations/0021_auto_20191023_0924.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-23 09:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0020_merge_20191023_0750'), + ] + + operations = [ + migrations.AlterField( + model_name='feature', + name='slug', + field=models.SlugField(max_length=255, unique=True), + ), + ] diff --git a/apps/main/migrations/0022_auto_20191023_1113.py b/apps/main/migrations/0022_auto_20191023_1113.py new file mode 100644 index 00000000..6ed60051 --- /dev/null +++ b/apps/main/migrations/0022_auto_20191023_1113.py @@ -0,0 +1,57 @@ +# Generated by Django 2.2.4 on 2019-10-23 11:13 + +from django.db import migrations, models +import django.db.models.deletion +import utils.models + + +def fill_currency_name(apps, schema_editor): + # We can't import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + Currency = apps.get_model('main', 'Currency') + for currency in Currency.objects.all(): + currency.name_json = {'en-GB': currency.name} + currency.save() + + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0021_auto_20191023_0924'), + ] + + operations = [ + migrations.AddField( + model_name='currency', + name='sign', + field=models.CharField(default='?', max_length=1, verbose_name='sign'), + preserve_default=False, + ), + migrations.AddField( + model_name='currency', + name='slug', + field=models.SlugField(default='?', max_length=255, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='sitesettings', + name='currency', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='main.Currency'), + ), + migrations.AddField( + model_name='currency', + name='name_json', + field=utils.models.TJSONField(blank=True, default=None, help_text='{"en-GB":"some text"}', null=True, verbose_name='name'), + ), + migrations.RunPython(fill_currency_name, migrations.RunPython.noop), + migrations.RemoveField( + model_name='currency', + name='name', + ), + migrations.RenameField( + model_name='currency', + old_name='name_json', + new_name='name', + ), + ] diff --git a/apps/main/models.py b/apps/main/models.py index 46598257..6af4e4d8 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -15,7 +15,8 @@ from location.models import Country from main import methods from review.models import Review from utils.models import (ProjectBaseMixin, TJSONField, - TranslatedFieldsMixin, ImageMixin, PlatformMixin) + TranslatedFieldsMixin, ImageMixin, + PlatformMixin, URLImageMixin) from utils.querysets import ContentTypeQuerySetMixin @@ -105,6 +106,22 @@ from utils.querysets import ContentTypeQuerySetMixin # +class Currency(TranslatedFieldsMixin, models.Model): + """Currency model.""" + name = TJSONField( + _('name'), null=True, blank=True, + default=None, help_text='{"en-GB":"some text"}') + sign = models.CharField(_('sign'), max_length=1) + slug = models.SlugField(max_length=255, unique=True) + + class Meta: + verbose_name = _('currency') + verbose_name_plural = _('currencies') + + def __str__(self): + return f'{self.name}' + + class SiteSettingsQuerySet(models.QuerySet): """Extended queryset for SiteSettings model.""" @@ -113,6 +130,7 @@ class SiteSettingsQuerySet(models.QuerySet): class SiteSettings(ProjectBaseMixin): + subdomain = models.CharField(max_length=255, db_index=True, unique=True, verbose_name=_('Subdomain')) country = models.OneToOneField(Country, on_delete=models.PROTECT, @@ -134,6 +152,7 @@ class SiteSettings(ProjectBaseMixin): verbose_name=_('Config')) ad_config = models.TextField(blank=True, null=True, default=None, verbose_name=_('AD config')) + currency = models.ForeignKey(Currency, on_delete=models.PROTECT, null=True, default=None) objects = SiteSettingsQuerySet.as_manager() @@ -181,7 +200,7 @@ class Page(models.Model): class Feature(ProjectBaseMixin, PlatformMixin): """Feature model.""" - slug = models.CharField(max_length=255, unique=True) + slug = models.SlugField(max_length=255, unique=True) priority = models.IntegerField(unique=True, null=True, default=None) route = models.ForeignKey(Page, on_delete=models.PROTECT, null=True, default=None) site_settings = models.ManyToManyField(SiteSettings, through='SiteFeature') @@ -226,7 +245,7 @@ class SiteFeature(ProjectBaseMixin): unique_together = ('site_settings', 'feature') -class Award(TranslatedFieldsMixin, models.Model): +class Award(TranslatedFieldsMixin, URLImageMixin, models.Model): """Award model.""" award_type = models.ForeignKey('main.AwardType', on_delete=models.CASCADE) title = TJSONField( @@ -256,61 +275,6 @@ class AwardType(models.Model): return self.name -class MetaDataCategory(models.Model): - """MetaData category model.""" - - country = models.ForeignKey( - 'location.Country', null=True, default=None, on_delete=models.CASCADE) - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - public = models.BooleanField() - - -class MetaData(TranslatedFieldsMixin, models.Model): - """MetaData model.""" - label = TJSONField( - _('label'), null=True, blank=True, - default=None, help_text='{"en-GB":"some text"}') - category = models.ForeignKey( - MetaDataCategory, verbose_name=_('category'), on_delete=models.CASCADE) - - class Meta: - verbose_name = _('metadata') - verbose_name_plural = _('metadata') - - def __str__(self): - label = 'None' - lang = TranslationSettings.get_solo().default_language - if self.label and lang in self.label: - label = self.label[lang] - return f'id:{self.id}-{label}' - - -class MetaDataContentQuerySet(ContentTypeQuerySetMixin): - """QuerySets for MetaDataContent model.""" - - -class MetaDataContent(models.Model): - """MetaDataContent model.""" - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - object_id = models.PositiveIntegerField() - content_object = generic.GenericForeignKey('content_type', 'object_id') - metadata = models.ForeignKey(MetaData, on_delete=models.CASCADE) - - objects = MetaDataContentQuerySet.as_manager() - - -class Currency(models.Model): - """Currency model.""" - name = models.CharField(_('name'), max_length=50) - - class Meta: - verbose_name = _('currency') - verbose_name_plural = _('currencies') - - def __str__(self): - return f'{self.name}' - - class CarouselQuerySet(models.QuerySet): """Carousel QuerySet.""" diff --git a/apps/main/serializers.py b/apps/main/serializers.py index d425016d..8975af14 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers.py @@ -1,10 +1,10 @@ """Main app serializers.""" from rest_framework import serializers + from advertisement.serializers.web import AdvertisementSerializer from location.serializers import CountrySerializer from main import models -from establishment.models import Establishment -from utils.serializers import TranslatedField +from utils.serializers import ProjectModelSerializer class FeatureSerializer(serializers.ModelSerializer): @@ -40,11 +40,24 @@ class SiteFeatureSerializer(serializers.ModelSerializer): ) +class CurrencySerializer(ProjectModelSerializer): + """Currency serializer""" + + class Meta: + model = models.Currency + fields = [ + 'id', + 'name_translated', + 'sign' + ] + + class SiteSettingsSerializer(serializers.ModelSerializer): """Site settings serializer.""" published_features = SiteFeatureSerializer(source='published_sitefeatures', many=True, allow_null=True) + currency = CurrencySerializer() # todo: remove this country_code = serializers.CharField(source='subdomain', read_only=True) @@ -63,6 +76,7 @@ class SiteSettingsSerializer(serializers.ModelSerializer): 'config', 'ad_config', 'published_features', + 'currency' ) @@ -102,6 +116,7 @@ class AwardBaseSerializer(serializers.ModelSerializer): 'id', 'title_translated', 'vintage_year', + 'image_url', ] @@ -113,30 +128,6 @@ class AwardSerializer(AwardBaseSerializer): fields = AwardBaseSerializer.Meta.fields + ['award_type', ] -class MetaDataContentSerializer(serializers.ModelSerializer): - """MetaData content serializer.""" - - id = serializers.IntegerField(source='metadata.id', read_only=True) - label_translated = TranslatedField(source='metadata.label_translated') - - class Meta: - """Meta class.""" - - model = models.MetaDataContent - fields = ('id', 'label_translated') - - -class CurrencySerializer(serializers.ModelSerializer): - """Currency serializer""" - - class Meta: - model = models.Currency - fields = [ - 'id', - 'name' - ] - - class CarouselListSerializer(serializers.ModelSerializer): """Serializer for retrieving list of carousel items.""" model_name = serializers.CharField() diff --git a/apps/news/admin.py b/apps/news/admin.py index 5d7f79f0..5c1cbba7 100644 --- a/apps/news/admin.py +++ b/apps/news/admin.py @@ -1,4 +1,6 @@ from django.contrib import admin +from django.conf import settings + from news import models from .tasks import send_email_with_news @@ -12,9 +14,10 @@ class NewsTypeAdmin(admin.ModelAdmin): def send_email_action(modeladmin, request, queryset): news_ids = list(queryset.values_list("id", flat=True)) - - send_email_with_news.delay(news_ids) - + if settings.USE_CELERY: + send_email_with_news.delay(news_ids) + else: + send_email_with_news(news_ids) send_email_action.short_description = "Send the selected news by email" @@ -24,3 +27,8 @@ send_email_action.short_description = "Send the selected news by email" class NewsAdmin(admin.ModelAdmin): """News admin.""" actions = [send_email_action] + + +@admin.register(models.NewsGallery) +class NewsGalleryAdmin(admin.ModelAdmin): + """News gallery admin.""" diff --git a/apps/news/migrations/0015_newsgallery.py b/apps/news/migrations/0015_newsgallery.py new file mode 100644 index 00000000..8f81b4f8 --- /dev/null +++ b/apps/news/migrations/0015_newsgallery.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.4 on 2019-09-30 08:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_auto_20190930_0714'), + ] + + operations = [ + migrations.CreateModel( + name='NewsGallery', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='news_gallery', to='gallery.Image', verbose_name='gallery')), + ('news', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='news_gallery', to='news.News', verbose_name='news')), + ], + options={ + 'verbose_name': 'news gallery', + 'verbose_name_plural': 'news galleries', + }, + ), + ] diff --git a/apps/news/migrations/0016_news_gallery.py b/apps/news/migrations/0016_news_gallery.py new file mode 100644 index 00000000..7917cf26 --- /dev/null +++ b/apps/news/migrations/0016_news_gallery.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-09-30 12:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_auto_20190930_0714'), + ('news', '0015_newsgallery'), + ] + + operations = [ + migrations.AddField( + model_name='news', + name='gallery', + field=models.ManyToManyField(through='news.NewsGallery', to='gallery.Image'), + ), + ] diff --git a/apps/news/migrations/0020_merge_20190930_1251.py b/apps/news/migrations/0020_merge_20190930_1251.py new file mode 100644 index 00000000..deb45e63 --- /dev/null +++ b/apps/news/migrations/0020_merge_20190930_1251.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-09-30 12:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0019_news_author'), + ('news', '0016_news_gallery'), + ] + + operations = [ + ] diff --git a/apps/news/migrations/0021_merge_20191002_1300.py b/apps/news/migrations/0021_merge_20191002_1300.py new file mode 100644 index 00000000..ab0acf69 --- /dev/null +++ b/apps/news/migrations/0021_merge_20191002_1300.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-02 13:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0020_remove_news_author'), + ('news', '0020_merge_20190930_1251'), + ] + + operations = [ + ] diff --git a/apps/news/migrations/0022_merge_20191015_0912.py b/apps/news/migrations/0022_merge_20191015_0912.py new file mode 100644 index 00000000..08b5a613 --- /dev/null +++ b/apps/news/migrations/0022_merge_20191015_0912.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-15 09:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0021_auto_20191009_1408'), + ('news', '0021_merge_20191002_1300'), + ] + + operations = [ + ] diff --git a/apps/news/migrations/0023_auto_20191023_0903.py b/apps/news/migrations/0023_auto_20191023_0903.py new file mode 100644 index 00000000..e1380b30 --- /dev/null +++ b/apps/news/migrations/0023_auto_20191023_0903.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-23 09:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0022_auto_20191021_1306'), + ] + + operations = [ + migrations.AlterField( + model_name='news', + name='slug', + field=models.SlugField(max_length=255, unique=True, verbose_name='News slug'), + ), + ] diff --git a/apps/news/migrations/0023_merge_20191023_1000.py b/apps/news/migrations/0023_merge_20191023_1000.py new file mode 100644 index 00000000..75dfd1b2 --- /dev/null +++ b/apps/news/migrations/0023_merge_20191023_1000.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-23 10:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0021_merge_20191002_1300'), + ('news', '0022_auto_20191021_1306'), + ] + + operations = [ + ] diff --git a/apps/news/migrations/0024_newsgallery_is_main.py b/apps/news/migrations/0024_newsgallery_is_main.py new file mode 100644 index 00000000..aa7fffd9 --- /dev/null +++ b/apps/news/migrations/0024_newsgallery_is_main.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-23 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0023_merge_20191023_1000'), + ] + + operations = [ + migrations.AddField( + model_name='newsgallery', + name='is_main', + field=models.BooleanField(default=False, verbose_name='Is the main image'), + ), + ] diff --git a/apps/news/migrations/0025_merge_20191023_1317.py b/apps/news/migrations/0025_merge_20191023_1317.py new file mode 100644 index 00000000..96cb3980 --- /dev/null +++ b/apps/news/migrations/0025_merge_20191023_1317.py @@ -0,0 +1,15 @@ +# Generated by Django 2.2.4 on 2019-10-23 13:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0024_newsgallery_is_main'), + ('news', '0023_auto_20191023_0903'), + ('news', '0022_merge_20191015_0912'), + ] + + operations = [ + ] diff --git a/apps/news/models.py b/apps/news/models.py index 0460174a..1abd10e1 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -139,19 +139,13 @@ class News(BaseAttributes, TranslatedFieldsMixin): start = models.DateTimeField(verbose_name=_('Start')) end = models.DateTimeField(blank=True, null=True, default=None, verbose_name=_('End')) - slug = models.SlugField(unique=True, max_length=50, + slug = models.SlugField(unique=True, max_length=255, verbose_name=_('News slug')) playlist = models.IntegerField(_('playlist')) state = models.PositiveSmallIntegerField(default=WAITING, choices=STATE_CHOICES, verbose_name=_('State')) is_highlighted = models.BooleanField(default=False, verbose_name=_('Is highlighted')) - # TODO: metadata_keys - описание ключей для динамического построения полей метаданных - # TODO: metadata_values - Описание значений для динамических полей из MetadataKeys - image_url = models.URLField(blank=True, null=True, default=None, - verbose_name=_('Image URL path')) - preview_image_url = models.URLField(blank=True, null=True, default=None, - verbose_name=_('Preview image URL path')) template = models.PositiveIntegerField(choices=TEMPLATE_CHOICES, default=NEWSPAPER) address = models.ForeignKey('location.Address', blank=True, null=True, default=None, verbose_name=_('address'), @@ -161,6 +155,7 @@ class News(BaseAttributes, TranslatedFieldsMixin): verbose_name=_('country')) tags = models.ManyToManyField('tag.Tag', related_name='news', verbose_name=_('Tags')) + gallery = models.ManyToManyField('gallery.Image', through='news.NewsGallery') ratings = generic.GenericRelation(Rating) agenda = models.ForeignKey('news.Agenda', blank=True, null=True, @@ -197,3 +192,36 @@ class News(BaseAttributes, TranslatedFieldsMixin): @property def same_theme(self): return self.__class__.objects.same_theme(self)[:3] + + @property + def main_image(self): + return self.news_gallery.main_images().first().image + + +class NewsGalleryQuerySet(models.QuerySet): + """QuerySet for model News""" + + def main_images(self): + """Return objects with flag is_main is True""" + return self.filter(is_main=True) + + +class NewsGallery(models.Model): + + news = models.ForeignKey(News, null=True, + related_name='news_gallery', + on_delete=models.SET_NULL, + verbose_name=_('news')) + image = models.ForeignKey('gallery.Image', null=True, + related_name='news_gallery', + on_delete=models.SET_NULL, + verbose_name=_('gallery')) + is_main = models.BooleanField(default=False, + verbose_name=_('Is the main image')) + + objects = NewsGalleryQuerySet.as_manager() + + class Meta: + """NewsGallery meta class.""" + verbose_name = _('news gallery') + verbose_name_plural = _('news galleries') diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 10662880..6814965a 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -1,6 +1,9 @@ """News app common serializers.""" +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers + from account.serializers.common import UserBaseSerializer +from gallery.models import Image from location import models as location_models from location.serializers import CountrySimpleSerializer, AddressBaseSerializer from news import models @@ -42,6 +45,77 @@ class NewsBannerSerializer(ProjectModelSerializer): ) +class CropImageSerializer(serializers.Serializer): + """Serializer for crop images for News object.""" + + preview_url = serializers.SerializerMethodField() + promo_horizontal_web_url = serializers.SerializerMethodField() + promo_horizontal_mobile_url = serializers.SerializerMethodField() + tile_horizontal_web_url = serializers.SerializerMethodField() + tile_horizontal_mobile_url = serializers.SerializerMethodField() + tile_vertical_web_url = serializers.SerializerMethodField() + highlight_vertical_web_url = serializers.SerializerMethodField() + editor_web_url = serializers.SerializerMethodField() + editor_mobile_url = serializers.SerializerMethodField() + + def get_preview_url(self, obj): + """Get crop preview.""" + return obj.instance.get_image_url('news_preview') + + def get_promo_horizontal_web_url(self, obj): + """Get crop promo_horizontal_web.""" + return obj.instance.get_image_url('news_promo_horizontal_web') + + def get_promo_horizontal_mobile_url(self, obj): + """Get crop promo_horizontal_mobile.""" + return obj.instance.get_image_url('news_promo_horizontal_mobile') + + def get_tile_horizontal_web_url(self, obj): + """Get crop tile_horizontal_web.""" + return obj.instance.get_image_url('news_tile_horizontal_web') + + def get_tile_horizontal_mobile_url(self, obj): + """Get crop tile_horizontal_mobile.""" + return obj.instance.get_image_url('news_tile_horizontal_mobile') + + def get_tile_vertical_web_url(self, obj): + """Get crop tile_vertical_web.""" + return obj.instance.get_image_url('news_tile_vertical_web') + + def get_highlight_vertical_web_url(self, obj): + """Get crop highlight_vertical_web.""" + return obj.instance.get_image_url('news_highlight_vertical_web') + + def get_editor_web_url(self, obj): + """Get crop editor_web.""" + return obj.instance.get_image_url('news_editor_web') + + def get_editor_mobile_url(self, obj): + """Get crop editor_mobile.""" + return obj.instance.get_image_url('news_editor_mobile') + + +class NewsImageSerializer(serializers.ModelSerializer): + """Serializer for returning crop images of news image.""" + orientation_display = serializers.CharField(source='get_orientation_display', + read_only=True) + original_url = serializers.URLField(source='image.url') + auto_crop_images = CropImageSerializer(source='image', allow_null=True) + + class Meta: + model = Image + fields = [ + 'id', + 'title', + 'orientation_display', + 'original_url', + 'auto_crop_images', + ] + extra_kwargs = { + 'orientation': {'write_only': True} + } + + class NewsTypeSerializer(serializers.ModelSerializer): """News type serializer.""" @@ -56,7 +130,7 @@ class NewsBaseSerializer(ProjectModelSerializer): """Base serializer for News model.""" # read only fields - title_translated = TranslatedField(source='title') + title_translated = TranslatedField() subtitle_translated = TranslatedField() # related fields @@ -72,14 +146,40 @@ class NewsBaseSerializer(ProjectModelSerializer): 'title_translated', 'subtitle_translated', 'is_highlighted', - 'image_url', - 'preview_image_url', 'news_type', 'tags', 'slug', ) +class NewsListSerializer(NewsBaseSerializer): + """List serializer for News model.""" + + # read only fields + title_translated = TranslatedField() + subtitle_translated = TranslatedField() + + # related fields + news_type = NewsTypeSerializer(read_only=True) + tags = TagBaseSerializer(read_only=True, many=True) + image = NewsImageSerializer(source='main_image', allow_null=True) + + class Meta: + """Meta class.""" + + model = models.News + fields = ( + 'id', + 'title_translated', + 'subtitle_translated', + 'is_highlighted', + 'news_type', + 'tags', + 'slug', + 'image', + ) + + class NewsDetailSerializer(NewsBaseSerializer): """News detail serializer.""" @@ -88,6 +188,7 @@ class NewsDetailSerializer(NewsBaseSerializer): author = UserBaseSerializer(source='created_by', read_only=True) state_display = serializers.CharField(source='get_state_display', read_only=True) + gallery = NewsImageSerializer(read_only=True, many=True) class Meta(NewsBaseSerializer.Meta): """Meta class.""" @@ -102,6 +203,7 @@ class NewsDetailSerializer(NewsBaseSerializer): 'state_display', 'author', 'country', + 'gallery', ) @@ -160,3 +262,46 @@ class NewsBackOfficeDetailSerializer(NewsBackOfficeBaseSerializer, 'template', 'template_display', ) + + +class NewsBackOfficeGallerySerializer(serializers.ModelSerializer): + """Serializer class for model NewsGallery.""" + class Meta: + """Meta class""" + model = models.NewsGallery + fields = [ + 'id', + 'is_main', + ] + + def get_request_kwargs(self): + """Get url kwargs from request.""" + return self.context.get('request').parser_context.get('kwargs') + + def validate(self, attrs): + """Override validate method.""" + news_pk = self.get_request_kwargs().get('pk') + image_id = self.get_request_kwargs().get('image_id') + is_main = attrs.get('is_main') + + news_qs = models.News.objects.filter(pk=news_pk) + image_qs = Image.objects.filter(id=image_id) + + if not news_qs.exists(): + raise serializers.ValidationError({'detail': _('News not found')}) + if not image_qs.exists(): + raise serializers.ValidationError({'detail': _('Image not found')}) + + news = news_qs.first() + image = image_qs.first() + + if news.news_gallery.filter(image=image).exists(): + raise serializers.ValidationError({'detail': _('Image is already added')}) + + if is_main and news.news_gallery.main_images().exists(): + raise serializers.ValidationError({'detail': _('Main image is already added')}) + + attrs['news'] = news + attrs['image'] = image + + return attrs diff --git a/apps/news/urls/back.py b/apps/news/urls/back.py index 8522592e..4ab11727 100644 --- a/apps/news/urls/back.py +++ b/apps/news/urls/back.py @@ -1,5 +1,6 @@ """News app urlpatterns for backoffice""" from django.urls import path + from news import views app_name = 'news' @@ -7,5 +8,9 @@ app_name = 'news' urlpatterns = [ path('', views.NewsBackOfficeLCView.as_view(), name='list-create'), path('/', views.NewsBackOfficeRUDView.as_view(), - name='retrieve-update-destroy'), + name='gallery-retrieve-update-destroy'), + path('/gallery/', views.NewsBackOfficeGalleryListView.as_view(), + name='gallery-list'), + path('/gallery//', views.NewsBackOfficeGalleryCreateDestroyView.as_view(), + name='gallery-create-destroy'), ] \ No newline at end of file diff --git a/apps/news/views.py b/apps/news/views.py index 78a3502b..c2a71eca 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,6 +1,13 @@ """News app views.""" from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions +from django.conf import settings +from django.db.transaction import on_commit +from django.shortcuts import get_object_or_404 +from rest_framework import generics, permissions, status +from rest_framework.response import Response + +from gallery.tasks import delete_image from news import filters, models, serializers from rating.tasks import add_rating from utils.permissions import IsCountryAdmin, IsContentPageManager @@ -23,7 +30,7 @@ class NewsMixinView: class NewsListView(NewsMixinView, generics.ListAPIView): """News list view.""" - + serializer_class = serializers.NewsListSerializer filter_class = filters.NewsListFilterSet @@ -74,6 +81,64 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, return super().get_queryset().with_extended_related() +class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, + generics.CreateAPIView, + generics.DestroyAPIView): + """Resource for a create gallery for news for back-office users.""" + serializer_class = serializers.NewsBackOfficeGallerySerializer + + def get_object(self): + """ + Returns the object the view is displaying. + """ + news_qs = self.filter_queryset(self.get_queryset()) + + news = get_object_or_404(news_qs, pk=self.kwargs['pk']) + gallery = get_object_or_404(news.news_gallery, image_id=self.kwargs['image_id']) + + # May raise a permission denied + self.check_object_permissions(self.request, gallery) + + return gallery + + def create(self, request, *args, **kwargs): + """Override create method""" + super().create(request, *args, **kwargs) + return Response(status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + """Override destroy method.""" + gallery_obj = self.get_object() + if settings.USE_CELERY: + on_commit(lambda: delete_image.delay(image_id=gallery_obj.image.id, + completely=False)) + else: + on_commit(lambda: delete_image(image_id=gallery_obj.image.id, + completely=False)) + # Delete an instances of NewsGallery model + gallery_obj.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIView): + """Resource for returning gallery for news for back-office users.""" + serializer_class = serializers.NewsImageSerializer + + def get_object(self): + """Override get_object method.""" + qs = super(NewsBackOfficeGalleryListView, self).get_queryset() + news = get_object_or_404(qs, pk=self.kwargs['pk']) + + # May raise a permission denied + self.check_object_permissions(self.request, news) + + return news + + def get_queryset(self): + """Override get_queryset method.""" + return self.get_object().gallery.all() + + class NewsBackOfficeRUDView(NewsBackOfficeMixinView, generics.RetrieveUpdateDestroyAPIView): """Resource for detailed information about news for back-office users.""" diff --git a/apps/products/serializers/__init__.py b/apps/product/__init__.py similarity index 100% rename from apps/products/serializers/__init__.py rename to apps/product/__init__.py diff --git a/apps/product/apps.py b/apps/product/apps.py new file mode 100644 index 00000000..7d1fc554 --- /dev/null +++ b/apps/product/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class ProductConfig(AppConfig): + name = 'product' + verbose_name = _('Product') diff --git a/apps/products/urls/__init__.py b/apps/product/migrations/__init__.py similarity index 100% rename from apps/products/urls/__init__.py rename to apps/product/migrations/__init__.py diff --git a/apps/product/models.py b/apps/product/models.py new file mode 100644 index 00000000..41f0c7c6 --- /dev/null +++ b/apps/product/models.py @@ -0,0 +1,110 @@ +"""Product app models.""" +from django.db import models +from django.contrib.postgres.fields import JSONField +from django.utils.translation import gettext_lazy as _ +from utils.models import (BaseAttributes, ProjectBaseMixin, + TranslatedFieldsMixin, TJSONField) + + +class ProductType(TranslatedFieldsMixin, ProjectBaseMixin): + """ProductType model.""" + + name = TJSONField(blank=True, null=True, default=None, + verbose_name=_('Name'), help_text='{"en-GB":"some text"}') + index_name = models.CharField(max_length=50, unique=True, db_index=True, + verbose_name=_('Index name')) + use_subtypes = models.BooleanField(_('Use subtypes'), default=True) + + class Meta: + """Meta class.""" + + verbose_name = _('Product type') + verbose_name_plural = _('Product types') + + +class ProductSubType(TranslatedFieldsMixin, ProjectBaseMixin): + """ProductSubtype model.""" + + product_type = models.ForeignKey(ProductType, on_delete=models.CASCADE, + related_name='subtypes', + verbose_name=_('Product type')) + name = TJSONField(blank=True, null=True, default=None, + verbose_name=_('Name'), help_text='{"en-GB":"some text"}') + index_name = models.CharField(max_length=50, unique=True, db_index=True, + verbose_name=_('Index name')) + + class Meta: + """Meta class.""" + + verbose_name = _('Product type') + verbose_name_plural = _('Product types') + + +class ProductManager(models.Manager): + """Extended manager for Product model.""" + + +class ProductQuerySet(models.QuerySet): + """Product queryset.""" + + def common(self): + return self.filter(category=self.model.COMMON) + + def online(self): + return self.filter(category=self.model.ONLINE) + + +class Product(TranslatedFieldsMixin, BaseAttributes): + """Product models.""" + + COMMON = 0 + ONLINE = 1 + + CATEGORY_CHOICES = ( + (COMMON, _('Common')), + (ONLINE, _('Online')), + ) + + category = models.PositiveIntegerField(choices=CATEGORY_CHOICES, + default=COMMON) + name = TJSONField(_('Name'), null=True, blank=True, default=None, + help_text='{"en-GB":"some text"}') + description = TJSONField(_('Description'), null=True, blank=True, + default=None, help_text='{"en-GB":"some text"}') + characteristics = JSONField(_('Characteristics')) + country = models.ForeignKey('location.Country', on_delete=models.PROTECT, + verbose_name=_('Country')) + available = models.BooleanField(_('Available'), default=True) + type = models.ForeignKey(ProductType, on_delete=models.PROTECT, + related_name='products', verbose_name=_('Type')) + subtypes = models.ManyToManyField(ProductSubType, related_name='products', + verbose_name=_('Subtypes')) + + objects = ProductManager.from_queryset(ProductQuerySet)() + + class Meta: + """Meta class.""" + + verbose_name = _('Product') + verbose_name_plural = _('Products') + + +class OnlineProductManager(ProductManager): + """Extended manger for OnlineProduct model.""" + + def get_queryset(self): + """Overrided get_queryset method.""" + return super().get_queryset().online() + + +class OnlineProduct(Product): + """Online product.""" + + objects = OnlineProductManager.from_queryset(ProductQuerySet)() + + class Meta: + """Meta class.""" + + proxy = True + verbose_name = _('Online product') + verbose_name_plural = _('Online products') diff --git a/apps/products/views/__init__.py b/apps/product/serializers/__init__.py similarity index 100% rename from apps/products/views/__init__.py rename to apps/product/serializers/__init__.py diff --git a/apps/products/serializers/common.py b/apps/product/serializers/common.py similarity index 100% rename from apps/products/serializers/common.py rename to apps/product/serializers/common.py diff --git a/apps/products/serializers/mobile.py b/apps/product/serializers/mobile.py similarity index 100% rename from apps/products/serializers/mobile.py rename to apps/product/serializers/mobile.py diff --git a/apps/products/serializers/web.py b/apps/product/serializers/web.py similarity index 100% rename from apps/products/serializers/web.py rename to apps/product/serializers/web.py diff --git a/apps/products/urls/back.py b/apps/product/urls/__init__.py similarity index 100% rename from apps/products/urls/back.py rename to apps/product/urls/__init__.py diff --git a/apps/products/views/back.py b/apps/product/urls/back.py similarity index 100% rename from apps/products/views/back.py rename to apps/product/urls/back.py diff --git a/apps/products/urls/common.py b/apps/product/urls/common.py similarity index 100% rename from apps/products/urls/common.py rename to apps/product/urls/common.py diff --git a/apps/products/urls/mobile.py b/apps/product/urls/mobile.py similarity index 100% rename from apps/products/urls/mobile.py rename to apps/product/urls/mobile.py diff --git a/apps/products/urls/web.py b/apps/product/urls/web.py similarity index 100% rename from apps/products/urls/web.py rename to apps/product/urls/web.py diff --git a/apps/products/views/common.py b/apps/product/views/__init__.py similarity index 100% rename from apps/products/views/common.py rename to apps/product/views/__init__.py diff --git a/apps/products/views/mobile.py b/apps/product/views/back.py similarity index 100% rename from apps/products/views/mobile.py rename to apps/product/views/back.py diff --git a/apps/products/views/web.py b/apps/product/views/common.py similarity index 100% rename from apps/products/views/web.py rename to apps/product/views/common.py diff --git a/apps/product/views/mobile.py b/apps/product/views/mobile.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/product/views/web.py b/apps/product/views/web.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/products/admin.py b/apps/products/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/apps/products/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/apps/products/apps.py b/apps/products/apps.py deleted file mode 100644 index 17d75292..00000000 --- a/apps/products/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.apps import AppConfig -from django.utils.translation import gettext_lazy as _ - - -class ProductsConfig(AppConfig): - """Products model.""" - name = 'products' - verbose_name = _('products') diff --git a/apps/products/models.py b/apps/products/models.py deleted file mode 100644 index 3c0e6ee8..00000000 --- a/apps/products/models.py +++ /dev/null @@ -1,63 +0,0 @@ -# from django.contrib.postgres.fields import JSONField -# from django.db import models -# from django.utils.translation import gettext_lazy as _ -# -# from utils.models import BaseAttributes -# -# -# class ProductManager(models.Manager): -# """Product manager.""" -# -# -# class ProductQuerySet(models.QuerySet): -# """Product queryset.""" -# -# -# class Product(BaseAttributes): -# """Product models.""" -# name = models.CharField(_('name'), max_length=255) -# country = models.ForeignKey('location.Country', on_delete=models.CASCADE) -# region = models.ForeignKey('location.Region', on_delete=models.CASCADE) -# # ASK: What is the "subregion" -# -# description = JSONField(_('description')) -# characteristics = JSONField(_('characteristics')) -# metadata_values = JSONField(_('metadata_values')) -# # common_relations_id -# # product_region_id -# code = models.CharField(_('code'), max_length=255) -# available = models.BooleanField(_('available')) -# -# # dealer_type -# # target_scope -# # target_type -# # rank -# # excluding_tax_unit_price -# # column_21 -# # currencies_id -# # vintage -# # producer_price -# # producer_description -# # annual_produced_quantity -# # production_method_description -# # unit_name -# # unit -# # unit_values -# # organic_source -# # certificates -# # establishments_id -# # restrictions -# # -# objects = ProductManager.from_queryset(ProductQuerySet)() -# -# class Meta: -# verbose_name = _('product') -# verbose_name_plural = _('products') -# -# -# class ProductType(models.Model): -# """ProductType model.""" -# -# class Meta: -# verbose_name_plural = _('product types') -# verbose_name = _('product type') diff --git a/apps/products/tests.py b/apps/products/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/apps/products/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/products/views/views.py b/apps/products/views/views.py deleted file mode 100644 index 60f00ef0..00000000 --- a/apps/products/views/views.py +++ /dev/null @@ -1 +0,0 @@ -# Create your views here. diff --git a/apps/search_indexes/documents/establishment.py b/apps/search_indexes/documents/establishment.py index c30d4c58..5d858321 100644 --- a/apps/search_indexes/documents/establishment.py +++ b/apps/search_indexes/documents/establishment.py @@ -32,6 +32,13 @@ class EstablishmentDocument(Document): }), }, multi=True) + works_evening = fields.ListField(fields.IntegerField( + attr='works_evening' + )) + works_noon = fields.ListField(fields.IntegerField( + attr='works_noon' + )) + works_now = fields.BooleanField(attr='works_now') tags = fields.ObjectField( properties={ 'id': fields.IntegerField(attr='id'), @@ -39,6 +46,14 @@ class EstablishmentDocument(Document): properties=OBJECT_FIELD_PROPERTIES), }, multi=True) + schedule = fields.ListField(fields.ObjectField( + properties={ + 'id': fields.IntegerField(attr='id'), + 'weekday': fields.IntegerField(attr='weekday'), + 'weekday_display': fields.KeywordField(attr='get_weekday_display'), + 'closed_at': fields.KeywordField(attr='closed_at_str'), + } + )) address = fields.ObjectField( properties={ 'id': fields.IntegerField(), diff --git a/apps/search_indexes/documents/news.py b/apps/search_indexes/documents/news.py index 99071e53..cd6fc089 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -43,8 +43,6 @@ class NewsDocument(Document): 'slug', 'state', 'is_highlighted', - 'image_url', - 'preview_image_url', 'template', ) related_models = [models.NewsType] diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index 18a1e240..535974a9 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -30,6 +30,15 @@ class AddressDocumentSerializer(serializers.Serializer): geo_lat = serializers.FloatField(allow_null=True, source='coordinates.lat') +class ScheduleDocumentSerializer(serializers.Serializer): + """Schedule serializer for ES Document""" + + id = serializers.IntegerField() + weekday = serializers.IntegerField() + weekday_display = serializers.CharField() + closed_at = serializers.CharField() + + class NewsDocumentSerializer(DocumentSerializer): """News document serializer.""" @@ -68,6 +77,7 @@ class EstablishmentDocumentSerializer(DocumentSerializer): address = AddressDocumentSerializer() tags = TagsDocumentSerializer(many=True) + schedule = ScheduleDocumentSerializer(many=True, allow_null=True) class Meta: """Meta class.""" @@ -84,6 +94,10 @@ class EstablishmentDocumentSerializer(DocumentSerializer): 'preview_image', 'address', 'tags', + 'schedule', + 'works_noon', + 'works_evening', + 'works_now', # 'collections', # 'establishment_type', # 'establishment_subtypes', diff --git a/apps/search_indexes/signals.py b/apps/search_indexes/signals.py index 77660a2c..3da50fb8 100644 --- a/apps/search_indexes/signals.py +++ b/apps/search_indexes/signals.py @@ -38,7 +38,7 @@ def update_document(sender, **kwargs): for establishment in establishments: registry.update(establishment) if model_name == 'establishmentsubtype': - if instance(instance, establishment_models.EstablishmentSubType): + if isinstance(instance, establishment_models.EstablishmentSubType): establishments = Establishment.objects.filter( establishment_subtypes=instance) for establishment in establishments: diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index 50c32fc7..25205bac 100644 --- a/apps/search_indexes/views.py +++ b/apps/search_indexes/views.py @@ -111,6 +111,9 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): }, 'tags_category_id': { 'field': 'tags.category.id', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + ], }, 'collection_type': { 'field': 'collections.collection_type' @@ -121,6 +124,24 @@ class EstablishmentDocumentViewSet(BaseDocumentViewSet): 'establishment_subtypes': { 'field': 'establishment_subtypes.id' }, + 'works_noon': { + 'field': 'works_noon', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + ], + }, + 'works_evening': { + 'field': 'works_evening', + 'lookups': [ + constants.LOOKUP_QUERY_IN, + ], + }, + 'works_now': { + 'field': 'works_now', + 'lookups': [ + constants.LOOKUP_FILTER_TERM, + ] + }, } geo_spatial_filter_fields = { diff --git a/apps/tag/migrations/0005_tagcategory_name_indexing.py b/apps/tag/migrations/0005_tagcategory_name_indexing.py new file mode 100644 index 00000000..2547481a --- /dev/null +++ b/apps/tag/migrations/0005_tagcategory_name_indexing.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-22 15:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tag', '0004_tag_priority'), + ] + + operations = [ + migrations.AddField( + model_name='tagcategory', + name='index_name', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='indexing name', unique=True), + ), + ] diff --git a/apps/tag/models.py b/apps/tag/models.py index 44eacddc..26079849 100644 --- a/apps/tag/models.py +++ b/apps/tag/models.py @@ -5,6 +5,14 @@ from configuration.models import TranslationSettings from utils.models import TJSONField, TranslatedFieldsMixin +class TagQuerySet(models.QuerySet): + def filter_chosen(self): + return self.exclude(priority__isnull=True) + + def order_by_priority(self): + return self.order_by('priority') + + class Tag(TranslatedFieldsMixin, models.Model): """Tag model.""" @@ -16,6 +24,8 @@ class Tag(TranslatedFieldsMixin, models.Model): verbose_name=_('Category')) priority = models.IntegerField(unique=True, null=True, default=None) + objects = TagQuerySet.as_manager() + class Meta: """Meta class.""" @@ -56,7 +66,7 @@ class TagCategoryQuerySet(models.QuerySet): def with_tags(self, switcher=True): """Filter by existing tags.""" - return self.filter(tags__isnull=not switcher) + return self.exclude(tags__isnull=switcher) class TagCategory(TranslatedFieldsMixin, models.Model): @@ -69,6 +79,8 @@ class TagCategory(TranslatedFieldsMixin, models.Model): on_delete=models.SET_NULL, null=True, default=None) public = models.BooleanField(default=False) + index_name = models.CharField(max_length=255, blank=True, null=True, + verbose_name=_('indexing name'), unique=True) objects = TagCategoryQuerySet.as_manager() diff --git a/apps/tag/serializers.py b/apps/tag/serializers.py index 6ee55c84..790c8926 100644 --- a/apps/tag/serializers.py +++ b/apps/tag/serializers.py @@ -49,7 +49,8 @@ class TagCategoryBaseSerializer(serializers.ModelSerializer): fields = ( 'id', 'label_translated', - 'tags' + 'index_name', + 'tags', ) diff --git a/apps/tag/urls/mobile.py b/apps/tag/urls/mobile.py new file mode 100644 index 00000000..561697d2 --- /dev/null +++ b/apps/tag/urls/mobile.py @@ -0,0 +1,7 @@ +from tag.urls.web import urlpatterns as common_urlpatterns + +urlpatterns = [ + +] + +urlpatterns.extend(common_urlpatterns) diff --git a/apps/tag/urls/web.py b/apps/tag/urls/web.py index c99253eb..ec63931e 100644 --- a/apps/tag/urls/web.py +++ b/apps/tag/urls/web.py @@ -7,6 +7,7 @@ app_name = 'tag' router = SimpleRouter() router.register(r'categories', views.TagCategoryViewSet) +router.register(r'chosen_tags', views.ChosenTagsView, basename='Tag') urlpatterns = [ diff --git a/apps/tag/views.py b/apps/tag/views.py index 2a0ff0f5..6d98f39f 100644 --- a/apps/tag/views.py +++ b/apps/tag/views.py @@ -1,11 +1,22 @@ """Tag views.""" -from rest_framework import viewsets, mixins, status +from rest_framework import viewsets, mixins, status, generics from rest_framework.decorators import action from rest_framework.response import Response from tag import filters, models, serializers from rest_framework import permissions +class ChosenTagsView(generics.ListAPIView, viewsets.GenericViewSet): + pagination_class = None + permission_classes = (permissions.AllowAny,) + serializer_class = serializers.TagBaseSerializer + + def get_queryset(self): + return models.Tag.objects\ + .filter_chosen() \ + .order_by_priority() + + # User`s views & viewsets class TagCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): """ViewSet for TagCategory model.""" diff --git a/apps/timetable/models.py b/apps/timetable/models.py index 53670d02..35469c32 100644 --- a/apps/timetable/models.py +++ b/apps/timetable/models.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from datetime import time from utils.models import ProjectBaseMixin @@ -14,6 +15,8 @@ class Timetable(ProjectBaseMixin): SATURDAY = 5 SUNDAY = 6 + NOON = time(17, 0) + WEEKDAYS_CHOICES = ( (MONDAY, _('Monday')), (TUESDAY, _('Tuesday')), @@ -32,6 +35,18 @@ class Timetable(ProjectBaseMixin): opening_at = models.TimeField(verbose_name=_('Opening time'), null=True) closed_at = models.TimeField(verbose_name=_('Closed time'), null=True) + @property + def closed_at_str(self): + return str(self.closed_at) if self.closed_at else None + + @property + def works_at_noon(self): + return bool(self.closed_at and self.closed_at <= self.NOON) + + @property + def works_at_afternoon(self): + return bool(self.closed_at and self.closed_at > self.NOON) + class Meta: """Meta class.""" verbose_name = _('Timetable') diff --git a/apps/timetable/serialziers.py b/apps/timetable/serialziers.py index 1339cc8e..babe33c1 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -77,3 +77,17 @@ class ScheduleCreateSerializer(ScheduleRUDSerializer): schedule_qs.delete() establishment.schedule.add(instance) return instance + + +class TimetableSerializer(serializers.ModelSerializer): + """Serailzier for Timetable model.""" + weekday_display = serializers.CharField(source='get_weekday_display', + read_only=True) + + class Meta: + model = Timetable + fields = ( + 'id', + 'weekday_display', + 'works_at_noon', + ) diff --git a/apps/timetable/urls/__init__.py b/apps/timetable/urls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/timetable/urls.py b/apps/timetable/urls/common.py similarity index 67% rename from apps/timetable/urls.py rename to apps/timetable/urls/common.py index ff4f4f00..95593b20 100644 --- a/apps/timetable/urls.py +++ b/apps/timetable/urls/common.py @@ -6,5 +6,5 @@ from timetable import views app_name = 'timetable' urlpatterns = [ - path('', views.TimetableListView.as_view(), name='list') + # path('', views.TimetableListView.as_view(), name='list') ] \ No newline at end of file diff --git a/apps/timetable/urls/mobile.py b/apps/timetable/urls/mobile.py new file mode 100644 index 00000000..1c6419a3 --- /dev/null +++ b/apps/timetable/urls/mobile.py @@ -0,0 +1,6 @@ +from timetable.urls.common import urlpatterns as common_urlpatterns + + +urlpatterns = [] + +urlpatterns.extend(common_urlpatterns) \ No newline at end of file diff --git a/apps/timetable/urls/web.py b/apps/timetable/urls/web.py new file mode 100644 index 00000000..1c6419a3 --- /dev/null +++ b/apps/timetable/urls/web.py @@ -0,0 +1,6 @@ +from timetable.urls.common import urlpatterns as common_urlpatterns + + +urlpatterns = [] + +urlpatterns.extend(common_urlpatterns) \ No newline at end of file diff --git a/apps/timetable/views.py b/apps/timetable/views.py index d8a13f71..5129f068 100644 --- a/apps/timetable/views.py +++ b/apps/timetable/views.py @@ -1,4 +1,4 @@ -from rest_framework import generics +from rest_framework import generics, permissions from timetable import serialziers, models @@ -6,3 +6,5 @@ class TimetableListView(generics.ListAPIView): """Method to get timetables""" serializer_class = serialziers.TimetableSerializer queryset = models.Timetable.objects.all() + pagination_class = None + permission_classes = (permissions.AllowAny, ) diff --git a/apps/utils/methods.py b/apps/utils/methods.py index acad6502..bea8fec7 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -1,13 +1,19 @@ """Utils app method.""" +import logging import random import re import string +import requests from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http.request import HttpRequest from django.utils.timezone import datetime +from rest_framework import status from rest_framework.request import Request +from os.path import exists + +logger = logging.getLogger(__name__) def generate_code(digits=6, string_output=True): @@ -86,3 +92,30 @@ def get_contenttype(app_label: str, model: str): qs = ContentType.objects.filter(app_label=app_label, model=model) if qs.exists(): return qs.first() + + +def image_url_valid(url: str): + """ + Check if requested URL is valid. + :param url: string + :return: boolean + """ + try: + assert url.startswith('http') + response = requests.request('head', url) + except Exception as e: + logger.info(f'ConnectionError: {e}') + else: + return response.status_code == status.HTTP_200_OK + + +def absolute_url_decorator(func): + def get_absolute_image_url(self, obj): + """Get absolute image url""" + url_path = func(self, obj) + if url_path: + if url_path.startswith('/media/'): + return f'{settings.MEDIA_URL}{url_path}/' + else: + return url_path + return get_absolute_image_url diff --git a/apps/utils/models.py b/apps/utils/models.py index 4e6df35e..fb1de17c 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -1,5 +1,8 @@ """Utils app models.""" +import logging from os.path import exists + +from django.conf import settings from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.gis.db import models from django.contrib.postgres.fields import JSONField @@ -8,10 +11,13 @@ from django.utils import timezone from django.utils.html import mark_safe from django.utils.translation import ugettext_lazy as _, get_language from easy_thumbnails.fields import ThumbnailerImageField +from sorl.thumbnail import get_thumbnail +from sorl.thumbnail.fields import ImageField as SORLImageField + from utils.methods import image_path, svg_image_path from utils.validators import svg_image_validator -from django.db.models.fields import Field -from django.core import exceptions + +logger = logging.getLogger(__name__) class ProjectBaseMixin(models.Model): @@ -118,7 +124,7 @@ class OAuthProjectMixin: def get_source(self): """Method to get of platform""" - return NotImplemented + return NotImplementedError class BaseAttributes(ProjectBaseMixin): @@ -177,6 +183,41 @@ class ImageMixin(models.Model): image_tag.allow_tags = True +class SORLImageMixin(models.Model): + """Abstract model for SORL ImageField""" + + image = SORLImageField(upload_to=image_path, + blank=True, null=True, default=None, + verbose_name=_('Image')) + + class Meta: + """Meta class.""" + abstract = True + + def get_image(self, thumbnail_key: str): + """Get thumbnail image file.""" + if thumbnail_key in settings.SORL_THUMBNAIL_ALIASES: + return get_thumbnail( + file_=self.image, + **settings.SORL_THUMBNAIL_ALIASES[thumbnail_key]) + + def get_image_url(self, thumbnail_key: str): + """Get image thumbnail url.""" + crop_image = self.get_image(thumbnail_key) + if hasattr(crop_image, 'url'): + return self.get_image(thumbnail_key).url + + def image_tag(self): + """Admin preview tag.""" + if self.image: + return mark_safe(f'') + else: + return None + + image_tag.short_description = _('Image') + image_tag.allow_tags = True + + class SVGImageMixin(models.Model): """SVG image model.""" diff --git a/apps/utils/pagination.py b/apps/utils/pagination.py index 2c9e92e5..ac83f4f2 100644 --- a/apps/utils/pagination.py +++ b/apps/utils/pagination.py @@ -52,4 +52,4 @@ class EstablishmentPortionPagination(ProjectMobilePagination): """ Pagination for app establishments with limit page size equal to 12 """ - page_size = settings.LIMITING_OUTPUT_OBJECTS + page_size = settings.QUERY_OUTPUT_OBJECTS diff --git a/apps/utils/querysets.py b/apps/utils/querysets.py index bf2816f2..45798fbb 100644 --- a/apps/utils/querysets.py +++ b/apps/utils/querysets.py @@ -1,8 +1,10 @@ """Utils QuerySet Mixins""" -from django.db import models -from django.db.models import Q, Sum, F from functools import reduce from operator import add + +from django.db import models +from django.db.models import Q, F + from utils.methods import get_contenttype @@ -50,7 +52,7 @@ class RelatedObjectsCountMixin(models.QuerySet): def filter_all_related_gt(self, count): """Queryset filter by all related objects count""" - exp =reduce(add, [F(f"{related_object}_count") for related_object in self._get_related_objects_names()]) + exp = reduce(add, [F(f"{related_object}_count") for related_object in self._get_related_objects_names()]) return self._annotate_related_objects_count()\ .annotate(all_related_count=exp)\ .filter(all_related_count__gt=count) diff --git a/celerybeat-schedule b/celerybeat-schedule new file mode 100644 index 00000000..e1a56a15 Binary files /dev/null and b/celerybeat-schedule differ diff --git a/project/settings/amazon_s3.py b/project/settings/amazon_s3.py new file mode 100644 index 00000000..c793dd77 --- /dev/null +++ b/project/settings/amazon_s3.py @@ -0,0 +1,22 @@ +"""Settings for Amazon S3""" +import os + +from .base import MEDIA_LOCATION + +# AMAZON S3 +AWS_S3_REGION_NAME = 'eu-central-1' +AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') +AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') +AWS_S3_CUSTOM_DOMAIN = f's3.{AWS_S3_REGION_NAME}.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} +AWS_S3_ADDRESSING_STYLE = 'path' + +# Static settings +# PUBLIC_STATIC_LOCATION = 'static' +# STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_STATIC_LOCATION}/' +# STATICFILES_STORAGE = 'project.storage_backends.PublicStaticStorage' + +# Public media settings +MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/' +DEFAULT_FILE_STORAGE = 'project.storage_backends.PublicMediaStorage' diff --git a/project/settings/base.py b/project/settings/base.py index 49408f01..02df804e 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -64,6 +64,7 @@ PROJECT_APPS = [ 'news.apps.NewsConfig', 'notification.apps.NotificationConfig', 'partner.apps.PartnerConfig', + # 'product.apps.ProductConfig', Uncomment after refining task and create migrations 'recipe.apps.RecipeConfig', 'search_indexes.apps.SearchIndexesConfig', 'translation.apps.TranslationConfig', @@ -94,6 +95,10 @@ EXTERNAL_APPS = [ 'rest_framework_simplejwt.token_blacklist', 'solo', 'phonenumber_field', + 'timezone_field', + 'storages', + 'sorl.thumbnail', + 'timezonefinder' ] @@ -193,19 +198,6 @@ LOCALE_PATHS = ( os.path.abspath(os.path.join(BASE_DIR, 'locale')), ) -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.2/howto/static-files/ - -STATIC_ROOT = os.path.join(PUBLIC_ROOT, 'static') -STATIC_URL = '/static/' - -MEDIA_ROOT = os.path.join(PUBLIC_ROOT, 'media') -MEDIA_URL = '/media/' - -STATICFILES_DIRS = ( - os.path.join(PROJECT_ROOT, 'static'), -) - AVAILABLE_VERSIONS = { # 'future': '1.0.1', 'current': '1.0.0', @@ -282,12 +274,14 @@ SMS_CODE_LENGTH = 6 SEND_SMS = True SMS_CODE_SHOW = False + # SMSC Settings SMS_SERVICE = 'http://smsc.ru/sys/send.php' SMS_LOGIN = os.environ.get('SMS_LOGIN') SMS_PASSWORD = os.environ.get('SMS_PASSWORD') SMS_SENDER = 'GM' + # EMAIL EMAIL_USE_TLS = True EMAIL_HOST = 'smtp.mandrillapp.com' @@ -295,6 +289,7 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') EMAIL_PORT = 587 + # Django Rest Swagger SWAGGER_SETTINGS = { # "DEFAULT_GENERATOR_CLASS": "rest_framework.schemas.generators.BaseSchemaGenerator", @@ -317,6 +312,7 @@ REDOC_SETTINGS = { 'LAZY_RENDERING': False, } + # CELERY # RabbitMQ # BROKER_URL = 'amqp://rabbitmq:5672' @@ -329,7 +325,7 @@ CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = TIME_ZONE -# Django FCM (Firebase push notificatoins) +# Django FCM (Firebase push notifications) FCM_DJANGO_SETTINGS = { 'FCM_SERVER_KEY': ( "AAAAJcC4Vbc:APA91bGovq7233-RHu2MbZTsuMU4jNf3obOue8s" @@ -338,39 +334,44 @@ FCM_DJANGO_SETTINGS = { ), } + # Thumbnail settings THUMBNAIL_ALIASES = { - 'news_preview': { - 'web': {'size': (300, 260), } - }, - 'news_promo_horizontal': { - 'web': {'size': (1900, 600), }, - 'mobile': {'size': (375, 260), }, - }, - 'news_tile_horizontal': { - 'web': {'size': (300, 275), }, - 'mobile': {'size': (343, 180), }, - }, - 'news_tile_vertical': { - 'web': {'size': (300, 380), }, - }, - 'news_highlight_vertical': { - 'web': {'size': (460, 630), }, - }, - 'news_editor': { - 'web': {'size': (940, 430), }, # при загрузке через контент эдитор - 'mobile': {'size': (343, 260), }, # через контент эдитор в мобильном браузерe - }, - 'avatar_comments': { - 'web': {'size': (116, 116), }, - }, + '': { + 'news_preview': {'size': (300, 260), }, + 'news_promo_horizontal_web': {'size': (1900, 600), }, + 'news_promo_horizontal_mobile': {'size': (375, 260), }, + 'news_tile_horizontal_web': {'size': (300, 275), }, + 'news_tile_horizontal_mobile': {'size': (343, 180), }, + 'news_tile_vertical_web': {'size': (300, 380), }, + 'news_highlight_vertical_web': {'size': (460, 630), }, + 'news_editor_web': {'size': (940, 430), }, # при загрузке через контент эдитор + 'news_editor_mobile': {'size': (343, 260), }, # через контент эдитор в мобильном браузерe + 'avatar_comments_web': {'size': (116, 116), }, + } } -# Password reset -RESETTING_TOKEN_EXPIRATION = 24 # hours +THUMBNAIL_DEFAULT_OPTIONS = { + 'crop': 'smart', +} -GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') +# SORL +THUMBNAIL_QUALITY = 85 +THUMBNAIL_DEBUG = False +SORL_THUMBNAIL_ALIASES = { + 'news_preview': {'geometry_string': '100x100', 'crop': 'center'}, + 'news_promo_horizontal_web': {'geometry_string': '1900x600', 'crop': 'center'}, + 'news_promo_horizontal_mobile': {'geometry_string': '375x260', 'crop': 'center'}, + 'news_tile_horizontal_web': {'geometry_string': '300x275', 'crop': 'center'}, + 'news_tile_horizontal_mobile': {'geometry_string': '343x180', 'crop': 'center'}, + 'news_tile_vertical_web': {'geometry_string': '300x380', 'crop': 'center'}, + 'news_highlight_vertical_web': {'geometry_string': '460x630', 'crop': 'center'}, + 'news_editor_web': {'geometry_string': '940x430', 'crop': 'center'}, + 'news_editor_mobile': {'geometry_string': '343x260', 'crop': 'center'}, # при загрузке через контент эдитор + 'avatar_comments_web': {'geometry_string': '116x116', 'crop': 'center'}, # через контент эдитор в мобильном браузерe +} + # JWT SIMPLE_JWT = { @@ -410,7 +411,8 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1 RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html' CHANGE_EMAIL_TEMPLATE = 'account/change_email.html' CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html' -NEWS_EMAIL_TEMPLATE = "news/news_email.html" +NEWS_EMAIL_TEMPLATE = 'news/news_email.html' +NOTIFICATION_PASSWORD_TEMPLATE = 'account/password_change_email.html' # COOKIES @@ -431,24 +433,42 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_PERMISSIONS = 0o644 + # SOLO SETTINGS # todo: make a separate service (redis?) and update solo_cache SOLO_CACHE = 'default' SOLO_CACHE_PREFIX = 'solo' SOLO_CACHE_TIMEOUT = 300 + # REDIRECT URL SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/' SITE_NAME = 'Gault & Millau' + # Used in annotations for establishments. DEFAULT_ESTABLISHMENT_PUBLIC_MARK = 10 # Limit output objects (see in pagination classes). -LIMITING_OUTPUT_OBJECTS = 12 +QUERY_OUTPUT_OBJECTS = 12 # Need to restrict objects to sort (3 times more then expected). -LIMITING_QUERY_NUMBER = LIMITING_OUTPUT_OBJECTS * 3 +LIMITING_QUERY_OBJECTS = QUERY_OUTPUT_OBJECTS * 3 + # GEO # A Spatial Reference System Identifier GEO_DEFAULT_SRID = 4326 +GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ +STATIC_ROOT = os.path.join(PUBLIC_ROOT, 'static') +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( + os.path.join(PROJECT_ROOT, 'static'), +) + + +# MEDIA +MEDIA_LOCATION = 'media' \ No newline at end of file diff --git a/project/settings/development.py b/project/settings/development.py index ff80b492..59691818 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -1,13 +1,14 @@ """Development settings.""" from .base import * +from .amazon_s3 import * import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration -ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126'] +ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126', '0.0.0.0'] SEND_SMS = False SMS_CODE_SHOW = True -USE_CELERY = False +USE_CELERY = True SCHEMA_URI = 'http' DEFAULT_SUBDOMAIN = 'www' diff --git a/project/settings/local.py b/project/settings/local.py index 31fe88c2..e87b99f6 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -1,18 +1,22 @@ """Local settings.""" from .base import * import sys +# from .amazon_s3 import * ALLOWED_HOSTS = ['*', ] + SEND_SMS = False SMS_CODE_SHOW = True USE_CELERY = True + SCHEMA_URI = 'http' DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'testserver.com:8000' DOMAIN_URI = '0.0.0.0:8000' + # CELERY # RabbitMQ # BROKER_URL = 'amqp://rabbitmq:5672' @@ -22,6 +26,15 @@ CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL +# MEDIA +MEDIA_URL = f'{SCHEMA_URI}://{DOMAIN_URI}/{MEDIA_LOCATION}/' +MEDIA_ROOT = os.path.join(PUBLIC_ROOT, MEDIA_LOCATION) + + +# SORL thumbnails +THUMBNAIL_DEBUG = True + + # LOGGING LOGGING = { 'version': 1, @@ -59,6 +72,7 @@ LOGGING = { } } + # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -66,7 +80,6 @@ ELASTICSEARCH_DSL = { } } - ELASTICSEARCH_INDEX_NAMES = { # 'search_indexes.documents.news': 'local_news', 'search_indexes.documents.establishment': 'local_establishment', diff --git a/project/settings/production.py b/project/settings/production.py index 5682a59d..b7ddc401 100644 --- a/project/settings/production.py +++ b/project/settings/production.py @@ -1,9 +1,10 @@ """Production settings.""" from .base import * +from .amazon_s3 import * # Booking API configuration GUESTONLINE_SERVICE = 'https://api.guestonline.fr/' GUESTONLINE_TOKEN = '' LASTABLE_SERVICE = '' LASTABLE_TOKEN = '' -LASTABLE_PROXY = '' \ No newline at end of file +LASTABLE_PROXY = '' diff --git a/project/settings/stage.py b/project/settings/stage.py index c0d6fdb1..49a7ae0f 100644 --- a/project/settings/stage.py +++ b/project/settings/stage.py @@ -1,11 +1,12 @@ """Stage settings.""" from .base import * +from .amazon_s3 import * ALLOWED_HOSTS = ['gm-stage.id-east.ru', '95.213.204.126'] SEND_SMS = False SMS_CODE_SHOW = True -USE_CELERY = False +USE_CELERY = True SCHEMA_URI = 'https' DEFAULT_SUBDOMAIN = 'www' diff --git a/project/storage_backends.py b/project/storage_backends.py new file mode 100644 index 00000000..633337e8 --- /dev/null +++ b/project/storage_backends.py @@ -0,0 +1,12 @@ +"""Extend storage backend for adding custom parameters""" +from storages.backends.s3boto3 import S3Boto3Storage + + +class PublicMediaStorage(S3Boto3Storage): + location = 'media' + file_overwrite = False + + +class PublicStaticStorage(S3Boto3Storage): + location = 'static' + file_overwrite = False diff --git a/project/templates/account/password_change_email.html b/project/templates/account/password_change_email.html new file mode 100644 index 00000000..30dd2aac --- /dev/null +++ b/project/templates/account/password_change_email.html @@ -0,0 +1,7 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this email because your account's password address at {{ site_name }}.{% endblocktrans %} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} +{% endautoescape %} \ No newline at end of file diff --git a/project/templates/news/news_email.html b/project/templates/news/news_email.html index d14bd898..0227b9de 100644 --- a/project/templates/news/news_email.html +++ b/project/templates/news/news_email.html @@ -7,12 +7,11 @@ {{ title }} - +
-
@@ -25,19 +24,21 @@
-
+
{{ title }}
{% if not image_url is None %}
- +
{% endif %} -
+
{{ description | safe }}
- - + +
+ Go to news +
- +
- + \ No newline at end of file diff --git a/project/urls/__init__.py b/project/urls/__init__.py index f1a89695..ca76ff43 100644 --- a/project/urls/__init__.py +++ b/project/urls/__init__.py @@ -64,7 +64,7 @@ urlpatterns = [ ] urlpatterns = urlpatterns + \ - static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + static(settings.MEDIA_LOCATION, document_root=settings.MEDIA_ROOT) if settings.DEBUG: urlpatterns.extend(urlpatterns_doc) diff --git a/project/urls/mobile.py b/project/urls/mobile.py index 0bea5305..4ec2fec5 100644 --- a/project/urls/mobile.py +++ b/project/urls/mobile.py @@ -4,7 +4,10 @@ app_name = 'mobile' urlpatterns = [ path('establishments/', include('establishment.urls.mobile')), - path('main/', include('main.urls.mobile')) + path('location/', include('location.urls.mobile')), + path('main/', include('main.urls.mobile')), + path('tags/', include('tag.urls.mobile')), + path('timetables/', include('timetable.urls.mobile')), # path('account/', include('account.urls.web')), # path('advertisement/', include('advertisement.urls.web')), # path('collection/', include('collection.urls.web')), diff --git a/project/urls/web.py b/project/urls/web.py index 872f0b9f..77a06961 100644 --- a/project/urls/web.py +++ b/project/urls/web.py @@ -34,4 +34,5 @@ urlpatterns = [ path('translation/', include('translation.urls')), path('comments/', include('comment.urls.web')), path('favorites/', include('favorites.urls')), + path('timetables/', include('timetable.urls.web')), ] diff --git a/requirements/base.txt b/requirements/base.txt index 5c734322..c95055de 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -9,6 +9,7 @@ fcm-django django-easy-select2 bootstrap-admin drf-yasg==1.16.0 +timezonefinder PySocks!=1.5.7,>=1.5.6; djangorestframework==3.9.4 @@ -17,6 +18,7 @@ django-filter==2.1.0 djangorestframework-xml geoip2==2.9.0 django-phonenumber-field[phonenumbers]==2.1.0 +django-timezone-field==3.1 # auth socials django-rest-framework-social-oauth2==1.1.0 @@ -33,6 +35,12 @@ django-elasticsearch-dsl>=7.0.0,<8.0.0 django-elasticsearch-dsl-drf==0.20.2 sentry-sdk==0.11.2 +# AMAZON S3 +boto3==1.9.238 +django-storages==1.7.2 + +sorl-thumbnail==12.5.0 + # temp solution redis==3.2.0 amqp>=2.4.0