Новый дизайн / Курсы поиск

remotes/origin/features/course-search-16-08-19
gzbender 7 years ago
parent 5407ccebb5
commit f10cd4e6b5
  1. 12
      api/v1/serializers/course.py
  2. 3
      api/v1/urls.py
  3. 9
      api/v1/views.py
  4. 38
      apps/course/migrations/0050_auto_20190815_1537.py
  5. 24
      apps/course/models.py
  6. 1
      apps/course/templates/course/_items.html
  7. 39
      apps/course/templates/course/courses.html
  8. 23
      apps/course/views.py
  9. 11
      web/package.json
  10. 56
      web/src/components/CourseRedactor.vue
  11. 120
      web/src/sass/_common.sass

@ -9,7 +9,7 @@ from apps.course.models import (
Comment, CourseComment, LessonComment,
Material, Lesson,
Like,
LiveLessonComment)
LiveLessonComment, Tag)
from .content import (
ImageObjectSerializer, ContentSerializer, ContentCreateSerializer,
GallerySerializer, )
@ -19,6 +19,12 @@ from .user import UserSerializer
User = get_user_model()
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ('tag',)
class MaterialCreateSerializer(serializers.ModelSerializer):
class Meta:
@ -116,6 +122,7 @@ class CourseCreateSerializer(DispatchContentMixin,
)
materials = MaterialSerializer(many=True, required=False)
gallery = GallerySerializer()
tags = TagSerializer(many=True, required=False)
class Meta:
model = Course
@ -145,6 +152,7 @@ class CourseCreateSerializer(DispatchContentMixin,
'content',
'gallery',
'lessons',
'tags',
)
read_only_fields = (
@ -269,6 +277,7 @@ class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
gallery = GallerySerializer()
content = ContentSerializer(many=True)
lessons = LessonSerializer(many=True)
tags = TagSerializer(many=True, required=False)
class Meta:
model = Course
@ -298,6 +307,7 @@ class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
'content',
'gallery',
'lessons',
'tags',
)
read_only_fields = (

@ -19,7 +19,7 @@ from .views import (
SchoolScheduleViewSet, LiveLessonViewSet,
PaymentViewSet, ObjectCommentsViewSet,
ContestViewSet, ContestWorkViewSet, NotifiedAboutBonuses,
AuthorBalanceUsersViewSet, CaptureEmail, FAQViewSet, UserGalleryViewSet, BonusesViewSet)
AuthorBalanceUsersViewSet, CaptureEmail, FAQViewSet, UserGalleryViewSet, BonusesViewSet, TagViewSet)
router = DefaultRouter()
router.register(r'author-requests', AuthorRequestViewSet, base_name='author-requests')
@ -48,6 +48,7 @@ router.register(r'users', UserViewSet, base_name='users')
router.register(r'user-gallery', UserGalleryViewSet, base_name='user-gallery')
router.register(r'contests', ContestViewSet, base_name='contests')
router.register(r'contest-works', ContestWorkViewSet, base_name='contest_works')
router.register(r'tags', TagViewSet, base_name='tags')
# router.register(r'configs', ConfigViewSet, base_name='configs')

@ -21,7 +21,7 @@ from .serializers.course import (
MaterialSerializer, MaterialCreateSerializer,
LessonSerializer, LessonCreateSerializer,
LikeCreateSerializer, CourseCommentSerializer, LessonCommentSerializer,
LiveLessonCommentSerializer,)
LiveLessonCommentSerializer, TagSerializer)
from .serializers.content import (
BannerSerializer,
ImageSerializer, ImageCreateSerializer,
@ -60,7 +60,7 @@ from apps.course.models import (
Comment, CourseComment, LessonComment,
Material, Lesson,
Like,
LiveLessonComment)
LiveLessonComment, Tag)
from apps.config.models import Config
from apps.content.models import (
Banner, Image, Text, ImageText, Video,
@ -774,3 +774,8 @@ class NotifiedAboutBonuses(views.APIView):
b.save()
return Response({'status': 'ok'})
class TagViewSet(ExtendedModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
search_fields = ('tag',)

@ -0,0 +1,38 @@
# Generated by Django 2.0.7 on 2019-08-15 15:37
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('course', '0049_auto_20190207_1551'),
]
operations = [
migrations.CreateModel(
name='CourseTags',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('course_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Course')),
],
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tag', models.CharField(max_length=20)),
],
),
migrations.AddField(
model_name='coursetags',
name='tag_id',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Tag'),
),
migrations.AddField(
model_name='course',
name='tags',
field=models.ManyToManyField(blank=True, through='course.CourseTags', to='course.Tag'),
),
]

