Merge branch 'feature/testing_courses_30-01-19' into 'master'

Feature/testing courses 30 01 19

See merge request lilschool/site!277
remotes/origin/hotfix/redis_version
Danil 7 years ago
commit 4c098eceb7
  1. 1
      apps/auth/__init__.py
  2. 25
      apps/auth/migrations/0001_initial.py
  3. 27
      apps/auth/models.py
  4. 3
      apps/content/tests.py
  5. 0
      apps/content/tests/__init__.py
  6. 144
      apps/content/tests/mixins.py
  7. 1
      apps/course/migrations/0022_auto_20180208_0647.py
  8. 3
      apps/course/templates/course/course_edit.html
  9. 3
      apps/course/tests.py
  10. 196
      apps/course/tests/test_views.py
  11. 2
      project/settings.py
  12. 65
      project/tests/__init__.py
  13. 96
      project/tests/factories.py
  14. 93
      project/utils/selenium_utils.py
  15. 14
      requirements.txt
  16. 77
      web/src/components/CourseRedactor.vue
  17. 8
      web/src/components/LessonRedactor.vue
  18. 16
      web/src/components/blocks/BlockAdd.vue
  19. 9
      web/src/components/blocks/BlockContent.vue
  20. 6
      web/src/components/blocks/BlockImage.vue
  21. 8
      web/src/components/blocks/BlockImageText.vue
  22. 13
      web/src/components/blocks/BlockImages.vue
  23. 6
      web/src/components/blocks/BlockText.vue
  24. 6
      web/src/components/blocks/BlockVideo.vue

@ -0,0 +1 @@
default_app_config = 'apps.auth.apps.AuthConfig'

@ -0,0 +1,25 @@
# Generated by Django 2.0.7 on 2019-02-19 18:31
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TempToken',
fields=[
('key', models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name='Key')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='auth_temp_token', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
),
]

@ -1,8 +1,29 @@
import binascii
import os
from django.db import models
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from rest_framework.authtoken.models import Token
class TempToken(models.Model):
key = models.CharField(_("Key"), max_length=40, primary_key=True)
user = models.OneToOneField(
settings.AUTH_USER_MODEL, related_name='auth_temp_token',
on_delete=models.CASCADE, verbose_name=_("User")
)
created = models.DateTimeField(_("Created"), auto_now_add=True)
class TempToken(Token):
class Meta:
app_label = 'auth'
app_label = 'lilcity_auth'
def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
return super().save(*args, **kwargs)
def generate_key(self):
return binascii.hexlify(os.urandom(20)).decode()
def __str__(self):
return self.key

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,144 @@
import time
from factory.faker import Faker
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from django.utils.html import strip_tags
class TestContentMixin:
content_data = []
def check_saving(self):
if not self.autosave:
return
raise NotImplementedError()
def check_content_saving(self, obj):
print('Check content saving',)
self.assertEqual(obj.content.all().count(), len(self.content_data))
for i, item in enumerate(obj.content.all().order_by('position', '-created_at',)):
item_data = self.content_data[i].get('data')
for key, value in item_data.items():
if key == 'txt':
self.assertEqual(strip_tags(getattr(item, key)), value)
else:
self.assertEqual(getattr(item, key), value)
print('OK')
def check_content(self, inside_el=None):
print('Check content block')
self.object_data['content'] = self.content_data
block_add_el = self.wait_elem_name('block-add', inside_el)
open_el = self.wait_elem_name('block-add-open', block_add_el)
self.assertRaises(TimeoutException, lambda: self.wait_elem_name('block-add-close', block_add_el))
open_el.click()
time.sleep(0.5)
close_el = self.wait_elem_name('block-add-close', block_add_el)
block_text_el = self.wait_elem_name('block-add-block-text', block_add_el)
block_text_el.click()
time.sleep(0.5)
self.check_block_text(inside_el)
open_el.click()
time.sleep(0.5)
block_image_el = self.wait_elem_name('block-add-block-image', block_add_el)
block_image_el.click()
time.sleep(0.5)
self.check_block_image(inside_el)
open_el.click()
time.sleep(0.5)
block_image_text_el = self.wait_elem_name('block-add-block-image-text', block_add_el)
block_image_text_el.click()
time.sleep(0.5)
self.check_block_image_text(inside_el)
open_el.click()
time.sleep(0.5)
block_images_el = self.wait_elem_name('block-add-block-images', block_add_el)
block_images_el.click()
time.sleep(0.5)
self.check_block_images(inside_el)
open_el.click()
time.sleep(0.5)
block_video_el = self.wait_elem_name('block-add-block-video', block_add_el)
block_video_el.click()
time.sleep(0.5)
self.check_block_video(inside_el)
def check_block_text(self, inside_el=None):
print('Check block text')
time.sleep(1)
block_obj = {'type': 'text', 'data': {}}
block_el = self.wait_elem_name('block-text', inside_el)
title_el = self.wait_elem_name('block-text-title', block_el)
text_el = self.wait_elem_name('block-text-text-wrap', block_el).find_element(
By.XPATH, './/div[contains(@class, "redactor-layer")][@contenteditable]')
self.content_data.append(block_obj)
title = Faker('sentence', nb_words=6).generate({})
title_el.send_keys(title)
block_obj['data']['title'] = title
text = Faker('sentence', nb_words=50).generate({})
text_el.click()
text_el.send_keys(text)
block_obj['data']['txt'] = text
self.check_saving()
def check_block_image(self, inside_el=None):
return
print('Check block image')
time.sleep(1)
block_obj = {'type': 'image', 'data': {}}
block_el = self.wait_elem_name('block-image', inside_el)
title_el = self.wait_elem_name('block-image-title', block_el)
image_el = self.wait_elem_name('block-image-image', block_el)
self.content_data.append(block_obj)
title = Faker('sentence', nb_words=6).generate({})
title_el.send_keys(title)
block_obj['data']['title'] = title
# TODO: check image upload
self.check_saving()
def check_block_image_text(self, inside_el=None):
return
print('Check block image-text')
time.sleep(1)
block_obj = {'type': 'image-text', 'data': {}}
block_el = self.wait_elem_name('block-image-text', inside_el)
title_el = self.wait_elem_name('block-image-text-title', block_el)
text_el = self.wait_elem_name('block-image-text-text-wrap', block_el).find_element(
By.XPATH, './/div[contains(@class, "redactor-layer")][@contenteditable]')
image_el = self.wait_elem_name('block-image-text-image', block_el)
self.content_data.append(block_obj)
title = Faker('sentence', nb_words=6).generate({})
title_el.send_keys(title)
block_obj['data']['title'] = title
text = Faker('sentence', nb_words=50).generate({})
text_el.click()
text_el.send_keys(text)
block_obj['data']['txt'] = text
# TODO: check image upload
self.check_saving()
def check_block_images(self, inside_el=None):
print('Check block images')
time.sleep(1)
block_obj = {'type': 'images', 'data': {}}
self.content_data.append(block_obj)
# block_el = self.wait_elem_name('block-images', inside_el)
# title_el = self.wait_elem_name('block-images-title', block_el)
# title = Faker('sentence', nb_words=6).generate({})
# title_el.send_keys(title)
# block_obj['data']['title'] = title
self.check_saving()
def check_block_video(self, inside_el=None):
return
print('Check block images')

