Merge branch 'master' of https://gitlab.com/lilcity/backend into feature/lil-583

remotes/origin/hotfix/LIL-691
gzbender 8 years ago
commit 167a6470b3
  1. 1
      .gitlab-ci.yml
  2. 0
      __init__.py
  3. 9
      api/v1/serializers/content.py
  4. 68
      api/v1/serializers/contest.py
  5. 44
      api/v1/serializers/course.py
  6. 4
      api/v1/serializers/mixins.py
  7. 5
      api/v1/urls.py
  8. 63
      api/v1/views.py
  9. 17
      apps/auth/backend.py
  10. 5
      apps/auth/forms.py
  11. 5
      apps/auth/views.py
  12. 48
      apps/content/migrations/0021_auto_20180813_1306.py
  13. 17
      apps/content/migrations/0022_auto_20180815_2129.py
  14. 71
      apps/content/models.py
  15. 0
      apps/content/templates/content/blocks/gallery.html
  16. 0
      apps/content/templates/content/blocks/image.html
  17. 0
      apps/content/templates/content/blocks/imagetext.html
  18. 0
      apps/content/templates/content/blocks/text.html
  19. 0
      apps/content/templates/content/blocks/video.html
  20. 47
      apps/content/templates/content/contest.html
  21. 18
      apps/content/templates/content/contest_edit.html
  22. 112
      apps/content/templates/content/contest_work.html
  23. 110
      apps/content/views.py
  24. 2
      apps/course/filters.py
  25. 18
      apps/course/migrations/0040_course_age.py
  26. 36
      apps/course/migrations/0041_auto_20180813_1306.py
  27. 18
      apps/course/migrations/0042_like_ip.py
  28. 35
      apps/course/models.py
  29. 2
      apps/course/templates/course/_items.html
  30. 2
      apps/course/templates/course/course.html
  31. 15
      apps/course/templates/course/course_only_lessons.html
  32. 4
      apps/course/templates/course/courses.html
  33. 3
      apps/course/templates/course/inclusion/category_items.html
  34. 2
      apps/course/templates/course/inclusion/category_menu_items.html
  35. 50
      apps/course/templates/course/lesson.html
  36. 4
      apps/course/templatetags/lilcity_category.py
  37. 2
      apps/course/views.py
  38. 3
      apps/payment/models.py
  39. 16
      apps/payment/views.py
  40. 2
      apps/school/templates/blocks/open_lesson.html
  41. 16
      apps/school/templates/school/livelesson_detail.html
  42. 2
      apps/user/views.py
  43. 2
      project/settings.py
  44. 3
      project/templates/blocks/header.html
  45. 10
      project/templates/blocks/lil_store_js.html
  46. 3
      project/templates/blocks/partners.html
  47. 2
      project/templates/blocks/popup_auth.html
  48. 2
      project/templates/blocks/share.html
  49. 1
      project/templates/lilcity/edit_index.html
  50. 10
      project/templates/lilcity/index.html
  51. 7
      project/urls.py
  52. 2
      project/views.py
  53. 2
      requirements.txt
  54. 4
      web/package.json
  55. 361
      web/src/components/ContestRedactor.vue
  56. 86
      web/src/components/ContestWorks.vue
  57. 44
      web/src/components/CourseRedactor.vue
  58. 193
      web/src/components/UploadContestWork.vue
  59. 62
      web/src/components/blocks/ContestWork.vue
  60. 41
      web/src/components/blocks/Image.vue
  61. 74
      web/src/components/blocks/Likes.vue
  62. 2
      web/src/components/redactor/VueRedactor.vue
  63. BIN
      web/src/img/pinkbus.jpg
  64. 27
      web/src/js/app.js
  65. 19
      web/src/js/contest-redactor.js
  66. 4
      web/src/js/modules/courses.js
  67. 5
      web/src/js/modules/popup.js
  68. 87
      web/src/sass/_common.sass
  69. 5
      web/webpack.config.js

@ -44,6 +44,7 @@ stop-review:
script: script:
- export REVIEW_HOST=$CI_COMMIT_REF_SLUG-$REVIEW_DOMAIN - export REVIEW_HOST=$CI_COMMIT_REF_SLUG-$REVIEW_DOMAIN
- cd docker - cd docker
- cp .env.review .env
- docker-compose -f docker-compose-review.yml -p back$CI_COMMIT_REF_NAME down - docker-compose -f docker-compose-review.yml -p back$CI_COMMIT_REF_NAME down
- rm -rf /work/data/back_${CI_COMMIT_REF_NAME}/ - rm -rf /work/data/back_${CI_COMMIT_REF_NAME}/
when: manual when: manual

