Merge remote-tracking branch 'origin/feature/develop_ci' into feature/migrate-news

# Conflicts:
#	apps/gallery/models.py
#	requirements/base.txt
This commit is contained in:
alex 2019-10-24 13:11:05 +03:00
commit 0adac058c5
88 changed files with 1505 additions and 185 deletions

4
.gitignore vendored
View File

@ -21,4 +21,6 @@ logs/
/geoip_db/
# dev
./docker-compose.override.yml
./docker-compose.override.yml
celerybeat-schedule

40
.gitlab-ci.yml Normal file
View File

@ -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

View File

@ -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'),
),
]

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

@ -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'),
),
]

View File

@ -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,

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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"""

View File

@ -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...'))

View File

@ -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()

View File

@ -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),
),
]

View File

@ -1,6 +1,8 @@
"""Establishment models."""
from datetime import datetime
from functools import reduce
import elasticsearch_dsl
from django.conf import settings
from django.contrib.contenttypes import fields as generic
from django.contrib.gis.db.models.functions import Distance
@ -19,6 +21,7 @@ from main.models import Award
from review.models import Review
from utils.models import (ProjectBaseMixin, TJSONField, URLImageMixin,
TranslatedFieldsMixin, BaseAttributes)
from timezone_field import TimeZoneField
# todo: establishment type&subtypes check
@ -128,15 +131,15 @@ class EstablishmentQuerySet(models.QuerySet):
else:
return self.none()
# def es_search(self, value, locale=None):
# """Search text via ElasticSearch."""
# from search_indexes.documents import EstablishmentDocument
# search = EstablishmentDocument.search().filter(
# Elastic_Q('match', name=value) |
# Elastic_Q('match', **{f'description.{locale}': value})
# ).execute()
# ids = [result.meta.id for result in search]
# return self.filter(id__in=ids)
def es_search(self, value, locale=None):
"""Search text via ElasticSearch."""
from search_indexes.documents import EstablishmentDocument
search = EstablishmentDocument.search().filter(
elasticsearch_dsl.Q('match', name=value) |
elasticsearch_dsl.Q('match', **{f'description.{locale}': value})
).execute()
ids = [result.meta.id for result in search]
return self.filter(id__in=ids)
def by_country_code(self, code):
"""Return establishments by country code"""
@ -204,7 +207,7 @@ class EstablishmentQuerySet(models.QuerySet):
.filter(image_url__isnull=False, public_mark__gte=10)
.has_published_reviews()
.annotate_distance(point=establishment.location)
.order_by('distance')[:settings.LIMITING_QUERY_NUMBER]
.order_by('distance')[:settings.LIMITING_QUERY_OBJECTS]
.values('id')
)
return self.filter(id__in=subquery_filter_by_distance) \
@ -224,7 +227,7 @@ class EstablishmentQuerySet(models.QuerySet):
self.filter(image_url__isnull=False, public_mark__gte=10)
.has_published_reviews()
.annotate_distance(point=point)
.order_by('distance')[:settings.LIMITING_QUERY_NUMBER]
.order_by('distance')[:settings.LIMITING_QUERY_OBJECTS]
.values('id')
)
return self.filter(id__in=subquery_filter_by_distance) \
@ -351,6 +354,7 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin):
blank=True, null=True, default=None)
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',
@ -416,6 +420,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 +461,8 @@ class Establishment(ProjectBaseMixin, URLImageMixin, TranslatedFieldsMixin):
@property
def the_most_recent_award(self):
return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)).latest(
field_name='vintage_year')
return Award.objects.filter(Q(establishment=self) | Q(employees__establishments=self)) \
.latest(field_name='vintage_year')
@property
def country_id(self):

View File

@ -41,6 +41,7 @@ class EstablishmentListCreateSerializer(EstablishmentBaseSerializer):
'is_publish',
'guestonline_id',
'lastable_id',
'tz',
]

View File

@ -26,7 +26,7 @@ class BaseTestCase(APITestCase):
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",
start="2020-12-03 12:00:00", end="2020-12-13 12:00:00",
state=News.PUBLISHED, slug='test-news')
self.test_content_type = ContentType.objects.get(app_label="news", model="news")

View File

@ -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

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'), max_length=255)
ORIENTATIONS = (
(HORIZONTAL, _('Horizontal')),
(VERTICAL, _('Vertical')),
)
image = SORLImageField(upload_to=image_path,
verbose_name=_('image file'))
orientation = models.PositiveSmallIntegerField(choices=ORIENTATIONS,
blank=True, null=True, default=None,
verbose_name=_('image orientation'))
title = models.CharField(_('title'), max_length=255, default='')
objects = ImageQuerySet.as_manager()
class Meta:
"""Meta class."""
@ -18,4 +37,22 @@ class Image(ProjectBaseMixin, ImageMixin):
def __str__(self):
"""String representation"""
return str(self.id)
return f'{self.id}'
def delete_image(self, completely: bool = True):
"""
Deletes an instance and crops of instance from media storage.
:param completely: if set to False then removed only crop neither original image.
"""
try:
# Delete from remote storage
delete(file_=self.image.file, delete_file=completely)
except FileNotFoundError:
pass
finally:
if completely:
# Delete an instance of image
super().delete()

View File

@ -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}
}

23
apps/gallery/tasks.py Normal file
View File

@ -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}')

View File

@ -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('<int:pk>/', views.ImageRetrieveDestroyView.as_view(), name='retrieve-destroy-image'),
]

View File

@ -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)

View File

@ -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):

View File

@ -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',
),
]

View File

@ -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()
@ -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."""

