Merge branch 'feature/create-edit-courses' into dev

remotes/origin/hasaccess
Vitaly Baev 8 years ago
commit e631905c60
  1. 2
      .gitignore
  2. 0
      api/__init__.py
  3. 45
      api/v1/__init__.py
  4. 45
      api/v1/auth.py
  5. 35
      api/v1/permissions.py
  6. 32
      api/v1/serializers/__init__.py
  7. 261
      api/v1/serializers/content.py
  8. 291
      api/v1/serializers/course.py
  9. 152
      api/v1/serializers/mixins.py
  10. 53
      api/v1/serializers/user.py
  11. 53
      api/v1/urls.py
  12. 243
      api/v1/views.py
  13. 0
      apps/content/__init__.py
  14. 72
      apps/content/admin.py
  15. 6
      apps/content/apps.py
  16. 93
      apps/content/migrations/0001_initial.py
  17. 29
      apps/content/migrations/0002_auto_20180205_1212.py
  18. 17
      apps/content/migrations/0003_auto_20180205_1246.py
  19. 45
      apps/content/migrations/0004_gallery_galleryimage.py
  20. 31
      apps/content/migrations/0005_auto_20180208_0520.py
  21. 29
      apps/content/migrations/0006_auto_20180208_0551.py
  22. 19
      apps/content/migrations/0007_auto_20180208_0626.py
  23. 19
      apps/content/migrations/0008_auto_20180208_0631.py
  24. 23
      apps/content/migrations/0009_auto_20180208_0637.py
  25. 17
      apps/content/migrations/0010_remove_gallery_course.py
  26. 18
      apps/content/migrations/0011_auto_20180209_1549.py
  27. 19
      apps/content/migrations/0012_auto_20180209_1847.py
  28. 40
      apps/content/migrations/0013_auto_20180212_0537.py
  29. 0
      apps/content/migrations/__init__.py
  30. 96
      apps/content/models.py
  31. 3
      apps/content/tests.py
  32. 2
      apps/content/views.py
  33. 1
      apps/course/admin.py
  34. 28
      apps/course/fixtures/course.json
  35. 35
      apps/course/migrations/0021_auto_20180205_1559.py
  36. 17
      apps/course/migrations/0021_auto_20180206_0632.py
  37. 35
      apps/course/migrations/0022_auto_20180205_1615.py
  38. 19
      apps/course/migrations/0022_auto_20180208_0647.py
  39. 19
      apps/course/migrations/0023_auto_20180208_0714.py
  40. 19
      apps/course/migrations/0024_auto_20180208_0824.py
  41. 20
      apps/course/migrations/0025_course_gallery.py
  42. 24
      apps/course/migrations/0026_auto_20180208_1053.py
  43. 17
      apps/course/migrations/0027_remove_course_url.py
  44. 19
      apps/course/migrations/0028_course_slug.py
  45. 19
      apps/course/migrations/0029_auto_20180209_0911.py
  46. 19
      apps/course/migrations/0030_auto_20180212_0537.py
  47. 23
      apps/course/migrations/0031_auto_20180213_0906.py
  48. 107
      apps/course/models.py
  49. 4
      apps/course/templates/course/_items.html
  50. 19
      apps/course/templates/course/content/gallery.html
  51. 6
      apps/course/templates/course/content/image.html
  52. 9
      apps/course/templates/course/content/imagetext.html
  53. 6
      apps/course/templates/course/content/text.html
  54. 13
      apps/course/templates/course/content/video.html
  55. 50
      apps/course/templates/course/course.html
  56. 12
      apps/course/templates/course/course_edit.html
  57. 13
      apps/course/templates/course/lesson.html
  58. 51
      apps/course/views.py
  59. 4
      apps/user/fixtures/superuser.json
  60. 49
      apps/user/forms.py
  61. 18
      apps/user/migrations/0005_user_birthday.py
  62. 18
      apps/user/migrations/0006_auto_20180206_1352.py
  63. 23
      apps/user/migrations/0007_auto_20180207_0808.py
  64. 17
      apps/user/migrations/0008_auto_20180212_0750.py
  65. 65
      apps/user/models.py
  66. 209
      apps/user/templates/user/profile-settings.html
  67. 2
      apps/user/templates/user/profile.html
  68. 76
      apps/user/views.py
  69. 4
      docker-compose.yml
  70. 38
      project/mixins.py
  71. 47
      project/settings.py
  72. 30
      project/templates/lilcity/index.html
  73. 11
      project/templates/lilcity/test.html
  74. 14
      project/urls.py
  75. 4
      requirements.txt
  76. 5526
      web/build/css/app.css
  77. 1
      web/build/css/app.css.map
  78. BIN
      web/build/css/fonts/ProximaNova-Bold.woff
  79. BIN
      web/build/css/fonts/ProximaNova-Bold.woff2
  80. BIN
      web/build/css/fonts/ProximaNova-Light.woff
  81. BIN
      web/build/css/fonts/ProximaNova-Light.woff2
  82. BIN
      web/build/css/fonts/ProximaNova-Regular.woff
  83. BIN
      web/build/css/fonts/ProximaNova-Regular.woff2
  84. BIN
      web/build/css/fonts/ProximaNova-SemiBold.woff
  85. BIN
      web/build/css/fonts/ProximaNova-SemiBold.woff2
  86. 0
      web/build/img/favicon.ico
  87. BIN
      web/build/img/no_cover.png
  88. 29637
      web/build/js/app.js
  89. 1
      web/build/js/app.js.map
  90. 2
      web/gulp/tasks/copy.js
  91. 1496
      web/package-lock.json
  92. 23
      web/package.json
  93. 582
      web/src/components/CourseRedactor.vue
  94. 122
      web/src/components/LessonRedactor.vue
  95. 121
      web/src/components/blocks/BlockAdd.vue
  96. 54
      web/src/components/blocks/BlockImage.vue
  97. 63
      web/src/components/blocks/BlockImageText.vue
  98. 91
      web/src/components/blocks/BlockImages.vue
  99. 48
      web/src/components/blocks/BlockText.vue
  100. 53
      web/src/components/blocks/BlockVideo.vue
  101. Some files were not shown because too many files have changed in this diff Show More

2
.gitignore vendored

@ -112,3 +112,5 @@ venv.bak/
node_modules
db.sqlite3
.vscode
/web/build

@ -0,0 +1,45 @@
from rest_framework import viewsets
from rest_framework.response import Response
# https://gist.github.com/ivlevdenis/a0c8f5b472b6b8550bbb016c6a30e0be
class ExtendViewSet(object):
"""
This viewset mixin class with extended options list.
"""
permission_map = {}
throttle_scope_map = {}
serializer_class_map = {}
def get_serializer_class(self):
ser = self.serializer_class_map.get(self.action, None)
self.serializer_class = ser or self.serializer_class
return super().get_serializer_class()
def initialize_request(self, request, *args, **kwargs):
request = super().initialize_request(request, *args, **kwargs)
throttle_scope = self.throttle_scope_map.get(self.action, None)
cls_throttle_scope = getattr(self, 'throttle_scope', None)
self.throttle_scope = throttle_scope or cls_throttle_scope or ''
return request
def get_permissions(self):
perms = self.permission_map.get(self.action, None)
if perms and not isinstance(perms, (tuple, list)):
perms = [perms, ]
self.permission_classes = perms or self.permission_classes
return super().get_permissions()
def options(self, request, *args, **kwargs):
if self.metadata_class is None:
return self.http_method_not_allowed(request, *args, **kwargs)
data = self.metadata_class().determine_metadata(request, self)
data['actions']['GET'] = self.query_metadata
return Response(data, status=status.HTTP_200_OK)
class ExtendedModelViewSet(ExtendViewSet, viewsets.ModelViewSet):
pass

@ -0,0 +1,45 @@
from django.contrib.auth import get_user_model
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.compat import authenticate
User = get_user_model()
class AuthTokenSerializer(serializers.Serializer):
email = serializers.CharField(label=_("Email"))
password = serializers.CharField(
label=_("Password"),
style={'input_type': 'password'},
trim_whitespace=False
)
def validate(self, attrs):
email = attrs.get('email')
password = attrs.get('password')
if email and password:
user = authenticate(request=self.context.get('request'),
email=email, password=password)
# The authenticate call simply returns None for is_active=False
# users. (Assuming the default ModelBackend authentication
# backend.)
if not user:
msg = _('Unable to log in with provided credentials.')
raise serializers.ValidationError(msg, code='authorization')
elif user.role != User.ADMIN_ROLE:
msg = _('Only admin have permission to login admin page.')
raise serializers.ValidationError(msg, code='authorization')
else:
msg = _('Must include "email" and "password".')
raise serializers.ValidationError(msg, code='authorization')
attrs['user'] = user
return attrs
class ObtainToken(ObtainAuthToken):
serializer_class = AuthTokenSerializer

@ -0,0 +1,35 @@
from django.contrib.auth import get_user_model
from rest_framework.permissions import BasePermission
User = get_user_model()
class IsAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and (
request.user.role == User.ADMIN_ROLE or request.user.is_staff or request.user.is_superuser
)
class IsAdminOrIsSelf(BasePermission):
def has_object_permission(self, request, view, user):
return request.user.is_authenticated and (
user == request.user or request.user.is_staff or request.user.is_superuser
)
class IsAuthorOrAdmin(BasePermission):
def has_permission(self, request, view):
return request.user.is_authenticated and (
request.user.role in [
User.AUTHOR_ROLE, User.ADMIN_ROLE
] or request.user.is_staff or request.user.is_superuser
)
class IsAuthorObjectOrAdmin(BasePermission):
def has_object_permission(self, request, view, obj):
return request.user.is_authenticated and (
request.user.role == User.ADMIN_ROLE or request.user.is_staff or request.user.is_superuser
) and request.user == obj.author

@ -0,0 +1,32 @@
import imghdr
import base64
import six
import uuid
from django.core.files.base import ContentFile
from rest_framework import serializers
class Base64ImageField(serializers.ImageField):
def to_internal_value(self, data):
if isinstance(data, six.string_types):
if 'data:' in data and ';base64,' in data:
header, data = data.split(';base64,')
try:
decoded_file = base64.b64decode(data)
except TypeError:
self.fail('invalid_image')
file_name = str(uuid.uuid4())[:12]
file_extension = self.get_file_extension(
file_name, decoded_file)
complete_file_name = "%s.%s" % (file_name, file_extension,)
data = ContentFile(decoded_file, name=complete_file_name)
return super().to_internal_value(data)
def get_file_extension(self, file_name, decoded_file):
extension = imghdr.what(file_name, decoded_file)
extension = "jpg" if extension == "jpeg" else extension
return extension

@ -0,0 +1,261 @@
from rest_framework import serializers
from apps.content.models import (
Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
from . import Base64ImageField
class ContentCreateSerializer(serializers.Serializer):
TYPE_CHOICES = (
'text',
'image',
'image-text',
'images',
'video',
)
type = serializers.ChoiceField(choices=TYPE_CHOICES)
data = serializers.JSONField()
def to_representation(self, obj):
if isinstance(obj, Image):
return ImageSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, Text):
return TextSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, ImageText):
return ImageTextSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, Video):
return VideoSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, Gallery):
return GallerySerializer(obj, context=self.context).to_representation(obj)
return super(ContentSerializer, self).to_representation(obj)
class ImageObjectSerializer(serializers.ModelSerializer):
image = Base64ImageField(
required=True, allow_empty_file=False, allow_null=False, read_only=False,
)
class Meta:
model = ImageObject
fields = (
'id',
'image',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'created_at',
'update_at',
)
class ImageCreateSerializer(serializers.ModelSerializer):
type = serializers.SerializerMethodField()
class Meta:
model = Image
fields = (
'id',
'course',
'lesson',
'title',
'position',
'img',
'type',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'type',
'created_at',
'update_at',
)
def get_type(self, object):
return 'image'
class ImageSerializer(ImageCreateSerializer):
img = ImageObjectSerializer()
class TextCreateSerializer(serializers.ModelSerializer):
type = serializers.SerializerMethodField()
class Meta:
model = Text
fields = (
'id',
'course',
'lesson',
'title',
'position',
'type',
'created_at',
'update_at',
) + ('txt',)
read_only_fields = (
'id',
'type',
'created_at',
'update_at',
)
def get_type(self, object):
return 'text'
class TextSerializer(TextCreateSerializer):
pass
class ImageTextCreateSerializer(serializers.ModelSerializer):
type = serializers.SerializerMethodField()
class Meta:
model = ImageText
fields = (
'id',
'course',
'lesson',
'title',
'position',
'img',
'txt',
'type',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'type',
'created_at',
'update_at',
)
def get_type(self, object):
return 'image-text'
class ImageTextSerializer(ImageTextCreateSerializer):
img = ImageObjectSerializer()
class VideoCreateSerializer(serializers.ModelSerializer):
type = serializers.SerializerMethodField()
class Meta:
model = Video
fields = (
'id',
'course',
'lesson',
'title',
'position',
'type',
'created_at',
'update_at',
) + ('url',)
read_only_fields = (
'id',
'type',
'created_at',
'update_at',
)
def get_type(self, object):
return 'video'
class VideoSerializer(VideoCreateSerializer):
pass
class GalleryImageCreateSerializer(serializers.ModelSerializer):
class Meta:
model = GalleryImage
fields = (
'id',
'gallery',
'img',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'created_at',
'update_at',
)
class GalleryImageSerializer(GalleryImageCreateSerializer):
img = ImageObjectSerializer()
class GallerySerializer(serializers.ModelSerializer):
type = serializers.SerializerMethodField()
gallery_images = GalleryImageSerializer(many=True)
class Meta:
model = Gallery
fields = (
'id',
'course',
'lesson',
'title',
'position',
'gallery_images',
'type',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'type',
'created_at',
'update_at',
)
def get_type(self, object):
return 'images'
class ContentSerializer(serializers.ModelSerializer):
class Meta:
model = Content
fields = (
'id',
'course',
'lesson',
'title',
'position',
'created_at',
'update_at',
)
def to_representation(self, obj):
if isinstance(obj, Image):
return ImageSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, Text):
return TextSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, ImageText):
return ImageTextSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, Video):
return VideoSerializer(obj, context=self.context).to_representation(obj)
elif isinstance(obj, Gallery):
return GallerySerializer(obj, context=self.context).to_representation(obj)
return super(ContentSerializer, self).to_representation(obj)

