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/models.py b/apps/account/models.py index da61f908..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) 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/establishment/models.py b/apps/establishment/models.py index 1406e552..c01d8613 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 @@ -12,6 +14,7 @@ from django.db.models import When, Case, F, ExpressionWrapper, Subquery, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ from phonenumber_field.modelfields import PhoneNumberField +from pytz import timezone as py_tz from collection.models import Collection from location.models import Address @@ -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) \ @@ -416,6 +419,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=py_tz(self.address.tz_name)) + current_week = now_at_est_tz.weekday() + schedule_for_today = self.schedule.filter(weekday=current_week).first() + if schedule_for_today 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 +460,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/gallery/admin.py b/apps/gallery/admin.py index fc20b0ee..e325a3ed 100644 --- a/apps/gallery/admin.py +++ b/apps/gallery/admin.py @@ -5,4 +5,9 @@ from gallery.models import Image @admin.register(Image) class ImageModelAdmin(admin.ModelAdmin): - """Image model admin""" + """Image model admin.""" + list_display = ['id', 'title', 'orientation_display', 'image_tag', ] + + def orientation_display(self, obj): + """Get image orientation name.""" + return obj.get_orientation_display() if obj.orientation else None diff --git a/apps/gallery/migrations/0002_auto_20190930_0714.py b/apps/gallery/migrations/0002_auto_20190930_0714.py new file mode 100644 index 00000000..8423910f --- /dev/null +++ b/apps/gallery/migrations/0002_auto_20190930_0714.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.4 on 2019-09-30 07:14 + +from django.db import migrations, models +import django.db.models.deletion +import easy_thumbnails.fields +import utils.methods + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='orientation', + field=models.PositiveSmallIntegerField(blank=True, choices=[(0, 'Horizontal'), (1, 'Vertical')], default=None, null=True, verbose_name='image orientation'), + ), + migrations.AddField( + model_name='image', + name='parent', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='parent_image', to='gallery.Image', verbose_name='parent image'), + ), + migrations.AddField( + model_name='image', + name='source', + field=models.PositiveSmallIntegerField(choices=[(0, 'Mobile'), (1, 'Web'), (2, 'All')], default=0, verbose_name='Source'), + ), + migrations.AddField( + model_name='image', + name='title', + field=models.CharField(default='', max_length=255, verbose_name='title'), + ), + migrations.AlterField( + model_name='image', + name='image', + field=easy_thumbnails.fields.ThumbnailerImageField(upload_to=utils.methods.image_path, verbose_name='image file'), + ), + ] diff --git a/apps/gallery/migrations/0003_auto_20191003_1228.py b/apps/gallery/migrations/0003_auto_20191003_1228.py new file mode 100644 index 00000000..4d054a29 --- /dev/null +++ b/apps/gallery/migrations/0003_auto_20191003_1228.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.4 on 2019-10-03 12:28 + +from django.db import migrations +import sorl.thumbnail.fields +import utils.methods + + +class Migration(migrations.Migration): + + dependencies = [ + ('gallery', '0002_auto_20190930_0714'), + ] + + operations = [ + migrations.RemoveField( + model_name='image', + name='parent', + ), + migrations.AlterField( + model_name='image', + name='image', + field=sorl.thumbnail.fields.ImageField(upload_to=utils.methods.image_path, verbose_name='image file'), + ), + ] diff --git a/apps/gallery/models.py b/apps/gallery/models.py index 022a24e0..bf3870cb 100644 --- a/apps/gallery/models.py +++ b/apps/gallery/models.py @@ -1,15 +1,34 @@ +from django.db import models from django.utils.translation import gettext_lazy as _ -from easy_thumbnails.fields import ThumbnailerImageField +from sorl.thumbnail import delete +from sorl.thumbnail.fields import ImageField as SORLImageField from utils.methods import image_path -from utils.models import ProjectBaseMixin, ImageMixin +from utils.models import ProjectBaseMixin, SORLImageMixin, PlatformMixin -class Image(ProjectBaseMixin, ImageMixin): +class ImageQuerySet(models.QuerySet): + """QuerySet for model Image.""" + + +class Image(ProjectBaseMixin, SORLImageMixin, PlatformMixin): """Image model.""" + HORIZONTAL = 0 + VERTICAL = 1 - image = ThumbnailerImageField(upload_to=image_path, - verbose_name=_('Image file')) + ORIENTATIONS = ( + (HORIZONTAL, _('Horizontal')), + (VERTICAL, _('Vertical')), + ) + + image = SORLImageField(upload_to=image_path, + verbose_name=_('image file')) + orientation = models.PositiveSmallIntegerField(choices=ORIENTATIONS, + blank=True, null=True, default=None, + verbose_name=_('image orientation')) + title = models.CharField(_('title'), max_length=255, default='') + + objects = ImageQuerySet.as_manager() class Meta: """Meta class.""" @@ -18,4 +37,22 @@ class Image(ProjectBaseMixin, ImageMixin): def __str__(self): """String representation""" - return str(self.id) + return f'{self.title}' + + 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..3591d4df 100644 --- a/apps/location/models.py +++ b/apps/location/models.py @@ -5,8 +5,10 @@ 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 timezonefinder import TimezoneFinder from translation.models import Language +from utils.models import ProjectBaseMixin, SVGImageMixin, TranslatedFieldsMixin, TJSONField class Country(TranslatedFieldsMixin, SVGImageMixin, ProjectBaseMixin): @@ -112,6 +114,11 @@ class Address(models.Model): def get_street_name(self): return self.street_name_1 or self.street_name_2 + @property + def tz_name(self): + tf = TimezoneFinder(in_memory=True) + return tf.certain_timezone_at(lng=self.latitude, lat=self.longitude) + @property def latitude(self): return self.coordinates.y if self.coordinates else float(0) diff --git a/apps/main/models.py b/apps/main/models.py index bbdd6386..3a54bedf 100644 --- a/apps/main/models.py +++ b/apps/main/models.py @@ -130,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, 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_merge_20191023_1000.py b/apps/news/migrations/0023_merge_20191023_1000.py new file mode 100644 index 00000000..75dfd1b2 --- /dev/null +++ b/apps/news/migrations/0023_merge_20191023_1000.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.4 on 2019-10-23 10:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0021_merge_20191002_1300'), + ('news', '0022_auto_20191021_1306'), + ] + + operations = [ + ] diff --git a/apps/news/migrations/0024_newsgallery_is_main.py b/apps/news/migrations/0024_newsgallery_is_main.py new file mode 100644 index 00000000..aa7fffd9 --- /dev/null +++ b/apps/news/migrations/0024_newsgallery_is_main.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.4 on 2019-10-23 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('news', '0023_merge_20191023_1000'), + ] + + operations = [ + migrations.AddField( + model_name='newsgallery', + name='is_main', + field=models.BooleanField(default=False, verbose_name='Is the main image'), + ), + ] diff --git a/apps/news/models.py b/apps/news/models.py index 9e2a2926..1abd10e1 100644 --- a/apps/news/models.py +++ b/apps/news/models.py @@ -146,10 +146,6 @@ class News(BaseAttributes, TranslatedFieldsMixin): 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 +155,7 @@ class News(BaseAttributes, TranslatedFieldsMixin): verbose_name=_('country')) tags = models.ManyToManyField('tag.Tag', related_name='news', verbose_name=_('Tags')) + gallery = models.ManyToManyField('gallery.Image', through='news.NewsGallery') ratings = generic.GenericRelation(Rating) agenda = models.ForeignKey('news.Agenda', blank=True, null=True, @@ -195,3 +192,36 @@ class News(BaseAttributes, TranslatedFieldsMixin): @property def same_theme(self): return self.__class__.objects.same_theme(self)[:3] + + @property + def main_image(self): + return self.news_gallery.main_images().first().image + + +class NewsGalleryQuerySet(models.QuerySet): + """QuerySet for model News""" + + def main_images(self): + """Return objects with flag is_main is True""" + return self.filter(is_main=True) + + +class NewsGallery(models.Model): + + news = models.ForeignKey(News, null=True, + related_name='news_gallery', + on_delete=models.SET_NULL, + verbose_name=_('news')) + image = models.ForeignKey('gallery.Image', null=True, + related_name='news_gallery', + on_delete=models.SET_NULL, + verbose_name=_('gallery')) + is_main = models.BooleanField(default=False, + verbose_name=_('Is the main image')) + + objects = NewsGalleryQuerySet.as_manager() + + class Meta: + """NewsGallery meta class.""" + verbose_name = _('news gallery') + verbose_name_plural = _('news galleries') diff --git a/apps/news/serializers.py b/apps/news/serializers.py index 10662880..417db72e 100644 --- a/apps/news/serializers.py +++ b/apps/news/serializers.py @@ -1,6 +1,9 @@ """News app common serializers.""" +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers + from account.serializers.common import UserBaseSerializer +from gallery.models import Image from location import models as location_models from location.serializers import CountrySimpleSerializer, AddressBaseSerializer from news import models @@ -42,6 +45,77 @@ class NewsBannerSerializer(ProjectModelSerializer): ) +class CropImageSerializer(serializers.Serializer): + """Serializer for crop images for News object.""" + + preview_url = serializers.SerializerMethodField() + promo_horizontal_web_url = serializers.SerializerMethodField() + promo_horizontal_mobile_url = serializers.SerializerMethodField() + tile_horizontal_web_url = serializers.SerializerMethodField() + tile_horizontal_mobile_url = serializers.SerializerMethodField() + tile_vertical_web_url = serializers.SerializerMethodField() + highlight_vertical_web_url = serializers.SerializerMethodField() + editor_web_url = serializers.SerializerMethodField() + editor_mobile_url = serializers.SerializerMethodField() + + def get_preview_url(self, obj): + """Get crop preview.""" + return obj.instance.get_image_url('news_preview') + + def get_promo_horizontal_web_url(self, obj): + """Get crop promo_horizontal_web.""" + return obj.instance.get_image_url('news_promo_horizontal_web') + + def get_promo_horizontal_mobile_url(self, obj): + """Get crop promo_horizontal_mobile.""" + return obj.instance.get_image_url('news_promo_horizontal_mobile') + + def get_tile_horizontal_web_url(self, obj): + """Get crop tile_horizontal_web.""" + return obj.instance.get_image_url('news_tile_horizontal_web') + + def get_tile_horizontal_mobile_url(self, obj): + """Get crop tile_horizontal_mobile.""" + return obj.instance.get_image_url('news_tile_horizontal_mobile') + + def get_tile_vertical_web_url(self, obj): + """Get crop tile_vertical_web.""" + return obj.instance.get_image_url('news_tile_vertical_web') + + def get_highlight_vertical_web_url(self, obj): + """Get crop highlight_vertical_web.""" + return obj.instance.get_image_url('news_highlight_vertical_web') + + def get_editor_web_url(self, obj): + """Get crop editor_web.""" + return obj.instance.get_image_url('news_editor_web') + + def get_editor_mobile_url(self, obj): + """Get crop editor_mobile.""" + return obj.instance.get_image_url('news_editor_mobile') + + +class NewsImageSerializer(serializers.ModelSerializer): + """Serializer for returning crop images of news image.""" + orientation_display = serializers.CharField(source='get_orientation_display', + read_only=True) + original_url = serializers.URLField(source='image.url') + auto_crop_images = CropImageSerializer(source='image', allow_null=True) + + class Meta: + model = Image + fields = [ + 'id', + 'title', + 'orientation_display', + 'original_url', + 'auto_crop_images', + ] + extra_kwargs = { + 'orientation': {'write_only': True} + } + + class NewsTypeSerializer(serializers.ModelSerializer): """News type serializer.""" @@ -56,7 +130,7 @@ class NewsBaseSerializer(ProjectModelSerializer): """Base serializer for News model.""" # read only fields - title_translated = TranslatedField(source='title') + title_translated = TranslatedField() subtitle_translated = TranslatedField() # related fields @@ -72,14 +146,40 @@ class NewsBaseSerializer(ProjectModelSerializer): 'title_translated', 'subtitle_translated', 'is_highlighted', - 'image_url', - 'preview_image_url', 'news_type', 'tags', 'slug', ) +class NewsListSerializer(NewsBaseSerializer): + """List serializer for News model.""" + + # read only fields + title_translated = TranslatedField() + subtitle_translated = TranslatedField() + + # related fields + news_type = NewsTypeSerializer(read_only=True) + tags = TagBaseSerializer(read_only=True, many=True) + image = NewsImageSerializer(source='main_image', allow_null=True) + + class Meta: + """Meta class.""" + + model = models.News + fields = ( + 'id', + 'title_translated', + 'subtitle_translated', + 'is_highlighted', + 'news_type', + 'tags', + 'slug', + 'image', + ) + + class NewsDetailSerializer(NewsBaseSerializer): """News detail serializer.""" @@ -88,6 +188,7 @@ class NewsDetailSerializer(NewsBaseSerializer): author = UserBaseSerializer(source='created_by', read_only=True) state_display = serializers.CharField(source='get_state_display', read_only=True) + gallery = NewsImageSerializer(read_only=True, many=True) class Meta(NewsBaseSerializer.Meta): """Meta class.""" @@ -102,6 +203,7 @@ class NewsDetailSerializer(NewsBaseSerializer): 'state_display', 'author', 'country', + 'gallery', ) @@ -160,3 +262,42 @@ 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') + + 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')}) + + attrs['news'] = news + attrs['image'] = image + + return attrs diff --git a/apps/news/urls/back.py b/apps/news/urls/back.py index 8522592e..4ab11727 100644 --- a/apps/news/urls/back.py +++ b/apps/news/urls/back.py @@ -1,5 +1,6 @@ """News app urlpatterns for backoffice""" from django.urls import path + from news import views app_name = 'news' @@ -7,5 +8,9 @@ app_name = 'news' urlpatterns = [ path('', views.NewsBackOfficeLCView.as_view(), name='list-create'), path('/', views.NewsBackOfficeRUDView.as_view(), - name='retrieve-update-destroy'), + name='gallery-retrieve-update-destroy'), + path('/gallery/', views.NewsBackOfficeGalleryListView.as_view(), + name='gallery-list'), + path('/gallery//', views.NewsBackOfficeGalleryCreateDestroyView.as_view(), + name='gallery-create-destroy'), ] \ No newline at end of file diff --git a/apps/news/views.py b/apps/news/views.py index 78a3502b..c2a71eca 100644 --- a/apps/news/views.py +++ b/apps/news/views.py @@ -1,6 +1,13 @@ """News app views.""" from django.shortcuts import get_object_or_404 from rest_framework import generics, permissions +from django.conf import settings +from django.db.transaction import on_commit +from django.shortcuts import get_object_or_404 +from rest_framework import generics, permissions, status +from rest_framework.response import Response + +from gallery.tasks import delete_image from news import filters, models, serializers from rating.tasks import add_rating from utils.permissions import IsCountryAdmin, IsContentPageManager @@ -23,7 +30,7 @@ class NewsMixinView: class NewsListView(NewsMixinView, generics.ListAPIView): """News list view.""" - + serializer_class = serializers.NewsListSerializer filter_class = filters.NewsListFilterSet @@ -74,6 +81,64 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView, return super().get_queryset().with_extended_related() +class NewsBackOfficeGalleryCreateDestroyView(NewsBackOfficeMixinView, + generics.CreateAPIView, + generics.DestroyAPIView): + """Resource for a create gallery for news for back-office users.""" + serializer_class = serializers.NewsBackOfficeGallerySerializer + + def get_object(self): + """ + Returns the object the view is displaying. + """ + news_qs = self.filter_queryset(self.get_queryset()) + + news = get_object_or_404(news_qs, pk=self.kwargs['pk']) + gallery = get_object_or_404(news.news_gallery, image_id=self.kwargs['image_id']) + + # May raise a permission denied + self.check_object_permissions(self.request, gallery) + + return gallery + + def create(self, request, *args, **kwargs): + """Override create method""" + super().create(request, *args, **kwargs) + return Response(status=status.HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + """Override destroy method.""" + gallery_obj = self.get_object() + if settings.USE_CELERY: + on_commit(lambda: delete_image.delay(image_id=gallery_obj.image.id, + completely=False)) + else: + on_commit(lambda: delete_image(image_id=gallery_obj.image.id, + completely=False)) + # Delete an instances of NewsGallery model + gallery_obj.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class NewsBackOfficeGalleryListView(NewsBackOfficeMixinView, generics.ListAPIView): + """Resource for returning gallery for news for back-office users.""" + serializer_class = serializers.NewsImageSerializer + + def get_object(self): + """Override get_object method.""" + qs = super(NewsBackOfficeGalleryListView, self).get_queryset() + news = get_object_or_404(qs, pk=self.kwargs['pk']) + + # May raise a permission denied + self.check_object_permissions(self.request, news) + + return news + + def get_queryset(self): + """Override get_queryset method.""" + return self.get_object().gallery.all() + + class NewsBackOfficeRUDView(NewsBackOfficeMixinView, generics.RetrieveUpdateDestroyAPIView): """Resource for detailed information about news for back-office users.""" diff --git a/apps/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..21c59e68 100644 --- a/apps/search_indexes/documents/news.py +++ b/apps/search_indexes/documents/news.py @@ -24,6 +24,7 @@ class NewsDocument(Document): country = fields.ObjectField(properties={'id': fields.IntegerField(), 'code': fields.KeywordField()}) web_url = fields.KeywordField(attr='web_url') + preview_image_url = fields.TextField(attr='preview_image_url') tags = fields.ObjectField( properties={ 'id': fields.IntegerField(attr='id'), @@ -43,8 +44,6 @@ class NewsDocument(Document): 'slug', 'state', 'is_highlighted', - 'image_url', - 'preview_image_url', 'template', ) related_models = [models.NewsType] diff --git a/apps/search_indexes/serializers.py b/apps/search_indexes/serializers.py index 18a1e240..535974a9 100644 --- a/apps/search_indexes/serializers.py +++ b/apps/search_indexes/serializers.py @@ -30,6 +30,15 @@ class AddressDocumentSerializer(serializers.Serializer): geo_lat = serializers.FloatField(allow_null=True, source='coordinates.lat') +class ScheduleDocumentSerializer(serializers.Serializer): + """Schedule serializer for ES Document""" + + id = serializers.IntegerField() + weekday = serializers.IntegerField() + weekday_display = serializers.CharField() + closed_at = serializers.CharField() + + class NewsDocumentSerializer(DocumentSerializer): """News document serializer.""" @@ -68,6 +77,7 @@ class EstablishmentDocumentSerializer(DocumentSerializer): address = AddressDocumentSerializer() tags = TagsDocumentSerializer(many=True) + schedule = ScheduleDocumentSerializer(many=True, allow_null=True) class Meta: """Meta class.""" @@ -84,6 +94,10 @@ class EstablishmentDocumentSerializer(DocumentSerializer): 'preview_image', 'address', 'tags', + 'schedule', + 'works_noon', + 'works_evening', + 'works_now', # 'collections', # 'establishment_type', # 'establishment_subtypes', diff --git a/apps/search_indexes/views.py b/apps/search_indexes/views.py index 50c32fc7..913c2d95 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,25 @@ 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_FILTER_TERM, + ], + }, + 'works_now': { + 'field': 'works_now', + 'lookups': [ + constants.LOOKUP_FILTER_EXISTS, + constants.LOOKUP_QUERY_IN, + ] + }, } geo_spatial_filter_fields = { diff --git a/apps/tag/models.py b/apps/tag/models.py index 44eacddc..f5565e9f 100644 --- a/apps/tag/models.py +++ b/apps/tag/models.py @@ -56,7 +56,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): 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/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..76a37257 100644 --- a/apps/timetable/serialziers.py +++ b/apps/timetable/serialziers.py @@ -77,3 +77,16 @@ class ScheduleCreateSerializer(ScheduleRUDSerializer): schedule_qs.delete() establishment.schedule.add(instance) return instance + +class TimetableSerializer(serializers.ModelSerializer): + """Serailzier for Timetable model.""" + weekday_display = serializers.CharField(source='get_weekday_display', + read_only=True) + + class Meta: + model = Timetable + fields = ( + 'id', + 'weekday_display', + 'works_at_noon', + ) diff --git a/apps/timetable/urls/__init__.py b/apps/timetable/urls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/timetable/urls.py b/apps/timetable/urls/common.py similarity index 67% rename from apps/timetable/urls.py rename to apps/timetable/urls/common.py index ff4f4f00..95593b20 100644 --- a/apps/timetable/urls.py +++ b/apps/timetable/urls/common.py @@ -6,5 +6,5 @@ from timetable import views app_name = 'timetable' urlpatterns = [ - path('', views.TimetableListView.as_view(), name='list') + # path('', views.TimetableListView.as_view(), name='list') ] \ No newline at end of file diff --git a/apps/timetable/urls/mobile.py b/apps/timetable/urls/mobile.py new file mode 100644 index 00000000..1c6419a3 --- /dev/null +++ b/apps/timetable/urls/mobile.py @@ -0,0 +1,6 @@ +from timetable.urls.common import urlpatterns as common_urlpatterns + + +urlpatterns = [] + +urlpatterns.extend(common_urlpatterns) \ No newline at end of file diff --git a/apps/timetable/urls/web.py b/apps/timetable/urls/web.py new file mode 100644 index 00000000..1c6419a3 --- /dev/null +++ b/apps/timetable/urls/web.py @@ -0,0 +1,6 @@ +from timetable.urls.common import urlpatterns as common_urlpatterns + + +urlpatterns = [] + +urlpatterns.extend(common_urlpatterns) \ No newline at end of file diff --git a/apps/timetable/views.py b/apps/timetable/views.py index d8a13f71..5129f068 100644 --- a/apps/timetable/views.py +++ b/apps/timetable/views.py @@ -1,4 +1,4 @@ -from rest_framework import generics +from rest_framework import generics, permissions from timetable import serialziers, models @@ -6,3 +6,5 @@ class TimetableListView(generics.ListAPIView): """Method to get timetables""" serializer_class = serialziers.TimetableSerializer queryset = models.Timetable.objects.all() + pagination_class = None + permission_classes = (permissions.AllowAny, ) diff --git a/apps/utils/methods.py b/apps/utils/methods.py index acad6502..bea8fec7 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -1,13 +1,19 @@ """Utils app method.""" +import logging import random import re import string +import requests from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.http.request import HttpRequest from django.utils.timezone import datetime +from rest_framework import status from rest_framework.request import Request +from os.path import exists + +logger = logging.getLogger(__name__) def generate_code(digits=6, string_output=True): @@ -86,3 +92,30 @@ def get_contenttype(app_label: str, model: str): qs = ContentType.objects.filter(app_label=app_label, model=model) if qs.exists(): return qs.first() + + +def image_url_valid(url: str): + """ + Check if requested URL is valid. + :param url: string + :return: boolean + """ + try: + assert url.startswith('http') + response = requests.request('head', url) + except Exception as e: + logger.info(f'ConnectionError: {e}') + else: + return response.status_code == status.HTTP_200_OK + + +def absolute_url_decorator(func): + def get_absolute_image_url(self, obj): + """Get absolute image url""" + url_path = func(self, obj) + if url_path: + if url_path.startswith('/media/'): + return f'{settings.MEDIA_URL}{url_path}/' + else: + return url_path + return get_absolute_image_url diff --git a/apps/utils/models.py b/apps/utils/models.py index 4e6df35e..fb1de17c 100644 --- a/apps/utils/models.py +++ b/apps/utils/models.py @@ -1,5 +1,8 @@ """Utils app models.""" +import logging from os.path import exists + +from django.conf import settings from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.gis.db import models from django.contrib.postgres.fields import JSONField @@ -8,10 +11,13 @@ from django.utils import timezone from django.utils.html import mark_safe from django.utils.translation import ugettext_lazy as _, get_language from easy_thumbnails.fields import ThumbnailerImageField +from sorl.thumbnail import get_thumbnail +from sorl.thumbnail.fields import ImageField as SORLImageField + from utils.methods import image_path, svg_image_path from utils.validators import svg_image_validator -from django.db.models.fields import Field -from django.core import exceptions + +logger = logging.getLogger(__name__) class ProjectBaseMixin(models.Model): @@ -118,7 +124,7 @@ class OAuthProjectMixin: def get_source(self): """Method to get of platform""" - return NotImplemented + return NotImplementedError class BaseAttributes(ProjectBaseMixin): @@ -177,6 +183,41 @@ class ImageMixin(models.Model): image_tag.allow_tags = True +class SORLImageMixin(models.Model): + """Abstract model for SORL ImageField""" + + image = SORLImageField(upload_to=image_path, + blank=True, null=True, default=None, + verbose_name=_('Image')) + + class Meta: + """Meta class.""" + abstract = True + + def get_image(self, thumbnail_key: str): + """Get thumbnail image file.""" + if thumbnail_key in settings.SORL_THUMBNAIL_ALIASES: + return get_thumbnail( + file_=self.image, + **settings.SORL_THUMBNAIL_ALIASES[thumbnail_key]) + + def get_image_url(self, thumbnail_key: str): + """Get image thumbnail url.""" + crop_image = self.get_image(thumbnail_key) + if hasattr(crop_image, 'url'): + return self.get_image(thumbnail_key).url + + def image_tag(self): + """Admin preview tag.""" + if self.image: + return mark_safe(f'') + else: + return None + + image_tag.short_description = _('Image') + image_tag.allow_tags = True + + class SVGImageMixin(models.Model): """SVG image model.""" diff --git a/apps/utils/pagination.py b/apps/utils/pagination.py index 2c9e92e5..ac83f4f2 100644 --- a/apps/utils/pagination.py +++ b/apps/utils/pagination.py @@ -52,4 +52,4 @@ class EstablishmentPortionPagination(ProjectMobilePagination): """ Pagination for app establishments with limit page size equal to 12 """ - page_size = settings.LIMITING_OUTPUT_OBJECTS + page_size = settings.QUERY_OUTPUT_OBJECTS diff --git a/apps/utils/querysets.py b/apps/utils/querysets.py index bf2816f2..45798fbb 100644 --- a/apps/utils/querysets.py +++ b/apps/utils/querysets.py @@ -1,8 +1,10 @@ """Utils QuerySet Mixins""" -from django.db import models -from django.db.models import Q, Sum, F from functools import reduce from operator import add + +from django.db import models +from django.db.models import Q, F + from utils.methods import get_contenttype @@ -50,7 +52,7 @@ class RelatedObjectsCountMixin(models.QuerySet): def filter_all_related_gt(self, count): """Queryset filter by all related objects count""" - exp =reduce(add, [F(f"{related_object}_count") for related_object in self._get_related_objects_names()]) + exp = reduce(add, [F(f"{related_object}_count") for related_object in self._get_related_objects_names()]) return self._annotate_related_objects_count()\ .annotate(all_related_count=exp)\ .filter(all_related_count__gt=count) diff --git a/celerybeat-schedule b/celerybeat-schedule index 3efe528a..e1a56a15 100644 Binary files a/celerybeat-schedule and b/celerybeat-schedule differ diff --git a/project/settings/amazon_s3.py b/project/settings/amazon_s3.py new file mode 100644 index 00000000..c793dd77 --- /dev/null +++ b/project/settings/amazon_s3.py @@ -0,0 +1,22 @@ +"""Settings for Amazon S3""" +import os + +from .base import MEDIA_LOCATION + +# AMAZON S3 +AWS_S3_REGION_NAME = 'eu-central-1' +AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') +AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') +AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') +AWS_S3_CUSTOM_DOMAIN = f's3.{AWS_S3_REGION_NAME}.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} +AWS_S3_ADDRESSING_STYLE = 'path' + +# Static settings +# PUBLIC_STATIC_LOCATION = 'static' +# STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{PUBLIC_STATIC_LOCATION}/' +# STATICFILES_STORAGE = 'project.storage_backends.PublicStaticStorage' + +# Public media settings +MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/' +DEFAULT_FILE_STORAGE = 'project.storage_backends.PublicMediaStorage' diff --git a/project/settings/base.py b/project/settings/base.py index 920caf4e..bec8fc2b 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -95,6 +95,9 @@ EXTERNAL_APPS = [ 'rest_framework_simplejwt.token_blacklist', 'solo', 'phonenumber_field', + 'storages', + 'sorl.thumbnail', + 'timezonefinder' ] @@ -194,19 +197,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', @@ -283,12 +273,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' @@ -296,6 +288,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", @@ -318,6 +311,7 @@ REDOC_SETTINGS = { 'LAZY_RENDERING': False, } + # CELERY # RabbitMQ # BROKER_URL = 'amqp://rabbitmq:5672' @@ -330,7 +324,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" @@ -339,39 +333,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 = { @@ -433,24 +432,42 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_MAX_MEMORY_SIZE = 5242880 # 5MB FILE_UPLOAD_PERMISSIONS = 0o644 + # SOLO SETTINGS # todo: make a separate service (redis?) and update solo_cache SOLO_CACHE = 'default' SOLO_CACHE_PREFIX = 'solo' SOLO_CACHE_TIMEOUT = 300 + # REDIRECT URL SITE_REDIRECT_URL_UNSUBSCRIBE = '/unsubscribe/' SITE_NAME = 'Gault & Millau' + # Used in annotations for establishments. DEFAULT_ESTABLISHMENT_PUBLIC_MARK = 10 # Limit output objects (see in pagination classes). -LIMITING_OUTPUT_OBJECTS = 12 +QUERY_OUTPUT_OBJECTS = 12 # Need to restrict objects to sort (3 times more then expected). -LIMITING_QUERY_NUMBER = LIMITING_OUTPUT_OBJECTS * 3 +LIMITING_QUERY_OBJECTS = QUERY_OUTPUT_OBJECTS * 3 + # GEO # A Spatial Reference System Identifier GEO_DEFAULT_SRID = 4326 +GEOIP_PATH = os.path.join(PROJECT_ROOT, 'geoip_db') + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/2.2/howto/static-files/ +STATIC_ROOT = os.path.join(PUBLIC_ROOT, 'static') +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( + os.path.join(PROJECT_ROOT, 'static'), +) + + +# MEDIA +MEDIA_LOCATION = 'media' \ No newline at end of file diff --git a/project/settings/development.py b/project/settings/development.py index ff80b492..59691818 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -1,13 +1,14 @@ """Development settings.""" from .base import * +from .amazon_s3 import * import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration -ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126'] +ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126', '0.0.0.0'] SEND_SMS = False SMS_CODE_SHOW = True -USE_CELERY = False +USE_CELERY = True SCHEMA_URI = 'http' DEFAULT_SUBDOMAIN = 'www' diff --git a/project/settings/local.py b/project/settings/local.py index 31fe88c2..e87b99f6 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -1,18 +1,22 @@ """Local settings.""" from .base import * import sys +# from .amazon_s3 import * ALLOWED_HOSTS = ['*', ] + SEND_SMS = False SMS_CODE_SHOW = True USE_CELERY = True + SCHEMA_URI = 'http' DEFAULT_SUBDOMAIN = 'www' SITE_DOMAIN_URI = 'testserver.com:8000' DOMAIN_URI = '0.0.0.0:8000' + # CELERY # RabbitMQ # BROKER_URL = 'amqp://rabbitmq:5672' @@ -22,6 +26,15 @@ CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL +# MEDIA +MEDIA_URL = f'{SCHEMA_URI}://{DOMAIN_URI}/{MEDIA_LOCATION}/' +MEDIA_ROOT = os.path.join(PUBLIC_ROOT, MEDIA_LOCATION) + + +# SORL thumbnails +THUMBNAIL_DEBUG = True + + # LOGGING LOGGING = { 'version': 1, @@ -59,6 +72,7 @@ LOGGING = { } } + # ELASTICSEARCH SETTINGS ELASTICSEARCH_DSL = { 'default': { @@ -66,7 +80,6 @@ ELASTICSEARCH_DSL = { } } - ELASTICSEARCH_INDEX_NAMES = { # 'search_indexes.documents.news': 'local_news', 'search_indexes.documents.establishment': 'local_establishment', diff --git a/project/settings/production.py b/project/settings/production.py index 5682a59d..b7ddc401 100644 --- a/project/settings/production.py +++ b/project/settings/production.py @@ -1,9 +1,10 @@ """Production settings.""" from .base import * +from .amazon_s3 import * # Booking API configuration GUESTONLINE_SERVICE = 'https://api.guestonline.fr/' GUESTONLINE_TOKEN = '' LASTABLE_SERVICE = '' LASTABLE_TOKEN = '' -LASTABLE_PROXY = '' \ No newline at end of file +LASTABLE_PROXY = '' diff --git a/project/settings/stage.py b/project/settings/stage.py index c0d6fdb1..49a7ae0f 100644 --- a/project/settings/stage.py +++ b/project/settings/stage.py @@ -1,11 +1,12 @@ """Stage settings.""" from .base import * +from .amazon_s3 import * ALLOWED_HOSTS = ['gm-stage.id-east.ru', '95.213.204.126'] SEND_SMS = False SMS_CODE_SHOW = True -USE_CELERY = False +USE_CELERY = True SCHEMA_URI = 'https' DEFAULT_SUBDOMAIN = 'www' diff --git a/project/storage_backends.py b/project/storage_backends.py new file mode 100644 index 00000000..633337e8 --- /dev/null +++ b/project/storage_backends.py @@ -0,0 +1,12 @@ +"""Extend storage backend for adding custom parameters""" +from storages.backends.s3boto3 import S3Boto3Storage + + +class PublicMediaStorage(S3Boto3Storage): + location = 'media' + file_overwrite = False + + +class PublicStaticStorage(S3Boto3Storage): + location = 'static' + file_overwrite = False diff --git a/project/templates/news/news_email.html b/project/templates/news/news_email.html index d14bd898..6fe14060 100644 --- a/project/templates/news/news_email.html +++ b/project/templates/news/news_email.html @@ -12,7 +12,6 @@ -
@@ -25,7 +24,7 @@
-
+
{{ title }}
{% if not image_url is None %} @@ -33,11 +32,13 @@
{% 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 5c734322..dbb3b20e 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 @@ -33,6 +34,12 @@ django-elasticsearch-dsl>=7.0.0,<8.0.0 django-elasticsearch-dsl-drf==0.20.2 sentry-sdk==0.11.2 +# AMAZON S3 +boto3==1.9.238 +django-storages==1.7.2 + +sorl-thumbnail==12.5.0 + # temp solution redis==3.2.0 amqp>=2.4.0