LIL-559 Добавить блок комментариев в уроки онлайн-школы: comments with using Pusher

remotes/origin/hotfix/LIL-661
gzbender 8 years ago
parent 055141c9f0
commit 3f4426618f
  1. 40
      api/v1/views.py
  2. 34
      apps/course/templates/course/lesson.html
  3. 14
      project/pusher.py
  4. 7
      project/settings.py
  5. 11
      project/templates/lilcity/index.html
  6. 2
      requirements.txt
  7. 48
      web/src/components/Comment.vue
  8. 42
      web/src/components/CommentForm.vue
  9. 153
      web/src/components/Comments.vue
  10. 11
      web/src/js/modules/api.js
  11. 4
      web/webpack.config.js

@ -64,6 +64,7 @@ from apps.payment.models import (
) )
from apps.school.models import SchoolSchedule, LiveLesson from apps.school.models import SchoolSchedule, LiveLesson
from apps.user.models import AuthorRequest from apps.user.models import AuthorRequest
from project.pusher import pusher
User = get_user_model() User = get_user_model()
@ -395,15 +396,15 @@ class ConfigViewSet(generics.RetrieveUpdateAPIView):
class CommentViewSet(ExtendedModelViewSet): class CommentViewSet(ExtendedModelViewSet):
queryset = Comment.objects.filter(level=0) queryset = Comment.objects.all()
serializer_class = CommentCreateSerializer serializer_class = CommentSerializer
permission_classes = (IsAdmin,) permission_classes = (IsAuthorObjectOrAdmin,)
def get_queryset(self): def get_queryset(self):
queryset = self.queryset queryset = self.queryset
is_deactivated = self.request.query_params.get('is_deactivated', '0') is_deactivated = self.request.query_params.get('is_deactivated', '0')
if is_deactivated == '0': if is_deactivated == '0':
queryset = queryset queryset = queryset.filter(level=0)
elif is_deactivated == '1': elif is_deactivated == '1':
queryset = queryset.filter(deactivated_at__isnull=True) queryset = queryset.filter(deactivated_at__isnull=True)
elif is_deactivated == '2': elif is_deactivated == '2':
@ -412,11 +413,12 @@ class CommentViewSet(ExtendedModelViewSet):
return queryset return queryset
class ObjectCommentsViewSet(viewsets.ReadOnlyModelViewSet): class ObjectCommentsViewSet(ExtendedModelViewSet):
queryset = Comment.objects.filter(level=0)
OBJ_TYPE_COURSE = 'course' OBJ_TYPE_COURSE = 'course'
OBJ_TYPE_LESSON = 'lesson' OBJ_TYPE_LESSON = 'lesson'
permission_classes = (IsAdmin,) queryset = Comment.objects.all()
serializer_class = CommentCreateSerializer
permission_classes = (IsAuthorObjectOrAdmin,)
def get_queryset(self): def get_queryset(self):
queryset = self.queryset queryset = self.queryset
@ -424,11 +426,11 @@ class ObjectCommentsViewSet(viewsets.ReadOnlyModelViewSet):
obj_id = self.request.query_params.get('obj_id') obj_id = self.request.query_params.get('obj_id')
is_deactivated = self.request.query_params.get('is_deactivated', '0') is_deactivated = self.request.query_params.get('is_deactivated', '0')
if obj_type == self.OBJ_TYPE_COURSE: if obj_type == self.OBJ_TYPE_COURSE:
queryset = CourseComment.objects.filter(course=obj_id) queryset = CourseComment.objects.filter(course=obj_id, parent__isnull=True)
elif obj_type == self.OBJ_TYPE_LESSON: elif obj_type == self.OBJ_TYPE_LESSON:
queryset = LessonComment.objects.filter(lesson=obj_id) queryset = LessonComment.objects.filter(lesson=obj_id, parent__isnull=True)
if is_deactivated == '0': if is_deactivated == '0':
queryset = queryset queryset = queryset.filter(level=0)
elif is_deactivated == '1': elif is_deactivated == '1':
queryset = queryset.filter(deactivated_at__isnull=True) queryset = queryset.filter(deactivated_at__isnull=True)
elif is_deactivated == '2': elif is_deactivated == '2':
@ -436,6 +438,8 @@ class ObjectCommentsViewSet(viewsets.ReadOnlyModelViewSet):
return queryset return queryset
def get_serializer_class(self): def get_serializer_class(self):
if self.request.method == 'POST':
return CommentCreateSerializer
obj_type = self.request.query_params.get('obj_type') obj_type = self.request.query_params.get('obj_type')
if obj_type == self.OBJ_TYPE_COURSE: if obj_type == self.OBJ_TYPE_COURSE:
serializer_class = CourseCommentSerializer serializer_class = CourseCommentSerializer
@ -443,6 +447,22 @@ class ObjectCommentsViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = LessonCommentSerializer serializer_class = LessonCommentSerializer
return serializer_class return serializer_class
def perform_create(self, serializer):
obj_type = self.request.data.get('obj_type')
obj_id = self.request.data.get('obj_id')
serializer.save()
pusher().trigger(f'comments_{obj_type}_{obj_id}', 'add', serializer.data)
def perform_destroy(self, instance):
obj_type = None
if isinstance(instance, LessonComment):
obj_type = self.OBJ_TYPE_LESSON
elif isinstance(instance, CourseComment):
obj_type = self.OBJ_TYPE_COURSE
serializer = self.get_serializer(instance)
pusher().trigger(f'comments_{obj_type}_{obj_id}', 'delete', serializer.data)
instance.delete()
class AuthorRequestViewSet(ExtendedModelViewSet): class AuthorRequestViewSet(ExtendedModelViewSet):
queryset = AuthorRequest.objects.all() queryset = AuthorRequest.objects.all()

