diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1d78b5d1..a1a48c4d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -44,6 +44,7 @@ stop-review: script: - export REVIEW_HOST=$CI_COMMIT_REF_SLUG-$REVIEW_DOMAIN - cd docker + - cp .env.review .env - docker-compose -f docker-compose-review.yml -p back$CI_COMMIT_REF_NAME down - rm -rf /work/data/back_${CI_COMMIT_REF_NAME}/ when: manual diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/v1/serializers/content.py b/api/v1/serializers/content.py index 729368e2..89daefa1 100644 --- a/api/v1/serializers/content.py +++ b/api/v1/serializers/content.py @@ -3,8 +3,7 @@ from rest_framework import serializers from apps.content.models import ( Baner, Content, Image, Text, ImageText, Video, - Gallery, GalleryImage, ImageObject, -) + Gallery, GalleryImage, ImageObject,) from . import Base64ImageField @@ -14,6 +13,7 @@ BASE_CONTENT_FIELDS = ( 'uuid', 'course', 'lesson', + 'contest', 'live_lesson', 'title', 'position', @@ -85,12 +85,16 @@ class ImageObjectSerializer(serializers.ModelSerializer): image = Base64ImageField( required=True, allow_empty_file=False, allow_null=False, read_only=False, ) + image_thumbnail = Base64ImageField( + required=False, allow_empty_file=True, allow_null=True, read_only=True, + ) class Meta: model = ImageObject fields = ( 'id', 'image', + 'image_thumbnail', 'created_at', 'update_at', ) @@ -254,3 +258,4 @@ class ContentSerializer(serializers.ModelSerializer): elif isinstance(obj, Gallery): return GallerySerializer(obj, context=self.context).to_representation(obj) return super(ContentSerializer, self).to_representation(obj) + diff --git a/api/v1/serializers/contest.py b/api/v1/serializers/contest.py new file mode 100644 index 00000000..9d2e63c4 --- /dev/null +++ b/api/v1/serializers/contest.py @@ -0,0 +1,75 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from api.v1.serializers.content import ContentSerializer, ContentCreateSerializer, ImageObjectSerializer +from api.v1.serializers.mixins import DispatchContentMixin + +from apps.content.models import (Contest, ContestWork) + +User = get_user_model() + + +class ContestSerializer(serializers.ModelSerializer): + cover = ImageObjectSerializer() + content = ContentSerializer(many=True) + + class Meta: + model = Contest + fields = ['title', 'description', 'slug', 'cover', + 'date_start', 'date_end', 'active', 'content', 'finished'] + + +class ContestCreateSerializer(DispatchContentMixin, serializers.ModelSerializer): + content = serializers.ListSerializer( + child=ContentCreateSerializer(), + required=False, + ) + + class Meta: + model = Contest + fields = '__all__' + + def create(self, validated_data): + content = validated_data.pop('content', []) + contest = super().create(validated_data) + self.dispatch_content(contest, content) + return contest + + def update(self, instance, validated_data): + content = validated_data.pop('content', []) + contest = super().update(instance, validated_data) + self.dispatch_content(contest, content) + return contest + + def to_representation(self, instance): + return ContestSerializer(instance=instance, context=self.context).to_representation(instance) + + +class ContestWorkSerializer(serializers.ModelSerializer): + image = ImageObjectSerializer() + likes = serializers.SerializerMethodField() + user_liked = serializers.SerializerMethodField() + + class Meta: + model = ContestWork + fields = ['id', 'user', 'contest', 'image', 'child_full_name', 'age', + 'created_at', 'likes', 'user_liked', 'img_width', 'img_height'] + + def get_likes(self, instance): + return instance.likes.filter(user__is_active=True).count() + + def get_user_liked(self, instance): + # FIXME + user = self.context['request'].query_params.get('current_user') + if user: + user = User.objects.get(pk=user) + return instance.likes.filter(user=user).exists() if user else False + + +class ContestWorkCreateSerializer(serializers.ModelSerializer): + class Meta: + model = ContestWork + fields = '__all__' + + def to_representation(self, instance): + return ContestWorkSerializer(instance=instance, context=self.context).to_representation(instance) diff --git a/api/v1/serializers/course.py b/api/v1/serializers/course.py index 4e558f3a..2b7d2806 100644 --- a/api/v1/serializers/course.py +++ b/api/v1/serializers/course.py @@ -1,6 +1,10 @@ +from ipware import get_client_ip + from rest_framework import serializers from rest_framework.validators import UniqueValidator +from django.contrib.auth import get_user_model + from apps.course.models import ( Category, Course, Comment, CourseComment, LessonComment, @@ -15,12 +19,15 @@ from .content import ( from apps.content.models import ( Content, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, -) + ContestWork) from .user import UserSerializer from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin +User = get_user_model() + + class MaterialCreateSerializer(serializers.ModelSerializer): class Meta: @@ -63,6 +70,26 @@ class LikeSerializer(serializers.ModelSerializer): ) +class LikeCreateSerializer(serializers.ModelSerializer): + + class Meta: + model = Like + fields = ['user'] + + def create(self, validated_data): + # FIXME + if validated_data.get('user'): + user = validated_data.get('user') + else: + user = self.context['request'].user + client_ip, is_routable = get_client_ip(self.context['request']) + like = Like.objects.create(user=user, ip=client_ip) + return like + + def to_representation(self, instance): + return LikeSerializer(instance, context=self.context).to_representation(instance) + + class CategorySerializer(serializers.ModelSerializer): class Meta: diff --git a/api/v1/serializers/mixins.py b/api/v1/serializers/mixins.py index 0f055f00..40584503 100644 --- a/api/v1/serializers/mixins.py +++ b/api/v1/serializers/mixins.py @@ -4,7 +4,7 @@ from apps.school.models import LiveLesson from apps.content.models import ( Content, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, -) + Contest) from .content import ( TextCreateSerializer, ImageCreateSerializer, ImageTextCreateSerializer, VideoCreateSerializer, @@ -25,6 +25,8 @@ class DispatchContentMixin(object): obj_type = 'lesson' elif isinstance(obj, LiveLesson): obj_type = 'live_lesson' + elif isinstance(obj, Contest): + obj_type = 'contest' cdata[obj_type] = obj.id if ctype == 'text': if 'id' in cdata and cdata['id']: diff --git a/api/v1/serializers/school.py b/api/v1/serializers/school.py index 751bb6c2..4f8cd318 100644 --- a/api/v1/serializers/school.py +++ b/api/v1/serializers/school.py @@ -54,6 +54,8 @@ class SchoolScheduleSerializer(serializers.ModelSerializer): 'day_discount', 'start_at', 'schoolschedule_images', + 'cover', + 'trial_lesson', ) read_only_fields = ( @@ -96,6 +98,7 @@ class SchoolScheduleSerializerImg(serializers.ModelSerializer): child=GalleryImageSerializer(), required=False, ) + cover = ImageObjectSerializer() class Meta: model = SchoolSchedule @@ -111,6 +114,8 @@ class SchoolScheduleSerializerImg(serializers.ModelSerializer): 'day_discount', 'start_at', 'schoolschedule_images', + 'cover', + 'trial_lesson', ) read_only_fields = ( diff --git a/api/v1/serializers/user.py b/api/v1/serializers/user.py index 888806d0..bbb5e6b5 100644 --- a/api/v1/serializers/user.py +++ b/api/v1/serializers/user.py @@ -42,6 +42,7 @@ class UserSerializer(serializers.ModelSerializer): 'photo', 'balance', 'show_in_mainpage', + 'trial_lesson', ) read_only_fields = ( diff --git a/api/v1/urls.py b/api/v1/urls.py index 409ed79f..51610cb6 100644 --- a/api/v1/urls.py +++ b/api/v1/urls.py @@ -18,7 +18,7 @@ from .views import ( UserViewSet, LessonViewSet, ImageObjectViewSet, SchoolScheduleViewSet, LiveLessonViewSet, PaymentViewSet, -) + ContestViewSet, ContestWorkViewSet) router = DefaultRouter() router.register(r'author-requests', AuthorRequestViewSet, base_name='author-requests') @@ -44,6 +44,9 @@ router.register(r'school-schedules', SchoolScheduleViewSet, base_name='school-sc router.register(r'users', UserViewSet, base_name='users') +router.register(r'contests', ContestViewSet, base_name='contests') +router.register(r'contest-works', ContestWorkViewSet, base_name='contest_works') + # router.register(r'configs', ConfigViewSet, base_name='configs') diff --git a/api/v1/views.py b/api/v1/views.py index 41cd881a..7620244b 100644 --- a/api/v1/views.py +++ b/api/v1/views.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from rest_framework import status, views, viewsets, generics -from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import detail_route, list_route, action from rest_framework.response import Response from . import ExtendedModelViewSet @@ -14,7 +14,7 @@ from .serializers.course import ( CommentSerializer, MaterialSerializer, MaterialCreateSerializer, LessonSerializer, LessonCreateSerializer, -) + LikeCreateSerializer) from .serializers.content import ( BanerSerializer, ImageSerializer, ImageCreateSerializer, @@ -39,6 +39,9 @@ from .serializers.user import ( AuthorRequestSerializer, UserSerializer, UserPhotoSerializer, ) +from .serializers.contest import ( + ContestCreateSerializer, ContestSerializer, ContestWorkSerializer, ContestWorkCreateSerializer +) from .permissions import ( IsAdmin, IsAdminOrIsSelf, @@ -56,7 +59,7 @@ from apps.config.models import Config from apps.content.models import ( Baner, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, -) + Contest, ContestWork) from apps.payment.models import ( AuthorBalance, Payment, CoursePayment, SchoolPayment, @@ -110,6 +113,8 @@ class BanerViewSet(ExtendedModelViewSet): class ImageObjectViewSet(ExtendedModelViewSet): queryset = ImageObject.objects.all() serializer_class = ImageObjectSerializer + # FIXME + authentication_classes = [] # permission_classes = (IsAuthorOrAdmin,) @@ -126,11 +131,41 @@ class MaterialViewSet(ExtendedModelViewSet): class LikeViewSet(ExtendedModelViewSet): + OBJ_TYPE_CONTEST_WORK = 'contest_work' + queryset = Like.objects.select_related('user').all() - serializer_class = LikeSerializer + serializer_class = LikeCreateSerializer + serializer_class_map = { + 'list': LikeSerializer, + 'retrieve': LikeSerializer, + } search_fields = ('user__email', 'user__firstname', 'user__lastname',) ordering_fields = ('created_at', 'update_at',) # permission_classes = (IsAdmin,) + # FIXME + authentication_classes = [] + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + obj_type = request.data.get('obj_type') + obj_id = request.data.get('obj_id') + user = serializer.validated_data.get('user') + if not user.is_active: # FIXME and user.is_authenticated): + return Response(status=status.HTTP_403_FORBIDDEN) + if obj_type == self.OBJ_TYPE_CONTEST_WORK: + contest_work = ContestWork.objects.get(pk=obj_id) + if contest_work.user == user: + return Response({'error': u'Нельзя голосовать за свою работу'}, status=status.HTTP_400_BAD_REQUEST) + if contest_work.likes.filter(user=user).exists(): + return Response({'error': u'Вы уже голосовали за эту работу'}, status=status.HTTP_400_BAD_REQUEST) + if contest_work.contest.finished: + return Response({'error': u'Голосование закончено'}, status=status.HTTP_400_BAD_REQUEST) + instance = serializer.save() + if obj_type == self.OBJ_TYPE_CONTEST_WORK: + contest_work.likes.add(instance) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) class CategoryViewSet(ExtendedModelViewSet): @@ -419,13 +454,66 @@ class AuthorRequestViewSet(ExtendedModelViewSet): class PaymentViewSet(ExtendedModelViewSet): - queryset = Payment.objects.filter(status__isnull=False).order_by('-created_at') + queryset = Payment.objects.all() serializer_class = PaymentSerializer permission_classes = (IsAdmin,) - filter_fields = ('status',) + filter_fields = ('status', 'user',) ordering_fields = ( 'id', 'user__email', 'user__first_name', 'user__last_name', 'amount', 'created_at', ) search_fields = ('user__email', 'user__first_name', 'user__last_name',) + + def get_queryset(self): + queryset = self.queryset + course = self.request.query_params.get('course') + weekdays = self.request.query_params.getlist('weekdays[]') + if course: + queryset = CoursePayment.objects.filter(course=course) + if weekdays: + queryset = SchoolPayment.objects.filter(weekdays__overlap=weekdays) + + return queryset.filter(status__isnull=False).order_by('-created_at') + + @action(methods=['get'], detail=False, url_path='calc-amount', authentication_classes=[], permission_classes=[]) + def calc_amount(self, request, pk=None): + user = request.query_params.get('user') + course = request.query_params.get('course') + weekdays = request.query_params.getlist('weekdays[]') + user = user and User.objects.get(pk=user) + course = course and Course.objects.get(pk=course) + + return Response(Payment.calc_amount(user=user, course=course, weekdays=weekdays)) + + +class ContestViewSet(ExtendedModelViewSet): + queryset = Contest.objects.all() + serializer_class = ContestCreateSerializer + serializer_class_map = { + 'list': ContestSerializer, + 'retrieve': ContestSerializer, + } + filter_fields = ('active',) + search_fields = ('description', 'title', 'slug',) + ordering_fields = ('id', 'title', 'active', 'date_start', 'date_end',) + permission_classes = (IsAdmin,) + + +class ContestWorkViewSet(ExtendedModelViewSet): + queryset = ContestWork.objects.all() + serializer_class = ContestWorkCreateSerializer + serializer_class_map = { + 'list': ContestWorkSerializer, + 'retrieve': ContestWorkSerializer, + } + filter_fields = ('contest',) + # FIXME + authentication_classes = [] + + def create(self, request, *args, **kwargs): + # FIXME + user = User.objects.get(pk=request.data.get('user')) + if ContestWork.objects.filter(user=user).exists(): + return Response(status=status.HTTP_400_BAD_REQUEST) + return super().create(request, *args, **kwargs) diff --git a/apps/auth/backend.py b/apps/auth/backend.py new file mode 100644 index 00000000..41568b52 --- /dev/null +++ b/apps/auth/backend.py @@ -0,0 +1,17 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class CaseInsensitiveModelBackend(ModelBackend): + + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get(User.USERNAME_FIELD) + try: + user = User.objects.get(**{f'{User.USERNAME_FIELD}__iexact': username}) + if user.check_password(password) and self.user_can_authenticate(user): + return user + except User.DoesNotExist: + return None diff --git a/apps/auth/forms.py b/apps/auth/forms.py index 6977a3c2..e369a8f9 100644 --- a/apps/auth/forms.py +++ b/apps/auth/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.auth.forms import AuthenticationForm class LearnerRegistrationForm(forms.Form): @@ -7,9 +6,3 @@ class LearnerRegistrationForm(forms.Form): last_name = forms.CharField() email = forms.EmailField() password = forms.CharField() - - -class AuthenticationForm(AuthenticationForm): - - def clean_username(self): - return self.cleaned_data.get('username', '').lower() diff --git a/apps/auth/views.py b/apps/auth/views.py index acb7712d..379ab0a5 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -7,6 +7,7 @@ from facepy import GraphAPI 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 @@ -19,7 +20,7 @@ from django.shortcuts import redirect from apps.notification.utils import send_email from apps.config.models import Config -from .forms import LearnerRegistrationForm, AuthenticationForm +from .forms import LearnerRegistrationForm from .tokens import verification_email_token User = get_user_model() @@ -171,7 +172,7 @@ class FacebookLoginOrRegistration(View): else: email = email.lower() try: - user = User.objects.get(email=email) + user = User.objects.get(email__iexact=email) except User.DoesNotExist: first_name = data.get('first_name', '') last_name = data.get('last_name', '') diff --git a/apps/content/admin.py b/apps/content/admin.py index 1c92c012..c025f198 100644 --- a/apps/content/admin.py +++ b/apps/content/admin.py @@ -8,6 +8,7 @@ from polymorphic.admin import ( from apps.content.models import ( Baner, Content, Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, + Contest,ContestWork, ) @@ -79,3 +80,13 @@ class ContentAdmin(PolymorphicParentModelAdmin): @admin.register(GalleryImage) class GalleryImageAdmin(admin.ModelAdmin): pass + + +@admin.register(Contest) +class ContestAdmin(admin.ModelAdmin): + base_model = Contest + + +@admin.register(ContestWork) +class ContestWorkAdmin(admin.ModelAdmin): + base_model = ContestWork diff --git a/apps/content/migrations/0021_auto_20180813_1306.py b/apps/content/migrations/0021_auto_20180813_1306.py new file mode 100644 index 00000000..66033641 --- /dev/null +++ b/apps/content/migrations/0021_auto_20180813_1306.py @@ -0,0 +1,48 @@ +# Generated by Django 2.0.6 on 2018-08-13 13:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0040_course_age'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('content', '0020_auto_20180424_1607'), + ] + + operations = [ + migrations.CreateModel( + name='Contest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='', max_length=1000)), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=100, null=True, unique=True)), + ('date_start', models.DateField(blank=True, null=True, verbose_name='Дата начала')), + ('date_end', models.DateField(blank=True, null=True, verbose_name='Дата окончания')), + ('active', models.BooleanField(default=True)), + ('cover', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contest_covers', to='content.ImageObject', verbose_name='Фоновая картинка')), + ], + ), + migrations.CreateModel( + name='ContestWork', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('child_full_name', models.CharField(max_length=255)), + ('age', models.SmallIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('contest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.Contest')), + ('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contest_work_images', to='content.ImageObject', verbose_name='Работа участника')), + ('likes', models.ManyToManyField(blank=True, to='course.Like')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='content', + name='contest', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='content', to='content.Contest', verbose_name='Конкурс'), + ), + ] diff --git a/apps/content/migrations/0022_auto_20180815_2129.py b/apps/content/migrations/0022_auto_20180815_2129.py new file mode 100644 index 00000000..92cc567d --- /dev/null +++ b/apps/content/migrations/0022_auto_20180815_2129.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.6 on 2018-08-15 21:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0021_auto_20180813_1306'), + ] + + operations = [ + migrations.AlterModelOptions( + name='contestwork', + options={'ordering': ('-created_at',), 'verbose_name': 'Конкурсная работа', 'verbose_name_plural': 'Конкурсные работы'}, + ), + ] diff --git a/apps/content/models.py b/apps/content/models.py index 468058eb..0c445958 100644 --- a/apps/content/models.py +++ b/apps/content/models.py @@ -1,11 +1,25 @@ +from datetime import datetime, time from urllib.parse import urlparse +from django.conf import settings from django.db import models +from django.contrib.auth import get_user_model +from django.urls import reverse_lazy +from django.utils import timezone +from imagekit.models import ImageSpecField +from imagekit.processors import ResizeToCover from polymorphic.models import PolymorphicModel +User = get_user_model() + + class ImageObject(models.Model): image = models.ImageField('Изображение', upload_to='content/imageobject') + image_thumbnail = ImageSpecField(source='image', + processors=[ResizeToCover(300, 200, False)], + format='JPEG', + options={'quality': 85}) created_at = models.DateTimeField(auto_now_add=True) update_at = models.DateTimeField(auto_now=True) @@ -36,6 +50,12 @@ class Content(PolymorphicModel): verbose_name='Урок онлайн школы', related_name='content', ) + contest = models.ForeignKey( + 'Contest', on_delete=models.CASCADE, + null=True, blank=True, + verbose_name='Конкурс', + related_name='content', + ) title = models.CharField('Заголовок', max_length=100, default='') position = models.PositiveSmallIntegerField( 'Положение на странице', @@ -131,3 +151,62 @@ class Baner(models.Model): if self.use: Baner.objects.filter(use=True).update(use=False) return super().save(*args, **kwargs) + + +class Contest(models.Model): + title = models.CharField(max_length=255) + description = models.TextField(max_length=1000, blank=True, default='') + slug = models.SlugField( + allow_unicode=True, null=True, blank=True, + max_length=100, unique=True, db_index=True, + ) + cover = models.ForeignKey( + ImageObject, related_name='contest_covers', + verbose_name='Фоновая картинка', on_delete=models.CASCADE, + null=True, blank=True, + ) + date_start = models.DateField('Дата начала', null=True, blank=True) + date_end = models.DateField('Дата окончания', null=True, blank=True) + active = models.BooleanField(default=True) + # TODO? baner + + @property + def finished(self): + # FIXME + return datetime(2018, 8, 29, 21) < timezone.now() + + def save(self, *args, **kwargs): + if self.active: + Contest.objects.filter(active=True).update(active=False) + return super().save(*args, **kwargs) + + +class ContestWork(models.Model): + user = models.ForeignKey( + User, on_delete=models.CASCADE + ) + contest = models.ForeignKey(Contest, on_delete=models.CASCADE) + image = models.ForeignKey( + ImageObject, related_name='contest_work_images', + verbose_name='Работа участника', on_delete=models.CASCADE, + ) + child_full_name = models.CharField(max_length=255) + age = models.SmallIntegerField() + created_at = models.DateTimeField(auto_now_add=True) + likes = models.ManyToManyField('course.Like', blank=True) + + class Meta: + verbose_name = 'Конкурсная работа' + verbose_name_plural = 'Конкурсные работы' + ordering = ('-created_at',) + + @property + def img_width(self): + return self.image.image.width if self.image and self.image.image else None + + @property + def img_height(self): + return self.image.image.height if self.image and self.image.image else None + + def get_absolute_url(self): + return reverse_lazy('contest_work', args=[self.id]) diff --git a/apps/course/templates/course/content/gallery.html b/apps/content/templates/content/blocks/gallery.html similarity index 97% rename from apps/course/templates/course/content/gallery.html rename to apps/content/templates/content/blocks/gallery.html index 1116322a..69ad0386 100644 --- a/apps/course/templates/course/content/gallery.html +++ b/apps/content/templates/content/blocks/gallery.html @@ -1,32 +1,32 @@ -{% load thumbnail %} -{% if results %} -