diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1d78b5d1..a1a48c4d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -44,6 +44,7 @@ stop-review: script: - export REVIEW_HOST=$CI_COMMIT_REF_SLUG-$REVIEW_DOMAIN - cd docker + - cp .env.review .env - docker-compose -f docker-compose-review.yml -p back$CI_COMMIT_REF_NAME down - rm -rf /work/data/back_${CI_COMMIT_REF_NAME}/ when: manual diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/v1/serializers/content.py b/api/v1/serializers/content.py index 729368e2..89daefa1 100644 --- a/api/v1/serializers/content.py +++ b/api/v1/serializers/content.py @@ -3,8 +3,7 @@ from rest_framework import serializers from apps.content.models import ( Baner, Content, Image, Text, ImageText, Video, - Gallery, GalleryImage, ImageObject, -) + Gallery, GalleryImage, ImageObject,) from . import Base64ImageField @@ -14,6 +13,7 @@ BASE_CONTENT_FIELDS = ( 'uuid', 'course', 'lesson', + 'contest', 'live_lesson', 'title', 'position', @@ -85,12 +85,16 @@ class ImageObjectSerializer(serializers.ModelSerializer): image = Base64ImageField( 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: model = ImageObject fields = ( 'id', 'image', + 'image_thumbnail', 'created_at', 'update_at', ) @@ -254,3 +258,4 @@ class ContentSerializer(serializers.ModelSerializer): elif isinstance(obj, Gallery): return GallerySerializer(obj, context=self.context).to_representation(obj) return super(ContentSerializer, self).to_representation(obj) + diff --git a/api/v1/serializers/contest.py b/api/v1/serializers/contest.py new file mode 100644 index 00000000..dc5b88fd --- /dev/null +++ b/api/v1/serializers/contest.py @@ -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) diff --git a/api/v1/serializers/course.py b/api/v1/serializers/course.py index bdb1a289..fe06ca53 100644 --- a/api/v1/serializers/course.py +++ b/api/v1/serializers/course.py @@ -1,6 +1,10 @@ +from ipware import get_client_ip + from rest_framework import serializers from rest_framework.validators import UniqueValidator +from django.contrib.auth import get_user_model + from apps.course.models import ( Category, Course, Comment, CourseComment, LessonComment, @@ -15,12 +19,15 @@ from .content import ( from apps.content.models import ( Content, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, -) + ContestWork) from .user import UserSerializer from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin +User = get_user_model() + + class MaterialCreateSerializer(serializers.ModelSerializer): 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 Meta: diff --git a/api/v1/serializers/mixins.py b/api/v1/serializers/mixins.py index 0f055f00..40584503 100644 --- a/api/v1/serializers/mixins.py +++ b/api/v1/serializers/mixins.py @@ -4,7 +4,7 @@ from apps.school.models import LiveLesson from apps.content.models import ( Content, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, -) + Contest) from .content import ( TextCreateSerializer, ImageCreateSerializer, ImageTextCreateSerializer, VideoCreateSerializer, @@ -25,6 +25,8 @@ class DispatchContentMixin(object): obj_type = 'lesson' elif isinstance(obj, LiveLesson): obj_type = 'live_lesson' + elif isinstance(obj, Contest): + obj_type = 'contest' cdata[obj_type] = obj.id if ctype == 'text': if 'id' in cdata and cdata['id']: diff --git a/api/v1/urls.py b/api/v1/urls.py index 409ed79f..51610cb6 100644 --- a/api/v1/urls.py +++ b/api/v1/urls.py @@ -18,7 +18,7 @@ from .views import ( UserViewSet, LessonViewSet, ImageObjectViewSet, SchoolScheduleViewSet, LiveLessonViewSet, PaymentViewSet, -) + ContestViewSet, ContestWorkViewSet) router = DefaultRouter() 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'contests', ContestViewSet, base_name='contests') +router.register(r'contest-works', ContestWorkViewSet, base_name='contest_works') + # router.register(r'configs', ConfigViewSet, base_name='configs') diff --git a/api/v1/views.py b/api/v1/views.py index 271d907f..2089f123 100644 --- a/api/v1/views.py +++ b/api/v1/views.py @@ -14,7 +14,7 @@ from .serializers.course import ( CommentSerializer, MaterialSerializer, MaterialCreateSerializer, LessonSerializer, LessonCreateSerializer, -) + LikeCreateSerializer) from .serializers.content import ( BanerSerializer, ImageSerializer, ImageCreateSerializer, @@ -39,6 +39,9 @@ from .serializers.user import ( AuthorRequestSerializer, UserSerializer, UserPhotoSerializer, ) +from .serializers.contest import ( + ContestCreateSerializer, ContestSerializer, ContestWorkSerializer, ContestWorkCreateSerializer +) from .permissions import ( IsAdmin, IsAdminOrIsSelf, @@ -56,7 +59,7 @@ from apps.config.models import Config from apps.content.models import ( Baner, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, -) + Contest, ContestWork) from apps.payment.models import ( AuthorBalance, Payment, CoursePayment, SchoolPayment, @@ -110,6 +113,8 @@ class BanerViewSet(ExtendedModelViewSet): class ImageObjectViewSet(ExtendedModelViewSet): queryset = ImageObject.objects.all() serializer_class = ImageObjectSerializer + # FIXME + authentication_classes = [] # permission_classes = (IsAuthorOrAdmin,) @@ -127,10 +132,16 @@ class MaterialViewSet(ExtendedModelViewSet): class LikeViewSet(ExtendedModelViewSet): 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',) ordering_fields = ('created_at', 'update_at',) # permission_classes = (IsAdmin,) + # FIXME + authentication_classes = [] class CategoryViewSet(ExtendedModelViewSet): @@ -419,10 +430,10 @@ class AuthorRequestViewSet(ExtendedModelViewSet): class PaymentViewSet(ExtendedModelViewSet): - queryset = Payment.objects.filter(status__isnull=False).order_by('-created_at') + queryset = Payment.objects.all() serializer_class = PaymentSerializer permission_classes = (IsAdmin,) - filter_fields = ('status',) + filter_fields = ('status', 'user',) ordering_fields = ( 'id', 'user__email', 'user__first_name', 'user__last_name', @@ -430,6 +441,17 @@ class PaymentViewSet(ExtendedModelViewSet): ) 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') def calc_amount(self, request, pk=None): user = request.query_params.get('user') @@ -439,3 +461,34 @@ class PaymentViewSet(ExtendedModelViewSet): course = course and Course.objects.get(pk=course) 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) diff --git a/apps/auth/backend.py b/apps/auth/backend.py new file mode 100644 index 00000000..41568b52 --- /dev/null +++ b/apps/auth/backend.py @@ -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 diff --git a/apps/auth/forms.py b/apps/auth/forms.py index 0baea362..e369a8f9 100644 --- a/apps/auth/forms.py +++ b/apps/auth/forms.py @@ -6,8 +6,3 @@ class LearnerRegistrationForm(forms.Form): last_name = forms.CharField() email = forms.EmailField() password = forms.CharField() - - -class LoginForm(forms.Form): - email = forms.CharField() - password = forms.CharField() diff --git a/apps/auth/views.py b/apps/auth/views.py index 6272dbac..f83a9269 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -35,7 +35,7 @@ class LearnerRegistrationView(FormView): config = Config.load() first_name = form.cleaned_data['first_name'] last_name = form.cleaned_data['last_name'] - email = form.cleaned_data['email'] + email = form.cleaned_data['email'].lower() password = form.cleaned_data['password'] user, created = User.objects.get_or_create( @@ -179,8 +179,9 @@ class FacebookLoginOrRegistration(View): "errors": {"email": 'is field required'} }) else: + email = email.lower() try: - user = User.objects.get(email=email) + user = User.objects.get(email__iexact=email) except User.DoesNotExist: first_name = data.get('first_name', '') last_name = data.get('last_name', '') diff --git a/apps/content/migrations/0021_auto_20180813_1306.py b/apps/content/migrations/0021_auto_20180813_1306.py new file mode 100644 index 00000000..66033641 --- /dev/null +++ b/apps/content/migrations/0021_auto_20180813_1306.py @@ -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='Конкурс'), + ), + ] diff --git a/apps/content/migrations/0022_auto_20180815_2129.py b/apps/content/migrations/0022_auto_20180815_2129.py new file mode 100644 index 00000000..92cc567d --- /dev/null +++ b/apps/content/migrations/0022_auto_20180815_2129.py @@ -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': 'Конкурсные работы'}, + ), + ] diff --git a/apps/content/models.py b/apps/content/models.py index 468058eb..197e1e7d 100644 --- a/apps/content/models.py +++ b/apps/content/models.py @@ -1,11 +1,22 @@ from urllib.parse import urlparse 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 +User = get_user_model() + + class ImageObject(models.Model): 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) update_at = models.DateTimeField(auto_now=True) @@ -36,6 +47,12 @@ class Content(PolymorphicModel): verbose_name='Урок онлайн школы', 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='') position = models.PositiveSmallIntegerField( 'Положение на странице', @@ -131,3 +148,57 @@ class Baner(models.Model): if self.use: Baner.objects.filter(use=True).update(use=False) 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]) diff --git a/apps/course/templates/course/content/gallery.html b/apps/content/templates/content/blocks/gallery.html similarity index 97% rename from apps/course/templates/course/content/gallery.html rename to apps/content/templates/content/blocks/gallery.html index 1116322a..69ad0386 100644 --- a/apps/course/templates/course/content/gallery.html +++ b/apps/content/templates/content/blocks/gallery.html @@ -1,32 +1,32 @@ -{% load thumbnail %} -{% if results %} -
Галерея итогов обучения
- -{% else %} -
-
-
{{ content.title }}
- -
-
-{% endif %} +{% load thumbnail %} +{% if results %} +
Галерея итогов обучения
+ +{% else %} +
+
+
{{ content.title }}
+ +
+
+{% endif %} diff --git a/apps/course/templates/course/content/image.html b/apps/content/templates/content/blocks/image.html similarity index 96% rename from apps/course/templates/course/content/image.html rename to apps/content/templates/content/blocks/image.html index 4ab24601..5d515430 100644 --- a/apps/course/templates/course/content/image.html +++ b/apps/content/templates/content/blocks/image.html @@ -1,10 +1,10 @@ -
-
-
- {{ content.title }} -
-
- -
-
-
+
+
+
+ {{ content.title }} +
+
+ +
+
+
diff --git a/apps/course/templates/course/content/imagetext.html b/apps/content/templates/content/blocks/imagetext.html similarity index 100% rename from apps/course/templates/course/content/imagetext.html rename to apps/content/templates/content/blocks/imagetext.html diff --git a/apps/course/templates/course/content/text.html b/apps/content/templates/content/blocks/text.html similarity index 94% rename from apps/course/templates/course/content/text.html rename to apps/content/templates/content/blocks/text.html index 18ce2235..59e46ded 100644 --- a/apps/course/templates/course/content/text.html +++ b/apps/content/templates/content/blocks/text.html @@ -1,10 +1,10 @@ -
-
-
- {{ content.title }} -
-
- {{ content.txt | safe }} -
-
+
+
+
+ {{ content.title }} +
+
+ {{ content.txt | safe }} +
+
\ No newline at end of file diff --git a/apps/course/templates/course/content/video.html b/apps/content/templates/content/blocks/video.html similarity index 95% rename from apps/course/templates/course/content/video.html rename to apps/content/templates/content/blocks/video.html index 3527507f..fe741465 100644 --- a/apps/course/templates/course/content/video.html +++ b/apps/content/templates/content/blocks/video.html @@ -1,43 +1,43 @@ -
-
-
- {{ content.title }} -
-
- {% if 'youtube.com' in content.url or 'youtu.be' in content.url %} - - {% elif 'vimeo.com' in content.url %} - - {% endif %} -
-
-
- - \ No newline at end of file diff --git a/apps/content/templates/content/contest.html b/apps/content/templates/content/contest.html new file mode 100644 index 00000000..f37f3886 --- /dev/null +++ b/apps/content/templates/content/contest.html @@ -0,0 +1,47 @@ +{% extends "templates/lilcity/index.html" %} + + +{% block content %} + +
+
+
+ {{ contest.title }} +
+
+ {{ contest.description }} +
+
+ {% if not contest_work_uploaded %} + Загрузить свою работу + {% endif %} +
+
+
+
+{% for content in contest.content.all %} + + {% with template="content/blocks/"|add:content.ctype|add:".html" %} + {% include template %} + {% endwith %} + +{% endfor %} +
+
+ +
Галерея
+
+
+ {% if not contest_work_uploaded %} + Загрузить свою работу + {% endif %} +
+ +
+
+
+{% endblock content %} diff --git a/apps/content/templates/content/contest_edit.html b/apps/content/templates/content/contest_edit.html new file mode 100644 index 00000000..d1c26b62 --- /dev/null +++ b/apps/content/templates/content/contest_edit.html @@ -0,0 +1,18 @@ +{% extends "templates/lilcity/edit_index.html" %} +{% load static %} + +{% block title %} + {% if object %} + Редактирование конкурса {{ object.title }} + {% else %} + Создание конкурса + {% endif %} +{% endblock title %} + +{% block content %} + +{% endblock content %} +{% block foot %} + + +{% endblock foot %} diff --git a/apps/content/templates/content/contest_work.html b/apps/content/templates/content/contest_work.html new file mode 100644 index 00000000..67e2e287 --- /dev/null +++ b/apps/content/templates/content/contest_work.html @@ -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 %} +
+ +
+
+ +
+
+
+
{{ contest_work.child_full_name }}
+
{{ contest_work.age }} {% if contest_work.age < 5 %}года{% else %}лет{% endif %}
+
+ +
+
+
+ +
+
+
+ {% if prev_contest_work %} + +
+ + + +
+
Предыдущая работа
+
+ {% else %} +
+ {% endif %} + {% if next_contest_work %} + +
Следующая работа
+
+ + + +
+
+ {% else %} +
+ {% endif %} +
+
+
+ +
+
+ {% include 'templates/blocks/share.html' with share_object_name='работой' %} +
+
+ +
+
+
Оставьте комментарий:
+
+ {% if request.user.is_authenticated %} +
+ +
+ +
+
+ +
+ +
+ +
+
+ {% else %} +
Только зарегистрированные пользователи могут оставлять комментарии.
+ {% endif %} +
+ {% include "templates/blocks/comments.html" with object=contest_work %} +
+
+
+
+{% endblock content %} diff --git a/apps/content/views.py b/apps/content/views.py index 28002783..ff3a4deb 100644 --- a/apps/content/views.py +++ b/apps/content/views.py @@ -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, + }) diff --git a/apps/course/filters.py b/apps/course/filters.py index 827079e6..6e4e873e 100644 --- a/apps/course/filters.py +++ b/apps/course/filters.py @@ -4,7 +4,7 @@ from .models import Course 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: model = Course diff --git a/apps/course/migrations/0040_course_age.py b/apps/course/migrations/0040_course_age.py new file mode 100644 index 00000000..f8426fd7 --- /dev/null +++ b/apps/course/migrations/0040_course_age.py @@ -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), + ), + ] diff --git a/apps/course/migrations/0041_auto_20180813_1306.py b/apps/course/migrations/0041_auto_20180813_1306.py new file mode 100644 index 00000000..d2596544 --- /dev/null +++ b/apps/course/migrations/0041_auto_20180813_1306.py @@ -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', + ), + ] diff --git a/apps/course/migrations/0042_like_ip.py b/apps/course/migrations/0042_like_ip.py new file mode 100644 index 00000000..388ab03f --- /dev/null +++ b/apps/course/migrations/0042_like_ip.py @@ -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), + ), + ] diff --git a/apps/course/models.py b/apps/course/models.py index 942a0593..39eed28e 100644 --- a/apps/course/models.py +++ b/apps/course/models.py @@ -1,4 +1,5 @@ import arrow +from random import shuffle from uuid import uuid4 from django.db import models from django.core.exceptions import ValidationError @@ -7,19 +8,21 @@ from django.utils.text import slugify from django.utils.timezone import now from django.contrib.auth import get_user_model from django.urls import reverse_lazy +from django.conf import settings from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey from project.mixins import BaseModel, DeactivatedMixin 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() class Like(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) + ip = models.GenericIPAddressField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) update_at = models.DateTimeField(auto_now=True) @@ -97,7 +100,7 @@ class Course(BaseModel, DeactivatedMixin): class Meta: verbose_name = 'Курс' verbose_name_plural = 'Курсы' - ordering = ['-created_at'] + ordering = ['-is_featured', ] def __str__(self): return str(self.id) + ' ' + self.title @@ -129,11 +132,11 @@ class Course(BaseModel, DeactivatedMixin): @property 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 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 def is_deferred_start(self): @@ -148,6 +151,24 @@ class Course(BaseModel, DeactivatedMixin): def count_videos_in_lessons(self): 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): title = models.CharField('Название категории', max_length=100) @@ -234,7 +255,7 @@ class Comment(PolymorphicMPTTModel, DeactivatedMixin): @property 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): return self.content @@ -264,3 +285,7 @@ class LessonComment(Comment): class Meta(Comment.Meta): verbose_name = 'Комментарий урока' verbose_name_plural = 'Комментарии уроков' + + +class ContestWorkComment(Comment): + contest_work = models.ForeignKey(ContestWork, on_delete=models.CASCADE, related_name='comments') diff --git a/apps/course/templates/course/_items.html b/apps/course/templates/course/_items.html index 42757ddc..b4356f2b 100644 --- a/apps/course/templates/course/_items.html +++ b/apps/course/templates/course/_items.html @@ -48,7 +48,7 @@
{{ course.category | upper }} + href="{% url 'courses' %}?category={{ course.category.id }}">{{ course.category | upper }} {% if not course.is_free %}
{{ course.price|floatformat:"-2" }}₽
{% endif %} diff --git a/apps/course/templates/course/course.html b/apps/course/templates/course/course.html index 53a56abc..c3a35c4f 100644 --- a/apps/course/templates/course/course.html +++ b/apps/course/templates/course/course.html @@ -227,7 +227,7 @@
{% 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 %} {% endwith %} diff --git a/apps/course/templates/course/course_only_lessons.html b/apps/course/templates/course/course_only_lessons.html index 74a5bf47..b987475c 100644 --- a/apps/course/templates/course/course_only_lessons.html +++ b/apps/course/templates/course/course_only_lessons.html @@ -180,19 +180,18 @@ {% endif %}
-
{{ forloop.counter }}. {{ lesson.title }}
- {% if lesson.cover %}
- +
- {% endif %} -
{{ lesson.short_description | truncatechars_html:800 | safe | linebreaks }}
-
-
- Перейти к уроку +
+
{{ forloop.counter }}. {{ lesson.title }}
+
{{ lesson.short_description | truncatechars_html:800 | safe }}
+ Перейти к уроку +
diff --git a/apps/course/templates/course/courses.html b/apps/course/templates/course/courses.html index a3277174..8f6f57d2 100644 --- a/apps/course/templates/course/courses.html +++ b/apps/course/templates/course/courses.html @@ -20,8 +20,8 @@
-
-
{% if category %}{{ category.0 }}{% else %}Категории{% endif %}
+
+
Категории
diff --git a/apps/course/templates/course/inclusion/category_items.html b/apps/course/templates/course/inclusion/category_items.html index 5e6aeef5..5549ffa4 100644 --- a/apps/course/templates/course/inclusion/category_items.html +++ b/apps/course/templates/course/inclusion/category_items.html @@ -1,5 +1,6 @@ {% for cat in category_items %} -
+
{{ cat.title }}
{% endfor %} \ No newline at end of file diff --git a/apps/course/templates/course/inclusion/category_menu_items.html b/apps/course/templates/course/inclusion/category_menu_items.html index a01342a9..ecb83298 100644 --- a/apps/course/templates/course/inclusion/category_menu_items.html +++ b/apps/course/templates/course/inclusion/category_menu_items.html @@ -2,7 +2,7 @@
Все курсы
{% for cat in category_items %} - +
{{ cat.title }}
{% endfor %} diff --git a/apps/course/templates/course/lesson.html b/apps/course/templates/course/lesson.html index 662a4dd5..94d6b89b 100644 --- a/apps/course/templates/course/lesson.html +++ b/apps/course/templates/course/lesson.html @@ -27,24 +27,43 @@ {% endif %}
-
{{ lesson.title }}
-
{{ lesson.short_description }}
- {% comment %} - {% if lesson.cover %} - - {% else %} - - {% endif %} - - - - {% endcomment %} +
{% 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 %} {% endwith %} @@ -98,6 +117,7 @@
{% if request.user.is_authenticated %}
+
+
diff --git a/apps/course/templatetags/lilcity_category.py b/apps/course/templatetags/lilcity_category.py index 957e50c2..956bbab4 100644 --- a/apps/course/templatetags/lilcity_category.py +++ b/apps/course/templatetags/lilcity_category.py @@ -9,7 +9,7 @@ register = template.Library() def category_items(category=None): return { '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): return { '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, } diff --git a/apps/course/views.py b/apps/course/views.py index 3ecd6bd0..857a57d0 100644 --- a/apps/course/views.py +++ b/apps/course/views.py @@ -241,7 +241,6 @@ class CoursesView(ListView): def get(self, request, *args, **kwargs): self.object_list = self.get_queryset() - allow_empty = self.get_allow_empty() if request.is_ajax(): context = self.get_context_data() template_name = self.get_template_names() @@ -283,6 +282,7 @@ class CoursesView(ListView): context = super().get_context_data() filtered = CourseFilter(self.request.GET) context.update(filtered.data) + context['course_items'] = Course.shuffle(context.get('course_items')) return context def get_template_names(self): diff --git a/apps/payment/models.py b/apps/payment/models.py index 17c75bb1..ec3936e2 100644 --- a/apps/payment/models.py +++ b/apps/payment/models.py @@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField, JSONField from django.core.validators import RegexValidator from django.utils.timezone import now +from django.conf import settings from project.utils import weekday_in_date_range @@ -245,4 +246,4 @@ class SchoolPayment(Payment): @property 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') diff --git a/apps/payment/views.py b/apps/payment/views.py index 2f70df36..afe2b8ac 100644 --- a/apps/payment/views.py +++ b/apps/payment/views.py @@ -17,6 +17,7 @@ from django.views.decorators.csrf import csrf_exempt from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.utils.timezone import now +from django.conf import settings from paymentwall import Pingback, Product, Widget @@ -196,12 +197,18 @@ class PaymentwallCallbackView(View): payment.status = pingback.get_type() payment.data = payment_raw_data if pingback.is_deliverable(): + effective_amount = pingback.get_parameter('effective_price_amount') + + if effective_amount: + payment.amount = effective_amount + transaction_to_mixpanel.delay( payment.user.id, payment.amount, now().strftime('%Y-%m-%dT%H:%M:%S'), product_type_name, ) + if product_type_name == 'school': school_payment = SchoolPayment.objects.filter( 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_end = school_payment.date_end else: - date_start = arrow.get(school_payment.date_end).shift(days=1).datetime - date_end = arrow.get(date_start).shift(months=1).datetime + date_start = arrow.get(school_payment.date_end, settings.TIME_ZONE).shift(days=1).datetime + date_end = arrow.get(date_start, settings.TIME_ZONE).shift(months=1).datetime else: #month = 0 if now().day >= 1 and now().day <= 10 else 1 # Логика июльского лагеря: до конца июля приобретаем только на текущий месяц month = 0 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_end = date_end if product_type_name == 'course': @@ -251,6 +258,7 @@ class PaymentwallCallbackView(View): 'update_at': payment.update_at, } payment.save() + product_payment_to_mixpanel.delay( payment.user.id, f'{product_type_name.title()} payment', @@ -268,6 +276,7 @@ class PaymentwallCallbackView(View): product_type_name, payment.roistat_visit, ) + author_balance = getattr(payment, 'author_balance', None) if author_balance and author_balance.type == AuthorBalance.IN: if pingback.is_deliverable(): @@ -276,7 +285,6 @@ class PaymentwallCallbackView(View): payment.author_balance.status = AuthorBalance.PENDING else: payment.author_balance.status = AuthorBalance.DECLINED - payment.author_balance.save() return HttpResponse('OK') else: diff --git a/apps/school/templates/blocks/open_lesson.html b/apps/school/templates/blocks/open_lesson.html index 46999c7f..abb8a9a2 100644 --- a/apps/school/templates/blocks/open_lesson.html +++ b/apps/school/templates/blocks/open_lesson.html @@ -1,4 +1,4 @@ смотреть урок +>подробнее diff --git a/apps/school/templates/school/livelesson_detail.html b/apps/school/templates/school/livelesson_detail.html index 1bcb3227..26e785f9 100644 --- a/apps/school/templates/school/livelesson_detail.html +++ b/apps/school/templates/school/livelesson_detail.html @@ -8,27 +8,25 @@
{{ livelesson.title }}
{{ livelesson.short_description }}
- +
{% if livelesson.stream_index %} - Если видео не загрузилось обновите страницу + Если видео не загрузилось, - уменьшите качество видео или обновите страницу {% else %} - {% if livelesson.cover %} - - {% else %} - - {% endif %} + {% if livelesson.cover %} + + {% endif %} {% endif %} - +
{% 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 %} {% endwith %} diff --git a/apps/user/views.py b/apps/user/views.py index da1245ae..309fb0e0 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -1,5 +1,3 @@ -import arrow - from io import BytesIO from PIL import Image from uuid import uuid4 diff --git a/project/settings.py b/project/settings.py index b4fc9a6f..1228211c 100644 --- a/project/settings.py +++ b/project/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'sorl.thumbnail', 'raven.contrib.django.raven_compat', 'django_user_agents', + 'imagekit', ] + [ 'apps.auth.apps', 'apps.user', @@ -143,6 +144,7 @@ AUTH_PASSWORD_VALIDATORS = [ AUTH_USER_MODEL = 'user.User' +AUTHENTICATION_BACKENDS = ['apps.auth.backend.CaseInsensitiveModelBackend'] # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ diff --git a/project/templates/blocks/header.html b/project/templates/blocks/header.html index 5be001d8..38d314bf 100644 --- a/project/templates/blocks/header.html +++ b/project/templates/blocks/header.html @@ -50,6 +50,9 @@ +
{% include 'templates/blocks/user_menu.html' %} diff --git a/project/templates/blocks/lil_store_js.html b/project/templates/blocks/lil_store_js.html new file mode 100644 index 00000000..cb5eb6f8 --- /dev/null +++ b/project/templates/blocks/lil_store_js.html @@ -0,0 +1,10 @@ +{% load static %} + diff --git a/project/templates/blocks/partners.html b/project/templates/blocks/partners.html index ee16423d..194daf54 100644 --- a/project/templates/blocks/partners.html +++ b/project/templates/blocks/partners.html @@ -11,6 +11,9 @@ + + +
diff --git a/project/templates/blocks/popup_auth.html b/project/templates/blocks/popup_auth.html index 058a2205..1a527a90 100644 --- a/project/templates/blocks/popup_auth.html +++ b/project/templates/blocks/popup_auth.html @@ -3,7 +3,7 @@