@ -0,0 +1,291 @@
from rest_framework import serializers
from apps.course.models import Category, Course, Material, Lesson, Like
from .content import (
ImageObjectSerializer, ContentSerializer, ContentCreateSerializer,
GallerySerializer, GalleryImageSerializer,
)
from apps.content.models import (
Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin
class MaterialCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Material
fields = (
'id',
'title',
'cover',
'short_description',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'created_at',
'update_at',
)
class MaterialSerializer(MaterialCreateSerializer):
cover = ImageObjectSerializer(allow_null=True)
class LikeSerializer(serializers.ModelSerializer):
class Meta:
model = Like
fields = (
'id',
'user',
'created_at',
'update_at',
)
read_only_fields = (
'id',
'created_at',
'update_at',
)
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = (
'id',
'title',
)
read_only_fields = (
'id',
)
class CourseCreateSerializer(DispatchContentMixin,
DispatchGalleryMixin,
DispatchMaterialMixin,
serializers.ModelSerializer
):
slug = serializers.SlugField(allow_unicode=True, required=False)
content = serializers.ListSerializer(
child=ContentCreateSerializer(),
required=False,
)
materials = MaterialSerializer(many=True, required=False)
gallery = GallerySerializer()
class Meta:
model = Course
fields = (
'id',
'slug',
'author',
'title',
'short_description',
'from_author',
'cover',
'price',
'is_infinite',
'deferred_start_at',
'category',
'duration',
'is_featured',
'url',
'status',
'likes',
'materials',
'created_at',
'update_at',
'deactivated_at',
'content',
'gallery',
)
read_only_fields = (
'id',
'url',
'created_at',
'update_at',
'deactivated_at',
)
def create(self, validated_data):
content = validated_data.pop('content', [])
materials = validated_data.pop('materials', [])
gallery = validated_data.pop('gallery', {})
course = super().create(validated_data)
self.dispatch_content(course, content)
self.dispatch_materials(course, materials)
self.dispatch_gallery(course, gallery)
return course
def update(self, instance, validated_data):
content = validated_data.pop('content', [])
materials = validated_data.pop('materials', [])
gallery = validated_data.pop('gallery', {})
course = super().update(instance, validated_data)
self.dispatch_materials(course, materials)
self.dispatch_content(course, content)
self.dispatch_gallery(course, gallery)
return course
def to_representation(self, instance):
return CourseSerializer(instance, context=self.context).to_representation(instance)
class CourseSerializer(CourseCreateSerializer):
category = CategorySerializer()
materials = MaterialSerializer(many=True)
cover = ImageObjectSerializer()
gallery = GallerySerializer()
content = ContentSerializer(many=True)
class LessonCreateSerializer(serializers.ModelSerializer):
content = serializers.ListSerializer(
child=ContentCreateSerializer(),
required=False,
)
class Meta:
model = Lesson
fields = (
'id',
'title',
'short_description',
'course',
'cover',
'content',
'created_at',
'update_at',
'deactivated_at',
)
read_only_fields = (
'id',
'created_at',
'update_at',
'deactivated_at',
)
def dispatch_content(self, lesson, content):
for c in content:
if 'type' not in c or not c['type'] or 'data' not in c or not c['data']:
continue
ctype = c['type']
cdata = c['data']
if ctype == 'text':
if 'id' in cdata and cdata['id']:
t = Text.objects.get(id=cdata['id'])
t.position = cdata['position']
t.title = cdata['title']
t.lesson = lesson
t.txt = cdata['txt']
t.save()
else:
t = Text.objects.create(
position=cdata['position'],
title=cdata['title'],
lesson=lesson,
txt=cdata['txt'],
)
elif ctype == 'image':
if 'id' in cdata and cdata['id']:
image = Image.objects.get(id=cdata['id'])
image.position = cdata['position']
image.title = cdata['title']
image.lesson = lesson
image.img = ImageObject.objects.get(id=cdata['img'])
image.save()
else:
image = Image.objects.create(
position=cdata['position'],
title=cdata['title'],
lesson=lesson,
img=ImageObject.objects.get(id=cdata['img']),
)
elif ctype == 'image-text':
if 'id' in cdata and cdata['id']:
it = ImageText.objects.get(id=cdata['id'])
it.position = cdata['position']
it.title = cdata['title']
it.lesson = lesson
it.img = ImageObject.objects.get(id=cdata['img'])
it.txt = cdata['txt']
it.save()
else:
it = ImageText.objects.create(
position=cdata['position'],
title=cdata['title'],
lesson=lesson,
img=ImageObject.objects.get(id=cdata['img']),
txt=cdata['txt'],
)
elif ctype == 'video':
if 'id' in cdata and cdata['id']:
v = Video.objects.get(id=cdata['id'])
v.position = cdata['position']
v.title = cdata['title']
v.lesson = lesson
v.url = cdata['url']
v.save()
else:
v = Video.objects.create(
position=cdata['position'],
title=cdata['title'],
lesson=lesson,
url=cdata['url'],
)
elif ctype == 'images':
if 'id' in cdata and cdata['id']:
g = Gallery.objects.get(id=cdata['id'])
g.position = cdata['position']
g.title = cdata['title']
g.lesson = lesson
g.save()
if 'images' in cdata:
for image in cdata['images']:
gi = GalleryImage.objects.create(
gallery=g,
img=ImageObject.objects.get(id=image['img']),
)
else:
g = Gallery.objects.create(
lesson=lesson,
position=cdata['position'],
title=cdata['title'],
)
if 'images' in cdata:
for image in cdata['images']:
gi = GalleryImage.objects.create(
gallery=g,
img=ImageObject.objects.get(id=image['img']),
)
def create(self, validated_data):
content = validated_data.pop('content', [])
lesson = super().create(validated_data)
self.dispatch_content(lesson, content)
return lesson
def update(self, instance, validated_data):
content = validated_data.pop('content', [])
lesson = super().update(instance, validated_data)
self.dispatch_content(lesson, content)
return lesson
class LessonSerializer(LessonCreateSerializer):
course = CourseSerializer()
cover = ImageObjectSerializer()
content = ContentSerializer(many=True)

@ -0,0 +1,152 @@
from apps.course.models import Category, Course, Material, Lesson, Like
from apps.content.models import (
Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
class DispatchContentMixin(object):
def dispatch_content(self, course, content):
for c in content:
if 'type' not in c or not c['type'] or 'data' not in c or not c['data']:
continue
ctype = c['type']
cdata = c['data']
if ctype == 'text':
if 'id' in cdata and cdata['id']:
t = Text.objects.get(id=cdata['id'])
t.position = cdata['position']
t.title = cdata['title']
t.course = course
t.txt = cdata['txt']
t.save()
else:
t = Text.objects.create(
position=cdata['position'],
title=cdata['title'],
course=course,
txt=cdata['txt'],
)
elif ctype == 'image':
if 'id' in cdata and cdata['id']:
image = Image.objects.get(id=cdata['id'])
image.position = cdata['position']
image.title = cdata['title']
image.course = course
image.img = ImageObject.objects.get(id=cdata['img'])
image.save()
else:
image = Image.objects.create(
position=cdata['position'],
title=cdata['title'],
course=course,
img=ImageObject.objects.get(id=cdata['img']),
)
elif ctype == 'image-text':
if 'id' in cdata and cdata['id']:
it = ImageText.objects.get(id=cdata['id'])
it.position = cdata['position']
it.title = cdata['title']
it.course = course
it.img = ImageObject.objects.get(id=cdata['img'])
it.txt = cdata['txt']
it.save()
else:
it = ImageText.objects.create(
position=cdata['position'],
title=cdata['title'],
course=course,
img=ImageObject.objects.get(id=cdata['img']),
txt=cdata['txt'],
)
elif ctype == 'video':
if 'id' in cdata and cdata['id']:
v = Video.objects.get(id=cdata['id'])
v.position = cdata['position']
v.title = cdata['title']
v.course = course
v.url = cdata['url']
v.save()
else:
v = Video.objects.create(
position=cdata['position'],
title=cdata['title'],
course=course,
url=cdata['url'],
)
elif ctype == 'images':
if 'id' in cdata and cdata['id']:
g = Gallery.objects.get(id=cdata['id'])
g.course = course
g.position = cdata['position']
g.title = cdata['title']
g.save()
if 'images' in cdata:
for image in cdata['images']:
gi = GalleryImage.objects.create(
gallery=g,
img=ImageObject.objects.get(id=image['img'])
)
else:
g = Gallery.objects.create(
course=course,
position=cdata['position'],
title=cdata['title'],
)
if 'images' in cdata:
for image in cdata['images']:
gi = GalleryImage.objects.create(
gallery=g,
img=ImageObject.objects.get(id=image['img']),
)
class DispatchMaterialMixin(object):
def dispatch_materials(self, course, materials):
for material in materials:
if 'id' in material and material['id']:
m = Material.objects.get(id=material['id'])
m.title = material['title']
m.cover = ImageObject.objects.get(id=material['cover'])
m.short_description = material['short_description']
m.save()
else:
m = Material.objects.create(
title=material['title'],
cover=ImageObject.objects.get(id=material['cover']),
short_description=material['short_description'],
)
course.materials.add(m)
class DispatchGalleryMixin(object):
def dispatch_gallery(self, course, gallery):
if gallery:
if 'id' in gallery and gallery['id']:
g = Gallery.objects.get(id=gallery['id'])
g.title = gallery.get('title', g.title)
g.position = 0
g.save()
else:
g = Gallery.objects.create(
title=gallery.get('title', ''),
position=0,
)
if 'images' in gallery:
for image in gallery['images']:
if 'id' in image and image['id']:
gi = GalleryImage.objects.get(id=image['id'])
gi.gallery = g
gi.img = image['img']
gi.save()
else:
gi = GalleryImage.objects.create(
gallery=g,
img=image['img'],
)
course.gallery = g
course.save()

@ -0,0 +1,53 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from . import Base64ImageField
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
'id',
'username',
'email',
'first_name',
'last_name',
'is_staff',
'is_active',
'date_joined',
'role',
'gender',
'country',
'city',
'about',
'instagram',
'facebook',
'twitter',
'pinterest',
'youtube',
'vkontakte',
'fb_id',
'fb_data',
'is_email_proved',
'photo',
)
read_only_fields = (
'id',
'photo',
'date_joined',
'is_staff',
'fb_id',
'fb_data',
)
class UserPhotoSerializer(serializers.Serializer):
photo = Base64ImageField(
required=False, allow_empty_file=True, allow_null=True
)

@ -0,0 +1,53 @@
from django.urls import path, include
from rest_framework import permissions
from rest_framework.routers import DefaultRouter
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from .auth import ObtainToken
from .views import (
CategoryViewSet, CourseViewSet,
MaterialViewSet, LikeViewSet,
ImageViewSet, TextViewSet,
ImageTextViewSet, VideoViewSet,
GalleryViewSet, GalleryImageViewSet,
UserViewSet, LessonViewSet, ImageObjectViewSet,
)
router = DefaultRouter()
router.register(r'courses', CourseViewSet, base_name='courses')
router.register(r'categories', CategoryViewSet, base_name='categories')
router.register(r'materials', MaterialViewSet, base_name='materials')
router.register(r'lessons', LessonViewSet, base_name='lessons')
router.register(r'likes', LikeViewSet, base_name='likes')
router.register(r'image-objects', ImageObjectViewSet, base_name='image-objects')
router.register(r'images', ImageViewSet, base_name='images')
router.register(r'texts', TextViewSet, base_name='texts')
router.register(r'image-texts', ImageTextViewSet, base_name='image-texts')
router.register(r'videos', VideoViewSet, base_name='videos')
router.register(r'galleries', GalleryViewSet, base_name='galleries')
router.register(r'gallery-images', GalleryImageViewSet, base_name='gallery-images')
router.register(r'users', UserViewSet, base_name='users')
schema_view = get_schema_view(
openapi.Info(
title="Lil Sity API",
default_version='v1',
description="Routes of Lil City project",
),
validators=['flex', 'ssv'],
public=False,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [
path('swagger(<str:format>.json|.yaml)', schema_view.without_ui(cache_timeout=None), name='schema-json'),
path('swagger/', schema_view.with_ui('swagger', cache_timeout=None), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=None), name='schema-redoc'),
path('api-token-auth/', ObtainToken.as_view(), name='api-token-auth'),
path('', include((router.urls, 'api-root')), name='api-root'),
]

@ -0,0 +1,243 @@
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework import viewsets
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response
from . import ExtendedModelViewSet
from .serializers.course import (
CategorySerializer, LikeSerializer,
CourseSerializer, CourseCreateSerializer,
MaterialSerializer, MaterialCreateSerializer,
LessonSerializer, LessonCreateSerializer,
)
from .serializers.content import (
ImageSerializer, ImageCreateSerializer,
TextSerializer, TextCreateSerializer,
ImageTextSerializer, ImageTextCreateSerializer,
VideoSerializer, VideoCreateSerializer,
GallerySerializer,
GalleryImageSerializer, GalleryImageCreateSerializer,
ImageObjectSerializer,
)
from .serializers.user import (
UserSerializer, UserPhotoSerializer,
)
from .permissions import IsAdmin, IsAdminOrIsSelf, IsAuthorOrAdmin, IsAuthorObjectOrAdmin
from apps.course.models import Category, Course, Material, Lesson, Like
from apps.content.models import (
Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
User = get_user_model()
class ImageObjectViewSet(ExtendedModelViewSet):
queryset = ImageObject.objects.all()
serializer_class = ImageObjectSerializer
# permission_classes = (IsAuthorOrAdmin,)
class MaterialViewSet(ExtendedModelViewSet):
queryset = Material.objects.all()
serializer_class = MaterialCreateSerializer
serializer_class_map = {
'list': MaterialSerializer,
'retrieve': MaterialSerializer,
}
search_fields = ('title', 'short_description',)
ordering_fields = ('title', 'created_at', 'update_at',)
# permission_classes = (IsAdmin,)
class LikeViewSet(ExtendedModelViewSet):
queryset = Like.objects.select_related('user').all()
serializer_class = LikeSerializer
search_fields = ('user__email', 'user__firstname', 'user__lastname',)
ordering_fields = ('created_at', 'update_at',)
# permission_classes = (IsAdmin,)
class CategoryViewSet(ExtendedModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
search_fields = ('title',)
ordering_fields = ('title',)
# permission_classes = (IsAdmin,)
class CourseViewSet(ExtendedModelViewSet):
queryset = Course.objects.select_related(
'author', 'category', 'cover', 'gallery',
).prefetch_related(
'likes', 'materials', 'content',
).all()
serializer_class = CourseCreateSerializer
serializer_class_map = {
'list': CourseSerializer,
'retrieve': CourseSerializer,
}
filter_fields = ('category', 'status', 'is_infinite', 'is_featured',)
search_fields = ('author__email', 'title', 'category__title',)
ordering_fields = ('title', 'created_at', 'update_at',)
# permission_classes = (IsAuthorObjectOrAdmin,)
# permission_map = {
# 'create': IsAuthorOrAdmin,
# 'delete': IsAdmin,
# }
class LessonViewSet(ExtendedModelViewSet):
queryset = Lesson.objects.select_related(
'course', 'cover'
).prefetch_related('content').all()
serializer_class = LessonCreateSerializer
serializer_class_map = {
'list': LessonSerializer,
'retrieve': LessonSerializer,
}
filter_fields = ('course',)
search_fields = ('title', 'short_description',)
ordering_fields = ('title', 'created_at', 'update_at',)
# permission_classes = (IsAuthorObjectOrAdmin,)
# permission_map = {
# 'create': IsAuthorOrAdmin,
# 'delete': IsAdmin,
# }
class ImageViewSet(ExtendedModelViewSet):
queryset = Image.objects.select_related(
'course', 'lesson', 'img',
).all()
serializer_class = ImageCreateSerializer
serializer_class_map = {
'list': ImageSerializer,
'retrieve': ImageSerializer,
}
search_fields = ('title',)
ordering_fields = ('title', 'created_at', 'update_at', 'position',)
# permission_classes = (IsAuthorOrAdmin,)
# permission_map = {
# 'delete': IsAdmin,
# }
class TextViewSet(ExtendedModelViewSet):
queryset = Text.objects.select_related(
'course', 'lesson'
).all()
serializer_class = TextCreateSerializer
serializer_class_map = {
'list': TextSerializer,
'retrieve': TextSerializer,
}
search_fields = ('title',)
ordering_fields = ('title', 'created_at', 'update_at', 'position',)
# permission_classes = (IsAuthorOrAdmin,)
# permission_map = {
# 'delete': IsAdmin,
# }
class ImageTextViewSet(ExtendedModelViewSet):
queryset = ImageText.objects.select_related(
'course', 'lesson', 'img'
).all()
serializer_class = ImageTextCreateSerializer
serializer_class_map = {
'list': ImageTextSerializer,
'retrieve': ImageTextSerializer,
}
search_fields = ('title',)
ordering_fields = ('title', 'created_at', 'update_at', 'position',)
# permission_classes = (IsAuthorOrAdmin,)
# permission_map = {
# 'delete': IsAdmin,
# }
class VideoViewSet(ExtendedModelViewSet):
queryset = Video.objects.select_related(
'course', 'lesson'
).all()
serializer_class = VideoCreateSerializer
serializer_class_map = {
'list': VideoSerializer,
'retrieve': VideoSerializer,
}
search_fields = ('title',)
ordering_fields = ('title', 'created_at', 'update_at', 'position',)
# permission_classes = (IsAuthorOrAdmin,)
# permission_map = {
# 'delete': IsAdmin,
# }
class GalleryViewSet(ExtendedModelViewSet):
queryset = Gallery.objects.all()
serializer_class = GallerySerializer
search_fields = ('title',)
ordering_fields = ('title', 'created_at', 'update_at',)
# permission_classes = (IsAuthorOrAdmin,)
# permission_map = {
# 'delete': IsAdmin,
# }
class GalleryImageViewSet(ExtendedModelViewSet):
queryset = GalleryImage.objects.select_related(
'gallery', 'img',
).all()
serializer_class = GalleryImageCreateSerializer
search_fields = ('gallery__title',)
serializer_class_map = {
'list': GalleryImageSerializer,
'retrieve': GalleryImageSerializer,
}
# permission_classes = (IsAuthorOrAdmin,)
# permission_map = {
# 'delete': IsAdmin,
# }
class UserViewSet(ExtendedModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
serializer_class_map = {
'upload_photo': UserPhotoSerializer,
}
filter_fields = ('is_staff', 'is_active', 'role',
'gender', 'is_email_proved', 'fb_id',)
search_fields = ('email', 'first_name', 'last_name',
'country', 'city', 'fb_id',)
ordering_fields = ('email', 'first_name', 'last_name',
'country', 'city', 'date_joined',)
# permission_classes = (IsAdminOrIsSelf,)
# permission_map = {
# 'delete': IsAdmin,
# }
@list_route(methods=['get'])
def me(self, request):
serializer = self.get_serializer_class()
serialized_data = serializer(instance=request.user)
return Response(serialized_data.data)
@detail_route(methods=['post'], url_path='upload-photo')
def upload_photo(self, request, pk=None):
user = self.get_object()
serializer = self.get_serializer()
serialized_data = serializer(data=request.data)
if serialized_data.is_valid():
user.photo = serialized_data['photo']
user.save()
return Response({'success': True})
else:
return Response({'success': False}, status=status.HTTP_400_BAD_REQUEST)

@ -0,0 +1,72 @@
from django.contrib import admin
from polymorphic.admin import (
PolymorphicParentModelAdmin,
PolymorphicChildModelAdmin,
PolymorphicChildModelFilter,
)
from apps.content.models import (
Content, Image, Text, ImageText, Video,
Gallery, GalleryImage, ImageObject,
)
@admin.register(ImageObject)
class ImageObjectAdmin(admin.ModelAdmin):
list_display = (
'id',
'image',
'created_at',
'update_at',
)
class ContentChildAdmin(PolymorphicChildModelAdmin):
base_model = Content
show_in_index = True
base_fieldsets = (
(None, {'fields': ('course', 'lesson', 'title', 'position',)}),
)
@admin.register(Image)
class ImageAdmin(ContentChildAdmin):
base_model = Image
@admin.register(Text)
class TextAdmin(ContentChildAdmin):
base_model = Text
@admin.register(ImageText)
class ImageTextAdmin(ContentChildAdmin):
base_model = ImageText
@admin.register(Video)
class VideoAdmin(ContentChildAdmin):
base_model = Video
@admin.register(Gallery)
class GalleryAdmin(ContentChildAdmin):
base_model = Gallery
@admin.register(Content)
class ContentAdmin(PolymorphicParentModelAdmin):
base_model = Content
polymorphic_list = True
child_models = (
Image,
Text,
ImageText,
Video,
# GalleryAdmin,
)
@admin.register(GalleryImage)
class GalleryImageAdmin(admin.ModelAdmin):
pass

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContentConfig(AppConfig):
name = 'content'
verbose_name = 'Контент'

@ -0,0 +1,93 @@
# Generated by Django 2.0.2 on 2018-02-05 12:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('course', '0020_auto_20180202_1716'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Content',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(default='', max_length=100, verbose_name='Заголовок')),
('position', models.PositiveSmallIntegerField(default=1, unique=True, verbose_name='Положение на странице')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='Image',
fields=[
('content_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='content.Content')),
('img', models.ImageField(upload_to='content/images', verbose_name='Изображение')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('content.content',),
),
migrations.CreateModel(
name='ImageText',
fields=[
('content_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='content.Content')),
('img', models.ImageField(upload_to='content/images', verbose_name='Изображение')),
('txt', models.TextField(default='', verbose_name='Текст')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('content.content',),
),
migrations.CreateModel(
name='Text',
fields=[
('content_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='content.Content')),
('txt', models.TextField(default='', verbose_name='Текст')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('content.content',),
),
migrations.CreateModel(
name='Video',
fields=[
('content_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='content.Content')),
('url', models.URLField(verbose_name='Ссылка')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('content.content',),
),
migrations.AddField(
model_name='content',
name='course',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course.Course', verbose_name='Курс'),
),
migrations.AddField(
model_name='content',
name='lesson',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course.Lesson', verbose_name='Урок'),
),
migrations.AddField(
model_name='content',
name='polymorphic_ctype',
field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_content.content_set+', to='contenttypes.ContentType'),
),
]

@ -0,0 +1,29 @@
# Generated by Django 2.0.2 on 2018-02-05 12:12
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('content', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='content',
options={'verbose_name': 'Контент', 'verbose_name_plural': 'Контент'},
),
migrations.AddField(
model_name='content',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='content',
name='update_at',
field=models.DateTimeField(auto_now=True),
),
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.2 on 2018-02-05 12:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('content', '0002_auto_20180205_1212'),
]
operations = [
migrations.AlterModelOptions(
name='content',
options={'ordering': ('-created_at',), 'verbose_name': 'Контент', 'verbose_name_plural': 'Контент'},
),
]

@ -0,0 +1,45 @@
# Generated by Django 2.0.2 on 2018-02-05 13:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0020_auto_20180202_1716'),
('content', '0003_auto_20180205_1246'),
]
operations = [
migrations.CreateModel(
name='Gallery',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(default='', max_length=100, verbose_name='Заголовок')),
('created_at', models.DateTimeField(auto_now_add=True)),
('update_at', models.DateTimeField(auto_now=True)),
('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='course.Course', verbose_name='Курс')),
],
options={
'verbose_name': 'Галерея',
'verbose_name_plural': 'Галереи',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='GalleryImage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='content/gallery_images', verbose_name='Изображение')),
('created_at', models.DateTimeField(auto_now_add=True)),
('update_at', models.DateTimeField(auto_now=True)),
('gallery', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.Gallery', verbose_name='Галерея')),
],
options={
'verbose_name': 'Изображение в галерее',
'verbose_name_plural': 'Изображения в галерее',
'ordering': ('-created_at',),
},
),
]

@ -0,0 +1,31 @@
# Generated by Django 2.0.2 on 2018-02-08 05:20
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0004_gallery_galleryimage'),
]
operations = [
migrations.CreateModel(
name='ImageObject',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='content/imageobject', verbose_name='Изображение')),
],
),
migrations.AlterField(
model_name='content',
name='course',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content', to='course.Course', verbose_name='Курс'),
),
migrations.AlterField(
model_name='content',
name='lesson',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content', to='course.Lesson', verbose_name='Урок'),
),
]

@ -0,0 +1,29 @@
# Generated by Django 2.0.2 on 2018-02-08 05:51
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('content', '0005_auto_20180208_0520'),
]
operations = [
migrations.AlterModelOptions(
name='imageobject',
options={'ordering': ('-created_at',), 'verbose_name': 'Объект изображения', 'verbose_name_plural': 'Объекты изображения'},
),
migrations.AddField(
model_name='imageobject',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='imageobject',
name='update_at',
field=models.DateTimeField(auto_now=True),
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-08 06:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0006_auto_20180208_0551'),
]
operations = [
migrations.AlterField(
model_name='image',
name='img',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_images', to='content.ImageObject', verbose_name='Объект изображения'),
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-08 06:31
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0007_auto_20180208_0626'),
]
operations = [
migrations.AlterField(
model_name='imagetext',
name='img',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='content_imagetexts', to='content.ImageObject', verbose_name='Объект изображения'),
),
]

@ -0,0 +1,23 @@
# Generated by Django 2.0.2 on 2018-02-08 06:37
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0008_auto_20180208_0631'),
]
operations = [
migrations.RemoveField(
model_name='galleryimage',
name='image',
),
migrations.AddField(
model_name='galleryimage',
name='img',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='gallery_images', to='content.ImageObject', verbose_name='Объект изображения'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.2 on 2018-02-08 08:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('content', '0009_auto_20180208_0637'),
]
operations = [
migrations.RemoveField(
model_name='gallery',
name='course',
),
]

@ -0,0 +1,18 @@
# Generated by Django 2.0.2 on 2018-02-09 15:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0010_remove_gallery_course'),
]
operations = [
migrations.AlterField(
model_name='content',
name='position',
field=models.PositiveSmallIntegerField(default=1, verbose_name='Положение на странице'),
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-09 18:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0011_auto_20180209_1549'),
]
operations = [
migrations.AlterField(
model_name='galleryimage',
name='gallery',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_images', to='content.Gallery', verbose_name='Галерея'),
),
]

@ -0,0 +1,40 @@
# Generated by Django 2.0.2 on 2018-02-12 05:37
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0012_auto_20180209_1847'),
]
operations = [
migrations.AlterModelOptions(
name='gallery',
options={'base_manager_name': 'objects'},
),
migrations.RemoveField(
model_name='gallery',
name='created_at',
),
migrations.RemoveField(
model_name='gallery',
name='id',
),
migrations.RemoveField(
model_name='gallery',
name='title',
),
migrations.RemoveField(
model_name='gallery',
name='update_at',
),
migrations.AddField(
model_name='gallery',
name='content_ptr',
field=models.OneToOneField(auto_created=True, default=1, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='content.Content'),
preserve_default=False,
),
]

@ -0,0 +1,96 @@
from django.db import models
from polymorphic.models import PolymorphicModel
class ImageObject(models.Model):
image = models.ImageField('Изображение', upload_to='content/imageobject')
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Объект изображения'
verbose_name_plural = 'Объекты изображения'
ordering = ('-created_at',)
class Content(PolymorphicModel):
course = models.ForeignKey(
'course.Course', on_delete=models.CASCADE,
null=True, blank=True,
verbose_name='Курс',
related_name='content',
)
lesson = models.ForeignKey(
'course.Lesson', on_delete=models.CASCADE,
null=True, blank=True,
verbose_name='Урок',
related_name='content',
)
title = models.CharField('Заголовок', max_length=100, default='')
position = models.PositiveSmallIntegerField(
'Положение на странице',
default=1,
)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Контент'
verbose_name_plural = 'Контент'
ordering = ('position', '-created_at',)
def ctype(self):
return self.__class__.__name__.lower()
class Image(Content):
img = models.ForeignKey(
ImageObject, related_name='content_images',
verbose_name='Объект изображения', on_delete=models.CASCADE,
)
class Text(Content):
txt = models.TextField('Текст', default='')
class ImageText(Content):
img = models.ForeignKey(
ImageObject, related_name='content_imagetexts',
verbose_name='Объект изображения', on_delete=models.CASCADE,
)
txt = models.TextField('Текст', default='')
class Video(Content):
url = models.URLField('Ссылка')
def video_index(self):
return self.url.split('/')[-1]
class Gallery(Content):
pass
class GalleryImage(models.Model):
gallery = models.ForeignKey(
Gallery, on_delete=models.CASCADE,
verbose_name='Галерея', related_name='gallery_images'
)
img = models.ForeignKey(
ImageObject, related_name='gallery_images',
verbose_name='Объект изображения', on_delete=models.CASCADE,
null=True, blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Изображение в галерее'
verbose_name_plural = 'Изображения в галерее'
ordering = ('-created_at',)

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,2 @@
from django.shortcuts import render

@ -16,6 +16,7 @@ class CourseAdmin(admin.ModelAdmin):
'created_at',
'update_at',
)
prepopulated_fields = {"slug": ("title",)}
@admin.register(Category)

@ -7,14 +7,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "1500.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 2,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:04:41.113Z",
"update_at": "2018-01-31T15:03:47.118Z",
@ -30,14 +28,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "1900.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 1,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.437Z",
"update_at": "2018-01-31T15:03:47.115Z",
@ -53,14 +49,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "100.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 9,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.442Z",
"update_at": "2018-01-31T15:03:47.112Z",
@ -76,14 +70,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "400.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 8,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.445Z",
"update_at": "2018-01-31T15:03:47.108Z",
@ -99,14 +91,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "1800.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 7,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.449Z",
"update_at": "2018-01-31T15:03:47.104Z",
@ -122,14 +112,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "100.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 6,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.452Z",
"update_at": "2018-01-31T15:03:47.101Z",
@ -145,14 +133,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "1600.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 5,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.455Z",
"update_at": "2018-01-31T15:03:47.097Z",
@ -168,14 +154,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "1900.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 4,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.458Z",
"update_at": "2018-01-31T15:03:47.093Z",
@ -191,14 +175,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "200.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 3,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 0,
"created_at": "2018-01-27T07:09:03.461Z",
"update_at": "2018-01-31T15:03:47.089Z",
@ -214,14 +196,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "800.00",
"is_infinite": false,
"deferred_start_at": null,
"category": 2,
"duration": 1,
"is_featured": true,
"url": "https://gitlab.com/",
"status": 1,
"created_at": "2018-01-27T07:09:03.464Z",
"update_at": "2018-01-31T15:03:47.086Z",
@ -241,14 +221,12 @@
"title": "\u0411\u0430\u0437\u043e\u0432\u044b\u0439 \u043a\u0443\u0440\u0441 \u0434\u043b\u044f \u0434\u0435\u0442\u0435\u0439 \u043f\u043e \u043e\u0441\u043d\u043e\u0432\u0430\u043c \u0438\u043b\u043b\u044e\u0441\u0442\u0440\u0430\u0446\u0438\u0438",
"short_description": "\u042d\u0442\u043e\u0442 \u043a\u0443\u0440\u0441 \u043f\u043e\u043c\u043e\u0436\u0435\u0442 \u0434\u0435\u0442\u044f\u043c \u0443\u0437\u043d\u0430\u0442\u044c \u043e \u0442\u043e\u043c \u043a\u0430\u043a \u0438\u0437 \u043f\u0440\u043e\u0441\u0442\u044b\u0445 \u0444\u043e\u0440\u043c \u0441\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0432\u0435\u0441\u0435\u043b\u044b\u0439 \u0438 \u0445\u0430\u0440\u0438\u0437\u043c\u0430\u0442\u0438\u0447\u043d\u044b\u0445 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0435\u0439.",
"from_author": "",
"cover": "courses/pic-1_sTaZawQ.jpg",
"price": "100.00",
"is_infinite": false,
"deferred_start_at": "2018-02-28T12:00:00Z",
"category": 1,
"duration": 1,
"is_featured": false,
"url": "https://gitlab.com/",
"status": 1,
"created_at": "2018-01-27T07:09:03.467Z",
"update_at": "2018-01-31T15:03:47.080Z",
@ -330,7 +308,6 @@
"title": "1 \u0423\u0420\u041e\u041a",
"short_description": "\u0412\u044b\u0431\u0438\u0440\u0430\u0435\u043c \u0441\u044e\u0436\u0435\u0442, \u0441 \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043c\u044b \u0431\u0443\u0434\u0435\u043c \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043d\u0430 \u043a\u0443\u0440\u0441\u0435 \u0438 \u0433\u043b\u0430\u0432\u043d\u043e\u0433\u043e \u0433\u0435\u0440\u043e\u044f \u0432\u0430\u0448\u0435\u0439 \u0438\u0441\u0442\u043e\u0440\u0438\u0438. \u0421 \u044d\u0442\u0438\u043c \u0433\u0435\u0440\u043e\u0435\u043c \u043c\u044b \u0431\u0443\u0434\u0435\u043c \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u043d\u0430 \u043f\u0440\u043e\u0442\u044f\u0436\u0435\u043d\u0438\u0438 \u0432\u0441\u0435\u0433\u043e \u043a\u0443\u0440\u0441\u0430.",
"course": 11,
"cover": "lessons/kat-watercolor.jpg",
"created_at": "2018-01-31T15:06:14.830Z",
"update_at": "2018-01-31T15:06:14.830Z"
}
@ -342,7 +319,6 @@
"title": "2 \u0423\u0420\u041e\u041a",
"short_description": "\u0421\u043e\u0431\u0438\u0440\u0430\u0435\u043c \u043c\u0430\u0442\u0435\u0440\u0438\u0430\u043b \u0438 \u044d\u043a\u0441\u043f\u0435\u0440\u0438\u043c\u0435\u043d\u0442\u0438\u0440\u0443\u0435\u043c \u0441 \u043e\u0431\u0440\u0430\u0437\u043e\u043c \u0433\u0435\u0440\u043e\u044f, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u0441\u043f\u0438\u0441\u043e\u043a \u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u043d\u044b\u0445 \u043e\u0441\u043e\u0431\u0435\u043d\u043d\u043e\u0441\u0442\u0435\u0439 \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0430. \u041f\u043e \u043a\u0430\u0436\u0434\u043e\u043c\u0443 \u043f\u0440\u0438\u0437\u043d\u0430\u043a\u0443 \u043d\u0443\u0436\u043d\u043e \u0431\u0443\u0434\u0435\u0442 \u0441\u043e\u0431\u0440\u0430\u0442\u044c \u00ab\u0440\u0435\u0444\u0435\u0440\u0435\u043d\u0441\u044b\u00bb. \u0420\u0438\u0441\u0443\u0435\u043c \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u043e\u0431\u0440\u0430\u0437\u0430 \u0432 \u0441\u0432\u043e\u0435\u043c \u0441\u0442\u0438\u043b\u0435.\r\n\r\n\u0421\u043e\u0431\u0438\u0440\u0430\u0435\u043c \u0438\u0437 \u043d\u0438\u0445 \u043d\u0430\u0448\u0435\u0433\u043e \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0430. \u0412\u044b\u0431\u0438\u0440\u0430\u0435\u043c \u0441\u0430\u043c\u044b\u0435 \u0443\u0434\u0430\u0447\u043d\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b, \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u044f\u0435\u043c \u0438\u0445 \u0432 \u043e\u0434\u043d\u043e\u043c \u043d\u0430\u0431\u0440\u043e\u0441\u043a\u0435.",
"course": 11,
"cover": "lessons/kat-watercolor_SA9juHa.jpg",
"created_at": "2018-01-31T15:06:46.772Z",
"update_at": "2018-01-31T15:06:46.772Z"
}
@ -354,7 +330,6 @@
"title": "3 \u0423\u0420\u041e\u041a",
"short_description": "\u041f\u043e\u043f\u0440\u043e\u0431\u0443\u0435\u043c \u043e\u0436\u0438\u0432\u0438\u0442\u044c \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0430. \u0412\u044b\u0431\u0438\u0440\u0430\u0435\u043c 5 \u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u043d\u044b\u0445 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439 \u0434\u043b\u044f \u0432\u0430\u0448\u0435\u0433\u043e \u0433\u0435\u0440\u043e\u044f \u0438 \u0442\u043e\u0433\u043e \u0441\u044e\u0436\u0435\u0442\u0430, \u0432 \u043a\u043e\u0442\u043e\u0440\u043e\u043c \u043e\u043d \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0435\u0442 \u0443\u0447\u0430\u0441\u0442\u0438\u0435, \u0440\u0438\u0441\u0443\u0435\u043c \u044d\u0441\u043a\u0438\u0437\u044b \u0432\u0430\u0448\u0435\u0433\u043e \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u0436\u0430 \u0432 \u0440\u0430\u0437\u043d\u044b\u0445 \u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u043d\u044b\u0445 \u0434\u043b\u044f \u043d\u0435\u0433\u043e \u043f\u043e\u0437\u0430\u0445 \u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0438.\r\n\r\n\u0412\u044b\u0434\u0435\u043b\u044f\u0435\u043c 5 \u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u043d\u044b\u0445 \u044d\u043c\u043e\u0446\u0438\u0439 \u0434\u043b\u044f \u0433\u0435\u0440\u043e\u044f, \u043d\u0430\u0434 \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u043c. \u041f\u043e \u044d\u043c\u043e\u0446\u0438\u044f\u043c \u043f\u043e\u0434\u0431\u0438\u0440\u0430\u0435\u043c \u0440\u0435\u0444\u0435\u0440\u0435\u043d\u0441\u044b \u0438 \u0441\u0442\u0438\u043b\u0438\u0437\u0443\u0435\u043c \u0438\u0445 \u0432 \u0441\u0432\u043e\u0435\u043c \u0441\u0442\u0438\u043b\u0435.",
"course": 11,
"cover": "lessons/kat-watercolor_QYFi9sq.jpg",
"created_at": "2018-01-31T15:07:08.979Z",
"update_at": "2018-01-31T15:07:08.979Z"
}
@ -364,7 +339,6 @@
"pk": 1,
"fields": {
"title": "\u0411\u0443\u043c\u0430\u0433\u0430 \u0430\u043a\u0432\u0430\u0440\u0435\u043b\u044c\u043d\u0430\u044f",
"cover": "materials/kat-watercolor.jpg",
"short_description": "\u0411\u0443\u043c\u0430\u0433\u0430 \u0434\u043b\u044f \u0440\u0430\u0431\u043e\u0442\u044b \u0441 \u0430\u043a\u0432\u0430\u0440\u0435\u043b\u044c\u044e \u0438\u043c\u0435\u0435\u0442 \u0431\u043e\u043b\u044c\u0448\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435. \u042d\u0442\u043e \u043e\u0431\u044a\u044f\u0441\u043d\u044f\u0435\u0442\u0441\u044f \u0442\u0435\u043c, \u0447\u0442\u043e \u0430\u043a\u0432\u0430\u0440\u0435\u043b\u044c \u2014 \u043a\u0440\u0430\u0441\u043a\u0430 \u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u0430\u044f, \u0430 \u0437\u043d\u0430\u0447\u0438\u0442 \u0444\u0430\u043a\u0442\u0443\u0440\u0430 \u0431\u0443\u043c\u0430\u0433\u0438 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u0441\u0438\u043b\u044c\u043d\u043e \u0432\u043b\u0438\u044f\u0442\u044c \u043d\u0430 \u0432\u043d\u0435\u0448\u043d\u0438\u0439 \u0432\u0438\u0434 \u043a\u0440\u0430\u0441\u043e\u0447\u043d\u043e\u0433\u043e \u0441\u043b\u043e\u044f.",
"created_at": "2018-01-31T14:55:48.394Z",
"update_at": "2018-01-31T14:55:48.394Z"
@ -375,7 +349,6 @@
"pk": 2,
"fields": {
"title": "\u041a\u0438\u0441\u0442\u043e\u0447\u043a\u0438 \u0434\u043b\u044f \u0440\u0438\u0441\u043e\u0432\u0430\u043d\u0438\u044f",
"cover": "materials/shutterstock_125323070-700x861.jpg",
"short_description": "\u041a\u0438\u0441\u0442\u044c \u2014 \u0438\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442 \u0434\u043b\u044f \u043f\u043e\u043a\u0440\u0430\u0441\u043a\u0438 \u0438 \u0436\u0438\u0432\u043e\u043f\u0438\u0441\u0438. \u041a\u0438\u0441\u0442\u0438 \u0434\u0435\u043b\u0430\u044e\u0442\u0441\u044f \u0438\u0437 \u0449\u0435\u0442\u0438\u043d\u044b \u0438 \u0445\u0432\u043e\u0441\u0442\u043e\u0432\u044b\u0445 \u0432\u043e\u043b\u043e\u0441\u043a\u043e\u0432 \u0440\u0430\u0437\u043b\u0438\u0447\u043d\u044b\u0445 \u0436\u0438\u0432\u043e\u0442\u043d\u044b\u0445.",
"created_at": "2018-01-31T14:57:37.751Z",
"update_at": "2018-01-31T14:57:37.751Z"
@ -386,7 +359,6 @@
"pk": 3,
"fields": {
"title": "\u041a\u0440\u0430\u0441\u043a\u0438 \u0430\u043a\u0432\u0430\u0440\u0435\u043b\u044c\u043d\u044b\u0435",
"cover": "materials/\u043a\u0440\u0430\u0441\u043a\u0438.jpeg",
"short_description": "\u0417\u0430\u0432\u043e\u0434 \u0445\u0443\u0434\u043e\u0436\u0435\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0445 \u043a\u0440\u0430\u0441\u043e\u043a \u00ab\u041d\u0435\u0432\u0441\u043a\u0430\u044f \u043f\u0430\u043b\u0438\u0442\u0440\u0430\u00bb \u0432\u044b\u043f\u0443\u0441\u043a\u0430\u0435\u0442 \u0430\u043a\u0432\u0430\u0440\u0435\u043b\u044c 80 \u043b\u0435\u0442, \u0441\u043e\u0445\u0440\u0430\u043d\u044f\u044f \u0442\u0440\u0430\u0434\u0438\u0446\u0438\u0438 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0441\u0442\u0432\u0430 \u043f\u0440\u043e\u0434\u0443\u043a\u0446\u0438\u0438 \u0432\u044b\u0441\u043e\u0447\u0430\u0439\u0448\u0435\u0433\u043e \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0430. \u041f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u043d\u044b\u0435 \u0432\u0440\u0435\u043c\u0435\u043d\u0435\u043c \u0440\u0435\u0446\u0435\u043f\u0442\u0443\u0440\u044b, \u043e\u0442\u043b\u0430\u0436\u0435\u043d\u043d\u0430\u044f \u0442\u0435\u0445\u043d\u043e\u043b\u043e\u0433\u0438\u044f \u0438\u0437\u0433\u043e\u0442\u043e\u0432\u043b\u0435\u043d\u0438\u044f \u0441\u0434\u0435\u043b\u0430\u043b\u0438 \u0430\u043a\u0432\u0430\u0440\u0435\u043b\u044c\u043d\u044b\u0435 \u043a\u0440\u0430\u0441\u043a\u0438 \u0432\u0438\u0437\u0438\u0442\u043d\u043e\u0439 \u043a\u0430\u0440\u0442\u043e\u0447\u043a\u043e\u0439 \u043f\u0440\u0435\u0434\u043f\u0440\u0438\u044f\u0442\u0438\u044f \u0432 \u0420\u043e\u0441\u0441\u0438\u0438",
"created_at": "2018-01-31T14:58:46.209Z",
"update_at": "2018-01-31T14:58:46.209Z"

@ -1,35 +0,0 @@
# Generated by Django 2.0.2 on 2018-02-05 15:59
from django.db import migrations
from django.contrib.contenttypes.models import ContentType
def fwrd_func(apps, schema_editor):
CourseComment = apps.get_model('course', 'CourseComment')
LessonComment = apps.get_model('course', 'LessonComment')
db_alias = schema_editor.connection.alias
if CourseComment.objects.exists():
coursecomment_content_type = ContentType.objects.get(
app_label='course', model='coursecomment',
)
CourseComment.objects.using(db_alias).all().update(
polymorphic_ctype=coursecomment_content_type,
)
if LessonComment.objects.exists():
lessoncomment_content_type = ContentType.objects.get(
app_label='course', model='lessoncomment',
)
LessonComment.objects.using(db_alias).all().update(
polymorphic_ctype=lessoncomment_content_type,
)
class Migration(migrations.Migration):
dependencies = [
('course', '0020_auto_20180202_1716'),
]
operations = [
migrations.RunPython(fwrd_func)
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.2 on 2018-02-06 06:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('course', '0020_auto_20180202_1716'),
]
operations = [
migrations.AlterModelOptions(
name='category',
options={'ordering': ['title'], 'verbose_name': 'Категория', 'verbose_name_plural': 'Категории'},
),
]

@ -1,35 +0,0 @@
# Generated by Django 2.0.2 on 2018-02-05 16:15
from django.db import migrations
from django.contrib.contenttypes.models import ContentType
def fwrd_func(apps, schema_editor):
CourseComment = apps.get_model('course', 'CourseComment')
LessonComment = apps.get_model('course', 'LessonComment')
db_alias = schema_editor.connection.alias
if CourseComment.objects.using(db_alias).all().exists():
coursecomment_content_type = ContentType.objects.get(
app_label='course', model='coursecomment',
)
CourseComment.objects.using(db_alias).all().update(
polymorphic_ctype=coursecomment_content_type,
)
if LessonComment.objects.using(db_alias).all().exists():
lessoncomment_content_type = ContentType.objects.get(
app_label='course', model='lessoncomment',
)
LessonComment.objects.using(db_alias).all().update(
polymorphic_ctype=lessoncomment_content_type,
)
class Migration(migrations.Migration):
dependencies = [
('course', '0021_auto_20180205_1559'),
]
operations = [
migrations.RunPython(fwrd_func)
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-08 06:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0021_auto_20180206_0632'),
]
operations = [
migrations.AlterField(
model_name='course',
name='cover',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_covers', to='content.ImageObject', verbose_name='Обложка курса'),
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-08 07:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0022_auto_20180208_0647'),
]
operations = [
migrations.AlterField(
model_name='course',
name='cover',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='course_covers', to='content.ImageObject', verbose_name='Обложка курса'),
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-08 08:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0023_auto_20180208_0714'),
]
operations = [
migrations.AlterField(
model_name='lesson',
name='cover',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lesson_covers', to='content.ImageObject', verbose_name='Обложка урока'),
),
]

@ -0,0 +1,20 @@
# Generated by Django 2.0.2 on 2018-02-08 08:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('content', '0010_remove_gallery_course'),
('course', '0024_auto_20180208_0824'),
]
operations = [
migrations.AddField(
model_name='course',
name='gallery',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='content.Gallery', verbose_name='Галерея работ'),
),
]

@ -0,0 +1,24 @@
# Generated by Django 2.0.2 on 2018-02-08 10:53
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0025_course_gallery'),
]
operations = [
migrations.AlterField(
model_name='course',
name='price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Если цена не выставлена, то курс бесплатный', max_digits=10, null=True, verbose_name='Цена курса'),
),
migrations.AlterField(
model_name='material',
name='cover',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='material_covers', to='content.ImageObject', verbose_name='Обложка материала'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.2 on 2018-02-09 08:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('course', '0026_auto_20180208_1053'),
]
operations = [
migrations.RemoveField(
model_name='course',
name='url',
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-09 08:59
from uuid import uuid4
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0027_remove_course_url'),
]
operations = [
migrations.AddField(
model_name='course',
name='slug',
field=models.SlugField(allow_unicode=True, default=str(uuid4()), max_length=100, unique=True),
preserve_default=False,
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-09 09:11
import apps.course.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0028_course_slug'),
]
operations = [
migrations.AlterField(
model_name='course',
name='slug',
field=models.SlugField(allow_unicode=True, default=apps.course.models.default_slug, max_length=100, unique=True),
),
]

@ -0,0 +1,19 @@
# Generated by Django 2.0.2 on 2018-02-12 05:37
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0029_auto_20180209_0911'),
]
operations = [
migrations.AlterField(
model_name='course',
name='gallery',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='results_gallery', to='content.Gallery', verbose_name='Галерея работ'),
),
]

@ -0,0 +1,23 @@
# Generated by Django 2.0.2 on 2018-02-13 09:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0030_auto_20180212_0537'),
]
operations = [
migrations.AddField(
model_name='course',
name='deactivated_at',
field=models.DateTimeField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='lesson',
name='deactivated_at',
field=models.DateTimeField(blank=True, default=None, null=True),
),
]

@ -1,12 +1,18 @@
import arrow
from uuid import uuid4
from django.db import models
from django.utils import timezone
from django.utils.text import slugify
from django.contrib.auth import get_user_model
from django.urls import reverse_lazy
from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey
from project.mixins import BaseModel, DeactivatedMixin
from .manager import CategoryQuerySet
from apps.content.models import ImageObject, Gallery
User = get_user_model()
@ -17,7 +23,11 @@ class Like(models.Model):
update_at = models.DateTimeField(auto_now=True)
class Course(models.Model):
def default_slug():
return str(uuid4())
class Course(BaseModel, DeactivatedMixin):
PENDING = 0
PUBLISHED = 1
ARCHIVED = 2
@ -26,25 +36,69 @@ class Course(models.Model):
(PUBLISHED, 'Published'),
(ARCHIVED, 'Archived'),
)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
slug = models.SlugField(
allow_unicode=True, default=default_slug,
max_length=100, unique=True, db_index=True,
)
author = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True)
title = models.CharField('Название курса', max_length=100, db_index=True)
short_description = models.TextField('Краткое описание курса', db_index=True)
from_author = models.TextField('От автора', default='', null=True, blank=True)
cover = models.ImageField('Фон курса', upload_to='courses')
price = models.DecimalField('Цена курса', help_text='Если цены нету, то курс бесплатный', max_digits=10, decimal_places=2, null=True, blank=True)
short_description = models.TextField(
'Краткое описание курса', db_index=True
)
from_author = models.TextField(
'От автора', default='', null=True, blank=True
)
cover = models.ForeignKey(
ImageObject, related_name='course_covers',
verbose_name='Обложка курса', on_delete=models.CASCADE,
null=True, blank=True,
)
price = models.DecimalField(
'Цена курса', help_text='Если цена не выставлена, то курс бесплатный',
max_digits=10, decimal_places=2, null=True, blank=True
)
is_infinite = models.BooleanField(default=False)
deferred_start_at = models.DateTimeField('Отложенный запуск курса', help_text='Заполнить если курс отложенный', null=True, blank=True)
deferred_start_at = models.DateTimeField(
'Отложенный запуск курса', help_text='Заполнить если курс отложенный',
null=True, blank=True
)
category = models.ForeignKey('Category', on_delete=models.PROTECT)
duration = models.IntegerField('Продолжительность курса', default=0)
is_featured = models.BooleanField(default=False)
url = models.URLField('Ссылка', default='')
status = models.PositiveSmallIntegerField('Статус', default=0, choices=STATUS_CHOICES)
status = models.PositiveSmallIntegerField(
'Статус', default=0, choices=STATUS_CHOICES
)
likes = models.ManyToManyField(Like, blank=True)
materials = models.ManyToManyField('Material', blank=True)
gallery = models.ForeignKey(
Gallery, verbose_name='Галерея работ',
on_delete=models.CASCADE, null=True, blank=True,
related_name='results_gallery',
)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(
self.title[:90],
allow_unicode=True
)
if Course.objects.filter(slug=self.slug).exclude(id=self.id).exists():
self.slug += '_' + str(uuid4())[:6]
return super().save()
@property
def url(self):
return self.get_absolute_url()
def get_absolute_url(self):
return reverse_lazy('course', args=[self.id])
@property
def is_free(self):
if self.price:
@ -86,14 +140,20 @@ class Category(models.Model):
class Meta:
verbose_name = 'Категория'
verbose_name_plural = 'Категории'
ordering = ['title']
class Lesson(models.Model):
class Lesson(BaseModel, DeactivatedMixin):
title = models.CharField('Название урока', max_length=100)
short_description = models.TextField('Краткое описание урока')
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='lessons')
cover = models.ImageField('Фон урока', upload_to='lessons')
course = models.ForeignKey(
Course, on_delete=models.CASCADE, related_name='lessons'
)
cover = models.ForeignKey(
ImageObject, related_name='lesson_covers',
verbose_name='Обложка урока', on_delete=models.CASCADE,
null=True, blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
@ -108,7 +168,11 @@ class Lesson(models.Model):
class Material(models.Model):
title = models.CharField('Название материала', max_length=100)
cover = models.ImageField('Фон материала', upload_to='materials')
cover = models.ForeignKey(
ImageObject, related_name='material_covers',
verbose_name='Обложка материала', on_delete=models.CASCADE,
null=True, blank=True,
)
short_description = models.TextField('Краткое описание материала')
created_at = models.DateTimeField(auto_now_add=True)
@ -126,7 +190,10 @@ class Material(models.Model):
class Comment(PolymorphicMPTTModel):
content = models.TextField('Текст комментария', default='')
author = models.ForeignKey(User, on_delete=models.CASCADE)
parent = PolymorphicTreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.PROTECT)
parent = PolymorphicTreeForeignKey(
'self', null=True, blank=True, related_name='children',
db_index=True, on_delete=models.PROTECT
)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)
@ -146,7 +213,9 @@ class Comment(PolymorphicMPTTModel):
class CourseComment(Comment):
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='comments')
course = models.ForeignKey(
Course, on_delete=models.CASCADE, related_name='comments'
)
class Meta(Comment.Meta):
verbose_name = 'Комментарий курса'
@ -154,7 +223,9 @@ class CourseComment(Comment):
class LessonComment(Comment):
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments')
lesson = models.ForeignKey(
Lesson, on_delete=models.CASCADE, related_name='comments'
)
class Meta(Comment.Meta):
verbose_name = 'Комментарий урока'

