diff --git a/.gitignore b/.gitignore index a32ff3df..5d44eda8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ logs/ /geoip_db/ # dev -./docker-compose.override.yml \ No newline at end of file +./docker-compose.override.yml + +celerybeat-schedule diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..94dda56d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +image: docker:latest + +stages: + - build + - test + - deploy + - clean + + +clean: + stage: clean + script: + - docker-compose -f compose-ci.yml stop + - docker-compose -f compose-ci.yml rm --force gm_app + when: always + + +buid: + stage: build + script: + - docker-compose -f compose-ci.yml build gm_app + when: always + + +test: + stage: test + script: + - docker-compose -f compose-ci.yml run gm_app python manage.py test -v 3 --noinput + when: always + + + +deploy-develop: + stage: deploy + only: + - develop + script: + - fab --roles=develop deploy + environment: + name: Develop \ No newline at end of file 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/migrations/0016_auto_20191024_0833.py b/apps/account/migrations/0016_auto_20191024_0833.py new file mode 100644 index 00000000..6c99d567 --- /dev/null +++ b/apps/account/migrations/0016_auto_20191024_0833.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.4 on 2019-10-24 08:33 + +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/migrations/0017_merge_20191024_1233.py b/apps/account/migrations/0017_merge_20191024_1233.py new file mode 100644 index 00000000..580f6b2f --- /dev/null +++ b/apps/account/migrations/0017_merge_20191024_1233.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-24 12:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0016_auto_20191024_0830'), + ('account', '0016_auto_20191024_0833'), + ] + + operations = [ + ] 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/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/migrations/0016_auto_20191024_1334.py b/apps/collection/migrations/0016_auto_20191024_1334.py new file mode 100644 index 00000000..a362777f --- /dev/null +++ b/apps/collection/migrations/0016_auto_20191024_1334.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2019-10-24 13:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('collection', '0015_auto_20191023_0715'), + ] + + operations = [ + migrations.AlterField( + model_name='guide', + name='collection', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='collection.Collection', verbose_name='collection'), + ), + ] diff --git a/apps/collection/models.py b/apps/collection/models.py index e7c930c3..cbcc3842 100644 --- a/apps/collection/models.py +++ b/apps/collection/models.py @@ -43,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 @@ -54,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')) @@ -79,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.""" @@ -101,8 +101,9 @@ class Guide(ProjectBaseMixin, CollectionNameMixin, CollectionDateMixin): advertorials = JSONField( _('advertorials'), null=True, blank=True, default=None, help_text='{"key":"value"}') - collection = models.ForeignKey( - Collection, verbose_name=_('collection'), on_delete=models.CASCADE) + collection = models.ForeignKey(Collection, on_delete=models.CASCADE, + null=True, blank=True, default=None, + verbose_name=_('collection')) objects = GuideQuerySet.as_manager() 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/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/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/migrations/0043_establishment_currency.py b/apps/establishment/migrations/0043_establishment_currency.py new file mode 100644 index 00000000..7c324dc4 --- /dev/null +++ b/apps/establishment/migrations/0043_establishment_currency.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.4 on 2019-10-24 13:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0022_auto_20191023_1113'), + ('establishment', '0042_establishment_tz'), + ] + + operations = [ + migrations.AddField( + model_name='establishment', + name='currency', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='main.Currency', verbose_name='currency'), + ), + ] diff --git a/apps/establishment/models.py b/apps/establishment/models.py index 68acb68f..304ea2a6 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 @@ -15,10 +17,11 @@ from phonenumber_field.modelfields import PhoneNumberField from collection.models import Collection from location.models import Address -from main.models import Award +from main.models import Award, Currency 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,8 +352,9 @@ 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') tags = models.ManyToManyField('tag.Tag', related_name='establishments', @@ -358,6 +362,9 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin): reviews = generic.GenericRelation(to='review.Review') comments = generic.GenericRelation(to='comment.Comment') favorites = generic.GenericRelation(to='favorites.Favorites') + currency = models.ForeignKey(Currency, blank=True, null=True, default=None, + on_delete=models.PROTECT, + verbose_name=_('currency')) objects = EstablishmentQuerySet.as_manager() @@ -416,6 +423,32 @@ 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, + 'label': tag.metadata.label} for tag in self.tags.all()] + @property def last_published_review(self): """Return last published review""" @@ -431,8 +464,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): diff --git a/apps/establishment/serializers/back.py b/apps/establishment/serializers/back.py index 59725710..f1fb40a5 100644 --- a/apps/establishment/serializers/back.py +++ b/apps/establishment/serializers/back.py @@ -7,6 +7,7 @@ from establishment.serializers import ( EstablishmentTypeBaseSerializer) from main.models import Currency from utils.decorators import with_base_attributes +from utils.serializers import TimeZoneChoiceField class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): @@ -19,8 +20,8 @@ class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): phones = ContactPhonesSerializer(read_only=True, many=True, ) emails = ContactEmailsSerializer(read_only=True, many=True, ) socials = SocialNetworkRelatedSerializers(read_only=True, many=True, ) - slug = serializers.SlugField(required=True, allow_blank=False, max_length=50) type = EstablishmentTypeBaseSerializer(source='establishment_type', read_only=True) + tz = TimeZoneChoiceField() class Meta: model = models.Establishment @@ -41,6 +42,7 @@ class EstablishmentListCreateSerializer(EstablishmentBaseSerializer): 'is_publish', 'guestonline_id', 'lastable_id', + 'tz', ] diff --git a/apps/establishment/serializers/common.py b/apps/establishment/serializers/common.py index 389d0d1c..2846c5c8 100644 --- a/apps/establishment/serializers/common.py +++ b/apps/establishment/serializers/common.py @@ -159,10 +159,10 @@ class EstablishmentBaseSerializer(ProjectModelSerializer): """Base serializer for Establishment model.""" preview_image = serializers.URLField(source='preview_image_url') - slug = serializers.SlugField(allow_blank=False, required=True, max_length=50) address = AddressBaseSerializer() in_favorites = serializers.BooleanField(allow_null=True) tags = TagBaseSerializer(read_only=True, many=True) + currency = CurrencySerializer() class Meta: """Meta class.""" @@ -180,6 +180,7 @@ class EstablishmentBaseSerializer(ProjectModelSerializer): 'in_favorites', 'address', 'tags', + 'currency' ] diff --git a/apps/establishment/tests.py b/apps/establishment/tests.py index a1d8fcb5..43aa62c6 100644 --- a/apps/establishment/tests.py +++ b/apps/establishment/tests.py @@ -9,6 +9,7 @@ from establishment.models import Establishment, EstablishmentType, Menu from translation.models import Language from account.models import Role, UserRole from location.models import Country, Address, City, Region +from pytz import timezone as py_tz class BaseTestCase(APITestCase): @@ -77,7 +78,8 @@ class EstablishmentBTests(BaseTestCase): 'name': 'Test establishment', 'type_id': self.establishment_type.id, 'is_publish': True, - 'slug': 'test-establishment-slug' + 'slug': 'test-establishment-slug', + 'tz': py_tz('Europe/Moscow').zone } response = self.client.post('/api/back/establishments/', data=data, format='json') diff --git a/apps/establishment/views/web.py b/apps/establishment/views/web.py index d3cc91cb..0699d9d0 100644 --- a/apps/establishment/views/web.py +++ b/apps/establishment/views/web.py @@ -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/favorites/tests.py b/apps/favorites/tests.py index 99b01444..cebd43c2 100644 --- a/apps/favorites/tests.py +++ b/apps/favorites/tests.py @@ -1,14 +1,16 @@ # Create your tests here. from http.cookies import SimpleCookie -from django.contrib.contenttypes.models import ContentType -from rest_framework.test import APITestCase +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse from rest_framework import status +from rest_framework.test import APITestCase from account.models import User +from establishment.models import Establishment, EstablishmentType from favorites.models import Favorites -from establishment.models import Establishment, EstablishmentType, EstablishmentSubType from news.models import NewsType, News +from datetime import datetime class BaseTestCase(APITestCase): @@ -17,38 +19,57 @@ class BaseTestCase(APITestCase): self.username = 'sedragurda' self.password = 'sedragurdaredips19' self.email = 'sedragurda@desoz.com' - self.user = User.objects.create_user(username=self.username, email=self.email, password=self.password) - tokkens = User.create_jwt_tokens(self.user) - self.client.cookies = SimpleCookie({'access_token': tokkens.get('access_token'), - 'refresh_token': tokkens.get('refresh_token')}) + self.user = User.objects.create_user( + username=self.username, + email=self.email, + password=self.password + ) - self.test_news_type = NewsType.objects.create(name="Test news type") - self.test_news = News.objects.create(created_by=self.user, modified_by=self.user, title={"en-GB": "Test news"}, - news_type=self.test_news_type, - description={"en-GB": "Description test news"}, - playlist=1, start="2020-12-03 12:00:00", end="2020-12-13 12:00:00", - state=News.PUBLISHED, slug='test-news') + tokens = User.create_jwt_tokens(self.user) + self.client.cookies = SimpleCookie( + {'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('refresh_token')}) - self.test_content_type = ContentType.objects.get(app_label="news", model="news") + self.test_news_type = NewsType.objects.create( + name="Test news type", + ) + self.test_news = News.objects.create( + created_by=self.user, + modified_by=self.user, + title={"en-GB": "Test news"}, + news_type=self.test_news_type, + description={"en-GB": "Description test news"}, + start=datetime.fromisoformat("2020-12-03 12:00:00"), + end=datetime.fromisoformat("2020-12-03 12:00:00"), + state=News.PUBLISHED, + slug='test-news' + ) - self.test_favorites = Favorites.objects.create(user=self.user, content_type=self.test_content_type, - object_id=self.test_news.id) + self.test_content_type = ContentType.objects.get( + app_label="news", model="news") - self.test_establishment_type = EstablishmentType.objects.create(name={"en-GB": "test establishment type"}, - use_subtypes=False) + self.test_favorites = Favorites.objects.create( + user=self.user, content_type=self.test_content_type, + object_id=self.test_news.id) - self.test_establishment = Establishment.objects.create(name="test establishment", - description={"en-GB": "description of test establishment"}, - establishment_type=self.test_establishment_type, - is_publish=True) + self.test_establishment_type = EstablishmentType.objects.create( + name={"en-GB": "test establishment type"}, + use_subtypes=False) + + self.test_establishment = Establishment.objects.create( + name="test establishment", + description={"en-GB": "description of test establishment"}, + establishment_type=self.test_establishment_type, + is_publish=True) # value for GenericRelation(reverse side) field must be iterable - # value for GenericRelation(reverse side) field must be assigned through "set" method of field + # value for GenericRelation(reverse side) field must be assigned through + # "set" method of field self.test_establishment.favorites.set([self.test_favorites]) class FavoritesTestCase(BaseTestCase): def test_func(self): - response = self.client.get("/api/web/favorites/establishments/") - print(response.json()) + url = reverse('web:favorites:establishment-list') + response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) \ No newline at end of file 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 aec1c119..1a11bb35 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -1,15 +1,39 @@ +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 + 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='') + +<<<<<<< HEAD image = ThumbnailerImageField(upload_to=image_path, verbose_name=_('Image file'), max_length=255) +======= + objects = ImageQuerySet.as_manager() +>>>>>>> develop class Meta: """Meta class.""" @@ -18,4 +42,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 c12f7ff0..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): 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/0020_merge_20191025_0423.py b/apps/main/migrations/0020_merge_20191025_0423.py new file mode 100644 index 00000000..122549cf --- /dev/null +++ b/apps/main/migrations/0020_merge_20191025_0423.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-25 04:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0019_auto_20191022_1359'), + ('main', '0019_award_image_url'), + ] + + 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 1bb6ace3..6af4e4d8 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -106,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.""" @@ -114,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, @@ -135,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() @@ -182,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') @@ -257,18 +275,6 @@ class AwardType(models.Model): return self.name -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 e2523fa9..a0af662b 100644 --- a/apps/main/serializers.py +++ b/apps/main/serializers.py @@ -4,7 +4,7 @@ from rest_framework import serializers from advertisement.serializers.web import AdvertisementSerializer from location.serializers import CountrySerializer from main import models -from utils.serializers import TranslatedField +from utils.serializers import ProjectModelSerializer, TranslatedField class FeatureSerializer(serializers.ModelSerializer): @@ -40,11 +40,26 @@ class SiteFeatureSerializer(serializers.ModelSerializer): ) +class CurrencySerializer(ProjectModelSerializer): + """Currency serializer.""" + + name_translated = TranslatedField() + + 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 +78,7 @@ class SiteSettingsSerializer(serializers.ModelSerializer): 'config', 'ad_config', 'published_features', + 'currency' ) @@ -114,17 +130,6 @@ class AwardSerializer(AwardBaseSerializer): fields = AwardBaseSerializer.Meta.fields + ['award_type', ] -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/0023_merge_20191025_0423.py b/apps/news/migrations/0023_merge_20191025_0423.py new file mode 100644 index 00000000..66e7cd92 --- /dev/null +++ b/apps/news/migrations/0023_merge_20191025_0423.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-25 04:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0021_auto_20191021_1120'), + ('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/migrations/0026_auto_20191024_0913.py b/apps/news/migrations/0026_auto_20191024_0913.py new file mode 100644 index 00000000..bcad2ce2 --- /dev/null +++ b/apps/news/migrations/0026_auto_20191024_0913.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.4 on 2019-10-24 09:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0025_merge_20191023_1317'), + ] + + operations = [ + migrations.RemoveField( + model_name='news', + name='image_url', + ), + migrations.RemoveField( + model_name='news', + name='preview_image_url', + ), + ] diff --git a/apps/news/migrations/0027_remove_news_playlist.py b/apps/news/migrations/0027_remove_news_playlist.py new file mode 100644 index 00000000..3741fca1 --- /dev/null +++ b/apps/news/migrations/0027_remove_news_playlist.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.4 on 2019-10-24 09:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0026_auto_20191024_0913'), + ] + + operations = [ + migrations.RemoveField( + model_name='news', + name='playlist', + ), + ] diff --git a/apps/news/migrations/0028_auto_20191024_1649.py b/apps/news/migrations/0028_auto_20191024_1649.py new file mode 100644 index 00000000..c9eaacb1 --- /dev/null +++ b/apps/news/migrations/0028_auto_20191024_1649.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.4 on 2019-10-24 16:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0027_remove_news_playlist'), + ] + + operations = [ + migrations.AlterField( + model_name='newsgallery', + name='image', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='news_gallery', to='gallery.Image', verbose_name='gallery'), + ), + migrations.AlterField( + model_name='newsgallery', + name='news', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='news_gallery', to='news.News', verbose_name='news'), + ), + migrations.AlterUniqueTogether( + name='newsgallery', + unique_together={('news', 'is_main')}, + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index 9e2a2926..dbb2f5bf 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -141,15 +141,10 @@ class News(BaseAttributes, TranslatedFieldsMixin): verbose_name=_('End')) 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')) - 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'), @@ -159,6 +154,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, @@ -195,3 +191,48 @@ class News(BaseAttributes, TranslatedFieldsMixin): @property def same_theme(self): return self.__class__.objects.same_theme(self)[:3] + + @property + def main_image(self): + qs = self.news_gallery.main_image() + if qs.exists(): + return qs.first().image + + @property + def image_url(self): + return self.main_image.image.url if self.main_image else None + + @property + def preview_image_url(self): + if self.main_image: + return self.main_image.get_image_url(thumbnail_key='news_preview') + + +class NewsGalleryQuerySet(models.QuerySet): + """QuerySet for model News""" + + def main_image(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.CASCADE, + verbose_name=_('news')) + image = models.ForeignKey('gallery.Image', null=True, + related_name='news_gallery', + on_delete=models.CASCADE, + 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') + unique_together = ('news', 'is_main') diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 10662880..2b4e98b6 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.""" @@ -55,11 +129,8 @@ class NewsTypeSerializer(serializers.ModelSerializer): class NewsBaseSerializer(ProjectModelSerializer): """Base serializer for News model.""" - # read only fields - title_translated = TranslatedField(source='title') + title_translated = TranslatedField() subtitle_translated = TranslatedField() - - # related fields news_type = NewsTypeSerializer(read_only=True) tags = TagBaseSerializer(read_only=True, many=True) @@ -72,14 +143,25 @@ 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.""" + + image = NewsImageSerializer(source='main_image', allow_null=True) + + class Meta(NewsBaseSerializer.Meta): + """Meta class.""" + + fields = NewsBaseSerializer.Meta.fields + ( + 'image', + ) + + class NewsDetailSerializer(NewsBaseSerializer): """News detail serializer.""" @@ -88,6 +170,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.""" @@ -96,12 +179,12 @@ class NewsDetailSerializer(NewsBaseSerializer): 'description_translated', 'start', 'end', - 'playlist', 'is_publish', 'state', 'state_display', 'author', 'country', + 'gallery', ) @@ -160,3 +243,48 @@ 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_image().exists(): + raise serializers.ValidationError({'detail': _('Main image is already added')}) + + attrs['news'] = news + attrs['image'] = image + + return attrs diff --git a/apps/news/tests.py b/apps/news/tests.py index b4e2b296..115763e5 100644 --- a/apps/news/tests.py +++ b/apps/news/tests.py @@ -18,11 +18,14 @@ class BaseTestCase(APITestCase): self.username = 'sedragurda' self.password = 'sedragurdaredips19' self.email = 'sedragurda@desoz.com' - self.user = User.objects.create_user(username=self.username, email=self.email, password=self.password) - #get tokkens - tokkens = User.create_jwt_tokens(self.user) - self.client.cookies = SimpleCookie({'access_token': tokkens.get('access_token'), - 'refresh_token': tokkens.get('refresh_token')}) + self.user = User.objects.create_user( + username=self.username, email=self.email, password=self.password) + + # get tokens + tokens = User.create_jwt_tokens(self.user) + self.client.cookies = SimpleCookie( + {'access_token': tokens.get('access_token'), + 'refresh_token': tokens.get('refresh_token')}) self.test_news_type = NewsType.objects.create(name="Test news type") self.lang = Language.objects.get( @@ -46,21 +49,25 @@ class BaseTestCase(APITestCase): ) user_role.save() - self.test_news = News.objects.create(created_by=self.user, modified_by=self.user, - title={"en-GB": "Test news"}, - news_type=self.test_news_type, - description={"en-GB": "Description test news"}, - playlist=1, start=datetime.now() + timedelta(hours=-2), - end=datetime.now() + timedelta(hours=2), - state=News.PUBLISHED, slug='test-news-slug', - country=self.country_ru) + self.test_news = News.objects.create( + created_by=self.user, modified_by=self.user, + title={"en-GB": "Test news"}, + news_type=self.test_news_type, + description={"en-GB": "Description test news"}, + start=datetime.now() + timedelta(hours=-2), + end=datetime.now() + timedelta(hours=2), + state=News.PUBLISHED, + slug='test-news-slug', + country=self.country_ru, + ) + class NewsTestCase(BaseTestCase): def setUp(self): super().setUp() def test_web_news(self): - response = self.client.get("/api/web/news/") + response = self.client.get(reverse('web:news:list')) self.assertEqual(response.status_code, status.HTTP_200_OK) response = self.client.get(f"/api/web/news/slug/{self.test_news.slug}/") @@ -85,7 +92,6 @@ class NewsTestCase(BaseTestCase): 'description': {"en-GB": "Description test news!"}, 'slug': self.test_news.slug, 'start': self.test_news.start, - 'playlist': self.test_news.playlist, 'news_type_id':self.test_news.news_type_id, 'country_id': self.country_ru.id } diff --git a/apps/news/urls/back.py b/apps/news/urls/back.py index 8522592e..9cc3d94a 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' @@ -8,4 +9,8 @@ urlpatterns = [ path('', views.NewsBackOfficeLCView.as_view(), name='list-create'), path('/', views.NewsBackOfficeRUDView.as_view(), name='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..9cd5d969 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,6 +1,11 @@ """News app views.""" +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 +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 @@ -9,21 +14,40 @@ from utils.permissions import IsCountryAdmin, IsContentPageManager class NewsMixinView: """News mixin.""" - permission_classes = (permissions.AllowAny, ) + permission_classes = (permissions.AllowAny,) serializer_class = serializers.NewsBaseSerializer def get_queryset(self, *args, **kwargs): + from django.conf import settings """Override get_queryset method.""" - qs = models.News.objects.published().with_base_related()\ + + qs = models.News.objects.published().with_base_related() \ .order_by('-is_highlighted', '-created') - if self.request.country_code: - qs = qs.by_country_code(self.request.country_code) + country_code = self.request.country_code + if country_code: + + # temp code + # Temporary stub for international news logic + # (по договорённости с заказчиком на демонстрации 4 ноября + # здесь будет 6 фиксированных новостей) + # TODO replace this stub with actual logic + if hasattr(settings, 'HARDCODED_INTERNATIONAL_NEWS_IDS'): + if country_code and country_code != 'www' and country_code != 'main': + qs = qs.by_country_code(country_code) + else: + qs = models.News.objects.filter( + id__in=settings.HARDCODED_INTERNATIONAL_NEWS_IDS) + return qs + # temp code + + qs = qs.by_country_code(country_code) return qs class NewsListView(NewsMixinView, generics.ListAPIView): """News list view.""" + serializer_class = serializers.NewsListSerializer filter_class = filters.NewsListFilterSet @@ -74,6 +98,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..86b117e5 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -24,6 +24,8 @@ class NewsDocument(Document): country = fields.ObjectField(properties={'id': fields.IntegerField(), 'code': fields.KeywordField()}) web_url = fields.KeywordField(attr='web_url') + image_url = fields.KeywordField(attr='image_url') + preview_image_url = fields.KeywordField(attr='preview_image_url') tags = fields.ObjectField( properties={ 'id': fields.IntegerField(attr='id'), @@ -37,14 +39,11 @@ class NewsDocument(Document): model = models.News fields = ( 'id', - 'playlist', 'start', 'end', '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..d4ab2dbf 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,32 +94,11 @@ class EstablishmentDocumentSerializer(DocumentSerializer): 'preview_image', 'address', 'tags', + 'schedule', + 'works_noon', + 'works_evening', + 'works_now', # 'collections', # 'establishment_type', # 'establishment_subtypes', ) - - - # def to_representation(self, instance): - # ret = super().to_representation(instance) - # dict_merge = lambda a, b: a.update(b) or a - # - # ret['tags'] = map(lambda tag: dict_merge(tag, {'label_translated': get_translated_value(tag.pop('label'))}), - # ret['tags']) - # ret['establishment_subtypes'] = map( - # lambda subtype: dict_merge(subtype, {'name_translated': get_translated_value(subtype.pop('name'))}), - # ret['establishment_subtypes']) - # if ret.get('establishment_type'): - # ret['establishment_type']['name_translated'] = get_translated_value(ret['establishment_type'].pop('name')) - # if ret.get('address'): - # ret['address']['city']['country']['name_translated'] = get_translated_value( - # ret['address']['city']['country'].pop('name')) - # location = ret['address'].pop('location') - # if location: - # ret['address']['geo_lon'] = location['lon'] - # ret['address']['geo_lat'] = location['lat'] - # - # ret['type'] = ret.pop('establishment_type') - # ret['subtypes'] = ret.pop('establishment_subtypes') - # - # return ret \ No newline at end of file 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/apps/utils/serializers.py b/apps/utils/serializers.py index 90efea00..eeff1043 100644 --- a/apps/utils/serializers.py +++ b/apps/utils/serializers.py @@ -1,4 +1,5 @@ """Utils app serializer.""" +import pytz from django.core import exceptions from rest_framework import serializers from utils import models @@ -48,6 +49,25 @@ class TJSONField(serializers.JSONField): validators = [validate_tjson] +class TimeZoneChoiceField(serializers.ChoiceField): + """Take the timezone object and make it JSON serializable.""" + + def __init__(self, choices=None, **kwargs): + if choices is None: + choices = pytz.all_timezones + super().__init__(choices=choices, **kwargs) + + def to_representation(self, value): + if isinstance(value, str): + return value + elif isinstance(value, pytz.tzinfo.BaseTzInfo): + return value.zone + return None + + def to_internal_value(self, data): + return pytz.timezone(data) + + class ProjectModelSerializer(serializers.ModelSerializer): """Overrided ModelSerializer.""" diff --git a/apps/utils/tests/tests_translated.py b/apps/utils/tests/tests_translated.py index c6a990c0..77b67d8a 100644 --- a/apps/utils/tests/tests_translated.py +++ b/apps/utils/tests/tests_translated.py @@ -27,7 +27,7 @@ class BaseTestCase(APITestCase): self.client.cookies = SimpleCookie( {'access_token': tokkens.get('access_token'), 'refresh_token': tokkens.get('refresh_token'), - 'locale': "en" + 'locale': "en-GB" }) @@ -47,7 +47,6 @@ class TranslateFieldTests(BaseTestCase): "ru-RU": "Тестовая новость" }, description={"en-GB": "Test description"}, - playlist=1, start=datetime.now(pytz.utc) + timedelta(hours=-13), end=datetime.now(pytz.utc) + timedelta(hours=13), news_type=self.news_type, @@ -59,12 +58,11 @@ class TranslateFieldTests(BaseTestCase): def test_model_field(self): self.assertTrue(hasattr(self.news_item, "title_translated")) - def test_read_locale(self): response = self.client.get(f"/api/web/news/slug/{self.news_item.slug}/", format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) news_data = response.json() - + print(news_data) self.assertIn("title_translated", news_data) self.assertIn("Test news item", news_data['title_translated']) diff --git a/celerybeat-schedule b/celerybeat-schedule index 3efe528a..e1a56a15 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/compose-ci.yml b/compose-ci.yml new file mode 100644 index 00000000..94079822 --- /dev/null +++ b/compose-ci.yml @@ -0,0 +1,79 @@ +version: '2' +services: + db: + build: + context: ./_dockerfiles/db + dockerfile: Dockerfile + hostname: db + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=postgres + ports: + - "5436:5432" + + elasticsearch: + image: elasticsearch:7.3.1 + hostname: elasticsearch + ports: + - 9200:9200 + - 9300:9300 + environment: + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + - discovery.type=single-node + - xpack.security.enabled=false + + # Redis + redis: + image: redis:2.8.23 + ports: + - "6379:6379" + + # Celery + worker: + build: . + command: ./run_celery.sh + environment: + - SETTINGS_CONFIGURATION=local + - DB_NAME=postgres + - DB_USERNAME=postgres + - DB_HOSTNAME=db + - DB_PORT=5432 + - DB_PASSWORD=postgres + links: + - db + - redis + + worker_beat: + build: . + command: ./run_celery_beat.sh + environment: + - SETTINGS_CONFIGURATION=local + - DB_NAME=postgres + - DB_USERNAME=postgres + - DB_HOSTNAME=db + - DB_PORT=5432 + - DB_PASSWORD=postgres + links: + - db + - redis + + # App: G&M + gm_app: + build: . + command: python manage.py runserver 0.0.0.0:8000 + environment: + - SETTINGS_CONFIGURATION=local + - DB_HOSTNAME=db + - DB_PORT=5432 + - DB_NAME=postgres + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + depends_on: + - db + - redis + - worker + - worker_beat + - elasticsearch + ports: + - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index 3b446101..c518a3ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,14 +31,6 @@ services: # Redis redis: image: redis:2.8.23 - ports: - - "6379:6379" - - # RabbitMQ - #rabbitmq: - # image: rabbitmq:latest - # ports: - # - "5672:5672" # Celery worker: diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 00000000..9ad7f871 --- /dev/null +++ b/fabfile.py @@ -0,0 +1,69 @@ +import os # NOQA +from fabric.api import * # NOQA + + + +user = 'gm' + +env.roledefs = { + 'develop': { + 'branch': 'develop', + 'hosts': ['%s@95.213.204.126' % user, ] + } +} + + +env.root = '~/' +env.src = '~/project' + +env.default_branch = 'develop' +env.tmpdir = '~/tmp' + + +def fetch(branch=None): + with cd(env.src): + role = env.roles[0] + run('git pull origin {}'.format(env.roledefs[role]['branch'])) + + +def migrate(): + with cd(env.src): + run('./manage.py migrate') + + +def install_requirements(): + with cd(env.src): + run('pip install -r requirements/base.txt') + + +def touch(): + with cd(env.src): + run('touch ~/%s.touch' % user) + + +def kill_celery(): + """Kill celery workers for $user.""" + with cd(env.src): + run('ps -u %s -o pid,fname | grep celery | (while read a b; do kill -9 $a; done;)' % user) + + +def collectstatic(): + with cd(env.src): + run('./manage.py collectstatic --noinput') + + +def deploy(branch=None): + fetch() + install_requirements() + migrate() + collectstatic() + touch() + kill_celery() + + +def rev(): + """Show head commit.""" + with hide('running', 'stdout'): + with cd(env.src): + commit = run('git rev-parse HEAD') + return local('git show -q %s' % commit) \ No newline at end of file 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 cd797225..99a76f79 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', @@ -96,6 +97,10 @@ EXTERNAL_APPS = [ 'rest_framework_simplejwt.token_blacklist', 'solo', 'phonenumber_field', + 'timezone_field', + 'storages', + 'sorl.thumbnail', + 'timezonefinder' ] @@ -204,19 +209,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', @@ -293,12 +285,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' @@ -306,6 +300,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", @@ -328,6 +323,7 @@ REDOC_SETTINGS = { 'LAZY_RENDERING': False, } + # CELERY # RabbitMQ # BROKER_URL = 'amqp://rabbitmq:5672' @@ -340,7 +336,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" @@ -349,39 +345,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 = { @@ -421,7 +422,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 @@ -442,24 +444,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' diff --git a/project/settings/development.py b/project/settings/development.py index ff80b492..9f950abf 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' @@ -33,3 +34,7 @@ sentry_sdk.init( dsn="https://35d9bb789677410ab84a822831c6314f@sentry.io/1729093", integrations=[DjangoIntegration()] ) + +# TMP ( TODO remove it later) +# Временный хардкод для демонстрации 4 ноября, потом удалить! +HARDCODED_INTERNATIONAL_NEWS_IDS = [8, 9, 10, 11, 15, 17] 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..0afe7e4e 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' @@ -25,3 +26,8 @@ ELASTICSEARCH_INDEX_NAMES = { # 'search_indexes.documents.news': 'stage_news', #temporarily disabled 'search_indexes.documents.establishment': 'stage_establishment', } + + +# TMP ( TODO remove it later) +# Временный хардкод для демонстрации 4 ноября, потом удалить! +HARDCODED_INTERNATIONAL_NEWS_IDS = [8, 9, 10, 11, 15, 17] \ No newline at end of file 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 77d007dc..4ec2fec5 100644 --- a/project/urls/mobile.py +++ b/project/urls/mobile.py @@ -4,8 +4,10 @@ app_name = 'mobile' urlpatterns = [ path('establishments/', include('establishment.urls.mobile')), + path('location/', include('location.urls.mobile')), path('main/', include('main.urls.mobile')), - path('location/', include('location.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 7de1517e..bd073996 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,10 +35,15 @@ 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 -mysqlclient==1.4.4 +sorl-thumbnail==12.5.0 # temp solution redis==3.2.0 amqp>=2.4.0 celery==4.3.0rc2 + +mysqlclient==1.4.4 \ No newline at end of file