Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
alex 2020-02-05 17:32:45 +03:00
commit 590292919b
47 changed files with 823 additions and 99 deletions

View File

@ -388,6 +388,7 @@ class User(PhoneModelMixin, AbstractUser):
'twitter_page_url': socials.twitter_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 '#',
'contact_email': socials.contact_email if socials else '-',
'send_to': username,
}
@ -430,6 +431,16 @@ class User(PhoneModelMixin, AbstractUser):
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):
"""Get notification email template"""
context = {'country_code': country_code}

View File

@ -40,9 +40,15 @@ def send_team_invite_to_new_user(user_id, country_code, user_role_id, restaurant
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)
user.send_email(subject=subject,
message=message,
emails=None)
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
@ -52,9 +58,32 @@ def send_team_invite_to_existing_user(user_id, country_code, user_role_id, resta
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)
user.send_email(subject=subject,
message=message,
emails=None)
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

View File

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

View File

@ -1,5 +1,6 @@
"""Web account views"""
from django.conf import settings
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils.encoding import force_text
from django.utils.http import urlsafe_base64_decode
@ -61,10 +62,18 @@ class PasswordResetConfirmView(JWTGenericViewMixin, generics.GenericAPIView):
def patch(self, request, *args, **kwargs):
"""Implement PATCH method"""
instance = self.get_object()
serializer = self.get_serializer(instance=instance,
data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
with transaction.atomic():
serializer = self.get_serializer(instance=instance,
data=request.data)
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
tokens = instance.create_jwt_tokens()
return self._put_cookies_in_response(
@ -72,3 +81,40 @@ class PasswordResetConfirmView(JWTGenericViewMixin, generics.GenericAPIView):
access_token=tokens.get('access_token'),
refresh_token=tokens.get('refresh_token')),
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

@ -41,12 +41,18 @@ class GuideFilterSet(filters.FilterSet):
'Use for Establishment detail\'s sheet to content display within '
'"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:
"""Meta class."""
model = models.Guide
fields = (
'establishment_id',
'guide_type',
)
def by_establishment_id(self, queryset, name, value):

View File

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

View File

@ -9,28 +9,6 @@ logging.basicConfig(format='[%(levelname)s] %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
def get_additional_establishment_data(section_node, establishment, guide):
data = {
'restaurant_section_node_id': section_node.id,
'guide_id': guide.id,
'establishment_id': establishment.id,
}
if establishment.last_published_review:
data.update({'review_id': establishment.last_published_review.id})
return data
def get_additional_product_data(section_node, product, guide):
data = {
'color_wine_section_node_id': section_node.id,
'wine_id': product.id,
'guide_id': guide.id,
}
if product.last_published_review:
data.update({'review_id': product.last_published_review.id})
return data
@shared_task
def generate_establishment_guide_elements(guide_id: int, filter_set: dict):
"""Generate guide elements."""
@ -38,9 +16,9 @@ def generate_establishment_guide_elements(guide_id: int, filter_set: dict):
from establishment.models import Establishment
guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.BUILDING)
queryset_values = Establishment.objects.filter(**filter_set).values()
try:
guide.change_state(Guide.BUILDING)
for instance in queryset_values:
populate_establishment_guide(guide_id, instance.get('id'))
except Exception as e:
@ -59,8 +37,8 @@ def populate_establishment_guide(guide_id: int, establishment_id: int):
from establishment.models import Establishment
guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.BUILDING)
try:
guide.change_state(Guide.BUILDING)
establishment_qs = Establishment.objects.filter(id=establishment_id)
if establishment_qs.exists():
establishment = establishment_qs.first()
@ -72,13 +50,17 @@ def populate_establishment_guide(guide_id: int, establishment_id: int):
section_node, _ = GuideElement.objects.get_or_create_establishment_section_node(
city_node.id,
transform_into_section_name(establishment.establishment_type.index_name),
guide_id,
guide.id,
)
if section_node:
GuideElement.objects.get_or_create_establishment_node(
**get_additional_establishment_data(section_node=section_node,
establishment=establishment,
guide=guide))
params = {
'restaurant_section_node_id': section_node.id,
'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:
logger.error(
f'METHOD_NAME: {generate_establishment_guide_elements.__name__}\n'
@ -108,8 +90,8 @@ def remove_establishment_guide(guide_id: int, establishment_id: int):
from establishment.models import Establishment
guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.REMOVING)
try:
guide.change_state(Guide.REMOVING)
establishment_qs = Establishment.objects.filter(id=establishment_id)
if establishment_qs.exists():
establishment = establishment_qs.first()
@ -144,9 +126,9 @@ def generate_product_guide_elements(guide_id: int, filter_set: dict):
from product.models import Product
guide = Guide.objects.get(id=guide_id)
guide.change_state(Guide.BUILDING)
queryset_values = Product.objects.filter(**filter_set).values()
try:
guide.change_state(Guide.BUILDING)
for instance in queryset_values:
wine_id = instance.get('id')
wine_qs = Product.objects.filter(id=wine_id)
@ -157,26 +139,29 @@ def generate_product_guide_elements(guide_id: int, filter_set: dict):
wine_region_node, _ = GuideElement.objects.get_or_create_wine_region_node(
root_node.id,
wine.wine_region.id,
guide_id)
guide.id)
if wine_region_node:
yard_node, _ = GuideElement.objects.get_or_create_yard_node(
wine.id,
wine_region_node.id,
guide_id)
guide.id)
if yard_node:
wine_color_qs = wine.wine_colors
if wine_color_qs.exists():
wine_color_section, _ = GuideElement.objects.get_or_create_color_wine_section_node(
wine_color_qs.first().value,
yard_node.id,
guide_id
guide.id
)
if wine_color_section:
GuideElement.objects.get_or_create_wine_node(
**get_additional_product_data(
wine_color_section,
wine,
guide))
params = {
'color_wine_section_node_id': wine_color_section.id,
'wine_id': wine.id,
'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:
logger.error(
f'METHOD_NAME: {generate_product_guide_elements.__name__}\n'

View File

@ -100,9 +100,48 @@ class CollectionBackOfficeList(CollectionBackOfficeViewSet):
pagination_class = None
class GuideListCreateView(GuideBaseView,
generics.ListCreateAPIView):
"""View for Guides list for BackOffice users and Guide create."""
class GuideListCreateView(GuideBaseView, generics.ListCreateAPIView):
"""
## 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
def create(self, request, *args, **kwargs):

View File

@ -34,7 +34,7 @@ from utils.models import (
BaseAttributes, FavoritesMixin, FileMixin, GalleryMixin, HasTagsMixin,
IntermediateGalleryModelMixin, ProjectBaseMixin, TJSONField, TranslatedFieldsMixin,
TypeDefaultImageMixin, URLImageMixin, default_menu_bool_array, PhoneModelMixin,
)
AwardsModelMixin)
# todo: establishment type&subtypes check
@ -549,7 +549,7 @@ class EstablishmentQuerySet(models.QuerySet):
class Establishment(GalleryMixin, ProjectBaseMixin, URLImageMixin,
TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin):
TranslatedFieldsMixin, HasTagsMixin, FavoritesMixin, AwardsModelMixin):
"""Establishment model."""
ABANDONED = 0
@ -1149,7 +1149,7 @@ class EmployeeQuerySet(models.QuerySet):
)
class Employee(PhoneModelMixin, BaseAttributes):
class Employee(PhoneModelMixin, AwardsModelMixin, BaseAttributes):
"""Employee model."""
user = models.OneToOneField('account.User', on_delete=models.PROTECT,
@ -1225,11 +1225,6 @@ class Employee(PhoneModelMixin, BaseAttributes):
)
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):
"""QuerySet for model EstablishmentSchedule"""

View File

@ -21,6 +21,8 @@ from utils.decorators import with_base_attributes
from utils.methods import string_random
from utils.serializers import ImageBaseSerializer, ProjectModelSerializer, TimeZoneChoiceField, \
PhoneMixinSerializer
from main import models as main_models
from django.contrib.contenttypes.models import ContentType
def phones_handler(phones_list, establishment):
@ -948,3 +950,24 @@ class TeamMemberSerializer(serializers.ModelSerializer):
'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

@ -83,5 +83,10 @@ urlpatterns = [
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."""
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models.query_utils import Q
from django.http import Http404
@ -16,6 +18,8 @@ from timetable.serialziers import ScheduleCreateSerializer, ScheduleRUDSerialize
from utils.methods import get_permission_classes
from utils.permissions import (IsEstablishmentAdministrator, IsEstablishmentManager)
from utils.views import CreateDestroyGalleryViewMixin
from main import models as main_models
from main import serializers as main_serializers
class MenuRUDMixinViews:
@ -102,6 +106,36 @@ class EmployeePositionsListView(generics.ListAPIView):
class EstablishmentRUDView(EstablishmentMixinViews, generics.RetrieveUpdateDestroyAPIView):
"""
Establishment by slug
Implements get, update, delete methods
**GET**
```
EstablishmentRUDView GET method
Implement getting Establishment by slug
```
**PUT**
```
EstablishmentRUDView PUT method
Implement update of Establishment by slug
```
**PATCH**
```
EstablishmentRUDView PATCH method
Implement partial update of Establishment by slug
```
**DELETE**
```
EstablishmentRUDView DELETE method
Implement delete Establishment by slug
```
"""
lookup_field = 'slug'
serializer_class = serializers.EstablishmentRUDSerializer
permission_classes = get_permission_classes(
@ -938,3 +972,37 @@ class TeamMemberDeleteView(generics.DestroyAPIView):
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)

File diff suppressed because one or more lines are too long

View File

@ -247,19 +247,19 @@ def get_ruby_data():
ruby_params = {}
if mysql_city.country_code is not None:
if 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
if mysql_city.region_code is not None:
if 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_response = get_ruby_socket(ruby_params)
ruby_response = get_ruby_socket(ruby_params) if ruby_params else None
if ruby_response is None:
continue

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 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):
"""Filter News by type"""
return self.filter(news_type__name=news_type)
@ -278,16 +274,12 @@ class News(GalleryMixin, BaseAttributes, TranslatedFieldsMixin, HasTagsMixin,
)
# STATE CHOICES
REMOVE = 0
HIDDEN = 1
PUBLISHED = 2
UNPUBLISHED = 3
PUBLISHED = 0
UNPUBLISHED = 1
PUBLISHED_STATES = [PUBLISHED]
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
(UNPUBLISHED, _('not published')), # newly created news
)