@ -7,7 +7,11 @@
{% if course.is_deferred_start %}data-future-course data-future-course-time={{ course.deferred_start_at.timestamp }}{% endif %}
>
<a class="courses__preview" href="{% url 'course' course.id %}?next={{ request.get_full_path }}">
{% if course.cover %}
<img class="courses__pic" src="{{ course.cover.url }}"/>
{% else %}
<img class="courses__pic" src="{% static 'img/no_cover.png' %}"/>
{% endif %}
<div class="courses__view">Подробнее</div>
{% if course.is_featured %}
<div class="courses__label courses__label_fav"></div>

@ -0,0 +1,19 @@
{% if results %}
<div class="title">Галерея итогов обучения</div>
<div class="examples">
{% for image in course.gallery.gallery_images.all %}
<div class="examples__item">
<img class="examples__pic" src="{{ image.img.image.url }}">
</div>
{% endfor %}
</div>
{% else %}
<div class="content-block title">{{ content.title }}</div>
<div class="examples">
{% for image in content.gallery_images.all %}
<div class="examples__item">
<img class="examples__pic" src="{{ image.img.image.url }}">
</div>
{% endfor %}
</div>
{% endif %}

@ -0,0 +1,6 @@
<div class="content-block title">
{{ content.title }}
</div>
<div>
<img class="content-block pic" src="{{ content.img.image.url }}" alt="">
</div>