@ -96,7 +96,7 @@
<div class="section__center center center_sm"> <div class="section__center center center_sm">
<div class="title">Задавайте вопросы:</div> <div class="title">Задавайте вопросы:</div>
<div class="questions" id="comments_block"> <div class="questions" id="comments_block">
<comments obj-type="lesson" obj-id="{{ lesson.id }}" <comments obj-type="lesson" obj-id="{{ lesson.id }}" :is-chat="true"
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
user-id="{{ request.user.id }}" user-photo="{{ request.user.photo.url }}" user-id="{{ request.user.id }}" user-photo="{{ request.user.photo.url }}"
{% endif %} {% endif %}
@ -105,38 +105,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="section section_gray">
<div class="section__center center center_sm">
<div class="title">Задавайте вопросы:</div>
<div class="questions">
{% if request.user.is_authenticated %}
<form class="questions__form" method="post" action="{% url 'lessoncomment' lesson_id=lesson.id %}">
<div class="questions__ava ava">
<img
class="ava__pic"
{% if request.user.photo %}
src="{{ request.user.photo.url }}"
{% else %}
src="{% static 'img/user_default.jpg' %}"
{% endif %}
>
</div>
<div class="questions__wrap">
<div class="questions__field">
<textarea class="questions__textarea" placeholder="Спросите автора курса интересующие вас вопросы"></textarea>
</div>
<button class="questions__btn btn btn_light">ОТПРАВИТЬ</button>
</div>
</form>
{% else %}
<div>Только зарегистрированные пользователи могут оставлять комментарии.</div>
{% endif %}
<div class="questions__list">
{% include "templates/blocks/comments.html" with object=lesson %}
</div>
</div>
</div>
</div>
<div class="section"> <div class="section">
<div class="section__center center center_sm"> <div class="section__center center center_sm">
{% include 'templates/blocks/share.html' %} {% include 'templates/blocks/share.html' %}

@ -0,0 +1,14 @@
from django.conf import settings
from pusher import Pusher
def pusher():
try:
pusher_cluster = settings.PUSHER_CLUSTER
except AttributeError:
pusher_cluster = 'mt1'
return Pusher(app_id=settings.PUSHER_APP_ID,
key=settings.PUSHER_KEY,
secret=settings.PUSHER_SECRET,
cluster=pusher_cluster)