@ -21,6 +21,17 @@ from apps.content.models import ImageObject, Gallery, Video, ContestWork
User = get_user_model()
def default_slug():
return str(uuid4())
def deferred_start_at_validator(value):
if value < now():
raise ValidationError(
'Дата и время начала курса не может быть меньше текущих.',
)
class Like(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
ip = models.GenericIPAddressField(blank=True, null=True)
@ -29,15 +40,13 @@ class Like(models.Model):
update_at = models.DateTimeField(auto_now=True)
def default_slug():
return str(uuid4())
class Tag(models.Model):
tag = models.CharField(max_length=20,)
def deferred_start_at_validator(value):
if value < now():
raise ValidationError(
'Дата и время начала курса не может быть меньше текущих.',
)
class CourseTags(models.Model):
tag_id = models.ForeignKey(Tag, on_delete=models.CASCADE)
course_id = models.ForeignKey('Course', on_delete=models.CASCADE)
class Course(BaseModel, DeactivatedMixin):
@ -110,6 +119,7 @@ class Course(BaseModel, DeactivatedMixin):
on_delete=models.CASCADE, null=True, blank=True,
related_name='results_gallery',
)
tags = models.ManyToManyField('Tag', through=CourseTags, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
update_at = models.DateTimeField(auto_now=True)

@ -13,7 +13,6 @@
{% empty %}
<img class="courses__pic" src="{% static 'img/no_cover.png' %}" width="300px" />
{% endthumbnail %}
<div class="courses__view">Подробнее</div>
{% if course.is_featured %}
<div class="courses__label courses__label_fav"></div>
{% endif %}

@ -12,17 +12,40 @@
</div>
{% endif %}
<div class="section">
<div class="section__center center course-search">
<form class="course-search__form" action="." method="get">
{% if cat %}<input type="hidden" name="cat" value="{{ cat.0|default:'' }}" />
{% else %}
<input type="hidden" name="category" value="{% if category %}{{ category.id }}{% endif %}" />
{% endif %}
<input type="hidden" name="age" value="{{ age.0|default:'' }}" />
<input name="q" class="course-search__query" type="text" value="{{ search_query|default:'' }}" />
<button class="btn course-search__search" type="submit">
<svg class="icon icon-search">
<use xlink:href="{% static 'img/sprite.svg' %}#icon-search"></use>
</svg>
</button>
</form>
{% if tags|length %}
<div class="course-search__text">
ИСКАТЬ ПО ТЕГАМ
</div>
<div class="course-search__tags">
{% for tag in tags %}
<a class="course-search__tag tag">#{{ tag.tag }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="section" style="padding-top: 5px">
<div class="section__center center">
<div class="text text_lg">
<p>Учите и развивайте креативное мышление когда и где угодно. Если вам не совсем удобно заниматься с нами в прямом эфире каждый день, как в
нашей онлайн-школе, специально для вас мы делаем отдельные уроки в записи, которые вы можете проходить,
когда вам будет удобно.</p><img class="text__curve text__curve_four" src="{% static 'img/curve-4.svg' %}" width="155"></div>
<div class="head">
<div class="head__title title title_center">Курсы</div>
<div class="head__right">
<div class="head__title title">Курсы</div>
<div class="head__right courses-filter">
<div class="head__field field">
<div class="field__wrap">
<div class="field__select select js-select{% if category %} selected{% endif %}" data-category-select>
<div class="courses-filter__select field__select select js-select{% if category %} selected{% endif %}" data-category-select>
<div class="select__head js-select-head">Категории</div>
<div class="select__drop js-select-drop">
<div class="select__option js-select-option{% if not category.0 %} active{% endif %}" data-category-option>
@ -35,7 +58,7 @@
</div>
<div class="head__field field">
<div class="field__wrap">
<div class="field__select select js-select{% if age.0 %} selected{% endif %}">
<div class="courses-filter__select field__select select js-select{% if age.0 %} selected{% endif %}">
<div class="select__head js-select-head">{% if age_name %}{{ age_name }}{% else %}Возраст{% endif %}</div>
<div class="select__drop js-select-drop">
<div class="select__option js-select-option{% if not age.0 %} active{% endif %}" data-age-option>

@ -1,4 +1,5 @@
from datetime import timedelta
from itertools import groupby
from paymentwall import Pingback
from django.contrib.auth import get_user_model
@ -17,8 +18,9 @@ from django.utils.timezone import now
from apps.content.models import Banner
from apps.payment.models import AuthorBalance, CoursePayment
from .models import Course, Like, Lesson, CourseComment, LessonComment, Category
from .models import Course, Like, Lesson, CourseComment, LessonComment, Category, CourseTags, Tag
from .filters import CourseFilter
from project.utils.db import ModelFieldsNames, format_sql, execute_sql
User = get_user_model()
@ -312,6 +314,14 @@ class CoursesView(ListView):
).prefetch_related(
'likes', 'materials', 'content',
).filter(status=Course.PUBLISHED)
q = self.request.GET.get('q')
if q:
if q.startswith('#'):
queryset = queryset.filter(tags__tag__istartswith=q[1:])
else:
queryset = queryset.filter(Q(tags__tag__icontains=q) | Q(title__icontains=q) | Q(short_description__icontains=q)
| Q(author__first_name__icontains=q) | Q(author__last_name__icontains=q)
| Q(author__email__icontains=q))
filtered = CourseFilter(self.request.GET, queryset=queryset)
return filtered.qs
@ -319,6 +329,17 @@ class CoursesView(ListView):
context = super().get_context_data()
filtered = CourseFilter(self.request.GET)
context.update(filtered.data)
sql = format_sql('''
select {ct.tag_id}
from {ct}
group by {ct.tag_id}
order by count(*) desc
limit 15''', ct=CourseTags)
tags = Tag.objects.filter(id__in=execute_sql(sql)).order_by('tag')
print('tags', tags)
context['tags'] = map(lambda i: i[0], sorted(tags, key=lambda i: len(i[1]))[:15])
print("context['tags']", context['tags'])
context['search_query'] = self.request.GET.get('q', '')
context['banners'] = Banner.get_for_page(Banner.PAGE_COURSES)
context['course_items'] = Course.shuffle(context.get('course_items'))
context['ages'] = Course.AGE_CHOICES[1:]