@ -0,0 +1,9 @@
<div class="content-block title">
{{ content.title }}
</div>
<div class="content-block text">
{{ content.txt }}
</div>
<div>
<img class="content-block pic" src="{{ content.img.image.url }}" alt="">
</div>

@ -0,0 +1,6 @@
<div class="content-block title">
{{ content.title }}
</div>
<div class="content-block text">
{{ content.txt }}
</div>

@ -0,0 +1,13 @@
<div class="content-block title">
{{ content.title }}
</div>
<div>
{% if 'youtube.com' in content.url or 'youtu.be' in content.url %}
<iframe width="640" height="360" src="https://www.youtube.com/embed/{{ content.video_index }}" frameborder="0" allow="autoplay; encrypted-media"
allowfullscreen></iframe>
{% elif 'vimeo.com' in content.url %}
<iframe src="https://player.vimeo.com/video/{{ content.video_index }}" width="640" height="360" frameborder="0" webkitallowfullscreen
mozallowfullscreen allowfullscreen>
</iframe>
{% endif %}
</div>

@ -3,6 +3,7 @@
{% load data_liked from data_liked %}
{% load rupluralize from plural %}
{% block title %}{{ course.title }} - {{ block.super }}{% endblock title %}
{% block description %}{{ course.title }} - {{ block.super }}{% endblock description%}
{% block content %}
<div class="section section_border">
<div class="section__center center center_sm">
@ -195,6 +196,15 @@
</div>
{% endif %}
</div>
{% for content in course.content.all %}
<div class="section section_border">
<div class="section__center center center_sm">
{% with template="course/content/"|add:content.ctype|add:".html" %}
{% include template %}
{% endwith %}
</div>
</div>
{% endfor %}
{% if user.is_authenticated %}
<div class="section">
<div class="section__center center center_sm">
@ -254,45 +264,7 @@
{% endif %}
<div class="section section_gradient">
<div class="section__center center center_sm">
<div class="title">Галерея итогов обучения</div>
<div class="examples">
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
<div class="examples__item">
<img class="examples__pic" src="{% static 'img/box.jpg' %}">
</div>
</div>
{% include "course/content/gallery.html" with results=True %}
</div>
</div>
<div class="course course_promo" style="background-image: url({% static 'img/video-1.jpg' %});">

