Merge branch 'develop' of ssh://gl.id-east.ru:222/gm/gm-backend into develop

This commit is contained in:
Александр Пархомин 2020-02-05 17:05:24 +03:00
commit b859792b8c
77 changed files with 1569 additions and 213 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2020-02-04 15:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0034_auto_20200131_0548'),
]
operations = [
migrations.AddField(
model_name='userrole',
name='for_team',
field=models.BooleanField(default=False, verbose_name='is this role for team membership'),
),
]

View File

@ -103,16 +103,46 @@ class UserManager(BaseUserManager):
use_in_migrations = False use_in_migrations = False
def make(self, email: str, password: str, newsletter: bool, username: str = '') -> object: def make(self, email: str, password: str, newsletter: bool, username: str = '', email_confirmed=False) -> object:
"""Register new user""" """Register new user"""
obj = self.model( obj = self.model(
username=username, username=username,
email=email.lower(), email=email.lower(),
newsletter=newsletter) newsletter=newsletter,
email_confirmed=email_confirmed)
obj.set_password(password) obj.set_password(password)
obj.save() obj.save()
return obj return obj
def invite_for_team(self, email: str, establishment: Establishment, country_code: str):
created = False
user = User.objects.filter(email=email).first()
if user is None:
from utils.methods import string_random
user = self.make(email, string_random(), True, string_random(), email_confirmed=True)
created = True
user_role_defaults = {'for_team': True, 'state': UserRole.PENDING}
role, is_role_created = Role.objects.get_or_create(role=Role.ESTABLISHMENT_ADMINISTRATOR)
user_role, is_user_role_created = UserRole.objects.get_or_create(user=user, establishment=establishment,
role=role, defaults=user_role_defaults)
if not is_user_role_created:
from rest_framework.serializers import ValidationError
raise ValidationError({'detail': f'User with this role already exists. State: {user_role.state}'})
if created:
from account.tasks import send_team_invite_to_new_user
if settings.USE_CELERY:
send_team_invite_to_new_user.delay(user.pk, country_code, user_role.pk, establishment.name)
else:
send_team_invite_to_new_user(user.pk, country_code, user_role.pk, establishment.name)
else:
from account.tasks import send_team_invite_to_existing_user
if settings.USE_CELERY:
send_team_invite_to_existing_user.delay(user.pk, country_code, user_role.pk, establishment.name)
else:
send_team_invite_to_existing_user(user.pk, country_code, user_role.pk, establishment.name)
return user
class UserQuerySet(models.QuerySet): class UserQuerySet(models.QuerySet):
"""Extended queryset for User model.""" """Extended queryset for User model."""
@ -358,6 +388,7 @@ class User(PhoneModelMixin, AbstractUser):
'twitter_page_url': socials.twitter_page_url if socials else '#', 'twitter_page_url': socials.twitter_page_url if socials else '#',
'instagram_page_url': socials.instagram_page_url if socials else '#', 'instagram_page_url': socials.instagram_page_url if socials else '#',
'facebook_page_url': socials.facebook_page_url if socials else '#', 'facebook_page_url': socials.facebook_page_url if socials else '#',
'contact_email': socials.contact_email if socials else '-',
'send_to': username, 'send_to': username,
} }
@ -378,9 +409,41 @@ class User(PhoneModelMixin, AbstractUser):
template_name=settings.RESETTING_TOKEN_TEMPLATE, template_name=settings.RESETTING_TOKEN_TEMPLATE,
context=context), get_template(settings.RESETTING_TOKEN_TEMPLATE).render(context) context=context), get_template(settings.RESETTING_TOKEN_TEMPLATE).render(context)
def invite_new_establishment_member_template(self, country_code, username, subject, restaurant_name, user_role_id):
"""Template for newly created user establishment team invite"""
context = {'token': self.reset_password_token,
'country_code': country_code,
'restaurant_name': restaurant_name,
'user_role_id': user_role_id}
context.update(self.base_template(country_code, username, subject))
return render_to_string(
template_name=settings.NEW_USER_FOR_ESTABLISHMENT_TEAM_TEMPLATE,
context=context), get_template(settings.NEW_USER_FOR_ESTABLISHMENT_TEAM_TEMPLATE).render(context)
def invite_establishment_member_template(self, country_code, username, subject, restaurant_name, user_role_id):
"""Template existing user establishment team invite"""
context = {'token': self.reset_password_token,
'country_code': country_code,
'restaurant_name': restaurant_name,
'user_role_id': user_role_id}
context.update(self.base_template(country_code, username, subject))
return render_to_string(
template_name=settings.EXISTING_USER_FOR_ESTABLISHMENT_TEAM_TEMPLATE,
context=context), get_template(settings.EXISTING_USER_FOR_ESTABLISHMENT_TEAM_TEMPLATE).render(context)
def establishment_team_role_revoked(self, country_code, username, subject, restaurant_name):
"""Template to notify user that his/her establishment role is revoked"""
context = {'token': self.reset_password_token,
'country_code': country_code,
'restaurant_name': restaurant_name}
context.update(self.base_template(country_code, username, subject))
return render_to_string(
template_name=settings.ESTABLISHMENT_TEAM_ROLE_REVOKED_TEMPLATE,
context=context), get_template(settings.ESTABLISHMENT_TEAM_ROLE_REVOKED_TEMPLATE).render(context)
def notify_password_changed_template(self, country_code, username, subject): def notify_password_changed_template(self, country_code, username, subject):
"""Get notification email template""" """Get notification email template"""
context = {'contry_code': country_code} context = {'country_code': country_code}
context.update(self.base_template(country_code, username, subject)) context.update(self.base_template(country_code, username, subject))
return render_to_string( return render_to_string(
template_name=settings.NOTIFICATION_PASSWORD_TEMPLATE, template_name=settings.NOTIFICATION_PASSWORD_TEMPLATE,
@ -595,6 +658,8 @@ class UserRole(ProjectBaseMixin):
help_text='A user (REQUESTER) who requests a ' help_text='A user (REQUESTER) who requests a '
'role change for a USER') 'role change for a USER')
for_team = models.BooleanField(verbose_name=_('is this role for team membership'), default=False)
objects = UserRoleQueryset.as_manager() objects = UserRoleQueryset.as_manager()
class Meta: class Meta:

View File

@ -1,5 +1,6 @@
"""Back account serializers""" """Back account serializers"""
from rest_framework import serializers from rest_framework import serializers
from django.db import transaction
from account import models from account import models
from account.serializers import RoleBaseSerializer, UserSerializer, subscriptions_handler from account.serializers import RoleBaseSerializer, UserSerializer, subscriptions_handler
@ -174,3 +175,20 @@ class UserCSVSerializer(serializers.ModelSerializer):
'last_ip', 'last_ip',
'roles_list', 'roles_list',
] ]
class InviteTeamSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True, allow_blank=False, allow_null=False)
class Meta:
model = models.User
fields = (
'id',
'email',
)
def create(self, validated_data):
with transaction.atomic():
establishment = self.context['view'].kwargs['establishment']
country_code = self.context['request'].country_code
return models.User.objects.invite_for_team(validated_data['email'], establishment, country_code)

View File

@ -1,11 +1,14 @@
"""Account app celery tasks.""" """Account app celery tasks."""
import datetime
import inspect import inspect
import logging import logging
from celery import shared_task from celery import shared_task
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from celery.schedules import crontab
from celery.task import periodic_task
from account.models import User from account.models import User, UserRole, Role
logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,6 +33,59 @@ def send_reset_password_email(user_id, country_code):
send_email(user_id, 'Password_resetting', 'reset_password_template', country_code) send_email(user_id, 'Password_resetting', 'reset_password_template', country_code)
@shared_task
def send_team_invite_to_new_user(user_id, country_code, user_role_id, restaurant_name):
"""Send email to establishment team member with resetting password and accepting role link"""
user = User.objects.get(id=user_id)
subject = _(f'GAULT&MILLAU INVITES YOU TO MANAGE {restaurant_name}')
message = user.invite_new_establishment_member_template(country_code, user.username,
subject, restaurant_name, user_role_id)
try:
user.send_email(subject=subject,
message=message,
emails=None)
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_team_invite_to_existing_user(user_id, country_code, user_role_id, restaurant_name):
"""Send email to establishment team member with role acceptance link"""
user = User.objects.get(id=user_id)
subject = _(f'GAULT&MILLAU INVITES YOU TO MANAGE {restaurant_name}')
message = user.invite_establishment_member_template(country_code, user.username,
subject, restaurant_name, user_role_id)
try:
user.send_email(subject=subject,
message=message,
emails=None)
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 team_role_revoked(user_id, country_code, restaurant_name):
"""Send email to establishment team member with role acceptance link"""
user = User.objects.get(id=user_id)
subject = _(f'Your GAULT&MILLAU privileges to manage {restaurant_name} have been revoked.')
message = user.establishment_team_role_revoked(country_code, user.username, subject, restaurant_name)
try:
user.send_email(subject=subject,
message=message,
emails=None)
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 @shared_task
def confirm_new_email_address(user_id, country_code): def confirm_new_email_address(user_id, country_code):
"""Send email to user new email.""" """Send email to user new email."""
@ -46,3 +102,17 @@ def change_email_address(user_id, country_code, emails=None):
def send_password_changed_email(user_id, country_code): def send_password_changed_email(user_id, country_code):
"""Send email which notifies user that his password had changed""" """Send email which notifies user that his password had changed"""
send_email(user_id, 'Notify password changed', 'notify_password_changed_template', country_code) send_email(user_id, 'Notify password changed', 'notify_password_changed_template', country_code)
@periodic_task(run_every=crontab(hour='*/3'))
def clean_unaccepted_establishment_team_invites():
"""This task cleans unaccepted (for 7 days) establishment team membership invites."""
week_ago = datetime.datetime.now() - datetime.timedelta(days=7)
roles_to_remove = UserRole.objects.filter(role__role=Role.ESTABLISHMENT_ADMINISTRATOR,
for_team=True, created__lte=week_ago)\
.exclude(state=UserRole.VALIDATED).prefetch_related('user', 'role')
for user_role in roles_to_remove:
user = user_role.user
user_role.delete()
if user.last_login is None:
user.delete()

View File

@ -13,4 +13,6 @@ urlpatterns = [
path('user/<int:id>/', views.UserRUDView.as_view(), name='user-rud'), path('user/<int:id>/', views.UserRUDView.as_view(), name='user-rud'),
path('user/<int:id>/csv/', views.get_user_csv, name='user-csv'), path('user/<int:id>/csv/', views.get_user_csv, name='user-csv'),
path('user/csv/', views.UserCSVViewSet.as_view({'get': 'to_csv'}), name='user-csv-list'), path('user/csv/', views.UserCSVViewSet.as_view({'get': 'to_csv'}), name='user-csv-list'),
path('team/invite/<int:establishment_id>', views.EstablishmentTeamInviteView.as_view(),
name='invite-to-establishment-team'),
] ]

View File

@ -10,6 +10,8 @@ urlpatterns_api = [
path('reset-password/', views.PasswordResetView.as_view(), name='password-reset'), path('reset-password/', views.PasswordResetView.as_view(), name='password-reset'),
path('reset-password/confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), path('reset-password/confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(),
name='password-reset-confirm'), name='password-reset-confirm'),
path('join-establishment-team/<uidb64>/<token>/<int:role_id>', views.ApplyUserEstablishmentRole.as_view(),
name='join-establishment-team'),
] ]
urlpatterns = urlpatterns_api + \ urlpatterns = urlpatterns_api + \

View File

@ -3,6 +3,7 @@ from datetime import datetime
from django.http import HttpResponse, HttpResponseNotFound from django.http import HttpResponse, HttpResponseNotFound
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.shortcuts import get_object_or_404
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework import permissions from rest_framework import permissions
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
@ -14,6 +15,7 @@ from account import models, filters
from account.models import User from account.models import User
from account.serializers import back as serializers from account.serializers import back as serializers
from account.serializers.common import RoleBaseSerializer from account.serializers.common import RoleBaseSerializer
from establishment.models import Establishment
from utils.methods import get_permission_classes from utils.methods import get_permission_classes
from utils.permissions import IsReviewManager from utils.permissions import IsReviewManager
@ -187,3 +189,16 @@ class UserCSVViewSet(ModelViewSet):
writer.writerow(item.values()) writer.writerow(item.values())
return response return response
class EstablishmentTeamInviteView(generics.CreateAPIView):
"""View to invite new team member by email"""
queryset = User.objects.all()
# permission_classes = get_permission_classes()
from rest_framework.permissions import AllowAny
permission_classes = (AllowAny, )
serializer_class = serializers.InviteTeamSerializer
def create(self, request, *args, **kwargs):
self.kwargs['establishment'] = get_object_or_404(klass=Establishment, pk=self.kwargs['establishment_id'])
return super().create(request, *args, **kwargs)

View File

@ -1,5 +1,6 @@
"""Web account views""" """Web account views"""
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
@ -61,10 +62,18 @@ class PasswordResetConfirmView(JWTGenericViewMixin, generics.GenericAPIView):
def patch(self, request, *args, **kwargs): def patch(self, request, *args, **kwargs):
"""Implement PATCH method""" """Implement PATCH method"""
instance = self.get_object() instance = self.get_object()
serializer = self.get_serializer(instance=instance, with transaction.atomic():
data=request.data) serializer = self.get_serializer(instance=instance,
serializer.is_valid(raise_exception=True) data=request.data)
serializer.save() serializer.is_valid(raise_exception=True)
serializer.save()
# apply requested user_role if 'role' in query_params
if 'role' in request.query_params:
user_role = get_object_or_404(klass=models.UserRole, pk=request.query_params['role'])
user_role.state = models.UserRole.VALIDATED
user_role.save()
# Create tokens # Create tokens
tokens = instance.create_jwt_tokens() tokens = instance.create_jwt_tokens()
return self._put_cookies_in_response( return self._put_cookies_in_response(
@ -72,3 +81,40 @@ class PasswordResetConfirmView(JWTGenericViewMixin, generics.GenericAPIView):
access_token=tokens.get('access_token'), access_token=tokens.get('access_token'),
refresh_token=tokens.get('refresh_token')), refresh_token=tokens.get('refresh_token')),
response=Response(status=status.HTTP_200_OK)) response=Response(status=status.HTTP_200_OK))
class ApplyUserEstablishmentRole(JWTGenericViewMixin, generics.GenericAPIView):
queryset = models.User.objects.all()
permission_classes = (permissions.AllowAny,)
def get_object(self):
"""Overridden get_object method"""
queryset = self.filter_queryset(self.get_queryset())
uidb64 = self.kwargs.get('uidb64')
user_id = force_text(urlsafe_base64_decode(uidb64))
token = self.kwargs.get('token')
user = get_object_or_404(queryset, id=user_id)
if not GMTokenGenerator(GMTokenGenerator.RESET_PASSWORD).check_token(
user, token):
raise utils_exceptions.NotValidTokenError()
# May raise a permission denied
self.check_object_permissions(self.request, user)
return get_object_or_404(klass=models.UserRole, pk=self.kwargs['role_id'], user=user), user
def patch(self, request, *args, **kwargs):
instance, user = self.get_object()
instance.state = models.UserRole.VALIDATED
instance.save()
# Create tokens
tokens = user.create_jwt_tokens()
return self._put_cookies_in_response(
cookies=self._put_data_in_cookies(
access_token=tokens.get('access_token'),
refresh_token=tokens.get('refresh_token')),
response=Response(status=status.HTTP_200_OK))

