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

remotes/origin/features/course-search-16-08-19
gzbender 7 years ago
parent f10cd4e6b5
commit 94417ea88c
  1. 9
      api/v1/serializers/course.py
  2. 29
      api/v1/serializers/mixins.py
  3. 5
      api/v1/views.py
  4. 6
      apps/course/migrations/0050_auto_20190818_1043.py
  5. 4
      apps/course/models.py
  6. 3
      apps/course/templates/course/courses.html
  7. 10
      apps/course/views.py
  8. 64
      web/src/components/CourseRedactor.vue
  9. 19
      web/src/js/modules/api.js
  10. 15
      web/src/js/modules/courses.js
  11. 30
      web/src/sass/_common.sass

@ -13,7 +13,7 @@ from apps.course.models import (
from .content import (
ImageObjectSerializer, ContentSerializer, ContentCreateSerializer,
GallerySerializer, )
from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin
from .mixins import DispatchContentMixin, DispatchGalleryMixin, DispatchMaterialMixin, DispatchTagsMixin
from .user import UserSerializer
User = get_user_model()
@ -22,7 +22,7 @@ User = get_user_model()
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ('tag',)
fields = ('__all__')
class MaterialCreateSerializer(serializers.ModelSerializer):
@ -109,6 +109,7 @@ class CourseBulkChangeCategorySerializer(serializers.Serializer):
class CourseCreateSerializer(DispatchContentMixin,
DispatchGalleryMixin,
DispatchMaterialMixin,
DispatchTagsMixin,
serializers.ModelSerializer
):
title = serializers.CharField(allow_blank=True)
@ -169,12 +170,14 @@ class CourseCreateSerializer(DispatchContentMixin,
materials = validated_data.pop('materials', [])
gallery = validated_data.pop('gallery', {})
author = validated_data.get('author', None)
tags = validated_data.pop('tags', [])
if not author:
validated_data['author'] = self.context['request'].user
course = super().create(validated_data)
self.dispatch_content(course, content)
self.dispatch_materials(course, materials)
self.dispatch_gallery(course, gallery)
self.dispatch_tags(course, tags)
return course
def update(self, instance, validated_data):
@ -182,12 +185,14 @@ class CourseCreateSerializer(DispatchContentMixin,
materials = validated_data.pop('materials', [])
gallery = validated_data.pop('gallery', {})
author = validated_data.get('author', None)
tags = validated_data.pop('tags', [])
if not instance.author or author and instance.author != author:
validated_data['author'] = self.context['request'].user
course = super().update(instance, validated_data)
self.dispatch_materials(course, materials)
self.dispatch_content(course, content)
self.dispatch_gallery(course, gallery)
self.dispatch_tags(course, tags)
return course
def to_representation(self, instance):

@ -1,4 +1,4 @@
from apps.course.models import Category, Course, Material, Lesson, Like
from apps.course.models import Category, Course, Material, Lesson, Like, Tag, CourseTags
from apps.school.models import LiveLesson
from apps.content.models import (
@ -163,3 +163,30 @@ class DispatchGalleryMixin(object):
)
obj.gallery = g
obj.save()
class DispatchTagsMixin(object):
def dispatch_tags(self, obj, tags):
current_tags = list(obj.tags.all())
new_tags = []
if tags:
for tag in tags:
id = tag.get('id')
tag_text = tag.get('tag')
if not id and tag_text:
t, created = Tag.objects.get_or_create(tag=tag_text)
id = t.id
new_tags.append(id)
if isinstance(obj, Course):
CourseTags.objects.filter(course=obj, tag__in=current_tags).delete()
for tag in Tag.objects.filter(id__in=new_tags):
CourseTags.objects.get_or_create(course=obj, tag=tag)
else:
obj.tags.clear(current_tags)
obj.tags.add(Tag.objects.filter(id__in=set(new_tags)))
for tag in current_tags:
if not tag.course_set.all().count():
tag.delete()

@ -779,3 +779,8 @@ class TagViewSet(ExtendedModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
search_fields = ('tag',)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

@ -1,4 +1,4 @@
# Generated by Django 2.0.7 on 2019-08-15 15:37
# Generated by Django 2.0.7 on 2019-08-18 10:43
from django.db import migrations, models
import django.db.models.deletion
@ -15,7 +15,7 @@ class Migration(migrations.Migration):
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')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Course')),
],
),
migrations.CreateModel(
@ -27,7 +27,7 @@ class Migration(migrations.Migration):
),
migrations.AddField(
model_name='coursetags',
name='tag_id',
name='tag',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.Tag'),
),
migrations.AddField(

@ -45,8 +45,8 @@ class Tag(models.Model):
class CourseTags(models.Model):
tag_id = models.ForeignKey(Tag, on_delete=models.CASCADE)
course_id = models.ForeignKey('Course', on_delete=models.CASCADE)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
course = models.ForeignKey('Course', on_delete=models.CASCADE)
class Course(BaseModel, DeactivatedMixin):

@ -32,7 +32,8 @@
</div>
<div class="course-search__tags">
{% for tag in tags %}
<a class="course-search__tag tag">#{{ tag.tag }}</a>
<a href="{% url 'courses' %}?cat={{ cat.0|default:'' }}&age={{ age.0|default:'' }}&q=%23{{ tag.tag }}"
class="course-search__tag tag">#{{ tag.tag }}</a>
{% endfor %}
</div>
{% endif %}

@ -317,11 +317,11 @@ class CoursesView(ListView):
q = self.request.GET.get('q')
if q:
if q.startswith('#'):
queryset = queryset.filter(tags__tag__istartswith=q[1:])
queryset = queryset.filter(tags__tag__istartswith=q[1:]).distinct()
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))
| Q(author__email__icontains=q)).distinct()
filtered = CourseFilter(self.request.GET, queryset=queryset)
return filtered.qs
@ -335,10 +335,8 @@ class CoursesView(ListView):
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'])
tags = [t[0] for t in execute_sql(sql)]
context['tags'] = Tag.objects.filter(id__in=tags).order_by('tag')
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'))