View File

@ -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
class FeatureSerializer(serializers.ModelSerializer):
@ -40,11 +40,24 @@ class SiteFeatureSerializer(serializers.ModelSerializer):
)
class CurrencySerializer(ProjectModelSerializer):
"""Currency serializer"""
class Meta:
model = models.Currency
fields = [
'id',
'name_translated',
'sign'
]
class SiteSettingsSerializer(serializers.ModelSerializer):
"""Site settings serializer."""
published_features = SiteFeatureSerializer(source='published_sitefeatures',
many=True, allow_null=True)
currency = CurrencySerializer()
# todo: remove this
country_code = serializers.CharField(source='subdomain', read_only=True)
@ -63,6 +76,7 @@ class SiteSettingsSerializer(serializers.ModelSerializer):
'config',
'ad_config',
'published_features',
'currency'
)
@ -114,17 +128,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()

View File

@ -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."""

View File

@ -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',
},
),
]

View File

@ -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'),
),
]

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

@ -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 = [
]

View File

@ -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'),
),
]

View File

@ -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 = [
]

View File

@ -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',
),
]

View File

@ -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',
),
]

View File

@ -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,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')

View File

@ -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_images().exists():
raise serializers.ValidationError({'detail': _('Main image is already added')})
attrs['news'] = news
attrs['image'] = image
return attrs

View File

@ -50,7 +50,7 @@ class BaseTestCase(APITestCase):
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),
start=datetime.now() + timedelta(hours=-2),
end=datetime.now() + timedelta(hours=2),
state=News.PUBLISHED, slug='test-news-slug',
country=self.country_ru)
@ -85,7 +85,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
}

View File

@ -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('<int:pk>/', views.NewsBackOfficeRUDView.as_view(),
name='retrieve-update-destroy'),
name='gallery-retrieve-update-destroy'),
path('<int:pk>/gallery/', views.NewsBackOfficeGalleryListView.as_view(),
name='gallery-list'),
path('<int:pk>/gallery/<int:image_id>/', views.NewsBackOfficeGalleryCreateDestroyView.as_view(),
name='gallery-create-destroy'),
]

View File

@ -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
@ -10,7 +15,6 @@ class NewsMixinView:
"""News mixin."""
permission_classes = (permissions.AllowAny, )
serializer_class = serializers.NewsBaseSerializer
def get_queryset(self, *args, **kwargs):
"""Override get_queryset method."""
@ -24,6 +28,7 @@ class NewsMixinView:
class NewsListView(NewsMixinView, generics.ListAPIView):
"""News list view."""
serializer_class = serializers.NewsListSerializer
filter_class = filters.NewsListFilterSet
@ -74,6 +79,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."""