View File

@ -182,7 +182,7 @@ class LoginByUsernameOrEmailView(JWTGenericViewMixin, generics.GenericAPIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
## Login view. ## Login view.
POST-request data ### POST-request data
``` ```
{ {
"username_or_email": <str>, "username_or_email": <str>,
@ -191,11 +191,12 @@ class LoginByUsernameOrEmailView(JWTGenericViewMixin, generics.GenericAPIView):
"source": <int> # 0 - Mobile, 1 - Web, 2 - All (by default used: 1) "source": <int> # 0 - Mobile, 1 - Web, 2 - All (by default used: 1)
} }
``` ```
## Response ### Response
After a successful login, server side set up access_token and refresh token to cookies. After a successful login, server side set up access_token and refresh token to cookies.
In a payload of access token, the following information is being embed: In a payload of access token, the following information is being embed:
see `User().get_user_info()`. see `User().get_user_info()`.
### Description
COOKIE Max-age are determined by `remember` flag: COOKIE Max-age are determined by `remember` flag:
if `remember` is `True` then `Max-age` parameter taken from `settings.COOKIES_MAX_AGE` if `remember` is `True` then `Max-age` parameter taken from `settings.COOKIES_MAX_AGE`
otherwise using session COOKIE Max-age. otherwise using session COOKIE Max-age.
@ -221,7 +222,23 @@ class LogoutView(JWTGenericViewMixin, generics.GenericAPIView):
permission_classes = (IsAuthenticatedAndTokenIsValid, ) permission_classes = (IsAuthenticatedAndTokenIsValid, )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Override create method""" """
## Logout view.
### POST-request data
```
{}
```
### Response
If user has *valid* `access_token` in COOKIES, then response return
blank response data with `HTTP_STATUS_CODE` *204*.
### Description
For complete logout, user must provide *valid* `access_token`
(`access_token` must be kept in `COOKIES`).
After successful request with valid access_token, token would be expired,
for reuse protection.
"""
# Get access token objs by JTI # Get access token objs by JTI
access_token = AccessToken(request.COOKIES.get('access_token')) access_token = AccessToken(request.COOKIES.get('access_token'))
access_token_obj = JWTAccessToken.objects.get(jti=access_token.payload.get('jti')) access_token_obj = JWTAccessToken.objects.get(jti=access_token.payload.get('jti'))

View File

@ -41,12 +41,18 @@ class GuideFilterSet(filters.FilterSet):
'Use for Establishment detail\'s sheet to content display within ' 'Use for Establishment detail\'s sheet to content display within '
'"Collections & Guides" tab.' '"Collections & Guides" tab.'
) )
guide_type = filters.ChoiceFilter(
choices=models.Guide.GUIDE_TYPE_CHOICES,
help_text=f'It allows filtering guide list by type.'
f'Enum: {dict(models.Guide.GUIDE_TYPE_CHOICES)}'
)
class Meta: class Meta:
"""Meta class.""" """Meta class."""
model = models.Guide model = models.Guide
fields = ( fields = (
'establishment_id', 'establishment_id',
'guide_type',
) )
def by_establishment_id(self, queryset, name, value): def by_establishment_id(self, queryset, name, value):

View File

@ -866,19 +866,22 @@ class GuideElementManager(models.Manager):
city=city_qs.first()) city=city_qs.first())
return None, False return None, False
def get_or_create_establishment_section_node(self, city_node: int, establishment_node_name: str): def get_or_create_establishment_section_node(self, city_node_id: int, establishment_node_name: str,
guide_id: int):
"""Get or Create (Restaurant|Shop...)SectionNode.""" """Get or Create (Restaurant|Shop...)SectionNode."""
parent_node_qs = GuideElement.objects.filter(id=city_node) parent_node_qs = GuideElement.objects.filter(id=city_node_id)
guide_element_type_qs = GuideElementType.objects.filter(name__iexact=establishment_node_name) guide_element_type_qs = GuideElementType.objects.filter(name__iexact=establishment_node_name)
guide_qs = Guide.objects.filter(id=guide_id)
if parent_node_qs.exists() and guide_element_type_qs.exists(): if parent_node_qs.exists() and guide_element_type_qs.exists() and guide_qs.exists():
parent_node = parent_node_qs.first() parent_node = parent_node_qs.first()
return self.get_or_create(guide_element_type=guide_element_type_qs.first(), return self.get_or_create(guide_element_type=guide_element_type_qs.first(),
parent=parent_node, parent=parent_node,
guide=parent_node.get_root().guide) guide=guide_qs.first())
return None, False return None, False
def get_or_create_establishment_node(self, restaurant_section_node_id: int, def get_or_create_establishment_node(self, restaurant_section_node_id: int, guide_id: int,
establishment_id: int, review_id: int = None): establishment_id: int, review_id: int = None):
"""Get or Create EstablishmentNode.""" """Get or Create EstablishmentNode."""
from establishment.models import Establishment from establishment.models import Establishment
@ -887,14 +890,16 @@ class GuideElementManager(models.Manager):
guide_element_type_qs = GuideElementType.objects.filter(name='EstablishmentNode') guide_element_type_qs = GuideElementType.objects.filter(name='EstablishmentNode')
parent_node_qs = GuideElement.objects.filter(id=restaurant_section_node_id) parent_node_qs = GuideElement.objects.filter(id=restaurant_section_node_id)
establishment_qs = Establishment.objects.filter(id=establishment_id) establishment_qs = Establishment.objects.filter(id=establishment_id)
guide_qs = Guide.objects.filter(id=guide_id)
if parent_node_qs.exists() and establishment_qs.exists() and guide_element_type_qs.exists(): if (parent_node_qs.exists() and establishment_qs.exists()
and guide_element_type_qs.exists() and guide_qs.exists()):
establishment = establishment_qs.first() establishment = establishment_qs.first()
parent_node = parent_node_qs.first() parent_node = parent_node_qs.first()
data.update({ data.update({
'guide_element_type': guide_element_type_qs.first(), 'guide_element_type': guide_element_type_qs.first(),
'parent': parent_node, 'parent': parent_node,
'guide': parent_node.get_root().guide, 'guide': guide_qs.first(),
'establishment': establishment 'establishment': establishment
}) })
if review_id: if review_id:
@ -903,44 +908,47 @@ class GuideElementManager(models.Manager):
return self.get_or_create(**data) return self.get_or_create(**data)
return None, False return None, False
def get_or_create_wine_region_node(self, root_node_id: int, wine_region_id: int): def get_or_create_wine_region_node(self, root_node_id: int, wine_region_id: int, guide_id: int):
"""Get or Create WineRegionNode.""" """Get or Create WineRegionNode."""
guide_element_type_qs = GuideElementType.objects.filter(name='RegionNode') guide_element_type_qs = GuideElementType.objects.filter(name='RegionNode')
parent_node_qs = GuideElement.objects.filter(id=root_node_id) parent_node_qs = GuideElement.objects.filter(id=root_node_id)
wine_region_qs = WineRegion.objects.filter(id=wine_region_id) wine_region_qs = WineRegion.objects.filter(id=wine_region_id)
guide_qs = Guide.objects.filter(id=guide_id)
if parent_node_qs.exists() and parent_node_qs.first().guide and wine_region_qs.exists() and guide_element_type_qs.exists(): if (parent_node_qs.exists() and parent_node_qs.first().guide and
wine_region_qs.exists() and guide_element_type_qs.exists() and
guide_qs.exists()):
root_node = parent_node_qs.first() root_node = parent_node_qs.first()
return self.get_or_create(guide_element_type=guide_element_type_qs.first(), return self.get_or_create(guide_element_type=guide_element_type_qs.first(),
parent=root_node, parent=root_node,
guide=root_node.guide, guide=guide_qs.first(),
wine_region=wine_region_qs.first()) wine_region=wine_region_qs.first())
return None, False return None, False
def get_or_create_yard_node(self, product_id: int, wine_region_node_id: int): def get_or_create_yard_node(self, product_id: int, wine_region_node_id: int, guide_id: int):
"""Make YardNode.""" """Make YardNode."""
from establishment.models import Establishment from establishment.models import Establishment
guide_element_type_qs = GuideElementType.objects.filter(name='YardNode') guide_element_type_qs = GuideElementType.objects.filter(name='YardNode')
wine_region_node_qs = GuideElement.objects.filter(id=wine_region_node_id) wine_region_node_qs = GuideElement.objects.filter(id=wine_region_node_id)
product_qs = Product.objects.filter(id=product_id) product_qs = Product.objects.filter(id=product_id)
guide_qs = Guide.objects.filter(id=guide_id)
if product_qs.exists() and wine_region_node_qs.exists(): if product_qs.exists() and wine_region_node_qs.exists() and guide_qs.exists():
wine_region_node = wine_region_node_qs.first() wine_region_node = wine_region_node_qs.first()
root_node = wine_region_node.get_root()
product = product_qs.first() product = product_qs.first()
return self.get_or_create(guide_element_type=guide_element_type_qs.first(),
if product.establishment: parent=wine_region_node,
return self.get_or_create(guide_element_type=guide_element_type_qs.first(), guide=guide_qs.first(),
parent=wine_region_node, establishment=product.establishment)
guide=root_node.guide,
establishment=product.establishment)
return None, False return None, False
def get_or_create_color_wine_section_node(self, wine_color_name: str, yard_node_id: int): def get_or_create_color_wine_section_node(self, wine_color_name: str,
yard_node_id: int, guide_id: int):
"""Get or Create WineSectionNode.""" """Get or Create WineSectionNode."""
guide_element_type_qs = GuideElementType.objects.filter(name='ColorWineSectionNode') guide_element_type_qs = GuideElementType.objects.filter(name='ColorWineSectionNode')
parent_node_qs = GuideElement.objects.filter(id=yard_node_id) parent_node_qs = GuideElement.objects.filter(id=yard_node_id)
guide_qs = Guide.objects.filter(id=guide_id)
if not wine_color_name.endswith('SectionNode'): if not wine_color_name.endswith('SectionNode'):
wine_color_name = transform_into_section_name(wine_color_name) wine_color_name = transform_into_section_name(wine_color_name)
@ -951,27 +959,30 @@ class GuideElementManager(models.Manager):
'name': wine_color_name 'name': wine_color_name
}) })
if parent_node_qs.exists() and guide_element_type_qs.exists(): if parent_node_qs.exists() and guide_element_type_qs.exists() and guide_qs.exists():
root_node = parent_node_qs.first() root_node = parent_node_qs.first()
return self.get_or_create(guide_element_type=guide_element_type_qs.first(), return self.get_or_create(guide_element_type=guide_element_type_qs.first(),
parent=root_node, parent=root_node,
wine_color_section=wine_color_section, wine_color_section=wine_color_section,
guide=root_node.guide) guide=guide_qs.first())
return None, False return None, False
def get_or_create_wine_node(self, color_wine_section_node_id: int, wine_id: int, review_id: int): def get_or_create_wine_node(self, color_wine_section_node_id: int,
wine_id: int, review_id: int, guide_id: int):
"""Get or Create WineNode.""" """Get or Create WineNode."""
guide_element_type_qs = GuideElementType.objects.filter(name='WineNode') guide_element_type_qs = GuideElementType.objects.filter(name='WineNode')
parent_node_qs = GuideElement.objects.filter(id=color_wine_section_node_id) parent_node_qs = GuideElement.objects.filter(id=color_wine_section_node_id)
wine_qs = Product.objects.wines().filter(id=wine_id) wine_qs = Product.objects.wines().filter(id=wine_id)
review_qs = Review.objects.filter(id=review_id) review_qs = Review.objects.filter(id=review_id)
guide_qs = Guide.objects.filter(id=guide_id)
if parent_node_qs.exists() and wine_qs.exists() and review_qs.exists() and guide_element_type_qs.exists(): if (parent_node_qs.exists() and wine_qs.exists() and
review_qs.exists() and guide_element_type_qs.exists() and guide_qs.exists()):
root_node = parent_node_qs.first() root_node = parent_node_qs.first()
return self.get_or_create(guide_element_type=guide_element_type_qs.first(), return self.get_or_create(guide_element_type=guide_element_type_qs.first(),
parent=root_node, parent=root_node,
product=wine_qs.first(), product=wine_qs.first(),
guide=root_node.guide, guide=guide_qs.first(),
review=review_qs.first()) review=review_qs.first())
return None, False return None, False

View File

@ -137,8 +137,8 @@ class GuideBaseSerializer(serializers.ModelSerializer):
'count_objects_during_init', 'count_objects_during_init',
] ]
extra_kwargs = { extra_kwargs = {
'guide_type': {'write_only': True}, 'guide_type': {'write_only': True, 'required': True},
'site': {'write_only': True}, 'site': {'write_only': True, 'required': True},
'state': {'write_only': True}, 'state': {'write_only': True},
'start': {'required': True}, 'start': {'required': True},
'slug': {'required': False}, 'slug': {'required': False},

View File

@ -9,26 +9,6 @@ logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_additional_establishment_data(section_node, establishment):
data = [
section_node.id,
establishment.id,
]
if establishment.last_published_review:
data.append(establishment.last_published_review.id)
return data
def get_additional_product_data(section_node, product):
data = [
section_node.id,
product.id,
]
if product.last_published_review:
data.append(product.last_published_review.id)
return data
@shared_task @shared_task
def generate_establishment_guide_elements(guide_id: int, filter_set: dict): def generate_establishment_guide_elements(guide_id: int, filter_set: dict):
"""Generate guide elements.""" """Generate guide elements."""
@ -36,9 +16,9 @@ def generate_establishment_guide_elements(guide_id: int, filter_set: dict):
from establishment.models import Establishment from establishment.models import Establishment
guide = Guide.objects.get(id=guide_id) guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.BUILDING)
queryset_values = Establishment.objects.filter(**filter_set).values() queryset_values = Establishment.objects.filter(**filter_set).values()
try: try:
guide.change_state(Guide.BUILDING)
for instance in queryset_values: for instance in queryset_values:
populate_establishment_guide(guide_id, instance.get('id')) populate_establishment_guide(guide_id, instance.get('id'))
except Exception as e: except Exception as e:
@ -57,8 +37,8 @@ def populate_establishment_guide(guide_id: int, establishment_id: int):
from establishment.models import Establishment from establishment.models import Establishment
guide = Guide.objects.get(id=guide_id) guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.BUILDING)
try: try:
guide.change_state(Guide.BUILDING)
establishment_qs = Establishment.objects.filter(id=establishment_id) establishment_qs = Establishment.objects.filter(id=establishment_id)
if establishment_qs.exists(): if establishment_qs.exists():
establishment = establishment_qs.first() establishment = establishment_qs.first()
@ -70,11 +50,17 @@ def populate_establishment_guide(guide_id: int, establishment_id: int):
section_node, _ = GuideElement.objects.get_or_create_establishment_section_node( section_node, _ = GuideElement.objects.get_or_create_establishment_section_node(
city_node.id, city_node.id,
transform_into_section_name(establishment.establishment_type.index_name), transform_into_section_name(establishment.establishment_type.index_name),
guide.id,
) )
if section_node: if section_node:
GuideElement.objects.get_or_create_establishment_node( params = {
*get_additional_establishment_data(section_node=section_node, 'restaurant_section_node_id': section_node.id,
establishment=establishment)) 'guide_id': guide.id,
'establishment_id': establishment.id,
}
if establishment.last_published_review:
params.update({'review_id': establishment.last_published_review.id})
GuideElement.objects.get_or_create_establishment_node(**params)
else: else:
logger.error( logger.error(
f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n' f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n'
@ -87,7 +73,7 @@ def populate_establishment_guide(guide_id: int, establishment_id: int):
f'DETAIL: Guide ID {guide_id} - RootNode is not exists.') f'DETAIL: Guide ID {guide_id} - RootNode is not exists.')
else: else:
logger.error(f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n' logger.error(f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n'
f'DETAIL: Guide ID {guide_id} - Establishment {establishment_id} id is not exists.') f'DETAIL: Guide ID {guide_id} - Establishment {establishment_id} id is not exists.')
except Exception as e: except Exception as e:
guide.change_state(Guide.WAITING) guide.change_state(Guide.WAITING)
logger.error(f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n' logger.error(f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n'
@ -104,8 +90,8 @@ def remove_establishment_guide(guide_id: int, establishment_id: int):
from establishment.models import Establishment from establishment.models import Establishment
guide = Guide.objects.get(id=guide_id) guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.REMOVING)
try: try:
guide.change_state(Guide.REMOVING)
establishment_qs = Establishment.objects.filter(id=establishment_id) establishment_qs = Establishment.objects.filter(id=establishment_id)
if establishment_qs.exists(): if establishment_qs.exists():
establishment = establishment_qs.first() establishment = establishment_qs.first()
@ -140,9 +126,9 @@ def generate_product_guide_elements(guide_id: int, filter_set: dict):
from product.models import Product from product.models import Product
guide = Guide.objects.get(id=guide_id) guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.BUILDING)
queryset_values = Product.objects.filter(**filter_set).values() queryset_values = Product.objects.filter(**filter_set).values()
try: try:
guide.change_state(Guide.BUILDING)
for instance in queryset_values: for instance in queryset_values:
wine_id = instance.get('id') wine_id = instance.get('id')
wine_qs = Product.objects.filter(id=wine_id) wine_qs = Product.objects.filter(id=wine_id)
@ -152,24 +138,30 @@ def generate_product_guide_elements(guide_id: int, filter_set: dict):
if root_node: if root_node:
wine_region_node, _ = GuideElement.objects.get_or_create_wine_region_node( wine_region_node, _ = GuideElement.objects.get_or_create_wine_region_node(
root_node.id, root_node.id,
wine.wine_region.id) wine.wine_region.id,
guide.id)
if wine_region_node: if wine_region_node:
yard_node, _ = GuideElement.objects.get_or_create_yard_node( yard_node, _ = GuideElement.objects.get_or_create_yard_node(
product_id=wine.id, wine.id,
wine_region_node_id=wine_region_node.id wine_region_node.id,
) guide.id)
if yard_node: if yard_node:
wine_color_qs = wine.wine_colors wine_color_qs = wine.wine_colors
if wine_color_qs.exists(): if wine_color_qs.exists():
wine_color_section, _ = GuideElement.objects.get_or_create_color_wine_section_node( wine_color_section, _ = GuideElement.objects.get_or_create_color_wine_section_node(
wine_color_name=wine_color_qs.first().value, wine_color_qs.first().value,
yard_node_id=yard_node.id yard_node.id,
guide.id
) )
if wine_color_section: if wine_color_section:
GuideElement.objects.get_or_create_wine_node( params = {
*get_additional_product_data( 'color_wine_section_node_id': wine_color_section.id,
section_node=wine_color_section, 'wine_id': wine.id,
product=wine)) 'guide_id': guide.id,
}
if wine.last_published_review:
params.update({'review_id': wine.last_published_review.id})
GuideElement.objects.get_or_create_wine_node(**params)
else: else:
logger.error( logger.error(
f'METHOD_NAME: {generate_product_guide_elements.__name__}\n' f'METHOD_NAME: {generate_product_guide_elements.__name__}\n'

View File

@ -100,9 +100,48 @@ class CollectionBackOfficeList(CollectionBackOfficeViewSet):
pagination_class = None pagination_class = None
class GuideListCreateView(GuideBaseView, class GuideListCreateView(GuideBaseView, generics.ListCreateAPIView):
generics.ListCreateAPIView): """
"""View for Guides list for BackOffice users and Guide create.""" ## Guide list/create view.
### Request
For creating new instance of Guide model, need to pass the following data in the request:
required fields:
* name (str) - guide name
* start (str) - guide start date (datetime in a format `ISO-8601`)
* vintage (str) - valid year
* guide_type (int) - guide type enum: `0 (Restaurant), 1 (Artisan), 2 (Wine)`
* site (int) - identifier of site
non-required fields:
* slug - generated automatically if not provided
* state - state enum`: `0 (Built), 1 (Waiting), 2 (Removing), 3 (Building)` (service states)
* end - guide end date (datetime in a format `ISO-8601`)
### Response
Return paginated list of guides.
I.e.:
```
{
"count": 58,
"next": 2,
"previous": null,
"results": [
{
"id": 1,
...
}
]
}
```
### Description
*GET*
Return paginated list of guides with the opportunity of filtering by next fields:
* establishment_id (int) - identifier of establishment,
* guide_type (int) - guide type enum: `0 (Restaurant), 1 (Artisan), 2 (Wine)`
*POST*
Create a new instance of guide.
"""
filter_class = filters.GuideFilterSet filter_class = filters.GuideFilterSet
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.7 on 2020-02-04 12:05
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comment', '0008_comment_status'),
]
operations = [
migrations.AlterField(
model_name='comment',
name='mark',
field=models.PositiveIntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)], verbose_name='Mark'),
),
]

View File

@ -17,7 +17,10 @@ class CommentLstView(generics.ListCreateAPIView):
"establishment": Establishment.__name__.lower() "establishment": Establishment.__name__.lower()
} }
qs = models.Comment.objects.with_base_related().filter(status=models.Comment.WAITING) qs = models.Comment.objects.with_base_related()
if "object" not in self.kwargs and "type" not in self.kwargs:
qs = qs.filter(status=models.Comment.WAITING)
if "object" in self.kwargs: if "object" in self.kwargs:
qs = qs.by_object_id(self.kwargs["object"]) qs = qs.by_object_id(self.kwargs["object"])

View File

@ -173,5 +173,5 @@ class PositionsByEstablishmentFilter(filters.FilterSet):
def by_subtype(self, queryset, name, value): def by_subtype(self, queryset, name, value):
"""filter by establishment subtype""" """filter by establishment subtype"""
if value not in EMPTY_VALUES: if value not in EMPTY_VALUES:
return queryset.by_establishment_subtype(value) return queryset.by_establishment_subtypes(value.split('__'))
return queryset return queryset

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2020-02-04 11:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('establishment', '0092_merge_20200131_0835'),
]
operations = [
migrations.AlterField(
model_name='establishment',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'Abandoned'), (1, 'Closed'), (2, 'Published'), (3, 'Unpicked'), (4, 'Waiting'), (5, 'Hidden'), (6, 'Deleted'), (7, 'Out of selection'), (8, 'Unpublished')], default=4, verbose_name='Status'),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 2.2.7 on 2020-02-04 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('establishment', '0093_auto_20200204_1120'),
('establishment', '0096_auto_20200204_0952'),
]
operations = [
]

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2.7 on 2020-02-04 12:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('establishment', '0097_merge_20200204_1135'),
]
operations = [
migrations.AlterField(
model_name='company',
name='establishment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='companies', to='establishment.Establishment', verbose_name='establishment'),
),
migrations.AlterField(
model_name='establishmentnote',
name='establishment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='establishment.Establishment', verbose_name='establishment'),
),
]

View File

@ -3,7 +3,6 @@ from datetime import datetime
from functools import reduce from functools import reduce
from operator import or_ from operator import or_
from typing import List from typing import List
from slugify import slugify
import elasticsearch_dsl import elasticsearch_dsl
from django.conf import settings from django.conf import settings
@ -35,7 +34,7 @@ from utils.models import (
BaseAttributes, FavoritesMixin, FileMixin, GalleryMixin, HasTagsMixin, BaseAttributes, FavoritesMixin, FileMixin, GalleryMixin, HasTagsMixin,
IntermediateGalleryModelMixin, ProjectBaseMixin, TJSONField, TranslatedFieldsMixin, IntermediateGalleryModelMixin, ProjectBaseMixin, TJSONField, TranslatedFieldsMixin,
TypeDefaultImageMixin, URLImageMixin, default_menu_bool_array, PhoneModelMixin, TypeDefaultImageMixin, URLImageMixin, default_menu_bool_array, PhoneModelMixin,
) AwardsModelMixin)
# todo: establishment type&subtypes check # todo: establishment type&subtypes check
@ -129,12 +128,16 @@ class EstablishmentQuerySet(models.QuerySet):
def with_base_related(self): def with_base_related(self):
"""Return qs with related objects.""" """Return qs with related objects."""
return self.select_related('address', 'establishment_type'). \ return self.select_related('address', 'establishment_type'). \
prefetch_related('tags', 'tags__translation').with_main_image() prefetch_related('tags', 'tags__translation', 'establishment_subtypes').with_main_image()
def with_schedule(self): def with_schedule(self):
"""Return qs with related schedule.""" """Return qs with related schedule."""
return self.prefetch_related('schedule') return self.prefetch_related('schedule')
def with_reviews(self):
"""Return qs with related reviews."""
return self.prefetch_related('reviews')
def with_currency_related(self): def with_currency_related(self):
"""Return qs with related """ """Return qs with related """
return self.prefetch_related('currency') return self.prefetch_related('currency')
@ -546,7 +549,7 @@ class EstablishmentQuerySet(models.QuerySet):
class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin, class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin): TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin, AwardsModelMixin):
"""Establishment model.""" """Establishment model."""
ABANDONED = 0 ABANDONED = 0
@ -554,6 +557,10 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
PUBLISHED = 2 PUBLISHED = 2
UNPICKED = 3 UNPICKED = 3
WAITING = 4 WAITING = 4
HIDDEN = 5
DELETED = 6
OUT_OF_SELECTION = 7
UNPUBLISHED = 8
STATUS_CHOICES = ( STATUS_CHOICES = (
(ABANDONED, _('Abandoned')), (ABANDONED, _('Abandoned')),
@ -561,6 +568,10 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
(PUBLISHED, _('Published')), (PUBLISHED, _('Published')),
(UNPICKED, _('Unpicked')), (UNPICKED, _('Unpicked')),
(WAITING, _('Waiting')), (WAITING, _('Waiting')),
(HIDDEN, _('Hidden')),
(DELETED, _('Deleted')),
(OUT_OF_SELECTION, _('Out of selection')),
(UNPUBLISHED, _('Unpublished')),
) )
old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None) old_id = models.PositiveIntegerField(_('old id'), blank=True, null=True, default=None)
@ -656,24 +667,6 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
def __str__(self): def __str__(self):
return f'id:{self.id}-{self.name}' return f'id:{self.id}-{self.name}'
def save(self, *args, **kwargs):
slugify_slug = slugify(
self.index_name,
word_boundary=True
)
self.slug = slugify_slug
super(Establishment, self).save(*args, **kwargs)
def delete(self, using=None, keep_parents=False):
"""Overridden delete method"""
# TODO: If this does not contradict the plan,
# it is better to change it.
# Just add CASCADE to Company and Note in establishment fk field.
# Delete all related companies
self.companies.all().delete()
# Delete all related notes
self.notes.all().delete()
return super().delete(using, keep_parents)
@property @property
def visible_tags(self): def visible_tags(self):
@ -853,6 +846,10 @@ class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
def distillery_type_indexing(self): def distillery_type_indexing(self):
return self.tags.filter(category__index_name='distillery_type') return self.tags.filter(category__index_name='distillery_type')
@property
def food_producer_indexing(self):
return self.tags.filter(category__index_name='producer_type')
@property @property
def last_comment(self): def last_comment(self):
if hasattr(self, 'comments_prefetched') and len(self.comments_prefetched): if hasattr(self, 'comments_prefetched') and len(self.comments_prefetched):
@ -937,7 +934,7 @@ class EstablishmentNote(ProjectBaseMixin):
"""Note model for Establishment entity.""" """Note model for Establishment entity."""
old_id = models.PositiveIntegerField(null=True, blank=True) old_id = models.PositiveIntegerField(null=True, blank=True)
text = models.TextField(verbose_name=_('text')) text = models.TextField(verbose_name=_('text'))
establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE,
related_name='notes', related_name='notes',
verbose_name=_('establishment')) verbose_name=_('establishment'))
user = models.ForeignKey('account.User', on_delete=models.PROTECT, user = models.ForeignKey('account.User', on_delete=models.PROTECT,
@ -973,10 +970,12 @@ class EstablishmentGallery(IntermediateGalleryModelMixin):
class PositionQuerySet(models.QuerySet): class PositionQuerySet(models.QuerySet):
def by_establishment_type(self, value: str): def by_establishment_type(self, value: str):
return self.filter(establishment_type__index_name=value) return self.filter(Q(establishment_type__index_name=value) |
Q(establishment_type__isnull=True, establishment_subtype__isnull=True))
def by_establishment_subtype(self, value: str): def by_establishment_subtypes(self, value: List[str]):
return self.filter(establishment_subtype__index_name=value) return self.filter(Q(establishment_subtype__index_name__in=value) |
Q(establishment_type__isnull=True, establishment_subtype__isnull=True))
class Position(BaseAttributes, TranslatedFieldsMixin): class Position(BaseAttributes, TranslatedFieldsMixin):
@ -1150,7 +1149,7 @@ class EmployeeQuerySet(models.QuerySet):
) )
class Employee(PhoneModelMixin, BaseAttributes): class Employee(PhoneModelMixin, AwardsModelMixin, BaseAttributes):
"""Employee model.""" """Employee model."""
user = models.OneToOneField('account.User', on_delete=models.PROTECT, user = models.OneToOneField('account.User', on_delete=models.PROTECT,
@ -1226,11 +1225,6 @@ class Employee(PhoneModelMixin, BaseAttributes):
) )
return image_property return image_property
def remove_award(self, award_id: int):
from main.models import Award
award = get_object_or_404(Award, pk=award_id)
self.awards.remove(award)
class EstablishmentScheduleQuerySet(models.QuerySet): class EstablishmentScheduleQuerySet(models.QuerySet):
"""QuerySet for model EstablishmentSchedule""" """QuerySet for model EstablishmentSchedule"""
@ -1547,7 +1541,7 @@ class CompanyQuerySet(models.QuerySet):
class Company(ProjectBaseMixin): class Company(ProjectBaseMixin):
"""Establishment company model.""" """Establishment company model."""
establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT, establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE,
related_name='companies', related_name='companies',
verbose_name=_('establishment')) verbose_name=_('establishment'))
name = models.CharField(max_length=255, verbose_name=_('name')) name = models.CharField(max_length=255, verbose_name=_('name'))

View File

@ -3,8 +3,11 @@ from functools import lru_cache
from django.db.models import F from django.db.models import F
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.serializerfields import PhoneNumberField
from rest_framework import serializers from rest_framework import serializers
from slugify import slugify
from account import models as account_models
from account.serializers.common import UserShortSerializer from account.serializers.common import UserShortSerializer
from collection.models import Guide from collection.models import Guide
from establishment import models, serializers as model_serializers from establishment import models, serializers as model_serializers
@ -15,8 +18,11 @@ from location.serializers import AddressDetailSerializer, TranslatedField
from main.models import Currency from main.models import Currency
from main.serializers import AwardSerializer from main.serializers import AwardSerializer
from utils.decorators import with_base_attributes from utils.decorators import with_base_attributes
from utils.methods import string_random
from utils.serializers import ImageBaseSerializer, ProjectModelSerializer, TimeZoneChoiceField, \ from utils.serializers import ImageBaseSerializer, ProjectModelSerializer, TimeZoneChoiceField, \
PhoneMixinSerializer PhoneMixinSerializer
from main import models as main_models
from django.contrib.contenttypes.models import ContentType
def phones_handler(phones_list, establishment): def phones_handler(phones_list, establishment):
@ -68,7 +74,7 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
source='contact_phones', source='contact_phones',
allow_null=True, allow_null=True,
allow_empty=True, allow_empty=True,
child=serializers.CharField(max_length=128), child=PhoneNumberField(),
required=False, required=False,
write_only=True, write_only=True,
) )
@ -80,6 +86,8 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
child=serializers.CharField(max_length=128), child=serializers.CharField(max_length=128),
required=False, required=False,
) )
subtypes = model_serializers.EstablishmentSubTypeBaseSerializer(source='establishment_subtypes',
read_only=True, many=True)
class Meta(model_serializers.EstablishmentBaseSerializer.Meta): class Meta(model_serializers.EstablishmentBaseSerializer.Meta):
fields = [ fields = [
@ -95,6 +103,7 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
'toque_number', 'toque_number',
'type_id', 'type_id',
'type', 'type',
'subtypes',
'socials', 'socials',
'image_url', 'image_url',
'slug', 'slug',
@ -126,6 +135,19 @@ class EstablishmentListCreateSerializer(model_serializers.EstablishmentBaseSeria
if 'contact_emails' in validated_data: if 'contact_emails' in validated_data:
emails_list = validated_data.pop('contact_emails') emails_list = validated_data.pop('contact_emails')
index_name = validated_data.get('index_name')
if 'slug' not in validated_data and index_name:
slug = slugify(
index_name,
word_boundary=True
)
while models.Establishment.objects.filter(slug=slug).exists():
slug = slugify(
f'{index_name} {string_random()}',
word_boundary=True
)
validated_data['slug'] = slug
instance = super().create(validated_data) instance = super().create(validated_data)
phones_handler(phones_list, instance) phones_handler(phones_list, instance)
emails_handler(emails_list, instance) emails_handler(emails_list, instance)
@ -165,7 +187,15 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer):
subtypes = model_serializers.EstablishmentSubTypeBaseSerializer(source='establishment_subtypes', subtypes = model_serializers.EstablishmentSubTypeBaseSerializer(source='establishment_subtypes',
read_only=True, many=True) read_only=True, many=True)
type = model_serializers.EstablishmentTypeBaseSerializer(source='establishment_type', read_only=True) type = model_serializers.EstablishmentTypeBaseSerializer(source='establishment_type', read_only=True)
phones = ContactPhonesSerializer(read_only=True, many=True) phones = serializers.ListField(
source='contact_phones',
allow_null=True,
allow_empty=True,
child=PhoneNumberField(),
required=False,
write_only=True,
)
contact_phones = ContactPhonesSerializer(source='phones', read_only=True, many=True)
class Meta(model_serializers.EstablishmentBaseSerializer.Meta): class Meta(model_serializers.EstablishmentBaseSerializer.Meta):
fields = [ fields = [
@ -176,6 +206,7 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer):
'index_name', 'index_name',
'website', 'website',
'phones', 'phones',
'contact_phones',
'emails', 'emails',
'price_level', 'price_level',
'toque_number', 'toque_number',
@ -196,6 +227,11 @@ class EstablishmentRUDSerializer(model_serializers.EstablishmentBaseSerializer):
'status_display', 'status_display',
] ]
def to_representation(self, instance):
data = super(EstablishmentRUDSerializer, self).to_representation(instance)
data['phones'] = data.pop('contact_phones', None)
return data
def update(self, instance, validated_data): def update(self, instance, validated_data):
phones_list = [] phones_list = []
if 'contact_phones' in validated_data: if 'contact_phones' in validated_data:
@ -397,7 +433,8 @@ class EmployeeBackSerializers(PhoneMixinSerializer, serializers.ModelSerializer)
'photo_id', 'photo_id',
] ]
extra_kwargs = { extra_kwargs = {
'phone': {'write_only': True} 'phone': {'write_only': True},
'available_for_events': {'required': False}
} }
@ -891,7 +928,7 @@ class CardAndWinesPlatesSerializer(serializers.ModelSerializer):
class CardAndWinesSerializer(serializers.ModelSerializer): class CardAndWinesSerializer(serializers.ModelSerializer):
"""View to show menus(not formulas) with dishes for certain establishment""" """Serializer to show menus(not formulas) with dishes for certain establishment"""
wine = EstablishmentBackOfficeWineSerializer(allow_null=True, read_only=True, source='back_office_wine') wine = EstablishmentBackOfficeWineSerializer(allow_null=True, read_only=True, source='back_office_wine')
dishes = CardAndWinesPlatesSerializer(many=True, source='card_and_wine_plates') dishes = CardAndWinesPlatesSerializer(many=True, source='card_and_wine_plates')
@ -902,3 +939,35 @@ class CardAndWinesSerializer(serializers.ModelSerializer):
'wine', 'wine',
'dishes', 'dishes',
) )
class TeamMemberSerializer(serializers.ModelSerializer):
"""Serializer for team establishment BO section"""
class Meta:
model = account_models.User
fields = (
'id',
'username',
'email',
)
class BackEstablishmentAwardCreateSerializer(serializers.ModelSerializer):
"""Award, The Creator."""
award_type = serializers.PrimaryKeyRelatedField(required=True, queryset=main_models.AwardType.objects.all())
title = serializers.CharField(write_only=True)
class Meta:
model = models.Award
fields = (
'id',
'award_type',
'title',
'vintage_year',
)
def validate(self, attrs):
attrs['object_id'] = self.context.get('request').parser_context.get('kwargs')['establishment_id']
attrs['content_type'] = ContentType.objects.get_for_model(models.Establishment)
attrs['title'] = {self.context.get('request').locale: attrs['title']}
return attrs

View File

@ -15,7 +15,7 @@ from location.serializers import (
EstablishmentWineRegionBaseSerializer, EstablishmentWineRegionBaseSerializer,
) )
from main.serializers import AwardSerializer, CurrencySerializer from main.serializers import AwardSerializer, CurrencySerializer
from review.serializers import ReviewShortSerializer from review.serializers import ReviewShortSerializer, ReviewBaseSerializer
from tag.serializers import TagBaseSerializer from tag.serializers import TagBaseSerializer
from timetable.serialziers import ScheduleRUDSerializer from timetable.serialziers import ScheduleRUDSerializer
from utils import exceptions as utils_exceptions from utils import exceptions as utils_exceptions
@ -408,6 +408,8 @@ class EstablishmentListRetrieveSerializer(EstablishmentBaseSerializer):
restaurant_cuisine = TagBaseSerializer(read_only=True, many=True, allow_null=True) restaurant_cuisine = TagBaseSerializer(read_only=True, many=True, allow_null=True)
artisan_category = TagBaseSerializer(read_only=True, many=True, allow_null=True) artisan_category = TagBaseSerializer(read_only=True, many=True, allow_null=True)
distillery_type = TagBaseSerializer(read_only=True, many=True, allow_null=True) distillery_type = TagBaseSerializer(read_only=True, many=True, allow_null=True)
food_producer = TagBaseSerializer(read_only=True, many=True, allow_null=True)
reviews = ReviewBaseSerializer(read_only=True, many=True)
class Meta(EstablishmentBaseSerializer.Meta): class Meta(EstablishmentBaseSerializer.Meta):
"""Meta class.""" """Meta class."""
@ -418,6 +420,11 @@ class EstablishmentListRetrieveSerializer(EstablishmentBaseSerializer):
'restaurant_cuisine', 'restaurant_cuisine',
'artisan_category', 'artisan_category',
'distillery_type', 'distillery_type',
'food_producer',
'vintage_year',
'reviews',
'contact_phones',
'public_mark'
] ]
@ -706,3 +713,8 @@ class EstablishmentGuideElementSerializer(serializers.ModelSerializer):
'range_price_carte', 'range_price_carte',
'currency', 'currency',
] ]
class EstablishmentStatusesSerializer(serializers.Serializer):
value = serializers.IntegerField()
state_translated = serializers.CharField()

View File

@ -77,6 +77,16 @@ urlpatterns = [
name='employee-positions-list'), name='employee-positions-list'),
path('employee_establishments/<int:pk>/', views.EmployeeEstablishmentsListView.as_view(), path('employee_establishments/<int:pk>/', views.EmployeeEstablishmentsListView.as_view(),
name='employee-establishments-list'), name='employee-establishments-list'),
path('available_statuses/', views.StatusesListView.as_view(), name='statuses-list'),
path('employee_establishment_positions/<int:pk>/', views.EmployeeEstablishmentPositionsView.as_view(), path('employee_establishment_positions/<int:pk>/', views.EmployeeEstablishmentPositionsView.as_view(),
name='employee-establishment-positions') name='employee-establishment-positions'),
path('team/<int:establishment_id>', views.TeamMemberListView.as_view(), name='establishment-team-members-list'),
path('team/<int:establishment_id>/<int:user_id>', views.TeamMemberDeleteView.as_view(),
name='establishment-team-member-delete'),
path('awards/create-and-bind/<int:establishment_id>/',
views.EstablishmentAwardCreateAndBind.as_view(),
name='establishment-awards-create-and-bind'),
path('awards/create-and-bind/<int:establishment_id>/<int:award_id>/',
views.EstablishmentAwardCreateAndBind.as_view(),
name='establishment-awards-create-and-bind',)
] ]

View File

@ -1,4 +1,6 @@
"""Establishment app views.""" """Establishment app views."""
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.http import Http404 from django.http import Http404
@ -7,7 +9,7 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, response, status from rest_framework import generics, response, status
from rest_framework.response import Response from rest_framework.response import Response
from account.models import User from account.models import User, Role, UserRole
from collection.models import Guide from collection.models import Guide
from establishment import filters, models, serializers from establishment import filters, models, serializers
from establishment.models import EstablishmentEmployee, Menu from establishment.models import EstablishmentEmployee, Menu
@ -16,6 +18,8 @@ from timetable.serialziers import ScheduleCreateSerializer, ScheduleRUDSerialize
from utils.methods import get_permission_classes from utils.methods import get_permission_classes
from utils.permissions import (IsEstablishmentAdministrator, IsEstablishmentManager) from utils.permissions import (IsEstablishmentAdministrator, IsEstablishmentManager)
from utils.views import CreateDestroyGalleryViewMixin from utils.views import CreateDestroyGalleryViewMixin
from main import models as main_models
from main import serializers as main_serializers
class MenuRUDMixinViews: class MenuRUDMixinViews:
@ -795,7 +799,7 @@ class EstablishmentGuideCreateDestroyView(generics.GenericAPIView):
# Perform the lookup filtering. # Perform the lookup filtering.
lookup_url_kwarg = getattr(self, 'establishment_lookup_url_kwarg', None) lookup_url_kwarg = getattr(self, 'establishment_lookup_url_kwarg', None)
assert lookup_url_kwarg and lookup_url_kwarg in self.kwargs, ( assert lookup_url_kwarg in self.kwargs, (
'Expected view %s to be called with a URL keyword argument ' 'Expected view %s to be called with a URL keyword argument '
'named "%s". Fix your URL conf, or set the `.lookup_field` ' 'named "%s". Fix your URL conf, or set the `.lookup_field` '
'attribute on the view correctly.' % 'attribute on the view correctly.' %
@ -816,7 +820,7 @@ class EstablishmentGuideCreateDestroyView(generics.GenericAPIView):
# Perform the lookup filtering. # Perform the lookup filtering.
lookup_url_kwarg = getattr(self, 'guide_lookup_url_kwarg', None) lookup_url_kwarg = getattr(self, 'guide_lookup_url_kwarg', None)
assert lookup_url_kwarg and lookup_url_kwarg in self.kwargs, ( assert lookup_url_kwarg in self.kwargs, (
'Expected view %s to be called with a URL keyword argument ' 'Expected view %s to be called with a URL keyword argument '
'named "%s". Fix your URL conf, or set the `.lookup_field` ' 'named "%s". Fix your URL conf, or set the `.lookup_field` '
'attribute on the view correctly.' % 'attribute on the view correctly.' %
@ -923,3 +927,82 @@ class MenuGalleryCreateDestroyView(CreateDestroyGalleryViewMixin):
self.check_object_permissions(self.request, gallery) self.check_object_permissions(self.request, gallery)
return gallery return gallery
class StatusesListView(generics.ListAPIView):
"""Possible project establishment statuses"""
pagination_class = None
serializer_class = serializers.EstablishmentStatusesSerializer
def get_queryset(self):
return None
def list(self, request, *args, **kwargs):
mutated_for_serializer = [{
'value': state[0],
'state_translated': state[1],
} for state in models.Establishment.STATUS_CHOICES]
serializer = self.get_serializer(mutated_for_serializer, many=True)
return response.Response(serializer.data)
class TeamMemberListView(generics.ListAPIView):
"""Show team for certain establishment"""
pagination_class = None
serializer_class = serializers.TeamMemberSerializer
queryset = User.objects.all()
permission_classes = get_permission_classes(
IsEstablishmentManager,
IsEstablishmentAdministrator
)
def get_queryset(self):
establishment = get_object_or_404(klass=models.Establishment, pk=self.kwargs['establishment_id'])
return super().get_queryset().filter(roles__role=Role.ESTABLISHMENT_ADMINISTRATOR,
userrole__establishment=establishment, userrole__state=UserRole.VALIDATED)
class TeamMemberDeleteView(generics.DestroyAPIView):
"""Delete user from team"""
permission_classes = get_permission_classes(
IsEstablishmentManager,
IsEstablishmentAdministrator
)
def get_object(self):
return UserRole.objects.get(role__role=Role.ESTABLISHMENT_ADMINISTRATOR, user_id=self.kwargs['user_id'],
establishment_id=self.kwargs['establishment_id'])
def perform_destroy(self, instance):
instance.delete()
from account.tasks import team_role_revoked
establishment = models.Establishment.objects.get(pk=self.kwargs['establishment_id'])
if settings.USE_CELERY:
team_role_revoked.delay(self.kwargs['user_id'], self.request.country_code, establishment.name)
else:
team_role_revoked(self.kwargs['user_id'], self.request.country_code, establishment.name)
class EstablishmentAwardCreateAndBind(generics.CreateAPIView, generics.DestroyAPIView):
queryset = main_models.Award.objects.with_base_related().all()
permission_classes = get_permission_classes()
serializer_class = serializers.BackEstablishmentAwardCreateSerializer
def _award_list_for_establishment(self, establishment_id: int, status: int) -> Response:
awards = main_models.Award.objects.with_base_related().filter(
object_id=establishment_id,
content_type=ContentType.objects.get_for_model(models.Establishment)
)
response_serializer = main_serializers.AwardBaseSerializer(awards, many=True)
headers = self.get_success_headers(response_serializer.data)
return Response(response_serializer.data, status=status, headers=headers)
def create(self, request, *args, **kwargs):
"""Overridden create method."""
super().create(request, args, kwargs)
return self._award_list_for_establishment(kwargs['establishment_id'], status.HTTP_201_CREATED)
def delete(self, request, *args, **kwargs):
establishment = get_object_or_404(models.Establishment, id=kwargs['establishment_id'])
establishment.remove_award(kwargs['award_id'])
return self._award_list_for_establishment(kwargs['establishment_id'], status.HTTP_200_OK)

View File

@ -33,12 +33,13 @@ class EstablishmentListView(EstablishmentMixinView, generics.ListAPIView):
serializer_class = serializers.EstablishmentListRetrieveSerializer serializer_class = serializers.EstablishmentListRetrieveSerializer
def get_queryset(self): def get_queryset(self):
return super().get_queryset().with_schedule() \ return super().get_queryset().with_schedule().with_reviews() \
.with_extended_address_related().with_currency_related() \ .with_extended_address_related().with_currency_related() \
.with_certain_tag_category_related('category', 'restaurant_category') \ .with_certain_tag_category_related('category', 'restaurant_category') \
.with_certain_tag_category_related('cuisine', 'restaurant_cuisine') \ .with_certain_tag_category_related('cuisine', 'restaurant_cuisine') \
.with_certain_tag_category_related('shop_category', 'artisan_category') \ .with_certain_tag_category_related('shop_category', 'artisan_category') \
.with_certain_tag_category_related('distillery_type', 'distillery_type') .with_certain_tag_category_related('distillery_type', 'distillery_type') \
.with_certain_tag_category_related('food_producer', 'producer_type')
class EstablishmentSimilarView(EstablishmentListView): class EstablishmentSimilarView(EstablishmentListView):

View File

@ -23,6 +23,22 @@ class CountryQuerySet(models.QuerySet):
"""Filter only active users.""" """Filter only active users."""
return self.filter(is_active=switcher) return self.filter(is_active=switcher)
def by_country_code(self, code: str):
"""Filter QuerySet by country code."""
return self.filter(code__iexact=code)
def aggregate_country_codes(self):
"""Aggregate country codes."""
calling_codes = list(
self.model.objects.exclude(calling_code__isnull=True)
.exclude(code__iexact='aa')
.distinct()
.values_list('calling_code', flat=True)
)
# extend country calling code hardcoded codes
calling_codes.extend(settings.CALLING_CODES_ANTILLES_GUYANE_WEST_INDIES)
return [self.model.CALLING_NUMBER_MASK % i for i in calling_codes]
class Country(TranslatedFieldsMixin, class Country(TranslatedFieldsMixin,
SVGImageMixin, ProjectBaseMixin): SVGImageMixin, ProjectBaseMixin):

File diff suppressed because one or more lines are too long

View File

@ -144,8 +144,8 @@ def transfer_addresses():
def transfer_wine_region(): def transfer_wine_region():
queryset = transfer_models.WineLocations.objects.filter(type='WineRegion') queryset = transfer_models.WineLocations.objects.filter(type='WineRegion')
serialized_data = location_serializers.WineRegionSerializer( serialized_data = location_serializers.WineRegionSerializer(
data=list(queryset.values()), data=list(queryset.values()),
many=True) many=True)
if serialized_data.is_valid(): if serialized_data.is_valid():
serialized_data.save() serialized_data.save()
else: else:
@ -155,8 +155,8 @@ def transfer_wine_region():
def transfer_wine_sub_region(): def transfer_wine_sub_region():
queryset = transfer_models.WineLocations.objects.filter(type='WineSubRegion') queryset = transfer_models.WineLocations.objects.filter(type='WineSubRegion')
serialized_data = location_serializers.WineSubRegionSerializer( serialized_data = location_serializers.WineSubRegionSerializer(
data=list(queryset.values()), data=list(queryset.values()),
many=True) many=True)
if serialized_data.is_valid(): if serialized_data.is_valid():
serialized_data.save() serialized_data.save()
else: else:
@ -166,8 +166,8 @@ def transfer_wine_sub_region():
def transfer_wine_village(): def transfer_wine_village():
queryset = transfer_models.WineLocations.objects.filter(type='Village') queryset = transfer_models.WineLocations.objects.filter(type='Village')
serialized_data = location_serializers.WineVillageSerializer( serialized_data = location_serializers.WineVillageSerializer(
data=list(queryset.values()), data=list(queryset.values()),
many=True) many=True)
if serialized_data.is_valid(): if serialized_data.is_valid():
serialized_data.save() serialized_data.save()
else: else:
@ -247,19 +247,19 @@ def get_ruby_data():
ruby_params = {} ruby_params = {}
if mysql_city.country_code is not None: if mysql_city.country_code:
ruby_params['country_code'] = mysql_city.country_code ruby_params['country_code'] = mysql_city.country_code
if mysql_city.country_code_2 is not None: if mysql_city.country_code_2:
ruby_params['country_code_2'] = mysql_city.country_code_2 ruby_params['country_code_2'] = mysql_city.country_code_2
if mysql_city.region_code is not None: if mysql_city.region_code:
ruby_params['region_code'] = mysql_city.region_code ruby_params['region_code'] = mysql_city.region_code
if mysql_city.subregion_code is not None: if mysql_city.subregion_code:
ruby_params['subregion_code'] = mysql_city.subregion_code ruby_params['subregion_code'] = mysql_city.subregion_code
ruby_response = get_ruby_socket(ruby_params) ruby_response = get_ruby_socket(ruby_params) if ruby_params else None
if ruby_response is None: if ruby_response is None:
continue continue
@ -332,7 +332,7 @@ def get_unused_data():
# Add correct objects of Country, Region and City with mysql_ids array (Country, Region) and mysql_id (City) # Add correct objects of Country, Region and City with mysql_ids array (Country, Region) and mysql_id (City)
def add_correct_location_models(ruby_data): def add_correct_location_models(ruby_data):
for mysql_id, city_object in tqdm(ruby_data.items()): for mysql_id, city_object in tqdm(ruby_data.items()):
country_data = city_object["country"] country_data = city_object['country']
country_code = country_data['code'] # i.e. fr country_code = country_data['code'] # i.e. fr
try: try:
country = Country.objects.get( country = Country.objects.get(
@ -377,7 +377,7 @@ def add_correct_location_models(ruby_data):
city_object['region'] = region city_object['region'] = region
if "subregion" in city_object: if "subregion" in city_object:
del(city_object['subregion']) del (city_object['subregion'])
try: try:
City.objects.create(**city_object) City.objects.create(**city_object)
@ -404,7 +404,7 @@ def fix_location_collection():
collections = Collection.objects.filter(old_id__isnull=False) collections = Collection.objects.filter(old_id__isnull=False)
for collection in collections: for collection in collections:
try: try:
mysql_collection = transfer_models.Collections.objects.\ mysql_collection = transfer_models.Collections.objects. \
raw(f"""select raw(f"""select
s.country_code_2, s.country_code_2,
c.id c.id
@ -425,7 +425,7 @@ def fix_location_collection():
def fix_award_type(): def fix_award_type():
award_types = AwardType.objects.filter(old_id__isnull=False) award_types = AwardType.objects.filter(old_id__isnull=False)
for award_type in award_types: for award_type in award_types:
mysql_award_type = transfer_models.AwardTypes.objects.\ mysql_award_type = transfer_models.AwardTypes.objects. \
raw(f"""SELECT at.id, s.country_code_2 AS country_code raw(f"""SELECT at.id, s.country_code_2 AS country_code
FROM award_types as at FROM award_types as at
JOIN sites s on s.id = at.site_id JOIN sites s on s.id = at.site_id
@ -501,7 +501,6 @@ def fix_chosen_tag():
def fix_location_models(): def fix_location_models():
try: try:
ruby_data_file = open(f"{settings.PROJECT_ROOT}/apps/location/ruby_data.py", "r") ruby_data_file = open(f"{settings.PROJECT_ROOT}/apps/location/ruby_data.py", "r")
ruby_data = json.loads(ruby_data_file.read()) ruby_data = json.loads(ruby_data_file.read())
@ -541,11 +540,11 @@ def transfer_city_photos():
cities_has_same_image = 0 cities_has_same_image = 0
city_gallery = transfer_models.CityPhotos.objects.exclude(city__isnull=True) \ city_gallery = transfer_models.CityPhotos.objects.exclude(city__isnull=True) \
.exclude(city__country_code_2__isnull=True) \ .exclude(city__country_code_2__isnull=True) \
.exclude(city__country_code_2__iexact='') \ .exclude(city__country_code_2__iexact='') \
.exclude(city__region_code__isnull=True) \ .exclude(city__region_code__isnull=True) \
.exclude(city__region_code__iexact='') \ .exclude(city__region_code__iexact='') \
.values_list('city_id', 'attachment_suffix_url') .values_list('city_id', 'attachment_suffix_url')
for old_city_id, image_suffix_url in tqdm(city_gallery): for old_city_id, image_suffix_url in tqdm(city_gallery):
city = City.objects.filter(old_id=old_city_id) city = City.objects.filter(old_id=old_city_id)
if city.exists(): if city.exists():
@ -639,7 +638,7 @@ def add_fake_country():
for region_data in regions_data: for region_data in regions_data:
if "subregions" in region_data: if "subregions" in region_data:
subregions = region_data['subregions'] subregions = region_data['subregions']
del(region_data['subregions']) del (region_data['subregions'])
else: else:
subregions = False subregions = False
@ -665,16 +664,16 @@ def add_fake_country():
file = open(f"{settings.PROJECT_ROOT}/apps/location/csv/aa_cities.csv") file = open(f"{settings.PROJECT_ROOT}/apps/location/csv/aa_cities.csv")
reader = csv.DictReader(file, delimiter=',') reader = csv.DictReader(file, delimiter=',')
for city_data in reader: for city_data in reader:
del(city_data['']) del (city_data[''])
city_data['mysql_id'] = city_data['old_id'] city_data['mysql_id'] = city_data['old_id']
del(city_data['old_id']) del (city_data['old_id'])
city_data['postal_code'] = city_data['zip_code'] city_data['postal_code'] = city_data['zip_code']
del(city_data['zip_code']) del (city_data['zip_code'])
if city_data['postal_code'] == 'null' or city_data['postal_code'] == '': if city_data['postal_code'] == 'null' or city_data['postal_code'] == '':
del(city_data['postal_code']) del (city_data['postal_code'])
city_data["country"] = country city_data["country"] = country
@ -683,10 +682,10 @@ def add_fake_country():
elif city_data['region'] != 'null': elif city_data['region'] != 'null':
region = regions[city_data['region']] region = regions[city_data['region']]
else: else:
del(city_data['region']) del (city_data['region'])
region = None region = None
del(city_data['subregion']) del (city_data['subregion'])
city_data['region'] = region city_data['region'] = region
@ -700,7 +699,7 @@ def add_fake_country():
country.mysql_ids.append(city_data['mysql_id']) country.mysql_ids.append(city_data['mysql_id'])
try: try:
mysql_data = transfer_models.Cities.objects.\ mysql_data = transfer_models.Cities.objects. \
only("map1", "map2", "map_ref", "situation").get(id=city_data['mysql_id']) only("map1", "map2", "map_ref", "situation").get(id=city_data['mysql_id'])
city_data['map1'] = mysql_data.map1 city_data['map1'] = mysql_data.map1
city_data['map2'] = mysql_data.map2 city_data['map2'] = mysql_data.map2

View File

@ -14,6 +14,8 @@ urlpatterns = [
path('cities/<int:pk>/', views.CityRUDView.as_view(), name='city-retrieve'), path('cities/<int:pk>/', views.CityRUDView.as_view(), name='city-retrieve'),
path('countries/', views.CountryListCreateView.as_view(), name='country-list-create'), path('countries/', views.CountryListCreateView.as_view(), name='country-list-create'),
path('countries/calling-codes/', views.CountryCallingCodeListView.as_view(),
name='country-calling-code-list'),
path('countries/<int:pk>/', views.CountryRUDView.as_view(), name='country-retrieve'), path('countries/<int:pk>/', views.CountryRUDView.as_view(), name='country-retrieve'),
path('regions/', views.RegionListCreateView.as_view(), name='region-list-create'), path('regions/', views.RegionListCreateView.as_view(), name='region-list-create'),

View File

@ -1,6 +1,8 @@
"""Location app views.""" """Location app views."""
from django.contrib.postgres.fields.jsonb import KeyTextTransform from django.contrib.postgres.fields.jsonb import KeyTextTransform
from rest_framework import generics from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.views import APIView
from location import filters from location import filters
from location import models, serializers from location import models, serializers
@ -107,26 +109,63 @@ class RegionRUDView(common.RegionViewMixin, generics.RetrieveUpdateDestroyAPIVie
# Country # Country
class CountryListCreateView(generics.ListCreateAPIView): class CountryBaseViewMixin:
"""Mixin for Country views."""
queryset = models.Country.objects.all()
permission_classes = get_permission_classes(
IsEstablishmentManager,
IsEstablishmentAdministrator,
IsGuest,
)
class CountryListCreateView(CountryBaseViewMixin, generics.ListCreateAPIView):
"""List/Create view for model Country.""" """List/Create view for model Country."""
queryset = models.Country.objects.all()\ queryset = (models.Country.objects.annotate(
.annotate(locale_name=KeyTextTransform(get_current_locale(), 'name'))\ locale_name=KeyTextTransform(get_current_locale(), 'name')).order_by('locale_name'))
.order_by('locale_name')
serializer_class = serializers.CountryBackSerializer serializer_class = serializers.CountryBackSerializer
pagination_class = None pagination_class = None
class CountryRUDView(CountryBaseViewMixin, generics.RetrieveUpdateDestroyAPIView):
"""RUD view for model Country."""
serializer_class = serializers.CountryBackSerializer
class CountryCallingCodeListView(APIView):
"""
## Country codes view.
### Response
```
[
"+7",
"+386",
"+352"
]
```
### Description
Return an array of unique country code for all countries in a database.
"""
pagination_class = None
permission_classes = get_permission_classes( permission_classes = get_permission_classes(
IsEstablishmentManager, IsEstablishmentManager,
IsEstablishmentAdministrator, IsEstablishmentAdministrator,
IsGuest, IsGuest,
) )
def get(self, request, *args, **kwargs):
class CountryRUDView(generics.RetrieveUpdateDestroyAPIView): """
"""RUD view for model Country.""" ## Country codes view.
serializer_class = serializers.CountryBackSerializer ### Response
queryset = models.Country.objects.all() ```
permission_classes = get_permission_classes( [
IsEstablishmentManager, "+7",
IsEstablishmentAdministrator, "+386",
IsGuest, "+352"
) ]
```
### Description
Return an array of unique country code for all countries in a database.
"""
return Response(data=models.Country.objects.aggregate_country_codes(),
status=status.HTTP_200_OK)

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.7 on 2020-02-04 14:06
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0049_remove_navigationbarpermission_section'),
]
operations = [
migrations.AddField(
model_name='awardtype',
name='years',
field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1980)]), blank=True, null=True, size=None, verbose_name='years'),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 2.2.7 on 2020-02-04 14:09
import datetime
from django.db import migrations
def fill_award_type_ears(apps, schemaeditor):
AwardType = apps.get_model('main', 'AwardType')
for aw in AwardType.objects.all():
aw.years = list(range(1980, datetime.date.today().year+1))
aw.save()
class Migration(migrations.Migration):
dependencies = [
('main', '0050_awardtype_years'),
]
operations = [
migrations.RunPython(fill_award_type_ears),
]

View File

@ -4,8 +4,8 @@ from typing import Iterable
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes import fields as generic from django.contrib.contenttypes import fields as generic
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField, ArrayField
from django.core.validators import EMPTY_VALUES from django.core.validators import EMPTY_VALUES, MinValueValidator
from django.db import connections from django.db import connections
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
@ -230,6 +230,12 @@ class AwardType(models.Model):
'location.Country', verbose_name=_('country'), on_delete=models.CASCADE) 'location.Country', verbose_name=_('country'), on_delete=models.CASCADE)
name = models.CharField(_('name'), max_length=255) name = models.CharField(_('name'), max_length=255)
old_id = models.IntegerField(null=True, blank=True) old_id = models.IntegerField(null=True, blank=True)
years = ArrayField(
models.IntegerField(validators=[MinValueValidator(1980)]),
verbose_name=_('years'),
blank=True,
null=True,
)
objects = AwardTypeQuerySet.as_manager() objects = AwardTypeQuerySet.as_manager()

View File

@ -199,6 +199,7 @@ class AwardTypeBaseSerializer(serializers.ModelSerializer):
fields = ( fields = (
'id', 'id',
'name', 'name',
'years',
) )

View File

@ -53,6 +53,13 @@ class AwardTypesListView(generics.ListAPIView):
pagination_class = None pagination_class = None
serializer_class = serializers.AwardTypeBaseSerializer serializer_class = serializers.AwardTypeBaseSerializer
permission_classes = get_permission_classes() permission_classes = get_permission_classes()
filter_backends = (DjangoFilterBackend,)
ordering_fields = '__all__'
lookup_field = 'id'
filterset_fields = (
'id',
'name',
)
def get_queryset(self): def get_queryset(self):
"""Overridden get_queryset method.""" """Overridden get_queryset method."""
@ -66,7 +73,7 @@ class ContentTypeView(generics.ListAPIView):
queryset = ContentType.objects.all() queryset = ContentType.objects.all()
serializer_class = serializers.ContentTypeBackSerializer serializer_class = serializers.ContentTypeBackSerializer
permission_classes = get_permission_classes() permission_classes = get_permission_classes()
filter_backends = (DjangoFilterBackend, ) filter_backends = (DjangoFilterBackend,)
ordering_fields = '__all__' ordering_fields = '__all__'
lookup_field = 'id' lookup_field = 'id'
filterset_fields = ( filterset_fields = (

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2020-02-05 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('news', '0055_auto_20200131_1232'),
]
operations = [
migrations.AlterField(
model_name='news',
name='state',
field=models.PositiveSmallIntegerField(choices=[(0, 'published'), (1, 'not published')], default=1, verbose_name='State'),
),
]

View File

@ -105,10 +105,6 @@ class NewsQuerySet(TranslationQuerysetMixin):
"""Return qs with related objects.""" """Return qs with related objects."""
return self.select_related('created_by', 'agenda', 'banner') return self.select_related('created_by', 'agenda', 'banner')
def visible(self):
"""Narrows qs by excluding invisible for API (at all) news"""
return self.exclude(state=self.model.REMOVE)
def by_type(self, news_type): def by_type(self, news_type):
"""Filter News by type""" """Filter News by type"""
return self.filter(news_type__name=news_type) return self.filter(news_type__name=news_type)
@ -278,16 +274,12 @@ class News(GalleryMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin,
) )
# STATE CHOICES # STATE CHOICES
REMOVE = 0 PUBLISHED = 0
HIDDEN = 1 UNPUBLISHED = 1
PUBLISHED = 2
UNPUBLISHED = 3
PUBLISHED_STATES = [PUBLISHED] PUBLISHED_STATES = [PUBLISHED]
STATE_CHOICES = ( STATE_CHOICES = (
(REMOVE, _('remove')), # simply stored in DB news. not shown anywhere
(HIDDEN, _('hidden')), # not shown in api/web or api/mobile
(PUBLISHED, _('published')), # shown everywhere (PUBLISHED, _('published')), # shown everywhere
(UNPUBLISHED, _('not published')), # newly created news (UNPUBLISHED, _('not published')), # newly created news
) )

View File

@ -26,7 +26,6 @@ class NewsMixinView:
"""Override get_queryset method.""" """Override get_queryset method."""
qs = models.News.objects.published() \ qs = models.News.objects.published() \
.with_base_related() \ .with_base_related() \
.visible() \
.annotate_in_favorites(self.request.user) \ .annotate_in_favorites(self.request.user) \
.order_by('-is_highlighted', '-publication_date', '-publication_time') .order_by('-is_highlighted', '-publication_date', '-publication_time')
@ -44,7 +43,7 @@ class NewsMixinView:
return qs return qs
def get_object(self): def get_object(self):
instance = self.get_queryset().visible().with_base_related().filter( instance = self.get_queryset().with_base_related().filter(
slugs__values__contains=[self.kwargs['slug']] slugs__values__contains=[self.kwargs['slug']]
).first() ).first()
@ -162,7 +161,7 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView,
def get_queryset(self): def get_queryset(self):
"""Override get_queryset method.""" """Override get_queryset method."""
qs = super().get_queryset().with_extended_related().visible() qs = super().get_queryset().with_extended_related()
if 'ordering' in self.request.query_params: if 'ordering' in self.request.query_params:
self.request.GET._mutable = True self.request.GET._mutable = True
if '-publication_datetime' in self.request.query_params['ordering']: if '-publication_datetime' in self.request.query_params['ordering']:

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.7 on 2020-02-04 12:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('product', '0025_auto_20191227_1443'),
]
operations = [
migrations.AlterField(
model_name='productnote',
name='product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='product.Product', verbose_name='product'),
),
]

View File

@ -344,12 +344,6 @@ class Product(GalleryMixin, TranslatedFieldsMixin, BaseAttributes,
"""Override str dunder method.""" """Override str dunder method."""
return f'{self.name}' return f'{self.name}'
def delete(self, using=None, keep_parents=False):
"""Overridden delete method"""
# Delete all related notes
self.notes.all().delete()
return super().delete(using, keep_parents)
@property @property
def product_type_translated_name(self): def product_type_translated_name(self):
"""Get translated name of product type.""" """Get translated name of product type."""
@ -624,7 +618,7 @@ class ProductNote(ProjectBaseMixin):
"""Note model for Product entity.""" """Note model for Product entity."""
old_id = models.PositiveIntegerField(null=True, blank=True) old_id = models.PositiveIntegerField(null=True, blank=True)
text = models.TextField(verbose_name=_('text')) text = models.TextField(verbose_name=_('text'))
product = models.ForeignKey(Product, on_delete=models.PROTECT, product = models.ForeignKey(Product, on_delete=models.CASCADE,
related_name='notes', related_name='notes',
verbose_name=_('product')) verbose_name=_('product'))
user = models.ForeignKey('account.User', on_delete=models.PROTECT, user = models.ForeignKey('account.User', on_delete=models.PROTECT,

0
apps/report/__init__.py Normal file
View File

8
apps/report/admin.py Normal file
View File

@ -0,0 +1,8 @@
from django.contrib import admin
from report.models import Report
@admin.register(Report)
class ReportAdmin(admin.ModelAdmin):
"""Report admin model."""

8
apps/report/apps.py Normal file
View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.text import gettext_lazy as _
class ReportConfig(AppConfig):
name = 'report'
verbose_name = _('Report')

26
apps/report/filters.py Normal file
View File

@ -0,0 +1,26 @@
"""Filters for application report."""
from django_filters import rest_framework as filters
from report.models import Report
class ReportFilterSet(filters.FilterSet):
"""Report filter set."""
source = filters.ChoiceFilter(
choices=Report.SOURCE_CHOICES,
help_text='Filter allow filtering QuerySet by a field - source.'
'Choices - 0 (Back office), 1 (Web), 2 (Mobile)'
)
category = filters.ChoiceFilter(
choices=Report.CATEGORY_CHOICES,
help_text='Filter allow filtering QuerySet by a field - category.'
'Choices - 0 (Bug), 1 (Suggestion/improvement)'
)
class Meta:
"""Meta class."""
model = Report
fields = (
'source',
'category',
)

View File

@ -0,0 +1,31 @@
# Generated by Django 2.2.7 on 2020-02-05 12:16
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Report',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='Date created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='Date updated')),
('source', models.PositiveSmallIntegerField(choices=[(0, 'Back office'), (1, 'Web'), (2, 'Mobile')], verbose_name='source')),
('category', models.PositiveSmallIntegerField(choices=[(0, 'Bug'), (1, 'Suggestion/improvement')], verbose_name='category')),
('url', models.URLField(verbose_name='URL')),
('description', models.TextField(verbose_name='description')),
],
options={
'verbose_name': 'Report',
'verbose_name_plural': 'Reports',
},
),
]

View File

84
apps/report/models.py Normal file
View File

@ -0,0 +1,84 @@
from django.conf import settings
from django.core.mail import send_mail
from django.db import models
from django.utils.text import gettext_lazy as _
from report.tasks import send_report_task
from utils.models import ProjectBaseMixin
class ReportManager(models.Manager):
"""Manager for model Report."""
def make(self, source: int, category, url: str, description: str):
"""Make object."""
obj = self.create(
source=source,
category=category,
url=url,
description=description
)
if settings.USE_CELERY:
send_report_task.delay(obj.id)
else:
send_report_task(obj.id)
return obj
class ReportQuerySet(models.QuerySet):
"""QuerySet for model Report."""
def by_source(self, source: int):
"""Return QuerySet filtered by a source."""
return self.filter(source=source)
class Report(ProjectBaseMixin):
"""Report model."""
BACK_OFFICE = 0
WEB = 1
MOBILE = 2
SOURCE_CHOICES = (
(BACK_OFFICE, _('Back office')),
(WEB, _('Web')),
(MOBILE, _('Mobile')),
)
BUG = 0
SUGGESTION_IMPROVEMENT = 1
CATEGORY_CHOICES = (
(BUG, _('Bug')),
(SUGGESTION_IMPROVEMENT, _('Suggestion/improvement')),
)
source = models.PositiveSmallIntegerField(choices=SOURCE_CHOICES,
verbose_name=_('source'))
category = models.PositiveSmallIntegerField(choices=CATEGORY_CHOICES,
verbose_name=_('category'))
url = models.URLField(verbose_name=_('URL'))
description = models.TextField(verbose_name=_('description'))
objects = ReportManager.from_queryset(ReportQuerySet)()
class Meta:
"""Meta class."""
verbose_name = _('Report')
verbose_name_plural = _('Reports')
def get_body_email_message(self):
"""Prepare the body of the email message"""
return {
'subject': self.get_category_display(),
'message': str(self.description),
'html_message': self.description,
'from_email': settings.EMAIL_HOST_USER,
'recipient_list': [settings.EMAIL_TECHNICAL_SUPPORT, ],
}
def send_email(self):
"""Send an email reset user password"""
send_mail(**self.get_body_email_message())

View File

@ -0,0 +1,34 @@
"""DRF-serializers for application report."""
from rest_framework import serializers
from report.models import Report
class ReportBaseSerializer(serializers.ModelSerializer):
"""Serializer for model Report."""
category_display = serializers.CharField(source='get_category_display', read_only=True)
class Meta:
"""Meta class."""
model = Report
fields = [
'id',
'category',
'category_display',
'url',
'description',
]
extra_kwargs = {
'source': {'required': False},
'category': {'write_only': True}
}
def validate(self, attrs):
"""An overridden validate method."""
attrs['source'] = self.context.get('view').get_source()
return attrs
def create(self, validated_data):
"""An overridden create method."""
return self.Meta.model.objects.make(**validated_data)

18
apps/report/tasks.py Normal file
View File

@ -0,0 +1,18 @@
"""Report app tasks."""
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task
def send_report_task(report_id: int):
from report.models import Report
report_qs = Report.objects.filter(id=report_id)
if report_qs.exists():
report = report_qs.first()
report.send_email()
else:
logger.error(f'Error sending report {report_id}')

1
apps/report/tests.py Normal file
View File

@ -0,0 +1 @@
# Create your tests here.

View File

10
apps/report/urls/back.py Normal file
View File

@ -0,0 +1,10 @@
"""Back office URL patterns for application report."""
from django.urls import path
from report.views import back as views
app_name = 'report'
urlpatterns = [
path('', views.ReportListCreateView.as_view(), name='report-list-create'),
]

View File

@ -0,0 +1,9 @@
"""Common URL patterns for application report."""
from django.urls import path
from report.views import common as views
app_name = 'report'
urlpatterns = [
path('<int:pk>/', views.ReportRetrieveView.as_view(), name='report-retrieve')
]

View File

@ -0,0 +1,5 @@
"""Mobile URL patterns for application report."""
app_name = 'report'
urlpatterns = []

11
apps/report/urls/web.py Normal file
View File

@ -0,0 +1,11 @@
"""Web URL patterns for application report."""
from django.urls import path
from report.urls.common import urlpatterns as common_urlpatterns
from report.views import web as views
app_name = 'report'
urlpatterns = common_urlpatterns + [
path('', views.ReportListCreateView.as_view(), name='report-list-create'),
]

View File

39
apps/report/views/back.py Normal file
View File

@ -0,0 +1,39 @@
"""Views for application report."""
from rest_framework.generics import ListCreateAPIView
from report.models import Report
from report.views.common import ReportBaseView
class ReportListCreateView(ReportBaseView, ListCreateAPIView):
"""
## View for getting list of reports or create a new one.
### POST-request data
Request data attributes:
* category: integer (0 - Bug, 1 - Suggestion improvement)
* url: char (URL)
* description: text (problem description)
I.e.:
```
{
"category": 1,
"url": "http://google.com",
"description": "Description"
}
```
### Response
*GET*
Return paginated list of reports.
*POST*
Creates a new report with a source - `BACK_OFFICE`, and returns a serialized object.
### Description
Method that allows getting list of reports or create a new one and return serialized object.
"""
@staticmethod
def get_source():
"""Return a source for view."""
return Report.BACK_OFFICE

View File

@ -0,0 +1,58 @@
"""Common views for application report."""
from rest_framework import generics
from report.filters import ReportFilterSet
from report.models import Report
from report.serializers import ReportBaseSerializer
from utils.methods import get_permission_classes
from utils.permissions import (
IsEstablishmentManager, IsContentPageManager, IsReviewManager,
IsModerator, IsRestaurantInspector, IsArtisanInspector,
IsWineryWineInspector, IsDistilleryLiquorInspector, IsProducerFoodInspector,
IsEstablishmentAdministrator
)
class ReportBaseView(generics.GenericAPIView):
"""
## Report base view.
"""
queryset = Report.objects.all()
serializer_class = ReportBaseSerializer
filter_class = ReportFilterSet
permission_classes = get_permission_classes(
IsEstablishmentManager, IsContentPageManager, IsReviewManager,
IsModerator, IsRestaurantInspector, IsArtisanInspector,
IsWineryWineInspector, IsDistilleryLiquorInspector, IsProducerFoodInspector,
IsEstablishmentAdministrator
)
@staticmethod
def get_source():
"""Return a source for view."""
return NotImplementedError('You must implement "get_source" method')
class ReportRetrieveView(ReportBaseView, generics.RetrieveAPIView):
"""
## View for retrieving serialized instance.
### Response
Return serialized object.
I.e.:
```
{
"count": 7,
"next": null,
"previous": null,
"results": [
{
"id": 1,
...
}
]
}
```
### Description
Method that allows retrieving serialized report object.
"""

39
apps/report/views/web.py Normal file
View File

@ -0,0 +1,39 @@
"""Views for application report."""
from rest_framework.generics import ListCreateAPIView
from report.models import Report
from report.views.common import ReportBaseView
class ReportListCreateView(ReportBaseView, ListCreateAPIView):
"""
## View for getting list of reports or create a new one.
### POST-request data
Request data attributes:
* category: integer (0 - Bug, 1 - Suggestion improvement)
* url: char (URL)
* description: text (problem description)
I.e.:
```
{
"category": 1,
"url": "http://google.com",
"description": "Description"
}
```
### Response
*GET*
Return paginated list of reports.
*POST*
Creates a new report with a source - `WEB`, and returns a serialized object.
### Description
Method that allows getting list of reports or create a new one and return serialized object.
"""
@staticmethod
def get_source():
"""Return a source for view."""
return Report.WEB

View File

@ -92,9 +92,9 @@ class NewsSerializer(serializers.Serializer):
states = { states = {
'new': News.UNPUBLISHED, 'new': News.UNPUBLISHED,
'published': News.PUBLISHED, 'published': News.PUBLISHED,
'hidden': News.HIDDEN, 'hidden': News.UNPUBLISHED,
'published_exclusive': News.PUBLISHED, 'published_exclusive': News.UNPUBLISHED,
'scheduled_exclusively': News.PUBLISHED, 'scheduled_exclusively': News.UNPUBLISHED,
} }
return states.get(data['page__state'], News.UNPUBLISHED) return states.get(data['page__state'], News.UNPUBLISHED)

View File

@ -1,11 +1,13 @@
"""Translation app models.""" """Translation app models."""
from django.apps import apps
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.apps import apps
from utils.models import ProjectBaseMixin, LocaleManagerMixin from utils.models import ProjectBaseMixin, LocaleManagerMixin
class LanguageQuerySet(models.QuerySet): class LanguageQuerySet(models.QuerySet):
"""QuerySet for model Language""" """QuerySet for model Language"""
@ -55,7 +57,7 @@ class SiteInterfaceDictionaryManager(LocaleManagerMixin):
Tag = apps.get_model('tag', 'Tag') Tag = apps.get_model('tag', 'Tag')
"""Creates or updates translation for EXISTING in DB Tag""" """Creates or updates translation for EXISTING in DB Tag"""
if not tag.pk or not isinstance(tag, Tag): if not tag.pk or not isinstance(tag, Tag):
raise NotImplementedError raise NotImplementedError()
if tag.translation: if tag.translation:
tag.translation.text = translations tag.translation.text = translations
tag.translation.page = 'tag' tag.translation.page = 'tag'
@ -74,7 +76,7 @@ class SiteInterfaceDictionaryManager(LocaleManagerMixin):
"""Creates or updates translation for EXISTING in DB TagCategory""" """Creates or updates translation for EXISTING in DB TagCategory"""
TagCategory = apps.get_model('tag', 'TagCategory') TagCategory = apps.get_model('tag', 'TagCategory')
if not tag_category.pk or not isinstance(tag_category, TagCategory): if not tag_category.pk or not isinstance(tag_category, TagCategory):
raise NotImplementedError raise NotImplementedError()
if tag_category.translation: if tag_category.translation:
tag_category.translation.text = translations tag_category.translation.text = translations
tag_category.translation.page = 'tag' tag_category.translation.page = 'tag'

View File

@ -9,6 +9,7 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import JSONField from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.fields.jsonb import KeyTextTransform from django.contrib.postgres.fields.jsonb import KeyTextTransform
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.html import mark_safe from django.utils.html import mark_safe
from django.utils.translation import ugettext_lazy as _, get_language from django.utils.translation import ugettext_lazy as _, get_language
@ -142,7 +143,7 @@ class OAuthProjectMixin:
def get_source(self): def get_source(self):
"""Method to get of platform""" """Method to get of platform"""
return NotImplementedError return NotImplementedError()
class BaseAttributes(ProjectBaseMixin): class BaseAttributes(ProjectBaseMixin):
@ -526,3 +527,12 @@ class PhoneModelMixin:
"""Return phone national number from from PhonеNumberField.""" """Return phone national number from from PhonеNumberField."""
if hasattr(self, 'phone') and (self.phone and hasattr(self.phone, 'national_number')): if hasattr(self, 'phone') and (self.phone and hasattr(self.phone, 'national_number')):
return self.phone.national_number return self.phone.national_number
class AwardsModelMixin:
def remove_award(self, award_id: int):
from main.models import Award
award = get_object_or_404(Award, pk=award_id)
if hasattr(self, 'awards'):
self.awards.remove(award)

View File

@ -76,6 +76,7 @@ PROJECT_APPS = [
'favorites.apps.FavoritesConfig', 'favorites.apps.FavoritesConfig',
'rating.apps.RatingConfig', 'rating.apps.RatingConfig',
'tag.apps.TagConfig', 'tag.apps.TagConfig',
'report.apps.ReportConfig',
] ]
EXTERNAL_APPS = [ EXTERNAL_APPS = [
@ -453,6 +454,9 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1
# TEMPLATES # TEMPLATES
RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html' RESETTING_TOKEN_TEMPLATE = 'account/password_reset_email.html'
NEW_USER_FOR_ESTABLISHMENT_TEAM_TEMPLATE = 'account/invite_est_team_new_user.html'
EXISTING_USER_FOR_ESTABLISHMENT_TEAM_TEMPLATE = 'account/invite_est_team_existing_user.html'
ESTABLISHMENT_TEAM_ROLE_REVOKED_TEMPLATE = 'account/est_team_role_revoked.html'
CHANGE_EMAIL_TEMPLATE = 'account/change_email.html' CHANGE_EMAIL_TEMPLATE = 'account/change_email.html'
CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html' CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html'
NEWS_EMAIL_TEMPLATE = 'news/news_email.html' NEWS_EMAIL_TEMPLATE = 'news/news_email.html'
@ -563,3 +567,5 @@ COUNTRY_CALLING_CODES = {
CALLING_CODES_ANTILLES_GUYANE_WEST_INDIES = [590, 594, 1758, 596] CALLING_CODES_ANTILLES_GUYANE_WEST_INDIES = [590, 594, 1758, 596]
DEFAULT_CALLING_CODE_ANTILLES_GUYANE_WEST_INDIES = 590 DEFAULT_CALLING_CODE_ANTILLES_GUYANE_WEST_INDIES = 590
EMAIL_TECHNICAL_SUPPORT = 'it-report@gaultmillau.com'

View File

@ -1,6 +1,6 @@
"""Development settings.""" """Development settings."""
from .base import *
from .amazon_s3 import * from .amazon_s3 import *
from .base import *
ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126', '0.0.0.0'] ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126', '0.0.0.0']
@ -77,3 +77,33 @@ EMAIL_PORT = 587
MIDDLEWARE.append('utils.middleware.log_db_queries_per_API_request') MIDDLEWARE.append('utils.middleware.log_db_queries_per_API_request')
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
},
'null': {
'class': 'logging.NullHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console', ],
'level': 'ERROR',
'propagate': False,
},
}
}

View File

@ -86,11 +86,11 @@ LOGGING = {
'py.warnings': { 'py.warnings': {
'handlers': ['console'], 'handlers': ['console'],
}, },
# 'django.db.backends': { 'django.db.backends': {
# 'handlers': ['console', ], 'handlers': ['console', ],
# 'level': 'DEBUG', 'level': 'DEBUG',
# 'propagate': False, 'propagate': False,
# }, },
} }
} }
@ -123,3 +123,5 @@ EMAIL_PORT = 587
# ADD TRANSFER TO INSTALLED APPS # ADD TRANSFER TO INSTALLED APPS
INSTALLED_APPS.append('transfer.apps.TransferConfig') INSTALLED_APPS.append('transfer.apps.TransferConfig')
EMAIL_TECHNICAL_SUPPORT = 'a.feteleu@spider.ru'

View File

@ -47,7 +47,7 @@
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" /> <img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span> <span class="letter__follow-title">Follow us</span>
</div> </div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also us on our social network below <div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div> </div>
<div class="letter__follow-social"> <div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;"> <a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">

View File

@ -0,0 +1,76 @@
{% load i18n %}{% autoescape off %}
<!DOCTYPE html>
<html lang="en" style="box-sizing: border-box;margin: 0;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700|PT+Serif&display=swap&subset=cyrillic" type="text/css">
<title></title>
</head>
<body style="box-sizing: border-box;margin: 0;font-family: &quot;Open Sans&quot;, sans-serif;font-size: 0.875rem;">
<div style="dispaly: none">
</div>
<div style="margin: 0 auto; max-width:38.25rem;" class="letter">
<div class="letter__wrapper">
<div class="letter__inner">
<div class="letter__content" style="position: relative;margin: 0 16px 40px;padding: 0 0 1px;">
<div class="letter__header" style="margin: 1.875rem 0 2.875rem;text-align: center;">
<div class="letter__logo" style="display: block;width: 7.9375rem;height: 4.6875rem;margin: 0 auto 14px auto;">
<a href="https://{{ country_code }}.{{ domain_uri }}/" class="letter__logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: block;border: none;">
<img alt="" style="width:100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/1.png" />
</a>
</div>
<div class="letter__sublogo" style="font-size: 21px;line-height: 1;letter-spacing: 0;color: #bcbcbc;text-transform: uppercase;">france</div>
</div>
</div>
<div class="letter__title" style="font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 1.5rem;margin: 0 0 10px;padding: 0 0 6px;border-bottom: 4px solid #ffee29;">
<span class="letter__title-txt">{{ title }}</span>
</div>
<div class="letter__text" style="margin: 0 0 30px; font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 14px; line-height: 21px;letter-spacing: -0.34px; overflow-x: hidden;">
<br>
{% blocktrans %}Hello, you are receiving this email because your admin privileges on <b>{{ restaurant_name }}</b> have been revoked by another administrator.
If you think this is a mistake, you should contact {{ contact_email }}.{% endblocktrans %}
<br>
<br>
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
</div>
<div class="letter__follow" style="padding: 8px;margin: 0 auto 40px auto;background: #ffee29; max-width: 400px;">
<div class="letter__follow-content" style="padding: 1.25rem 0;background: #fff;text-align: center;">
<div class="letter__follow-header" style="display: inline-block;margin: 0 0 18px;font-family: &quot;PT Serif&quot;, sans-serif;font-size: 1.25rem;text-transform: uppercase;">
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span>
</div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div>
<div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="facebook" style="width: 30px; vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/3.png" />
</a>
<a href="{{ instagram_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="instagram" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/4.png" />
</a>
<a href="{{ twitter_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0;background: #ffee29;border: none;">
<img alt="twitter" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/5.png" />
</a>
</div>
</div>
</div>
<div class="letter__unsubscribe" style="margin: 0 0 1.25rem;font-size: 12px;text-align: center;">
<span class="letter__unsubscribe-dscr" style="display: inline-block;">Please contact {{ contact_email }} if you have any questions.</span>
</div>
<div class="letter__footer" style="padding: 24px 0 15px;text-align: center;background: #ffee29;">
<div class="letter__footer-logo" style="width: 71px;height: 42px;margin: 0 auto 14px auto;">
<a href="#" class="letter__footer-logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;">
<img alt="" style="width: 100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/6.png" /></a>
</div>
<div class="letter__copyright">GaultMillau © {{ year }}</div>
</div>
</div>
</div>
</div>
</body>
</html>
{% endautoescape %}

View File

@ -0,0 +1,83 @@
{% load i18n %}{% autoescape off %}
<!DOCTYPE html>
<html lang="en" style="box-sizing: border-box;margin: 0;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700|PT+Serif&display=swap&subset=cyrillic" type="text/css">
<title></title>
</head>
<body style="box-sizing: border-box;margin: 0;font-family: &quot;Open Sans&quot;, sans-serif;font-size: 0.875rem;">
<div style="dispaly: none">
</div>
<div style="margin: 0 auto; max-width:38.25rem;" class="letter">
<div class="letter__wrapper">
<div class="letter__inner">
<div class="letter__content" style="position: relative;margin: 0 16px 40px;padding: 0 0 1px;">
<div class="letter__header" style="margin: 1.875rem 0 2.875rem;text-align: center;">
<div class="letter__logo" style="display: block;width: 7.9375rem;height: 4.6875rem;margin: 0 auto 14px auto;">
<a href="https://{{ country_code }}.{{ domain_uri }}/" class="letter__logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: block;border: none;">
<img alt="" style="width:100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/1.png" />
</a>
</div>
<div class="letter__sublogo" style="font-size: 21px;line-height: 1;letter-spacing: 0;color: #bcbcbc;text-transform: uppercase;">france</div>
</div>
</div>
<div class="letter__title" style="font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 1.5rem;margin: 0 0 10px;padding: 0 0 6px;border-bottom: 4px solid #ffee29;">
<span class="letter__title-txt">{{ title }}</span>
</div>
<div class="letter__text" style="margin: 0 0 30px; font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 14px; line-height: 21px;letter-spacing: -0.34px; overflow-x: hidden;">
<br>
{% blocktrans %}Hi, you are receiving this email because you have been granted to manage the establishment <b>{{ restaurant_name }}</b>.
From now, you can manage information about this place and have it published in {{ site_name }}.{% endblocktrans %}
<br>
<br>
{% trans "Please go to the following page to apply the appointment:" %}
<br>
<br>
https://{{ country_code }}.{{ domain_uri }}/join-establishment-team/{{ uidb64 }}/{{ token }}/?role={{ user_role_id }}
<br>
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
</div>
<div class="letter__follow" style="padding: 8px;margin: 0 auto 40px auto;background: #ffee29; max-width: 400px;">
<div class="letter__follow-content" style="padding: 1.25rem 0;background: #fff;text-align: center;">
<div class="letter__follow-header" style="display: inline-block;margin: 0 0 18px;font-family: &quot;PT Serif&quot;, sans-serif;font-size: 1.25rem;text-transform: uppercase;">
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span>
</div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div>
<div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="facebook" style="width: 30px; vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/3.png" />
</a>
<a href="{{ instagram_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="instagram" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/4.png" />
</a>
<a href="{{ twitter_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0;background: #ffee29;border: none;">
<img alt="twitter" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/5.png" />
</a>
</div>
</div>
</div>
<div class="letter__unsubscribe" style="margin: 0 0 1.25rem;font-size: 12px;text-align: center;">
<span class="letter__unsubscribe-dscr" style="display: inline-block;"> In case that you were not already a Gault&Millau user, a temporary account with your email has been temporarily created by a Gault&Millau administrator.
By completing the creation process of your account, you agree to have this account permanently created.
This temporary account will be deleted after 7 days if you don't complete the registration process. Please contact {{ contact_email }} if you have any questions.</span>
</div>
<div class="letter__footer" style="padding: 24px 0 15px;text-align: center;background: #ffee29;">
<div class="letter__footer-logo" style="width: 71px;height: 42px;margin: 0 auto 14px auto;">
<a href="#" class="letter__footer-logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;">
<img alt="" style="width: 100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/6.png" /></a>
</div>
<div class="letter__copyright">GaultMillau © {{ year }}</div>
</div>
</div>
</div>
</div>
</body>
</html>
{% endautoescape %}

View File

@ -0,0 +1,83 @@
{% load i18n %}{% autoescape off %}
<!DOCTYPE html>
<html lang="en" style="box-sizing: border-box;margin: 0;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700|PT+Serif&display=swap&subset=cyrillic" type="text/css">
<title></title>
</head>
<body style="box-sizing: border-box;margin: 0;font-family: &quot;Open Sans&quot;, sans-serif;font-size: 0.875rem;">
<div style="dispaly: none">
</div>
<div style="margin: 0 auto; max-width:38.25rem;" class="letter">
<div class="letter__wrapper">
<div class="letter__inner">
<div class="letter__content" style="position: relative;margin: 0 16px 40px;padding: 0 0 1px;">
<div class="letter__header" style="margin: 1.875rem 0 2.875rem;text-align: center;">
<div class="letter__logo" style="display: block;width: 7.9375rem;height: 4.6875rem;margin: 0 auto 14px auto;">
<a href="https://{{ country_code }}.{{ domain_uri }}/" class="letter__logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: block;border: none;">
<img alt="" style="width:100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/1.png" />
</a>
</div>
<div class="letter__sublogo" style="font-size: 21px;line-height: 1;letter-spacing: 0;color: #bcbcbc;text-transform: uppercase;">france</div>
</div>
</div>
<div class="letter__title" style="font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 1.5rem;margin: 0 0 10px;padding: 0 0 6px;border-bottom: 4px solid #ffee29;">
<span class="letter__title-txt">{{ title }}</span>
</div>
<div class="letter__text" style="margin: 0 0 30px; font-family:&quot;Open-Sans&quot;,sans-serif; font-size: 14px; line-height: 21px;letter-spacing: -0.34px; overflow-x: hidden;">
<br>
{% blocktrans %}Hi, you are receiving this email because you have been granted to manage the establishment <b>{{ restaurant_name }}</b>.
From now, you can manage information about this place and have it published in {{ site_name }}.{% endblocktrans %}
<br>
<br>
{% trans "Please go to the following page and choose a new password:" %}
<br>
<br>
https://{{ country_code }}.{{ domain_uri }}/recovery/{{ uidb64 }}/{{ token }}/?role={{ user_role_id }}
<br>
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
</div>
<div class="letter__follow" style="padding: 8px;margin: 0 auto 40px auto;background: #ffee29; max-width: 400px;">
<div class="letter__follow-content" style="padding: 1.25rem 0;background: #fff;text-align: center;">
<div class="letter__follow-header" style="display: inline-block;margin: 0 0 18px;font-family: &quot;PT Serif&quot;, sans-serif;font-size: 1.25rem;text-transform: uppercase;">
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span>
</div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div>
<div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="facebook" style="width: 30px; vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/3.png" />
</a>
<a href="{{ instagram_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">
<img alt="instagram" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/4.png" />
</a>
<a href="{{ twitter_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0;background: #ffee29;border: none;">
<img alt="twitter" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/5.png" />
</a>
</div>
</div>
</div>
<div class="letter__unsubscribe" style="margin: 0 0 1.25rem;font-size: 12px;text-align: center;">
<span class="letter__unsubscribe-dscr" style="display: inline-block;">In case that you were not already a Gault&Millau user, a temporary account with your email has been temporarily created by a Gault&Millau administrator.
By completing the creation process of your account, you agree to have this account permanently created.
This temporary account will be deleted after 7 days if you don't complete the registration process. Please contact {{ contact_email }} if you have any questions. </span>
</div>
<div class="letter__footer" style="padding: 24px 0 15px;text-align: center;background: #ffee29;">
<div class="letter__footer-logo" style="width: 71px;height: 42px;margin: 0 auto 14px auto;">
<a href="#" class="letter__footer-logo-photo" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;">
<img alt="" style="width: 100%;" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/6.png" /></a>
</div>
<div class="letter__copyright">GaultMillau © {{ year }}</div>
</div>
</div>
</div>
</div>
</body>
</html>
{% endautoescape %}

View File

@ -44,7 +44,7 @@
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" /> <img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span> <span class="letter__follow-title">Follow us</span>
</div> </div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also us on our social network below <div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div> </div>
<div class="letter__follow-social"> <div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;"> <a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">

View File

@ -46,7 +46,7 @@
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" /> <img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span> <span class="letter__follow-title">Follow us</span>
</div> </div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also us on our social network below <div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div> </div>
<div class="letter__follow-social"> <div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;"> <a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">

View File

@ -49,7 +49,7 @@
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" /> <img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span> <span class="letter__follow-title">Follow us</span>
</div> </div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also us on our social network below <div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div> </div>
<div class="letter__follow-social"> <div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;"> <a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">

View File

@ -47,7 +47,7 @@
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" /> <img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">Follow us</span> <span class="letter__follow-title">Follow us</span>
</div> </div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also us on our social network below <div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">You can also find us on our social network below
</div> </div>
<div class="letter__follow-social"> <div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;"> <a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">

View File

@ -43,7 +43,7 @@
<img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" /> <img alt="thumb" style="width:30px;vertical-align: sub; display: inline-block" src="https://s3.eu-central-1.amazonaws.com/gm-test.com/manually_uploaded/2.png" />
<span class="letter__follow-title">{% trans "Follow us" %}</span> <span class="letter__follow-title">{% trans "Follow us" %}</span>
</div> </div>
<div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">{% trans "You can also us on our social network below" %} <div class="letter__follow-text" style="display: block;margin: 0 0 30px;font-size: 12px;font-style: italic;">{% trans "You can also find us on our social network below" %}
</div> </div>
<div class="letter__follow-social"> <div class="letter__follow-social">
<a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;"> <a href="{{ facebook_page_url }}" class="letter__follow-link" target="_blank" style="color: #000;font-weight: 700;text-decoration: none;padding: 0;border-bottom: 1.5px solid #ffee29;cursor: pointer;display: inline-block;width: 30px;height: 30px;margin: 0 1rem 0 0;background: #ffee29;border: none;">

View File

@ -18,4 +18,5 @@ urlpatterns = [
namespace='advertisement')), namespace='advertisement')),
path('main/', include('main.urls.back')), path('main/', include('main.urls.back')),
path('partner/', include('partner.urls.back')), path('partner/', include('partner.urls.back')),
path('report/', include('report.urls.back')),
] ]

View File

@ -36,5 +36,5 @@ urlpatterns = [
path('favorites/', include('favorites.urls')), path('favorites/', include('favorites.urls')),
path('timetables/', include('timetable.urls.web')), path('timetables/', include('timetable.urls.web')),
path('products/', include('product.urls.web')), path('products/', include('product.urls.web')),
path('report/', include('report.urls.web')),
] ]