@ -0,0 +1,12 @@
{% extends "templates/lilcity/index.html" %}
{% load static %}
{% block content %}
<course-redactor author-picture="{% if request.user.photo %}{{ request.user.photo.url }}{% else %}{% static 'img/user.jpg' %}{% endif %}"
author-name="{{ request.user.first_name }} {{ request.user.last_name }}"
access-token="{{ request.user.auth_token }}"
{% if course and course.id %}:course-id="{{ course.id }}"{% endif %}></course-redactor>
{% endblock content %}
{% block foot %}
<script type="text/javascript" src={% static "courseRedactor.js" %}></script>
<link rel="stylesheet" href={% static "courseRedactor.css" %}/>
{% endblock foot %}

@ -35,7 +35,16 @@
</div>
</div>
</div>
<div class="section section_gradient">
{% for content in lesson.content.all %}
<div class="section section_border">
<div class="section__center center center_sm">
{% with template="course/content/"|add:content.ctype|add:".html" %}
{% include template %}
{% endwith %}
</div>
</div>
{% endfor %}
{% comment %} <div class="section section_gradient">
<div class="section__center center center_sm">
<div class="title">Примеры техники</div>
<div class="examples">
@ -77,7 +86,7 @@
</div>
</div>
</div>
</div>
</div> {% endcomment %}
<div class="section section_gray">
<div class="section__center center center_sm">
<div class="title">Задавайте вопросы:</div>

@ -1,8 +1,9 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.template import loader, Context, Template
from django.views.generic import View, CreateView, DetailView, ListView
from django.views.generic import View, CreateView, DetailView, ListView, TemplateView
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import Course, Like, Lesson, CourseComment, LessonComment
@ -136,6 +137,23 @@ def lessoncomment(request, lesson_id):
})
class CourseEditView(TemplateView):
template_name = 'course/course_edit.html'
def get(self, request, pk=None):
if pk:
self.object = get_object_or_404(Course, pk=pk)
else:
self.object = None
return super().get(request)
def get_context_data(self):
context = super().get_context_data()
if self.object:
context['course'] = self.object
return context
class CourseView(DetailView):
model = Course
context_object_name = 'course'
@ -146,6 +164,14 @@ class CourseView(DetailView):
context['next'] = self.request.GET.get('next', None)
return context
def get_queryset(self):
queryset = super().get_queryset().select_related(
'author', 'category',
).prefetch_related(
'likes', 'materials', 'content',
)
return queryset
class CoursesView(ListView):
model = Course
@ -158,12 +184,17 @@ class CoursesView(ListView):
if request.is_ajax():
context = self.get_context_data()
template_name = self.get_template_names()
html = loader.render_to_string(template_name, context, request=request)
html = loader.render_to_string(
template_name, context, request=request)
is_paginated = context.get('is_paginated')
if is_paginated:
page_obj = context.get('page_obj')
prev_url = request.path + '?page=' + str(page_obj.previous_page_number()) if page_obj.has_previous() else None
next_url = request.path + '?page=' + str(page_obj.next_page_number()) if page_obj.has_next() else None
prev_url = request.path + '?page=' + \
str(page_obj.previous_page_number()
) if page_obj.has_previous() else None
next_url = request.path + '?page=' + \
str(page_obj.next_page_number()
) if page_obj.has_next() else None
else:
prev_url = None
next_url = None
@ -177,7 +208,11 @@ class CoursesView(ListView):
return super().get(request, args, kwargs)
def get_queryset(self):
queryset = super().get_queryset()
queryset = super().get_queryset().select_related(
'author', 'category', 'cover',
).prefetch_related(
'likes', 'materials', 'content',
)
filtered = CourseFilter(self.request.GET, queryset=queryset)
return filtered.qs
@ -206,7 +241,8 @@ class SearchView(CoursesView):
search_query = self.request.GET.get('q', None)
queryset = super().get_queryset()
if search_query:
query = Q(title__icontains=search_query) | Q(short_description__icontains=search_query)
query = Q(title__icontains=search_query) | Q(
short_description__icontains=search_query)
queryset = queryset.filter(query)
else:
queryset = queryset.none()
@ -219,7 +255,8 @@ class SearchView(CoursesView):
if 'is_paginated' in context and context['is_paginated']:
page_obj = context.get('page_obj')
context['page'] = page_obj.number
context['next_page'] = str(page_obj.next_page_number()) if page_obj.has_next() else None
context['next_page'] = str(
page_obj.next_page_number()) if page_obj.has_next() else None
else:
context['page'] = 1
context['next_page'] = None

@ -13,7 +13,7 @@
"is_active": true,
"date_joined": "2018-01-28T08:41:19Z",
"email": "admin@lil.city",
"role": 0,
"role": 2,
"gender": "n",
"country": "",
"city": "",
@ -26,7 +26,7 @@
"vkontakte": null,
"fb_id": null,
"fb_data": {},
"is_email_proved": false,
"is_email_proved": true,
"photo": "",
"groups": [],
"user_permissions": []

@ -0,0 +1,49 @@
from django import forms
from django.contrib.auth import get_user_model
User = get_user_model()
class UserEditForm(forms.ModelForm):
# first_name = forms.CharField()
# last_name = forms.CharField()
# email = forms.CharField()
# city = forms.CharField()
# country = forms.CharField()
birthday = forms.DateField(input_formats=['%d.%m.%Y'])
# gender = forms.ChoiceField(choices=User.GENDER_CHOICES, required=False)
gender = forms.CharField(required=False)
# about = forms.CharField()
old_password = forms.CharField(required=False)
new_password1 = forms.CharField(required=False)
new_password2 = forms.CharField(required=False)
instagram = forms.URLField(required=False)
facebook = forms.URLField(required=False)
twitter = forms.URLField(required=False)
pinterest = forms.URLField(required=False)
youtube = forms.URLField(required=False)
vkontakte = forms.URLField(required=False)
photo = forms.ImageField(required=False)
class Meta:
model = User
fields = (
'first_name',
'last_name',
'email',
'city',
'country',
'birthday',
'gender',
'about',
'old_password',
'new_password1',
'new_password2',
'instagram',
'facebook',
'twitter',
'pinterest',
'youtube',
'vkontakte',
'photo',
)

@ -0,0 +1,18 @@
# Generated by Django 2.0.2 on 2018-02-06 13:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0004_auto_20180129_1259'),
]
operations = [
migrations.AddField(
model_name='user',
name='birthday',
field=models.DateField(blank=True, null=True, verbose_name='День рождения'),
),
]

@ -0,0 +1,18 @@
# Generated by Django 2.0.2 on 2018-02-06 13:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0005_user_birthday'),
]
operations = [
migrations.AlterField(
model_name='user',
name='about',
field=models.CharField(blank=True, max_length=1000, null=True, verbose_name='О себе'),
),
]

@ -0,0 +1,23 @@
# Generated by Django 2.0.2 on 2018-02-07 08:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user', '0006_auto_20180206_1352'),
]
operations = [
migrations.AlterField(
model_name='user',
name='city',
field=models.CharField(blank=True, max_length=85, null=True, verbose_name='Город'),
),
migrations.AlterField(
model_name='user',
name='country',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Страна'),
),
]