@ -3,8 +3,7 @@ from rest_framework import serializers
from apps.content.models import ( from apps.content.models import (
Baner, Content, Image, Text, ImageText, Video, Baner, Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject, Gallery, GalleryImage, ImageObject,)
)
from . import Base64ImageField from . import Base64ImageField
@ -14,6 +13,7 @@ BASE_CONTENT_FIELDS = (
'uuid', 'uuid',
'course', 'course',
'lesson', 'lesson',
'contest',
'live_lesson', 'live_lesson',
'title', 'title',
'position', 'position',
@ -85,12 +85,16 @@ class ImageObjectSerializer(serializers.ModelSerializer):
image = Base64ImageField( image = Base64ImageField(
required=True, allow_empty_file=False, allow_null=False, read_only=False, required=True, allow_empty_file=False, allow_null=False, read_only=False,
) )
image_thumbnail = Base64ImageField(
required=False, allow_empty_file=True, allow_null=True, read_only=True,
)
class Meta: class Meta:
model = ImageObject model = ImageObject
fields = ( fields = (
'id', 'id',
'image', 'image',
'image_thumbnail',
'created_at', 'created_at',
'update_at', 'update_at',
) )
@ -254,3 +258,4 @@ class ContentSerializer(serializers.ModelSerializer):
elif isinstance(obj, Gallery): elif isinstance(obj, Gallery):
return GallerySerializer(obj, context=self.context).to_representation(obj) return GallerySerializer(obj, context=self.context).to_representation(obj)
return super(ContentSerializer, self).to_representation(obj) return super(ContentSerializer, self).to_representation(obj)

@ -0,0 +1,68 @@
from rest_framework import serializers
from api.v1.serializers.content import ContentSerializer, ContentCreateSerializer, ImageObjectSerializer
from api.v1.serializers.mixins import DispatchContentMixin
from apps.content.models import (Contest, ContestWork)
class ContestSerializer(serializers.ModelSerializer):
cover = ImageObjectSerializer()
content = ContentSerializer(many=True)
class Meta:
model = Contest
fields = '__all__'
class ContestCreateSerializer(DispatchContentMixin, serializers.ModelSerializer):
content = serializers.ListSerializer(
child=ContentCreateSerializer(),
required=False,
)
class Meta:
model = Contest
fields = '__all__'
def create(self, validated_data):
content = validated_data.pop('content', [])
contest = super().create(validated_data)
self.dispatch_content(contest, content)
return contest
def update(self, instance, validated_data):
content = validated_data.pop('content', [])
contest = super().update(instance, validated_data)
self.dispatch_content(contest, content)
return contest
def to_representation(self, instance):
return ContestSerializer(instance=instance, context=self.context).to_representation(instance)
class ContestWorkSerializer(serializers.ModelSerializer):
image = ImageObjectSerializer()
likes = serializers.SerializerMethodField()
user_liked = serializers.SerializerMethodField()
class Meta:
model = ContestWork
fields = ['id', 'user', 'contest', 'image', 'child_full_name', 'age',
'created_at', 'likes', 'user_liked', 'img_width', 'img_height']
def get_likes(self, instance):
return instance.likes.count()
def get_user_liked(self, instance):
user = self.context['request'].user
return instance.likes.filter(user=user).exists() if user.is_authenticated else False
class ContestWorkCreateSerializer(serializers.ModelSerializer):
class Meta:
model = ContestWork
fields = '__all__'
def to_representation(self, instance):
return ContestWorkSerializer(instance=instance, context=self.context).to_representation(instance)

@ -1,6 +1,10 @@
from ipware import get_client_ip
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueValidator from rest_framework.validators import UniqueValidator
from django.contrib.auth import get_user_model
from apps.course.models import ( from apps.course.models import (
Category, Course, Category, Course,
Comment, CourseComment, LessonComment, Comment, CourseComment, LessonComment,
@ -15,12 +19,15 @@ from .content import (
from apps.content.models import ( from apps.content.models import (
Content, Image, Text, ImageText, Video, Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject, Gallery, GalleryImage, ImageObject,
) ContestWork)
from .user import UserSerializer from .user import UserSerializer
from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin
User = get_user_model()
class MaterialCreateSerializer(serializers.ModelSerializer): class MaterialCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@ -63,6 +70,41 @@ class LikeSerializer(serializers.ModelSerializer):
) )
class LikeCreateSerializer(serializers.ModelSerializer):
OBJ_TYPE_CONTEST_WORK = 'contest_work'
obj_type = serializers.CharField(required=True)
obj_id = serializers.IntegerField(required=True)
class Meta:
model = Like
fields = ['user', 'obj_type', 'obj_id']
def create(self, validated_data):
# FIXME
if validated_data.get('user'):
user = validated_data.get('user')
else:
user = self.context['request'].user
if not user: # FIXME and user.is_authenticated):
return Like()
obj_type = validated_data.pop('obj_type')
obj_id = validated_data.pop('obj_id')
client_ip, is_routable = get_client_ip(self.context['request'])
if obj_type == self.OBJ_TYPE_CONTEST_WORK:
contest_work = ContestWork.objects.get(pk=obj_id)
# FIXME in prod: fixed
if contest_work.user == user or contest_work.likes.filter(user=user).exists():
# if contest_work.likes.filter(user=user).exists():
return Like()
like = Like.objects.create(user=user, ip=client_ip)
contest_work.likes.add(like)
return like
def to_representation(self, instance):
return LikeSerializer(instance, context=self.context).to_representation(instance)
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
class Meta: class Meta:

@ -4,7 +4,7 @@ from apps.school.models import LiveLesson
from apps.content.models import ( from apps.content.models import (
Content, Image, Text, ImageText, Video, Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject, Gallery, GalleryImage, ImageObject,
) Contest)
from .content import ( from .content import (
TextCreateSerializer, ImageCreateSerializer, TextCreateSerializer, ImageCreateSerializer,
ImageTextCreateSerializer, VideoCreateSerializer, ImageTextCreateSerializer, VideoCreateSerializer,
@ -25,6 +25,8 @@ class DispatchContentMixin(object):
obj_type = 'lesson' obj_type = 'lesson'
elif isinstance(obj, LiveLesson): elif isinstance(obj, LiveLesson):
obj_type = 'live_lesson' obj_type = 'live_lesson'
elif isinstance(obj, Contest):
obj_type = 'contest'
cdata[obj_type] = obj.id cdata[obj_type] = obj.id
if ctype == 'text': if ctype == 'text':
if 'id' in cdata and cdata['id']: if 'id' in cdata and cdata['id']:

@ -18,7 +18,7 @@ from .views import (
UserViewSet, LessonViewSet, ImageObjectViewSet, UserViewSet, LessonViewSet, ImageObjectViewSet,
SchoolScheduleViewSet, LiveLessonViewSet, SchoolScheduleViewSet, LiveLessonViewSet,
PaymentViewSet, PaymentViewSet,
) ContestViewSet, ContestWorkViewSet)
router = DefaultRouter() router = DefaultRouter()
router.register(r'author-requests', AuthorRequestViewSet, base_name='author-requests') router.register(r'author-requests', AuthorRequestViewSet, base_name='author-requests')
@ -44,6 +44,9 @@ router.register(r'school-schedules', SchoolScheduleViewSet, base_name='school-sc
router.register(r'users', UserViewSet, base_name='users') router.register(r'users', UserViewSet, base_name='users')
router.register(r'contests', ContestViewSet, base_name='contests')
router.register(r'contest-works', ContestWorkViewSet, base_name='contest_works')
# router.register(r'configs', ConfigViewSet, base_name='configs') # router.register(r'configs', ConfigViewSet, base_name='configs')

@ -14,7 +14,7 @@ from .serializers.course import (
CommentSerializer, CommentSerializer,
MaterialSerializer, MaterialCreateSerializer, MaterialSerializer, MaterialCreateSerializer,
LessonSerializer, LessonCreateSerializer, LessonSerializer, LessonCreateSerializer,
) LikeCreateSerializer)
from .serializers.content import ( from .serializers.content import (
BanerSerializer, BanerSerializer,
ImageSerializer, ImageCreateSerializer, ImageSerializer, ImageCreateSerializer,
@ -39,6 +39,9 @@ from .serializers.user import (
AuthorRequestSerializer, AuthorRequestSerializer,
UserSerializer, UserPhotoSerializer, UserSerializer, UserPhotoSerializer,
) )
from .serializers.contest import (
ContestCreateSerializer, ContestSerializer, ContestWorkSerializer, ContestWorkCreateSerializer
)
from .permissions import ( from .permissions import (
IsAdmin, IsAdminOrIsSelf, IsAdmin, IsAdminOrIsSelf,
@ -56,7 +59,7 @@ from apps.config.models import Config
from apps.content.models import ( from apps.content.models import (
Baner, Image, Text, ImageText, Video, Baner, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject, Gallery, GalleryImage, ImageObject,
) Contest, ContestWork)
from apps.payment.models import ( from apps.payment.models import (
AuthorBalance, Payment, AuthorBalance, Payment,
CoursePayment, SchoolPayment, CoursePayment, SchoolPayment,
@ -110,6 +113,8 @@ class BanerViewSet(ExtendedModelViewSet):
class ImageObjectViewSet(ExtendedModelViewSet): class ImageObjectViewSet(ExtendedModelViewSet):
queryset = ImageObject.objects.all() queryset = ImageObject.objects.all()
serializer_class = ImageObjectSerializer serializer_class = ImageObjectSerializer
# FIXME
authentication_classes = []
# permission_classes = (IsAuthorOrAdmin,) # permission_classes = (IsAuthorOrAdmin,)
@ -127,10 +132,16 @@ class MaterialViewSet(ExtendedModelViewSet):
class LikeViewSet(ExtendedModelViewSet): class LikeViewSet(ExtendedModelViewSet):
queryset = Like.objects.select_related('user').all() queryset = Like.objects.select_related('user').all()
serializer_class = LikeSerializer serializer_class = LikeCreateSerializer
serializer_class_map = {
'list': LikeSerializer,
'retrieve': LikeSerializer,
}
search_fields = ('user__email', 'user__firstname', 'user__lastname',) search_fields = ('user__email', 'user__firstname', 'user__lastname',)
ordering_fields = ('created_at', 'update_at',) ordering_fields = ('created_at', 'update_at',)
# permission_classes = (IsAdmin,) # permission_classes = (IsAdmin,)
# FIXME
authentication_classes = []
class CategoryViewSet(ExtendedModelViewSet): class CategoryViewSet(ExtendedModelViewSet):
@ -419,10 +430,10 @@ class AuthorRequestViewSet(ExtendedModelViewSet):
class PaymentViewSet(ExtendedModelViewSet): class PaymentViewSet(ExtendedModelViewSet):
queryset = Payment.objects.filter(status__isnull=False).order_by('-created_at') queryset = Payment.objects.all()
serializer_class = PaymentSerializer serializer_class = PaymentSerializer
permission_classes = (IsAdmin,) permission_classes = (IsAdmin,)
filter_fields = ('status',) filter_fields = ('status', 'user',)
ordering_fields = ( ordering_fields = (
'id', 'user__email', 'id', 'user__email',
'user__first_name', 'user__last_name', 'user__first_name', 'user__last_name',
@ -430,6 +441,17 @@ class PaymentViewSet(ExtendedModelViewSet):
) )
search_fields = ('user__email', 'user__first_name', 'user__last_name',) search_fields = ('user__email', 'user__first_name', 'user__last_name',)
def get_queryset(self):
queryset = self.queryset
course = self.request.query_params.get('course')
weekdays = self.request.query_params.getlist('weekdays[]')
if course:
queryset = CoursePayment.objects.filter(course=course)
if weekdays:
queryset = SchoolPayment.objects.filter(weekdays__overlap=weekdays)
return queryset.filter(status__isnull=False).order_by('-created_at')
@action(methods=['get'], detail=False, url_path='calc-amount') @action(methods=['get'], detail=False, url_path='calc-amount')
def calc_amount(self, request, pk=None): def calc_amount(self, request, pk=None):
user = request.query_params.get('user') user = request.query_params.get('user')
@ -439,3 +461,34 @@ class PaymentViewSet(ExtendedModelViewSet):
course = course and Course.objects.get(pk=course) course = course and Course.objects.get(pk=course)
return Response(Payment.calc_amount(user=user, course=course, weekdays=weekdays)) return Response(Payment.calc_amount(user=user, course=course, weekdays=weekdays))
class ContestViewSet(ExtendedModelViewSet):
queryset = Contest.objects.all()
serializer_class = ContestCreateSerializer
serializer_class_map = {
'list': ContestSerializer,
'retrieve': ContestSerializer,
}
filter_fields = ('active',)
search_fields = ('description', 'title', 'slug',)
ordering_fields = ('id', 'title', 'active', 'date_start', 'date_end',)
permission_classes = (IsAdmin,)
class ContestWorkViewSet(ExtendedModelViewSet):
queryset = ContestWork.objects.all()
serializer_class = ContestWorkCreateSerializer
serializer_class_map = {
'list': ContestWorkSerializer,
'retrieve': ContestWorkSerializer,
}
filter_fields = ('contest',)
# FIXME
authentication_classes = []
def create(self, request, *args, **kwargs):
# FIXME
user = User.objects.get(pk=request.data.get('user'))
if ContestWork.objects.filter(user=user).exists():
return Response(status=status.HTTP_400_BAD_REQUEST)
return super().create(request, *args, **kwargs)

@ -0,0 +1,17 @@
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
User = get_user_model()
class CaseInsensitiveModelBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
if username is None:
username = kwargs.get(User.USERNAME_FIELD)
try:
user = User.objects.get(**{f'{User.USERNAME_FIELD}__iexact': username})
if user.check_password(password) and self.user_can_authenticate(user):
return user
except User.DoesNotExist:
return None

@ -6,8 +6,3 @@ class LearnerRegistrationForm(forms.Form):
last_name = forms.CharField() last_name = forms.CharField()
email = forms.EmailField() email = forms.EmailField()
password = forms.CharField() password = forms.CharField()
class LoginForm(forms.Form):
email = forms.CharField()
password = forms.CharField()

@ -35,7 +35,7 @@ class LearnerRegistrationView(FormView):
config = Config.load() config = Config.load()
first_name = form.cleaned_data['first_name'] first_name = form.cleaned_data['first_name']
last_name = form.cleaned_data['last_name'] last_name = form.cleaned_data['last_name']
email = form.cleaned_data['email'] email = form.cleaned_data['email'].lower()
password = form.cleaned_data['password'] password = form.cleaned_data['password']
user, created = User.objects.get_or_create( user, created = User.objects.get_or_create(
@ -179,8 +179,9 @@ class FacebookLoginOrRegistration(View):
"errors": {"email": 'is field required'} "errors": {"email": 'is field required'}
}) })
else: else:
email = email.lower()
try: try:
user = User.objects.get(email=email) user = User.objects.get(email__iexact=email)
except User.DoesNotExist: except User.DoesNotExist:
first_name = data.get('first_name', '') first_name = data.get('first_name', '')
last_name = data.get('last_name', '') last_name = data.get('last_name', '')

@ -0,0 +1,48 @@
# Generated by Django 2.0.6 on 2018-08-13 13:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0040_course_age'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('content', '0020_auto_20180424_1607'),
]
operations = [
migrations.CreateModel(
name='Contest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='', max_length=1000)),
('slug', models.SlugField(allow_unicode=True, blank=True, max_length=100, null=True, unique=True)),
('date_start', models.DateField(blank=True, null=True, verbose_name='Дата начала')),
('date_end', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
('active', models.BooleanField(default=True)),
('cover', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contest_covers', to='content.ImageObject', verbose_name='Фоновая картинка')),
],
),
migrations.CreateModel(
name='ContestWork',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('child_full_name', models.CharField(max_length=255)),
('age', models.SmallIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.Contest')),
('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_work_images', to='content.ImageObject', verbose_name='Работа участника')),
('likes', models.ManyToManyField(blank=True, to='course.Like')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='content',
name='contest',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content', to='content.Contest', verbose_name='Конкурс'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.6 on 2018-08-15 21:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('content', '0021_auto_20180813_1306'),
]
operations = [
migrations.AlterModelOptions(
name='contestwork',
options={'ordering': ('-created_at',), 'verbose_name': 'Конкурсная работа', 'verbose_name_plural': 'Конкурсные работы'},
),
]

@ -1,11 +1,22 @@
from urllib.parse import urlparse from urllib.parse import urlparse
from django.db import models from django.db import models
from django.contrib.auth import get_user_model
from django.urls import reverse_lazy
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToCover
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
User = get_user_model()
class ImageObject(models.Model): class ImageObject(models.Model):
image = models.ImageField('Изображение', upload_to='content/imageobject') image = models.ImageField('Изображение', upload_to='content/imageobject')
image_thumbnail = ImageSpecField(source='image',
processors=[ResizeToCover(300, 200, False)],
format='JPEG',
options={'quality': 85})
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True) update_at = models.DateTimeField(auto_now=True)
@ -36,6 +47,12 @@ class Content(PolymorphicModel):
verbose_name='Урок онлайн школы', verbose_name='Урок онлайн школы',
related_name='content', related_name='content',
) )
contest = models.ForeignKey(
'Contest', on_delete=models.CASCADE,
null=True, blank=True,
verbose_name='Конкурс',
related_name='content',
)
title = models.CharField('Заголовок', max_length=100, default='') title = models.CharField('Заголовок', max_length=100, default='')
position = models.PositiveSmallIntegerField( position = models.PositiveSmallIntegerField(
'Положение на странице', 'Положение на странице',
@ -131,3 +148,57 @@ class Baner(models.Model):
if self.use: if self.use:
Baner.objects.filter(use=True).update(use=False) Baner.objects.filter(use=True).update(use=False)
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
class Contest(models.Model):
title = models.CharField(max_length=255)
description = models.TextField(max_length=1000, blank=True, default='')
slug = models.SlugField(
allow_unicode=True, null=True, blank=True,
max_length=100, unique=True, db_index=True,
)
cover = models.ForeignKey(
ImageObject, related_name='contest_covers',
verbose_name='Фоновая картинка', on_delete=models.CASCADE,
null=True, blank=True,
)
date_start = models.DateField('Дата начала', null=True, blank=True)
date_end = models.DateField('Дата окончания', null=True, blank=True)
active = models.BooleanField(default=True)
# TODO? baner
def save(self, *args, **kwargs):
if self.active:
Contest.objects.filter(active=True).update(active=False)
return super().save(*args, **kwargs)
class ContestWork(models.Model):
user = models.ForeignKey(
User, on_delete=models.CASCADE
)
contest = models.ForeignKey(Contest, on_delete=models.CASCADE)
image = models.ForeignKey(
ImageObject, related_name='contest_work_images',
verbose_name='Работа участника', on_delete=models.CASCADE,
)
child_full_name = models.CharField(max_length=255)
age = models.SmallIntegerField()
created_at = models.DateTimeField(auto_now_add=True)
likes = models.ManyToManyField('course.Like', blank=True)
class Meta:
verbose_name = 'Конкурсная работа'
verbose_name_plural = 'Конкурсные работы'
ordering = ('-created_at',)
@property
def img_width(self):
return self.image.image.width if self.image and self.image.image else None
@property
def img_height(self):
return self.image.image.height if self.image and self.image.image else None
def get_absolute_url(self):
return reverse_lazy('contest_work', args=[self.id])

@ -0,0 +1,47 @@
{% extends "templates/lilcity/index.html" %}
{% block content %}
<upload-contest-work contest-id="{{ contest.id }}"></upload-contest-work>
<div class="main main_default" {% if contest.cover %}style="background-image: url({{ contest.cover.image.url }});"{% endif %}>
<div class="main__center center">
<div class="main__title">
{{ contest.title }}
</div>
<div class="main__subtitle">
{{ contest.description }}
</div>
<div class="main__actions">
{% if not contest_work_uploaded %}
<a class="main__btn btn" href=""
{% if request.user.is_authenticated %}data-show-upload-contest-work
{% else %}data-popup=".js-popup-auth"{% endif %}>Загрузить свою работу</a>
{% endif %}
</div>
</div>
</div>
<div style="text-align: center;">
{% for content in contest.content.all %}
{% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %}
{% endwith %}
{% endfor %}
<div class="section">
<div class="section__center center">
<a id="gallery" name="gallery">
<div class="title title_center">Галерея</div>
</a>
<div class="text">
{% if not contest_work_uploaded %}
<a class="btn" href=""
{% if request.user.is_authenticated %}data-show-upload-contest-work
{% else %}data-popup=".js-popup-auth"{% endif %}>Загрузить свою работу</a>
{% endif %}
</div>
<contest-works contest-id="{{ contest.id }}" autoload="true"></contest-works>
</div>
</div>
</div>
{% endblock content %}

@ -0,0 +1,18 @@
{% extends "templates/lilcity/edit_index.html" %}
{% load static %}
{% block title %}
{% if object %}
Редактирование конкурса {{ object.title }}
{% else %}
Создание конкурса
{% endif %}
{% endblock title %}
{% block content %}
<contest-redactor {% if object and object.id %}:contest-id="{{ object.id }}"{% endif %}></contest-redactor>
{% endblock content %}
{% block foot %}
<script type="text/javascript" src="{% static 'contestRedactor.js' %}"></script>
<link rel="stylesheet" href="{% static 'contestRedactor.css' %}" />
{% endblock foot %}

@ -0,0 +1,112 @@
{% extends "templates/lilcity/index.html" %}
{% load static %}
{% block title %}{{ contest_work.child_full_name }}, {{ contest_work.age }} лет{% endblock title %}
{% block ogimage %}http://{{request.META.HTTP_HOST}}{{ contest_work.image.image.url }}{% endblock ogimage %}
{% block content %}
<div class="section" style="padding-bottom: 25px;">
<div class="section__center center center_sm">
<div class="go">
<a class="go__item" href="{% url 'contest' contest_work.contest.slug %}">
<div class="go__arrow">
<svg class="icon icon-arrow-left">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-arrow-left"></use>
</svg>
</div>
<div class="go__title">Вернуться к&nbsp;галерее</div>
</a>
</div>
</div>
<div class="contest-work section__center center center_sm">
<div class="contest-work__img-wrap">
<img class="contest-work__img" src="{{ contest_work.image.image.url }}">
</div>
<div class="contest-work__info">
<div class="contest-work__bio">
<div>{{ contest_work.child_full_name }}</div>
<div class="contest-work__age">{{ contest_work.age }} {% if contest_work.age < 5 %}года{% else %}лет{% endif %}</div>
</div>
<div class="contest-work__likes">
<likes obj-type="contest_work" obj-id="{{ contest_work.id }}"
{% if user_liked %}:user-liked="true"{% endif %} likes="{{ contest_work.likes.count }}"></likes>
</div>
</div>
</div>
</div>
<div class="section" style="padding: 0;">
<div class="section__center center center_sm">
<div class="go">
{% if prev_contest_work %}
<a class="go__item" href="{% url 'contest_work' prev_contest_work.id %}">
<div class="go__arrow">
<svg class="icon icon-arrow-left">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-arrow-left"></use>
</svg>
</div>
<div class="go__title">Предыдущая работа</div>
</a>
{% else %}
<div class="go__item"></div>
{% endif %}
{% if next_contest_work %}
<a class="go__item" href="{% url 'contest_work' next_contest_work.id %}">
<div class="go__title">Следующая работа</div>
<div class="go__arrow">
<svg class="icon icon-arrow-right">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-arrow-right"></use>
</svg>
</div>
</a>
{% else %}
<div class="go__item"></div>
{% endif %}
</div>
</div>
</div>
<div class="section">
<div class="section__center center center_sm">
{% include 'templates/blocks/share.html' with share_object_name='работой' %}
</div>
</div>
<div class="section section_gray">
<div class="section__center center center_sm">
<div class="title">Оставьте комментарий:</div>
<div class="questions">
{% if request.user.is_authenticated %}
<form class="questions__form" method="post" action="{% url 'contest_work_comment' contest_work_id=contest_work.id %}">
<input type="hidden" name="reply_id">
<div class="questions__ava ava">
<img
class="ava__pic"
{% if request.user.photo %}
src="{{ request.user.photo.url }}"
{% else %}
src="{% static 'img/user_default.jpg' %}"
{% endif %}
>
</div>
<div class="questions__wrap">
<div class="questions__reply-info">В ответ на
<a href="" class="questions__reply-anchor">этот комментарий</a>.
<a href="#" class="questions__reply-cancel grey-link">Отменить</a>
</div>
<div class="questions__field">
<textarea class="questions__textarea"></textarea>
</div>
<button class="questions__btn btn btn_light">ОТПРАВИТЬ</button>
</div>
</form>
{% else %}
<div>Только зарегистрированные пользователи могут оставлять комментарии.</div>
{% endif %}
<div class="questions__list">
{% include "templates/blocks/comments.html" with object=contest_work %}
</div>
</div>
</div>
</div>
{% endblock content %}

@ -1,2 +1,110 @@
from django.shortcuts import render from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.template import loader
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView, DetailView
from apps.content.models import Contest, ContestWork
from apps.course.models import ContestWorkComment
@method_decorator(login_required, name='dispatch')
class ContestEditView(TemplateView):
template_name = 'content/contest_edit.html'
def get(self, request, pk=None, lesson=None):
if pk:
self.object = get_object_or_404(Contest, pk=pk)
else:
self.object = Contest()
return super().get(request)
def get_context_data(self):
context = super().get_context_data()
context['object'] = self.object
return context
class ContestView(DetailView):
model = Contest
context_object_name = 'contest'
template_name = 'content/contest.html'
query_pk_and_slug = True
def get_context_data(self, *args, **kwargs):
context = super().get_context_data()
if self.request.user.is_authenticated:
context['contest_work_uploaded'] = ContestWork.objects.filter(user=self.request.user).exists()
return context
class ContestWorkView(DetailView):
model = ContestWork
context_object_name = 'contest_work'
template_name = 'content/contest_work.html'
def get_context_data(self, *args, **kwargs):
context = super().get_context_data()
prev_contest_work = ContestWork.objects.filter(created_at__gt=self.object.created_at)[:1]
if prev_contest_work:
context['prev_contest_work'] = prev_contest_work[0]
next_contest_work = ContestWork.objects.filter(created_at__lt=self.object.created_at)[:1]
if next_contest_work:
context['next_contest_work'] = next_contest_work[0]
context['user_liked'] = self.object.likes.filter(user=self.request.user).exists() \
if self.request.user.is_authenticated else False
return context
@login_required
@csrf_exempt
@require_http_methods(['POST'])
def contest_work_comment(request, contest_work_id):
try:
contest_work = ContestWork.objects.get(id=contest_work_id)
except ContestWork.DoesNotExist:
return JsonResponse({
'success': False,
'errors': ['Contest_work with id f{contest_work_id} not found']
}, status=400)
else:
reply_to = request.POST.get('reply_id', 0)
comment = request.POST.get('comment', '')
if not comment:
return JsonResponse({
'success': False,
'errors': ['Comment can not be empty']
}, status=400)
if not int(reply_to):
contest_work_comment = ContestWorkComment.objects.create(
author=request.user,
content=comment,
contest_work=contest_work,
)
else:
try:
_contest_work_comment = ContestWorkComment.objects.get(id=reply_to)
except ContestWorkComment.DoesNotExist:
return JsonResponse({
'success': False,
'errors': ['LessonComment with id f{reply_to} not found']
}, status=400)
else:
contest_work_comment = ContestWorkComment.objects.create(
author=request.user,
content=comment,
contest_work=contest_work,
parent=_contest_work_comment,
)
ctx = {'node': contest_work_comment, 'user': request.user}
html = loader.render_to_string('templates/blocks/comment.html', ctx)
return JsonResponse({
'success': True,
'comment': html,
})

@ -4,7 +4,7 @@ from .models import Course
class CourseFilter(django_filters.FilterSet): class CourseFilter(django_filters.FilterSet):
category = django_filters.CharFilter(field_name='category__title', lookup_expr='iexact') category = django_filters.CharFilter(field_name='category')
class Meta: class Meta:
model = Course model = Course

@ -0,0 +1,18 @@
# Generated by Django 2.0.6 on 2018-08-08 01:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0039_lesson_position'),
]
operations = [
migrations.AddField(
model_name='course',
name='age',
field=models.SmallIntegerField(choices=[(0, ''), (1, 'до 5'), (2, '5-7'), (3, '7-9'), (4, '9-12'), (5, '12-15'), (6, '15-18'), (7, 'от 18')], default=0),
),
]

@ -0,0 +1,36 @@
# Generated by Django 2.0.6 on 2018-08-13 13:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0021_auto_20180813_1306'),
('course', '0040_course_age'),
]
operations = [
migrations.CreateModel(
name='ContestWorkComment',
fields=[
('comment_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='course.Comment')),
('contest_work', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='content.ContestWork')),
],
options={
'ordering': ('tree_id', 'lft'),
'abstract': False,
'base_manager_name': 'objects',
},
bases=('course.comment',),
),
migrations.AlterModelOptions(
name='course',
options={'ordering': ['-is_featured'], 'verbose_name': 'Курс', 'verbose_name_plural': 'Курсы'},
),
migrations.RemoveField(
model_name='course',
name='age',
),
]

@ -0,0 +1,18 @@
# Generated by Django 2.0.6 on 2018-08-16 16:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0041_auto_20180813_1306'),
]
operations = [
migrations.AddField(
model_name='like',
name='ip',
field=models.GenericIPAddressField(blank=True, null=True),
),
]

@ -1,4 +1,5 @@
import arrow import arrow
from random import shuffle
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -7,19 +8,21 @@ from django.utils.text import slugify
from django.utils.timezone import now from django.utils.timezone import now
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.conf import settings
from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey
from project.mixins import BaseModel, DeactivatedMixin from project.mixins import BaseModel, DeactivatedMixin
from .manager import CategoryQuerySet from .manager import CategoryQuerySet
from apps.content.models import ImageObject, Gallery, Video from apps.content.models import ImageObject, Gallery, Video, ContestWork
User = get_user_model() User = get_user_model()
class Like(models.Model): class Like(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
ip = models.GenericIPAddressField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True) update_at = models.DateTimeField(auto_now=True)
@ -97,7 +100,7 @@ class Course(BaseModel, DeactivatedMixin):
class Meta: class Meta:
verbose_name = 'Курс' verbose_name = 'Курс'
verbose_name_plural = 'Курсы' verbose_name_plural = 'Курсы'
ordering = ['-created_at'] ordering = ['-is_featured', ]
def __str__(self): def __str__(self):
return str(self.id) + ' ' + self.title return str(self.id) + ' ' + self.title
@ -129,11 +132,11 @@ class Course(BaseModel, DeactivatedMixin):
@property @property
def deferred_start_at_humanize(self): def deferred_start_at_humanize(self):
return arrow.get(self.deferred_start_at).humanize(locale='ru') return arrow.get(self.deferred_start_at, settings.TIME_ZONE).humanize(locale='ru')
@property @property
def created_at_humanize(self): def created_at_humanize(self):
return arrow.get(self.created_at).humanize(locale='ru') return arrow.get(self.created_at, settings.TIME_ZONE).humanize(locale='ru')
@property @property
def is_deferred_start(self): def is_deferred_start(self):
@ -148,6 +151,24 @@ class Course(BaseModel, DeactivatedMixin):
def count_videos_in_lessons(self): def count_videos_in_lessons(self):
return Video.objects.filter(lesson__in=self.lessons.all()).count() return Video.objects.filter(lesson__in=self.lessons.all()).count()
@classmethod
def shuffle(cls, qs, order_is_featured = True):
if order_is_featured:
featured = []
other = []
for c in list(qs):
if c.is_featured:
featured.append(c)
else:
other.append(c)
shuffle(featured)
shuffle(other)
return featured + other
else:
all = list(qs)
shuffle(all)
return all
class Category(models.Model): class Category(models.Model):
title = models.CharField('Название категории', max_length=100) title = models.CharField('Название категории', max_length=100)
@ -234,7 +255,7 @@ class Comment(PolymorphicMPTTModel, DeactivatedMixin):
@property @property
def created_at_humanize(self): def created_at_humanize(self):
return arrow.get(self.created_at).humanize(locale='ru') return arrow.get(self.created_at, settings.TIME_ZONE).humanize(locale='ru')
def __str__(self): def __str__(self):
return self.content return self.content
@ -264,3 +285,7 @@ class LessonComment(Comment):
class Meta(Comment.Meta): class Meta(Comment.Meta):
verbose_name = 'Комментарий урока' verbose_name = 'Комментарий урока'
verbose_name_plural = 'Комментарии уроков' verbose_name_plural = 'Комментарии уроков'
class ContestWorkComment(Comment):
contest_work = models.ForeignKey(ContestWork, on_delete=models.CASCADE, related_name='comments')

@ -48,7 +48,7 @@
</a> </a>
<div class="courses__details"> <div class="courses__details">
<a class="courses__theme theme {{ theme_color }}" <a class="courses__theme theme {{ theme_color }}"
href="{% url 'courses' %}?category={{ course.category.title }}">{{ course.category | upper }}</a> href="{% url 'courses' %}?category={{ course.category.id }}">{{ course.category | upper }}</a>
{% if not course.is_free %} {% if not course.is_free %}
<div class="courses__price">{{ course.price|floatformat:"-2" }}₽</div> <div class="courses__price">{{ course.price|floatformat:"-2" }}₽</div>
{% endif %} {% endif %}

@ -227,7 +227,7 @@
</div> </div>
{% for content in course.content.all %} {% for content in course.content.all %}
{% with template="course/content/"|add:content.ctype|add:".html" %} {% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %} {% include template %}
{% endwith %} {% endwith %}

@ -180,19 +180,18 @@
<a href="#"> <a href="#">
{% endif %} {% endif %}
<div class="lessons__item"> <div class="lessons__item">
<div class="lessons__subtitle subtitle">{{ forloop.counter }}. {{ lesson.title }}</div>
<div class="lessons__row"> <div class="lessons__row">
{% if lesson.cover %}
<div class="lessons__preview"> <div class="lessons__preview">
<div class="lessons__pic-wrapper"> <div class="lessons__pic-wrapper">
<img class="lessons__pic" src="{{ lesson.cover.image.url }}"> <img class="lessons__pic"
src="{% if lesson.cover %}{{ lesson.cover.image.url }}{% else %}{% static 'img/no_cover.png' %}{% endif %}">
</div> </div>
</div> </div>
{% endif %} <div>
<div class="lessons__content">{{ lesson.short_description | truncatechars_html:800 | safe | linebreaks }}</div> <div class="lessons__subtitle subtitle">{{ forloop.counter }}. {{ lesson.title }}</div>
</div> <div class="lessons__content">{{ lesson.short_description | truncatechars_html:800 | safe }}</div>
<div class="lessons__row"> <a href="{% url 'lesson' pk=lesson.id %}" class="btn btn_stroke">Перейти к уроку</a>
<a href="{% url 'lesson' pk=lesson.id %}" class="btn btn_stroke">Перейти к уроку</a> </div>
</div> </div>
</div> </div>
</a> </a>

@ -20,8 +20,8 @@
<div class="head__right"> <div class="head__right">
<div class="head__field field"> <div class="head__field field">
<div class="field__wrap"> <div class="field__wrap">
<div class="field__select select js-select{% if category %} selected{% endif %}"> <div class="field__select select js-select{% if category %} selected{% endif %}" data-category-select>
<div class="select__head js-select-head">{% if category %}{{ category.0 }}{% else %}Категории{% endif %}</div> <div class="select__head js-select-head">Категории</div>
<div class="select__drop js-select-drop"> <div class="select__drop js-select-drop">
<div class="select__option js-select-option{% if not category %} active{% endif %}" <div class="select__option js-select-option{% if not category %} active{% endif %}"
data-category-option data-category-url="{% url 'courses' %}"> data-category-option data-category-url="{% url 'courses' %}">

@ -1,5 +1,6 @@
{% for cat in category_items %} {% for cat in category_items %}
<div class="select__option js-select-option{% if category and category.0 == cat.title %} active{% endif %}" data-category-option data-category-name="{{ cat.title }}" data-category-url="{% url 'courses' %}?category={{ cat.title }}"> <div class="select__option js-select-option{% if category == cat.id %} active{% endif %}"
data-category-option data-category-name="{{ cat.title }}" data-category-url="{% url 'courses' %}?category={{ cat.id }}">
<div class="select__title">{{ cat.title }}</div> <div class="select__title">{{ cat.title }}</div>
</div> </div>
{% endfor %} {% endfor %}

@ -2,7 +2,7 @@
<div class="header__title">Все курсы</div> <div class="header__title">Все курсы</div>
</a> </a>
{% for cat in category_items %} {% for cat in category_items %}
<a class="header__link{% if category.0 == cat.title %} active{% endif %}" data-category-name="{{ cat.title }}" href="{% url 'courses' %}?category={{ cat.title }}"> <a class="header__link{% if category == cat.id %} active{% endif %}" data-category-name="{{ cat.title }}" href="{% url 'courses' %}?category={{ cat.id }}">
<div class="header__title">{{ cat.title }}</div> <div class="header__title">{{ cat.title }}</div>
</a> </a>
{% endfor %} {% endfor %}

@ -27,24 +27,43 @@
{% endif %} {% endif %}
</div> </div>
<div class="lesson"> <div class="lesson">
<div class="lesson__subtitle subtitle">{{ lesson.title }}</div> <div class="lesson__row">
<div class="lesson__content">{{ lesson.short_description }}</div> <div class="lesson__preview">
{% comment %} <a class="lesson__video video" href="#"> <div class="lesson__pic-wrapper">
{% if lesson.cover %} <img class="lesson__pic"
<img class="video__pic" src="{{ lesson.cover.image.url }}"/> src="{% if lesson.cover %}{{ lesson.cover.image.url }}{% else %}{% static 'img/no_cover.png' %}{% endif %}">
{% else %} </div>
</div>
{% endif %} <div>
<svg class="icon icon-play"> <div class="lesson__subtitle subtitle">{{ lesson.title }}</div>
<use xlink:href="{% static 'img/sprite.svg' %}#icon-play"></use> <div class="lesson__content">{{ lesson.short_description }}</div>
</svg> <a href="{% url 'user' lesson.author.id %}">
</a> {% endcomment %} <div class="lesson__user user">
{% if lesson.author.photo %}
<div class="user__ava ava">
<img class="ava__pic" src="{{ lesson.author.photo.url }}">
</div>
{% else %}
<div class="user__ava ava">
<img class="ava__pic" src="{% static 'img/user_default.jpg' %}">
</div>
{% endif %}
<div class="user__info">
<div class="user__name">{{ lesson.author.get_full_name }}</div>
<div class="user__meta">
<div class="user__date">{{ lesson.created_at_humanize }}</div>
</div>
</div>
</div>
</a>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{% for content in lesson.content.all %} {% for content in lesson.content.all %}
{% with template="course/content/"|add:content.ctype|add:".html" %} {% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %} {% include template %}
{% endwith %} {% endwith %}
@ -98,6 +117,7 @@
<div class="questions"> <div class="questions">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<form class="questions__form" method="post" action="{% url 'lessoncomment' lesson_id=lesson.id %}"> <form class="questions__form" method="post" action="{% url 'lessoncomment' lesson_id=lesson.id %}">
<input type="hidden" name="reply_id">
<div class="questions__ava ava"> <div class="questions__ava ava">
<img <img
class="ava__pic" class="ava__pic"
@ -109,6 +129,10 @@
> >
</div> </div>
<div class="questions__wrap"> <div class="questions__wrap">
<div class="questions__reply-info">В ответ на
<a href="" class="questions__reply-anchor">этот комментарий</a>.
<a href="#" class="questions__reply-cancel grey-link">Отменить</a>
</div>
<div class="questions__field"> <div class="questions__field">
<textarea class="questions__textarea" placeholder="Спросите автора курса интересующие вас вопросы"></textarea> <textarea class="questions__textarea" placeholder="Спросите автора курса интересующие вас вопросы"></textarea>
</div> </div>

@ -9,7 +9,7 @@ register = template.Library()
def category_items(category=None): def category_items(category=None):
return { return {
'category_items': Category.objects.filter(courses__status=Course.PUBLISHED).exclude(courses=None).distinct(), 'category_items': Category.objects.filter(courses__status=Course.PUBLISHED).exclude(courses=None).distinct(),
'category': category, 'category': int(category[0]) if category and category[0] else None,
} }
@ -17,5 +17,5 @@ def category_items(category=None):
def category_menu_items(category=None): def category_menu_items(category=None):
return { return {
'category_items': Category.objects.filter(courses__status=Course.PUBLISHED).exclude(courses=None).distinct(), 'category_items': Category.objects.filter(courses__status=Course.PUBLISHED).exclude(courses=None).distinct(),
'category': category, 'category': int(category[0]) if category and category[0] else None,
} }

@ -241,7 +241,6 @@ class CoursesView(ListView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object_list = self.get_queryset() self.object_list = self.get_queryset()
allow_empty = self.get_allow_empty()
if request.is_ajax(): if request.is_ajax():
context = self.get_context_data() context = self.get_context_data()
template_name = self.get_template_names() template_name = self.get_template_names()
@ -283,6 +282,7 @@ class CoursesView(ListView):
context = super().get_context_data() context = super().get_context_data()
filtered = CourseFilter(self.request.GET) filtered = CourseFilter(self.request.GET)
context.update(filtered.data) context.update(filtered.data)
context['course_items'] = Course.shuffle(context.get('course_items'))
return context return context
def get_template_names(self): def get_template_names(self):

@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField, JSONField from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.utils.timezone import now from django.utils.timezone import now
from django.conf import settings
from project.utils import weekday_in_date_range from project.utils import weekday_in_date_range
@ -245,4 +246,4 @@ class SchoolPayment(Payment):
@property @property
def date_end_humanize(self): def date_end_humanize(self):
return arrow.get(self.date_end).humanize(locale='ru') return arrow.get(self.date_end, settings.TIME_ZONE).humanize(locale='ru')

@ -17,6 +17,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.timezone import now from django.utils.timezone import now
from django.conf import settings
from paymentwall import Pingback, Product, Widget from paymentwall import Pingback, Product, Widget
@ -196,12 +197,18 @@ class PaymentwallCallbackView(View):
payment.status = pingback.get_type() payment.status = pingback.get_type()
payment.data = payment_raw_data payment.data = payment_raw_data
if pingback.is_deliverable(): if pingback.is_deliverable():
effective_amount = pingback.get_parameter('effective_price_amount')
if effective_amount:
payment.amount = effective_amount
transaction_to_mixpanel.delay( transaction_to_mixpanel.delay(
payment.user.id, payment.user.id,
payment.amount, payment.amount,
now().strftime('%Y-%m-%dT%H:%M:%S'), now().strftime('%Y-%m-%dT%H:%M:%S'),
product_type_name, product_type_name,
) )
if product_type_name == 'school': if product_type_name == 'school':
school_payment = SchoolPayment.objects.filter( school_payment = SchoolPayment.objects.filter(
user=payment.user, user=payment.user,
@ -219,14 +226,14 @@ class PaymentwallCallbackView(View):
date_start = self.add_months(sourcedate=now().replace(hour=0, minute=0, day=1), months=1) date_start = self.add_months(sourcedate=now().replace(hour=0, minute=0, day=1), months=1)
date_end = school_payment.date_end date_end = school_payment.date_end
else: else:
date_start = arrow.get(school_payment.date_end).shift(days=1).datetime date_start = arrow.get(school_payment.date_end, settings.TIME_ZONE).shift(days=1).datetime
date_end = arrow.get(date_start).shift(months=1).datetime date_end = arrow.get(date_start, settings.TIME_ZONE).shift(months=1).datetime
else: else:
#month = 0 if now().day >= 1 and now().day <= 10 else 1 #month = 0 if now().day >= 1 and now().day <= 10 else 1
# Логика июльского лагеря: до конца июля приобретаем только на текущий месяц # Логика июльского лагеря: до конца июля приобретаем только на текущий месяц
month = 0 month = 0
date_start = self.add_months(sourcedate=now().replace(hour=0, minute=0, day=1), months=month) date_start = self.add_months(sourcedate=now().replace(hour=0, minute=0, day=1), months=month)
date_end = arrow.get(date_start).shift(months=1, minutes=-1).datetime date_end = arrow.get(date_start, settings.TIME_ZONE).shift(months=1, minutes=-1).datetime
payment.date_start = date_start payment.date_start = date_start
payment.date_end = date_end payment.date_end = date_end
if product_type_name == 'course': if product_type_name == 'course':
@ -251,6 +258,7 @@ class PaymentwallCallbackView(View):
'update_at': payment.update_at, 'update_at': payment.update_at,
} }
payment.save() payment.save()
product_payment_to_mixpanel.delay( product_payment_to_mixpanel.delay(
payment.user.id, payment.user.id,
f'{product_type_name.title()} payment', f'{product_type_name.title()} payment',
@ -268,6 +276,7 @@ class PaymentwallCallbackView(View):
product_type_name, product_type_name,
payment.roistat_visit, payment.roistat_visit,
) )
author_balance = getattr(payment, 'author_balance', None) author_balance = getattr(payment, 'author_balance', None)
if author_balance and author_balance.type == AuthorBalance.IN: if author_balance and author_balance.type == AuthorBalance.IN:
if pingback.is_deliverable(): if pingback.is_deliverable():
@ -276,7 +285,6 @@ class PaymentwallCallbackView(View):
payment.author_balance.status = AuthorBalance.PENDING payment.author_balance.status = AuthorBalance.PENDING
else: else:
payment.author_balance.status = AuthorBalance.DECLINED payment.author_balance.status = AuthorBalance.DECLINED
payment.author_balance.save() payment.author_balance.save()
return HttpResponse('OK') return HttpResponse('OK')
else: else:

@ -1,4 +1,4 @@
<a <a
class="timing__btn btn btn_light" class="timing__btn btn btn_light"
href="{% url 'school:lesson-detail' live_lesson.id %}" href="{% url 'school:lesson-detail' live_lesson.id %}"
>смотреть урок</a> >подробнее</a>

@ -8,27 +8,25 @@
<div class="lesson"> <div class="lesson">
<div class="lesson__subtitle subtitle">{{ livelesson.title }}</div> <div class="lesson__subtitle subtitle">{{ livelesson.title }}</div>
<div class="lesson__content">{{ livelesson.short_description }}</div> <div class="lesson__content">{{ livelesson.short_description }}</div>
<a class="lesson__video video" href="#"> <div class="lesson__video video">
{% if livelesson.stream_index %} {% if livelesson.stream_index %}
<iframe class="lesson__video_frame" src="https://player.vimeo.com/video/{{ livelesson.stream_index }}?autoplay=1" frameborder="0" webkitallowfullscreen <iframe class="lesson__video_frame" src="https://player.vimeo.com/video/{{ livelesson.stream_index }}?autoplay=1" frameborder="0" webkitallowfullscreen
mozallowfullscreen allowfullscreen> mozallowfullscreen allowfullscreen>
</iframe> </iframe>
<a href="#" onclick="location.reload();">Если видео не загрузилось обновите страницу</a> <span>Если видео не загрузилось, - уменьшите качество видео или <a href="#" onclick="location.reload();">обновите страницу</a></span>
<iframe class="lesson__chat_frame" src="https://vimeo.com/live-chat/{{ livelesson.stream_index }}" frameborder="0"></iframe> <iframe class="lesson__chat_frame" src="https://vimeo.com/live-chat/{{ livelesson.stream_index }}" frameborder="0"></iframe>
{% else %} {% else %}
{% if livelesson.cover %} {% if livelesson.cover %}
<img class="video__pic" src="{{ livelesson.cover.image.url }}"/> <img class="video__pic" src="{{ livelesson.cover.image.url }}"/>
{% else %} {% endif %}
{% endif %}
{% endif %} {% endif %}
</a> </div>
</div> </div>
</div> </div>
</div> </div>
{% for content in livelesson.content.all %} {% for content in livelesson.content.all %}
{% with template="course/content/"|add:content.ctype|add:".html" %} {% with template="content/blocks/"|add:content.ctype|add:".html" %}
{% include template %} {% include template %}
{% endwith %} {% endwith %}

@ -1,5 +1,3 @@
import arrow
from io import BytesIO from io import BytesIO
from PIL import Image from PIL import Image
from uuid import uuid4 from uuid import uuid4

@ -56,6 +56,7 @@ INSTALLED_APPS = [
'sorl.thumbnail', 'sorl.thumbnail',
'raven.contrib.django.raven_compat', 'raven.contrib.django.raven_compat',
'django_user_agents', 'django_user_agents',
'imagekit',
] + [ ] + [
'apps.auth.apps', 'apps.auth.apps',
'apps.user', 'apps.user',
@ -143,6 +144,7 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = 'user.User' AUTH_USER_MODEL = 'user.User'
AUTHENTICATION_BACKENDS = ['apps.auth.backend.CaseInsensitiveModelBackend']
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/ # https://docs.djangoproject.com/en/2.0/topics/i18n/

@ -50,6 +50,9 @@
<div class="header__group"> <div class="header__group">
<a class="header__section" href="https://blog.lil.school">БЛОГ</a> <a class="header__section" href="https://blog.lil.school">БЛОГ</a>
</div> </div>
<div class="header__group">
<a class="header__section" href="{% url 'contest' 'august' %}">КОНКУРС</a>
</div>
</nav> </nav>
</div> </div>
{% include 'templates/blocks/user_menu.html' %} {% include 'templates/blocks/user_menu.html' %}

@ -0,0 +1,10 @@
{% load static %}
<script>
window.LIL_STORE = {
staticUrl: '{% static "" %}',
accessToken: '{{ request.user.auth_token }}',
user: {
id: {{ request.user.id|default:'null' }},
}
};
</script>

@ -11,6 +11,9 @@
<a target="_blank" class="partners__item" href="https://www.mann-ivanov-ferber.ru/tag/sasha-kru/"> <a target="_blank" class="partners__item" href="https://www.mann-ivanov-ferber.ru/tag/sasha-kru/">
<img class="partners__pic" src="{% static 'img/mif.jpg' %}"> <img class="partners__pic" src="{% static 'img/mif.jpg' %}">
</a> </a>
<a target="_blank" class="partners__item" href="http://www.pinkbus.ru/">
<img class="partners__pic" src="{% static 'img/pinkbus.jpg' %}">
</a>
</div> </div>
</div> </div>
</div> </div>

@ -3,7 +3,7 @@
<div class="popup__wrap js-popup-wrap"> <div class="popup__wrap js-popup-wrap">
<button class="popup__close js-popup-close"> <button class="popup__close js-popup-close">
<svg class="icon icon-close"> <svg class="icon icon-close">
<use xlink:href={% static "img/sprite.svg" %}#icon-close></use> <use xlink:href="{% static 'img/sprite.svg' %}#icon-close"></use>
</svg> </svg>
</button> </button>
<div class="popup__body"> <div class="popup__body">

@ -1,7 +1,7 @@
{% load static %} {% load static %}
<div class="share"> <div class="share">
<div class="share__title">Поделиться {% if livelesson or lesson %}уроком{% else %}курсом{% endif %}</div> <div class="share__title">Поделиться {% if share_object_name %}{{ share_object_name }}{% else %}{% if livelesson or lesson %}уроком{% else %}курсом{% endif %}{% endif %}</div>
<div class="share__list likely"> <div class="share__list likely">
<a class="share__item facebook" href="#" data-url="http://{{request.META.HTTP_HOST}}{{object.get_absolute_url}}"> <a class="share__item facebook" href="#" data-url="http://{{request.META.HTTP_HOST}}{{object.get_absolute_url}}">
<svg class="icon icon-share-facebook"> <svg class="icon icon-share-facebook">

@ -315,6 +315,7 @@
</div> </div>
</div> </div>
</div> </div>
{% include 'templates/blocks/lil_store_js.html' %}
<script type="text/javascript" src={% static "app.js" %}></script> <script type="text/javascript" src={% static "app.js" %}></script>
<script> <script>
var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }}); var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }});