View File

@ -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(),

View File

@ -37,14 +37,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]

View File

@ -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',

View File

@ -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:

View File

@ -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 = {

View File

@ -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),
),
]

View File

@ -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()

View File

@ -49,7 +49,8 @@ class TagCategoryBaseSerializer(serializers.ModelSerializer):
fields = (
'id',
'label_translated',
'tags'
'index_name',
'tags',
)

7
apps/tag/urls/mobile.py Normal file
View File

@ -0,0 +1,7 @@
from tag.urls.web import urlpatterns as common_urlpatterns
urlpatterns = [
]
urlpatterns.extend(common_urlpatterns)

View File

@ -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 = [

View File

@ -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."""

View File

@ -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')

View File

@ -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',
)

View File

View File

@ -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')
]

View File

@ -0,0 +1,6 @@
from timetable.urls.common import urlpatterns as common_urlpatterns
urlpatterns = []
urlpatterns.extend(common_urlpatterns)

View File

@ -0,0 +1,6 @@
from timetable.urls.common import urlpatterns as common_urlpatterns
urlpatterns = []
urlpatterns.extend(common_urlpatterns)

View File

@ -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, )

View File

@ -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

View File

@ -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'<img src="{self.image.url}" style="max-height: 25%; max-width: 25%" />')
else:
return None
image_tag.short_description = _('Image')
image_tag.allow_tags = True
class SVGImageMixin(models.Model):
"""SVG image model."""

View File

@ -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

View File

@ -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)

View File

@ -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,

Binary file not shown.

79
compose-ci.yml Normal file
View File

@ -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"

69
fabfile.py vendored Normal file
View File

@ -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)

View File

@ -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'

View File

@ -97,6 +97,10 @@ EXTERNAL_APPS = [
'rest_framework_simplejwt.token_blacklist',
'solo',
'phonenumber_field',
'timezone_field',
'storages',
'sorl.thumbnail',
'timezonefinder'
]
@ -205,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',
@ -294,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'
@ -307,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",
@ -329,6 +323,7 @@ REDOC_SETTINGS = {
'LAZY_RENDERING': False,
}
# CELERY
# RabbitMQ
# BROKER_URL = 'amqp://rabbitmq:5672'
@ -341,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"
@ -350,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 = {
@ -422,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
@ -443,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'

View File

@ -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'

View File

@ -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',

View File

@ -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 = ''
LASTABLE_PROXY = ''

View File

@ -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'

View File

@ -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

View File

@ -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 %}

File diff suppressed because one or more lines are too long

View File

@ -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)

View File

@ -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')),

View File

@ -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')),
]

View File

@ -9,6 +9,7 @@ fcm-django
django-easy-select2
bootstrap-admin
drf-yasg==1.16.0
timezonefinder
PySocks!=1.5.7,>=1.5.6;
djangorestframework==3.9.4
@ -17,6 +18,7 @@ django-filter==2.1.0
djangorestframework-xml
geoip2==2.9.0
django-phonenumber-field[phonenumbers]==2.1.0
django-timezone-field==3.1
# auth socials
django-rest-framework-social-oauth2==1.1.0
@ -33,6 +35,12 @@ django-elasticsearch-dsl>=7.0.0,<8.0.0
django-elasticsearch-dsl-drf==0.20.2
sentry-sdk==0.11.2
# AMAZON S3
boto3==1.9.238
django-storages==1.7.2
sorl-thumbnail==12.5.0
mysqlclient==1.4.4