View File

@ -26,7 +26,6 @@ class NewsMixinView:
"""Override get_queryset method."""
qs = models.News.objects.published() \
.with_base_related() \
.visible() \
.annotate_in_favorites(self.request.user) \
.order_by('-is_highlighted', '-publication_date', '-publication_time')
@ -44,7 +43,7 @@ class NewsMixinView:
return qs
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']]
).first()
@ -162,7 +161,7 @@ class NewsBackOfficeLCView(NewsBackOfficeMixinView,
def get_queryset(self):
"""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:
self.request.GET._mutable = True
if '-publication_datetime' in self.request.query_params['ordering']:

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 = {
'new': News.UNPUBLISHED,
'published': News.PUBLISHED,
'hidden': News.HIDDEN,
'published_exclusive': News.PUBLISHED,
'scheduled_exclusively': News.PUBLISHED,
'hidden': News.UNPUBLISHED,
'published_exclusive': News.UNPUBLISHED,
'scheduled_exclusively': News.UNPUBLISHED,
}
return states.get(data['page__state'], News.UNPUBLISHED)

View File

@ -1,11 +1,13 @@
"""Translation app models."""
from django.apps import apps
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.apps import apps
from utils.models import ProjectBaseMixin, LocaleManagerMixin
class LanguageQuerySet(models.QuerySet):
"""QuerySet for model Language"""
@ -55,7 +57,7 @@ class SiteInterfaceDictionaryManager(LocaleManagerMixin):
Tag = apps.get_model('tag', 'Tag')
"""Creates or updates translation for EXISTING in DB Tag"""
if not tag.pk or not isinstance(tag, Tag):
raise NotImplementedError
raise NotImplementedError()
if tag.translation:
tag.translation.text = translations
tag.translation.page = 'tag'
@ -74,7 +76,7 @@ class SiteInterfaceDictionaryManager(LocaleManagerMixin):
"""Creates or updates translation for EXISTING in DB TagCategory"""
TagCategory = apps.get_model('tag', 'TagCategory')
if not tag_category.pk or not isinstance(tag_category, TagCategory):
raise NotImplementedError
raise NotImplementedError()
if tag_category.translation:
tag_category.translation.text = translations
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.jsonb import KeyTextTransform
from django.core.validators import FileExtensionValidator
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.html import mark_safe
from django.utils.translation import ugettext_lazy as _, get_language
@ -142,7 +143,7 @@ class OAuthProjectMixin:
def get_source(self):
"""Method to get of platform"""
return NotImplementedError
return NotImplementedError()
class BaseAttributes(ProjectBaseMixin):
@ -526,3 +527,12 @@ class PhoneModelMixin:
"""Return phone national number from from PhonеNumberField."""
if hasattr(self, 'phone') and (self.phone and hasattr(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',
'rating.apps.RatingConfig',
'tag.apps.TagConfig',
'report.apps.ReportConfig',
]
EXTERNAL_APPS = [
@ -455,6 +456,7 @@ PASSWORD_RESET_TIMEOUT_DAYS = 1
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'
CONFIRM_EMAIL_TEMPLATE = 'authorization/confirm_email.html'
NEWS_EMAIL_TEMPLATE = 'news/news_email.html'
@ -565,3 +567,5 @@ COUNTRY_CALLING_CODES = {
CALLING_CODES_ANTILLES_GUYANE_WEST_INDIES = [590, 594, 1758, 596]
DEFAULT_CALLING_CODE_ANTILLES_GUYANE_WEST_INDIES = 590
EMAIL_TECHNICAL_SUPPORT = 'it-report@gaultmillau.com'

View File

@ -1,6 +1,6 @@
"""Development settings."""
from .base import *
from .amazon_s3 import *
from .base import *
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')
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': {
'handlers': ['console'],
},
# 'django.db.backends': {
# 'handlers': ['console', ],
# 'level': 'DEBUG',
# 'propagate': False,
# },
'django.db.backends': {
'handlers': ['console', ],
'level': 'DEBUG',
'propagate': False,
},
}
}
@ -123,3 +123,5 @@ EMAIL_PORT = 587
# ADD TRANSFER TO INSTALLED APPS
INSTALLED_APPS.append('transfer.apps.TransferConfig')
EMAIL_TECHNICAL_SUPPORT = 'a.feteleu@spider.ru'

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

@ -31,7 +31,7 @@
</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>.
{% 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>
@ -66,7 +66,7 @@ From now, you can manage information about this place and have it published in {
<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 %license_contact_email% if you have any questions.</span>
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;">

View File

@ -66,7 +66,7 @@ From now, you can manage information about this place and have it published in {
<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 %license_contact_email% if you have any questions. </span>
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;">

View File

@ -18,4 +18,5 @@ urlpatterns = [
namespace='advertisement')),
path('main/', include('main.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('timetables/', include('timetable.urls.web')),
path('products/', include('product.urls.web')),
path('report/', include('report.urls.web')),
]