@ -130,14 +130,8 @@
{% include "templates/blocks/popup_course_lock.html" %} {% include "templates/blocks/popup_course_lock.html" %}
{% include "templates/blocks/popup_subscribe.html" %} {% include "templates/blocks/popup_subscribe.html" %}
</div> </div>
<script> {% include 'templates/blocks/lil_store_js.html' %}
window.STORE = {
accessToken: '{{ request.user.auth_token }}',
user: {
id: '{{ request.user.id }}',
}
};
</script>
<script type="text/javascript" src={% static "app.js" %}></script> <script type="text/javascript" src={% static "app.js" %}></script>
<script> <script>
var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }}); var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }});

@ -18,7 +18,7 @@ from django.contrib import admin
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.urls import path, include from django.urls import path, include
from apps.content.views import ContestEditView, ContestView, ContestWorkView, contest_work_comment
from apps.course.views import ( from apps.course.views import (
CoursesView, likes, coursecomment, CoursesView, likes, coursecomment,
CourseView, LessonView, SearchView, CourseView, LessonView, SearchView,
@ -83,6 +83,11 @@ urlpatterns = [
path('api/v1/', include(('api.v1.urls', 'api_v1'))), path('api/v1/', include(('api.v1.urls', 'api_v1'))),
path('school/', include(('apps.school.urls', 'school'))), path('school/', include(('apps.school.urls', 'school'))),
path('test', TemplateView.as_view(template_name='templates/lilcity/test.html'), name='test'), path('test', TemplateView.as_view(template_name='templates/lilcity/test.html'), name='test'),
path('contest/create', ContestEditView.as_view(), name='contest_create'),
path('contest/<int:pk>/edit', ContestEditView.as_view(), name='contest_edit'),
path('contest/<str:slug>/', ContestView.as_view(), name='contest'),
path('contest-work/<int:pk>/', ContestWorkView.as_view(), name='contest_work'),
path('contest-work/<int:contest_work_id>/comment', contest_work_comment, name='contest_work_comment'),
] ]

@ -87,7 +87,7 @@ class IndexView(TemplateView):
'online': online, 'online': online,
'online_coming_soon': online_coming_soon, 'online_coming_soon': online_coming_soon,
'school_schedule': school_schedule, 'school_schedule': school_schedule,
'course_items': Course.objects.filter(status=Course.PUBLISHED)[:6], 'course_items': Course.shuffle(Course.objects.filter(status=Course.PUBLISHED)[:6]),
'is_purchased': school_payment_exists, 'is_purchased': school_payment_exists,
'min_school_price': SchoolSchedule.objects.aggregate(Min('month_price'))['month_price__min'], 'min_school_price': SchoolSchedule.objects.aggregate(Min('month_price'))['month_price__min'],
'school_schedules': SchoolSchedule.objects.all(), 'school_schedules': SchoolSchedule.objects.all(),

@ -28,5 +28,7 @@ git+https://github.com/ivlevdenis/python-instagram.git
django-user-agents==0.3.2 django-user-agents==0.3.2
user-agents==1.1.0 user-agents==1.1.0
ua-parser==0.8.0 ua-parser==0.8.0
django-ipware
django-imagekit
short_url short_url

@ -34,9 +34,13 @@
"webpack": "^3.10.0" "webpack": "^3.10.0"
}, },
"dependencies": { "dependencies": {
"autosize": "^4.0.2",
"autosize-input": "^1.0.2",
"axios": "^0.17.1", "axios": "^0.17.1",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"baguettebox.js": "^1.10.0", "baguettebox.js": "^1.10.0",
"downscale": "^1.0.4",
"glob": "^7.1.2",
"history": "^4.7.2", "history": "^4.7.2",
"ilyabirman-likely": "^2.3.0", "ilyabirman-likely": "^2.3.0",
"inputmask": "^3.3.11", "inputmask": "^3.3.11",

@ -0,0 +1,361 @@
<template>
<form @submit.prevent="save">
<div class="info">
<div class="info__section" :style="coverBackgroundStyle">
<div class="info__main">
<div class="info__head">
<div class="info__upload upload">
Загрузить фон
<input type="file" class="upload__file" @change="onCoverImageSelected">
</div>
</div>
<div class="info__title">
<div class="info__field field field_info"
v-bind:class="{ error: $v.contest.title.$dirty && $v.contest.title.$invalid }">
<div class="field__label">Название</div>
<div class="field__wrap">
<textarea class="field__textarea"
rows="1"
v-autosize="contest.title"
@change="onTitleInput"
v-model="contest.title"></textarea>
</div>
</div>
<div class="info__field field field_info field_short_description">
<div class="field__label">Описание</div>
<div class="field__wrap">
<textarea class="field__textarea"
v-autosize="contest.description"
v-model="contest.description"></textarea>
</div>
</div>
</div>
<div class="info__foot">
<div class="info__field field">
<div class="field__label field__label_gray">ССЫЛКА</div>
<div class="field__wrap">
<input type="text" class="field__input" v-model="contest.slug" @input="slugChanged = true">
</div>
<div class="field__wrap field__wrap--additional">{{ contestFullUrl }}</div>
</div>
<div class="info__field field">
<div class="field__label">ДАТА НАЧАЛА</div>
<div class="field__wrap">
<vue-datepicker input-class="field__input" v-model="contest.date_start" language="ru" format="dd/MM/yyyy"/>
</div>
</div>
<div class="info__field field">
<div class="field__label">ДАТА ОКОНЧАНИЯ</div>
<div class="field__wrap">
<vue-datepicker input-class="field__input" v-model="contest.date_end" language="ru" format="dd/MM/yyyy"/>
</div>
</div>
<button type="submit">Save</button>
</div>
</div>
</div>
<div class="info__sidebar">
<div class="info__wrap">
<div class="info__fieldset">
</div>
</div>
</div>
</div>
<div class="section">
<div class="section__center center">
<div class="kit">
<div class="kit__body">
<vue-draggable v-model="contest.content" @start="drag=true" @end="drag=false" :options="{ handle: '.sortable__handle' }">
<div v-for="(block, index) in contest.content" :key="block.data.id ? block.data.id : block.data.guid">
<block-text v-if="block.type === 'text'"
:index="index"
:title.sync="block.data.title"
:text.sync="block.data.text"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-image-text v-if="block.type === 'image-text'"
:index="index"
:title.sync="block.data.title"
:text.sync="block.data.text"
:image-id.sync="block.data.image_id"
:image-url.sync="block.data.image_url"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-image v-if="block.type === 'image'"
:index="index"
:title.sync="block.data.title"
:image-id.sync="block.data.image_id"
:image-url.sync="block.data.image_url"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-images v-if="block.type === 'images'"
:index="index"
:title.sync="block.data.title"
:text.sync="block.data.text"
:images.sync="block.data.images"
v-on:remove="onBlockRemoved"
:access-token="accessToken"/>
<block-video v-if="block.type === 'video'"
:index="index"
:title.sync="block.data.title"
v-on:remove="onBlockRemoved"
:video-url.sync="block.data.video_url"/>
</div>
</vue-draggable>
<block-add v-on:added="onBlockAdded"/>
</div>
</div>
</div>
</div>
</form>
</template>
<script>
import BlockText from './blocks/BlockText'
import BlockImage from './blocks/BlockImage'
import BlockImages from './blocks/BlockImages'
import BlockImageText from './blocks/BlockImageText'
import BlockVideo from './blocks/BlockVideo'
import BlockAdd from "./blocks/BlockAdd";
import {api} from "../js/modules/api";
import DatePicker from 'vuejs-datepicker';
import Draggable from 'vuedraggable';
import slugify from 'slugify';
import {required, minValue, numeric, url } from 'vuelidate/lib/validators'
import _ from 'lodash';
import moment from 'moment'
export default {
name: 'contest-redactor',
props: ["contestId"],
data() {
return {
loading: false,
slugChanged: false,
contest: {
coverImage: '',
coverImageId: null,
title: '',
description: '',
content: [],
date_start: '',
date_end: '',
slug: '',
active: true,
}
}
},
computed: {
accessToken() {
return window.LIL_STORE.accessToken;
},
coverBackgroundStyle() {
return this.contest.coverImage ? `background-image: url(${this.contest.coverImage});` : '';
},
contestFullUrl() {
return `https://lil.city/contest/${this.contest.slug}`;
},
},
validations() {
return {
contest: {
title: {
required
},
},
};
},
mounted() {
if (this.contestId) {
this.load();
}
},
methods: {
onBlockRemoved(blockIndex) {
const blockToRemove = this.contest.content[blockIndex];
// Удаляем блок из Vue
this.contest.content.splice(blockIndex, 1);
// Если блок уже был записан в БД, отправляем запрос на сервер на удаление блока из БД
if (blockToRemove.data.id) {
api.removeContentBlock(blockToRemove, this.accessToken);
}
},
onBlockAdded(blockData) {
this.contest.content.push(blockData);
},
onTitleInput() {
this.$v.contest.title.$touch();
if (!this.slugChanged) {
this.contest.slug = slugify(this.contest.title);
}
},
onCoverImageSelected(event) {
let file = event.target.files[0];
let reader = new FileReader();
reader.onload = () => {
this.$set(this.contest, 'coverImage', reader.result);
api.uploadImage(reader.result, this.accessToken)
.then((response) => {
this.contest.coverImageId = response.data.id;
})
.catch((error) => {
//console.log('error', error);
});
};
if (file) {
reader.readAsDataURL(file);
}
},
processContestJson(data) {
this.contest = {
coverImage: data.cover && data.cover.image || null,
coverImageId: data.cover && data.cover.id || null,
id: data.id,
title: data.title,
description: data.description,
content: api.convertContentResponse(data.content),
date_start: data.date_start,
date_end: data.date_end,
slug: data.slug,
active: data.active,
};
},
load() {
this.loading = true;
const request = api.get(`/api/v1/contests/${this.contestId}/`, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
});
request
.then((response) => {
this.processContestJson(response.data);
this.$nextTick(() => {
this.loading = false;
});
})
.catch((err) => {
this.loading = false;
});
return request;
},
save() {
let data = _.pick(this.contest, ['title', 'description', 'slug', 'active']);
data.date_start = data.date_start ? moment(data.date_start).format('MM-DD-YYYY') : null;
data.date_end = data.date_end ? moment(data.date_end).format('MM-DD-YYYY') : null;
data.cover = this.contest.coverImageId || '';
data.content = this.contest.content.map((block, index) => {
if (block.type === 'text') {
return {
'type': 'text',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'txt': block.data.text,
}
}
} else if (block.type === 'image') {
return {
'type': 'image',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'img': block.data.image_id,
}
}
} else if (block.type === 'image-text') {
return {
'type': 'image-text',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'img': block.data.image_id,
'txt': block.data.text,
}
}
} else if (block.type === 'images') {
return {
'type': 'images',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'images': block.data.images.map((galleryImage) => {
return {
'id': galleryImage.id ? galleryImage.id : null,
'img': galleryImage.img,
}
}),
}
}
} else if (block.type === 'video') {
return {
'type': 'video',
'data': {
'id': block.data.id ? block.data.id : null,
'uuid': block.uuid,
'position': ++index,
'title': block.data.title,
'url': block.data.video_url,
}
}
}
});
const request = this.contest.id
? api.put(`/api/v1/contests/${this.contest.id}/`, data, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
})
: api.post('/api/v1/contests/', data, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
});
request.then((response) => {
if(this.contest.id) {
this.contest = this.processContestJson(response.data);
}
else {
window.location.href = `/contest/${response.data.id}/edit`;
}
})
.catch((err) => {
//this.contestSaving = false;
});
}
},
components: {
BlockAdd,
'vue-datepicker': DatePicker,
'block-text': BlockText,
'block-image': BlockImage,
'block-image-text': BlockImageText,
'block-images': BlockImages,
'block-video': BlockVideo,
'vue-draggable': Draggable,
}
};
</script>
<style>
</style>

