├── .coveragerc
├── .gitignore
├── .travis.yml
├── CONTRIBUTING.md
├── CONTRIBUTORS
├── LICENSE
├── MANIFEST.in
├── README.md
├── manage.py
├── publish.py
├── run_tests.py
├── settings.py
├── setup.cfg
├── setup.py
└── user_guide
├── __init__.py
├── admin.py
├── apps.py
├── migrations
├── 0001_initial.py
└── __init__.py
├── models.py
├── static
└── user_guide
│ ├── .jscs.json
│ ├── .jshintrc
│ ├── Gruntfile.js
│ ├── build
│ ├── django-user-guide.css
│ └── django-user-guide.js
│ ├── package.json
│ └── tests
│ └── django-user-guide.js
├── templates
└── user_guide
│ └── window.html
├── templatetags
├── __init__.py
└── user_guide_tags.py
├── tests
├── __init__.py
├── admin_tests.py
├── model_tests.py
├── no_admin_tests.py
├── templatetag_tests.py
├── urls_tests.py
└── view_tests.py
├── urls.py
├── version.py
└── views.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | omit =
4 | */migrations/*
5 | user_guide/version.py
6 | user_guide/apps.py
7 | source = user_guide
8 | [report]
9 | exclude_lines =
10 | # Have to re-enable the standard pragma
11 | pragma: no cover
12 |
13 | # Don't complain if tests don't hit defensive assertion code:
14 | raise NotImplementedError
15 | fail_under = 100
16 | show_missing = 1
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled python files
2 | *.pyc
3 |
4 | # Vim files
5 | *.swp
6 | *.swo
7 |
8 | # Coverage files
9 | .coverage
10 |
11 | # Setuptools distribution folder.
12 | /dist/
13 | /build/
14 |
15 | # Python egg metadata, regenerated from source files by setuptools.
16 | /*.egg-info
17 | /*.egg
18 | .eggs/
19 |
20 | # Virtual environment
21 | env/
22 | venv/
23 |
24 | # Js Stuff
25 | node_modules/
26 | npm-debug.log
27 | .grunt/
28 | .tmp/
29 | _SpecRunner.html
30 | html-report/
31 |
32 | # Pycharm
33 | .idea/
34 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: python
3 | python:
4 | - '2.7'
5 | - '3.4'
6 | - '3.5'
7 | env:
8 | global:
9 | - DB=postgres
10 | matrix:
11 | - DJANGO=">=1.8,<1.9"
12 | - DJANGO=">=1.9,<1.10"
13 | install:
14 | - pip install -q coverage flake8 Django$DJANGO django-nose>=1.4
15 | before_script:
16 | - flake8 .
17 | - psql -c 'CREATE DATABASE user_guide;' -U postgres
18 | script:
19 | - (cd user_guide/static/user_guide && npm install && npm test)
20 | - coverage run setup.py test
21 | - coverage report --fail-under=100
22 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 | Contributions and issues are most welcome! All issues and pull requests are
3 | handled through GitHub on the
4 | [ambitioninc repository](https://github.com/ambitioninc/django-user-guide/issues).
5 | Also, please check for any existing issues before filing a new one. If you have
6 | a great idea but it involves big changes, please file a ticket before making a
7 | pull request! We want to make sure you don't spend your time coding something
8 | that might not fit the scope of the project.
9 |
10 | ## Running the tests
11 |
12 | To get the source source code and run the unit tests, run:
13 | ```bash
14 | git clone git://github.com/ambitioninc/django-user-guide.git
15 | cd django-user-guide
16 | virtualenv env
17 | . env/bin/activate
18 | python setup.py install
19 | coverage run setup.py test
20 | coverage report --fail-under=100
21 | ```
22 |
23 | While 100% code coverage does not make a library bug-free, it significantly
24 | reduces the number of easily caught bugs! Please make sure coverage is at 100%
25 | before submitting a pull request!
26 |
27 | ## Code Quality
28 |
29 | For code quality, please run flake8:
30 | ```bash
31 | pip install flake8
32 | flake8 .
33 | ```
34 |
35 | ## Code Styling
36 | Please arrange imports with the following style
37 |
38 | ```python
39 | # Standard library imports
40 | import os
41 |
42 | # Third party package imports
43 | from mock import patch
44 | from django.conf import settings
45 |
46 | # Local package imports
47 | from user_guide.version import __version__
48 | ```
49 |
50 | Please follow
51 | [Google's python style](http://google-styleguide.googlecode.com/svn/trunk/pyguide.html)
52 | guide wherever possible.
53 |
54 |
55 |
56 | ## Release Checklist
57 |
58 | Before a new release, please go through the following checklist:
59 |
60 | * Bump version in user_guide/version.py
61 | * Git tag the version
62 | * Upload to pypi:
63 | ```bash
64 | pip install wheel
65 | python setup.py sdist bdist_wheel upload
66 | ```
67 |
68 | ## Vulnerability Reporting
69 |
70 | For any security issues, please do NOT file an issue or pull request on GitHub!
71 | Please contact [security@ambition.com](mailto:security@ambition.com) with the
72 | GPG key provided on [Ambition's website](http://ambition.com/security/).
73 |
--------------------------------------------------------------------------------
/CONTRIBUTORS:
--------------------------------------------------------------------------------
1 | Jeff McRiffey (jeff.mcriffey@tryambition.com)
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Ambition
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include CONTRIBUTING.md
3 | include LICENSE
4 | recursive-include user_guide/static/user_guide/build *
5 | recursive-include user_guide/templates *
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/ambitioninc/django-user-guide)
2 | # Django User Guide
3 |
4 |
5 | Django User Guide is a `django>=1.6` app that shows configurable, self-contained HTML guides to users. Showing a guide to all of your users is as easy as
6 | creating a `Guide` object and linking them to your users. Use the convenient `{% user_guide %}` template tag where you want guides to appear and Django User Guide does the rest. When a user visits a page containing the template tag, they are greeted with relevant guides. Django User Guide decides what guide(s) a user needs to see and displays them in a modal window with controls for cycling through those guides. Django User Guide tracks plenty of meta-data: creation times, guide importance, if the guide has been finished by specific users, finished times, etc.
7 |
8 | ## Table of Contents
9 | 1. [Installation](#installation)
10 | 1. [Guide](#guide)
11 | 1. [GuideInfo](#guide-info)
12 | 1. [Settings](#settings)
13 | 1. [Finishing Criteria](#finishing-criteria)
14 | 1. [Putting It All Together](#putting-it-all-together)
15 |
16 | ## Installation
17 | To install Django User Guide:
18 |
19 | ```shell
20 | pip install git+https://github.com/ambitioninc/django-user-guide.git@0.1
21 | ```
22 |
23 | Add Django User Guide to your `INSTALLED_APPS` to get started:
24 |
25 | settings.py
26 |
27 | ```python
28 |
29 | # Simply add 'user_guide' to your installed apps.
30 | # Django User Guide relies on several basic django apps.
31 | INSTALLED_APPS = (
32 | 'django.contrib.auth',
33 | 'django.contrib.admin',
34 | 'django.contrib.sites',
35 | 'django.contrib.sessions',
36 | 'django.contrib.messages',
37 | 'django.contrib.staticfiles',
38 | 'django.contrib.contenttypes',
39 | 'user_guide',
40 | )
41 | ```
42 |
43 | Make sure Django's CsrfViewMiddleware is enabled:
44 |
45 | settings.py
46 |
47 | ```python
48 | MIDDLEWARE_CLASSES = (
49 | 'django.middleware.csrf.CsrfViewMiddleware',
50 | )
51 | ```
52 |
53 | Add Django User Guide's urls to your project:
54 |
55 | urls.py
56 |
57 | ```python
58 | from django.conf.urls import include, patterns, url
59 |
60 | urlpatterns = patterns(
61 | url(r'^user-guide/', include('user_guide.urls')),
62 | )
63 | ```
64 |
65 | ## Guide
66 |
67 | First you will need to create one or more `Guide` objects. A `Guide` object consists of:
68 |
69 | #### guide_name (required, max_length=64, unique)
70 |
71 | This is a semantic, unique identifier for a `Guide`. Allows for easy identification and targeted filtering.
72 |
73 | #### html
74 |
75 | The markup for the `Guide`. Use this field to communicate with your users in a meaningful way.
76 | Note that the data in this field is output with `{% html|safe %}`, so it would be a bad idea to put untrusted data in it. The html field automatically replaces `{static}` within the html with the value of `settings.STATIC_URL` for convenience.
77 |
78 | #### guide_tag (default='all')
79 |
80 | A custom tag for grouping several guides together. Specifically designed to be used for filtering. If you had `my_guide_tag_list = ['welcome', 'onboarding']` in your context, you would use `{% user_guide guide_tags=my_guide_tag_list %}` to show users all guides with tags 'welcome' and 'onboard' specifically.
81 |
82 | #### guide_importance (default=0)
83 |
84 | A number representing the importance of the `Guide`. `Guide` objects with a higher `guide_importance` are shown first. `Guide` objects are always sorted by `guide_importance`, then `creation_time`.
85 |
86 | #### guide_type (default='Window')
87 |
88 | The rendering type for the `Guide`. Only a modal window is currently supported. Future support for positioned coach-marks and other elements is planned.
89 |
90 | #### creation_time (auto_now_add=True)
91 |
92 | Stores the current datetime when a `Guide` is created.
93 |
94 |
95 | ## Guide Usage
96 |
97 | ```python
98 | from user_guide.models import Guide
99 |
100 | Guide.objects.create(
101 | html='
Hello Guide!
',
102 | guide_name='First Guide',
103 | guide_tag='onboarding',
104 | guide_importance=5
105 | )
106 | ```
107 |
108 | ## GuideInfo
109 |
110 | The next step is creating `GuideInfo` objects. These are used to connect a `Guide` to a `User`. A `GuideInfo` object consists of:
111 |
112 | #### user (required)
113 |
114 | The `User` that should see a `Guide`. Any number of `User` objects can be pointed to a `Guide`.
115 |
116 | #### guide (required)
117 |
118 | The `Guide` to show a `User`. Any number of `Guide` objects can be tied to a `User`.
119 |
120 | #### is_finished (default=False)
121 |
122 | Marked true when the `User` has completed some [finishing criteria](#finishing-criteria). By default, users are only shown `Guide` objects with `is_finished=False`.
123 |
124 | #### finished_time
125 |
126 | When the [finishing criteria](#finishing-criteria) is met, the value of `datetime.utcnow()` is stored.
127 |
128 | ## GuideInfo Usage
129 |
130 | ```python
131 | from django.contrib.auth.models import User
132 |
133 | from user_guide.models import Guide, GuideInfo
134 |
135 | # Show the guide with the name 'First Guide' to the given user
136 | guide = Guide.objects.get(guide_name='First Guide')
137 | user = User.objects.get(id=1)
138 |
139 | GuideInfo.objects.create(guide=guide, user=user)
140 | ```
141 |
142 | ## Settings
143 |
144 | Django User Guide has several configurations that can finely tune your user guide experience.
145 |
146 | #### USER_GUIDE_SHOW_MAX (default=10)
147 |
148 | The maximum number of guides to show for a single page load. If a user had 20 possible guides and `USER_GUIDE_SHOW_MAX` was set to 5, only the first 5 (based on `guide_importance` and `creation_time`) guides would be shown.
149 |
150 | #### USER_GUIDE_CSS_URL (default=None)
151 |
152 | The path to a custom style sheet for Django User Guides. Added as a `link` tag immediately after the [django-user-guide.css](user_guide/static/user_guide/build/django-user-guide.css) source. If omitted, no extra style sheets are included. See [django-user-guide.css](user_guide/static/user_guide/build/django-user-guide.css) for class names to override.
153 |
154 | #### USER_GUIDE_JS_URL (default=None)
155 |
156 | The path to a custom script for Django User Guides. Added as a `script` tag immediately after the [django-user-guide.js](user_guide/static/user_guide/build/django-user-guide.js) source. If omitted, no extra scripts are included. See [django-user-guide.js](user_guide/static/user_guide/build/django-user-guide.js) for methods to override.
157 |
158 | #### USER_GUIDE_USE_COOKIES (default=False)
159 |
160 | True to use cookies instead of marking the guides as seen in the database. Useful for showing guides to multiple shared Django users.
161 |
162 | ## Settings Usage
163 |
164 | settings.py
165 |
166 | ```python
167 | # Django User Guide settings
168 | USER_GUIDE_SHOW_MAX = 5
169 | USER_GUIDE_USE_COOKIES = True
170 | USER_GUIDE_CSS_URL = 'absolute/path/to/style.css'
171 | USER_GUIDE_JS_URL = 'absolute/path/to/script.js'
172 | ```
173 |
174 | ## Finishing criteria
175 |
176 | Finishing criteria are rules to marking a guide as finished. By default, they only need to press the 'next' or 'done' button on a guide. This behavior can be overridden by creating a custom script and adding it to the USER_GUIDE_JS_URL setting. The custom script only needs to override the `window.DjangoUserGuide.isFinished` method.
177 |
178 | custom-script.js
179 |
180 | ```js
181 | /**
182 | * @override isFinished
183 | * Only allows guides to be marked finished on Mondays.
184 | * @param {HTMLDivElement} item - The item to check.
185 | * @returns {Boolean}
186 | */
187 | window.DjangoUserGuide.prototype.isFinished = function isFinished(item) {
188 | if ((new Date()).getDay() === 1) {
189 | return true;
190 | }
191 | return false;
192 | };
193 | ```
194 |
195 | settings.py
196 |
197 | ```python
198 | USER_GUIDE_JS_URL = 'path/to/custom-script.js'
199 | ```
200 |
201 | ## Putting It All Together
202 |
203 | Assuming you have created some `Guide` and `GuideInfo` objects, this is how you would
204 | show your users their relevant guides.
205 |
206 | views.py
207 |
208 | ```python
209 | from django.views.generic import TemplateView
210 |
211 | class CoolView(TemplateView):
212 | template_name = 'cool_project/cool_template.html'
213 |
214 | def get_context_data(self, **kwargs):
215 | context = super(CoolView, self).get_context_data(**kwargs)
216 | context['cool_guide_tags'] = ['general', 'welcome', 'onboarding']
217 | return context
218 | ```
219 |
220 | templates/cool_project/cool_template.html
221 |
222 | ```html
223 |
224 |
225 |
226 |
227 | Hello User Guides
228 |
229 |
230 | {% load user_guide_tags %}
231 | {% user_guide guide_tags=cool_guide_tags %}
232 |
Hello User Guides!
233 |
234 |
235 | ```
236 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import sys
3 |
4 | from settings import configure_settings
5 |
6 |
7 | if __name__ == '__main__':
8 | configure_settings()
9 |
10 | from django.core.management import execute_from_command_line
11 |
12 | execute_from_command_line(sys.argv)
13 |
--------------------------------------------------------------------------------
/publish.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 |
3 | subprocess.call(['pip', 'install', 'wheel'])
4 | subprocess.call(['python', 'setup.py', 'clean', '--all'])
5 | subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel', 'upload'])
6 |
--------------------------------------------------------------------------------
/run_tests.py:
--------------------------------------------------------------------------------
1 | """
2 | Provides the ability to run test on a standalone Django app.
3 | """
4 | import sys
5 | from optparse import OptionParser
6 |
7 | import django
8 |
9 | from settings import configure_settings
10 |
11 | # Configure the default settings and setup django
12 | configure_settings()
13 | django.setup()
14 |
15 | # Django nose must be imported here since it depends on the settings being configured
16 | from django_nose import NoseTestSuiteRunner
17 |
18 |
19 | def run_tests(*test_args, **kwargs):
20 | if not test_args:
21 | test_args = ['user_guide']
22 |
23 | kwargs.setdefault('interactive', False)
24 |
25 | test_runner = NoseTestSuiteRunner(**kwargs)
26 |
27 | failures = test_runner.run_tests(test_args)
28 | sys.exit(failures)
29 |
30 |
31 | if __name__ == '__main__':
32 | parser = OptionParser()
33 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int)
34 | (options, args) = parser.parse_args()
35 |
36 | run_tests(*args, **options.__dict__)
37 |
--------------------------------------------------------------------------------
/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.conf import settings
4 |
5 |
6 | def configure_settings():
7 | if not settings.configured:
8 | # Determine the database settings depending on if a test_db var is set in CI mode or not
9 | test_db = os.environ.get('DB', None)
10 | if test_db is None:
11 | db_config = {
12 | 'ENGINE': 'django.db.backends.postgresql_psycopg2',
13 | 'NAME': 'ambition_dev',
14 | 'USER': 'ambition_dev',
15 | 'PASSWORD': 'ambition_dev',
16 | 'HOST': 'localhost'
17 | }
18 | elif test_db == 'postgres':
19 | db_config = {
20 | 'ENGINE': 'django.db.backends.postgresql_psycopg2',
21 | 'USER': 'postgres',
22 | 'NAME': 'user_guide',
23 | }
24 | else:
25 | raise RuntimeError('Unsupported test DB {0}'.format(test_db))
26 |
27 | settings.configure(
28 | DATABASES={
29 | 'default': db_config,
30 | },
31 | MIDDLEWARE_CLASSES=(
32 | 'django.middleware.csrf.CsrfViewMiddleware',
33 | ),
34 | INSTALLED_APPS=(
35 | 'django.contrib.auth',
36 | 'django.contrib.contenttypes',
37 | 'django.contrib.sessions',
38 | 'django.contrib.admin',
39 | 'user_guide',
40 | 'user_guide.tests',
41 | ),
42 | ROOT_URLCONF='user_guide.urls',
43 | DEBUG=False,
44 | USER_GUIDE_SHOW_MAX=5,
45 | USER_GUIDE_CSS_URL='custom-style.css',
46 | USER_GUIDE_JS_URL='custom-script.js',
47 | STATIC_URL='/static/',
48 | SECRET_KEY='somethignmadeup',
49 | TEST_RUNNER='django_nose.NoseTestSuiteRunner',
50 | NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'],
51 | )
52 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 120
3 | exclude = build,docs,venv,env,*.egg,migrations,south_migrations
4 | max-complexity = 10
5 | ignore = E402
6 |
7 | [bdist_wheel]
8 | universal = 1
9 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import re
2 | from setuptools import setup, find_packages
3 |
4 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215)
5 | import multiprocessing
6 | assert multiprocessing
7 |
8 |
9 | def get_version():
10 | """
11 | Extracts the version number from the version.py file.
12 | """
13 | VERSION_FILE = 'user_guide/version.py'
14 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M)
15 | if mo:
16 | return mo.group(1)
17 | else:
18 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE))
19 |
20 |
21 | setup(
22 | name='django-user-guide',
23 | version=get_version(),
24 | description='Show configurable HTML guides to users.',
25 | long_description=open('README.md').read(),
26 | url='https://github.com/ambitioninc/django-user-guide',
27 | author='Jeff McRiffey',
28 | author_email='jeff.mcriffey@ambition.com',
29 | packages=find_packages(),
30 | classifiers=[
31 | 'Programming Language :: Python',
32 | 'Programming Language :: Python :: 2.7',
33 | 'Programming Language :: Python :: 3.4',
34 | 'Programming Language :: Python :: 3.5',
35 | 'Intended Audience :: Developers',
36 | 'License :: OSI Approved :: MIT License',
37 | 'Operating System :: OS Independent',
38 | 'Framework :: Django',
39 | 'Framework :: Django :: 1.8',
40 | 'Framework :: Django :: 1.9',
41 | ],
42 | license='MIT',
43 | install_requires=[
44 | 'Django>=1.8',
45 | ],
46 | tests_require=[
47 | 'psycopg2',
48 | 'django-nose',
49 | 'django-dynamic-fixture',
50 | 'mock>=1.0.1',
51 | 'freezegun'
52 | ],
53 | test_suite='run_tests.run_tests',
54 | include_package_data=True,
55 | )
56 |
--------------------------------------------------------------------------------
/user_guide/__init__.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 | from .version import __version__
3 |
4 | default_app_config = 'user_guide.apps.UserGuideConfig'
5 |
--------------------------------------------------------------------------------
/user_guide/admin.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 | from user_guide.models import Guide, GuideInfo
4 |
5 | if 'django.contrib.admin' in settings.INSTALLED_APPS:
6 | from django.contrib import admin
7 |
8 | class GuideAdmin(admin.ModelAdmin):
9 | list_display = ('guide_name', 'guide_tag', 'guide_importance', 'creation_time')
10 |
11 | class GuideInfoAdmin(admin.ModelAdmin):
12 | list_display = ('user', 'guide_name', 'is_finished', 'finished_time')
13 |
14 | def guide_name(self, obj):
15 | return obj.guide.guide_name
16 |
17 | admin.site.register(Guide, GuideAdmin)
18 | admin.site.register(GuideInfo, GuideInfoAdmin)
19 |
--------------------------------------------------------------------------------
/user_guide/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UserGuideConfig(AppConfig):
5 | name = 'user_guide'
6 | verbose_name = 'Django User Guide'
7 |
--------------------------------------------------------------------------------
/user_guide/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import models, migrations
5 | from django.conf import settings
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='Guide',
17 | fields=[
18 | ('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)),
19 | ('html', models.TextField()),
20 | ('guide_type', models.CharField(max_length=16, default='WINDOW', choices=[('WINDOW', 'Window')])),
21 | ('guide_name', models.CharField(unique=True, max_length=64)),
22 | ('guide_tag', models.TextField(default='all')),
23 | ('guide_importance', models.IntegerField(default=0)),
24 | ('creation_time', models.DateTimeField(auto_now_add=True)),
25 | ],
26 | options={
27 | },
28 | bases=(models.Model,),
29 | ),
30 | migrations.CreateModel(
31 | name='GuideInfo',
32 | fields=[
33 | ('id', models.AutoField(primary_key=True, auto_created=True, verbose_name='ID', serialize=False)),
34 | ('is_finished', models.BooleanField(default=False)),
35 | ('finished_time', models.DateTimeField(null=True, blank=True)),
36 | ('guide', models.ForeignKey(to='user_guide.Guide')),
37 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
38 | ],
39 | options={
40 | 'ordering': ['-guide__guide_importance', 'guide__creation_time'],
41 | },
42 | bases=(models.Model,),
43 | ),
44 | migrations.AlterUniqueTogether(
45 | name='guideinfo',
46 | unique_together=set([('user', 'guide')]),
47 | ),
48 | ]
49 |
--------------------------------------------------------------------------------
/user_guide/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambitioninc/django-user-guide/803258e4e31a1db351d5c050ce655c354e4d7888/user_guide/migrations/__init__.py
--------------------------------------------------------------------------------
/user_guide/models.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.db import models
3 | import six
4 |
5 |
6 | @six.python_2_unicode_compatible
7 | class Guide(models.Model):
8 | """
9 | Describes a guide to be tied to any number of users.
10 | """
11 | # The html that should be rendered in a guide.
12 | html = models.TextField()
13 | # The type of guide to render. The only guide type currently supported is 'Window.'
14 | guide_type = models.CharField(max_length=16, choices=(('WINDOW', 'Window'),), default='WINDOW')
15 | # The name of the guide. Mainly for display purposes.
16 | guide_name = models.CharField(max_length=64, unique=True)
17 | # A tag for the given guide. For filtering purposes.
18 | guide_tag = models.TextField(default='all')
19 | # An ordering parameter for the guide. To show a guide first, give it a larger guide_importance.
20 | guide_importance = models.IntegerField(default=0)
21 | # The creation time of the guide.
22 | creation_time = models.DateTimeField(auto_now_add=True)
23 |
24 | def __str__(self):
25 | return str(self.guide_name)
26 |
27 |
28 | class GuideInfo(models.Model):
29 | """
30 | Ties a guide to a user.
31 | """
32 | # The user that should see this guide.
33 | user = models.ForeignKey(settings.AUTH_USER_MODEL)
34 | # The guide that should be shown to the user.
35 | guide = models.ForeignKey(Guide)
36 | # Has the guide been seen by a user?
37 | is_finished = models.BooleanField(default=False)
38 | # Save the finished time for convenience
39 | finished_time = models.DateTimeField(null=True, blank=True)
40 |
41 | class Meta:
42 | unique_together = ('user', 'guide')
43 | ordering = ['-guide__guide_importance', 'guide__creation_time']
44 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/.jscs.json:
--------------------------------------------------------------------------------
1 | {
2 | "requireCurlyBraces": ["if", "else", "for", "while", "do"],
3 | "requireSpaceAfterKeywords": ["if", "else", "for", "while", "do", "switch", "return"],
4 | "requireSpacesInFunctionExpression": {
5 | "beforeOpeningCurlyBrace": true
6 | },
7 | "disallowSpacesInFunctionExpression": {
8 | "beforeOpeningRoundBrace": true
9 | },
10 | "requireMultipleVarDecl": true,
11 | "disallowLeftStickedOperators": ["?", "/", "*", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="],
12 | "disallowRightStickedOperators": ["?", "/", "*", ":", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="],
13 | "requireRightStickedOperators": ["!"],
14 | "requireLeftStickedOperators": [","],
15 | "disallowKeywords": ["with"],
16 | "disallowMultipleLineBreaks": true,
17 | "disallowKeywordsOnNewLine": ["else"],
18 | "requireLineFeedAtFileEnd": true
19 | }
20 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "camelcase": true,
3 | "indent": 4,
4 | "trailing": true,
5 | "quotmark": "single",
6 | "maxlen": 120,
7 | "unused": true,
8 | "undef": true,
9 | "sub": true,
10 | "browser": true,
11 | "node": true,
12 | "jquery": true,
13 | "globals": {
14 | "jasmine": true,
15 | "runs": true,
16 | "waitsFor": true,
17 | "it": true,
18 | "describe": true,
19 | "beforeEach": true,
20 | "afterEach": true,
21 | "expect": true,
22 | "spyOn": true,
23 | "xdescribe": true,
24 | "xit": true,
25 | "xexpect": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/Gruntfile.js:
--------------------------------------------------------------------------------
1 | module.exports = function(grunt) {
2 | var jsFiles = ['build/*.js'],
3 | testFiles = ['tests/*.js'],
4 | lintFiles = ['Gruntfile.js'].concat(jsFiles, testFiles),
5 | npmTasks = [
6 | 'grunt-contrib-jshint',
7 | 'grunt-jscs-checker',
8 | 'grunt-contrib-jasmine'
9 | ];
10 |
11 | grunt.initConfig({
12 | pkg: grunt.file.readJSON('package.json'),
13 | jshint: {
14 | all: lintFiles,
15 | options: {
16 | jshintrc: '.jshintrc'
17 | }
18 | },
19 | jscs: {
20 | all: lintFiles,
21 | options: {
22 | config: '.jscs.json'
23 | }
24 | },
25 | jasmine: {
26 | src: jsFiles,
27 | options: {
28 | specs: testFiles,
29 | styles: ['build/django-user-guide.css'],
30 | template: require('grunt-template-jasmine-istanbul'),
31 | templateOptions: {
32 | coverage: '.tmp/coverage.json',
33 | report: [{type: 'text-summary'}, {type: 'html'}],
34 | thresholds: {
35 | lines: 100,
36 | statements: 100,
37 | branches: 100,
38 | functions: 100
39 | }
40 | }
41 | }
42 | }
43 | });
44 |
45 | npmTasks.forEach(function(task) {
46 | grunt.loadNpmTasks(task);
47 | });
48 |
49 | grunt.registerTask('test', ['jshint', 'jscs', 'jasmine']);
50 | };
51 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/build/django-user-guide.css:
--------------------------------------------------------------------------------
1 | .django-user-guide {
2 | display: none;
3 | position: absolute;
4 | top: 0;
5 | left: 0;
6 | height: 100%;
7 | width: 100%;
8 | z-index: 99999;
9 | }
10 |
11 | .django-user-guide-mask {
12 | position: absolute;
13 | top: 0;
14 | left: 0;
15 | height: 100%;
16 | width: 100%;
17 | background-color: rgba(0, 0, 0, 0.5);
18 | }
19 |
20 | .django-user-guide-item {
21 | position: absolute;
22 | top: 20px;
23 | display: none;
24 | }
25 |
26 | .django-user-guide-window {
27 | border-radius: 5px;
28 | background-color: #fff;
29 | position: absolute;
30 | height: 350px;
31 | width: 350px;
32 | margin: auto;
33 | top: 100px;
34 | left: 0;
35 | right: 0;
36 | padding: 20px;
37 | }
38 |
39 | .django-user-guide-close-div {
40 | border-radius: 5px;
41 | height: 20px;
42 | width: 20px;
43 | position: absolute;
44 | right: 10px;
45 | top: 10px;
46 | font-family: sans-serif;
47 | font-size: 20px;
48 | text-align: center;
49 | cursor: pointer;
50 | }
51 |
52 | .django-user-guide-window-nav {
53 | padding-top: 20px;
54 | width: 100%;
55 | }
56 |
57 | .django-user-guide-html-wrapper {
58 | position: relative;
59 | width: 100%;
60 | height: 100%;
61 | }
62 |
63 | .django-user-guide-btn {
64 | display: none;
65 | }
66 |
67 | .django-user-guide-counter {
68 | font-style: italic;
69 | position: absolute;
70 | bottom: 20px;
71 | left: 20px;
72 | }
73 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/build/django-user-guide.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | 'use strict';
3 |
4 | /**
5 | * @constructor
6 | * Sets the passed csrf token name from the template.
7 | */
8 | window.DjangoUserGuide = function DjangoUserGuide(config) {
9 | config = config || {};
10 | this.useCookies = config.useCookies;
11 | this.finishedItems = {};
12 | this.itemIndex = 0;
13 | };
14 |
15 | window.DjangoUserGuide.prototype = {
16 |
17 | /**
18 | * @method getGuide
19 | * Gets the entire user guide div.
20 | * @returns {HTMLDivElement}
21 | */
22 | getGuide: function getGuide() {
23 | if (!this.guide) {
24 | this.guide = document.querySelector('.django-user-guide');
25 | }
26 | return this.guide;
27 | },
28 |
29 | /**
30 | * @method getGuideMask
31 | * Gets the guide mask.
32 | * @returns {HTMLDivElement}
33 | */
34 | getGuideMask: function getGuideMask() {
35 | if (!this.guideMask) {
36 | this.guideMask = document.querySelector('.django-user-guide-mask');
37 | }
38 | return this.guideMask;
39 | },
40 |
41 | /**
42 | * @method getItems
43 | * Gets the guide's html guide items.
44 | * @returns {HTMLDivElement[]}
45 | */
46 | getItems: function getItems() {
47 | if (!this.items) {
48 | var self = this,
49 | items = Array.prototype.slice.call(
50 | document.querySelectorAll('.django-user-guide-item')
51 | );
52 |
53 | if (self.useCookies) { //only show items that do not have a cookie
54 | self.items = [];
55 |
56 | items.forEach(function(item) {
57 | var guideId = item.getAttribute('data-guide'),
58 | cookie = self.getCookie('django-user-guide-' + guideId);
59 |
60 | if (!cookie) {
61 | self.items.push(item);
62 | }
63 | });
64 | } else { //show all the items
65 | self.items = items;
66 | }
67 | }
68 | return this.items;
69 | },
70 |
71 | /**
72 | * @method getBackBtn
73 | * Gets the guide's back button.
74 | * @returns {HTMLButtonElement}
75 | */
76 | getBackBtn: function getBackBtn() {
77 | if (!this.backBtn) {
78 | this.backBtn = document.querySelector('.django-user-guide-back-btn');
79 | }
80 | return this.backBtn;
81 | },
82 |
83 | /**
84 | * @method getNextBtn
85 | * Gets the guide's next button.
86 | * @returns {HTMLButtonElement}
87 | */
88 | getNextBtn: function getNextBtn() {
89 | if (!this.nextBtn) {
90 | this.nextBtn = document.querySelector('.django-user-guide-next-btn');
91 | }
92 | return this.nextBtn;
93 | },
94 |
95 | /**
96 | * @method getDoneBtn
97 | * Gets the guide's done button.
98 | * @returns {HTMLButtonElement}
99 | */
100 | getDoneBtn: function getNextBtn() {
101 | if (!this.doneBtn) {
102 | this.doneBtn = document.querySelector('.django-user-guide-done-btn');
103 | }
104 | return this.doneBtn;
105 | },
106 |
107 | /**
108 | * @method getCloseDiv
109 | * Gets the guide's close div.
110 | * @returns {HTMLDivElement}
111 | */
112 | getCloseDiv: function getCloseDiv() {
113 | if (!this.closeDiv) {
114 | this.closeDiv = document.querySelector('.django-user-guide-close-div');
115 | }
116 | return this.closeDiv;
117 | },
118 |
119 | /**
120 | * @method getCounterSpan
121 | * Gets the guide's counter span.
122 | * @returns {HTMLSpanElement}
123 | */
124 | getCounterSpan: function getCounterSpan() {
125 | if (!this.counterDiv) {
126 | this.counterDiv = document.querySelector('.django-user-guide-counter span');
127 | }
128 | return this.counterDiv;
129 | },
130 |
131 | /**
132 | * @method getCookie
133 | * Gets the cookie value for a given cookie name.
134 | * @param {String} name - The name of the cookie to get.
135 | */
136 | getCookie: function getCookie(name) {
137 | return document.cookie.match(new RegExp(name + '=([^;]*)'));
138 | },
139 |
140 | /**
141 | * @method getCsrfToken
142 | * Gets the csrf token as set by the cookie.
143 | * @returns {String}
144 | */
145 | getCsrfToken: function getCsrfToken() {
146 | var input = this.getGuide().querySelector('input[name=csrfmiddlewaretoken]');
147 | return input ? input.value : '';
148 | },
149 |
150 | /**
151 | * @type {Object}
152 | * Objects that should be shown inline-block instead of block.
153 | * Add more items here as needed.
154 | */
155 | inlineBlockItems: {
156 | 'BUTTON': true
157 | },
158 |
159 | /**
160 | * @method hideEl
161 | * Hides an item.
162 | * @param {HTMLElement} item - The item to hide.
163 | */
164 | hideEl: function hideEl(item) {
165 | item.style.display = 'none';
166 | },
167 |
168 | /**
169 | * @method showEl
170 | * Shows an item. Sets the display property to 'block' unless it appears in {@link inlineBlockItems}.
171 | * @param {HTMLElement} item - The item to show.
172 | */
173 | showEl: function showEl(item) {
174 | if (this.inlineBlockItems.hasOwnProperty(item.tagName)) {
175 | item.style.display = 'inline-block';
176 | } else {
177 | item.style.display = 'block';
178 | }
179 | },
180 |
181 | /**
182 | * @method showHideBtns
183 | * Decides which buttons should be visible, then shows/hides them accordingly.
184 | */
185 | showHideBtns: function showHideBtns() {
186 | if (!this.getItems()[this.itemIndex + 1]) { //we have reached the end
187 |
188 | //there might not be a previous guide
189 | if (this.getItems().length > 1) {
190 | this.showEl(this.getBackBtn());
191 | } else {
192 | this.hideEl(this.getBackBtn());
193 | }
194 |
195 | this.hideEl(this.getNextBtn());
196 | this.showEl(this.getDoneBtn());
197 | } else if (!this.getItems()[this.itemIndex - 1]) { //we are at the start
198 | this.hideEl(this.getBackBtn());
199 | this.hideEl(this.getDoneBtn());
200 | this.showEl(this.getNextBtn());
201 | } else { //we are in the middle
202 | this.hideEl(this.getDoneBtn());
203 | this.showEl(this.getBackBtn());
204 | this.showEl(this.getNextBtn());
205 | }
206 | },
207 |
208 | /**
209 | * @method showNextGuide
210 | * Shows the next guide in the list of {@link items}.
211 | */
212 | showNextGuide: function showNextGuide() {
213 | var curr = this.getItems()[this.itemIndex],
214 | next = this.getItems()[this.itemIndex + 1];
215 |
216 | if (curr && next) {
217 | this.updateItemIndex(1);
218 | this.hideEl(curr);
219 | this.showEl(next);
220 | this.showHideBtns();
221 | }
222 | },
223 |
224 | /**
225 | * @method showPrevGuide
226 | * Shows the previous guide in the list of {@link items}.
227 | */
228 | showPrevGuide: function showPrevGuide() {
229 | var curr = this.getItems()[this.itemIndex],
230 | prev = this.getItems()[this.itemIndex - 1];
231 |
232 | if (curr && prev) {
233 | this.updateItemIndex(-1);
234 | this.hideEl(curr);
235 | this.showEl(prev);
236 | this.showHideBtns();
237 | }
238 | },
239 |
240 | /**
241 | * @method updateItemIndex
242 | * Updates the item index and refreshes the tool tip numbers.
243 | * @param {Number} num - The number to incread the {@link itemIndex} by.
244 | */
245 | updateItemIndex: function updateItemIndex(num) {
246 | this.itemIndex += num;
247 |
248 | this.getCounterSpan().innerHTML = 'Tip ' + (this.itemIndex + 1) + ' of ' + (this.getItems().length);
249 | },
250 |
251 | /**
252 | * @method post
253 | * Makes a POST request to the given url, with the given data.
254 | * @param {String} url - The url to POST.
255 | * @param {Object} data - The data to POST.
256 | */
257 | post: function post(url, data) {
258 | var req = new XMLHttpRequest(),
259 | csrfToken = this.getCsrfToken(),
260 | encoded = Object.keys(data).map(function(key) {
261 | return encodeURIComponent(key) + '=' + encodeURIComponent(data[key]);
262 | }).join('&');
263 |
264 | //open the request
265 | req.open('POST', url, true);
266 |
267 | if (csrfToken) { //see if the csrf token should be set
268 | req.setRequestHeader('X-CSRFToken', csrfToken);
269 | }
270 |
271 | //send the data
272 | req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
273 | req.send(encoded);
274 | },
275 |
276 | /**
277 | * @method saveCookie
278 | * Saves a cookie for the given user guide.
279 | * @param {String} name - The name of the cookie to save.
280 | * @param {Boolean} value - The value of the cookie to save.
281 | */
282 | saveCookie: function saveCookie(name, value) {
283 | document.cookie = name + '=' + value + ';path=/;';
284 | },
285 |
286 | /**
287 | * @method isFinished
288 | * Describes if a particular guide has been finished. Always returns true by default.
289 | * Override this method for custom finish criteria logic.
290 | * Return true to allow the {@link finishedItem} method to proceed.
291 | * @param {HTMLDivElement} item - The item to check.
292 | * @returns {Boolean}
293 | */
294 | isFinished: function isFinished() {
295 | return true;
296 | },
297 |
298 | /**
299 | * @method finishItem
300 | * Marks an item finished and calls {@link post}.
301 | * @param {HTMLDivElement} item - The item to mark finished.
302 | */
303 | finishItem: function finishItem(item) {
304 | var guideId = +(item ? item.getAttribute('data-guide') : null);
305 |
306 | if (guideId && !this.finishedItems[guideId] && this.isFinished(item)) {
307 | this.finishedItems[guideId] = true;
308 |
309 | if (this.useCookies) { //save a cookie for the finished guide
310 | this.saveCookie('django-user-guide-' + guideId, 'true');
311 | } else { //make a post request to mark the guide finished
312 | this.post('/user-guide/seen/', {
313 | 'id': guideId,
314 | 'is_finished': true
315 | });
316 | }
317 | }
318 |
319 | return item;
320 | },
321 |
322 | /**
323 | * @method show
324 | * Shows the entire guide.
325 | */
326 | show: function show() {
327 | if (this.getItems().length) { //we have some guides
328 | this.addListeners();
329 | this.updateItemIndex(0);
330 | this.showEl(this.getGuide());
331 | this.showEl(this.getItems()[0]);
332 | this.showHideBtns();
333 | }
334 | },
335 |
336 | /**
337 | * @method addListeners
338 | * Adds listeners to the various guide components.
339 | */
340 | addListeners: function addListeners() {
341 | this.getBackBtn().onclick = this.onBackClick.bind(this);
342 | this.getNextBtn().onclick = this.onNextClick.bind(this);
343 | this.getDoneBtn().onclick = this.onDoneClick.bind(this);
344 | this.getCloseDiv().onclick = this.onCloseClick.bind(this);
345 | this.getGuideMask().onclick = this.onMaskClick.bind(this);
346 | },
347 |
348 | /**
349 | * @method onCloseClick
350 | * Handler for clicking on the guide mask.
351 | */
352 | onMaskClick: function onMaskClick(evt) {
353 | if (evt.target.className === 'django-user-guide-mask') {
354 | this.hideEl(this.getGuide());
355 | }
356 | evt.stopPropagation();
357 | },
358 |
359 | /**
360 | * @method onCloseClick
361 | * Handler for closing the guide window.
362 | */
363 | onCloseClick: function onCloseClick() {
364 | this.hideEl(this.getGuide());
365 | },
366 |
367 | /**
368 | * @method onDoneClick
369 | * Handler for finishing the guide window.
370 | */
371 | onDoneClick: function onDoneClick() {
372 | this.finishItem(this.getItems()[this.itemIndex]);
373 | this.hideEl(this.getGuide());
374 | },
375 |
376 | /**
377 | * @method onNextClick
378 | * Handler for showing the next guide.
379 | */
380 | onNextClick: function onNextClick() {
381 | this.finishItem(this.getItems()[this.itemIndex]);
382 | this.showNextGuide();
383 | },
384 |
385 | /**
386 | * @method onBackClick
387 | * Handler for showing the previous guide.
388 | */
389 | onBackClick: function onBackClick() {
390 | this.showPrevGuide();
391 | },
392 |
393 | /**
394 | * @method run
395 | * Runs the entire process for showing the guide window.
396 | */
397 | run: function run() {
398 | this.show();
399 | }
400 | };
401 | })();
402 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "user_guide",
3 | "version": "0.0.1",
4 | "description": "Static files for django-user-guide.",
5 | "main": "Gruntfile.js",
6 | "scripts": {
7 | "test": "grunt test"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/ambitioninc/django-user-guide.git"
12 | },
13 | "keywords": [
14 | "django"
15 | ],
16 | "author": "Jeff McRiffey",
17 | "license": "MIT",
18 | "bugs": {
19 | "url": "https://github.com/ambitioninc/django-user-guide/issues"
20 | },
21 | "homepage": "https://github.com/ambitioninc/django-user-guide",
22 | "devDependencies": {
23 | "grunt-cli": "^0.1.13",
24 | "grunt": "^0.4.4",
25 | "grunt-contrib-jasmine": "^0.6.3",
26 | "grunt-contrib-jshint": "^0.9.2",
27 | "grunt-jscs-checker": "^0.4.1",
28 | "grunt-template-jasmine-istanbul": "^0.3.0"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/user_guide/static/user_guide/tests/django-user-guide.js:
--------------------------------------------------------------------------------
1 | describe('DjangoUserGuide', function() {
2 | function appendDom(el) {
3 | document.body.appendChild(el);
4 | }
5 |
6 | function removeDom(el) {
7 | document.body.removeChild(el);
8 | }
9 |
10 | function createDom(html) {
11 | var el = document.createElement('div');
12 | el.innerHTML = html;
13 | return el;
14 | }
15 |
16 | function queryAllDom(selector) {
17 | return document.body.querySelectorAll(selector);
18 | }
19 |
20 | function getRenderedStyle(el, prop) {
21 | return getComputedStyle(el).getPropertyValue(prop);
22 | }
23 |
24 | function getFakeEvt(className) {
25 | return {
26 | target: {
27 | className: className || ''
28 | },
29 | stopPropagation: function() {}
30 | };
31 | }
32 |
33 | var guideHtml = [
34 | '
',
75 | ].join('\n');
76 |
77 | //set a custom cookie
78 | document.cookie = 'csrf-token-custom=123456789;path=/;';
79 |
80 | //add the guide items to the dom
81 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = guides;
82 |
83 | //mock async methods
84 | spyOn(dug, 'post');
85 |
86 | //run the user guide
87 | dug.run();
88 |
89 | //examine the result
90 | items = queryAllDom('.django-user-guide-item');
91 | btns = queryAllDom('button');
92 | cont = queryAllDom('.django-user-guide');
93 | counter = queryAllDom('.django-user-guide-counter span')[0];
94 |
95 | expect(getRenderedStyle(items[0], 'display')).toBe('block'); //should show the first item
96 | expect(getRenderedStyle(items[1], 'display')).toBe('none'); //should NOT show the second item
97 | expect(getRenderedStyle(items[2], 'display')).toBe('none'); //should NOT show the third item
98 | expect(getRenderedStyle(btns[0], 'display')).toBe('none'); //should NOT show the back button
99 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button
100 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should NOT show the done button
101 | expect(counter.innerHTML).toBe('Tip 1 of 3');
102 |
103 | //click the next button
104 | dug.onNextClick();
105 |
106 | expect(dug.post).toHaveBeenCalledWith(
107 | '/user-guide/seen/', {'is_finished': true, id: 1}
108 | ); //should make a PUT request
109 | expect(getRenderedStyle(items[0], 'display')).toBe('none'); //should NOT show the first item
110 | expect(getRenderedStyle(items[1], 'display')).toBe('block'); //should show the second item
111 | expect(getRenderedStyle(items[2], 'display')).toBe('none'); //should NOT show the third item
112 | expect(getRenderedStyle(btns[0], 'display')).toBe('inline-block'); //should show the back button
113 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button
114 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should NOT show the done button
115 | expect(counter.innerHTML).toBe('Tip 2 of 3');
116 |
117 | //click the next button
118 | dug.onNextClick();
119 |
120 | expect(dug.post).toHaveBeenCalledWith(
121 | '/user-guide/seen/', {'is_finished': true, id: 2}
122 | ); //should make a PUT request
123 | expect(getRenderedStyle(items[0], 'display')).toBe('none'); //should NOT show the first item
124 | expect(getRenderedStyle(items[1], 'display')).toBe('none'); //should NOT show the second item
125 | expect(getRenderedStyle(items[2], 'display')).toBe('block'); //should show the third item
126 | expect(getRenderedStyle(btns[0], 'display')).toBe('inline-block'); //should show the back button
127 | expect(getRenderedStyle(btns[1], 'display')).toBe('none'); //should NOT show the next button
128 | expect(getRenderedStyle(btns[2], 'display')).toBe('inline-block'); //should show the done button
129 | expect(counter.innerHTML).toBe('Tip 3 of 3');
130 |
131 | //click the back button
132 | dug.onBackClick();
133 |
134 | expect(getRenderedStyle(items[0], 'display')).toBe('none'); //should NOT show the first item
135 | expect(getRenderedStyle(items[1], 'display')).toBe('block'); //should show the second item
136 | expect(getRenderedStyle(items[2], 'display')).toBe('none'); //should NOT show the third item
137 | expect(getRenderedStyle(btns[0], 'display')).toBe('inline-block'); //should show the back button
138 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button
139 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should NOT show the done button
140 | expect(counter.innerHTML).toBe('Tip 2 of 3');
141 |
142 | //close the window
143 | dug.onCloseClick();
144 | expect(getRenderedStyle(cont[0], 'display')).toBe('none');
145 | });
146 |
147 | it('should handle one item', function() {
148 | var dug = new window.DjangoUserGuide(),
149 | items = null,
150 | btns = null,
151 | cont = null,
152 | counter = null,
153 | guides = [
154 | '
Hello guide 23
',
155 | ].join('\n');
156 |
157 | //add the guide items to the dom
158 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = guides;
159 |
160 | //mock async methods
161 | spyOn(dug, 'post');
162 |
163 | //run the user guide
164 | dug.run();
165 |
166 | //examine the result
167 | items = queryAllDom('.django-user-guide-item');
168 | btns = queryAllDom('button');
169 | cont = queryAllDom('.django-user-guide');
170 | counter = queryAllDom('.django-user-guide-counter span')[0];
171 |
172 | expect(getRenderedStyle(items[0], 'display')).toBe('block'); //should show the first item
173 | expect(getRenderedStyle(btns[2], 'display')).toBe('inline-block'); //should show the done button
174 | expect(counter.innerHTML).toBe('Tip 1 of 1');
175 |
176 | //click done on the window
177 | dug.onDoneClick();
178 | expect(dug.post).toHaveBeenCalledWith(
179 | '/user-guide/seen/', {'is_finished': true, id: 23}
180 | ); //should make a PUT request
181 | expect(getRenderedStyle(cont[0], 'display')).toBe('none');
182 | });
183 |
184 | it('should handle cookies instead of posts', function() {
185 | var dug = new window.DjangoUserGuide({
186 | useCookies: true
187 | }),
188 | items = null,
189 | btns = null,
190 | cont = null,
191 | counter = null,
192 | guides = [
193 | '
Hello guide 23
',
194 | '
Hello guide 24
',
195 | ].join('\n');
196 |
197 | //add the guide items to the dom
198 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = guides;
199 |
200 | //mock async methods
201 | spyOn(dug, 'post');
202 |
203 | //run the user guide
204 | dug.run();
205 |
206 | //examine the result
207 | items = queryAllDom('.django-user-guide-item');
208 | btns = queryAllDom('button');
209 | cont = queryAllDom('.django-user-guide');
210 | counter = queryAllDom('.django-user-guide-counter span')[0];
211 |
212 | expect(getRenderedStyle(items[0], 'display')).toBe('block'); //should show the first item
213 | expect(getRenderedStyle(btns[1], 'display')).toBe('inline-block'); //should show the next button
214 | expect(getRenderedStyle(btns[2], 'display')).toBe('none'); //should not show the done button
215 | expect(counter.innerHTML).toBe('Tip 1 of 2');
216 |
217 | //click on the next button
218 | dug.onNextClick();
219 | expect(dug.post).not.toHaveBeenCalled(); //should NOT make a PUT request
220 | expect(dug.getCookie('django-user-guide-23')).not.toBeNull(); //should have set a cookie for guide 23
221 |
222 | //click done on the window
223 | dug.onDoneClick();
224 | expect(dug.post).not.toHaveBeenCalled(); //should NOT make a PUT request
225 | expect(dug.getCookie('django-user-guide-24')).not.toBeNull(); //should have set a cookie for guide 24
226 | expect(getRenderedStyle(cont[0], 'display')).toBe('none');
227 |
228 | //a new user guide using cookies should get no items
229 | dug = new window.DjangoUserGuide({
230 | useCookies: true
231 | });
232 | expect(dug.getItems().length).toBe(0);
233 |
234 | //a new user guide not using cookies should get items
235 | dug = new window.DjangoUserGuide({
236 | useCookies: false
237 | });
238 | expect(dug.getItems().length).toBe(2);
239 |
240 | //clear the cookies
241 | document.cookie = 'django-user-guide-23=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
242 | document.cookie = 'django-user-guide-24=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
243 |
244 | //a new user guide using cookies should get 2 items
245 | dug = new window.DjangoUserGuide({
246 | useCookies: true
247 | });
248 | expect(dug.getItems().length).toBe(2);
249 |
250 | //clean up the the cookies
251 | document.cookie = 'django-user-guide-23=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
252 | document.cookie = 'django-user-guide-24=;path=/;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
253 | });
254 |
255 | it('should handle no items', function() {
256 | var dug = new window.DjangoUserGuide(),
257 | cont = null;
258 |
259 | //mock async methods
260 | spyOn(dug, 'post');
261 |
262 | //run the user guide
263 | dug.run();
264 |
265 | //examine the result
266 | cont = queryAllDom('.django-user-guide');
267 |
268 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); //should not show the guide
269 |
270 | //buttons exist, but don't do anything
271 | expect(dug.getBackBtn()).not.toBeUndefined();
272 | expect(dug.getNextBtn()).not.toBeUndefined();
273 | expect(dug.getDoneBtn()).not.toBeUndefined();
274 | expect(dug.getCloseDiv()).not.toBeUndefined();
275 | expect(dug.getGuideMask()).not.toBeUndefined();
276 | expect(getRenderedStyle(dug.getBackBtn(), 'display')).toBe('none');
277 | expect(getRenderedStyle(dug.getDoneBtn(), 'display')).toBe('none');
278 | expect(getRenderedStyle(dug.getNextBtn(), 'display')).toBe('none');
279 | expect(getRenderedStyle(dug.getGuideMask(), 'display')).toBe('block'); //hidden by parent
280 | expect(getRenderedStyle(dug.getCloseDiv(), 'display')).toBe('block'); //hidden by parent
281 |
282 | //none of the events should cause trouble
283 | dug.onBackClick();
284 | dug.onNextClick();
285 | dug.onDoneClick();
286 | dug.onCloseClick();
287 | dug.onMaskClick(getFakeEvt());
288 | dug.onMaskClick(getFakeEvt('django-user-guide-mask'));
289 |
290 | expect(getRenderedStyle(cont[0], 'display')).toBe('none'); //should still be hidden
291 | });
292 |
293 | it('should return empty csrf token', function() {
294 | var dug = new window.DjangoUserGuide();
295 |
296 | expect(dug.getCsrfToken()).toBe('');
297 | });
298 |
299 | it('should post data to a given url', function() {
300 | var dug = new window.DjangoUserGuide(),
301 | sendData = {
302 | id: 1,
303 | 'is_finished': true
304 | },
305 | input = '';
306 |
307 | //mock async methods and setRequestHeader
308 | spyOn(XMLHttpRequest.prototype, 'send');
309 | spyOn(XMLHttpRequest.prototype, 'setRequestHeader');
310 |
311 | dug.post('/', sendData);
312 | expect(XMLHttpRequest.prototype.setRequestHeader).toHaveBeenCalledWith(
313 | 'Content-Type',
314 | 'application/x-www-form-urlencoded; charset=UTF-8'
315 | );
316 | expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith('id=1&is_finished=true');
317 |
318 | //make sure a csrf token can be set
319 | dug = new window.DjangoUserGuide();
320 |
321 | //add the csrf token to the html
322 | document.querySelector('.django-user-guide-html-wrapper').innerHTML = input;
323 |
324 | dug.post('/', sendData);
325 | expect(XMLHttpRequest.prototype.setRequestHeader).toHaveBeenCalledWith('X-CSRFToken', '1234');
326 | expect(XMLHttpRequest.prototype.send).toHaveBeenCalledWith('id=1&is_finished=true');
327 | });
328 | });
329 |
--------------------------------------------------------------------------------
/user_guide/templates/user_guide/window.html:
--------------------------------------------------------------------------------
1 |
41 |
--------------------------------------------------------------------------------
/user_guide/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambitioninc/django-user-guide/803258e4e31a1db351d5c050ce655c354e4d7888/user_guide/templatetags/__init__.py
--------------------------------------------------------------------------------
/user_guide/templatetags/user_guide_tags.py:
--------------------------------------------------------------------------------
1 | """
2 | Template tag for displaying user guides.
3 | """
4 | import re
5 |
6 | from django import template
7 | from django.conf import settings
8 | from django.template import loader
9 | from django.template.defaulttags import CsrfTokenNode
10 |
11 | from user_guide.models import GuideInfo
12 |
13 |
14 | register = template.Library()
15 |
16 | # The maximum number of guides to show per page
17 | USER_GUIDE_SHOW_MAX = getattr(settings, 'USER_GUIDE_SHOW_MAX', 10)
18 |
19 | # Use cookies to determine if guides should be shown
20 | USER_GUIDE_USE_COOKIES = getattr(settings, 'USER_GUIDE_USE_COOKIES', False)
21 |
22 | # The url to any custom CSS
23 | USER_GUIDE_CSS_URL = getattr(
24 | settings,
25 | 'USER_GUIDE_CSS_URL',
26 | None
27 | )
28 |
29 | # The url to any custom JS
30 | USER_GUIDE_JS_URL = getattr(
31 | settings,
32 | 'USER_GUIDE_JS_URL',
33 | None
34 | )
35 |
36 |
37 | @register.simple_tag(takes_context=True)
38 | def user_guide(context, *args, **kwargs):
39 | """
40 | Creates html items for all appropriate user guides.
41 |
42 | Kwargs:
43 | guide_name: A string name of a specific guide.
44 | guide_tags: An array of string guide tags.
45 | limit: An integer maxmimum number of guides to show at a single time.
46 |
47 | Returns:
48 | An html string containing the user guide scaffolding and any guide html.
49 | """
50 | user = context['request'].user if 'request' in context and hasattr(context['request'], 'user') else None
51 |
52 | if user and user.is_authenticated(): # No one is logged in
53 | limit = kwargs.get('limit', USER_GUIDE_SHOW_MAX)
54 | filters = {
55 | 'user': user,
56 | 'is_finished': False
57 | }
58 |
59 | # Handle special filters
60 | if kwargs.get('guide_name'):
61 | filters['guide__guide_name'] = kwargs.get('guide_name')
62 | if kwargs.get('guide_tags'):
63 | filters['guide__guide_tag__in'] = kwargs.get('guide_tags')
64 |
65 | # Set the html
66 | html = ''.join((
67 | '
{1}
'.format(
68 | guide_info.id,
69 | guide_info.guide.html
70 | ) for guide_info in GuideInfo.objects.select_related('guide').filter(**filters).only('guide')[:limit]
71 | ))
72 |
73 | # Return the rendered template with the guide html
74 | return loader.render_to_string('user_guide/window.html', {
75 | 'html': re.sub(r'\{\s*static\s*\}', settings.STATIC_URL, html),
76 | 'css_href': '{0}user_guide/build/django-user-guide.css'.format(settings.STATIC_URL),
77 | 'js_src': '{0}user_guide/build/django-user-guide.js'.format(settings.STATIC_URL),
78 | 'custom_css_href': USER_GUIDE_CSS_URL,
79 | 'custom_js_src': USER_GUIDE_JS_URL,
80 | 'use_cookies': str(USER_GUIDE_USE_COOKIES).lower(),
81 | 'csrf_node': CsrfTokenNode().render(context)
82 | })
83 | else:
84 | return ''
85 |
--------------------------------------------------------------------------------
/user_guide/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ambitioninc/django-user-guide/803258e4e31a1db351d5c050ce655c354e4d7888/user_guide/tests/__init__.py
--------------------------------------------------------------------------------
/user_guide/tests/admin_tests.py:
--------------------------------------------------------------------------------
1 | from django.contrib.admin.sites import AdminSite
2 | from django.test import TestCase
3 | from django_dynamic_fixture import G, F
4 |
5 | from user_guide.admin import GuideAdmin, GuideInfoAdmin
6 | from user_guide.models import Guide, GuideInfo
7 |
8 |
9 | class AdminTest(TestCase):
10 | def setUp(self):
11 | super(AdminTest, self).setUp()
12 | self.site = AdminSite()
13 |
14 | def test_user_guide_admin(self):
15 | guide_admin = GuideAdmin(Guide, self.site)
16 | self.assertEqual(guide_admin.list_display, ('guide_name', 'guide_tag', 'guide_importance', 'creation_time'))
17 |
18 | def test_user_guide_info_admin(self):
19 | guide_info_admin = GuideInfoAdmin(GuideInfo, self.site)
20 | guide_info_obj = G(GuideInfo, guide=F(guide_name='test_name'))
21 | self.assertEqual(guide_info_admin.list_display, ('user', 'guide_name', 'is_finished', 'finished_time'))
22 | self.assertEqual(guide_info_admin.guide_name(guide_info_obj), 'test_name')
23 |
--------------------------------------------------------------------------------
/user_guide/tests/model_tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django_dynamic_fixture import G
3 |
4 | from user_guide.models import Guide
5 |
6 |
7 | class GuideTest(TestCase):
8 |
9 | def test_guide_unicode(self):
10 | guide_obj = G(Guide, guide_name='test_name')
11 | self.assertEqual(str(guide_obj), 'test_name')
12 |
--------------------------------------------------------------------------------
/user_guide/tests/no_admin_tests.py:
--------------------------------------------------------------------------------
1 | import six
2 | if six.PY3: # pragma: no cover
3 | from importlib import reload
4 |
5 | from django.conf import settings
6 | from django.test import TestCase
7 |
8 | from user_guide import admin
9 |
10 |
11 | class NoAdminTest(TestCase):
12 | """
13 | Tests loading of the admin module when django.contrib.admin is not installed.
14 | """
15 | def test_no_admin(self):
16 | with self.settings(INSTALLED_APPS=[app for app in settings.INSTALLED_APPS if app != 'django.contrib.admin']):
17 | reload(admin)
18 | self.assertIsNotNone(admin)
19 |
--------------------------------------------------------------------------------
/user_guide/tests/templatetag_tests.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.http import HttpRequest
3 | from django.template import Context, Template
4 | from django.test import TestCase
5 |
6 | from user_guide.models import Guide, GuideInfo
7 |
8 |
9 | class TemplateTagTest(TestCase):
10 | def setUp(self):
11 | super(TemplateTagTest, self).setUp()
12 |
13 | self.users = [
14 | User.objects.create_user(
15 | username='test{0}@test.com'.format(i),
16 | email='test{0}@test.com'.format(i),
17 | password='test'
18 | )
19 | for i in range(0, 5)
20 | ]
21 |
22 | self.guides = [
23 | Guide.objects.create(
24 | html='
Hello test {0}!
'.format(i),
25 | guide_name='Test Guide {0}'.format(i),
26 | guide_type='Window',
27 | guide_tag='tag{0}'.format(i),
28 | guide_importance=i
29 | )
30 | for i in range(0, 10)
31 | ]
32 |
33 | def test_user_guide_tags_no_user(self):
34 | t = Template('{% load user_guide_tags %}{% user_guide %}')
35 | c = Context({})
36 | self.assertEqual('', t.render(c))
37 |
38 | def test_user_guide_tags_no_filters(self):
39 | t = Template('{% load user_guide_tags %}{% user_guide %}')
40 | r = HttpRequest()
41 | r.user = self.users[0]
42 | c = Context({
43 | 'request': r,
44 | 'csrf_token': '1234'
45 | })
46 |
47 | # Create an info for each guide
48 | guide_infos = [GuideInfo.objects.create(user=self.users[0], guide=guide) for guide in self.guides]
49 |
50 | # Render the template
51 | rendered = t.render(c)
52 |
53 | # Make sure the correct guides show up, guide_order should apply here
54 | self.assertTrue('' in rendered)
55 | self.assertTrue('Hello test 9!' in rendered)
56 | self.assertTrue('data-guide="{0}"'.format(guide_infos[9].id) in rendered)
57 | self.assertTrue('Hello test 8!' in rendered)
58 | self.assertTrue('data-guide="{0}"'.format(guide_infos[8].id) in rendered)
59 | self.assertTrue('Hello test 7!' in rendered)
60 | self.assertTrue('data-guide="{0}"'.format(guide_infos[7].id) in rendered)
61 | self.assertTrue('Hello test 6!' in rendered)
62 | self.assertTrue('data-guide="{0}"'.format(guide_infos[6].id) in rendered)
63 | self.assertTrue('Hello test 5!' in rendered)
64 | self.assertTrue('data-guide="{0}"'.format(guide_infos[5].id) in rendered)
65 | self.assertTrue('Hello test 4!' not in rendered) # Should not have rendered 6 guides
66 | self.assertTrue('Hello test 3!' not in rendered) # Should not have rendered 7 guides
67 | self.assertTrue('Hello test 2!' not in rendered) # Should not have rendered 8 guides
68 | self.assertTrue('Hello test 1!' not in rendered) # Should not have rendered 9 guides
69 | self.assertTrue('Hello test 0!' not in rendered) # Should not have rendered 10 guides
70 | self.assertTrue('django-user-guide.css' in rendered) # Should have django-user-guide style sheet
71 | self.assertTrue('django-user-guide.js' in rendered) # Should have django-user-guide script
72 | self.assertTrue('custom-style.css' in rendered) # Should have custom style sheet
73 | self.assertTrue('custom-script.js' in rendered) # Should have custom script
74 | self.assertTrue('' in rendered) # Should have expanded {static}
75 |
76 | def test_user_guide_tags_guide_name_filter(self):
77 | t = Template('{% load user_guide_tags %}{% user_guide guide_name=guide_name %}')
78 | r = HttpRequest()
79 | r.user = self.users[0]
80 | c = Context({
81 | 'request': r,
82 | 'guide_name': 'Test Guide 1'
83 | })
84 |
85 | # create a guide info
86 | GuideInfo.objects.create(user=self.users[0], guide=self.guides[1])
87 |
88 | # render the template
89 | rendered = t.render(c)
90 |
91 | self.assertTrue('