@ -14,14 +14,14 @@
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.6.1",
"babel-preset-es2015": "^6.24.1",
"browser-sync": "^2.10.0",
"browser-sync": "^2.26.7",
"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",
"lodash": "^4.3.0",
"node-sass": "^4.9.0",
"lodash": "^4.17.15",
"node-sass": "^4.12.0",
"require-dir": "^0.3.0",
"run-sequence": "^1.1.5",
"sass-loader": "^7.0.1",
@ -36,7 +36,7 @@
"dependencies": {
"autosize": "^4.0.2",
"autosize-input": "^1.0.2",
"axios": "^0.17.1",
"axios": "^0.19.0",
"babel-polyfill": "^6.26.0",
"baguettebox.js": "^1.10.0",
"bowser": "^2.1.2",
@ -47,7 +47,7 @@
"history": "^4.7.2",
"ilyabirman-likely": "^2.3.0",
"inputmask": "^3.3.11",
"jquery": "^3.3.1",
"jquery": "^3.4.1",
"js-cookie": "^2.2.0",
"lodash.debounce": "^4.0.8",
"modal-video": "git+https://github.com/gzbender/modal-video.git",
@ -62,6 +62,7 @@
"vue": "^2.5.13",
"vue-autosize": "^1.0.2",
"vue-awesome-swiper": "^3.1.3",
"vue-tags-component": "^1.3.0",
"vuedraggable": "^2.16.0",
"vuejs-datepicker": "^0.9.25",
"vuelidate": "^0.6.1"

@ -123,6 +123,20 @@
<input type="checkbox" class="switch__input" v-model="course.is_featured">
<span class="switch__content" name="course-is-featured">Выделить</span>
</label>
<div v-if="!live" class="info__field field">
<div class="field__label field__label_gray">Теги</div>
<div class="field__wrap">
<!--<vue-tags :active="courseTags" :all="allTags"></vue-tags>-->
<select>
<options v-for="tag in tags" @click="selectTag(tag)">{{ tag.tag }}</options>
</select>
<input type="text" v-model="tagSearchQuery" v-class="{loading: tagsLoading}" />
<div>
<a v-for="tag in course.tags" class="tag">#{{ tag.tag }}</a>
</div>
</div>
</div>
</div>
<div v-if="!live" class="info__fieldset">
<div class="info__field field">
@ -238,20 +252,21 @@
</template>
<script>
import { ROLE_ADMIN, ROLE_AUTHOR } from './consts'
import { ROLE_ADMIN } from './consts'
import LinkInput from './inputs/LinkInput'
import DatePicker from 'vuejs-datepicker'
import BlockContent from './blocks/BlockContent'
import VueRedactor from './redactor/VueRedactor';
import LilSelect from "./inputs/LilSelect";
import LessonRedactor from "./LessonRedactor";
import {api} from "../js/modules/api";
import { api } from "../js/modules/api";
import $ from 'jquery';
import {required, minValue, numeric, url } from 'vuelidate/lib/validators'
import { required, minValue, numeric, url } from 'vuelidate/lib/validators'
import slugify from 'slugify';
import Draggable from 'vuedraggable';
import {showNotification} from "../js/modules/notification";
import createHistory from "history/createBrowserHistory";
import { VueTags } from 'vue-tags-component';
import moment from 'moment'
import _ from 'lodash'
@ -262,6 +277,10 @@
props: ["authorName", "authorPicture", "accessToken", "courseId", "live", "camp"],
data() {
return {
tagsLoading: false,
tagSearchXhr: null,
tagSearchQuery: '',
courseTags: [{id: 1, name: 'tag'}, {id: 2, name: 'tag2'}],
disabledDates: {
to: new Date(new Date().setDate(new Date().getDate() - 1)),
},
@ -298,7 +317,8 @@
content: [],
gallery: {
images: [],
}
},
tags: [],
},
courseLoading: false,
courseSaving: false,
@ -453,6 +473,28 @@
}
},
methods: {
searchTags(){
this.tagsLoading = true;
this.tagSearchXhr = api.get('tags', {tag: this.tagSearchQuery}).then(response => {
thistagsLoading = false;
this.tags = response.data;
});
},
selectTag(tag){
this.course.tags.push(tag);
},
addTag(){
this.tagsLoading = true;
this.api.post('tags', {tag: this.tagSearchQuery}).then(response => {
this.tagsLoading = false;
this.tagSearchQuery = '';
this.selectTag(response.data);
});
},
removeTag(tag){
const index = this.course.tags.findIndex(i => i.id == tag.id);
this.course.tags.splice(index, 1);
},
removeCover() {
if(! this.course.coverImageId){
return;
@ -1018,12 +1060,18 @@
},
deep: true,
},
tagSearchQuery(){
if(this.tagSearchQuery){
this.searchTags();
}
}
},
components: {
LessonRedactor,
LilSelect,
BlockContent,
VueTags,
'link-input': LinkInput,
'vue-datepicker': DatePicker,
'lesson-redactor': LessonRedactor,

@ -32,6 +32,7 @@ $green: #8ECFC0
$green-light: #5BD700
$cyan: #B6DFD6
$gray: #A7A7A7
$gray-dark: #888888
$blue: #4A90E2
$viol: #B995D9
$viol2: #A186BD
@ -1110,8 +1111,8 @@ a[name]
color: $cl
align-items: center
&__counter
color: $gray-dark
margin-right: 5px
+fb
font-size: 13px
+t
font-size: 12px
@ -1151,7 +1152,12 @@ a[name]
&__info
flex: 0 0 calc(100% - 50px)
display: flex
&__name,
&__name
font-size: 13px
text-transform: capitalize
letter-spacing: 0.5px
line-height: 1.1
color: $gray-dark
&__date
+fb
font-size: 10px
@ -1711,31 +1717,31 @@ a.grey-link
.courses
&__list
display: flex
margin: 0 -10px
margin: 0 -20px
flex-wrap: wrap
+m
display: block
margin: 0
margin: 0 -10px
&__item
display: block
margin: 0 10px 60px
margin: 0 20px 75px
color: $cl
flex: 0 0 calc(33.33% - 20px)
flex: 0 0 288px
+t
margin-bottom: 50px !important
+m
margin: 0 0 30px
margin: 0 5px 30px
flex: 0 0 calc(50% - 10px)
&__preview
display: block
position: relative
margin-bottom: 15px
border-radius: 2px
border-radius: 10px
color: $cl
overflow: hidden
+t
margin-bottom: 10px
&__preview:hover &__view
opacity: .9
&:hover
box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.15)
&__label
position: absolute
top: 0
@ -1809,19 +1815,29 @@ a.grey-link
font-size: 12px
letter-spacing: 2px
color: $cl
+m
font-size: 10px
&__old-price
margin-right: -15px
&__title
display: block
margin-bottom: 10px
+fs
font-size: 18px
color: $cl
+t
line-height: 1.33
+m
font-size: 13px
&__content
color: $gray-dark
font-size: 14px
+m
font-size: 11px
&__theme
text-transform: uppercase
flex: 1
+m
font-size: 10px
&__user
margin-top: 20px
&_two &__item
@ -2131,22 +2147,21 @@ a.grey-link
&__head
position: relative
height: 36px
border-bottom: 1px solid rgba(82, 82, 82, 0.2)
border: 1px solid rgba(82, 82, 82, 0.2)
transition: border-color .2s
font-size: 18px
font-size: 16px
line-height: 36px
white-space: nowrap
text-overflow: ellipsis
color: $gray
cursor: pointer
transition: border-color .2s
padding-right: 15px
padding: 0px 18px
overflow: hidden
&:after
content: ''
position: absolute
top: 14px
right: 0
right: 18px
+arr(8,8,$cl,b)
&__drop
position: absolute
@ -2469,12 +2484,12 @@ a.grey-link
.head
display: flex
margin-bottom: 50px
margin-bottom: 30px
+t
margin-bottom: 40px
flex-wrap: wrap
&__right
flex: 0 0 220px
display: flex
+t
margin-left: auto !important
+m
@ -2484,8 +2499,7 @@ a.grey-link
&__title
position: relative
margin: 0
padding: 0 20px 0 260px
flex: 0 0 calc(100% - 220px)
flex: 1
z-index: 4
+t
max-width: 100%
@ -2499,7 +2513,7 @@ a.grey-link
transform: translateY(-50%)
z-index: -2
&__field
margin: 0
margin: 0 0 20px
&__text
padding-right: 30px
font-size: 18px
@ -4837,3 +4851,65 @@ a
padding-top: 80px
min-height: 200px
padding-bottom: 60px
.course-search
text-align: center
&__form
position: relative
&__query
width: 600px
height: 50px
border-radius: 30px
box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.1)
border: solid 1px rgba(0, 0, 0, 0.03)
background-color: #ffffff
padding: 10px 22px
font-size: 16px
+m
width: 100%
&__search
width: 58px
height: 40px
border-radius: 20px
top: 5px
position: absolute
margin-left: -65px
+m
margin-left: 0
right: 10px
& .icon
width: 17px
height: 17px
opacity: 0.5
margin-left: -4px
top: -2px
+m
margin-left: 0
&__text
font-size: 10px
color: #9b9b9b
margin: 30px 0 15px
&__tags
width: 460px
margin: 0 auto
.tag
height: 30px
border-radius: 20px
border: solid 1px #e7e7e7
padding: 5px 15px
display: inline-block
margin-right: 8px
.courses-filter
+m
display: none
&__select
width: 200px
margin-left: 20px

Loading…
Cancel
Save