@ -0,0 +1,86 @@
<template>
<div class="contest-works">
<div class="contest-works__works">
<contest-work v-for="contestWork in contestWorks" :key="contestWork.id" :contest-work="contestWork"></contest-work>
</div>
<div v-show="loading" class="contest-works__loader"><div class="loading-loader"></div></div>
<div v-if="loaded && !contestWorks.length" class="contest-works__no-works">Здесь вы сможете увидеть работы участников после их добавления</div>
</div>
</template>
<script>
import {api} from "../js/modules/api";
import $ from 'jquery';
import ContestWork from "./blocks/ContestWork.vue";
export default {
name: "contest-works",
props: ['contestId', 'autoload'],
data(){
return {
page: 1,
lastPage: null,
loading: false,
loaded: false,
contestWorks: [],
};
},
mounted() {
this.load();
if(this.autoload) {
$(window).scroll(() => {
const pos = $(this.$el).offset().top + this.$el.clientHeight - 100;
if($(window).scrollTop() + $(window).height() >= pos
&& !this.loading && !this.lastPage) {
this.page += 1;
this.load();
}
});
}
},
methods: {
load() {
this.loading = true;
api.get(`/api/v1/contest-works/?contest=${this.contestId}&page=${this.page}`)
.then((response) => {
this.loading = false;
this.loaded = true;
if(this.page > 1){
this.contestWorks = this.contestWorks.concat(response.data.results);
}
else{
this.contestWorks = response.data.results;
}
})
.catch((response) => {
this.loading = false;
this.loaded = true;
if(response.response.status == 404){
this.lastPage = this.page;
}
});
}
},
components: {ContestWork},
}
</script>
<style>
.contest-works {
width: 100%;
}
.contest-works__works {
column-width: 300px;
column-gap: 20px;
text-align: left;
}
.contest-works__loader {
width: 100%;
height: 30px;
position: relative;
}
.contest-works__no-works {
text-align: center;
width: 100%;
}
</style>