@ -0,0 +1,17 @@
# Generated by Django 2.0.2 on 2018-02-12 07:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('user', '0007_auto_20180207_0808'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-date_joined',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]

@ -1,26 +1,42 @@
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import AbstractUser, UserManager
from django.contrib.postgres import fields as pgfields
from django.utils.translation import gettext_lazy as _
from rest_framework.authtoken.models import Token
from json import dumps
from api.v1 import serializers
class User(AbstractUser):
USER_ROLE = 0
AUTHOR_ROLE = 1
ADMIN_ROLE = 2
ROLE_CHOICES = (
(0, 'пользователь'),
(1, 'автор'),
(2, 'администратор'),
(USER_ROLE, 'пользователь'),
(AUTHOR_ROLE, 'автор'),
(ADMIN_ROLE, 'администратор'),
)
NOT_DEFINED = 'n'
MALE = 'm'
FEMALE = 'f'
GENDER_CHOICES = (
('n', 'не указан'),
('m', 'Мужчина'),
('f', 'Женщина'),
(NOT_DEFINED, 'не указан'),
(MALE, 'Мужчина'),
(FEMALE, 'Женщина'),
)
email = models.EmailField(_('email address'), unique=True)
role = models.PositiveSmallIntegerField('Роль', default=0, choices=ROLE_CHOICES)
gender = models.CharField('Пол', max_length=1, default='n', choices=GENDER_CHOICES)
country = models.CharField('Страна', max_length=50, default='')
city = models.CharField('Город', max_length=85, default='')
about = models.CharField('О себе', max_length=1000, default='', blank=True)
role = models.PositiveSmallIntegerField(
'Роль', default=0, choices=ROLE_CHOICES)
gender = models.CharField(
'Пол', max_length=1, default='n', choices=GENDER_CHOICES)
birthday = models.DateField('День рождения', null=True, blank=True)
country = models.CharField('Страна', max_length=50, null=True, blank=True)
city = models.CharField('Город', max_length=85, null=True, blank=True)
about = models.CharField('О себе', max_length=1000, null=True, blank=True)
instagram = models.URLField(default='', null=True, blank=True)
facebook = models.URLField(default='', null=True, blank=True)
twitter = models.URLField(default='', null=True, blank=True)
@ -29,8 +45,33 @@ class User(AbstractUser):
vkontakte = models.URLField('ВКонтакте', default='', null=True, blank=True)
fb_id = models.BigIntegerField(null=True, blank=True, unique=True)
fb_data = pgfields.JSONField(default={}, null=True, blank=True)
is_email_proved = models.BooleanField('Верифицирован по email', default=False)
is_email_proved = models.BooleanField(
'Верифицирован по email', default=False
)
photo = models.ImageField('Фото', null=True, blank=True, upload_to='users')
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
class Meta(AbstractUser.Meta):
ordering = ('-date_joined',)
def serialized(self):
user_data = serializers.user.UserSerializer(instance=self).data
user_data = dumps(user_data, ensure_ascii=False)
return user_data
@receiver(post_save, sender=User)
def create_auth_token(sender, instance=None, created=False, **kwargs):
if (
(instance.is_active or instance.fb_id) and
instance.role in [User.AUTHOR_ROLE, User.ADMIN_ROLE] and not
hasattr(instance, 'auth_token')
):
Token.objects.create(user=instance)
elif (
not (instance.is_active or instance.fb_id) or
instance.role not in [User.AUTHOR_ROLE, User.ADMIN_ROLE]
) and hasattr(instance, 'auth_token'):
instance.auth_token.delete()

@ -0,0 +1,209 @@
{% extends "templates/lilcity/index.html" %} {% load static %} {% block content %}
<div class="section section_gray section_menu">
<div class="section__center center center_xs">
<div class="menu">
<a class="menu__link active" href="{% url 'user-edit' user.id %}">Профиль</a>
<a class="menu__link" href="#">Уведомления</a>
<a class="menu__link" href="#">Платежи</a>
</div>
</div>
</div>
{% comment %}
<!-- <div class="section section_confirm">
<div class="section__center center center_xs">
<div class="confirm">
<div class="confirm__title title">Подтверждение почты</div>
<div class="confirm__content">На электронный адрес
<strong>sasha@lil.city</strong> отправлено письмо с кодом подтверждения. Введите код, чтобы подтвердить почту.</div>
<div class="confirm__form">
<div class="confirm__field field field_code">
<div class="field__wrap">
<input class="field__input" type="text" placeholder="Введите код подтверждения">
</div>
</div>
<button class="confirm__btn btn btn_dark">ПОДТВЕРДИТЬ</button>
</div>
<div class="confirm__content">Если у вас нет кода или письмо где-то затерялось, вы можете получить новый код подтверждения. Отправить новый код?</div>
</div>
</div>
</div> -->
{% endcomment %}
{% if messages %}
<div class="section section_gray section_menu">
<div class="section__center center center_xs">
{% for message in messages %}
<div class="message message_{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
</div>
{% endif %}
{{form.errors}}
<div class="section section_gray">
<div class="section__center center center_xs">
<div class="form">
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div class="form__group">
<div class="form__title">Личные данные</div>
<div class="form__ava ava">
{% if user.photo %}
<img id="photo" class="ava__pic" src="{{user.photo.url}}">
{% else %}
<img id="photo" class="ava__pic" src="{% static 'img/user.jpg' %}">
{% endif %}
<input name="photo" class="ava__input" type="file" accept='image/*' onchange='openFile(event)'>
<div class="ava__icon">
<svg class="icon icon-photo">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-photo"></use>
</svg>
</div>
</div>
<div class="form__fieldset">
<div class="form__field field">
<div class="field__label">ИМЯ</div>
<div class="field__wrap">
<input name='first_name' class="field__input" type="text" placeholder="Имя" value="{{ user.first_name }}">
</div>
</div>
<div class="form__field field">
<div class="field__label">ФАМИЛИЯ</div>
<div class="field__wrap">
<input name='last_name' class="field__input" type="text" placeholder="Фамилия" value="{{ user.last_name }}">
</div>
</div>
</div>
<div class="form__field field">
<div class="field__label">Почта</div>
<div class="field__wrap">
<input name='email' class="field__input" type="email" placeholder="Почта" value="{{ user.email }}">
</div>
</div>
<div class="form__fieldset">
<div class="form__field field">
<div class="field__label">ГОРОД</div>
<div class="field__wrap">
<input name='city' class="field__input" type="text" placeholder="Город" value="{% if user.city %}{{ user.city }}{% endif %}">
</div>
</div>
<div class="form__field field">
<div class="field__label">СТРАНА</div>
<div class="field__wrap">
<input name='country' class="field__input" type="text" placeholder="Страна" value="{% if user.country %}{{ user.country }}{% endif %}">
</div>
</div>
</div>
<div class="form__fieldset">
<div class="form__field field">
<div class="field__label">ДАТА РОЖДЕНИЯ</div>
<div class="field__wrap">
<input name='birthday' class="field__input" type="text" placeholder="dd.mm.yyyy" value="{% if user.birthday %}{{ user.birthday | date:'d.m.Y' }}{% endif %}">
</div>
</div>
<div class="form__field field">
<div class="field__label">ПОЛ</div>
<div class="field__wrap">
<div class="field__select select js-select{% if user.gender and user.gender != 'n' %} selected{% endif %}">
<div class="select__head js-select-head">
{% if user.gender == 'f' %}Ж{% elif user.gender == 'm' %}M{% else %}М / Ж{% endif %}
</div>
<div class="select__drop js-select-drop">
<div class="select__option js-select-option" data-gender-option data-gender="m">
<div class="select__title">М</div>
</div>
<div class="select__option js-select-option" data-gender-option data-gender="f">
<div class="select__title">Ж</div>
</div>
</div>
<input id="gender" name='gender' class="select__input" type="hidden">
</div>
</div>
</div>
</div>
<div class="form__field field">
<div class="field__label">О себе</div>
<div class="field__wrap">
<textarea name='about' class="field__textarea" placeholder="Расскажите о себе и своем опыте">{% if user.about %}{{ user.about }}{% endif %}</textarea>
</div>
</div>
</div>
<div class="form__group">
<div class="form__title">Пароль</div>
<div class="form__field field">
<div class="field__label">ТЕКУЩИЙ ПАРОЛЬ</div>
<div class="field__wrap">
<input name='old_password' class="field__input" type="password" placeholder="Введите текущий пароль">
</div>
</div>
<div class="form__field field">
<div class="field__label">НОВЫЙ ПАРОЛЬ</div>
<div class="field__wrap">
<input name='new_password1' class="field__input" type="password" placeholder="Введите новый пароль">
</div>
</div>
<div class="form__field field">
<div class="field__label">ПОДТВЕРДИТЬ НОВЫЙ ПАРОЛЬ</div>
<div class="field__wrap">
<input name='new_password2' class="field__input" type="password" placeholder="Подтвердите новый пароль">
</div>
</div>
</div>
<div class="form__group">
<div class="form__title">Соцсети</div>
<div class="form__field field">
<div class="field__label">INSTAGRAM</div>
<div class="field__wrap">
<input name='instagram' class="field__input" type="text" placeholder="https://instagram.com/school.lil.city" value="{{ user.instagram }}">
</div>
</div>
<div class="form__field field">
<div class="field__label">FACEBOOK</div>
<div class="field__wrap">
<input name='facebook' class="field__input" type="text" placeholder="https://facebook.com/lilcitycompany" value="{{ user.facebook }}">
</div>
</div>
<div class="form__field field">
<div class="field__label">TWITTER</div>
<div class="field__wrap">
<input name='twitter' class="field__input" type="text" placeholder="https://twitter.com/lilcitycompany" value="{{ user.twitter }}">
</div>
</div>
<div class="form__field field">
<div class="field__label">PINTEREST</div>
<div class="field__wrap">
<input name='pinterest' class="field__input" type="text" placeholder="https://pinterest.com/lilcitycompany" value="{{ user.pinterest }}">
</div>
</div>
<div class="form__field field">
<div class="field__label">YOUTUBE</div>
<div class="field__wrap">
<input name='youtube' class="field__input" type="text" placeholder="https://youtube.com/lilcitycompany" value="{{ user.youtube }}">
</div>
</div>
<div class="form__field field">
<div class="field__label">VKONTAKTE</div>
<div class="field__wrap">
<input name='vkontakte' class="field__input" type="text" placeholder="https://vk.com/lilcitycompany" value="{{ user.vkontakte }}">
</div>
</div>
</div>
<div class="form__foot">
<button type="submit" class="form__btn btn btn_md">СОХРАНИТЬ</button>
</div>
</form>
</div>
</div>
</div>
<script>
var openFile = function(file) {
var input = file.target;
var reader = new FileReader();
reader.onload = function(){
var dataURL = reader.result;
var output = document.getElementById('photo');
output.src = dataURL;
};
reader.readAsDataURL(input.files[0]);
};
</script>
{% endblock content %}

@ -2,7 +2,7 @@
<div class="section">
<div class="section__center center">
<div class="profile">
<a class="profile__btn profile__btn_edit btn" href="#">Редактировать</a>
<a class="profile__btn profile__btn_edit btn" href="{% url 'user-edit' user.id %}">Редактировать</a>
{% if user.photo %}
<div class="profile__ava ava">
<img class="ava__pic" src="{{ user.photo.url }}">

@ -1,9 +1,20 @@
from django.shortcuts import render
from django.views.generic import DetailView
from io import BytesIO
from PIL import Image
from os.path import splitext
from django.contrib.auth import login
from django.shortcuts import render, reverse
from django.views.generic import DetailView, UpdateView
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.auth.hashers import check_password, make_password
from django.http import Http404
from django.utils.decorators import method_decorator
from apps.course.models import Course
from .forms import UserEditForm
User = get_user_model()
@ -13,6 +24,65 @@ class UserView(DetailView):
def get_context_data(self, object):
context = super().get_context_data()
context['published'] = Course.objects.filter(author=self.object, status=Course.PUBLISHED)
context['published'] = Course.objects.filter(
author=self.object, status=Course.PUBLISHED
)
context['paid'] = Course.objects.none()
return context
class UserEditView(UpdateView):
model = User
template_name = 'user/profile-settings.html'
form_class = UserEditForm
@method_decorator(login_required)
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if request.user != self.object:
raise Http404()
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
# it's magic *-*-*-*-*
if 'photo' in request.FILES:
photo_fp = request.FILES.pop('photo')[0]
fname = photo_fp.name
photo = Image.open(photo_fp)
lowest_side = min(photo.size)
horizontal_padding = (lowest_side - photo.size[0]) / 2
vertical_padding = (lowest_side - photo.size[1]) / 2
photo = photo.crop(
(
-horizontal_padding,
-vertical_padding,
photo.size[0] + horizontal_padding,
photo.size[1] + vertical_padding
)
)
if photo.size[0] > 512:
photo = photo.resize((512, 512,))
buffer = BytesIO()
ext = splitext(fname)[1][1:].upper()
if ext == 'JPG':
ext = 'JPEG'
photo.save(buffer, ext)
self.object.photo.save(fname, buffer)
buffer.close()
if not request.POST._mutable:
request.POST._mutable = True
old_password = request.POST.pop('old_password')[0]
new_password1 = request.POST.pop('new_password1')[0]
new_password2 = request.POST.pop('new_password2')[0]
if old_password:
if request.user.check_password(old_password) and new_password1 == new_password2:
request.user.set_password(new_password1)
request.user.save()
login(request, request.user)
else:
messages.error(request, 'Неверный пароль.')
messages.info(request, 'Данные сохранены.')
return super().post(request, *args, **kwargs)
def get_success_url(self):
return reverse('user-edit', args=[self.object.id])

@ -22,13 +22,13 @@ services:
restart: always
volumes:
- .:/lilcity
command: bash -c "python manage.py migrate && python manage.py loaddata /lilcity/apps/*/fixtures/*.json && python manage.py runserver 0.0.0.0:8000 && celery worker -A project -Q web"
command: bash -c "python manage.py migrate && python manage.py loaddata /lilcity/apps/*/fixtures/*.json && python manage.py runserver 0.0.0.0:8000 && celery worker -A project"
environment:
- DJANGO_SETTINGS_MODULE=project.settings
- DATABASE_SERVICE_HOST=db
- REDIS_SERVICE_HOST=redis
ports:
- "8000:8000"
- "${PORT}:8000"
depends_on:
- db
- redis

@ -0,0 +1,38 @@
from django.db import models
from django.utils import timezone
class BaseModel(models.Model):
class Meta:
abstract = True
class DeactivatedQueryset(models.Manager):
def allow_delete(self):
super().delete()
def delete(self):
count = self.count()
self.filter(deactivated_at__isnull=True).update(
deactivated_at=timezone.now()
)
return (count, None)
class DeactivatedMixin(models.Model):
deactivated_at = models.DateTimeField(null=True, blank=True, default=None)
objects = DeactivatedQueryset()
class Meta:
abstract = True
def allow_delete(self, using=None, keep_parents=False):
super().delete(using=using, keep_parents=keep_parents)
def delete(self, using=None, keep_parents=False):
if not self.deactivated_at:
self.deactivated_at = timezone.now()
self.save()