@ -194,6 +194,13 @@ TWILIO_FROM_PHONE = os.getenv('TWILIO_FROM_PHONE', '+37128914409')
ACTIVE_LINK_STRICT = True ACTIVE_LINK_STRICT = True
# PUSHER settings
PUSHER_APP_ID = u""
PUSHER_KEY = u""
PUSHER_SECRET = u""
PUSHER_CLUSTER = u""
# DRF settings # DRF settings
REST_FRAMEWORK = { REST_FRAMEWORK = {

@ -38,6 +38,7 @@
{% endcompress %} {% endcompress %}
<link rel="shortcut icon" type="image/png" href="{% static 'img/favicon.png' %}"/> <link rel="shortcut icon" type="image/png" href="{% static 'img/favicon.png' %}"/>
<script src="https://js.pusher.com/4.1/pusher.min.js"></script>
<script> <script>
var viewportmeta = document.querySelector('meta[name="viewport"]'); var viewportmeta = document.querySelector('meta[name="viewport"]');
if (viewportmeta) { if (viewportmeta) {
@ -130,6 +131,16 @@
{% include "templates/blocks/popup_course_lock.html" %} {% include "templates/blocks/popup_course_lock.html" %}
{% include "templates/blocks/popup_subscribe.html" %} {% include "templates/blocks/popup_subscribe.html" %}
</div> </div>
<script>
window.VUE_STORE = {
accessToken: '{{ request.user.auth_token }}',
defaultUserPhoto: "{% static 'img/user_default.jpg' %}",
user: {
id: '{{ request.user.id }}',
photo: '{{ request.user.photo.url }}',
}
};
</script>
<script type="text/javascript" src={% static "app.js" %}></script> <script type="text/javascript" src={% static "app.js" %}></script>
<script> <script>
var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }}); var schoolDiscount = parseFloat({{ config.SERVICE_DISCOUNT }});

@ -28,4 +28,6 @@ git+https://github.com/ivlevdenis/python-instagram.git
django-user-agents==0.3.2 django-user-agents==0.3.2
user-agents==1.1.0 user-agents==1.1.0
ua-parser==0.8.0 ua-parser==0.8.0
pusher==2.0.1

@ -1,14 +1,15 @@
<template> <template>
<div v-if="! comment.deactivated_at"> <div>
<div v-if="! comment.deactivated_at" :class="{'questions_heart': isHeart}">
<a class="questions__anchor" :id="'question__' + comment.id"></a> <a class="questions__anchor" :id="'question__' + comment.id"></a>
<div :id="'question__replyto__' + comment.id" :class="{'questions__item_reply': comment.is_child_comment}" class="questions__item"> <div :id="'question__replyto__' + comment.id" :class="{'questions__item_reply': comment.parent && ! controller.isChat}" class="questions__item">
<div v-if="comment.author.photo" class="questions__ava ava"> <div v-if="comment.author.photo" class="questions__ava ava">
<img class="ava__pic" :src="comment.author.photo"> <img class="ava__pic" :src="comment.author.photo">
</div> </div>
<div v-if="! comment.author.photo" class="questions__ava ava"> <div v-if="! comment.author.photo" class="questions__ava ava">
<img class="ava__pic" :src="defaultAuthorPicture"> <img class="ava__pic" :src="store.defaultUserPhoto">
</div> </div>
<div class="questions__wrap"> <div class="questions__wrap">
@ -19,26 +20,53 @@
</div> </div>
<div class="questions__content">{{ comment.content }}</div> <div class="questions__content">{{ comment.content }}</div>
</div> </div>
<div class="questions__foot"> <div class="questions__foot" v-if="! controller.isChat">
<button @click="reply" v-if="userId" class="questions__action question__reply-button">ОТВЕТИТЬ</button> <button @click="controller.reply(comment)" v-if="store.user.id" class="questions__action question__reply-button">ОТВЕТИТЬ</button>
<button @click="controller.remove(comment)" v-if="store.user.id == comment.author.id" class="questions__action question__reply-button">УДАЛИТЬ</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<comment-form v-if="store.user.id && !controller.$data.isChat && controller.$data.replyTo && controller.$data.replyTo.id == comment.id"
:controller="controller"></comment-form>
<ul v-if="comment.children" v-for="(node, index) in comment.children" :key="index">
<li>
<comment v-if="! node.deactivated_at" :controller="controller" :comment="node"></comment>
</li>
</ul>
</div>
</template> </template>
<script> <script>
import CommentForm from './CommentForm';
export default { export default {
name: 'comment', name: 'comment',
props: ['comment', 'userId', 'defaultAuthorPicture'], props: ['controller', 'comment',],
methods: { data() {
reply() { return {
this.$emit('reply', this.comment); store: window.VUE_STORE,
} }
}, },
computed: {
isHeart(){
return this.comment.content === '❤';
},
},
mounted(){ mounted(){
console.log(this.defaultAuthorPicture); this.controller.flatComments[this.comment.id] = this.comment;
}, },
components: {
CommentForm
}
} }
</script> </script>
<style>
.questions_heart .questions__content {
font-size: 24px;
color: #d40700;
}
</style>

@ -0,0 +1,42 @@
<template>
<div class="questions__form" :class="{'questions__item_reply': controller.$data.replyTo}">
<div class="questions__ava ava">
<img class="ava__pic" :src="store.user.photo || store.defaultUserPhoto">
</div>
<div class="questions__wrap">
<div class="questions__field">
<textarea v-model="content" class="questions__textarea"
:placeholder="controller.$data.replyTo ? 'Ответьте на комментарий' : 'Задайте автору курса интересующие вас вопросы'"></textarea>
</div>
<div style="text-align: center;">
<button class="questions__btn btn btn_light" style="display: inline; margin: 0 20px;" @click="add">ОТПРАВИТЬ</button>
<button v-show="controller.$data.replyTo" class="questions__btn btn btn_light" style="display: inline; margin: 0 20px;"
@click="controller.cancelReply">ОТМЕНИТЬ</button>
<button v-if="controller.isChat" class="questions__btn btn btn_light" style="display: inline; margin: 0 20px; color: #d40700;"
@click="controller.addHeart">&#10084;</button>
</div>
</div>
</div>
</template>
<script>
import {api} from "../js/modules/api";
export default {
name: 'comment-form',
props: ['controller',],
data() {
return {
content: '',
store: window.VUE_STORE,
}
},
methods: {
add() {
this.controller.add(this.content);
this.content = '';
},
}
}
</script>

@ -1,97 +1,142 @@
<template> <template>
<div> <div :class="{'questions_chat': isChat}">
<ul v-for="node in nodes"> <comment-form v-if="store.user.id && ! replyTo" :controller="controller"></comment-form>
<div class="questions-block" style="">
<ul v-for="(node, index) in nodes" :key="index">
<li> <li>
<comment v-on:reply="onReply" v-if="! node.deactivated_at" :comment="node" :user-id="userId" :default-author-picture="defaultAuthorPicture"></comment> <comment v-if="! node.deactivated_at" :comment="node" :controller="controller" v-on:remove="remove"></comment>
<comments v-if="! node.is_leaf_node && node.children" :comments="node.children" default-author-picture="defaultAuthorPicture"></comments>
</li> </li>
</ul> </ul>
<form v-if="userId" class="questions__form" method="post" >
<input type="hidden" name="reply_id">
<div class="questions__ava ava">
<img class="ava__pic" :src="userPhoto || defaultAuthorPicture">
</div> </div>
<div class="questions__wrap">
<div v-show="replyTo" class="questions__reply-info" style="display: block;">В ответ на
<a :href="replyAnchor" class="questions__reply-anchor">этот комментарий</a>.
<a href="#" @click="cancelReply" class="questions__reply-cancel grey-link">Отменить</a>
</div>
<div class="questions__field">
<textarea v-model="content" class="questions__textarea" placeholder="Задайте автору курса интересующие вас вопросы"></textarea>
</div>
<button class="questions__btn btn btn_light" @click="post">ОТПРАВИТЬ</button>
</div>
</form>
</div> </div>
</template> </template>
<script type="text/javascript"> <script type="text/javascript">
import Comment from './comment'; import Comment from './Comment';
import CommentForm from './CommentForm';
import {api} from "../js/modules/api"; import {api} from "../js/modules/api";
export default { export default {
name: 'comments', name: 'comments',
props: ['objType', 'objId', 'comments', 'userId', 'userPhoto', 'accessToken', 'defaultAuthorPicture'], props: ['objType', 'objId', 'isChat'],
data() { data() {
return { return {
loading: false, loading: false,
replyTo: null, replyTo: null,
content: '',
nodes: [], nodes: [],
} controller: this,
}, store: window.VUE_STORE,
computed: { flatComments: {},
replyAnchor(){
return this.replyTo ? `#question__${this.replyTo.id}` : '';
} }
}, },
methods: { methods: {
clear() { reply(comment) {
this.content = '', this.replyTo = comment;
this.replyTo = null
}, },
cancelReply(){ cancelReply(){
this.replyTo = null; this.replyTo = null;
}, },
post() { addHeart(){
this.add('❤');
},
add(content){
let vm = this;
this.loading = true; this.loading = true;
let request = api.addComment(this.objId, this.objType, { let request = api.addObjComment(this.objId, this.objType, {
content: this.content, content: content,
author: this.userId, author: this.store.user.id,
parent: this.replyTo && this.replyTo.id, parent: this.replyTo && this.replyTo.id,
}, this.accessToken); }, this.store.accessToken);
request.then((response) => { request.then((response) => {
this.loading = false; vm.loading = false;
if(this.replyTo){ vm.onAdd(response.data);
this.replyTo.children.push(response.data); if(vm.replyTo){
} vm.cancelReply();
else {
this.nodes.push(response.data);
} }
this.clear();
}).catch(() => { }).catch(() => {
this.loading = false; vm.loading = false;
}); });
}, },
onReply(replyTo) { remove(comment){
this.replyTo = replyTo; if(! confirm('Удалить комментарий?')){
return;
}
let vm = this;
this.loading = true;
let request = api.removeComment(comment.id, this.store.accessToken);
request.then((response) => {
vm.loading = false;
vm.onRemove(comment);
});
}, },
onAdd(comment){
if(this.flatComments[comment.id]){
return;
}
if(comment.parent){
this.flatComments[comment.parent].children.unshift(comment);
}
else{
this.nodes.unshift(comment);
}
this.flatComments[comment.id] = comment;
}, },
mounted() { onRemove(comment){
if(this.comments && this.comments.length){ let comments = [];
this.nodes = this.comments; if(comment.parent){
comments = this.flatComments[comment.parent].children;
}
else{
comments = this.nodes;
}
let index = comments.indexOf(comment);
if(index === -1){
return;
} }
else if(this.objType && this.objId){ comments.splice(index, 1);
let request = api.getObjComments(this.objId, this.objType, this.accessToken); delete this.flatComments[comment.id];
},
connectToPusher(){
let vm = this;
// Enable pusher logging - don't include this in production
Pusher.logToConsole = true;
let pusher = new Pusher('d9bfd3aaf6ed22bdcc0d', {
cluster: 'eu',
encrypted: true
});
let channel = pusher.subscribe('comments_' + this.objType + '_' + this.objId);
channel.bind('add', this.onAdd);
channel.bind('delete', this.onRemove);
}
},
mounted() {
let vm = this;
this.loading = true;
let request = api.getObjComments(this.objId, this.objType, this.store.accessToken);
request request
.then((response) => { .then((response) => {
this.nodes = response.data.results; vm.loading = false;
vm.nodes = response.data.results;
vm.connectToPusher();
})
.catch(() => {
vm.loading = false;
}); });
}
console.log('nodes', this.nodes);
}, },
components: { components: {
Comment Comment,
CommentForm
} }
} }
</script> </script>
<style>
.questions_chat .questions-block {
background: white;
border: 1px solid #ccc;
padding: 10px;
border-radius: 5px;
}
</style>

@ -513,11 +513,18 @@ export const api = {
} }
}); });
}, },
addComment: (objId, objType, commentJson, accessToken) => { addObjComment: (objId, objType, commentJson, accessToken) => {
let data = commentJson; let data = commentJson;
data.obj_id = objId; data.obj_id = objId;
data.obj_type = objType; data.obj_type = objType;
return api.post('/api/v1/comments/', data, { return api.post('/api/v1/obj-comments/', data, {
headers: {
'Authorization': `Token ${accessToken}`,
}
});
},
removeComment: (commentId, accessToken) => {
return api.delete(`/api/v1/comments/${commentId}/`, {
headers: { headers: {
'Authorization': `Token ${accessToken}`, 'Authorization': `Token ${accessToken}`,
} }

@ -9,7 +9,6 @@ module.exports = {
entry: { entry: {
app: "./src/js/app.js", app: "./src/js/app.js",
courseRedactor: "./src/js/course-redactor.js", courseRedactor: "./src/js/course-redactor.js",
comments_vue: "./src/js/modules/comments_vue.js",
mixpanel: "./src/js/third_party/mixpanel-2-latest.js", mixpanel: "./src/js/third_party/mixpanel-2-latest.js",
sprite: glob('./src/icons/*.svg'), sprite: glob('./src/icons/*.svg'),
images: glob('./src/img/*'), images: glob('./src/img/*'),
@ -118,7 +117,8 @@ module.exports = {
watch: NODE_ENV === 'development', watch: NODE_ENV === 'development',
watchOptions: { watchOptions: {
poll: true, ignored: "/node_modules/",
poll: 2000,
}, },
devtool: NODE_ENV === 'development' ? 'source-map' : false devtool: NODE_ENV === 'development' ? 'source-map' : false

Loading…
Cancel
Save