diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/v1/__init__.py b/api/v1/__init__.py new file mode 100644 index 00000000..8c95c2f2 --- /dev/null +++ b/api/v1/__init__.py @@ -0,0 +1,74 @@ +import imghdr +import base64 +import six +import uuid + +from django.core.files.base import ContentFile + +from rest_framework import serializers, 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 + + +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 diff --git a/api/v1/permissions.py b/api/v1/permissions.py new file mode 100644 index 00000000..b7f6e693 --- /dev/null +++ b/api/v1/permissions.py @@ -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 diff --git a/api/v1/serializers.py b/api/v1/serializers.py new file mode 100644 index 00000000..ca2b77f6 --- /dev/null +++ b/api/v1/serializers.py @@ -0,0 +1,337 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from . import Base64ImageField + +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 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 MaterialSerializer(serializers.ModelSerializer): + + class Meta: + model = Material + fields = ( + 'id', + 'title', + 'cover', + 'short_description', + 'created_at', + 'update_at', + ) + + read_only_fields = ( + 'id', + 'cover', + 'created_at', + 'update_at', + ) + + +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 CourseSerializer(serializers.ModelSerializer): + + class Meta: + model = Course + fields = ( + 'id', + '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', + 'content', + ) + + read_only_fields = ( + 'id', + 'cover', + 'content', + 'created_at', + 'update_at', + ) + + +class CourseRetrieveSerializer(CourseSerializer): + category = CategorySerializer() + materials = MaterialSerializer(many=True) + + +class LessonSerializer(serializers.ModelSerializer): + + class Meta: + model = Lesson + fields = ( + 'id', + 'title', + 'short_description', + 'course', + 'cover', + 'created_at', + 'update_at', + ) + + read_only_fields = ( + 'id', + 'cover', + 'created_at', + 'update_at', + ) + + +class ImageSerializer(serializers.ModelSerializer): + + class Meta: + model = Image + fields = ( + 'id', + 'course', + 'lesson', + 'title', + 'position', + 'created_at', + 'update_at', + ) + ('img',) + + read_only_fields = ( + 'id', + 'img', + 'created_at', + 'update_at', + ) + + +class TextSerializer(serializers.ModelSerializer): + + class Meta: + model = Text + fields = ( + 'id', + 'course', + 'lesson', + 'title', + 'position', + 'created_at', + 'update_at', + ) + ('txt',) + + read_only_fields = ( + 'id', + 'created_at', + 'update_at', + ) + + +class ImageTextSerializer(serializers.ModelSerializer): + + class Meta: + model = ImageText + fields = ( + 'id', + 'course', + 'lesson', + 'title', + 'position', + 'created_at', + 'update_at', + ) + ('img', 'txt',) + + read_only_fields = ( + 'id', + 'img', + 'created_at', + 'update_at', + ) + + +class VideoSerializer(serializers.ModelSerializer): + + class Meta: + model = Video + fields = ( + 'id', + 'course', + 'lesson', + 'title', + 'position', + 'created_at', + 'update_at', + ) + ('url',) + + read_only_fields = ( + 'id', + 'created_at', + 'update_at', + ) + + +class GallerySerializer(serializers.ModelSerializer): + + class Meta: + model = Gallery + fields = ( + 'id', + 'course', + 'title', + 'created_at', + 'update_at', + ) + + read_only_fields = ( + 'id', + 'created_at', + 'update_at', + ) + + +class GalleryImageSerializer(serializers.ModelSerializer): + + class Meta: + model = GalleryImage + fields = ( + 'id', + 'gallery', + 'image', + 'created_at', + 'update_at', + ) + + read_only_fields = ( + 'id', + 'image', + 'created_at', + 'update_at', + ) + + +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 CoverImageSerializer(serializers.Serializer): + cover = Base64ImageField( + required=False, allow_empty_file=True, allow_null=True + ) + + +class UserPhotoSerializer(serializers.Serializer): + photo = Base64ImageField( + required=False, allow_empty_file=True, allow_null=True + ) + + +class ContentImageSerializer(serializers.Serializer): + img = Base64ImageField( + required=False, allow_empty_file=True, allow_null=True + ) + + +class GalleryImageSerializer(serializers.Serializer): + image = Base64ImageField( + required=False, allow_empty_file=True, allow_null=True + ) diff --git a/api/v1/urls.py b/api/v1/urls.py new file mode 100644 index 00000000..846af2ae --- /dev/null +++ b/api/v1/urls.py @@ -0,0 +1,51 @@ +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 .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(.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('', include((router.urls, 'api-root')), name='api-root') +] diff --git a/api/v1/views.py b/api/v1/views.py new file mode 100644 index 00000000..7ecb780d --- /dev/null +++ b/api/v1/views.py @@ -0,0 +1,269 @@ +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 import ( + CategorySerializer, CourseSerializer, + MaterialSerializer, LikeSerializer, + ImageSerializer, TextSerializer, + ImageTextSerializer, VideoSerializer, + GallerySerializer, GalleryImageSerializer, + UserSerializer, UserPhotoSerializer, + LessonSerializer, ContentImageSerializer, + GalleryImageSerializer, CoverImageSerializer, + CourseRetrieveSerializer, ImageObjectSerializer, +) +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 = 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' + ).prefetch_related( + 'likes', 'materials', 'content', + ).all() + serializer_class = CourseSerializer + serializer_class_map = { + 'list': CourseRetrieveSerializer, + 'retrieve': CourseRetrieveSerializer, + 'upload_photo': CoverImageSerializer, + } + 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, + # } + + @detail_route(methods=['post'], url_path='upload-photo') + def upload_photo(self, request, pk=None): + course = self.get_object() + serializer = self.get_serializer() + serialized_data = serializer(data=request.data) + if serialized_data.is_valid(): + course.cover = serialized_data['cover'] + course.save() + return Response({'success': True}) + else: + return Response({'success': False}, status=status.HTTP_400_BAD_REQUEST) + + +class LessonViewSet(ExtendedModelViewSet): + queryset = Lesson.objects.select_related('course').all() + serializer_class = LessonSerializer + serializer_class_map = { + 'upload_photo': CoverImageSerializer, + } + filter_fields = ('course',) + search_fields = ('title', 'short_description',) + ordering_fields = ('title', 'created_at', 'update_at',) + # permission_classes = (IsAuthorObjectOrAdmin,) + # permission_map = { + # 'create': IsAuthorOrAdmin, + # 'delete': IsAdmin, + # } + + @detail_route(methods=['post'], url_path='upload-photo') + def upload_photo(self, request, pk=None): + lesson = self.get_object() + serializer = self.get_serializer() + serialized_data = serializer(data=request.data) + if serialized_data.is_valid(): + lesson.cover = serialized_data['cover'] + lesson.save() + return Response({'success': True}) + else: + return Response({'success': False}, status=status.HTTP_400_BAD_REQUEST) + + +class ImageViewSet(ExtendedModelViewSet): + queryset = Image.objects.select_related( + 'course', 'lesson' + ).all() + serializer_class = ImageSerializer + serializer_class_map = { + 'upload_photo': ContentImageSerializer, + } + search_fields = ('title',) + ordering_fields = ('title', 'created_at', 'update_at', 'position',) + # permission_classes = (IsAuthorOrAdmin,) + # permission_map = { + # 'delete': IsAdmin, + # } + + @detail_route(methods=['post'], url_path='upload-photo') + def upload_photo(self, request, pk=None): + image = self.get_object() + serializer = self.get_serializer() + serialized_data = serializer(data=request.data) + if serialized_data.is_valid(): + image.img = serialized_data['img'] + image.save() + return Response({'success': True}) + else: + return Response({'success': False}, status=status.HTTP_400_BAD_REQUEST) + + +class TextViewSet(ExtendedModelViewSet): + queryset = Text.objects.select_related( + 'course', 'lesson' + ).all() + serializer_class = 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' + ).all() + serializer_class = ImageTextSerializer + serializer_class_map = { + 'upload_photo': ContentImageSerializer, + } + search_fields = ('title',) + ordering_fields = ('title', 'created_at', 'update_at', 'position',) + # permission_classes = (IsAuthorOrAdmin,) + # permission_map = { + # 'delete': IsAdmin, + # } + + @detail_route(methods=['post'], url_path='upload-photo') + def upload_photo(self, request, pk=None): + image_text = self.get_object() + serializer = self.get_serializer() + serialized_data = serializer(data=request.data) + if serialized_data.is_valid(): + image_text.img = serialized_data['img'] + image_text.save() + return Response({'success': True}) + else: + return Response({'success': False}, status=status.HTTP_400_BAD_REQUEST) + + +class VideoViewSet(ExtendedModelViewSet): + queryset = Video.objects.select_related( + 'course', 'lesson' + ).all() + serializer_class = 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.select_related('course').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').all() + serializer_class = GalleryImageSerializer + serializer_class_map = { + 'upload_photo': GalleryImageSerializer, + } + search_fields = ('gallery__title',) + # permission_classes = (IsAuthorOrAdmin,) + # permission_map = { + # 'delete': IsAdmin, + # } + + @detail_route(methods=['post'], url_path='upload-photo') + def upload_photo(self, request, pk=None): + gallery_image = self.get_object() + serializer = self.get_serializer() + serialized_data = serializer(data=request.data) + if serialized_data.is_valid(): + gallery_image.image = serialized_data['image'] + gallery_image.save() + return Response({'success': True}) + else: + return Response({'success': False}, status=status.HTTP_400_BAD_REQUEST) + + +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, + # } + + @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) diff --git a/apps/auth/views.py b/apps/auth/views.py index 03bafabb..3d4ba19d 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -5,6 +5,7 @@ from facepy.exceptions import FacepyError from django.contrib.auth import get_user_model, logout, login, views from django.contrib.auth.forms import AuthenticationForm +from django.core.files.base import ContentFile from django.http import JsonResponse from django.urls import reverse_lazy from django.utils.decorators import method_decorator @@ -135,27 +136,42 @@ class FacebookLoginOrRegistration(View): graph = GraphAPI(access_token) try: data = graph.get('/me?fields=email, first_name, last_name') + photo_data = graph.get('/me/picture?height=120') except FacepyError: return JsonResponse({"success": False}) fb_id = data.get('id') - lilcity_user_settings = User.objects.filter(fb_id=fb_id) - if lilcity_user_settings.count(): - login(requests, user=lilcity_user_settings[0]) - return JsonResponse({"success": True}) - - email = requests.POST.get('email') or data.get('email') - if not email: - return JsonResponse({"success": False, - "errors": {"email": 'is field required'} - }) + try: + user = User.objects.get(fb_id=fb_id) + except User.DoesNotExist: + email = requests.POST.get('email') or data.get('email') + if not email: + return JsonResponse({"success": False, + "errors": {"email": 'is field required'} + }) + else: + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + first_name = data.get('first_name', '') + last_name = data.get('last_name', '') + + user = User.objects.create_user(username=email, email=email, first_name=first_name, last_name=last_name, password=uuid4().hex) + user.is_email_proved = True + user.fb_id = fb_id + if photo_data: + photo = ContentFile(photo_data) + fname = str(fb_id) + '.jpg' + user.photo.save(fname, photo, save=True) + user.save() + + login(requests, user=user) + return JsonResponse({"success": True}) else: - first_name = data.get('first_name', '') - last_name = data.get('last_name', '') - - user = User.objects.create_user(username=email, email=email, first_name=first_name, last_name=last_name, password=uuid4().hex) - user.is_email_proved = True - user.fb_id = fb_id - user.save() + if not user.photo and photo_data: + photo = ContentFile(photo_data) + fname = str(fb_id) + '.jpg' + user.photo.save(fname, photo, save=True) + login(requests, user=user) return JsonResponse({"success": True}) diff --git a/apps/content/__init__.py b/apps/content/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/content/admin.py b/apps/content/admin.py new file mode 100644 index 00000000..bb935e08 --- /dev/null +++ b/apps/content/admin.py @@ -0,0 +1,66 @@ +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 + + +@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(Content) +class ContentAdmin(PolymorphicParentModelAdmin): + base_model = Content + child_models = ( + Image, + Text, + ImageText, + Video + ) + + +@admin.register(Gallery) +class GalleryAdmin(admin.ModelAdmin): + pass + + +@admin.register(GalleryImage) +class GalleryImageAdmin(admin.ModelAdmin): + pass diff --git a/apps/content/apps.py b/apps/content/apps.py new file mode 100644 index 00000000..0905fca9 --- /dev/null +++ b/apps/content/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ContentConfig(AppConfig): + name = 'content' + verbose_name = 'Контент' diff --git a/apps/content/migrations/0001_initial.py b/apps/content/migrations/0001_initial.py new file mode 100644 index 00000000..6b05bcc5 --- /dev/null +++ b/apps/content/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/apps/content/migrations/0002_auto_20180205_1212.py b/apps/content/migrations/0002_auto_20180205_1212.py new file mode 100644 index 00000000..8dfa06a8 --- /dev/null +++ b/apps/content/migrations/0002_auto_20180205_1212.py @@ -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), + ), + ] diff --git a/apps/content/migrations/0003_auto_20180205_1246.py b/apps/content/migrations/0003_auto_20180205_1246.py new file mode 100644 index 00000000..978f00b1 --- /dev/null +++ b/apps/content/migrations/0003_auto_20180205_1246.py @@ -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': 'Контент'}, + ), + ] diff --git a/apps/content/migrations/0004_gallery_galleryimage.py b/apps/content/migrations/0004_gallery_galleryimage.py new file mode 100644 index 00000000..aed5808e --- /dev/null +++ b/apps/content/migrations/0004_gallery_galleryimage.py @@ -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',), + }, + ), + ] diff --git a/apps/content/migrations/0005_auto_20180208_0520.py b/apps/content/migrations/0005_auto_20180208_0520.py new file mode 100644 index 00000000..a739f51f --- /dev/null +++ b/apps/content/migrations/0005_auto_20180208_0520.py @@ -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='Урок'), + ), + ] diff --git a/apps/content/migrations/0006_auto_20180208_0551.py b/apps/content/migrations/0006_auto_20180208_0551.py new file mode 100644 index 00000000..361a2d49 --- /dev/null +++ b/apps/content/migrations/0006_auto_20180208_0551.py @@ -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), + ), + ] diff --git a/apps/content/migrations/__init__.py b/apps/content/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/content/models.py b/apps/content/models.py new file mode 100644 index 00000000..64b1c200 --- /dev/null +++ b/apps/content/models.py @@ -0,0 +1,97 @@ +from django.db import models + +from polymorphic.models import PolymorphicModel + +from apps.course.models import Course, Lesson + + +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, on_delete=models.CASCADE, + null=True, blank=True, + verbose_name='Курс', + related_name='content', + ) + lesson = models.ForeignKey( + 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, unique=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',) + + +class Image(Content): + img = models.ImageField('Изображение', upload_to='content/images') + + +class Text(Content): + txt = models.TextField('Текст', default='') + + +class ImageText(Content): + img = models.ImageField('Изображение', upload_to='content/images') + txt = models.TextField('Текст', default='') + + +class Video(Content): + url = models.URLField('Ссылка') + + +class Gallery(models.Model): + course = models.ForeignKey( + Course, on_delete=models.CASCADE, + null=True, blank=True, + verbose_name='Курс' + ) + title = models.CharField('Заголовок', max_length=100, default='') + + 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 GalleryImage(models.Model): + gallery = models.ForeignKey( + Gallery, on_delete=models.CASCADE, + verbose_name='Галерея' + ) + image = models.ImageField( + 'Изображение', upload_to='content/gallery_images' + ) + + 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',) diff --git a/apps/content/tests.py b/apps/content/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/content/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/content/views.py b/apps/content/views.py new file mode 100644 index 00000000..28002783 --- /dev/null +++ b/apps/content/views.py @@ -0,0 +1,2 @@ +from django.shortcuts import render + diff --git a/apps/course/migrations/0020_auto_20180202_1716.py b/apps/course/migrations/0020_auto_20180202_1716.py new file mode 100644 index 00000000..1b7f63d8 --- /dev/null +++ b/apps/course/migrations/0020_auto_20180202_1716.py @@ -0,0 +1,48 @@ +# Generated by Django 2.0.2 on 2018-02-02 17:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import polymorphic_tree.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('course', '0019_auto_20180130_1630'), + ] + + operations = [ + migrations.AlterModelOptions( + name='coursecomment', + options={'base_manager_name': 'objects', 'ordering': ('tree_id', 'lft'), 'verbose_name': 'Комментарий курса', 'verbose_name_plural': 'Комментарии курсов'}, + ), + migrations.AlterModelOptions( + name='lessoncomment', + options={'base_manager_name': 'objects', 'ordering': ('tree_id', 'lft'), 'verbose_name': 'Комментарий урока', 'verbose_name_plural': 'Комментарии уроков'}, + ), + migrations.RemoveField( + model_name='coursecomment', + name='parent', + ), + migrations.RemoveField( + model_name='lessoncomment', + name='parent', + ), + migrations.AddField( + model_name='comment', + name='parent', + field=polymorphic_tree.models.PolymorphicTreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='course.Comment'), + ), + migrations.AddField( + model_name='comment', + name='polymorphic_ctype', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_course.comment_set+', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='comment', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/course/migrations/0021_auto_20180206_0632.py b/apps/course/migrations/0021_auto_20180206_0632.py new file mode 100644 index 00000000..f527bc4a --- /dev/null +++ b/apps/course/migrations/0021_auto_20180206_0632.py @@ -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': 'Категории'}, + ), + ] diff --git a/apps/course/models.py b/apps/course/models.py index 2cbc6d27..76c46756 100644 --- a/apps/course/models.py +++ b/apps/course/models.py @@ -3,7 +3,7 @@ from django.db import models from django.utils import timezone from django.contrib.auth import get_user_model -from mptt.models import MPTTModel, TreeForeignKey +from polymorphic_tree.models import PolymorphicMPTTModel, PolymorphicTreeForeignKey from .manager import CategoryQuerySet @@ -86,6 +86,7 @@ class Category(models.Model): class Meta: verbose_name = 'Категория' verbose_name_plural = 'Категории' + ordering = ['title'] class Lesson(models.Model): @@ -123,9 +124,10 @@ class Material(models.Model): ordering = ('title',) -class Comment(MPTTModel): - author = models.ForeignKey(User, on_delete=models.PROTECT) +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) created_at = models.DateTimeField(auto_now_add=True) update_at = models.DateTimeField(auto_now=True) @@ -142,11 +144,9 @@ class Comment(MPTTModel): class MPTTMeta: order_insertion_by = ['-created_at'] - abstract = True class CourseComment(Comment): - parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.PROTECT) course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='comments') class Meta(Comment.Meta): @@ -155,7 +155,6 @@ class CourseComment(Comment): class LessonComment(Comment): - parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.PROTECT) lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments') class Meta(Comment.Meta): diff --git a/apps/course/templates/course/_items.html b/apps/course/templates/course/_items.html index af350f5c..fe9a5a3d 100644 --- a/apps/course/templates/course/_items.html +++ b/apps/course/templates/course/_items.html @@ -6,27 +6,31 @@ data-course data-course-id={{ course.id }} {% if course.is_deferred_start %}data-future-course data-future-course-time={{ course.deferred_start_at.timestamp }}{% endif %} > - - -
Подробнее
- {% if course.is_featured %} -
- {% endif %} {% if course.is_deferred_start %} -
-
Курс начнется:
-
{{ course.deferred_start_at_humanize }}
+
+ {% if course.cover %} + + {% else %} + + {% endif %} +
Подробнее
+ {% if course.is_featured %} +
+ {% endif %} {% if course.is_deferred_start %} +
+
Курс начнется:
+
{{ course.deferred_start_at_humanize }}
{% endif %} -
-
+ +
{{ course.category | upper }} {% if not course.is_free %}
{{ course.price|floatformat:"-2" }}₽
{% endif %}
- {{ course.title }} + {{ course.title }}
{{ course.short_description }}
@@ -42,7 +46,7 @@ {% endif %}
- +
{{ course.author.get_full_name }}
diff --git a/apps/course/templates/course/course.html b/apps/course/templates/course/course.html index cf994653..2176d023 100644 --- a/apps/course/templates/course/course.html +++ b/apps/course/templates/course/course.html @@ -7,7 +7,7 @@
- +
diff --git a/apps/course/templates/course/inclusion/category_items.html b/apps/course/templates/course/inclusion/category_items.html index 3de03a41..5e6aeef5 100644 --- a/apps/course/templates/course/inclusion/category_items.html +++ b/apps/course/templates/course/inclusion/category_items.html @@ -1,5 +1,5 @@ {% for cat in category_items %} -
+
{{ cat.title }}
{% endfor %} \ No newline at end of file diff --git a/apps/course/views.py b/apps/course/views.py index e173f477..cd5fd64a 100644 --- a/apps/course/views.py +++ b/apps/course/views.py @@ -90,7 +90,7 @@ def coursecomment(request, course_id): @login_required @csrf_exempt @require_http_methods(['POST']) -def lessoncomment(request, Lesson): +def lessoncomment(request, lesson_id): try: lesson = Lesson.objects.get(id=lesson_id) except Lesson.DoesNotExist: @@ -111,7 +111,7 @@ def lessoncomment(request, Lesson): lessoncomment = LessonComment.objects.create( author=request.user, content=comment, - course=course, + lesson=lesson, ) else: try: @@ -119,13 +119,13 @@ def lessoncomment(request, Lesson): except LessonComment.DoesNotExist: return JsonResponse({ 'success': False, - 'errors': ['CourseComment with id f{reply_to} not found'] + 'errors': ['LessonComment with id f{reply_to} not found'] }, status=400) else: lessoncomment = LessonComment.objects.create( author=request.user, content=comment, - course=course, + lesson=lesson, parent=_lessoncomment, ) ctx = {'node': lessoncomment, 'user': request.user} @@ -141,6 +141,11 @@ class CourseView(DetailView): context_object_name = 'course' template_name = 'course/course.html' + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['next'] = self.request.GET.get('next', None) + return context + class CoursesView(ListView): model = Course @@ -153,7 +158,7 @@ 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) + 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') @@ -172,7 +177,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' + ).prefetch_related( + 'likes', 'materials', 'content', + ) filtered = CourseFilter(self.request.GET, queryset=queryset) return filtered.qs @@ -207,9 +216,10 @@ class SearchView(CoursesView): queryset = queryset.none() return queryset - def get_context_data(self): - context = super().get_context_data() + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) context['q'] = self.request.GET.get('q', None) or '' + return context if 'is_paginated' in context and context['is_paginated']: page_obj = context.get('page_obj') context['page'] = page_obj.number diff --git a/apps/user/forms.py b/apps/user/forms.py new file mode 100644 index 00000000..697d3910 --- /dev/null +++ b/apps/user/forms.py @@ -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', + ) diff --git a/apps/user/migrations/0005_user_birthday.py b/apps/user/migrations/0005_user_birthday.py new file mode 100644 index 00000000..479a9294 --- /dev/null +++ b/apps/user/migrations/0005_user_birthday.py @@ -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='День рождения'), + ), + ] diff --git a/apps/user/migrations/0006_auto_20180206_1352.py b/apps/user/migrations/0006_auto_20180206_1352.py new file mode 100644 index 00000000..b65c014a --- /dev/null +++ b/apps/user/migrations/0006_auto_20180206_1352.py @@ -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='О себе'), + ), + ] diff --git a/apps/user/migrations/0007_auto_20180207_0808.py b/apps/user/migrations/0007_auto_20180207_0808.py new file mode 100644 index 00000000..98dd1616 --- /dev/null +++ b/apps/user/migrations/0007_auto_20180207_0808.py @@ -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='Страна'), + ), + ] diff --git a/apps/user/models.py b/apps/user/models.py index d5633f8c..141073ce 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -5,22 +5,29 @@ from django.utils.translation import gettext_lazy as _ 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) + 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) diff --git a/apps/user/templates/user/profile-settings.html b/apps/user/templates/user/profile-settings.html new file mode 100644 index 00000000..4af4e6ba --- /dev/null +++ b/apps/user/templates/user/profile-settings.html @@ -0,0 +1,209 @@ +{% extends "templates/lilcity/index.html" %} {% load static %} {% block content %} +
+{% comment %} + +{% endcomment %} +{% if messages %} +
+
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+
+{% endif %} +{{form.errors}} +
+
+
+
+ {% csrf_token %} +
+
Личные данные
+
+ {% if user.photo %} + + {% else %} + + {% endif %} + +
+ + + +
+
+
+
+
ИМЯ
+
+ +
+
+
+
ФАМИЛИЯ
+
+ +
+
+
+
+
Почта
+
+ +
+
+
+
+
ГОРОД
+
+ +
+
+
+
СТРАНА
+
+ +
+
+
+
+
+
ДАТА РОЖДЕНИЯ
+
+ +
+
+
+
ПОЛ
+
+
+
+ {% if user.gender == 'f' %}Ж{% elif user.gender == 'm' %}M{% else %}М / Ж{% endif %} +
+
+
+
М
+
+
+
Ж
+
+
+ +
+
+
+
+
+
О себе
+
+ +
+
+
+
+
Пароль
+
+
ТЕКУЩИЙ ПАРОЛЬ
+
+ +
+
+
+
НОВЫЙ ПАРОЛЬ
+
+ +
+
+
+
ПОДТВЕРДИТЬ НОВЫЙ ПАРОЛЬ
+
+ +
+
+
+
+
Соцсети
+
+
INSTAGRAM
+
+ +
+
+
+
FACEBOOK
+
+ +
+
+
+
TWITTER
+
+ +
+
+
+
PINTEREST
+
+ +
+
+
+
YOUTUBE
+
+ +
+
+
+
VKONTAKTE
+
+ +
+
+
+
+ +
+
+
+
+
+ +{% endblock content %} \ No newline at end of file diff --git a/apps/user/templates/user/profile.html b/apps/user/templates/user/profile.html index 2142dca5..c4085f29 100644 --- a/apps/user/templates/user/profile.html +++ b/apps/user/templates/user/profile.html @@ -2,7 +2,7 @@
- Редактировать + Редактировать {% if user.photo %}
diff --git a/apps/user/views.py b/apps/user/views.py index 18d7c9ce..3b3f1daa 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -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]) diff --git a/docker-compose.yml b/docker-compose.yml index 1736e0f3..a6d65c69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,18 +12,26 @@ services: ports: - "5432:5432" + redis: + image: redis:3-alpine + ports: + - "6379:6379" + web: build: . 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" + 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: - "${PORT}:8000" depends_on: - db + - redis links: - - db \ No newline at end of file + - db + - redis \ No newline at end of file diff --git a/project/celery.py b/project/celery.py new file mode 100644 index 00000000..8cbb640c --- /dev/null +++ b/project/celery.py @@ -0,0 +1,21 @@ +import os +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') + +app = Celery('project') + +# Using a string here means the worker don't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('project.celery_settings') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + return f'Request: {self.request}' diff --git a/project/celery_settings.py b/project/celery_settings.py new file mode 100644 index 00000000..8afa610e --- /dev/null +++ b/project/celery_settings.py @@ -0,0 +1,3 @@ +broker_url = 'redis://redis:6379/0' +result_backend = 'redis://redis:6379/1' +task_serializer = 'json' diff --git a/project/settings.py b/project/settings.py index f55487d1..a450d5b2 100644 --- a/project/settings.py +++ b/project/settings.py @@ -40,16 +40,26 @@ INSTALLED_APPS = [ 'anymail', 'active_link', 'django_filters', + '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', @@ -58,6 +68,8 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +if DEBUG: + MIDDLEWARE += ['silk.middleware.SilkyMiddleware'] ROOT_URLCONF = 'project.urls' @@ -150,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 @@ -169,4 +181,34 @@ TWILIO_ACCOUNT = 'ACdf4a96b776cc764bc3ec0f0e136ba550' TWILIO_TOKEN = '559a6b1fce121759c9af2dcbb3f755ea' TWILIO_FROM_PHONE = '+37128914409' -ACTIVE_LINK_STRICT = True \ No newline at end of file +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_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', +} diff --git a/project/templates/lilcity/index.html b/project/templates/lilcity/index.html index d6ef826c..43ae104b 100644 --- a/project/templates/lilcity/index.html +++ b/project/templates/lilcity/index.html @@ -33,7 +33,7 @@ - +