@ -119,7 +119,8 @@
placeholder="Выберите возраст"/>
</div>
</div>
<label v-if="me && !live && me.role === ROLE_ADMIN" class="info__switch switch switch_lg">
<label v-if="me && !live && me.role === ROLE_ADMIN" class="info__switch switch switch_lg"
style="margin: 25px 0">
<input type="checkbox" class="switch__input" v-model="course.is_featured">
<span class="switch__content" name="course-is-featured">Выделить</span>
</label>
@ -127,13 +128,24 @@
<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 class="autocomplete">
<input class="autocomplete__query" type="text" v-model="tagSearchQuery" placeholder="добавить тег"
:class="{loading: tagsLoading}" @keyup.enter="addTag" @keyup.down="selectFirstTag" />
<select ref="tags_options" class="autocomplete__options" multiple="multiple"
v-show="tags.length && tagSearchQuery" style="display: none;">
<option v-for="tag in tags" :key="tag.id" @click="selectTag(tag)"
@keyup.enter.stop.prevent="selectTag(tag)">{{ tag.tag }}</option>
</select>
</div>
<div style="margin: 0 -5px">
<a href="#" v-for="tag in course.tags" :key="tag.id" class="tag">
#{{ tag.tag }}
<span class="tag__remove" @click.prevent="removeTag(tag)">
<svg class="icon icon-close">
<use xlink:href="/static/img/sprite.svg#icon-close"></use>
</svg>
</span>
</a>
</div>
</div>
</div>
@ -280,7 +292,7 @@
tagsLoading: false,
tagSearchXhr: null,
tagSearchQuery: '',
courseTags: [{id: 1, name: 'tag'}, {id: 2, name: 'tag2'}],
tags: [],
disabledDates: {
to: new Date(new Date().setDate(new Date().getDate() - 1)),
},
@ -475,17 +487,34 @@
methods: {
searchTags(){
this.tagsLoading = true;
this.tagSearchXhr = api.get('tags', {tag: this.tagSearchQuery}).then(response => {
thistagsLoading = false;
this.tags = response.data;
});
if(this.tagSearchTimeout){
clearTimeout(this.tagSearchTimeout);
}
this.tagSearchTimeout = setTimeout(() => {
api.getTags({search: this.tagSearchQuery}).then(response => {
this.tagsLoading = false;
const tags = this.course.tags.map(t => t.id);
this.tags = response.data.filter(t => tags.indexOf(t.id) == -1);
});
}, 500);
},
selectFirstTag(){
const $opts = $(this.$refs.tags_options);
$opts.find("option:selected").prop("selected", false);
$opts.find("option:first").prop("selected", "selected");
$opts.focus();
},
selectTag(tag){
if(this.course.tags.findIndex(t => t.id == tag.id) > -1){
return;
}
this.course.tags.push(tag);
this.tags = [];
this.tagSearchQuery = '';
},
addTag(){
this.tagsLoading = true;
this.api.post('tags', {tag: this.tagSearchQuery}).then(response => {
api.addTag({tag: this.tagSearchQuery}).then(response => {
this.tagsLoading = false;
this.tagSearchQuery = '';
this.selectTag(response.data);
@ -1019,6 +1048,13 @@
this.updateViewSection(window.location, 'load err '+this.courseId)
})
});
$(window).click(e => {
const cssClass = $(e.target).attr('class');
if(!cssClass || cssClass.indexOf('autocomplete') == -1){
this.tags = [];
}
});
},
computed: {
displayPrice: {

@ -129,6 +129,7 @@ export const api = {
},
content: api.convertContentJson(courseObject.content, true),
materials: courseObject.materials,
tags: courseObject.tags,
};
if(courseObject.live) {
@ -209,6 +210,7 @@ export const api = {
content: api.convertContentJson(courseJSON.content),
gallery: {images: (courseJSON.gallery) ? courseJSON.gallery.gallery_images:[]},
materials: courseJSON.materials,
tags: courseJSON.tags,
}
},
convertGalleryImagesJson: (images) => {
@ -513,4 +515,21 @@ export const api = {
}
});
},
getTags: params => {
return api.get('/api/v1/tags/', {params});
},
addTag: data => {
return api.post('/api/v1/tags/', data, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
});
},
removeTag: tagId => {
return api.delete(`/api/v1/tags/${tagId}/`, {
headers: {
'Authorization': `Token ${window.LIL_STORE.accessToken}`,
}
});
}
};