@ -992,28 +992,28 @@
}); });
} }
// let user = api.getCurrentUser(this.accessToken); let user = api.getCurrentUser(this.accessToken);
// promises.push(user); promises.push(user);
//
// user.then((response) => { user.then((response) => {
// if (response.data) { if (response.data) {
// this.me = response.data; this.me = response.data;
//
// if(this.me.role == ROLE_ADMIN) { /* if(this.me.role == ROLE_ADMIN) {
// api.getUsers({role: [ROLE_AUTHOR,ROLE_ADMIN], page_size: 1000}, this.accessToken) api.getUser(this.me.id, {role: [ROLE_AUTHOR, ROLE_ADMIN], page_size: 1000}, this.accessToken)
// .then((usersResponse) => { .then((usersResponse) => {
// if (usersResponse.data) { if (usersResponse.data) {
// this.users = usersResponse.data.results.map((user) => { this.users = usersResponse.data.results.map((user) => {
// return { return {
// title: `${user.first_name} ${user.last_name}`, title: `${user.first_name} ${user.last_name}`,
// value: user.id value: user.id
// } }
// }); });
// } }
// }); });
// } } */
// } }
// }); });
// if (this.courseId) { // if (this.courseId) {
// this.loadCourse().then(()=>{this.updateViewSection(window.location, 'load')}).catch(()=>{ // this.loadCourse().then(()=>{this.updateViewSection(window.location, 'load')}).catch(()=>{