@ -8,6 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
('course', '0021_auto_20180206_0632'),
('content', '0005_auto_20180208_0520'),
]
operations = [

@ -17,7 +17,8 @@
{% endblock header_buttons %}
{% block content %}
<course-redactor :live="{{ live }}" author-picture="{% if request.user.photo %}{{ request.user.photo.url }}{% else %}{% static 'img/user_default.jpg' %}{% endif %}"
<course-redactor name="course-redactor" :live="{{ live }}"
author-picture="{% if request.user.photo %}{{ request.user.photo.url }}{% else %}{% static 'img/user_default.jpg' %}{% endif %}"
author-name="{{ request.user.first_name }} {{ request.user.last_name }}"
access-token="{{ request.user.auth_token }}"
{% if object and object.id %}:course-id="{{ object.id }}"{% endif %}></course-redactor>

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,196 @@
from datetime import timedelta
from random import randint
import time
import re
from selenium.common.exceptions import TimeoutException
from factory.faker import Faker
from django.utils import timezone
from django.test import TestCase
from django.shortcuts import reverse
from django.utils.text import slugify
from unidecode import unidecode
from selenium.webdriver.common.by import By
from django.utils.html import strip_tags
from project.tests.factories import create_admin, create_batch_unique, User, UserFactory, Course, CourseFactory, login_admin
from project.tests import SeleniumTestCase
from apps.content.tests.mixins import TestContentMixin
class CoursesTestCase(TestCase):
@classmethod
def setUpTestData(cls):
create_admin()
create_batch_unique(CourseFactory, status=Course.STATUS_CHOICES[:3], price=[0, 1000],
age=Course.AGE_CHOICES[:2], deferred_start_at=[None, timezone.now() + timedelta(days=randint(5, 15))])
def test_courses_url_accessible(self):
print('test_courses_url_accessible')
print('get ', reverse('courses'))
resp = self.client.get(reverse('courses'))
self.assertEqual(resp.status_code, 200)
def test_course_url_accessible(self):
print('test_course_url_accessible')
for course in Course.objects.filter(status=Course.PUBLISHED):
print('get ', course.url)
resp = self.client.get(course.url)
self.assertEqual(resp.status_code, 200)
@login_admin
def test_course_edit_url_accessible(self):
print('test_course_edit_url_accessible')
for course in Course.objects.all():
print('get ', reverse('course_edit', args=[course.id]))
resp = self.client.get(reverse('course_edit', args=[course.id]))
self.assertEqual(resp.status_code, 200)
class CourseEditTestCase(TestContentMixin, SeleniumTestCase):
model = Course
object_id = None
object_data = {}
@classmethod
def setUpClass(cls):
super().setUpClass()
create_admin()
UserFactory.create_batch(5, role=User.AUTHOR_ROLE)
def check_saving(self, url=None, response_code=200):
print('Check saving')
time.sleep(1)
request = self.wait_for_request(url or f'/api/v1/courses/{self.object_id}/')
self.assertEqual(request[2].get('status'), response_code)
self.assertNotEqual(request[2].get('response'), None)
obj = self.model.objects.get(id=self.object_id)
for key, value in self.object_data.items():
if key not in ['content', 'lessons']:
self.assertEqual(getattr(obj, key), value)
self.check_content_saving(obj)
self.check_lessons_saving(obj)
def check_lessons_saving(self, obj):
print('Check lessons saving', )
lessons = self.object_data.get('lessons', [])
self.assertEqual(obj.lessons.all().count(), len(lessons))
for i, lesson in enumerate(obj.lessons.all().order_by('title',)):
self.assertEqual(strip_tags(lesson.short_description), lessons[i]['short_description'])
self.assertEqual(lesson.title, lessons[i]['title'])
print('OK')
def test_course_edit(self):
print('test_course_edit')
user = User.objects.filter(role=User.AUTHOR_ROLE).first()
self.login(user)
url = self.get_url(reverse('course_create'))
print('url is', url)
self.driver.get(url)
print('page opened', self.driver.current_url)
self.add_requests_log()
course_redactor = self.wait_elem_name('course-redactor')
# visible always elements
title_el = self.wait_elem_name('course-title')
slug_el = self.wait_elem_name('course-slug')
category_el = self.wait_elem_name('course-category')
is_paid_no_el = self.wait_elem_name('course-is-paid-no')
is_paid_yes_el = self.wait_elem_name('course-is-paid-yes')
is_paid_input_el = self.wait_elem_css('[name=course-is-paid-input]:checked')
age_el = self.wait_elem_name('course-age')
# Only for ADMIN
# is_featured_el = self.wait_elem_name('course-is-featured')
is_deferred_no_el = self.wait_elem_name('course-is-deferred-no')
is_deferred_yes_el = self.wait_elem_name('course-is-deferred-yes')
is_deferred_input_el = self.wait_elem_name('course-is-deferred-input')
content_btn_el = self.wait_elem_name('course-content-btn')
lessons_btn_el = self.wait_elem_name('course-lessons-btn')
content_el = self.wait_elem_name('course-content')
title = Faker('sentence', nb_words=6).generate({})
title_el.send_keys(title)
slug = slugify(unidecode(title[:90]))
slug = re.sub(r'[^-\w]+$', '', slug)
slug = re.sub(r'[^-\w]', '-', slug)
self.object_data['title'] = title
self.object_data['slug'] = slug
time.sleep(1)
self.assertEqual(slug_el.get_attribute('value'), slug)
# check save
obj = self.model.objects.get(title=title, slug=slug)
self.assertEqual(bool(obj), True)
self.object_id = obj.id
self.assertEqual(is_paid_input_el.get_attribute('value'), 'false')
self.assertRaises(TimeoutException, callable=lambda: self.driver.find_element_by_name('course-price'))
self.assertRaises(TimeoutException, callable=lambda: self.driver.find_element_by_name('course-old-price'))
self.assertRaises(TimeoutException, callable=lambda: self.driver.find_element_by_name('course-access-duration'))
is_paid_yes_el.click()
time.sleep(1)
is_paid_input_el = self.wait_elem_css('[name=course-is-paid-input]:checked')
self.assertEqual(is_paid_input_el.get_attribute('value'), 'true')
try:
price_el = self.wait_elem_name('course-price')
old_price_el = self.wait_elem_name('course-old-price')
access_duration_el = self.wait_elem_name('course-access-duration')
except:
self.fail('Price, old price and access_duration elements not shown')
self.assertEqual(is_deferred_input_el.get_attribute('checked'), 'true')
self.assertRaises(TimeoutException, callable=lambda: self.driver.find_element_by_name('course-date'))
self.assertRaises(TimeoutException, callable=lambda: self.driver.find_element_by_name('course-time'))
is_deferred_yes_el.click()
try:
date_el = self.wait_elem_name('course-date')
time_el = self.wait_elem_name('course-time')
except:
self.fail('Date and time elements not shown')
price_el.send_keys(1000)
self.object_data['price'] = 1000
old_price_el.send_keys(1500)
self.object_data['old_price'] = 1500
access_duration_el.send_keys(15)
self.object_data['access_duration'] = 15
self.check_saving()
self.check_content(content_el)
lessons_btn_el.click()
time.sleep(0.5)
# lessons_el = self.wait_elem_name('course-lessons')
# lesson_edit_el = self.wait_elem_name('course-lesson-edit')
# stream_el = self.wait_elem_name('course-stream')
add_lesson_el = self.wait_elem_name('course-add-lesson')
add_lesson_el.click()
time.sleep(0.5)
lesson_title_el = self.wait_elem_name('course-lesson-title')
lesson_text_el = self.wait_elem_name('course-lesson-text-wrap').find_element(
By.XPATH, './/div[contains(@class, "redactor-layer")][@contenteditable]')
lesson_save_btn_el = self.wait_elem_name('course-lesson-save')
lesson = {}
title = Faker('sentence', nb_words=6).generate({})
lesson_title_el.send_keys(title)
lesson['title'] = title
text = Faker('sentence', nb_words=50).generate({})
lesson_text_el.click()
lesson_text_el.send_keys(text)
time.sleep(0.5)
lesson_save_btn_el.click()
self.object_data['lessons'] = [{
'title': title,
'short_description': text
}]
self.check_saving('/api/v1/lessons/', 201)

@ -59,7 +59,7 @@ INSTALLED_APPS = [
'django_user_agents',
'imagekit',
] + [
'apps.auth.apps',
'apps.auth',
'apps.user',
'apps.notification',
'apps.payment',

@ -0,0 +1,65 @@
from django.test import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.by import By
from pyvirtualdisplay import Display
from django.conf import settings
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from project.utils.selenium_utils import SeleniumExtensions as SE
from apps.auth.models import TempToken
class SeleniumTestCase(LiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.display = Display(visible=0, size=(1280, 1024))
cls.display.start()
cls.driver = webdriver.Firefox()
@classmethod
def tearDownClass(cls):
cls.driver.quit()
cls.display.stop()
super().tearDownClass()
def get_url(self, url=''):
return '%s%s' % (self.live_server_url, url)
def wait_elem_xpath(self, xpath, inside_el=None, wait_time=10):
return SE.wait_elem(self.driver, (By.XPATH, xpath), inside_el, wait_time)
def wait_elems_xpath(self, xpath, inside_el=None, wait_time=10):
return SE.wait_elems(self.driver, (By.XPATH, xpath), inside_el, wait_time)
def wait_elem_css(self, selector, inside_el=None, wait_time=10):
return SE.wait_elem(self.driver, (By.CSS_SELECTOR, selector), inside_el, wait_time)
def wait_elems_css(self, selector, inside_el=None, wait_time=10):
return SE.wait_elems(self.driver, (By.CSS_SELECTOR, selector), inside_el, wait_time)
def wait_elem_name(self, name, inside_el=None, wait_time=10):
return SE.wait_elem(self.driver, (By.NAME, name), inside_el, wait_time)
def wait_elem_id(self, id, inside_el=None, wait_time=10):
return SE.wait_elem(self.driver, (By.ID, id), inside_el, wait_time)
def wait_elems_name(self, name, inside_el=None, wait_time=10):
return SE.wait_elems(self.driver, (By.NAME, name), inside_el, wait_time)
def login(self, user):
TempToken.objects.all().delete()
tt = TempToken.objects.create(user=user)
self.driver.get('%s?temp-token=%s' % (self.get_url(), tt.key))
def add_requests_log(self):
SE.add_requests_log(self.driver)
def get_requests(self):
return SE.get_requests(self.driver)
def wait_for_request(self, path, wait_time=10):
return SE.wait_for_request(self.driver, path, wait_time)

@ -0,0 +1,96 @@
from itertools import combinations
from factory.django import DjangoModelFactory, ImageField
import factory
import factory.fuzzy
from unidecode import unidecode
from django.utils.text import slugify
from apps.course.models import *
from apps.content.models import *
from apps.user.models import *
ADMIN_EMAIL = 'admin@mail.com'
def create_admin():
admin = UserFactory(username=ADMIN_EMAIL, email=ADMIN_EMAIL, role=User.ADMIN_ROLE,
is_staff=True, is_superuser=True)
admin.set_password('admin')
admin.save()
return admin
def create_users(count_multiplier=1):
create_admin()
UserFactory.create_batch(10 * count_multiplier, role=User.USER_ROLE)
UserFactory.create_batch(5 * count_multiplier, role=User.AUTHOR_ROLE)
UserFactory.create_batch(5 * count_multiplier, role=User.TEACHER_ROLE)
UserFactory.create_batch(5, role=User.ADMIN_ROLE, is_staff=True)
def login_admin(fn):
def wrap(self, *args, **kwargs):
admin = User.objects.get(username=ADMIN_EMAIL)
self.client.force_login(admin)
try:
fn(self, *args, **kwargs)
finally:
self.client.logout()
return wrap
def create_batch_unique(factory_class, **kwargs):
model = factory_class._meta.model
values = []
for k, v in kwargs.items():
try:
f = model._meta.get_field(k)
except:
del kwargs[k]
if getattr(f, 'choices', None):
v = [c[0] for c in v]
values += ((k, val) for val in v)
for params in combinations(values, len(kwargs)):
data = dict(params)
if len(data) == len(kwargs):
factory_class(**data)
class ImageObjectFactory(DjangoModelFactory):
image = ImageField()
image_thumbnail = ImageField()
class UserFactory(DjangoModelFactory):
class Meta:
model = User
first_name = factory.Faker('first_name')
last_name = factory.Faker('last_name')
email = factory.Sequence(lambda n: "test_user%d@mail.com" % n)
username = factory.LazyAttribute(lambda o: o.email)
gallery = None
photo = None #factory.SubFactory(ImageObjectFactory)
is_active = True
auth_token = None
class CategoryFactory(DjangoModelFactory):
class Meta:
model = Category
class CourseFactory(DjangoModelFactory):
class Meta:
model = Course
author = factory.SubFactory(UserFactory, role=User.AUTHOR_ROLE)
title = factory.Faker('sentence', nb_words=6)
slug = factory.LazyAttribute(lambda o: slugify(unidecode(o.title[:90])))
category = factory.SubFactory(CategoryFactory)
cover = None #factory.SubFactory(ImageObjectFactory)
gallery = None
status = factory.Iterator([s[0] for s in Course.STATUS_CHOICES])

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class ElementIn(object):
default_find_fn = 'find_element'
def __init__(self, element, locator, find_fn=None):
self.element = element
self.locator = locator
self.find_fn = self.default_find_fn if find_fn is None else find_fn
def __call__(self, driver):
try:
return getattr(self.element, self.find_fn)(*self.locator)
except:
return False
class ElementsIn(ElementIn):
default_find_fn = 'find_elements'
class SeleniumExtensions(object):
@classmethod
def wait_elem(cls, driver, locator, inside_el=None, wait_time=10):
if inside_el:
return WebDriverWait(driver, wait_time).until(ElementIn(inside_el, locator))
return WebDriverWait(driver, wait_time).until(
EC.presence_of_element_located(locator)
)
@classmethod
def wait_elems(cls, driver, locator, inside_el=None, wait_time=10):
if inside_el:
return WebDriverWait(driver, wait_time).until(ElementsIn(inside_el, locator))
return WebDriverWait(driver, wait_time).until(
EC.presence_of_all_elements_located(locator)
)
@classmethod
def wait_for_js_load(cls, driver, wait_time=10):
WebDriverWait(driver, wait_time).until(
lambda driver: driver.execute_script('return document.readyState') == 'complete')
@classmethod
def add_requests_log(cls, driver):
js = '''
(function() {
var open = XMLHttpRequest.prototype.open;
var send = XMLHttpRequest.prototype.send;
window._requestsLog = [];
window._findRequest = function(pathOrXHR){
for(var i=window._requestsLog.length - 1; i >= 0; i--){
var request = window._requestsLog[i];
if(typeof(pathOrXHR) == 'string' && request[1].indexOf(pathOrXHR) > -1
|| request[2] === pathOrXHR){
return request;
}
}
}
XMLHttpRequest.prototype.open = function(method, url){
window._requestsLog.push([method, url, this]);
return open.apply(this, arguments);
}
XMLHttpRequest.prototype.send = function(body){
var r = window._findRequest(this);
if(r){
r.push(body);
}
return send.apply(this, arguments);
}
})();
'''
return driver.execute_script(js)
@classmethod
def get_requests(cls, driver):
return driver.execute_script('return window._requestsLog;')
@classmethod
def wait_for_request(cls, driver, path, wait_time=10):
WebDriverWait(driver, wait_time).until(
lambda driver: driver.execute_script('''
var r = window._findRequest(arguments[0]);
return !!r && r[2].readyState == 4;
''', path) is True)
return driver.execute_script('return window._findRequest(arguments[0]);', path)

@ -37,3 +37,17 @@ sendgrid
drf_dynamic_fields
flower==0.9.2
unidecode
factory-boy==2.11.1
pyvirtualdisplay==0.2.1
selenium
# sudo apt-get install xvfb
# sudo apt-get install libappindicator3-1 fonts-liberation
# wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
# sudo apt-get install unzip
# wget -N https://chromedriver.storage.googleapis.com/73.0.3683.20/chromedriver_linux64.zip
# unzip chromedriver_linux64.zip
# chmod +x chromedriver
# sudo mv -f chromedriver /usr/local/share/chromedriver
# sudo ln -s /usr/local/share/chromedriver /usr/local/bin/chromedriver
# sudo ln -s /usr/local/share/chromedriver /usr/bin/chromedriver

@ -6,12 +6,13 @@
<div class="info__section">
<div class="courses__item">
<div class="courses__preview">
<img class="courses__pic" :src="course.coverImage || defaultCover" width="300px" />
<img class="courses__pic" name="course-cover" :src="course.coverImage || defaultCover" width="300px" />
<div class="upload" v-if="! course.coverImage">
<div class="upload__title">Загрузить превью</div>
<input type="file" class="upload__file" @change="onCoverImageSelected">
<input type="file" class="upload__file" name="course-cover-upload" @change="onCoverImageSelected">
</div>
<a href="#" title="Удалить превью" class="course-delete-cover" v-if="course.coverImage" @click="removeCover">
<a href="#" title="Удалить превью" class="course-delete-cover" v-if="course.coverImage" @click="removeCover"
name="course-cover-delete">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
@ -20,13 +21,13 @@
<div class="courses__details">
<div class="field-category courses__theme theme field info__field--light" v-if="!live" v-bind:class="{ error: (!$v.live && $v.course.category.$dirty || showErrors) && $v.course.category.$invalid }">
<lil-select :value.sync="course.category" :options="categoryOptions"
placeholder="Выберите категорию"/>
placeholder="Выберите категорию" name="course-category"/>
</div>
<div class="courses__old-price" v-if="course.is_paid && course.old_price"><s>{{ course.old_price }}</s></div>
<div class="courses__price" v-if="course.is_paid && course.price">{{ course.price }}</div>
</div>
<div class="courses__title field field" v-bind:class="{ error: ($v.course.title.$dirty || showErrors) && $v.course.title.$invalid }">
<textarea class="field__textarea"
<textarea class="field__textarea" name="course-title"
rows="1"
:title="titles.courseTitle"
v-autosize="course.title"
@ -34,7 +35,7 @@
v-model="course.title"
placeholder="Добавить заголовок"></textarea>
</div>
<div class="courses__content field" style="width: 300px;"
<div class="courses__content field" style="width: 300px;" name="course-short-description"
v-bind:class="{ error: ($v.course.short_description.$dirty || showErrors) && $v.course.short_description.$invalid }">
<vue-redactor :value.sync="course.short_description" placeholder="Добавить краткое описание"/>
</div>
@ -46,7 +47,7 @@
<div v-if="!live" class="info__field field">
<div class="field__label field__label_gray">ССЫЛКА</div>
<div class="field__wrap">
<input type="text" class="field__input" v-model="course.slug" @input="slugChanged = true">
<input type="text" class="field__input" name="course-slug" v-model="course.slug" @input="slugChanged = true">
</div>
<div class="field__wrap field__wrap--additional">{{ courseFullUrl }}</div>
</div>
@ -55,7 +56,7 @@
v-bind:class="{ error: ($v.course.stream.$dirty || showErrors) && $v.course.stream.$invalid }">
<div class="field__label field__label_gray">ССЫЛКА НА VIMEO</div>
<div class="field__wrap">
<input type="text" class="field__input" v-model="course.stream">
<input type="text" name="course-stream" class="field__input" v-model="course.stream">
</div>
</div>
@ -71,12 +72,14 @@
<div class="field__label field__label_gray">ДОСТУП</div>
<div class="field__wrap">
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="false" class="switch__input" v-model="course.is_paid">
<span class="switch__content">Бесплатный</span>
<input type="radio" :value="false" class="switch__input" v-model="course.is_paid"
name="course-is-paid-input">
<span class="switch__content" name="course-is-paid-no">Бесплатный</span>
</label>
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="true" class="switch__input" v-model="course.is_paid">
<span class="switch__content">Платный</span>
<input type="radio" :value="true" class="switch__input" v-model="course.is_paid"
name="course-is-paid-input">
<span class="switch__content" name="course-is-paid-yes">Платный</span>
</label>
</div>
</div>
@ -87,7 +90,7 @@
<div class="field__label field__label_gray">ПРОДОЛЖИТЕЛЬНОСТЬ ДОСТУПА</div>
<div class="field__wrap field__wrap__appended">
<input type="text" class="field__input field__input__appended" v-model.number="course.access_duration"
@input="$v.course.access_duration.$touch()">
@input="$v.course.access_duration.$touch()" name="course-access-duration">
<button disabled class="field__append">{{pluralize(course.access_duration, ['день', 'дня', 'дней'])}}</button>
</div>
</div>
@ -96,14 +99,16 @@
<div v-if="course.is_paid" class="info__field field">
<div class="field__label field__label_gray">СТОИМОСТЬ</div>
<div class="field__wrap field__wrap__appended" style="width: 120px;">
<input type="text" class="field__input field__input__appended" v-model.number.lazy="displayPrice">
<input type="text" class="field__input field__input__appended" v-model.number.lazy="displayPrice"
name="course-price">
<button disabled class="field__append">руб.</button>
</div>
</div>
<div v-if="course.is_paid" class="info__field field" style="margin-left: 10px;">
<div class="field__label field__label_gray">СТОИМОСТЬ БЕЗ СКИДКИ</div>
<div class="field__wrap field__wrap__appended">
<input type="text" class="field__input field__input__appended" v-model.number.lazy="displayOldPrice">
<input type="text" class="field__input field__input__appended" v-model.number.lazy="displayOldPrice"
name="course-old-price">
<button disabled class="field__append">руб.</button>
</div>
</div>
@ -111,13 +116,13 @@
<div v-if="!live" class="info__field field">
<div class="field__label field__label_gray">ВОЗРАСТ</div>
<div class="field__wrap">
<lil-select :value.sync="course.age" :options="ages" value-key="value"
<lil-select :value.sync="course.age" :options="ages" value-key="value" name="course-age"
placeholder="Выберите возраст"/>
</div>
</div>
<label v-if="me && !live && me.role === ROLE_ADMIN" class="info__switch switch switch_lg">
<input type="checkbox" class="switch__input" v-model="course.is_featured">
<span class="switch__content">Выделить</span>
<span class="switch__content" name="course-is-featured">Выделить</span>
</label>
</div>
<div v-if="!live" class="info__fieldset">
@ -125,12 +130,14 @@
<div class="field__label field__label_gray">ЗАПУСК</div>
<div class="field__wrap">
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="false" class="switch__input" v-model="course.is_deferred">
<span class="switch__content">Мгновенный</span>
<input type="radio" :value="false" class="switch__input" v-model="course.is_deferred"
name="course-is-deferred-input">
<span class="switch__content" name="course-is-deferred-no">Мгновенный</span>
</label>
<label class="field__switch switch switch_lg switch_circle">
<input type="radio" :value="true" class="switch__input" v-model="course.is_deferred">
<span class="switch__content">Отложенный</span>
<input type="radio" :value="true" class="switch__input" v-model="course.is_deferred"
name="course-is-deferred-input">
<span class="switch__content" name="course-is-deferred-yes">Отложенный</span>
</label>
</div>
</div>
@ -138,13 +145,15 @@
<div class="info__field field">
<div class="field__label">ДАТА</div>
<div class="field__wrap">
<vue-datepicker :disabled="disabledDates" input-class="field__input" v-model="course.date" language="ru" format="dd/MM/yyyy"/>
<vue-datepicker :disabled="disabledDates" input-class="field__input" name="course-date"
v-model="course.date" language="ru" format="dd/MM/yyyy"/>
</div>
</div>
<div class="field-time info__field field">
<div class="field__label">ВРЕМЯ</div>
<div class="field__wrap">
<lil-select :value.sync="course.time" value-key="value" :options="timeOptions" placeholder="Выберите время"/>
<lil-select :value.sync="course.time" value-key="value" :options="timeOptions" name="course-time"
placeholder="Выберите время"/>
</div>
</div>
</div>
@ -156,12 +165,12 @@
<div class="section__center center">
<div class="kit" style="margin: 0 auto;">
<div v-if="!live" id="course-redactor__nav" class="kit__nav">
<button class="kit__btn btn btn_lg"
<button class="kit__btn btn btn_lg" name="course-content-btn"
v-bind:class="{ 'btn_stroke': viewSection === 'course', 'btn_gray': viewSection !== 'course' }"
type="button" @click="showCourse">Описание
курса
</button>
<button class="kit__btn btn btn_lg"
<button class="kit__btn btn btn_lg" name="course-lessons-btn"
v-bind:class="{ 'btn_stroke': viewSection === 'lessons', 'btn_gray': viewSection !== 'lessons' }"
type="button"
@click="showLessons"
@ -170,7 +179,7 @@
</button>
</div>
<div v-if="viewSection === 'course'" class="kit__body">
<block-content :content.sync="course.content"></block-content>
<block-content :content.sync="course.content" name="course-content"></block-content>
<!--<div class="kit__foot">
<button type="submit" class="kit__submit btn btn_md" v-bind:class="{ loading: courseSaving }">
@ -181,7 +190,8 @@
<div v-if="viewSection === 'lessons'" class="kit__body">
<div class="lessons__title title">Содержание курса</div>
<div v-if="!lessonsLoading" class="lessons__list">
<vue-draggable v-model="lessons" @start="drag=true" @end="onLessonsChanged" :options="{ handle: '.sortable__handle' }">
<vue-draggable v-model="lessons" @start="drag=true" @end="onLessonsChanged" name="course-lessons"
:options="{ handle: '.sortable__handle' }">
<div class="lessons__item" v-for="(lesson, index) in lessons" :key="lesson.id">
<div class="lessons__actions">
<button class="sortable__handle" type="button">
@ -209,7 +219,8 @@
</div>
<div v-if="lessonsLoading">Загрузка...</div>
<div class="lessons__foot">
<button type="button" class="lessons__btn btn btn_md" @click="addLesson">СОЗДАТЬ УРОК</button>
<button type="button" class="lessons__btn btn btn_md" @click="addLesson"
name="course-add-lesson">СОЗДАТЬ УРОК</button>
</div>
</div>
</div>
@ -218,7 +229,7 @@
</form>
<form v-if="viewSection === 'lessons-edit'" @submit.prevent="onLessonSubmit">
<lesson-redactor :$v="$v" :lesson.sync="currentLesson" :saving.sync="lessonSaving" :access-token="accessToken"
v-on:back="goToLessons" />
v-on:back="goToLessons" name="course-lesson-edit" />
</form>
</div>
<div v-else>
@ -476,10 +487,13 @@
onCoursePriceChange(event) {
this.course.price = event.target.value;
},
getSlug(text) {
return slugify(text || '').toLowerCase().replace(/[^-\w]+$/, '').replace(/[^-\w]/g, '-');
},
onCourseNameInput() {
this.$v.course.title.$touch();
if (!this.slugChanged && !this.$v.course.status) {
this.course.slug = (slugify(this.course.title) || '').toLowerCase();
this.course.slug = this.getSlug(this.course.title);
}
},
removeLesson(lessonIndex) {
@ -602,7 +616,6 @@
this.lessons = data.lessons.map((lessonJson) => {
return api.convertLessonJson(lessonJson);
});
this.course.access_duration = this.course.access_duration || '';
},
loadCourseDraft() {
//console.log('loadCourseDraft');
@ -757,7 +770,7 @@
this.courseSaving = true;
this.changeSavingStatus();
const courseObject = this.course;
courseObject.slug = courseObject.slug && slugify(courseObject.slug);
courseObject.slug = this.getSlug(courseObject.slug);
api.saveCourse(courseObject, this.accessToken)
.then((response) => {
this.courseSaving = false;

@ -21,12 +21,12 @@
<div class="kit__field field"
v-bind:class="{ error: $v.currentLesson.title.$invalid }">
<div class="field__wrap">
<input type="text" class="field__input" placeholder="Название урока" v-model="lesson.title">
<input type="text" name="course-lesson-title" class="field__input" placeholder="Название урока" v-model="lesson.title">
</div>
</div>
<div class="kit__field field"
v-bind:class="{ error: $v.currentLesson.short_description.$invalid }">
<div class="field__wrap">
<div class="field__wrap" name="course-lesson-text-wrap">
<vue-redactor :value="lesson.short_description"
v-on:update:value="(value) => { this.lesson.short_description = value; }" placeholder="Описание урока"/>
</div>
@ -34,10 +34,10 @@
</div>
</div>
</div>
<block-content :content.sync="lesson.content"></block-content>
<block-content name="course-lesson-content" :content.sync="lesson.content"></block-content>
<div class="kit__foot">
<button class="kit__submit btn btn_md" v-bind:class="{ loading: saving }">Сохранить</button>
<button class="kit__submit btn btn_md" v-bind:class="{ loading: saving }" name="course-lesson-save">Сохранить</button>
</div>
</div>
</div>

@ -1,7 +1,7 @@
<template>
<div class="kit__section">
<div class="kit__section" name="block-add">
<div v-if="!isOpen" class="kit__add add">
<button type="button" class="add__toggle" @click="isOpen = true">
<button type="button" class="add__toggle" @click="isOpen = true" name="block-add-open">
<span class="add__circle">
<svg class="icon icon-add-plus">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
@ -11,7 +11,7 @@
</button>
</div>
<div v-if="isOpen" class="kit__add add open">
<button type="button" class="add__toggle" @click="isOpen = false">
<button type="button" class="add__toggle" @click="isOpen = false" name="block-add-close">
<span class="add__circle">
<svg class="icon icon-add-plus">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
@ -20,27 +20,27 @@
<span class="add__title">Добавить блок</span>
</button>
<div class="add__list">
<button class="add__btn" type="button" @click="addBlockText">
<button class="add__btn" type="button" @click="addBlockText" name="block-add-block-text">
<svg class="icon icon-text">
<use xlink:href="/static/img/sprite.svg#icon-text"></use>
</svg>
</button>
<button class="add__btn" type="button" @click="addBlockImage">
<button class="add__btn" type="button" @click="addBlockImage" name="block-add-block-image">
<svg class="icon icon-image">
<use xlink:href="/static/img/sprite.svg#icon-image"></use>
</svg>
</button>
<button type="button" class="add__btn" @click="addBlockImageText">
<button type="button" class="add__btn" @click="addBlockImageText" name="block-add-block-image-text">
<svg class="icon icon-image-text">
<use xlink:href="/static/img/sprite.svg#icon-image-text"></use>
</svg>
</button>
<button type="button" class="add__btn" @click="addBlockImages">
<button type="button" class="add__btn" @click="addBlockImages" name="block-add-block-images">
<svg class="icon icon-images">
<use xlink:href="/static/img/sprite.svg#icon-images"></use>
</svg>
</button>
<button type="button" class="add__btn" @click="addBlockVideo">
<button type="button" class="add__btn" @click="addBlockVideo" name="block-add-block-video">
<svg class="icon icon-video-stroke">
<use xlink:href="/static/img/sprite.svg#icon-video-stroke"></use>
</svg>

@ -1,14 +1,15 @@
<template>
<div>
<vue-draggable :list="content" @start="drag=true" @end="drag=false" :options="{ handle: '.sortable__handle' }">
<div v-for="(block, index) in content" :key="block.id ? block.id : block.uuid" class="kit__section kit__section--block">
<div v-for="(block, index) in content" :key="block.id ? block.id : block.uuid" class="kit__section kit__section--block"
name="block-content-block">
<div class="kit__section-remove">
<button if="index != 0" type="button" @click="moveUp(index)">
<button if="index != 0" type="button" @click="moveUp(index)" name="block-content-move-up">
<svg class="icon icon-arrow-up">
<use xlink:href="/static/img/sprite.svg#icon-arrow-down"></use>
</svg>
</button>
<button if="index < (content.length - 1)" type="button" @click="moveDown(index)">
<button if="index < (content.length - 1)" type="button" @click="moveDown(index)" name="block-content-move-down">
<svg class="icon icon-arrow-down">
<use xlink:href="/static/img/sprite.svg#icon-arrow-down"></use>
</svg>
@ -18,7 +19,7 @@
<use xlink:href="/static/img/sprite.svg#icon-hamburger"></use>
</svg>
</button>
<button type="button" @click="onBlockRemoved(index)">
<button type="button" @click="onBlockRemoved(index)" name="block-content-delete">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>

@ -1,8 +1,8 @@
<template>
<div>
<div name="block-image">
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
<input type="text" name="block-image-title"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@ -10,7 +10,7 @@
</div>
</div>
<div class="kit__row">
<lil-image :image-id="imageId" :image-url="imageUrl" v-on:update:imageUrl="onUpdateImageUrl"
<lil-image :image-id="imageId" :image-url="imageUrl" v-on:update:imageUrl="onUpdateImageUrl" name="block-image-image"
v-on:update:imageId="onUpdateImageId" :access-token="accessToken" />
</div>
</div>

@ -1,12 +1,12 @@
<template>
<div>
<div name="block-image-text">
<div class="kit__row">
<lil-image :image-id="imageId" :image-url="imageUrl" v-on:update:imageUrl="onUpdateImageUrl"
v-on:update:imageId="onUpdateImageId" :access-token="accessToken"/>
v-on:update:imageId="onUpdateImageId" :access-token="accessToken" name="block-image-text-image"/>
<div class="kit__fieldset">
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
<input type="text" name="block-image-text-title"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@ -14,7 +14,7 @@
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<div class="field__wrap" name="block-image-text-text-wrap">
<vue-redactor :value="text" v-on:update:value="onTextChange" placeholder="Описание"/>
</div>
</div>

@ -1,8 +1,8 @@
<template>
<div>
<div name="block-images">
<div v-if="! noTitle" class="kit__field field">
<div class="field__wrap field__wrap--title">
<input :readonly="readOnly" type="text"
<input :readonly="readOnly" type="text" name="block-images-title"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@ -10,16 +10,17 @@
</div>
</div>
<div class="kit__gallery">
<div class="kit__preview" v-for="(image, index) in images" v-bind:class="{ 'kit__preview--loading': image.loading }">
<img :src="image.image_thumbnail_url" class="kit__pic">
<button class="kit__delete-photo" type="button" @click="onRemoveImage(index)">
<div class="kit__preview" v-for="(image, index) in images" v-bind:class="{ 'kit__preview--loading': image.loading }"
name="block-images-image-block">
<img :src="image.image_thumbnail_url" class="kit__pic" name="block-images-image">
<button class="kit__delete-photo" type="button" @click="onRemoveImage(index)" name="block-images-delete-image">
<svg class="icon icon-delete">
<use xlink:href="/static/img/sprite.svg#icon-delete"></use>
</svg>
</button>
</div>
<div class="kit__photo">
<svg class="icon icon-add-plus">
<svg class="icon icon-add-plus" name="block-images-add-image">
<use xlink:href="/static/img/sprite.svg#icon-add-plus"></use>
</svg>
<input type="file" class="kit__file" multiple @change="onImageAdded">

@ -1,8 +1,8 @@
<template>
<div>
<div name="block-text">
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
<input type="text" name="block-text-title"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@ -10,7 +10,7 @@
</div>
</div>
<div class="kit__field field">
<div class="field__wrap">
<div class="field__wrap" name="block-text-text-wrap">
<vue-redactor :value="text" v-on:update:value="onTextChange" placeholder="Описание"/>
</div>
</div>

@ -1,8 +1,8 @@
<template>
<div>
<div name="block-video">
<div class="kit__field field">
<div class="field__wrap field__wrap--title">
<input type="text"
<input type="text" name="block-video-title"
:value="title"
class="field__input"
placeholder="Заголовок раздела"
@ -12,7 +12,7 @@
<div class="kit__field field">
<div class="field__wrap">
<div class="field__flex">
<input type="text"
<input type="text" name="block-video-url"
:value="videoUrl"
class="field__input field__input_sm"
placeholder="Вставьте ссылку на Vimeo, YouTube, или другой сервис"

Loading…
Cancel
Save