@ -36,6 +36,13 @@ $(document).ready(function () {
loadCourses();
});
// Обработчик кнопки поиска
$('.course-search__search').click(function(e){
e.preventDefault();
page = 1;
loadCourses();
})
// Обработчик выбора категории
$('div.js-select-option[data-category-option]').on('click', function (e) {
e.preventDefault();
@ -58,6 +65,13 @@ $(document).ready(function () {
loadCourses(true);
});
// Обработчик тегов
$('.course-search__tag').on('click', function(e){
e.preventDefault();
$('.course-search__query').val($(this).text());
loadCourses(true);
});
// Обработчик лайков
$('.container').on('click', 'a[data-course-likes]', function (e) {
e.preventDefault();
@ -119,6 +133,7 @@ $(document).ready(function () {
const buttonElement = $('.courses').find('button.load__btn');
let coursesUrl = window.LIL_STORE.urls.courses + '?' + $.param({
cat: categoryName,
q: $('.course-search__query').val(),
age,
});
if (page > 1) {

@ -4897,15 +4897,23 @@ a
&__tags
width: 460px
max-width: 100%
margin: 0 auto
.tag
color: black
height: 30px
border-radius: 20px
border: solid 1px #e7e7e7
padding: 5px 15px
display: inline-block
margin-right: 8px
margin: 0 3px 8px
&__remove .icon
fill: #888
width: 9px
height: 9px
margin-left: 7px
.courses-filter
+m
@ -4913,3 +4921,23 @@ a
&__select
width: 200px
margin-left: 20px
.autocomplete
position: relative
font-size: 15px
margin: 8px 0 22px
&__query
width: 100%
background: none
padding: 5px 0
border-bottom: 1px solid #e6e6e6
&__options
position: absolute
width: 100%
overflow: auto
top: 22px
height: auto
max-height: 70px
z-index: 1

Loading…
Cancel
Save