@ -0,0 +1,193 @@
<template>
<div ref="popup" class="upload-contest-work popup" @click.prevent="hide">
<div class="popup__wrap popup__wrap_md" @click.stop>
<button class="popup__close" @click.prevent="hide">
<svg class="icon icon-close">
<use v-bind="{'xlink:href': $root.store.staticUrl + 'img/sprite.svg' + '#icon-close' }"></use>
</svg>
</button>
<div class="popup__body">
<form class="form">
<div class="title">
Чтобы принять участие<br>в конкурсе, заполните поля<br>и прикрепите изображение
<img src="/static/img/curve-1.svg" class="text__curve">
</div>
<div class="field"
v-bind:class="{ error: $v.contestWork.child_full_name.$dirty && $v.contestWork.child_full_name.$invalid }">
<div class="field__label">ИМЯ И ФАМИЛИЯ РЕБЕНКА</div>
<div class="field__wrap">
<input class="field__input" type="text" v-model="contestWork.child_full_name">
</div>
<div class="field__error"></div>
</div>
<div class="field"
v-bind:class="{ error: $v.contestWork.age.$dirty && $v.contestWork.age.$invalid }">
<div class="field__label">ВОЗРАСТ</div>
<div class="field__wrap">
<input class="field__input" type="number" v-model="contestWork.age" style="width: 100px;">
</div>
<div class="field__error"></div>
</div>
<div class="field">
<lil-image :image-id.sync="contestWork.imageId" :image-url.sync="contestWork.imageUrl"
v-on:update:imageId="onUpdateImageId" :access-token="$root.store.accessToken" />
</div>
<div class="field" style="text-align: center;">
<button class="btn" tabindex="3" @click.prevent="save">Отправить на конкурс</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
import {api} from "../js/modules/api";
import LilImage from './blocks/Image';
import {showNotification} from "../js/modules/notification";
import {required, minValue, numeric, url } from 'vuelidate/lib/validators';
import $ from 'jquery';
export default {
name: 'upload-contest-work',
props: ['contestId'],
data() {
return {
fields: {
image: "Изображение",
child_full_name: "Имя и фамилия ребенка",
age: 'Возраст',
},
contestWork: {
contest: this.contestId,
image: null,
imageId: null,
imageUrl: '',
child_full_name: '',
age: '',
}
}
},
validations() {
return {
contestWork: {
image: {
required
},
child_full_name: {
required
},
age: {
required, numeric
},
},
};
},
mounted() {
$('[data-show-upload-contest-work]').click((e) => {
e.preventDefault();
e.stopPropagation();
this.clear();
this.show();
});
},
methods: {
clear() {
Object.assign(this.$data, this.$options.data.apply(this))
},
onUpdateImageId(imageId) {
this.contestWork.image = imageId;
},
show() {
const $popup = $(this.$refs.popup)
$('body').addClass('no-scroll');
$popup.addClass('visible');
setTimeout(() => {
$popup.addClass('open');
}, 300);
},
hide() {
const $popup = $(this.$refs.popup)
$('body').removeClass('no-scroll');
$popup.removeClass('visible');
setTimeout(() => {
$popup.removeClass('open');
}, 300);
},
validate() {
if (this.$v.contestWork.$invalid) {
for(let i in this.$v.contestWork) {
if(this.$v.contestWork[i].$invalid) {
showNotification("error", "Ошибка валидации поля " + this.fields[i]);
}
}
return false;
}
return true;
},
save() {
if(! this.validate()) {
return false;
}
let data = this.contestWork;
data.contest = this.contestId;
data.user = this.$root.store.user.id;
const request = api.post(`/api/v1/contest-works/`, data, {
headers: {
'Authorization': `Token ${this.$root.store.accessToken}`,
}
});
request.then((response) => {
if(+response.data.id){
this.$emit('add:contest-work', response.data);
this.hide();
window.location.reload();
}
});
}
},
components: {
'lil-image': LilImage,
}
}
</script>
<style lang="scss">
.upload-contest-work {
.popup__wrap {
padding: 35px 35px 0;
}
.title {
text-align: center; font-size: 24px;
.text__curve {
right: 55px;
width: 170px;
bottom: -40px;
}
}
.kit__photo {
height: 400px;
}
.kit__photo.has-image {
border: none;
}
.kit__photo-image {
max-height: 400px;
height: auto;
width: auto;
}
.kit__file {
bottom: 0;
}
}
</style>