@ -43,15 +43,23 @@ INSTALLED_APPS = [
'polymorphic_tree',
'polymorphic',
'mptt',
'rest_framework',
'rest_framework.authtoken',
'drf_yasg',
'corsheaders',
] + [
'apps.auth.apps',
'apps.user',
'apps.notification',
'apps.payment',
'apps.course',
'apps.content',
]
if DEBUG:
INSTALLED_APPS += ['silk']
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
@ -60,6 +68,8 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
if DEBUG:
MIDDLEWARE += ['silk.middleware.SilkyMiddleware']
ROOT_URLCONF = 'project.urls'
@ -152,7 +162,7 @@ STATICFILES_DIRS = [
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
LOGIN_URL = '/'
# Email
# https://github.com/anymail/django-anymail
@ -171,4 +181,37 @@ TWILIO_ACCOUNT = 'ACdf4a96b776cc764bc3ec0f0e136ba550'
TWILIO_TOKEN = '559a6b1fce121759c9af2dcbb3f755ea'
TWILIO_FROM_PHONE = '+37128914409'
ACTIVE_LINK_STRICT = True
ACTIVE_LINK_STRICT = True
# DRF settings
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
),
'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
}
# CORS settings
if DEBUG:
CORS_ORIGIN_ALLOW_ALL = True
# Swagger doc settings
SWAGGER_SETTINGS = {
'DOC_EXPANSION': 'none',
}