@ -0,0 +1,62 @@
<template>
<div class="contest-work-item">
<a :href="`/contest-work/${contestWork.id}/`">
<img class="contest-work-item__img" :src="contestWork.image.image_thumbnail" />
</a>
<div class="contest-work-item__info">
<div class="contest-work-item__bio">
<div>{{ contestWork.child_full_name }}</div>
<div class="contest-work-item__age">{{ contestWork.age }} {{ contestWork.age < 5 ? 'года' : 'лет' }}</div>
</div>
<div class="contest-work-item__likes">
<likes obj-type="contest_work" :obj-id="contestWork.id" :user-liked="contestWork.user_liked"
:likes="contestWork.likes"></likes>
</div>
</div>
</div>
</template>
<script>
import Likes from './Likes.vue';
export default {
name: "contest-work",
props: ['contestWork'],
components: {Likes},
}
</script>
<style lang="scss">
.contest-work-item {
break-inside: avoid;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
transition: opacity .4s ease-in-out;
text-transform: uppercase;
font-weight: bold;
color: black;
border: 1px solid #ececec;
display: block;
}
.contest-work-item__img {
width: 100%;
height: auto;
}
.contest-work-item__info {
display: flex;
padding: 5px 10px;
}
.contest-work-item__age {
color: #919191;
}
.contest-work-item__bio {
flex: calc(100% - 70px);
}
@media only screen and (min-width: 1023px) {
.contest-works:hover .contest-work-item:not(:hover) {
opacity: 0.4;
}
}
</style>

@ -1,5 +1,5 @@
<template> <template>
<div class="kit__photo"> <div class="kit__photo" :class="{ 'loading': loading, 'has-image': !! imageUrl }">
<svg class="icon icon-add-plus" v-if="!imageUrl"> <svg class="icon icon-add-plus" v-if="!imageUrl">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use> <use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
</svg> </svg>
@ -10,6 +10,7 @@
<script> <script>
import {api} from "../../js/modules/api"; import {api} from "../../js/modules/api";
import downscale from 'downscale';
export default { export default {
name: "lil-image", name: "lil-image",
@ -22,19 +23,37 @@
methods: { methods: {
onImageAdded(event) { onImageAdded(event) {
this.loading = true; this.loading = true;
const maxSize = 1600;
let file = event.target.files[0]; let file = event.target.files[0];
let reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
api.uploadImage(reader.result, this.accessToken) let img = document.createElement('img');
.then((response) => { img.onload = () => {
this.loading = false; let w = 0;
this.$emit('update:imageId', response.data.id); let h = 0;
this.$emit('update:imageUrl', response.data.image); if(img.width > img.height) {
}) w = maxSize;
.catch((error) => { h = 0;
this.loading = false; }
console.log('error', error); else {
w = 0;
h = maxSize;
}
downscale(img.src, w, h).then((dataURL) => {
img = null;
api.uploadImage(dataURL, this.accessToken)
.then((response) => {
this.loading = false;
this.$emit('update:imageId', response.data.id);
this.$emit('update:imageUrl', response.data.image);
})
.catch((error) => {
this.loading = false;
console.log('error', error);
});
}); });
}
img.src = reader.result;
}; };
if (file) { if (file) {
reader.readAsDataURL(file); reader.readAsDataURL(file);

@ -0,0 +1,74 @@
<template>
<div class="likes" :class="{ 'likes_liked': userLikedProp }">
<span>{{ likesProp }}</span><span class="likes__like" @click="addLike"
v-bind="{ 'data-popup': $root.store.user.id ? '' : '.js-popup-auth' }">
<svg class="likes__icon icon icon-like">
<use v-bind="{'xlink:href': $root.store.staticUrl + 'img/sprite.svg' + '#icon-like' }"></use>
</svg></span>
</div>
</template>
<script>
import {api} from "../../js/modules/api";
export default {
name: 'likes',
props: ['likes', 'userLiked', 'objType', 'objId'],
data() {
return {
likesProp: +this.likes || 0,
userLikedProp: this.userLiked || false,
}
},
methods: {
addLike(event) {
if(this.userLikedProp){
return;
}
if(this.$root.store.user.id) {
event.stopPropagation();
api.post('/api/v1/likes/', {
user: this.$root.store.user.id, // FIXME
obj_type: this.objType,
obj_id: this.objId,
}, {
headers: {
'Authorization': `Token ${this.$root.store.accessToken}`,
}
})
.then((response) => {
if (response.data && response.data.id) {
this.userLikedProp = true;
this.likesProp += 1;
this.$emit('liked');
}
});
}
}
}
}
</script>
<style>
.likes {
font-weight: bold;
text-align: right;
}
.likes__like {
cursor: pointer;
margin-left: 5px;
}
.likes_liked .likes__like {
cursor: default;
}
.likes__icon {
margin-bottom: -3px;
}
.likes_liked .likes__icon {
fill: #d40700;
}
</style>

@ -18,7 +18,7 @@
const me = this; const me = this;
$(me.$refs.input).redactor({ $(me.$refs.input).redactor({
air: true, air: true,
buttonsHide: ['image', 'link', 'format'], buttonsHide: ['image', 'format'],
lang: 'ru', lang: 'ru',
placeholder: this.placeholder, placeholder: this.placeholder,
callbacks: { callbacks: {

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

@ -17,3 +17,30 @@ import "./modules/notification";
import "./modules/mixpanel"; import "./modules/mixpanel";
import "../sass/app.sass"; import "../sass/app.sass";
import Vue from 'vue';
import Vuelidate from 'vuelidate';
import UploadContestWork from '../components/UploadContestWork.vue';
import ContestWorks from '../components/ContestWorks.vue';
import Likes from '../components/blocks/Likes.vue';
Vue.use(Vuelidate);
if (process.env.NODE_ENV === 'development') {
// Enable vue-devtools
Vue.config.devtools = true;
}
const app = new Vue({
el: '#lilcity-vue-app',
data() {
return {
store: window.LIL_STORE,
}
},
components: {
UploadContestWork,
ContestWorks,
Likes,
}
});

@ -0,0 +1,19 @@
import Vue from 'vue'
import Vuelidate from 'vuelidate'
import VueAutosize from '../components/directives/autosize'
import ContestRedactor from '../components/ContestRedactor.vue'
if (process.env.NODE_ENV === 'development') {
// Enable vue-devtools
Vue.config.devtools = true;
}
Vue.use(VueAutosize);
Vue.use(Vuelidate);
let app = new Vue({
el: '#lilcity-vue-app',
components: {
'contest-redactor': ContestRedactor,
}
});

@ -12,6 +12,10 @@ moment.locale('ru');
const history = createHistory(); const history = createHistory();
$(document).ready(function () { $(document).ready(function () {
const currentCategory = $('div.js-select-option.active[data-category-option]').attr('data-category-name');
if(currentCategory) {
$('.js-select[data-category-select] .js-select-head').text(currentCategory);
}
// Обработчик отложенных курсов // Обработчик отложенных курсов
setInterval(() => { setInterval(() => {
$('div[data-future-course]').each((_, element) => { $('div[data-future-course]').each((_, element) => {

@ -7,10 +7,13 @@ $(document).ready(function () {
popup = $('.popup.visible.open'); popup = $('.popup.visible.open');
body.on('click', '[data-popup]', function(e){ body.on('click', '[data-popup]', function(e){
let data = $(this).data('popup');
if(! data) {
return true;
}
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
let data = $(this).data('popup');
popup = $(data); popup = $(data);
showPopup(); showPopup();

@ -175,7 +175,7 @@ button
top: 1px top: 1px
left: 1px left: 1px
right: 1px right: 1px
bottom: 1px bottom: 2px
background: white background: white
border-radius: 2px border-radius: 2px
transition: opacity .2s transition: opacity .2s
@ -812,12 +812,10 @@ a[name]
+m +m
margin-bottom: 30px margin-bottom: 30px
p p
+t
display: inline
&:not(:last-child) &:not(:last-child)
margin-bottom: 35px margin-bottom: 35px
+t +t
margin: 0 margin: 5px 5px 15px 5px
&__curve &__curve
position: absolute position: absolute
pointer-events: none pointer-events: none
@ -2654,10 +2652,9 @@ a.grey-link
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
&__pic &__pic
top: 50%; object-fit: cover
position: relative;
transform: translateY(-50%);
width: 100% width: 100%
height: 100%
&__content &__content
flex: 0 0 calc(100% - 165px) flex: 0 0 calc(100% - 165px)
&__actions &__actions
@ -2700,7 +2697,7 @@ a.grey-link
margin-bottom: 10px margin-bottom: 10px
color: #191919 color: #191919
&__content &__content
margin-bottom: 30px margin-bottom: 15px
color: #191919 color: #191919
&__video_frame &__video_frame
width: 100% width: 100%
@ -2708,6 +2705,26 @@ a.grey-link
&__chat_frame &__chat_frame
width: 100% width: 100%
height: 600px height: 600px
&__row
display: flex
+m
display: block
&__preview
margin-right: 25px
flex: 0 0 140px
+m
display: none
&__pic-wrapper
width: 130px;
height: 130px;
border-radius: 50%;
overflow: hidden;
&__pic
object-fit: cover
width: 100%
height: 100%
&__user
margin-bottom: 30px
.lessons .lessons
@ -3463,6 +3480,24 @@ a.grey-link
.icon .icon
font-size: 20px font-size: 20px
fill: #B5B5B5 fill: #B5B5B5
&.loading &-image
visibility: hidden
&.loading
.icon
visibility: hidden
&:after
content: ''
position: absolute
top: 50%
left: 50%
width: 24px
height: 24px
margin: -12px 0 0 -12px
border: 3px solid #B5B5B5
border-left: 3px solid transparent
border-radius: 50%
animation: loading .6s infinite linear
&__file &__file
position: absolute position: absolute
top: 0 top: 0
@ -3869,7 +3904,7 @@ a.grey-link
position: relative position: relative
padding-left: 17px padding-left: 17px
&:before &:before
content: '' content: '-'
position: absolute position: absolute
top: 0 top: 0
left: 0 left: 0
@ -4080,3 +4115,37 @@ a
.anchor .anchor
padding-top: 100px padding-top: 100px
margin-top: -100px margin-top: -100px
.contest-work
text-transform: uppercase;
font-weight: bold;
&__img-wrap
width: 100%;
text-align: center;
&__img
max-width: 100%;
&__info
display: flex;
padding: 5px 10px;
&__age
color: #919191;
&__bio
flex: calc(100% - 70px);
.loading-loader
content: ''
position: absolute
top: 50%
left: 50%
width: 24px
height: 24px
margin: -12px 0 0 -12px
border: 3px solid #B5B5B5
border-left: 3px solid transparent
border-radius: 50%
animation: loading .6s infinite linear

@ -9,6 +9,7 @@ module.exports = {
entry: { entry: {
app: "./src/js/app.js", app: "./src/js/app.js",
courseRedactor: "./src/js/course-redactor.js", courseRedactor: "./src/js/course-redactor.js",
contestRedactor: "./src/js/contest-redactor.js",
mixpanel: "./src/js/third_party/mixpanel-2-latest.js", mixpanel: "./src/js/third_party/mixpanel-2-latest.js",
sprite: glob('./src/icons/*.svg'), sprite: glob('./src/icons/*.svg'),
images: glob('./src/img/*'), images: glob('./src/img/*'),
@ -116,6 +117,10 @@ module.exports = {
}, },
watch: NODE_ENV === 'development', watch: NODE_ENV === 'development',
watchOptions: {
ignored: 'node_modules',
poll: 2000,
},
devtool: NODE_ENV === 'development' ? 'source-map' : false devtool: NODE_ENV === 'development' ? 'source-map' : false
}; };

Loading…
Cancel
Save