@ -15,7 +15,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="theme-color" content="#fff">
<meta name="format-detection" content="telephone=no">
<meta name="description" content="Page description">
<meta name="description" content="{% block description %}Онлайн-курсы LilCity{% endblock description%}">
<!--Twitter Card data-->
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="@publisher_handle">
@ -32,8 +32,8 @@
<meta property="og:site_name" content="Site Name, i.e. Moz">
<meta property="fb:admins" content="Facebook numeric ID">
<meta name="csrf-token" content="{{ csrf_token }}">
<link rel="stylesheet" media="all" href={% static "css/app.css" %}>
<link rel="shortcut icon" type="image/png" href="{% static 'favicon.ico' %}"/>
<link rel="stylesheet" media="all" href={% static "app.css" %}>
<link rel="shortcut icon" type="image/png" href="{% static 'img/favicon.ico' %}"/>
<script>
var viewportmeta = document.querySelector('meta[name="viewport"]');
if (viewportmeta) {
@ -126,19 +126,24 @@
<div class="header__group"><a class="header__section" target="_blank" href="http://blog.lil.school">БЛОГ</a></div>
</nav>
</div>
{% if user.is_authenticated %}
{% if request.user.is_authenticated %}
<div class="header__login">
{% if user.photo %}
<div class="header__ava ava"><img class="ava__pic" src="{{ user.photo.url }}"></div>
{% if request.user.photo %}
<div class="header__ava ava"><img class="ava__pic" src="{{ request.user.photo.url }}"></div>
{% else %}
<div class="header__ava ava"><img class="ava__pic" src="{% static 'img/user.jpg' %}"></div>
{% endif %}
<div class="header__drop">
{% comment %} <a class="header__link header__link_border" href="#">234.120.345 руб.</a> {% endcomment %}
<a class="header__link header__link_green" href="#">
{% if request.user.auth_token %}
{% if request.user.role == 1 or request.user.role == 2 %}
<a class="header__link header__link_green" href="{% url 'course_create' %}">
{% comment %} <a class="header__link header__link_gray disabled" href="#"> {% endcomment %}
<div class="header__title">ДОБАВИТЬ КУРС</div>
</a>
<a class="header__link" href="{% url 'user' user.id %}">
{% endif %}
{% endif %}
<a class="header__link" href="{% url 'user' request.user.id %}">
<div class="header__title">ПРОФИЛЬ</div>
</a>
<a class="header__link" href="#">
@ -158,11 +163,13 @@
{% endif %}
</div>
</div>
{% if user.is_authenticated and not user.fb_id or user.is_authenticated and not user.is_email_proved %}
{% if request.user.is_authenticated %}
{% if not request.user.is_email_proved and not request.user.fb_id %}
<div class="message message_error">Необходимо подтвердить электронную почту</div>
{% endif %}
{% endif %}
</header>
<div class="container">
<div id="lilcity-vue-app" class="container">
{% block content %}{% endblock content %}
</div>
<footer class="footer">
@ -480,7 +487,8 @@
</div>
</div>
</div>
<script type="text/javascript" src={% static "js/app.js" %}></script>
<script type="text/javascript" src={% static "app.js" %}></script>
{% block foot %}{% endblock foot %}
</body>
</html>

@ -0,0 +1,11 @@
{% extends "templates/lilcity/index.html" %} {% load static %}
{% block title %}School LIL.CITY{% endblock title %}
{% block content %}
{{ request.user.auth_token }}
<course-redactor author-picture="http://localhost:8000/static/img/user.jpg" author-name="Vitaly Baev" access-token="1fac76972542b0f6492076e45e4b8cb39b5c422a" :course-id="71"></course-redactor>
{% endblock content %}
{% block foot %}
<script type="text/javascript" src={% static "courseRedactor.js" %}></script>
<link rel="stylesheet" href={% static "courseRedactor.css" %}></link>
{% endblock foot %}

@ -21,29 +21,39 @@ from django.conf import settings
from apps.course.views import (
CoursesView, likes, coursecomment,
CourseView, LessonView, SearchView,
lessoncomment,
lessoncomment, CourseEditView,
)
from apps.course.models import Course
from apps.user.views import UserView
from apps.user.views import UserView, UserEditView
urlpatterns = [
path('admin/', admin.site.urls),
path('auth/', include(('apps.auth.urls', 'lilcity'))),
path('courses/', CoursesView.as_view(), name='courses'),
path('course/<int:pk>/', CourseView.as_view(), name='course'),
path('course/<str:slug>/', CourseView.as_view(), name='course'),
path('course/<int:course_id>/like', likes, name='likes'),
path('course/create', CourseEditView.as_view(), name='course_create'),
path('course/<int:pk>/edit', CourseEditView.as_view(), name='course_edit'),
path('course/<int:course_id>/comment', coursecomment, name='coursecomment'),
path('lesson/<int:pk>/', LessonView.as_view(), name='lesson'),
path('lesson/<int:lesson_id>/comment', lessoncomment, name='lessoncomment'),
path('search/', SearchView.as_view(), name='search'),
path('user/<int:pk>/', UserView.as_view(), name='user'),
path('user/<int:pk>/edit/', UserEditView.as_view(), name='user-edit'),
path('privacy', TemplateView.as_view(template_name="templates/lilcity/privacy_policy.html"), name='privacy'),
path('terms', TemplateView.as_view(template_name="templates/lilcity/terms.html"), name='terms'),
path('refund-policy', TemplateView.as_view(template_name="templates/lilcity/refund_policy.html"), name='refund_policy'),
path('', TemplateView.as_view(template_name="templates/lilcity/main.html", extra_context={'course_items': Course.objects.all()[:3]}), name='index'),
path('api/v1/', include(('api.v1.urls', 'api_v1'))),
path('test', TemplateView.as_view(template_name="templates/lilcity/test.html"), name='test'),
]
if settings.DEBUG:
from django.conf.urls.static import static
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += staticfiles_urlpatterns()

@ -12,3 +12,7 @@ django-filter==2.0.0.dev1
django-mptt==0.9.0
django-polymorphic-tree==1.5
celery[redis]==4.1.0
djangorestframework==3.7.7
drf-yasg[validation]==1.4.0
django-silk==2.0.0
django-cors-headers==2.1.0

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -22,7 +22,7 @@ gulp.task('copy:rootfiles', function() {
gulp.task('copy:img', function() {
return gulp
.src([
config.src.img + '/**/*.{jpg,png,jpeg,svg,gif}',
config.src.img + '/**/*.{jpg,png,jpeg,svg,gif,ico}',
'!' + config.src.img + '/svgo/**/*.*'
])
.pipe(gulp.dest(config.dest.img));

1496
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -11,12 +11,18 @@
"autoprefixer": "^6.3.3",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-runtime": "^6.4.3",
"babel-plugin-transform-es2015-arrow-functions": "^6.22.0",
"babel-plugin-transform-es2015-shorthand-properties": "^6.24.1",
"babel-plugin-transform-es2015-template-literals": "^6.22.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.6.1",
"babel-preset-es2015": "^6.3.13",
"babel-preset-es2015": "^6.24.1",
"browser-sync": "^2.10.0",
"css-loader": "^0.28.9",
"css-mqpacker": "^5.0.1",
"del": "^2.2.0",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.6",
"gulp": "^3.9.1",
"gulp-babel": "^6.1.2",
"gulp-changed": "^1.3.0",
@ -40,15 +46,26 @@
"lodash": "^4.3.0",
"require-dir": "^0.3.0",
"run-sequence": "^1.1.5",
"sass-loader": "^6.0.6",
"style-loader": "^0.20.1",
"through2": "^2.0.1",
"url-loader": "^0.6.2",
"vue-loader": "^14.1.1",
"vue-style-loader": "^3.1.2",
"vue-template-compiler": "^2.5.13",
"webpack": "^3.10.0"
},
"dependencies": {
"axios": "^0.17.1",
"babel-polyfill": "^6.26.0",
"history": "^4.7.2",
"jquery": "^3.3.1",
"moment": "^2.20.1",
"owl.carousel": "^2.2.0",
"slugify": "^1.2.9",
"smooth-scroll": "^12.1.5",
"validator": "^9.2.0"
"validator": "^9.2.0",
"vue": "^2.5.13",
"vuejs-datepicker": "^0.9.25"
}
}

@ -0,0 +1,582 @@
<template>
<div>
<div v-if="!courseLoading">
<form v-if="viewSection !== 'lessons-edit'" @submit.prevent="onSubmit">
<div class="info">
<div class="info__section" :style="coverBackgroundStyle">
<div class="info__main">
<div class="info__head">
<div class="info__user">
<div class="info__ava ava">
<img :src="authorPicture" alt="Аватар" class="ava__pic">
</div>
<div class="info__group">
<div class="info__label">АВТОР</div>
<div class="info__value">{{ authorName }}</div>
</div>
</div>
<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">
<div class="field__label">НАЗВАНИЕ КУРСА</div>
<div class="field__wrap">
<textarea class="field__textarea field__textarea_lg" title="Название курса"
v-model="course.title"></textarea>
</div>
</div>
</div>
<div class="info__foot">
<div class="info__field field field_info">
<div class="field__label field__label_gray">КАТЕГОРИЯ</div>
<div class="field__wrap">
<lil-select :value.sync="categorySelect" :options="categoryOptions"
placeholder="Выберите категорию"/>
</div>
</div>
<div class="info__field field field_info">
<div class="field__label field__label_gray">ПРОДОЛЖИТЕЛЬНОСТЬ</div>
<div class="field__wrap">
<input type="text" class="field__input" v-model.number="course.duration">
</div>
</div>
<div class="info__field field field_info">
<div class="field__label field__label_gray">СТОИМОСТЬ</div>
<div class="field__wrap">
<input type="text" class="field__input" v-model="displayPrice" :disabled="!course.is_paid">
</div>
</div>
</div>
</div>
</div>
<div class="info__sidebar">
<div class="info__wrap">
<div class="info__fieldset">
<!--<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="course.url">
</div>
<div class="field__wrap field__wrap&#45;&#45;additional">{{ courseFullUrl }}</div>
</div>-->
<div class="info__field field">
<div class="field__label field__label_gray">ДОСТУП</div>
<div class="field__wrap">
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="true" class="switch__input" v-model="course.is_paid">
<span class="switch__content">Платный</span>
</label>
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="false" class="switch__input" v-model="course.is_paid">
<span class="switch__content">Бесплатный</span>
</label>
</div>
</div>
<label class="info__switch switch switch_lg">
<input type="checkbox" class="switch__input" v-model="course.is_featured">
<span class="switch__content">Выделить</span>
</label>
</div>
<div class="info__fieldset">
<div class="info__field field">
<div class="field__label field__label_gray">ЗАПУСК</div>
<div class="field__wrap">
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="false" class="switch__input" v-model="course.is_deferred">
<span class="switch__content">Мгновенный</span>
</label>
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="true" class="switch__input" v-model="course.is_deferred">
<span class="switch__content">Отложенный</span>
</label>
</div>
</div>
<div class="info__field field" v-show="course.is_deferred">
<div class="field__label">ДАТА</div>
<div class="field__wrap">
<vue-datepicker input-class="field__input" v-model="course.date" language="ru" format="dd/MM/yyyy"/>
</div>
</div>
<div class="info__field field" v-show="course.is_deferred">
<div class="field__label">ВРЕМЯ</div>
<div class="field__wrap">
<lil-select :value.sync="course.time" :options="timeOptions" placeholder="Выберите время"/>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section__center center">
<div class="kit">
<div class="kit__section">
<div class="kit__field field">
<div class="field__wrap">
<input type="text" class="field__input" placeholder="Кратко о курсе"
v-model="course.short_description">
</div>
</div>
</div>
<div class="kit__nav">
<button class="kit__btn btn btn_lg"
v-bind:class="{ 'btn_stroke': viewSection === 'course', 'btn_gray': viewSection !== 'course' }"
type="button" @click="viewSection = 'course'">Описание
курса
</button>
<button class="kit__btn btn btn_lg"
v-bind:class="{ 'btn_stroke': viewSection === 'lessons', 'btn_gray': viewSection !== 'lessons' }"
type="button"
@click="showLessons"
:disabled="!course.id">
Уроки
</button>
</div>
<div v-if="viewSection === 'course'" class="kit__body">
<div v-for="(block, index) in course.content">
<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>
<block-add v-on:added="onBlockAdded"/>
<div class="kit__foot">
<button type="submit" class="kit__submit btn btn_md" v-bind:class="{ loading: courseSaving }">
Сохранить
</button>
</div>
</div>
<div v-if="viewSection === 'lessons'" class="kit__body">
<div class="lessons__title title">Содержание курса</div>
<div v-if="!lessonsLoading" class="lessons__list">
<div class="lessons__item" v-for="(lesson, index) in lessons">
<div class="lessons__actions">
<button type="button" class="lessons__action" @click="removeLesson(index)">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
<button type="button" class="lessons__action" @click="editLesson(index)">
<svg class="icon icon-edit">
<use xlink:href="/static/img/sprite.svg#icon-edit"></use>
</svg>
</button>
</div>
<div class="lessons__subtitle subtitle">{{ lesson.title }}</div>
<div class="lessons__row">
<div class="lessons__content">{{ lesson.short_description }}</div>
</div>
</div>
</div>
<div v-if="lessonsLoading">Загрузка...</div>
<div class="lessons__foot">
<button type="button" class="lessons__btn btn btn_md" @click="addLesson">СОЗДАТЬ УРОК</button>
</div>
</div>
</div>
</div>
</div>
</form>
<form v-if="viewSection === 'lessons-edit'" @submit.prevent="onLessonSubmit">
<lesson-redactor :lesson.sync="currentLesson" :saving="lessonSaving" :access-token="accessToken"
v-on:back="goToLessons"/>
</form>
</div>
<div v-else>
<div class="section">
<div class="section__center center">
Загрузка...
</div>
</div>
</div>
</div>
</template>
<script>
import LinkInput from './inputs/LinkInput'
import DatePicker from 'vuejs-datepicker'
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 LilSelect from "./inputs/LilSelect";
import LessonRedactor from "./LessonRedactor";
import {api} from "../js/modules/api";
import BlockAdd from "./blocks/BlockAdd";
export default {
name: "course-redactor",
props: ["authorName", "authorPicture", "accessToken", "courseId"],
data() {
return {
viewSection: 'course',
course: {
title: '',
category: null,
categorySelect: null,
duration: 1,
price: 0,
url: '',
coverImage: '',
coverImageId: null,
is_paid: false,
is_featured: true,
is_deferred: false,
date: '',
time: null,
short_description: '',
content: [],
gallery: {
images: [],
}
},
courseLoading: false,
courseSaving: false,
lessons: [],
lessonsLoading: false,
lessonSaving: false,
currentLesson: null,
is_adding_block: false,
timeOptions: [
{
'title': '10:00',
'value': '10:00',
},
{
'title': '11:00',
'value': '11:00',
},
{
'title': '12:00',
'value': '12:00',
},
{
'title': '13:00',
'value': '13:00',
},
{
'title': '14:00',
'value': '14:00',
},
{
'title': '15:00',
'value': '15:00',
},
{
'title': '16:00',
'value': '16:00',
},
{
'title': '17:00',
'value': '17:00',
},
{
'title': '18:00',
'value': '18:00',
}
],
categoryOptions: []
}
},
methods: {
onCoverImageSelected(event) {
let file = event.target.files[0];
let reader = new FileReader();
reader.onload = () => {
this.$set(this.course, 'coverImage', reader.result);
api.uploadImage(reader.result, this.accessToken)
.then((response) => {
this.course.coverImageId = response.data.id;
})
.catch((error) => {
console.log('error', error);
});
};
if (file) {
reader.readAsDataURL(file);
}
},
onCoursePriceChange(event) {
this.course.price = event.target.value;
},
updateCategory() {
if (this.categoryOptions && Array.isArray(this.categoryOptions) && this.course.category) {
this.categoryOptions.forEach((category) => {
if (category.id === this.course.category) {
this.course.categorySelect = category;
}
});
}
},
onBlockRemoved(blockIndex) {
const blockToRemove = this.course.content[blockIndex];
// Удаляем блок из Vue
this.course.content.splice(blockIndex, 1);
// Если блок уже был записан в БД, отправляем запрос на сервер на удаление блока из БД
if (blockToRemove.data.id) {
api.removeContentBlock(blockToRemove, this.accessToken);
}
},
onBlockAdded(blockData) {
this.course.content.push(blockData);
},
removeLesson(lessonIndex) {
if (!confirm('Вы действительно хотите удалить этот урок?')) {
return
}
const lesson = this.lessons[lessonIndex];
if (lesson.hasOwnProperty('id') && lesson.id) {
api.removeCourseLesson(lesson.id, this.accessToken);
}
this.lessons.splice(lessonIndex, 1);
},
editLesson(lessonIndex) {
this.currentLesson = this.lessons[lessonIndex];
this.viewSection = 'lessons-edit';
},
showLessons() {
this.viewSection = 'lessons';
},
addLesson() {
this.currentLesson = {
title: '',
short_description: '',
course_id: this.course.id,
content: [],
};
this.viewSection = 'lessons-edit';
},
onSubmit() {
this.courseSaving = true;
api.saveCourse(this.course, this.accessToken)
.then((response) => {
this.courseSaving = false;
this.course = api.convertCourseJson(response.data);
})
.catch((err) => {
this.courseSaving = false;
});
},
onLessonSubmit() {
this.lessonSaving = true;
const currentLessonId = this.currentLesson.id;
api.saveLesson(this.currentLesson, this.accessToken)
.then((response) => {
this.lessonSaving = false;
const newLesson = api.convertLessonJson(response.data);
newLesson.course_id = this.course.id;
this.currentLesson = newLesson;
if (!currentLessonId) {
this.lessons.push(newLesson);
}
if (this.lessons && Array.isArray(this.lessons)) {
this.lessons.forEach((lesson, index) => {
if (newLesson.id === lesson.id) {
this.$set('lessons', index, newLesson);
}
});
}
})
.catch((err) => {
this.lessonSaving = false;
});
},
goToLessons() {
this.viewSection = 'lessons';
},
loadCourse(courseId) {
this.courseLoading = true;
api.loadCourse(courseId, this.accessToken)
.then((response) => {
this.courseLoading = false;
this.course = api.convertCourseJson(response.data);
})
.catch((err) => {
this.courseLoading = false;
console.log('error course loading', err);
});
},
loadLessons(courseId) {
api.getCourseLessons(courseId, this.accessToken)
.then((response) => {
this.lessons = response.data.results.map((lessonJson) => {
return api.convertLessonJson(lessonJson);
});
})
.catch((err) => {
console.log('error course loading', err);
});
}
},
mounted() {
api.getCategories(this.accessToken)
.then((response) => {
if (response.data) {
this.categoryOptions = response.data.results.map((category) => {
return {
title: category.title,
value: category.id
}
});
}
this.updateCategory();
});
if (this.courseId) {
this.loadCourse(this.courseId);
this.loadLessons(this.courseId);
}
},
computed: {
coverBackgroundStyle() {
return this.course.coverImage ? `background-image: url(${this.course.coverImage});` : '';
},
displayPrice: {
get: function () {
return this.course.is_paid ? this.course.price : 0;
},
set: function (value) {
this.course.price = value;
}
},
categorySelect: {
get() {
if (!this.categoryOptions || this.categoryOptions.length === 0 || !this.course || !this.course.category) {
return null;
}
let value;
this.categoryOptions.forEach((category) => {
if (category.value === this.course.category) {
value = category;
}
});
return value;
},
set(value) {
this.course.category = value.value;
}
},
courseFullUrl() {
let suffix = this.course.url ? this.course.url : 'ваша_ссылка';
return `https://lil.city/course/${suffix}`;
}
},
components: {
BlockAdd,
LessonRedactor,
LilSelect,
BlockText,
'link-input': LinkInput,
'vue-datepicker': DatePicker,
'block-text': BlockText,
'block-image': BlockImage,
'block-image-text': BlockImageText,
'block-images': BlockImages,
'block-video': BlockVideo,
'lesson-redactor': LessonRedactor,
}
}
</script>
<style lang="scss">
.vdp-datepicker__calendar {
width: 240px;
margin-top: 10px;
padding: 5px;
background: white;
box-shadow: 0 2px 20px 0 rgba(0, 0, 0, 0.1);
z-index: 99 !important;
header {
display: flex;
margin-bottom: 5px;
-ms-flex-align: center;
align-items: center;
}
.prev, .next {
font-size: 0;
cursor: pointer;
order: 1;
width: auto !important;
padding: 10px;
}
.prev {
order: 1;
}
.next {
order: 3;
}
.prev:before, .next:before {
content: '';
display: block;
width: 10px;
height: 10px;
border: solid #E6E6E6;
border-width: 2px 2px 0 0;
}
.prev:after, .next:after {
content: none !important;
}
.prev:before {
transform: rotate(-135deg);
}
.next:before {
transform: rotate(45deg);
}
}
.kit__preview {
img {
width: 140px;
height: 140px;
}
}
.kit__photo {
width: 140px;
height: 140px;
}
</style>

@ -0,0 +1,122 @@
<template>
<div class="section">
<div class="section__center center">
<div class="kit">
<div class="kit__go go">
<a href="#" class="go__item" @click.prevent="goBack">
<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>
</div>
<div class="kit__title title">{{ title }}</div>
<div class="kit__section">
<div class="kit__field field">
<div class="field__wrap">
<input type="text" class="field__input" placeholder="Название урока" v-model="lesson.title">
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<textarea class="field__input" placeholder="Описание урока" v-model="lesson.short_description"></textarea>
</div>
</div>
</div>
<div v-for="(block, index) in lesson.content">
<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>
<block-add v-on:added="onBlockAdded" />
<div class="kit__foot">
<button class="kit__submit btn btn_md" v-bind:class="{ loading: saving }">Сохранить</button>
</div>
</div>
</div>
</div>
</template>
<script>
import BlockAdd from "./blocks/BlockAdd";
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 {api} from "../js/modules/api";
export default {
name: "lesson-redactor",
props: ["lesson", "saving", "accessToken"],
methods: {
goBack() {
this.$emit('back');
},
onBlockAdded(blockData) {
this.lesson.content.push(blockData);
this.$emit('update:lesson', this.lesson);
},
onBlockRemoved(blockIndex) {
const blockToRemove = this.lesson.content[blockIndex];
// Удаляем блок из Vue
this.lesson.content.splice(blockIndex, 1);
this.$emit('update:lesson', this.lesson);
// Если блок уже был записан в БД, отправляем запрос на сервер на удаление блока из БД
if (blockToRemove.data.id) {
api.removeContentBlock(blockToRemove, this.accessToken);
}
}
},
computed: {
title() {
return this.lesson && this.lesson.id ? 'Редактирование урока' : 'Создать урок';
}
},
components: {
BlockAdd,
'block-text': BlockText,
'block-image': BlockImage,
'block-image-text': BlockImageText,
'block-images': BlockImages,
'block-video': BlockVideo,
}
}
</script>
<style scoped>
</style>

@ -0,0 +1,121 @@
<template>
<div class="kit__section">
<div v-if="!isOpen" class="kit__add add">
<button type="button" class="add__toggle" @click="isOpen = true">
<span class="add__circle">
<svg class="icon icon-add-plus">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
</svg>
</span>
<span class="add__title">Добавить блок</span>
</button>
</div>
<div v-if="isOpen" class="kit__add add open">
<button type="button" class="add__toggle" @click="isOpen = false">
<span class="add__circle">
<svg class="icon icon-add-plus">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
</svg>
</span>
<span class="add__title">Добавить блок</span>
</button>
<div class="add__list">
<button class="add__btn" type="button" @click="addBlockText">
<svg class="icon icon-text">
<use xlink:href="/static/img/sprite.svg#icon-text"></use>
</svg>
</button>
<button class="add__btn" type="button" @click="addBlockImage">
<svg class="icon icon-image">
<use xlink:href="/static/img/sprite.svg#icon-image"></use>
</svg>
</button>
<button type="button" class="add__btn" @click="addBlockImageText">
<svg class="icon icon-image-text">
<use xlink:href="/static/img/sprite.svg#icon-image-text"></use>
</svg>
</button>
<button type="button" class="add__btn" @click="addBlockImages">
<svg class="icon icon-images">
<use xlink:href="/static/img/sprite.svg#icon-images"></use>
</svg>
</button>
<button type="button" class="add__btn" @click="addBlockVideo">
<svg class="icon icon-video-stroke">
<use xlink:href="/static/img/sprite.svg#icon-video-stroke"></use>
</svg>
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "block-add",
data() {
return {
isOpen: false
}
},
methods: {
add(blockData) {
this.isOpen = false;
this.$emit('added', blockData)
},
addBlockText() {
this.add({
type: 'text',
data: {
title: '',
text: '',
}
})
},
addBlockImage() {
this.add({
type: 'image',
data: {
title: '',
image_id: null,
image_url: null,
}
})
},
addBlockImageText() {
this.add({
type: 'image-text',
data: {
title: '',
text: '',
image_id: null,
image_url: null,
}
})
},
addBlockImages() {
this.add({
type: 'images',
data: {
title: '',
text: '',
images: [],
}
})
},
addBlockVideo() {
this.add({
type: 'video',
data: {
title: '',
video_url: '',
}
})
},
}
}
</script>
<style scoped>
</style>

@ -0,0 +1,54 @@
<template>
<div class="kit__section kit__section--block">
<div class="kit__section-remove">
<button type="button" @click="onRemove">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
</div>
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@change="onTitleChange">
</div>
</div>
<div class="kit__row">
<lil-image :image-id="imageId" :image-url="imageUrl" v-on:update:imageUrl="onUpdateImageUrl"
v-on:update:imageId="onUpdateImageId" :access-token="accessToken" />
</div>
</div>
</template>
<script>
import LilImage from "./Image";
export default {
name: "block-image",
props: ["index", "title", "imageUrl", "imageId", "accessToken"],
methods: {
onTitleChange(event) {
this.$emit('update:title', event.target.value);
},
onUpdateImageUrl(newValue) {
this.$emit('update:imageUrl', newValue);
},
onUpdateImageId(newValue) {
this.$emit('update:imageId', newValue);
},
onRemove() {
this.$emit('remove', this.index);
}
},
components: {
'lil-image': LilImage,
},
}
</script>
<style scoped>
</style>

@ -0,0 +1,63 @@
<template>
<div class="kit__section kit__section--block">
<div class="kit__section-remove">
<button type="button" @click="onRemove">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
</div>
<div class="kit__row">
<lil-image :image-id="imageId" :image-url="imageUrl" v-on:update:imageUrl="onUpdateImageUrl"
v-on:update:imageId="onUpdateImageId" :access-token="accessToken"/>
<div class="kit__fieldset">
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@change="onTitleChange">
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<textarea class="field__textarea field__textarea_sm"
placeholder="Описание"
:value="text"
@change="onTextChange"></textarea>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import LilImage from "./Image";
export default {
name: "block-image-text",
props: ["index", "title", "text", "imageUrl", "imageId", "accessToken"],
methods: {
onTitleChange(event) {
this.$emit('update:title', event.target.value);
},
onTextChange(event) {
this.$emit('update:text', event.target.value);
},
onUpdateImageUrl(newValue) {
this.$emit('update:imageUrl', newValue);
},
onUpdateImageId(newValue) {
this.$emit('update:imageId', newValue);
},
onRemove() {
this.$emit('remove', this.index);
}
},
components: {
'lil-image': LilImage,
},
}
</script>

@ -0,0 +1,91 @@
<template>
<div class="kit__section kit__section--block">
<div class="kit__section-remove">
<button type="button" @click="onRemove">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
</div>
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@change="onTitleChange">
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<textarea class="field__textarea field__textarea_sm"
:value="text"
placeholder="Описание"
@change="onTextChange"></textarea>
</div>
</div>
<div class="kit__gallery">
<div class="kit__preview" v-for="image in images" v-bind:class="{ 'kit__preview--loading': image.loading }">
<img :src="image.src" class="kit__pic">
</div>
<div class="kit__photo">
<svg class="icon icon-add-plus">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
</svg>
<input type="file" class="kit__file" multiple @change="onImageAdded">
</div>
</div>
</div>
</template>
<script>
import {api} from "../../js/modules/api";
export default {
name: "block-images",
props: ["index", "title", "text", "images", "accessToken"],
methods: {
onTitleChange(event) {
this.$emit('update:title', event.target.value);
},
onTextChange(event) {
this.$emit('update:text', event.target.value);
},
onImageAdded(event) {
Array.from(event.target.files).forEach((file) => {
let reader = new FileReader();
reader.onload = () => {
let images = this.images;
images.push({
src: reader.result,
loading: true,
});
this.$emit('update:images', images);
api.uploadImage(reader.result, this.accessToken)
.then((response) => {
let images = this.images;
images.forEach((image, index) => {
if (image.src === reader.result) {
images[index].img = response.data.id;
images[index].loading = false;
images[index].src = response.data.image;
}
});
this.$emit('update:images', images);
})
.catch((error) => {
console.log('error', error);
});
};
if (file) {
reader.readAsDataURL(file);
}
});
},
onRemove() {
this.$emit('remove', this.index);
}
}
}
</script>

@ -0,0 +1,48 @@
<template>
<div class="kit__section kit__section--block">
<div class="kit__section-remove">
<button type="button" @click="onRemove">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
</div>
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@change="onTitleChange">
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<vue-redactor :value="text" v-on:update:value="onTextChange" placeholder="Описание"/>
</div>
</div>
</div>
</template>
<script>
import VueRedactor from '../redactor/VueRedactor';
export default {
name: "block-text",
props: ["index", "title", "text"],
methods: {
onTitleChange(event) {
this.$emit('update:title', event.target.value);
},
onTextChange(newValue) {
this.$emit('update:text', newValue);
},
onRemove() {
this.$emit('remove', this.index);
}
},
components: {
'vue-redactor': VueRedactor,
}
}
</script>

@ -0,0 +1,53 @@
<template>
<div class="kit__section kit__section--block">
<div class="kit__section-remove">
<button type="button" @click="onRemove">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
</div>
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@change="onTitleChange">
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<div class="field__flex">
<input type="text"
:value="videoUrl"
class="field__input field__input_sm"
placeholder="Вставьте ссылку на Vimeo, YouTube, или другой сервис"
@change="onVideoUrlChange">
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "block-video",
props: ["index", "title", "videoUrl"],
methods: {
onTitleChange(event) {
this.$emit('update:title', event.target.value);
},
onVideoUrlChange(event) {
this.$emit('update:videoUrl', event.target.value);
},
onRemove() {
this.$emit('remove', this.index);
}
}
}
</script>
<style scoped>
</style>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save