7

Compare commits

..

212 Commits

Author SHA1 Message Date
acd273c06c Add reverse code 2018-07-19 16:09:55 +02:00
4b3af020fd Fix flake8 errors 2018-07-19 14:33:38 +02:00
05afea8d68 Convert old value status to enabled 2018-07-19 14:26:03 +02:00
c31415b484 Fix migration ordering 2018-07-19 14:17:45 +02:00
4a596d62f2 Fix tests for static/dynamic segments 2018-07-19 14:17:45 +02:00
3c1c0c3306 Fixes tests 2018-07-19 14:17:45 +02:00
937c06cf32 More simple status toggle 2018-07-19 14:17:44 +02:00
d7fac2607b Better migration name 2018-07-19 14:17:44 +02:00
be672f6fde Replaces choice field segment status with boolean 2018-07-19 14:17:44 +02:00
a47803eca5 Delete related variants when deleting the segment (#183)
* Delete related variants when deleting the segment

Closes #155

* Fix typo

* Fix migration ordering

* Split metadata migrations
2018-07-19 12:59:46 +02:00
e42e1a865b Add width and height to logo in README 2018-07-17 17:14:44 +02:00
293004fdc6 Feature/documentation (#170)
* Splits documentation over multiple folders

* Editor guide documentation intro

* Adds segment dashboard documentation

* Adds editor documenation regarding segment creation

* Adds logo with padding for the documentation

* Updates usage guide documentation

* Splits sandbox and custom rules documentation

* Improves ‘Create a variant’ documentation

* Adds documentation regarding streamfield and template tags

* Consistent StreamField references

* Feedback from M. Dingjan

* Remove ‘coming soon’ section

* Enable sandbox debug toolbar

* Updated documentation
2018-07-17 16:59:32 +02:00
6f0425cd5f Merge pull request #184 from wagtail/182-deleting-the-page-deletes-its-variants
Delete page variants when deleting the page
2018-07-09 08:05:26 +02:00
0fd6d4d2e5 Delete variants of a page that is being deleted 2018-07-06 15:11:03 +01:00
e0fbefd53f Update contributors list with all the committers 2018-06-08 18:03:50 +02:00
c87b2936e9 Merge pull request #179 from wagtail/feature/fix-page-summary-item-page-count
Feature/fix page summary item page count
2018-05-31 13:54:58 +02:00
7010f5acea Move comment to top of function 2018-05-31 11:02:39 +02:00
d94890848d Add description of logic 2018-05-31 10:26:17 +02:00
f8d226efaf Prevent corrected summary item from counting the root page 2018-05-31 10:22:22 +02:00
037381f79f Merge pull request #178 from tm-kn/use-the-same-way-as-wagtail-to-count-pages
Use Wagtail's logic in the page count in the dash
2018-05-30 22:26:25 +02:00
0f5501ceef Merge pull request #177 from tm-kn/django-get-field-bug
Fix bug on visiting a segment page in the admin
2018-05-30 22:25:08 +02:00
4e5454b348 Use Wagtail's logic in the page count in the dash
This adds a check if root page is root page in order to calculate the
count properly the same way as Wagtail does [1].

[1] 5c9ff23e22/wagtail/admin/site_summary.py (L38)
2018-05-30 20:13:04 +01:00
9919d76741 Merge pull request #176 from tm-kn/fix-excluding-pages-without-variant
Fix excluding pages without variant
2018-05-30 21:07:32 +02:00
7e9dd8624b Fix bug on visiting a segment page in the admin
Wagtail's InlinePanel's relies on
django.db.models.options.Options.get_field -
bcf6b6da77/wagtail/admin/edit_handlers.py (L688)
which seems to have a bug of using related_query_name rather than
related_name naming and therefore trying to edit segment always
fails - https://code.djangoproject.com/ticket/29458. The bug has not
yet been confirmed by others but it's impossible to edit segments.

Therefore this change deletes related_query_name from relations from
rules to the Segment model.
2018-05-30 20:00:53 +01:00
6514bc1763 Fix excluding pages without variant
Currently count in the admin dashboard shows "-1" and no pages are
displayed in the explorer.
2018-05-30 19:47:28 +01:00
65a46f2bd9 Fix segment model naming for forced_segment and the userbar 2018-05-26 16:37:53 +02:00
f1b62a7546 add missing migration 2018-05-26 16:27:20 +02:00
03e02e8b91 Change all mentions of LabD urls to wagtail urls 2018-05-26 16:26:59 +02:00
8d8975ac36 Merge pull request #126 from wagtail/feature/force-as-segment
adds simple segment forcing for superusers
2018-05-26 16:24:44 +02:00
2324a30afd Merge branch 'feature/djangoconf-sprint' 2018-05-26 16:11:27 +02:00
0bdb80f25a improve SessionSegmentAdapter 2018-05-26 16:04:11 +02:00
1c1a7ce1b8 Remove wagtail icon from segment link in user bar 2018-05-26 15:08:17 +02:00
2a48eb3498 fix django version in tox.ini 2018-05-26 14:59:25 +02:00
4ad097b4fa include wagtail-2.1 in test matrix 2018-05-26 14:57:03 +02:00
939247c147 Add force as segment to the Wagtail user bar 2018-05-26 14:52:09 +02:00
12f110d913 remove customer manager again for now 2018-05-26 14:35:53 +02:00
c8fe62d2b1 remove praekholt deployment target from travis setup 2018-05-26 14:35:53 +02:00
83c2a4289e Adjust README for ordering 2018-05-26 12:56:18 +02:00
84ac76f33e Adjust tox.ini for wagtail 2.1 2018-05-26 12:56:02 +02:00
f6598ca1f7 Adjust requirements 2018-05-26 12:55:29 +02:00
726c0cd70f update travis setup 2018-05-26 12:32:39 +02:00
4f3f9a4d40 lint 2018-05-26 12:28:01 +02:00
3a378830e0 fix basepython 2018-05-26 12:27:52 +02:00
8a151e3bab python2 cleanups 2018-05-26 12:06:35 +02:00
bb34bddaf4 add custom model manager 2018-05-26 12:01:26 +02:00
9710d3b479 post-merge cleanups 2018-05-26 11:45:28 +02:00
5536adc3ec Merge branch 'develop' into feature/djangoconf-sprint 2018-05-26 10:48:33 +02:00
5b8d578493 only test for wagtail2 and django2 on python3 2018-05-26 09:54:56 +02:00
bdba6b65cf use new wagtail_factories package 2018-03-22 14:41:54 +01:00
cbcd80d248 update tox.ini 2018-03-17 11:56:14 +01:00
9b1c5a6ab6 fixes test runs
added dependency link to Makefile until Michael releases new
wagtail-factories
2018-03-17 11:37:11 +01:00
62d258fd9e fixes wagtail2 compatibility
return QuerySets instead of lists
2018-03-17 11:26:56 +01:00
32e73329c3 Revert wagtail-factories setting 2018-03-16 11:51:25 +01:00
fde53ea0ef Fix all tests for django and wagtail 2 2018-03-16 11:45:07 +01:00
22a7367211 Update module paths for tests 2018-03-16 11:16:47 +01:00
0d89d47735 Prevent webpack copy error from img dir 2018-03-16 11:14:19 +01:00
92189a3be8 Fix dashboard edit links 2018-03-16 11:14:19 +01:00
6c9d8b2730 remove typo 2018-03-16 11:14:19 +01:00
e141e5396e make Makefile more portable 2018-03-16 11:14:19 +01:00
c0e2b969e8 Set site ID in sandbox settings 2018-03-16 11:14:19 +01:00
7b5e3d4c9d Fix exampledata 2018-03-16 11:14:19 +01:00
6b7a1ed591 Updated requirements and module paths 2018-03-16 11:14:19 +01:00
9b25cd2a94 Add missing dependency `pytest-pythonpath` 2018-03-16 11:10:45 +01:00
3a86c189dc Merge tag '0.11.3' into develop
Bugfix: Handle errors when testing an invalid visit count rule
2018-03-09 20:35:33 +02:00
82c26f9772 Merge branch 'release/0.11.3' 2018-03-09 20:35:20 +02:00
03eb812e45 Version 0.11.3 2018-03-09 20:35:08 +02:00
e3522d0acb Merge pull request #26 from praekeltfoundation/feature/catch-exceptions-when-visit-count-rule-is-blank
Handle exceptions for empty VisitCountRule
2018-03-09 20:32:05 +02:00
7f5e958ee3 Catch the exception if the visit count rule doesn't have a page 2018-03-09 19:20:30 +02:00
241bfb5240 Merge tag '0.11.2' into develop
Bugfix: Stop populating static segments when the count is reached
2018-03-08 14:00:58 +02:00
d5df6e0e58 Merge branch 'release/0.11.2' 2018-03-08 14:00:38 +02:00
865efd0792 Version 0.11.2 2018-03-08 13:59:23 +02:00
454c936e0f Merge pull request #25 from praekeltfoundation/feature/fix-static-segment-population
Fix off-by-one error in static segment population
2018-03-08 13:24:48 +02:00
74d3123084 Ensure static segments don't have one extra user 2018-03-08 13:14:29 +02:00
9bfd816430 Merge tag '0.11.1' into develop
Populate entirely static segments from registered Users not active Sessions
2018-03-01 16:26:46 +02:00
02e06bd9f3 Merge branch 'release/0.11.1' 2018-03-01 16:26:35 +02:00
c7ad3251cf Version 0.11.1 2018-03-01 16:26:20 +02:00
cb8b7da496 Merge pull request #23 from praekeltfoundation/feature/populate-static-segments-from-db-at-creation
Populate static segments from database
2018-03-01 16:23:10 +02:00
0efd3ae937 Update tests for new static segment population 2018-02-26 14:34:02 +02:00
d335e4fd7b Populate static segments even if the count is 0 2018-02-26 14:31:56 +02:00
db2f82967e Only loop through users once when saving static segments
When saving a new static segment we count the matching users and
populate the segment from the database. Each of these requires us
to loop through all of the users, so it's better to do them at the
same time.
2018-02-25 15:43:12 +02:00
37243365a7 Merge branch 'develop' into feature/populate-static-segments-from-db-at-creation 2018-02-25 15:08:36 +02:00
43a2b590b4 Merge tag '0.11.0' into develop
Bug Fix: Query rule should not be static
Enable retrieval of user data for static rules through csv download
2018-02-23 17:02:53 +02:00
cc1dd337bb Merge branch 'release/0.11.0' 2018-02-23 17:02:34 +02:00
a677846ff7 Bump version to 0.11.0 2018-02-23 17:02:17 +02:00
7d7861b862 Merge pull request #22 from praekeltfoundation/feature/SAS-72-download-cvs-of-user-data
Download CSV of users in a segment
2018-02-23 16:50:52 +02:00
8e854d0abe Merge branch 'develop' into feature/SAS-72-download-cvs-of-user-data 2018-02-23 14:05:31 +02:00
0051061d96 Merge pull request #21 from praekeltfoundation/feature/SAS-72-get-user-info-for-visit-count-rule
Get user info from visit count rule
2018-02-23 14:02:15 +02:00
f898dfe017 Actually add tests for segment users view 2018-02-22 16:12:24 +02:00
8ced5bd81c Fix flake8 2018-02-22 15:15:01 +02:00
9a86b0c8cc Add tests for segment users view 2018-02-22 15:14:34 +02:00
9408f90789 Use mock for testing matching user count
The fake class was causing other tests to fail because it inherits from AbstractBaseRule but isn't in the database.
I removed it and replaced it with mocked calls
2018-02-22 14:23:14 +02:00
ba6056e3f8 Link to download CSV of users in segment 2018-02-22 14:21:43 +02:00
fdc0a7f2e1 Get user info for Visit count rule 2018-02-21 19:08:29 +02:00
12b0cd9231 Make visit count session retrieval seperate method 2018-02-21 19:07:35 +02:00
330557be8d Make VisitCountRule.test_user actually test with only a user 2018-02-21 18:48:44 +02:00
aa917dee9c Merge pull request #20 from praekeltfoundation/feature/SAS-90-revert-query-rule-to-not-be-static
Remove static flag for Query Rule
2018-02-21 15:54:36 +02:00
364cb1a7e6 Query rule should not be static 2018-02-20 15:08:12 +02:00
8f789b3e17 Fill static segments with users from the database at creation 2018-02-15 13:20:48 +02:00
bedbe06c65 Merge branch 'release/0.10.9' into develop 2018-02-13 14:17:55 +02:00
362f15e5ff version bump 0.10.9 2018-02-13 14:17:29 +02:00
8a0dba2efb Merge pull request #18 from praekeltfoundation/feature/issue-18-Display-the-number-of-users-in-a-static-segment-SAS-99
Display the number of users in a static segment SAS-99
2018-02-13 14:11:56 +02:00
59f4877e04 add localize filter 2018-02-13 13:57:00 +02:00
2ff29cc375 get the number of users in a static segment from static_users variable 2018-02-13 13:47:45 +02:00
8527e6ff23 Merge tag '0.10.8' into develop
Don't add users to exclude list for dynamic segments
Store segments a user is excluded from in the session
2018-02-13 10:13:24 +02:00
d7c07cb238 Merge branch 'release/0.10.8' 2018-02-13 10:12:45 +02:00
6e83366df6 Bump version to 0.10.8 2018-02-13 10:12:35 +02:00
55364f8906 Merge pull request #16 from praekeltfoundation/feature/SAS-78-fix-sampling-dynamic-segments
Fix sampling for Dynamic segments
2018-02-13 09:59:28 +02:00
4fd0b30c66 Check rules test skipped if segment excluded by session 2018-02-12 18:56:13 +02:00
c909852b08 Add tests 2018-02-12 18:01:01 +02:00
ea1ecc2a98 Get excluded segments from session and don't check them again 2018-02-12 18:00:38 +02:00
0f0aecf673 Store excluded segments in the session object 2018-02-12 17:57:36 +02:00
c11960f921 Only store excluded users for static segments 2018-02-12 16:58:20 +02:00
37d49dcdfb Merge tag '0.10.7' into develop
Bug Fix: Ensure static segment members are show the survey immediately
Records users excluded by randomisation on the segment
Don't re-check excluded users
2018-02-09 17:01:02 +02:00
869237360d Merge branch 'release/0.10.7' 2018-02-09 17:00:09 +02:00
33277a0b20 Version 0.10.7 2018-02-09 16:59:26 +02:00
2cd643fb2d Merge pull request #15 from praekeltfoundation/feature/SAS-78-store-excluded-users-on-segment
Store users excluded by randomisation
2018-02-09 16:52:44 +02:00
0f18024ebc Tests 2018-02-09 12:36:34 +02:00
521222f748 Don't check if excluded users match segment rules 2018-02-09 12:35:53 +02:00
56a8e106d8 Add users excluded by randomisation to excluded_users list 2018-02-09 12:35:09 +02:00
3162191a16 Add field to segment to store excluded users 2018-02-09 12:32:42 +02:00
8c7e99313b Merge pull request #14 from praekeltfoundation/feature/add-static-segments-to-session
Add static segments to session if rules pass
2018-02-09 12:30:55 +02:00
824e42174f Tests 2018-02-08 19:48:31 +02:00
d114bb2570 Always add the segment to the session if they pass 2018-02-08 19:47:35 +02:00
7bba1e57cc Merge pull request #12 from praekeltfoundation/feature/only-deploy-once-per-build
Only deploy for one environment per travis build
2018-02-06 16:26:43 +02:00
3017f32b6b Add spaces around = in bash deploy condition 2018-02-06 16:18:16 +02:00
6b1a7cf1f2 Only deploy for one environment per travis build 2018-02-06 16:11:20 +02:00
1525b7946c Merge tag '0.10.6' into develop
Accepts and stores randomisation percentage for segment
Adds users to segment based on random number relative to percentage
2018-02-06 15:26:25 +02:00
7bf1bc3f19 Merge branch 'release/0.10.6' 2018-02-06 15:26:02 +02:00
4c60bcbe6b Version 0.10.6 2018-02-06 15:25:51 +02:00
ad4f75d471 Merge pull request #11 from praekeltfoundation/feature/SAS-93-use-randomisation-percentage
Use randomisation percentage
2018-02-06 14:51:56 +02:00
086168954d Test randomisation of static segments at creation 2018-02-05 12:30:12 +02:00
881090f2f9 Test randomisation for dynamic segments 2018-02-05 12:21:09 +02:00
d073c7d268 Randomise into static segments when they are created 2018-02-05 12:20:10 +02:00
7200b5b4c4 If a session passes segment rules randomise them into the segement 2018-02-05 12:19:36 +02:00
6f97c76958 Add method to randomise matching sessions into the segment 2018-02-05 12:18:22 +02:00
ecb4f928fb Merge pull request #10 from praekeltfoundation/feature/SAS-92-store-randomisation-percentage
Add randomisation percentage to segment model
2018-02-05 11:54:23 +02:00
29aa91477e Migrations 2018-02-02 10:15:20 +02:00
5c3acc6661 Display randomisation percentages on segment dashboard 2018-02-02 10:15:04 +02:00
602919d2d4 Test randomisation percentage added to segments 2018-02-02 10:14:18 +02:00
ae97118c3f Store randomisation percentage on segment model 2018-02-02 10:13:18 +02:00
51774b939e Version 0.10.5 2018-01-26 17:57:43 +02:00
908f85e295 Merge pull request #9 from praekeltfoundation/feature/SAS-86-display-record-counter-on-dash
Display record counter on segments in dash
2018-01-26 17:51:34 +02:00
99f9700ed0 Display record counter for active segments 2018-01-26 16:21:15 +02:00
7fa8ee1a46 Merge pull request #8 from praekeltfoundation/feature/SAS-85-only-count-active-frontend-users
Don't include staff and inactive users when counting matched users
2018-01-26 16:01:02 +02:00
5ad70d68f6 Don't include staff and inactive users when counting matched users 2018-01-26 15:38:26 +02:00
06bfe77901 Merge pull request #7 from praekeltfoundation/feature/SAS-85-calculate-matching-users
Count number of users that match static rules for a segment
2018-01-26 08:58:00 +02:00
d5e89d374b Remove unnecessary imports 2018-01-25 19:51:50 +02:00
5b39e82f80 Fixed test for adding user counter to segment 2018-01-25 18:42:38 +02:00
fbcebb43a4 Store record count on a segment when it is created 2018-01-25 15:14:19 +02:00
ef271587ec Test count_matching_users method 2018-01-25 13:26:05 +02:00
786a8801b1 Migrations for Segment.matched_user_count 2018-01-25 11:26:57 +02:00
caf73aa43c Add matched_users_count field to segments 2018-01-25 11:12:46 +02:00
4021d2c915 Add method to calculate the number of users that match a segment 2018-01-24 22:00:28 +02:00
33f96af4a3 Allow test_user() for static rules to accept a user 2018-01-24 15:14:24 +02:00
6299feb497 Merge tag '0.10.4' into develop
0.10.4
2018-01-22 12:58:49 +02:00
7ced6db126 Merge branch 'release/0.10.4' 2018-01-22 12:58:31 +02:00
c6ce67c9c9 Version bump 0.10.4 2018-01-22 12:58:12 +02:00
3df3fc0b16 Merge pull request #6 from praekeltfoundation/feature/SAS-87-fix-static-rules
Set QueryRule to be static
2018-01-22 12:03:29 +02:00
a00929846e Set query rule to be static 2018-01-18 16:17:30 +02:00
49fba11049 Merge pull request #5 from praekeltfoundation/enable-linting
Enable and fix lint
2018-01-08 10:23:36 +00:00
e3488e87ad Enable and fix lint 2018-01-08 09:08:11 +00:00
808aa6d202 Add tests for exclude_variants 2018-01-08 09:07:15 +00:00
efb060cc6e Merge branch 'release/0.10.3' into develop 2018-01-05 19:18:14 +02:00
414afa5269 Merge branch 'release/0.10.3' 2018-01-05 19:17:18 +02:00
b3f0ac2d58 Version Bump 0.10.3 2018-01-05 19:16:45 +02:00
4f9c18d2cf Merge pull request #3 from praekeltfoundation/fix-segment-edit-link
Fix segment edit link
2018-01-05 17:08:12 +00:00
a4a283e4f3 Fix segment edit link
This is hardcoded at the moment which doesn't work unless you
redirect URLs to have a trailing slash.

Using the reverse URL lookup works in all cases.
2018-01-05 17:00:15 +00:00
30318549e2 Merge pull request #4 from praekeltfoundation/feature/issue-4-update-wagtail-django-dependencies
Update wagtail and django dependencies
2018-01-05 18:55:18 +02:00
f19de241b0 Update dependencies for wagtail and django
Only run tests for wagtail v1.13 and django v1.11
2018-01-05 18:28:40 +02:00
95ecd8d200 Merge branch 'release/0.10.2' into develop 2017-11-23 16:33:16 +02:00
6436b85b1d Merge branch 'release/0.10.2' 2017-11-23 16:32:31 +02:00
06471248d3 Version Bump 0.10.2 2017-11-23 16:32:20 +02:00
e3df03f559 Merge pull request #2 from torchbox/fix/visitor-rule-not-updating
Fix visitor rule not updating correct paths
2017-11-23 16:29:13 +02:00
0a42ce3eeb Fix not updating visitor count rule properly 2017-11-23 14:10:16 +00:00
e5068894c3 Merge branch 'release/0.10.1' into develop 2017-11-13 15:03:41 +02:00
fdc2b97194 Merge branch 'release/0.10.1' 2017-11-13 15:03:32 +02:00
a8d3aeab68 Version Bump 0.10.1 2017-11-13 15:02:56 +02:00
c76d6d1617 Update manifest to include missing js files 2017-11-13 14:58:20 +02:00
a8c4b66d6e Merge branch 'release/0.10.0' into develop 2017-11-09 16:53:42 +02:00
f3fbee99a2 Merge branch 'release/0.10.0' 2017-11-09 16:53:37 +02:00
4918c99b5f Version Bump 0.10.0 2017-11-09 16:53:22 +02:00
330c3bd377 Merge branch 'feature/issue-1-create-pypi-release' into develop 2017-11-09 16:49:29 +02:00
9c9a9d3acd add missing function call 2017-11-09 16:37:52 +02:00
51e9aa9724 remove all the extra testing environments 2017-11-09 13:36:43 +02:00
a5705fd53c Removed extra newline 2017-11-09 13:27:31 +02:00
9d1f3074c0 add pypi password 2017-11-09 13:18:22 +02:00
3bfd5b8e8f Add deploy instructions to travis file 2017-11-09 11:54:13 +02:00
232609fb4e Update setup file for new package name 2017-11-09 11:52:55 +02:00
35fd4836b0 update the realease 2017-11-08 13:58:50 +00:00
b786b0a4d2 Merge pull request #1 from torchbox/feature/dynamic-segments
Add the logic for static segments
2017-11-08 11:16:25 +00:00
23b1456438 Add tests which cover anonymous users 2017-11-01 17:10:03 +00:00
1f4a4536ab Make the static elements tracked users only
We cannot track anonymous users as the session expires after 10 minutes of
inactivity. This also avoids an issue where there is an error when the user's
session has expired and they navigate a page
2017-11-01 16:43:22 +00:00
b8bf27fb99 add enabled to the excluded segments checked 2017-10-27 07:29:41 +01:00
d07e06b4f0 lock down the static segment to prevent changes 2017-10-26 14:34:16 +01:00
71d7faba1f Correctly initialise the form 2017-10-26 13:11:16 +01:00
743d3f668e Limit the segemnt to count for all static case 2017-10-26 12:47:59 +01:00
bc0b69fde5 Hide and show the count input as required 2017-10-26 11:47:28 +01:00
7cf22d05f6 Tidy up the logic checks and remove the frozen property 2017-10-26 10:55:13 +01:00
9e0fc8e6fd Make the static segments work with match_any and fix bug in visit count 2017-10-24 10:50:05 +01:00
a116b14d57 Update to use the save method on the form to populate the segments 2017-10-23 15:46:34 +01:00
44cc95617e Use a form to clean the instance 2017-10-23 15:00:31 +01:00
c6ff2801c5 Update to use a post_init signal to populate the segment 2017-10-20 17:33:47 +01:00
0d2834a55f Update the help text migration 2017-10-20 17:17:31 +01:00
ff236a095d Improve the clarity of the help text 2017-10-20 12:18:29 +01:00
ef20580334 Notify users to static compatible rules and update docs 2017-10-20 12:09:25 +01:00
cf41be4b76 Add clean method to ensure mixed static segments are valid 2017-10-20 10:57:19 +01:00
f339879907 Ensure that mixed static and dynamic segments are not populated at runtime 2017-10-20 09:53:18 +01:00
aa2a239aec Update the dashboard to display information about static segments 2017-10-19 17:19:53 +01:00
8c96fffd4e Ensure that the session is checked correctly 2017-10-17 17:35:57 +01:00
675d219f1f Add the logic for static segments 2017-10-17 16:57:07 +01:00
9c88ec1582 fixes segment adapter add syntax for segment forcing 2017-06-12 11:12:33 +02:00
785d1486e4 adds simple segment forcing for superusers 2017-06-12 07:56:38 +02:00
98 changed files with 2691 additions and 575 deletions

2
.gitignore vendored
View File

@ -23,3 +23,5 @@ tests/sandbox/assets
node_modules node_modules
.DS_Store .DS_Store
.pytest_cache/

View File

@ -4,33 +4,12 @@ language: python
matrix: matrix:
include: include:
# Django 1.9, Wagtail 1.9
- python: 2.7
env: TOXENV=py27-django19-wagtail19
- python: 3.5
env: TOXENV=py35-django19-wagtail19
- python: 3.6 - python: 3.6
env: TOXENV=py36-django19-wagtail19 env: lint
# Django 1.10, Wagtail 1.10
- python: 2.7
env: TOXENV=py27-django110-wagtail110
- python: 3.5
env: TOXENV=py35-django110-wagtail110
- python: 3.6 - python: 3.6
env: TOXENV=py36-django110-wagtail110 env: TOXENV=py36-django20-wagtail20
# Django 1.11, Wagtail 1.10
- python: 2.7
env: TOXENV=py27-django111-wagtail110
- python: 3.5
env: TOXENV=py35-django111-wagtail110
- python: 3.6 - python: 3.6
env: TOXENV=py36-django111-wagtail110 env: TOXENV=py36-django20-wagtail21
allow_failures:
- python: 3.5
env: TOXENV=lint
install: install:
- pip install tox codecov - pip install tox codecov

47
CHANGES
View File

@ -1,3 +1,50 @@
0.11.3
==================
- Bugfix: Handle errors when testing an invalid visit count rule
0.11.2
==================
- Bugfix: Stop populating static segments when the count is reached
0.11.1
==================
- Populate entirely static segments from registered Users not active Sessions
0.11.0
==================
- Bug Fix: Query rule should not be static
- Enable retrieval of user data for static rules through csv download
0.10.9
==================
- Bug Fix: Display the number of users in a static segment on dashboard
0.10.8
==================
- Don't add users to exclude list for dynamic segments
- Store segments a user is excluded from in the session
0.10.7
==================
- Bug Fix: Ensure static segment members are show the survey immediately
- Records users excluded by randomisation on the segment
- Don't re-check excluded users
0.10.6
==================
- Accepts and stores randomisation percentage for segment
- Adds users to segment based on random number relative to percentage
0.10.5
==================
- Count how many users match a segments rules before saving the segment
- Stores count on the segment and displays in the dashboard
- Enables testing users against rules if there isn't an active request
0.10.0
==================
- Adds static and dynamic segments
0.9.1 (tbd) 0.9.1 (tbd)
================== ==================

View File

@ -8,3 +8,13 @@ Contributors
* Michael van Tellingen * Michael van Tellingen
* Pim Vernooij * Pim Vernooij
* Tomasz Knapik * Tomasz Knapik
* Kaitlyn Crawford
* Todd Dembrey
* Nathan Begbie
* Rob Moorman
* Tom Dyson
* Bertrand Bordage
* Alex Muller
* Saeed Marzban
* Milton Madanda
* Mike Dingjan

View File

@ -1,3 +1,6 @@
include README.rst include README.rst
recursive-include src recursive-include src *
recursive-exclude src __pycache__
recursive-exclude src *.py[co]

View File

@ -1,13 +1,13 @@
.PHONY: all clean requirements develop test lint flake8 isort dist sandbox docs .PHONY: all clean requirements develop test lint flake8 isort dist sandbox docs
all: clean requirements dist
default: develop default: develop
all: clean requirements dist
clean: clean:
find src -name '*.pyc' -delete find src -name '*.pyc' -delete
find tests -name '*.pyc' -delete find tests -name '*.pyc' -delete
find . -name '*.egg-info' -delete find . -name '*.egg-info' |xargs rm -rf
requirements: requirements:
pip install --upgrade -e .[docs,test] pip install --upgrade -e .[docs,test]
@ -38,7 +38,8 @@ isort:
isort --recursive src tests isort --recursive src tests
dist: dist:
./setup.py sdist bdist_wheel pip install wheel
python ./setup.py sdist bdist_wheel
sandbox: sandbox:
pip install -r sandbox/requirements.txt pip install -r sandbox/requirements.txt

View File

@ -3,17 +3,24 @@
.. image:: https://readthedocs.org/projects/wagtail-personalisation/badge/?version=latest .. image:: https://readthedocs.org/projects/wagtail-personalisation/badge/?version=latest
:target: http://wagtail-personalisation.readthedocs.io/en/latest/?badge=latest :target: http://wagtail-personalisation.readthedocs.io/en/latest/?badge=latest
.. image:: https://travis-ci.org/LabD/wagtail-personalisation.svg?branch=master .. image:: https://travis-ci.org/wagtail/wagtail-personalisation.svg?branch=master
:target: https://travis-ci.org/LabD/wagtail-personalisation :target: https://travis-ci.org/wagtail/wagtail-personalisation
.. image:: http://codecov.io/github/LabD/wagtail-personalisation/coverage.svg?branch=master .. image:: http://codecov.io/github/wagtail/wagtail-personalisation/coverage.svg?branch=master
:target: http://codecov.io/github/LabD/wagtail-personalisation?branch=master :target: http://codecov.io/github/wagtail/wagtail-personalisation?branch=master
.. image:: https://img.shields.io/pypi/v/wagtail-personalisation.svg .. image:: https://img.shields.io/pypi/v/wagtail-personalisation.svg
:target: https://pypi.python.org/pypi/wagtail-personalisation/ :target: https://pypi.python.org/pypi/wagtail-personalisation/
.. end-no-pypi .. end-no-pypi
.. image:: logo.png
:height: 261
:width: 300
:scale: 50
:alt: Wagxperience
:align: center
Wagtail Personalisation Wagtail Personalisation
======================= =======================
@ -24,20 +31,17 @@ in the admin interface.
.. _Wagtail CMS: http://wagtail.io/ .. _Wagtail CMS: http://wagtail.io/
.. image:: logo.png
:scale: 50 %
:alt: Wagxperience
:align: center
.. image:: screenshot.png .. image:: screenshot.png
Instructions Instructions
------------ ------------
Wagtail Personalisation requires Wagtail 1.9 or 1.10 and Django 1.9, 1.10 or 1.11. Wagtail Personalisation requires Wagtail 2.0 or 2.1 and Django 1.11 or 2.0.
To install the package with pip:: To install the package with pip:
.. code-block:: console
pip install wagtail-personalisation pip install wagtail-personalisation
@ -64,6 +68,16 @@ been added in first, this is a prerequisite for this project.
# ... # ...
] ]
Documentation
-------------
You can find more information about installing, extending and using this module
on `Read the Docs`_.
.. _Read the Docs: http://wagtail-personalisation.readthedocs.io
Sandbox Sandbox
------- -------

BIN
docs/_static/images/dual_streamfield.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
docs/_static/images/editing_variant.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
docs/_static/images/variants_button.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -17,10 +17,17 @@
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
# #
# import os import os
# import sys import sys
# sys.path.insert(0, os.path.abspath('.'))
on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
if not on_rtd: # only import and set the theme if we're building docs locally
import sphinx_rtd_theme
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
@ -47,7 +54,7 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = 'wagtail-personalisation' project = 'wagtail-personalisation'
copyright = '2017, Lab Digital BV' copyright = '2018, Lab Digital BV'
author = 'Lab Digital BV' author = 'Lab Digital BV'
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
@ -55,17 +62,17 @@ author = 'Lab Digital BV'
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = '0.9.1' version = '0.12.0'
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = '0.9.1' release = '0.12.0'
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # Usually you set "language" from the command line for these cases.
language = None # language = None
# List of patterns, relative to source directory, that match files and # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
@ -84,7 +91,7 @@ todo_include_todos = False
# The theme to use for HTML and HTML Help pages. See the documentation for # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
# #
html_theme = 'alabaster' # html_theme = 'alabaster'
# Theme options are theme-specific and customize the look and feel of a theme # Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the # further. For a list of options available for each theme, see the
@ -92,14 +99,11 @@ html_theme = 'alabaster'
# #
# html_theme_options = {} # html_theme_options = {}
html_theme_options = { html_theme_options = {
'github_user': 'LabD',
'github_banner': True,
'github_repo': 'wagtail-personalisation',
'travis_button': True,
'codecov_button': True,
'analytics_id': 'UA-100203499-2', 'analytics_id': 'UA-100203499-2',
} }
html_logo = 'logo.png'
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".

View File

@ -1,6 +1,10 @@
Included rules Included rules
============== ==============
Wagxperience comes with a base set of rules that allow you to start segmenting
your visitors quickly.
Time rule Time rule
--------- ---------
@ -16,11 +20,12 @@ End time The end time of your time frame.
``wagtail_personalisation.rules.TimeRule`` ``wagtail_personalisation.rules.TimeRule``
Day rule Day rule
-------- --------
The day rule allows you to segment visitors based on the day of their visit. The day rule allows you to segment visitors based on the day of their visit.
Select one or multiple days on which you'd like your segment to be applied. Select one or multiple days on which you would like your segment to be applied.
================== ========================================================== ================== ==========================================================
Option Description Option Description
@ -36,6 +41,7 @@ Sunday Matches when the visitors visits on a sunday.
``wagtail_personalisation.rules.DayRule`` ``wagtail_personalisation.rules.DayRule``
Referral rule Referral rule
------------- -------------
@ -54,6 +60,7 @@ Regex string The regex string to match the referral header to.
``wagtail_personalisation.rules.ReferralRule`` ``wagtail_personalisation.rules.ReferralRule``
Visit count rule Visit count rule
---------------- ----------------
@ -72,6 +79,7 @@ Operator Whether to match for more than, less than or equal to the
``wagtail_personalisation.rules.VisitCountRule`` ``wagtail_personalisation.rules.VisitCountRule``
Query rule Query rule
---------- ----------
@ -92,6 +100,7 @@ Value The second part of the query ('ourbestoffer').
``wagtail_personalisation.rules.QueryRule`` ``wagtail_personalisation.rules.QueryRule``
Device rule Device rule
----------- -----------
@ -108,6 +117,7 @@ Desktop Matches when the visitor uses a desktop.
``wagtail_personalisation.rules.DeviceRule`` ``wagtail_personalisation.rules.DeviceRule``
User is logged in rule User is logged in rule
---------------------- ----------------------

View File

@ -0,0 +1,91 @@
Creating personalised content
=============================
Once you've created a segment you can start serving personalised content to your
visitors. To do this, you can choose one of three methods.
1. Create a page variant for a segment.
2. Use StreamField blocks visible for a segment only.
3. Use a template block visible for a segment only.
Method 1: Create a page variant
-------------------------------
**Why you would want to use this method**
* It has absolutely no restrictions, you can change anything you want.
* That's pretty much it.
**Why you would want to use a different method**
* You are editing a page that changes often. You would probably rather not
change the variation(s) every time the original page changes.
To create a variant of a page for a specific Segment (which you can change to
your liking after creating it), simply go to the Explorer section and find the
page you would like to personalize.
.. figure:: ../_static/images/variants_button.png
:alt: The variants button that appears on personalisable pages.
When you hover over a page, you'll notice a "Variants" dropdown button appears.
Click the button and select the segment you would like to create personalised
content for.
Once you've selected the segment, a copy of the original page will be created
with a title that includes the segment. Don't worry, your visitors won't be able
to see this title. It's only there for your reference.
.. figure:: ../_static/images/editing_variant.png
:alt: The newly created page allowing you to change anything you want.
You can change everything on this page you would like. Visitors that are appointed
to your segment will automatically see the new variant you've created for them
when attempting to visit the original page.
Method 2: Use a StreamField block
---------------------------------
Preparing a page and it's StreamField blocks for this method is described in the
Usage guide for developers. Please refer to
:ref:`implementing_streamfield_blocks` for more information.
**Why you would want to use this method**
* Allows you to create personalised content in the original page (without
creating a variant).
* Create multiple StreamField blocks for different segments inline.
**Why you would want to use a different method**
* You need someone tech savvy to change the back-end implementation.
To create personalised StreamField blocks, first select the page you wan't to
create the content for. Note that the personalisable StreamField blocks must be
activated on the page by your developer.
Scroll down to the block containing the StreamField and add a personalisable
block. The first input field in the block is a dropdown allowing you to select
the segment this StreamField block is ment for.
.. figure:: ../_static/images/single_streamfield.png
:alt: Create a new StreamField block and select the segment.
If you want, you can even add multiple blocks and change the segment to show
different content between segments!
.. figure:: ../_static/images/dual_streamfield.png
:alt: You can even create multiple variations of the same block!
Once saved, the page will selectively show StreamField blocks based on the
visitor's segment.
Method 3: Use a template block
------------------------------
Setting up content in this manner is described in the Usage guide for
developers. Please refer to :ref:`implementing_template_blocks` for more
information.

View File

@ -0,0 +1,84 @@
Creating a segment
==================
To create a segment, go to the "Segments dashboard" and click "Add segment".
You can find the segments dashboard in the administration menu on the left of
the page.
.. figure:: ../_static/images/segment_dashboard_header.png
:alt: The segment dashboard header containing the "Add segment" button.
On this page you will be presented with two forms. One with specific information
about your segment, the other allowing you to choose and configure your
rules.
Set segment specific options
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. figure:: ../_static/images/edit_segment_specifics.png
:alt: The form that allows you to configure specific segment options.
1. Enter a name for your segment
Choose something meaningful like "Newsletter campaign visitors". This will
ensure you'll have a general idea which visitors are in this segment in
other parts of the administration interface.
2. Select the status of the segment *Optional*
You will generally keep this one **enabled**. If for some reason you want
to disable the segment, you can change this to **disabled**.
3. Set the segment persistence. *Optional*
When persistence is **enabled**, your segment will stick to the visitor once
applied, even if the rules no longer match the next visit.
4. Select whether to match any or all defined rules. *Optional*
**Match any** will result in a segment that is applied as soon as one of
your rules matches the visitor. When **match all** is selected, all rules
must match before the segment is applied.
5. The segment type *Required*
**Dynamic**: Users in this segment will change as more or less meet the
rules specified in the segment.
**Static**: If the segment contains only static compatible rules the segment
will contain the members that pass those rules when the segment is created.
Mixed static segments or those containing entirely non static compatible
rules will be populated using the count variable.
6. The segment count *Optional*
If this number is set for a static segment users will be added to the set
until the number is reached. After this no more users will be added.
7. Randomisation percentage *Optional*
If this number is set each user matching the rules will have this percentage
chance of being placed in the segment.
Defining rules
^^^^^^^^^^^^^^
.. figure:: ../_static/images/edit_segment_rules.png
:alt: The form that allows you to set the rules for a segment.
5. Choose the rules you want to use.
Wagxperience comes with a basic set of :doc:`../default_rules` that allow
you to get started quickly. The rules you define will be evaluated once a
visitor makes a request to your application.
The rules that come with Wagxperience are as follows:
.. toctree::
:maxdepth: 2
../default_rules
Click "save" to store your segment. It will be enabled by default, unless
otherwise defined.

View File

@ -0,0 +1,13 @@
Editor Guide
============
The editor guide is meant for content editors and marketers using Wagxperience
to offer a personalised experience to their visitors.
.. toctree::
:maxdepth: 2
introduction
segments_dashboard
creating_segments
creating_personalised_content

View File

@ -0,0 +1,22 @@
Introduction
============
Wagxperience_ is an open source module developed by `Lab Digital`_ for the
Wagtail_ content management system. It allows editors and marketeers to create
personalised experiences by harnessing the power of segmentation and rules.
.. _Wagxperience: http://wagxperience.io
.. _Wagtail: https://wagtail.io
.. _Lab Digital: http://labdigital.nl
In this guide, we'll take you step by step through the process of offering your
visitors a tailor made online experience. The subjects covered are:
* Using the segments dashboard
* Defining a new segment
* Setting up rules used to match visitors to a segment
* Personalize a page by creating a variant
* Using the StreamField to personalize content blocks
* And even more helpful stuff...
So without further ado, let's get started!

View File

@ -0,0 +1,84 @@
The segments dashboard
======================
Wagxperience comes with two different views for it's segment dashboard. A "list
view" and a "dashboard view". Where the dashboard view attempts to show all
relevant information and statistics in a visually pleasing manner, the list view
is more fitted for sites using large amounts of segments, as it may be
considered more clear in these cases.
Switching between views
-----------------------
By default, Wagxperience's "dashboard view" is active on the segment dashboard.
If you would like to switch between the dashboard view and list view, open the
segment dashboard and click the "Switch view" button in the green header at the
top of the page.
.. figure:: ../_static/images/segment_dashboard_header.png
:alt: The header containing the "Switch view" button.
Using the list view
-------------------
Advantages of using the list view:
* Uses the familiar table view that is used on many other parts of the Wagtail
administration interface.
* Offers a better overview for large amounts of segments.
* Allows for reordering based on fields, such as name or status.
.. figure:: ../_static/images/segment_list_view.png
:alt: The segment list view.
Definitions
^^^^^^^^^^^
Name
The name of your segment.
Persistent
If this is disabled (default), whenever a visitor requests a page, the rules
of this segment are reevaluated. This means that when the rules no longer
match, the visitor is no longer a part of this segment. However, if
persistence is enabled, this segment will "stick" with the visitor, even when
the rules no longer apply.
Match any
If this is disabled (default) all rules of this segment must match a visitor
before the visitor is appointed to this segment. If this is enabled, only 1
rule has to match before the visitor is appointed.
Status
Indicates whether this segment is active (default) or inactive. If it has
been set to 'inactive', visitors will not be appointed to this segment and no
personalised content for this segment will be shown to visitors.
Page count
The amount of pages that have variants using this segment.
Variant count
The total amount of variants for this segment. Does not yet apply, as this
will always match the amount of pages in the "Page count".
Statistics
Shows the amount of visits of this segment and the days it has been
enabled. If the segment is disabled and then re-enabled, these statistics
will reset.
Using the dashboard view
------------------------
Advantages of using the dashboard view:
* Offers a more pleasing visual representation of segments.
* Focused on giving insights about your segments at a glance.
* Shows the actual rules of a segment.
* Gives more wordy explanation about the information shown.
.. figure:: ../_static/images/segment_dashboard_view.png
:alt: The segment dashboard view.

View File

@ -1,32 +0,0 @@
Getting started
===============
Installing Wagxperience
-----------------------
Installing the module
^^^^^^^^^^^^^^^^^^^^^
The Wagxperience app runs in the Wagtail CMS. You can find out more here_.
.. _here: http://docs.wagtail.io/en/latest/getting_started/tutorial.html
1. Install the module::
pip install wagtail-personalisation
2. Add the module and ``wagtail.contrib.modeladmin`` to your ``INSTALLED_APPS``::
INSTALLED_APPS = [
# ...
'wagtail.contrib.modeladmin',
'wagtail_personalisation',
# ...
]
3. Update your database::
python manage.py migrate
Continue reading: :doc:`implementation`

View File

@ -0,0 +1,8 @@
Getting Started
===============
.. toctree::
:maxdepth: 3
installation
sandbox

View File

@ -0,0 +1,37 @@
Installing Wagxperience
=======================
Wagtail Personalisation requires Wagtail_ 2.0 or 2.1 and Django_ 1.11 or 2.0.
.. _Wagtail: https://github.com/wagtail/wagtail
.. _Django: https://github.com/django/django
To install the package with pip:
.. code-block:: console
pip install wagtail-personalisation
Next, include the ``wagtail_personalisation``, ``wagtail.contrib.modeladmin``
and ``wagtailfontawesome`` apps in your project's ``INSTALLED_APPS``:
.. code-block:: python
INSTALLED_APPS = [
# ...
'wagtail.contrib.modeladmin',
'wagtail_personalisation',
'wagtailfontawesome',
# ...
]
Make sure that ``django.contrib.sessions.middleware.SessionMiddleware`` has
been added in first, this is a prerequisite for this project.
.. code-block:: python
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
# ...
]

View File

@ -0,0 +1,14 @@
Using the sandbox
=================
To experiment with the package you can use the sandbox provided in
the repository_. It includes a couple of segments with rules, a personalisable
page with a variant and a personalisable StreamField block.
To install this you will need to create and activate a
virtualenv and then run ``make sandbox``. This will start a fresh Wagtail
install, with the personalisation module enabled, on http://localhost:8000
and http://localhost:8000/cms/. The superuser credentials are
``superuser@example.com`` with the password ``testing``.
.. _repository: https://github.com/LabD/wagtail-personalisation

View File

@ -1,87 +0,0 @@
Implementation
===============
Extending a page to be personalisable
-------------------------------------
Wagxperience offers a ``PersonalisablePage`` base class to extend from.
This is a standard ``Page`` class with personalisation options added.
Creating a new personalisable page
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Import and extend the ``personalisation.models.PersonalisablePage`` class to create a personalisable page.
A very simple example for a personalisable homepage::
from wagtail_personalisation.models import PersonalisablePage
class HomePage(PersonalisablePage):
subtitle = models.CharField(max_length=255)
body = RichTextField(blank=True, default='')
content_panels = PersonalisablePage.content_panels + [
FieldPanel('subtitle'),
FieldPanel('body'),
]
It's just as simple as extending a standard ``Page`` class.
Migrating an existing page to be personalisable
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Creating custom rules
---------------------
Rules consist of two important elements, the model's fields and the ``test_user`` function.
A very simple example of a rule would look something like this::
from django.db import models
from wagtail.wagtailadmin.edit_handlers import FieldPanel
from personalisation import AbstractBaseRule
class MyNewRule(AbstractBaseRule):
field = models.BooleanField(default=False)
panels = [
FieldPanel('field'),
]
def __init__(self, *args, **kwargs):
super(MyNewRule, self).__init__(*args, **kwargs)
def test_user(self, request):
return self.field
As you can see, the only real requirement is the ``test_user`` function that will either return
``True`` or ``False`` based on the model's fields and optionally the request object.
Below is the "Time rule" model included with the module, which offers more complex functionality::
@python_2_unicode_compatible
class TimeRule(AbstractBaseRule):
"""Time rule to segment users based on a start and end time"""
start_time = models.TimeField(_("Starting time"))
end_time = models.TimeField(_("Ending time"))
panels = [
FieldRowPanel([
FieldPanel('start_time'),
FieldPanel('end_time'),
]),
]
def __init__(self, *args, **kwargs):
super(TimeRule, self).__init__(*args, **kwargs)
def test_user(self, request=None):
current_time = datetime.now().time()
starting_time = self.start_time
ending_time = self.end_time
return starting_time <= current_time <= ending_time
def __str__(self):
return 'Time Rule'
Continue reading: :doc:`usage_guide`

View File

@ -3,22 +3,49 @@
You can adapt this file completely to your liking, but it should at least You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive. contain the root `toctree` directive.
Welcome to the Wagxperience documentation! Welcome to the Wagxperience documentation
========================================== =========================================
.. image:: https://readthedocs.org/projects/wagtail-personalisation/badge/?version=latest
:target: http://wagtail-personalisation.readthedocs.io/en/latest/?badge=latest
.. image:: https://travis-ci.org/wagtail/wagtail-personalisation.svg?branch=master
:target: https://travis-ci.org/wagtail/wagtail-personalisation
.. image:: http://codecov.io/github/wagtail/wagtail-personalisation/coverage.svg?branch=master
:target: http://codecov.io/github/wagtail/wagtail-personalisation?branch=master
.. image:: https://img.shields.io/pypi/v/wagtail-personalisation.svg
:target: https://pypi.python.org/pypi/wagtail-personalisation/
Wagxperience is a fully-featured personalisation module for Wagtail.
It enables editors to create customised pages - or parts of pages - based on
segments whose rules are configured directly in the admin interface.
* **Get up and running**
* :doc:`getting_started/index`
* **For developers**
* :doc:`usage_guide/index`
* **For editors & marketeers**
* :doc:`editor_guide/index`
Index
-----
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
:caption: Contents:
getting_started/index
getting_started usage_guide/index
implementation editor_guide/index
usage_guide
default_rules default_rules
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

BIN
docs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -1,95 +0,0 @@
Usage guide
===========
Creating a segment
------------------
As soon as the installation is completed and configured, the module will be
visible in the Wagtail administrative area.
To create a segment, go to the "Segments" page and click on "Add a new segment".
On this page you will be presented with a form. Follow these steps to create a
new segment:
1. Enter a name for your segment.
2. (Optional) Select whether to match any or all defined rules.
``match any`` will result in a segment that is applied as soon as one of
your rules matches the visitor. When ``match all`` is selected, all rules
must match before the segment is applied.
3. (Optional) Set the segment persistence.
When persistence is enabled, your segment will stick to the visitor once
applied, even if the rules no longer match on the next visit.
4. Define your segment rules.
Wagxperience comes with a basic set of :doc:`default_rules` that allow
you to get started quickly. The rules you define will be evaluated once a
visitor makes a request to your application.
5. Save your segment.
Click "save" to store your segment. It will be enabled by default,
unless otherwise defined.
Creating personalized content
-----------------------------
Once you've created a segment you can start serving these visitors with
personalised content. To do this, you can go one of two directions.
1. Create a copy of a page for your segment.
2. Create StreamField blocks only visible to your segment.
3. Create a template block only visible to your segment.
Method 1: Create a copy
^^^^^^^^^^^^^^^^^^^^^^^
To create a copy from a page for a specific Segment (which you can change to
your liking after copying it) simply go to the Explorer section and find the
page you'd wish to personalize.
You'll notice a new "Variants" dropdown button has appeared. Click the button
and select the segment you'd like to create personalized content for.
Once you've selected the segment, a copy of the page will be created with a
title that includes the segment. Don't worry, your visitors won't be able to
see this title.
You can change everything on this page you'd like. Visitors that are included in
your segment, will automatically see the new page you've created for them.
Method 2: Create a StreamField block
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Method 3: Create a template block
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
You can add a template block that only shows its contents to users of a
specific segment. This is done using the "segment" block.
When editing templates make sure to load the ``wagtail_personalisation_tags``
tags library in the template::
{% load wagtail_personalisation_tags %}
After that you can add a template block with the name of the segment you want
the content to show up for::
{% segment name="My Segment" %}
<p>Only users within "My Segment" see this!</p>
{% endsegment %}
The template block currently only supports one segment at a time. If you want
to target multiple segments you will have to make multiple blocks with the
same content.

View File

@ -0,0 +1,17 @@
Creating custom rules
=====================
Rules consist of two important elements, the model fields and the
``test_user`` function. They should inherit the ``AbstractBaseRule`` class from
``wagtail_personalisation.rules``.
A simple example of a rule could look something like this:
.. literalinclude:: ../../src/wagtail_personalisation/rules.py
:pyobject: UserIsLoggedInRule
As you can see, the only real requirement is the ``test_user`` function that
will either return ``True`` or ``False`` based on the model fields and
optionally the request object.
That's it!

View File

@ -0,0 +1,71 @@
Implementation
==============
Extending a page to be personalisable
-------------------------------------
Wagxperience offers a ``PersonalisablePage`` base class to extend from.
This is a standard ``Page`` class with personalisation options added.
Creating a new personalisable page
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Import and extend the ``personalisation.models.PersonalisablePage`` class to
create a personalisable page.
A very simple example for a personalisable homepage:
.. code-block:: python
from wagtail.wagtailcore.models import Page
from wagtail_personalisation.models import PersonalisablePageMixin
class HomePage(PersonalisablePageMixin, Page):
pass
All you need is the ``PersonalisablePageMixin`` mixin and a Wagtail ``Page``
class of your liking.
.. _implementing_streamfield_blocks:
Adding personalisable StreamField blocks
----------------------------------------
Taking things a step further, you can also add personalisable StreamField blocks
to your page models. Below is the full Homepage model used in the sandbox.
.. literalinclude:: ../../sandbox/sandbox/apps/home/models.py
.. _implementing_template_blocks:
Using template blocks for personalisation
-----------------------------------------
*Please note that using the personalisable template tag is not the recommended
method for adding personalisation to your content, as it is largely decoupled
from the administration interface. Use responsibly.*
You can add a template block that only shows its contents to users of a
specific segment. This is done using the "segment" block.
When editing templates make sure to load the ``wagtail_personalisation_tags``
tags library in the template:
.. code-block:: jinja
{% load wagtail_personalisation_tags %}
After that you can add a template block with the name of the segment you want
the content to show up for:
.. code-block:: jinja
{% segment name="My Segment" %}
<p>Only users within "My Segment" see this!</p>
{% endsegment %}
The template block currently only supports one segment at a time. If you want
to target multiple segments you will have to make multiple blocks with the
same content.

View File

@ -0,0 +1,8 @@
Usage Guide
===========
.. toctree::
:maxdepth: 3
implementation
custom_rules

0
frontend/img/.gitkeep Normal file
View File

View File

@ -86,6 +86,11 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
.stat_card {
display: inline-block;
margin-bottom: 5px;
margin-right: 10px;
}
} }
.block_container .block span.icon::before { .block_container .block span.icon::before {
@ -93,11 +98,6 @@
vertical-align: bottom; vertical-align: bottom;
} }
.block_container .block .inspect_container .inspect li {
display: inline-block;
margin-bottom: 5px;
}
.block_container .block .inspect_container .inspect li span { .block_container .block .inspect_container .inspect li span {
display: block; display: block;
font-size: 20px; font-size: 20px;

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 103 KiB

BIN
logo_bw.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -42,12 +42,12 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/LabD/wagtail-personalisation.git" "url": "git+https://github.com/wagtail/wagtail-personalisation.git"
}, },
"author": "Lab Digital", "author": "Lab Digital",
"license": "ISC", "license": "ISC",
"bugs": { "bugs": {
"url": "https://github.com/LabD/wagtail-personalisation/issues" "url": "https://github.com/wagtail/wagtail-personalisation/issues"
}, },
"homepage": "https://github.com/LabD/wagtail-personalisation#readme" "homepage": "https://github.com/wagtail/wagtail-personalisation#readme"
} }

View File

@ -25,7 +25,7 @@
"enable_date": "2017-06-02T10:58:39.389Z", "enable_date": "2017-06-02T10:58:39.389Z",
"disable_date": "2017-06-02T10:34:51.722Z", "disable_date": "2017-06-02T10:34:51.722Z",
"visit_count": 0, "visit_count": 0,
"status": "enabled", "enabled": true,
"persistent": false, "persistent": false,
"match_any": false "match_any": false
} }
@ -39,7 +39,7 @@
"enable_date": "2017-06-02T10:57:44.497Z", "enable_date": "2017-06-02T10:57:44.497Z",
"disable_date": "2017-06-02T10:57:39.984Z", "disable_date": "2017-06-02T10:57:39.984Z",
"visit_count": 1, "visit_count": 1,
"status": "enabled", "enabled": true,
"persistent": false, "persistent": false,
"match_any": false "match_any": false
} }

View File

@ -1,4 +1,4 @@
Django>=1.11,<1.12 Django>=2.0,<2.1
wagtail>=1.10,<1.11 wagtail>=2.1,<2.2
django-debug-toolbar==1.8 django-debug-toolbar==1.9.1
-e .[docs,test] -e .[docs,test]

View File

@ -3,7 +3,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations from django.db import migrations
import wagtail.wagtailcore.fields import wagtail.core.fields
import wagtail_personalisation import wagtail_personalisation
@ -17,14 +17,14 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='homepage', model_name='homepage',
name='intro', name='intro',
field=wagtail.wagtailcore.fields.RichTextField( field=wagtail.core.fields.RichTextField(
default='<p>Thank you for trying <a href="http://wagxperience.io" target="_blank">Wagxperience</a>!</p>'), default='<p>Thank you for trying <a href="http://wagxperience.io" target="_blank">Wagxperience</a>!</p>'),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField( migrations.AddField(
model_name='homepage', model_name='homepage',
name='body', name='body',
field=wagtail.wagtailcore.fields.StreamField((('personalisable_paragraph', wagtail.wagtailcore.blocks.StructBlock((('segment', wagtail.wagtailcore.blocks.ChoiceBlock(choices=wagtail_personalisation.blocks.list_segment_choices, help_text='Only show this content block for users in this segment', label='Personalisation segment', required=False)), ('paragraph', wagtail.wagtailcore.blocks.RichTextBlock())), icon='pilcrow')),), default=''), field=wagtail.core.fields.StreamField((('personalisable_paragraph', wagtail.core.blocks.StructBlock((('segment', wagtail.core.blocks.ChoiceBlock(choices=wagtail_personalisation.blocks.list_segment_choices, help_text='Only show this content block for users in this segment', label='Personalisation segment', required=False)), ('paragraph', wagtail.core.blocks.RichTextBlock())), icon='pilcrow')),), default=''),
preserve_default=False, preserve_default=False,
), ),
] ]

View File

@ -1,9 +1,9 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from wagtail.wagtailadmin.edit_handlers import RichTextFieldPanel, StreamFieldPanel from wagtail.admin.edit_handlers import RichTextFieldPanel, StreamFieldPanel
from wagtail.wagtailcore import blocks from wagtail.core import blocks
from wagtail.wagtailcore.fields import RichTextField, StreamField from wagtail.core.fields import RichTextField, StreamField
from wagtail.wagtailcore.models import Page from wagtail.core.models import Page
from wagtail_personalisation.models import PersonalisablePageMixin from wagtail_personalisation.models import PersonalisablePageMixin
from wagtail_personalisation.blocks import PersonalisedStructBlock from wagtail_personalisation.blocks import PersonalisedStructBlock

View File

@ -3,8 +3,8 @@ from __future__ import absolute_import, unicode_literals
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.shortcuts import render from django.shortcuts import render
from wagtail.wagtailcore.models import Page from wagtail.core.models import Page
from wagtail.wagtailsearch.models import Query from wagtail.search.models import Query
def search(request): def search(request):

View File

@ -29,21 +29,30 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
SITE_ID = 1
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'wagtail.wagtailforms', 'django.contrib.admin',
'wagtail.wagtailredirects', 'django.contrib.auth',
'wagtail.wagtailembeds', 'django.contrib.contenttypes',
'wagtail.wagtailsites', 'django.contrib.messages',
'wagtail.wagtailusers', 'django.contrib.sessions',
'wagtail.wagtailsnippets', 'django.contrib.sites',
'wagtail.wagtaildocs', 'django.contrib.staticfiles',
'wagtail.wagtailimages',
'wagtail.wagtailsearch', 'wagtail.contrib.forms',
'wagtail.wagtailadmin', 'wagtail.contrib.redirects',
'wagtail.wagtailcore', 'wagtail.embeds',
'wagtail.sites',
'wagtail.users',
'wagtail.snippets',
'wagtail.documents',
'wagtail.images',
'wagtail.search',
'wagtail.admin',
'wagtail.core',
'wagtail.contrib.modeladmin', 'wagtail.contrib.modeladmin',
'wagtailfontawesome', 'wagtailfontawesome',
@ -51,13 +60,6 @@ INSTALLED_APPS = [
'taggit', 'taggit',
'debug_toolbar', 'debug_toolbar',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'wagtail_personalisation', 'wagtail_personalisation',
'sandbox.apps.home', 'sandbox.apps.home',
@ -68,17 +70,17 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware',
'wagtail.wagtailcore.middleware.SiteMiddleware', 'wagtail.core.middleware.SiteMiddleware',
'wagtail.wagtailredirects.middleware.RedirectMiddleware', 'wagtail.contrib.redirects.middleware.RedirectMiddleware',
] ]
ROOT_URLCONF = 'sandbox.urls' ROOT_URLCONF = 'sandbox.urls'

View File

@ -4,14 +4,14 @@ import debug_toolbar
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
from wagtail.wagtailcore import urls as wagtail_urls from wagtail.core import urls as wagtail_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
from sandbox.apps.search import views as search_views from sandbox.apps.search import views as search_views
urlpatterns = [ urlpatterns = [
url(r'^admin/', include(admin.site.urls)), url(r'^admin/', admin.site.urls),
url(r'^cms/', include(wagtailadmin_urls)), url(r'^cms/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)), url(r'^documents/', include(wagtaildocs_urls)),

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.9.1 current_version = 0.12.0
commit = true commit = true
tag = true tag = true
tag_name = {new_version} tag_name = {new_version}
@ -15,15 +15,15 @@ python_paths = .
[flake8] [flake8]
ignore = E731 ignore = E731
max-line-length = 120 max-line-length = 120
exclude = exclude =
src/**/migrations/*.py src/**/migrations/*.py
[wheel] [wheel]
universal = 1 universal = 1
[coverage:run] [coverage]
omit = include = src/**/
src/**/migrations/*.py omit = src/**/migrations/*.py
[bumpversion:file:setup.py] [bumpversion:file:setup.py]

View File

@ -1,29 +1,31 @@
import re import re
from setuptools import find_packages, setup from setuptools import find_packages, setup
install_requires = [ install_requires = [
'wagtail>=1.9,<1.11', 'wagtail>=2.0,<2.2',
'user-agents>=1.0.1', 'user-agents>=1.1.0',
'wagtailfontawesome>=1.0.6', 'wagtailfontawesome>=1.1.3',
] ]
tests_require = [ tests_require = [
'factory_boy==2.8.1', 'factory_boy==2.8.1',
'flake8',
'flake8-blind-except', 'flake8-blind-except',
'flake8-debugger', 'flake8-debugger',
'flake8-imports', 'flake8-imports',
'flake8',
'freezegun==0.3.8', 'freezegun==0.3.8',
'pytest-cov==2.4.0', 'pytest-cov==2.5.1',
'pytest-django==3.1.2', 'pytest-django==3.1.2',
'pytest-sugar==0.7.1', 'pytest-pythonpath==0.7.2',
'pytest==3.1.0', 'pytest-sugar==0.9.1',
'wagtail_factories==0.3.0', 'pytest==3.4.2',
'wagtail_factories==1.0.0',
'pytest-mock==1.6.3',
] ]
docs_require = [ docs_require = [
'sphinx>=1.4.0', 'sphinx>=1.7.6',
'sphinx_rtd_theme>=0.4.0',
] ]
with open('README.rst') as fh: with open('README.rst') as fh:
@ -32,11 +34,11 @@ with open('README.rst') as fh:
setup( setup(
name='wagtail-personalisation', name='wagtail-personalisation',
version='0.9.1', version='0.12.0',
description='A Wagtail add-on for showing personalized content', description='A Wagtail add-on for showing personalized content',
author='Lab Digital BV', author='Lab Digital BV and others',
author_email='opensource@labdigital.nl', author_email='opensource@labdigital.nl',
url='http://labdigital.nl', url='https://labdigital.nl/',
install_requires=install_requires, install_requires=install_requires,
tests_require=tests_require, tests_require=tests_require,
extras_require={ extras_require={
@ -54,16 +56,10 @@ setup(
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License', 'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent', 'Operating System :: OS Independent',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.6',
'Framework :: Django', 'Framework :: Django',
'Framework :: Django :: 1.9', 'Framework :: Django :: 2',
'Framework :: Django :: 1.10',
'Framework :: Django :: 1.11',
'Topic :: Internet :: WWW/HTTP :: Site Management', 'Topic :: Internet :: WWW/HTTP :: Site Management',
], ],
) )

View File

@ -9,7 +9,7 @@ from wagtail_personalisation.rules import AbstractBaseRule
from wagtail_personalisation.utils import create_segment_dictionary from wagtail_personalisation.utils import create_segment_dictionary
class BaseSegmentsAdapter(object): class BaseSegmentsAdapter:
"""Base segments adapter.""" """Base segments adapter."""
def __init__(self, request): def __init__(self, request):
@ -66,34 +66,48 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
self.request.session.setdefault('segments', []) self.request.session.setdefault('segments', [])
self._segment_cache = None self._segment_cache = None
def get_segments(self): def _segments(self, ids=None):
"""Return the persistent segments stored in the request session. if not ids:
ids = []
:returns: The segments in the request session
:rtype: list of wagtail_personalisation.models.Segment or empty list
"""
if self._segment_cache is not None:
return self._segment_cache
raw_segments = self.request.session['segments']
segment_ids = [segment['id'] for segment in raw_segments]
segments = ( segments = (
Segment.objects Segment.objects
.enabled() .enabled()
.filter(persistent=True) .filter(persistent=True)
.in_bulk(segment_ids)) .filter(pk__in=ids)
)
return segments
retval = [segments[pk] for pk in segment_ids if pk in segments] def get_segments(self, key="segments"):
self._segment_cache = retval """Return the persistent segments stored in the request session.
return retval
def set_segments(self, segments): :param key: The key under which the segments are stored
:type key: String
:returns: The segments in the request session
:rtype: list of wagtail_personalisation.models.Segment or empty list
"""
if key == "segments" and self._segment_cache is not None:
return self._segment_cache
if key not in self.request.session:
return []
raw_segments = self.request.session[key]
segment_ids = [segment['id'] for segment in raw_segments]
segments = self._segments(ids=segment_ids)
result = list(segments)
if key == "segments":
self._segment_cache = result
return result
def set_segments(self, segments, key="segments"):
"""Set the currently active segments """Set the currently active segments
:param segments: The segments to set for the current request :param segments: The segments to set for the current request
:type segments: list of wagtail_personalisation.models.Segment :type segments: list of wagtail_personalisation.models.Segment
:param key: The key under which to store the segments. Optional
:type key: String
""" """
cache_segments = [] cache_segments = []
@ -108,8 +122,9 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
serialized_segments.append(serialized) serialized_segments.append(serialized)
segment_ids.add(segment.pk) segment_ids.add(segment.pk)
self.request.session['segments'] = serialized_segments self.request.session[key] = serialized_segments
self._segment_cache = cache_segments if key == "segments":
self._segment_cache = cache_segments
def get_segment_by_id(self, segment_id): def get_segment_by_id(self, segment_id):
"""Find and return a single segment from the request session. """Find and return a single segment from the request session.
@ -120,9 +135,9 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
:rtype: wagtail_personalisation.models.Segment or None :rtype: wagtail_personalisation.models.Segment or None
""" """
for segment in self.get_segments(): segments = self._segments(ids=[segment_id])
if segment.pk == segment_id: if segments.exists():
return segment return segments.get()
def add_page_visit(self, page): def add_page_visit(self, page):
"""Mark the page as visited by the user""" """Mark the page as visited by the user"""
@ -132,18 +147,19 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
if page_visits: if page_visits:
for page_visit in page_visits: for page_visit in page_visits:
page_visit['count'] += 1 page_visit['count'] += 1
page_visit['path'] = page.url_path if page else self.request.path
self.request.session.modified = True self.request.session.modified = True
else: else:
visit_count.append({ visit_count.append({
'slug': page.slug, 'slug': page.slug,
'id': page.pk, 'id': page.pk,
'path': self.request.path, 'path': page.url_path if page else self.request.path,
'count': 1, 'count': 1,
}) })
def get_visit_count(self, page=None): def get_visit_count(self, page=None):
"""Return the number of visits on the current request or given page""" """Return the number of visits on the current request or given page"""
path = page.path if page else self.request.path path = page.url_path if page else self.request.path
visit_count = self.request.session.setdefault('visit_count', []) visit_count = self.request.session.setdefault('visit_count', [])
for visit in visit_count: for visit in visit_count:
if visit['path'] == path: if visit['path'] == path:
@ -170,21 +186,40 @@ class SessionSegmentsAdapter(BaseSegmentsAdapter):
rule_models = AbstractBaseRule.get_descendant_models() rule_models = AbstractBaseRule.get_descendant_models()
current_segments = self.get_segments() current_segments = self.get_segments()
excluded_segments = self.get_segments("excluded_segments")
current_segments = list(
set(current_segments) - set(excluded_segments)
)
# Run tests on all remaining enabled segments to verify applicability. # Run tests on all remaining enabled segments to verify applicability.
additional_segments = [] additional_segments = []
for segment in enabled_segments: for segment in enabled_segments:
segment_rules = [] if segment.is_static and segment.static_users.filter(id=self.request.user.id).exists():
for rule_model in rule_models:
segment_rules.extend(rule_model.objects.filter(segment=segment))
result = self._test_rules(segment_rules, self.request,
match_any=segment.match_any)
if result:
additional_segments.append(segment) additional_segments.append(segment)
elif (segment.excluded_users.filter(id=self.request.user.id).exists() or
segment in excluded_segments):
continue
elif not segment.is_static or not segment.is_full:
segment_rules = []
for rule_model in rule_models:
segment_rules.extend(rule_model.objects.filter(segment=segment))
result = self._test_rules(segment_rules, self.request,
match_any=segment.match_any)
if result and segment.randomise_into_segment():
if segment.is_static and not segment.is_full:
if self.request.user.is_authenticated:
segment.static_users.add(self.request.user)
additional_segments.append(segment)
elif result:
if segment.is_static and self.request.user.is_authenticated:
segment.excluded_users.add(self.request.user)
else:
excluded_segments += [segment]
self.set_segments(current_segments + additional_segments) self.set_segments(current_segments + additional_segments)
self.set_segments(excluded_segments, "excluded_segments")
self.update_visit_count() self.update_visit_count()

View File

@ -13,4 +13,6 @@ urlpatterns = [
views.copy_page_view, name='copy_page'), views.copy_page_view, name='copy_page'),
url(r'^segment/toggle_segment_view/$', url(r'^segment/toggle_segment_view/$',
views.toggle_segment_view, name='toggle_segment_view'), views.toggle_segment_view, name='toggle_segment_view'),
url(r'^segment/users/(?P<segment_id>[0-9]+)$',
views.segment_user_data, name='segment_user_data'),
] ]

View File

@ -1,7 +1,7 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailcore import blocks from wagtail.core import blocks
from wagtail_personalisation.adapters import get_segment_adapter from wagtail_personalisation.adapters import get_segment_adapter
from wagtail_personalisation.models import Segment from wagtail_personalisation.models import Segment

View File

@ -0,0 +1,138 @@
from __future__ import absolute_import, unicode_literals
from datetime import datetime
from importlib import import_module
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.test.client import RequestFactory
from django.utils.lru_cache import lru_cache
from django.utils.translation import ugettext_lazy as _
from wagtail.admin.forms import WagtailAdminModelForm
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
@lru_cache(maxsize=1000)
def user_from_data(user_id):
User = get_user_model()
try:
return User.objects.get(id=user_id)
except User.DoesNotExist:
return AnonymousUser()
class SegmentAdminForm(WagtailAdminModelForm):
def count_matching_users(self, rules, match_any):
""" Calculates how many users match the given static rules
"""
count = 0
static_rules = [rule for rule in rules if rule.static]
if not static_rules:
return count
User = get_user_model()
users = User.objects.filter(is_active=True, is_staff=False)
for user in users.iterator():
if match_any:
if any(rule.test_user(None, user) for rule in static_rules):
count += 1
elif all(rule.test_user(None, user) for rule in static_rules):
count += 1
return count
def clean(self):
cleaned_data = super(SegmentAdminForm, self).clean()
Segment = self._meta.model
rules = [
form.instance for formset in self.formsets.values()
for form in formset
if form not in formset.deleted_forms
]
consistent = rules and Segment.all_static(rules)
if cleaned_data.get('type') == Segment.TYPE_STATIC and not cleaned_data.get('count') and not consistent:
self.add_error('count', _('Static segments with non-static compatible rules must include a count.'))
if self.instance.id and self.instance.is_static:
if self.has_changed():
self.add_error_to_fields(self, excluded=['name', 'enabled'])
for formset in self.formsets.values():
if formset.has_changed():
for form in formset:
if form not in formset.deleted_forms:
self.add_error_to_fields(form)
return cleaned_data
def add_error_to_fields(self, form, excluded=list()):
for field in form.changed_data:
if field not in excluded:
form.add_error(field, _('Cannot update a static segment'))
def save(self, *args, **kwargs):
is_new = not self.instance.id
if not self.instance.is_static:
self.instance.count = 0
if is_new and self.instance.is_static and not self.instance.all_rules_static:
rules = [
form.instance for formset in self.formsets.values()
for form in formset
if form not in formset.deleted_forms
]
self.instance.matched_users_count = self.count_matching_users(
rules, self.instance.match_any)
self.instance.matched_count_updated_at = datetime.now()
instance = super(SegmentAdminForm, self).save(*args, **kwargs)
if is_new and instance.is_static and instance.all_rules_static:
from .adapters import get_segment_adapter
request = RequestFactory().get('/')
request.session = SessionStore()
adapter = get_segment_adapter(request)
users_to_add = []
users_to_exclude = []
User = get_user_model()
users = User.objects.filter(is_active=True, is_staff=False)
matched_count = 0
for user in users.iterator():
request.user = user
passes = adapter._test_rules(instance.get_rules(), request, instance.match_any)
if passes:
matched_count += 1
if instance.count == 0 or len(users_to_add) < instance.count:
if instance.randomise_into_segment():
users_to_add.append(user)
else:
users_to_exclude.append(user)
instance.matched_users_count = matched_count
instance.matched_count_updated_at = datetime.now()
instance.static_users.add(*users_to_add)
instance.excluded_users.add(*users_to_exclude)
return instance
@property
def media(self):
media = super(SegmentAdminForm, self).media
media.add_js(
[static('js/segment_form_control.js')]
)
return media

View File

@ -2,8 +2,8 @@
# Generated by Django 1.11.1 on 2017-05-31 14:28 # Generated by Django 1.11.1 on 2017-05-31 14:28
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-10-17 11:18
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sessions', '0001_initial'),
('wagtail_personalisation', '0012_remove_personalisablepagemetadata_is_segmented'),
]
operations = [
migrations.AddField(
model_name='segment',
name='count',
field=models.PositiveSmallIntegerField(default=0, help_text='If this number is set for a static segment users will be added to the set until the number is reached. After this no more users will be added.'),
),
migrations.AddField(
model_name='segment',
name='sessions',
field=models.ManyToManyField(to='sessions.Session'),
),
migrations.AddField(
model_name='segment',
name='type',
field=models.CharField(choices=[('dynamic', 'Dynamic'), ('static', 'Static')], default='dynamic', help_text='\n </br></br><strong>Dynamic:</strong> Users in this segment will change\n as more or less meet the rules specified in the segment.\n </br><strong>Static:</strong> If the segment contains only static\n compatible rules the segment will contain the members that pass\n those rules when the segment is created. Mixed static segments or\n those containing entirely non static compatible rules will be\n populated using the count variable.\n ', max_length=20),
),
]

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2017-11-01 15:58
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wagtail_personalisation', '0013_add_dynamic_static_to_segment'),
]
operations = [
migrations.RemoveField(
model_name='segment',
name='sessions',
),
migrations.AddField(
model_name='segment',
name='static_users',
field=models.ManyToManyField(to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-01-25 09:18
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0015_static_users'),
]
operations = [
migrations.AddField(
model_name='segment',
name='matched_count_updated_at',
field=models.DateTimeField(editable=False, null=True),
),
migrations.AddField(
model_name='segment',
name='matched_users_count',
field=models.PositiveIntegerField(default=0, editable=False),
),
]

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.8 on 2018-01-31 16:12
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0016_auto_20180125_0918'),
]
operations = [
migrations.AddField(
model_name='segment',
name='randomisation_percent',
field=models.PositiveSmallIntegerField(blank=True, default=None, help_text='If this number is set each user matching the rules will have this percentage chance of being placed in the segment.', null=True, validators=[django.core.validators.MaxValueValidator(100), django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.9 on 2018-02-09 08:28
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wagtail_personalisation', '0017_segment_randomisation_percent'),
]
operations = [
migrations.AddField(
model_name='segment',
name='excluded_users',
field=models.ManyToManyField(help_text='Users that matched the rules but were excluded from the segment for some reason e.g. randomisation', related_name='excluded_segments', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.5 on 2018-05-26 14:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0018_segment_excluded_users'),
]
operations = [
migrations.AlterField(
model_name='personalisablepagemetadata',
name='segment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_metadata', to='wagtail_personalisation.Segment'),
),
]

View File

@ -0,0 +1,50 @@
# Generated by Django 2.0.5 on 2018-05-30 18:51
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0019_auto_20180526_1425'),
]
operations = [
migrations.AlterField(
model_name='dayrule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_dayrules', to='wagtail_personalisation.Segment'),
),
migrations.AlterField(
model_name='devicerule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_devicerules', to='wagtail_personalisation.Segment'),
),
migrations.AlterField(
model_name='queryrule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_queryrules', to='wagtail_personalisation.Segment'),
),
migrations.AlterField(
model_name='referralrule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_referralrules', to='wagtail_personalisation.Segment'),
),
migrations.AlterField(
model_name='timerule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_timerules', to='wagtail_personalisation.Segment'),
),
migrations.AlterField(
model_name='userisloggedinrule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_userisloggedinrules', to='wagtail_personalisation.Segment'),
),
migrations.AlterField(
model_name='visitcountrule',
name='segment',
field=modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='wagtail_personalisation_visitcountrules', to='wagtail_personalisation.Segment'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.7 on 2018-07-04 15:26
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0020_rules_delete_relatedqueryname'),
]
operations = [
migrations.AlterField(
model_name='personalisablepagemetadata',
name='segment',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='page_metadata', to='wagtail_personalisation.Segment'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.7 on 2018-07-05 13:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0021_personalisablepagemetadata_segment_set_on_delete_protect'),
]
operations = [
migrations.AlterField(
model_name='personalisablepagemetadata',
name='canonical_page',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='personalisable_canonical_metadata', to='wagtailcore.Page'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.5 on 2018-07-19 09:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0022_personalisablepagemetadata_canonical_protect'),
]
operations = [
migrations.AlterField(
model_name='personalisablepagemetadata',
name='variant',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_personalisable_page_metadata', to='wagtailcore.Page'),
),
]

View File

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-08-10 13:48
from __future__ import unicode_literals
from django.db import migrations, models
def forward(apps, schema_editor):
Segment = apps.get_model('wagtail_personalisation', 'Segment')
for segment in Segment.objects.all():
segment.enabled = segment.status == 'enabled'
segment.save()
def backward(apps, schema_editor):
Segment = apps.get_model('wagtail_personalisation', 'Segment')
for segment in Segment.objects.all():
if segment.enabled:
segment.status = 'enabled'
else:
segment.status = 'disabled'
segment.save()
class Migration(migrations.Migration):
dependencies = [
('wagtail_personalisation', '0023_personalisablepagemetadata_variant_cascade'),
]
operations = [
migrations.AddField(
model_name='segment',
name='enabled',
field=models.BooleanField(default=True, help_text='Should the segment be active?'),
),
migrations.RunPython(forward, reverse_code=backward),
migrations.RemoveField(
model_name='segment',
name='status',
),
]

View File

@ -1,33 +1,39 @@
from __future__ import absolute_import, unicode_literals import random
from django import forms
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from modelcluster.models import ClusterableModel from modelcluster.models import ClusterableModel
from wagtail.wagtailadmin.edit_handlers import ( from wagtail.admin.edit_handlers import (
FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel) FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel)
from wagtail.wagtailcore.models import Page from wagtail.core.models import Page
from wagtail_personalisation.rules import AbstractBaseRule from wagtail_personalisation.rules import AbstractBaseRule
from wagtail_personalisation.utils import count_active_days from wagtail_personalisation.utils import count_active_days
from .forms import SegmentAdminForm
class SegmentQuerySet(models.QuerySet): class SegmentQuerySet(models.QuerySet):
def enabled(self): def enabled(self):
return self.filter(status=self.model.STATUS_ENABLED) return self.filter(enabled=True)
@python_2_unicode_compatible @python_2_unicode_compatible
class Segment(ClusterableModel): class Segment(ClusterableModel):
"""The segment model.""" """The segment model."""
STATUS_ENABLED = 'enabled' TYPE_DYNAMIC = 'dynamic'
STATUS_DISABLED = 'disabled' TYPE_STATIC = 'static'
STATUS_CHOICES = ( TYPE_CHOICES = (
(STATUS_ENABLED, _('Enabled')), (TYPE_DYNAMIC, _('Dynamic')),
(STATUS_DISABLED, _('Disabled')), (TYPE_STATIC, _('Static')),
) )
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -36,31 +42,82 @@ class Segment(ClusterableModel):
enable_date = models.DateTimeField(null=True, editable=False) enable_date = models.DateTimeField(null=True, editable=False)
disable_date = models.DateTimeField(null=True, editable=False) disable_date = models.DateTimeField(null=True, editable=False)
visit_count = models.PositiveIntegerField(default=0, editable=False) visit_count = models.PositiveIntegerField(default=0, editable=False)
status = models.CharField( enabled = models.BooleanField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_ENABLED) default=True, help_text=_("Should the segment be active?"))
persistent = models.BooleanField( persistent = models.BooleanField(
default=False, help_text=_("Should the segment persist between visits?")) default=False, help_text=_("Should the segment persist between visits?"))
match_any = models.BooleanField( match_any = models.BooleanField(
default=False, default=False,
help_text=_("Should the segment match all the rules or just one of them?") help_text=_("Should the segment match all the rules or just one of them?")
) )
type = models.CharField(
max_length=20,
choices=TYPE_CHOICES,
default=TYPE_DYNAMIC,
help_text=mark_safe(_("""
</br></br><strong>Dynamic:</strong> Users in this segment will change
as more or less meet the rules specified in the segment.
</br><strong>Static:</strong> If the segment contains only static
compatible rules the segment will contain the members that pass
those rules when the segment is created. Mixed static segments or
those containing entirely non static compatible rules will be
populated using the count variable.
"""))
)
count = models.PositiveSmallIntegerField(
default=0,
help_text=_(
"If this number is set for a static segment users will be added to the "
"set until the number is reached. After this no more users will be added."
)
)
static_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
)
excluded_users = models.ManyToManyField(
settings.AUTH_USER_MODEL,
help_text=_("Users that matched the rules but were excluded from the "
"segment for some reason e.g. randomisation"),
related_name="excluded_segments"
)
matched_users_count = models.PositiveIntegerField(default=0, editable=False)
matched_count_updated_at = models.DateTimeField(null=True, editable=False)
randomisation_percent = models.PositiveSmallIntegerField(
null=True, blank=True, default=None,
help_text=_(
"If this number is set each user matching the rules will "
"have this percentage chance of being placed in the segment."
), validators=[
MaxValueValidator(100),
MinValueValidator(0)
])
objects = SegmentQuerySet.as_manager() objects = SegmentQuerySet.as_manager()
base_form_class = SegmentAdminForm
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
Segment.panels = [ Segment.panels = [
MultiFieldPanel([ MultiFieldPanel([
FieldPanel('name', classname="title"), FieldPanel('name', classname="title"),
FieldRowPanel([ FieldRowPanel([
FieldPanel('status'), FieldPanel('enabled'),
FieldPanel('persistent'), FieldPanel('persistent'),
]), ]),
FieldPanel('match_any'), FieldPanel('match_any'),
FieldPanel('type', widget=forms.RadioSelect),
FieldPanel('count', classname='count_field'),
FieldPanel('randomisation_percent', classname='percent_field'),
], heading="Segment"), ], heading="Segment"),
MultiFieldPanel([ MultiFieldPanel([
InlinePanel( InlinePanel(
"{}_related".format(rule_model._meta.db_table), "{}s".format(rule_model._meta.db_table),
label=rule_model._meta.verbose_name, label='{}{}'.format(
rule_model._meta.verbose_name,
' ({})'.format(_('Static compatible')) if rule_model.static else ''
),
) for rule_model in AbstractBaseRule.__subclasses__() ) for rule_model in AbstractBaseRule.__subclasses__()
], heading=_("Rules")), ], heading=_("Rules")),
] ]
@ -70,6 +127,23 @@ class Segment(ClusterableModel):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def is_static(self):
return self.type == self.TYPE_STATIC
@classmethod
def all_static(cls, rules):
return all(rule.static for rule in rules)
@property
def all_rules_static(self):
rules = self.get_rules()
return rules and self.all_static(rules)
@property
def is_full(self):
return self.static_users.count() >= self.count
def encoded_name(self): def encoded_name(self):
"""Return a string with a slug for the segment.""" """Return a string with a slug for the segment."""
return slugify(self.name.lower()) return slugify(self.name.lower())
@ -80,15 +154,11 @@ class Segment(ClusterableModel):
def get_used_pages(self): def get_used_pages(self):
"""Return the pages that have variants using this segment.""" """Return the pages that have variants using this segment."""
pages = list(PersonalisablePageMetadata.objects.filter(segment=self)) return PersonalisablePageMetadata.objects.filter(segment=self)
return pages
def get_created_variants(self): def get_created_variants(self):
"""Return the variants using this segment.""" """Return the variants using this segment."""
pages = Page.objects.filter(_personalisable_page_metadata__segment=self) return Page.objects.filter(_personalisable_page_metadata__segment=self)
return pages
def get_rules(self): def get_rules(self):
"""Retrieve all rules in the segment.""" """Retrieve all rules in the segment."""
@ -100,29 +170,44 @@ class Segment(ClusterableModel):
return segment_rules return segment_rules
def toggle(self, save=True): def toggle(self, save=True):
self.status = ( self.enabled = not self.enabled
self.STATUS_ENABLED if self.status == self.STATUS_DISABLED
else self.STATUS_DISABLED)
if save: if save:
self.save() self.save()
def randomise_into_segment(self):
""" Returns True if randomisation_percent is not set or it generates
a random number less than the randomisation_percent
This is so there is some randomisation in which users are added to the
segment
"""
if self.randomisation_percent is None:
return True
if random.randint(1, 100) <= self.randomisation_percent:
return True
return False
class PersonalisablePageMetadata(ClusterableModel): class PersonalisablePageMetadata(ClusterableModel):
"""The personalisable page model. Allows creation of variants with linked """The personalisable page model. Allows creation of variants with linked
segments. segments.
""" """
# Canonical pages should not ever be deleted if they have variants
# because the variants will be orphaned.
canonical_page = models.ForeignKey( canonical_page = models.ForeignKey(
Page, related_name='personalisable_canonical_metadata', Page, models.PROTECT, related_name='personalisable_canonical_metadata',
on_delete=models.SET_NULL, null=True
blank=True, null=True
) )
# Delete metadata of the variant if its page gets deleted.
variant = models.OneToOneField( variant = models.OneToOneField(
Page, related_name='_personalisable_page_metadata') Page, models.CASCADE, related_name='_personalisable_page_metadata',
null=True
)
segment = models.ForeignKey( segment = models.ForeignKey(Segment, models.PROTECT, null=True,
Segment, related_name='page_metadata', null=True, blank=True) related_name='page_metadata')
@cached_property @cached_property
def has_variants(self): def has_variants(self):
@ -193,7 +278,7 @@ class PersonalisablePageMetadata(ClusterableModel):
return Segment.objects.none() return Segment.objects.none()
class PersonalisablePageMixin(object): class PersonalisablePageMixin:
"""The personalisable page model. Allows creation of variants with linked """The personalisable page model. Allows creation of variants with linked
segments. segments.

View File

@ -7,16 +7,16 @@ from wagtail_personalisation.models import Segment
def check_status_change(sender, instance, *args, **kwargs): def check_status_change(sender, instance, *args, **kwargs):
"""Check if the status has changed. Alter dates accordingly.""" """Check if the status has changed. Alter dates accordingly."""
try: try:
original_status = sender.objects.get(pk=instance.id).status original_status = sender.objects.get(pk=instance.id).enabled
except sender.DoesNotExist: except sender.DoesNotExist:
original_status = "" original_status = None
if original_status != instance.status: if original_status != instance.enabled:
if instance.status == instance.STATUS_ENABLED: if instance.enabled is True:
instance.enable_date = timezone.now() instance.enable_date = timezone.now()
instance.visit_count = 0 instance.visit_count = 0
return instance return instance
if instance.status == instance.STATUS_DISABLED: if instance.enabled is False:
instance.disable_date = timezone.now() instance.disable_date = timezone.now()

View File

@ -2,27 +2,34 @@ from __future__ import absolute_import, unicode_literals
import re import re
from datetime import datetime from datetime import datetime
from importlib import import_module
from django.apps import apps from django.apps import apps
from django.conf import settings
from django.contrib.sessions.models import Session
from django.core.exceptions import ObjectDoesNotExist
from django.db import models from django.db import models
from django.template.defaultfilters import slugify from django.template.defaultfilters import slugify
from django.test.client import RequestFactory
from django.utils.encoding import force_text, python_2_unicode_compatible from django.utils.encoding import force_text, python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from user_agents import parse from user_agents import parse
from wagtail.wagtailadmin.edit_handlers import ( from wagtail.admin.edit_handlers import (
FieldPanel, FieldRowPanel, PageChooserPanel) FieldPanel, FieldRowPanel, PageChooserPanel)
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
@python_2_unicode_compatible @python_2_unicode_compatible
class AbstractBaseRule(models.Model): class AbstractBaseRule(models.Model):
"""Base for creating rules to segment users with.""" """Base for creating rules to segment users with."""
icon = 'fa-circle-o' icon = 'fa-circle-o'
static = False
segment = ParentalKey( segment = ParentalKey(
'wagtail_personalisation.Segment', 'wagtail_personalisation.Segment',
related_name="%(app_label)s_%(class)s_related", related_name="%(app_label)s_%(class)ss",
related_query_name="%(app_label)s_%(class)ss"
) )
class Meta: class Meta:
@ -190,6 +197,7 @@ class VisitCountRule(AbstractBaseRule):
""" """
icon = 'fa-calculator' icon = 'fa-calculator'
static = True
OPERATOR_CHOICES = ( OPERATOR_CHOICES = (
('more_than', _("More than")), ('more_than', _("More than")),
@ -218,16 +226,46 @@ class VisitCountRule(AbstractBaseRule):
class Meta: class Meta:
verbose_name = _('Visit count Rule') verbose_name = _('Visit count Rule')
def test_user(self, request): def _get_user_session(self, user):
sessions = Session.objects.iterator()
for session in sessions:
session_data = session.get_decoded()
if session_data.get('_auth_user_id') == str(user.id):
return SessionStore(session_key=session.session_key)
return SessionStore()
def test_user(self, request, user=None):
# Local import for cyclic import
from wagtail_personalisation.adapters import (
get_segment_adapter, SessionSegmentsAdapter, SEGMENT_ADAPTER_CLASS)
# Django formsets don't honour 'required' fields so check rule is valid
try:
self.counted_page
except ObjectDoesNotExist:
return False
if user:
# Create a fake request so we can use the adapter
request = RequestFactory().get('/')
request.user = user
# If we're using the session adapter check for an active session
if SEGMENT_ADAPTER_CLASS == SessionSegmentsAdapter:
request.session = self._get_user_session(user)
else:
request.session = SessionStore()
elif not request:
# Return false if we don't have a user or a request
return False
operator = self.operator operator = self.operator
segment_count = self.count segment_count = self.count
# Local import for cyclic import
from wagtail_personalisation.adapters import get_segment_adapter
adapter = get_segment_adapter(request) adapter = get_segment_adapter(request)
visit_count = adapter.get_visit_count() visit_count = adapter.get_visit_count(self.counted_page)
if visit_count and operator == "more_than": if visit_count and operator == "more_than":
if visit_count > segment_count: if visit_count > segment_count:
return True return True
@ -250,6 +288,28 @@ class VisitCountRule(AbstractBaseRule):
), ),
} }
def get_column_header(self):
return "Visit count - %s" % self.counted_page
def get_user_info_string(self, user):
# Local import for cyclic import
from wagtail_personalisation.adapters import (
get_segment_adapter, SessionSegmentsAdapter, SEGMENT_ADAPTER_CLASS)
# Create a fake request so we can use the adapter
request = RequestFactory().get('/')
request.user = user
# If we're using the session adapter check for an active session
if SEGMENT_ADAPTER_CLASS == SessionSegmentsAdapter:
request.session = self._get_user_session(user)
else:
request.session = SessionStore()
adapter = get_segment_adapter(request)
visit_count = adapter.get_visit_count(self.counted_page)
return str(visit_count)
class QueryRule(AbstractBaseRule): class QueryRule(AbstractBaseRule):
"""Query rule to segment users based on matching queries. """Query rule to segment users based on matching queries.

View File

@ -1,2 +1,2 @@
.nice-padding{padding-left:50px;padding-right:50px}.block_container{display:block;margin-top:30px}.block_container .block{display:block;float:left;-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;width:calc(50% - 10px);min-height:216px;padding:10px 20px;margin-bottom:20px;border:1px solid #d9d9d9;border-radius:3px;background-color:#fff;-webkit-box-shadow:0 1px 3px transparent,0 1px 2px transparent;box-shadow:0 1px 3px transparent,0 1px 2px transparent;-webkit-transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);cursor:pointer}.block_container .block--disabled .inspect_container,.block_container .block--disabled h2{opacity:.5}.block_container .block h2{display:inline-block;width:auto}.block_container .block:nth-child(odd){margin-right:20px}.block_container .block .block_actions{list-style:none;margin:0;padding:0}.block_container .block .block_actions li{float:left;margin-right:10px}.block_container .block .block_actions li:last-child{margin-right:0}.block_container .block.suggestion{border:1px dashed #d9d9d9}.block_container .block:hover{border:1px solid #fff;-webkit-box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23);box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23)}@media (max-width:699px){.block_container .block{width:100%;margin-right:0}}.block_container .block .inspect_container{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;margin-bottom:10px}.block_container .block .inspect_container .inspect{display:block;float:left;width:calc(50% - 10px);padding:0;margin:0;list-style:none}.block_container .block span.icon:before{margin-right:.3em;vertical-align:bottom}.block_container .block .inspect_container .inspect li{display:inline-block;margin-bottom:5px}.block_container .block .inspect_container .inspect li span{display:block;font-size:20px;font-weight:700;margin:5px 0;overflow-wrap:break-word}.block_container .block .inspect_container .inspect li pre{position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;width:auto;background-color:#eee;border:1px solid #ccc;margin:5px 0 5px 21px;padding:2px 5px;word-wrap:break-word;word-break:break-all;border-radius:3px}.block_container .block.suggestion .suggestive_text{display:block;position:absolute;width:calc(100% - 40px);text-align:center;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);color:#d9d9d9;font-size:20px;font-weight:100} .nice-padding{padding-left:50px;padding-right:50px}.block_container{display:block;margin-top:30px}.block_container .block{display:block;float:left;-webkit-box-sizing:border-box;box-sizing:border-box;position:relative;width:calc(50% - 10px);min-height:216px;padding:10px 20px;margin-bottom:20px;border:1px solid #d9d9d9;border-radius:3px;background-color:#fff;-webkit-box-shadow:0 1px 3px transparent,0 1px 2px transparent;box-shadow:0 1px 3px transparent,0 1px 2px transparent;-webkit-transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1);transition:box-shadow .3s cubic-bezier(.25,.8,.25,1),border .3s cubic-bezier(.25,.8,.25,1),-webkit-box-shadow .3s cubic-bezier(.25,.8,.25,1);cursor:pointer}.block_container .block--disabled .inspect_container,.block_container .block--disabled h2{opacity:.5}.block_container .block h2{display:inline-block;width:auto}.block_container .block:nth-child(odd){margin-right:20px}.block_container .block .block_actions{list-style:none;margin:0;padding:0}.block_container .block .block_actions li{float:left;margin-right:10px}.block_container .block .block_actions li:last-child{margin-right:0}.block_container .block.suggestion{border:1px dashed #d9d9d9}.block_container .block:hover{border:1px solid #fff;-webkit-box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23);box-shadow:0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23)}@media (max-width:699px){.block_container .block{width:100%;margin-right:0}}.block_container .block .inspect_container{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;margin-bottom:10px}.block_container .block .inspect_container .inspect{display:block;float:left;width:calc(50% - 10px);padding:0;margin:0;list-style:none}.block_container .block .inspect_container .inspect .stat_card{display:inline-block;margin-bottom:5px;margin-right:10px}.block_container .block span.icon:before{margin-right:.3em;vertical-align:bottom}.block_container .block .inspect_container .inspect li span{display:block;font-size:20px;font-weight:700;margin:5px 0;overflow-wrap:break-word}.block_container .block .inspect_container .inspect li pre{position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;width:auto;background-color:#eee;border:1px solid #ccc;margin:5px 0 5px 21px;padding:2px 5px;word-wrap:break-word;word-break:break-all;border-radius:3px}.block_container .block.suggestion .suggestive_text{display:block;position:absolute;width:calc(100% - 40px);text-align:center;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);color:#d9d9d9;font-size:20px;font-weight:100}
/*# sourceMappingURL=dashboard.css.map*/ /*# sourceMappingURL=dashboard.css.map*/

View File

@ -1 +1 @@
{"version":3,"sources":["webpack:///./scss/dashboard.scss"],"names":[],"mappings":"AAAA,cACI,kBACA,kBAAmB,CAGvB,iBACI,cACA,eAAgB,CAGpB,wBACI,cACA,WACA,8BAAsB,sBACtB,kBACA,uBACA,iBACA,kBACA,mBACA,yBACA,kBACA,sBACA,+DAAkE,uDAClE,2GAA8F,2UAC9F,cAAe,CAGnB,0FAEI,UAAY,CAGZ,2BACI,qBACA,UAAW,CAGnB,uCACI,iBAAkB,CAGlB,uCACI,gBACA,SACA,SAAU,CAGV,0CACI,WACA,iBAAkB,CAGtB,qDACI,cAAe,CAG3B,mCACI,yBAA0B,CAG9B,8BACI,sBACA,uEAAkE,+DAGtE,yBACI,wBACI,WACA,cAAe,CAClB,CAGL,2CACI,oBAAa,iCACb,8BAAmB,uEACnB,qBAAiB,iBACjB,yBAA8B,oDAC9B,0BAAoB,2CACpB,kBAAmB,CAGvB,oDACI,cACA,WACA,uBACA,UACA,SACA,eAAgB,CAGhB,yCACI,kBACA,qBAAsB,CAG1B,uDACI,qBACA,iBAAkB,CAGtB,4DACI,cACA,eACA,gBACA,aACA,wBAAyB,CAG7B,2DACI,kBACA,8BAAsB,sBACtB,WACA,sBACA,sBACA,sBACA,gBACA,qBACA,qBACA,iBAAkB,CAG1B,oDACI,cACA,kBACA,wBACA,kBACA,QACA,mCAA2B,2BAC3B,cACA,eACA,eAAgB","file":"../css/dashboard.css","sourcesContent":[".nice-padding {\n padding-left: 50px;\n padding-right: 50px;\n}\n\n.block_container {\n display: block;\n margin-top: 30px;\n}\n\n.block_container .block {\n display: block;\n float: left;\n box-sizing: border-box;\n position: relative;\n width: calc(50% - 10px);\n min-height: 216px;\n padding: 10px 20px;\n margin-bottom: 20px;\n border: 1px solid #d9d9d9;\n border-radius: 3px;\n background-color: #fff;\n box-shadow: 0 1px 3px rgba(0,0,0,0.00), 0 1px 2px rgba(0,0,0,0.00);\n transition: box-shadow 0.3s cubic-bezier(.25,.8,.25,1), border 0.3s cubic-bezier(.25,.8,.25,1);\n cursor: pointer;\n}\n\n.block_container .block--disabled h2,\n.block_container .block--disabled .inspect_container {\n opacity: 0.5;\n}\n\n .block_container .block h2 {\n display: inline-block;\n width: auto;\n }\n\n.block_container .block:nth-child(odd) {\n margin-right: 20px;\n}\n\n .block_container .block .block_actions {\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n .block_container .block .block_actions li {\n float: left;\n margin-right: 10px;\n }\n\n .block_container .block .block_actions li:last-child {\n margin-right: 0;\n }\n\n.block_container .block.suggestion {\n border: 1px dashed #d9d9d9;\n}\n\n.block_container .block:hover {\n border: 1px solid #fff;\n box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);\n}\n\n@media (max-width: 699px) {\n .block_container .block {\n width: 100%;\n margin-right: 0;\n }\n}\n\n.block_container .block .inspect_container {\n display: flex;\n flex-direction: row;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: stretch;\n margin-bottom: 10px;\n}\n\n.block_container .block .inspect_container .inspect {\n display: block;\n float: left;\n width: calc(50% - 10px);\n padding: 0;\n margin: 0;\n list-style: none;\n}\n\n .block_container .block span.icon::before {\n margin-right: 0.3em;\n vertical-align: bottom;\n }\n\n .block_container .block .inspect_container .inspect li {\n display: inline-block;\n margin-bottom: 5px;\n }\n\n .block_container .block .inspect_container .inspect li span {\n display: block;\n font-size: 20px;\n font-weight: bold;\n margin: 5px 0;\n overflow-wrap: break-word;\n }\n\n .block_container .block .inspect_container .inspect li pre {\n position: relative;\n box-sizing: border-box;\n width: auto;\n background-color: #eee;\n border: 1px solid #ccc;\n margin: 5px 0 5px 21px;\n padding: 2px 5px;\n word-wrap: break-word;\n word-break: break-all;\n border-radius: 3px;\n }\n\n.block_container .block.suggestion .suggestive_text {\n display: block;\n position: absolute;\n width: calc(100% - 40px);\n text-align: center;\n top: 50%;\n transform: translateY(-50%);\n color: #d9d9d9;\n font-size: 20px;\n font-weight: 100;\n}\n\n\n\n// WEBPACK FOOTER //\n// ./scss/dashboard.scss"],"sourceRoot":""} {"version":3,"sources":["webpack:///./scss/dashboard.scss"],"names":[],"mappings":"AAAA,cACI,kBACA,kBAAmB,CAGvB,iBACI,cACA,eAAgB,CAGpB,wBACI,cACA,WACA,8BAAsB,sBACtB,kBACA,uBACA,iBACA,kBACA,mBACA,yBACA,kBACA,sBACA,+DAAkE,uDAClE,2GAA8F,2UAC9F,cAAe,CAGnB,0FAEI,UAAY,CAGZ,2BACI,qBACA,UAAW,CAGnB,uCACI,iBAAkB,CAGlB,uCACI,gBACA,SACA,SAAU,CAGV,0CACI,WACA,iBAAkB,CAGtB,qDACI,cAAe,CAG3B,mCACI,yBAA0B,CAG9B,8BACI,sBACA,uEAAkE,+DAGtE,yBACI,wBACI,WACA,cAAe,CAClB,CAGL,2CACI,oBAAa,iCACb,8BAAmB,uEACnB,qBAAiB,iBACjB,yBAA8B,oDAC9B,0BAAoB,2CACpB,kBAAmB,CAGvB,oDACI,cACA,WACA,uBACA,UACA,SACA,eAAgB,CAMnB,+DAJO,qBACA,kBACA,iBAAkB,CAItB,yCACI,kBACA,qBAAsB,CAG1B,4DACI,cACA,eACA,gBACA,aACA,wBAAyB,CAG7B,2DACI,kBACA,8BAAsB,sBACtB,WACA,sBACA,sBACA,sBACA,gBACA,qBACA,qBACA,iBAAkB,CAG1B,oDACI,cACA,kBACA,wBACA,kBACA,QACA,mCAA2B,2BAC3B,cACA,eACA,eAAgB","file":"../css/dashboard.css","sourcesContent":[".nice-padding {\n padding-left: 50px;\n padding-right: 50px;\n}\n\n.block_container {\n display: block;\n margin-top: 30px;\n}\n\n.block_container .block {\n display: block;\n float: left;\n box-sizing: border-box;\n position: relative;\n width: calc(50% - 10px);\n min-height: 216px;\n padding: 10px 20px;\n margin-bottom: 20px;\n border: 1px solid #d9d9d9;\n border-radius: 3px;\n background-color: #fff;\n box-shadow: 0 1px 3px rgba(0,0,0,0.00), 0 1px 2px rgba(0,0,0,0.00);\n transition: box-shadow 0.3s cubic-bezier(.25,.8,.25,1), border 0.3s cubic-bezier(.25,.8,.25,1);\n cursor: pointer;\n}\n\n.block_container .block--disabled h2,\n.block_container .block--disabled .inspect_container {\n opacity: 0.5;\n}\n\n .block_container .block h2 {\n display: inline-block;\n width: auto;\n }\n\n.block_container .block:nth-child(odd) {\n margin-right: 20px;\n}\n\n .block_container .block .block_actions {\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n .block_container .block .block_actions li {\n float: left;\n margin-right: 10px;\n }\n\n .block_container .block .block_actions li:last-child {\n margin-right: 0;\n }\n\n.block_container .block.suggestion {\n border: 1px dashed #d9d9d9;\n}\n\n.block_container .block:hover {\n border: 1px solid #fff;\n box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);\n}\n\n@media (max-width: 699px) {\n .block_container .block {\n width: 100%;\n margin-right: 0;\n }\n}\n\n.block_container .block .inspect_container {\n display: flex;\n flex-direction: row;\n flex-wrap: nowrap;\n justify-content: space-between;\n align-items: stretch;\n margin-bottom: 10px;\n}\n\n.block_container .block .inspect_container .inspect {\n display: block;\n float: left;\n width: calc(50% - 10px);\n padding: 0;\n margin: 0;\n list-style: none;\n .stat_card {\n display: inline-block;\n margin-bottom: 5px;\n margin-right: 10px;\n }\n}\n\n .block_container .block span.icon::before {\n margin-right: 0.3em;\n vertical-align: bottom;\n }\n\n .block_container .block .inspect_container .inspect li span {\n display: block;\n font-size: 20px;\n font-weight: bold;\n margin: 5px 0;\n overflow-wrap: break-word;\n }\n\n .block_container .block .inspect_container .inspect li pre {\n position: relative;\n box-sizing: border-box;\n width: auto;\n background-color: #eee;\n border: 1px solid #ccc;\n margin: 5px 0 5px 21px;\n padding: 2px 5px;\n word-wrap: break-word;\n word-break: break-all;\n border-radius: 3px;\n }\n\n.block_container .block.suggestion .suggestive_text {\n display: block;\n position: absolute;\n width: calc(100% - 40px);\n text-align: center;\n top: 50%;\n transform: translateY(-50%);\n color: #d9d9d9;\n font-size: 20px;\n font-weight: 100;\n}\n\n\n\n// WEBPACK FOOTER //\n// ./scss/dashboard.scss"],"sourceRoot":""}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
(function($) {
$(document).ready( () => {
var count = $('.count_field');
var typeRadio = $('input:radio[name="type"]');
var updateCountDispay = function(value) {
if (value == 'dynamic') {
count.slideUp(250);
} else {
count.slideDown(250);
}
};
updateCountDispay(typeRadio.filter(':checked').val());
typeRadio.change( event => {
updateCountDispay(event.target.value);
});
});
})(jQuery);

View File

@ -22,24 +22,38 @@
<div class="nice-padding block_container"> <div class="nice-padding block_container">
{% if all_count %} {% if all_count %}
{% for segment in object_list %} {% for segment in object_list %}
<div class="block block--{{ segment.status }}" onclick="location.href = 'edit/{{ segment.pk }}'"> <div class="block block--{{ segment.enabled|yesno:"enabled,disabled" }}" onclick="location.href = '{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}'">
<h2>{{ segment }}</h2> <h2>{{ segment }}</h2>
<div class="inspect_container"> <div class="inspect_container">
<ul class="inspect segment_stats"> <ul class="inspect segment_stats">
<li class="visit_stat"> <li class="stat_card">
{% trans "This segment has been visited" %} {% trans "This segment has been visited" %}
<span class="icon icon-fa-rocket">{{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}</span> <span class="icon icon-fa-rocket">{{ segment.visit_count|localize }} {% trans "time" %}{{ segment.visit_count|pluralize }}</span>
</li> </li>
<li class="days_stat"> <li class="stat_card">
{% trans "This segment has been active for" %} {% trans "This segment has been active for" %}
<span class="icon icon-fa-calendar">{{ segment.enable_date|days_since:segment.disable_date }} {% trans "day" %}{{ segment.enable_date|days_since:segment.disable_date|pluralize }}</span> <span class="icon icon-fa-calendar">{{ segment.enable_date|days_since:segment.disable_date }} {% trans "day" %}{{ segment.enable_date|days_since:segment.disable_date|pluralize }}</span>
</li> </li>
{% if segment.is_static %}
<li class="stat_card">
{% trans "This segment is Static" %}
<span class="icon icon-fa-user">
{{ segment.static_users.count|localize }}
{% if segment.static_users.count < segment.count %}
/ {{ segment.count }} {% trans "member" %}{{ segment.count|pluralize }}
{% else %}
{% trans "member" %}{{ segment.count|pluralize }}
{% endif %}
</span>
</li>
{% endif %}
</ul> </ul>
<hr /> <hr />
<ul class="inspect segment_rules"> <ul class="inspect segment_rules">
<li class="match_state {{ segment.match_any|yesno:"any,all" }}"> <li class="stat_card {{ segment.match_any|yesno:"any,all" }}">
{% trans "The visitor must match" %} {% trans "The visitor must match" %}
{% if segment.match_any %} {% if segment.match_any %}
<span class="icon icon-fa-cube">{% trans "Any rule" %}</span> <span class="icon icon-fa-cube">{% trans "Any rule" %}</span>
@ -48,7 +62,7 @@
{% endif %} {% endif %}
</li> </li>
<li class="persistent_state {{ segment.persistent|yesno:"persistent,fleeting" }}"> <li class="stat_card {{ segment.persistent|yesno:"persistent,fleeting" }}">
{% trans "The persistence of this segment is" %} {% trans "The persistence of this segment is" %}
{% if segment.persistent %} {% if segment.persistent %}
<span class="icon icon-fa-bookmark" title="{% trans "This segment persists in between visits" %}">{% trans "Persistent" %}</span> <span class="icon icon-fa-bookmark" title="{% trans "This segment persists in between visits" %}">{% trans "Persistent" %}</span>
@ -57,8 +71,15 @@
{% endif %} {% endif %}
</li> </li>
{% if segment.randomisation_percent is not None %}
<li class="stat_card">
<span>{{ segment.randomisation_percent }} %</span>
{% trans "Chance that visitors matching the rules are added to the segment" %}
</li>
{% endif %}
{% for rule in segment.get_rules %} {% for rule in segment.get_rules %}
<li class="{{ rule.encoded_name }}"> <li class="stat_card {{ rule.encoded_name }}">
{{ rule.description.title }} {{ rule.description.title }}
{% if rule.description.code %} {% if rule.description.code %}
<pre>{{ rule.description.value }}</pre> <pre>{{ rule.description.value }}</pre>
@ -67,17 +88,25 @@
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
{% if segment.matched_users_count > 0 %}
<li class="stat_card">
<span class="icon icon-fa-user"> {{ segment.matched_users_count }} {% trans "user" %}{{ segment.matched_users_count|pluralize }}</span> {% trans "were possible matches for this segment at creation" %}
</li>
{% endif %}
</ul> </ul>
</div> </div>
{% if user_can_create %} {% if user_can_create %}
<ul class="block_actions"> <ul class="block_actions">
{% if segment.status == segment.STATUS_DISABLED %} {% if segment.enabled %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Enable this segment" %}">enable</a></li>
{% elif segment.status == segment.STATUS_ENABLED %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li> <li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Disable this segment" %}">disable</a></li>
{% else %}
<li><a href="{% url 'segment:toggle' segment.pk %}" title="{% trans "Enable this segment" %}">enable</a></li>
{% endif %}
<li><a href="{% url 'wagtail_personalisation_segment_modeladmin_edit' segment.pk %}" title="{% trans "Configure this segment" %}">configure this</a></li>
{% if segment.is_static %}
<li><a href="{% url 'segment:segment_user_data' segment.pk %}" title="{% trans "Download user info" %}">download users csv</a></li>
{% endif %} {% endif %}
<li><a href="edit/{{ segment.pk }}" title="{% trans "Configure this segment" %}">configure this</a></li>
</ul> </ul>
{% endif %} {% endif %}
</div> </div>

View File

@ -0,0 +1,49 @@
{% extends "modeladmin/delete.html" %}
{% load i18n modeladmin_tags %}
{% block content_main %}
<div class="nice-padding">
{% if protected_error %}
<h2>{% blocktrans with view.verbose_name|capfirst as model_name %}{{ model_name }} could not be deleted{% endblocktrans %}</h2>
<p>{% blocktrans with instance as instance_name %}'{{ instance_name }}' is currently referenced by other objects, and cannot be deleted without jeopardising data integrity. To delete it successfully, first remove references from the following objects, then try again:{% endblocktrans %}</p>
<ul>
{% for obj in linked_objects %}<li><b>{{ obj|get_content_type_for_obj|title }}:</b> {{ obj }}</li>{% endfor %}
</ul>
<p><a href="{{ view.index_url }}" class="button">{% trans 'Go back to listing' %}</a></p>
{% elif cannot_delete_page_variants_error %}
<h2>{% blocktrans %}Cannot delete all the page variants{% endblocktrans %}</h2>
<p>{% blocktrans %}You need to have permissions to delete the page variants associated with this segment.{% endblocktrans %}
{% else %}
{% with page_variants=view.get_affected_page_objects %}
{% if page_variants %}
<p>
{% blocktrans %}Deleting the segment will also mean deleting all the page variants associated with it. Do you want to continue?{% endblocktrans %}
</p>
<p>
{% blocktrans %}The page objects that <strong>will be deleted</strong> are:{% endblocktrans %}
</p>
<ul>
{% for variant in page_variants %}
<li>
<a href="{% url 'wagtailadmin_explore' variant.pk %}">
{{ variant }}
</a>
</li>
{% endfor %}
</ul>
{% trans 'Yes, delete the segment and associated page variants' as submit_button_value %}
{% else %}
<p>
{% blocktrans %}Do you want to continue deleting this segment?{% endblocktrans %}
</p>
{% trans 'Yes, delete the segment' as submit_button_value %}
{% endif %}
<form action="{{ view.delete_url }}" method="POST">
{% csrf_token %}
<input type="submit" value="{{ submit_button_value }}" class="button serious" />
</form>
{% endwith %}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% extends "wagtailadmin/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block content %}
{% trans "Delete" as del_str %}
{% include "wagtailadmin/shared/header.html" with title=del_str subtitle=page.get_admin_display_title icon="doc-empty-inverse" %}
<div class="nice-padding">
<p>
{% trans 'Are you sure you want to delete this page?' %}
{% if descendant_count %}
{% blocktrans count counter=descendant_count %}
This will also delete one more subpage.
{% plural %}
This will also delete {{ descendant_count }} more subpages.
{% endblocktrans %}
{% endif %}
</p>
{% if variants %}
<p>
{% blocktrans count counter=variants|length %}
This page is personalisable. Deleting it will delete its variant:
{% plural %}
This page is personalisable. Deleting it will delete all of its variants:
{% endblocktrans %}
</p>
<ul>
{% for variant in variants %}
<li>
<a href="{% url 'wagtailadmin_explore' variant.pk %}">
{{ variant }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<form action="{% url 'wagtailadmin_pages:delete' page.id %}" method="POST">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
{% if variants %}
{% trans 'Yes, delete the page and its variants' as submit_button_value %}
{% else %}
{% trans 'Yes, delete it' as submit_button_value %}
{% endif %}
<input type="submit" value="{{ submit_button_value }}" class="button serious">
<a href="{% if next %}{{ next }}{% else %}{% url 'wagtailadmin_explore' page.get_parent.id %}{% endif %}" class="button button-secondary">{% trans "No, don't delete it" %}</a>
</form>
{% page_permissions page as page_perms %}
{% if page_perms.can_unpublish %}
{% url 'wagtailadmin_pages:unpublish' page.id as unpublish_url %}
<p style="margin-top: 1em">{% blocktrans %}Alternatively you can <a href="{{ unpublish_url }}">unpublish the page</a>. This removes the page from public view and you can edit or publish it again later.{% endblocktrans %}</p>
{% endif %}
</div>
{% endblock %}

View File

@ -1,5 +1,6 @@
import time import time
from django.db.models import F
from django.template.base import FilterExpression, kwarg_re from django.template.base import FilterExpression, kwarg_re
from django.utils import timezone from django.utils import timezone
@ -98,14 +99,20 @@ def parse_tag(token, parser):
def exclude_variants(pages): def exclude_variants(pages):
"""Checks if page is not a variant """Checks if page is not a variant
:param pages: List of pages to check :param pages: Set of pages to check
:type pages: list :type pages: QuerySet
:return: List of pages that aren't variants :return: Queryset of pages that aren't variants
:rtype: list :rtype: QuerySet
""" """
return [page for page in pages from wagtail_personalisation.models import PersonalisablePageMetadata
if (hasattr(page, 'personalisation_metadata') is False) excluded_variant_pages = PersonalisablePageMetadata.objects.exclude(
or (hasattr(page, 'personalisation_metadata') canonical_page_id=F('variant_id')
and page.personalisation_metadata is None) ).values_list('variant_id')
or (hasattr(page, 'personalisation_metadata') return pages.exclude(pk__in=excluded_variant_pages)
and page.personalisation_metadata.is_canonical)]
def can_delete_pages(pages, user):
for variant in pages:
if not variant.permissions_for_user(user).can_delete():
return False
return True

View File

@ -1,15 +1,21 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import csv
from django import forms from django import forms
from django.core.urlresolvers import reverse from django.core.exceptions import PermissionDenied
from django.http import HttpResponseForbidden, HttpResponseRedirect from django.db import transaction
from django.http import (
HttpResponse, HttpResponseForbidden, HttpResponseRedirect)
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
from wagtail.contrib.modeladmin.views import IndexView from wagtail.contrib.modeladmin.views import DeleteView, IndexView
from wagtail.wagtailcore.models import Page from wagtail.core.models import Page
from wagtail_personalisation.models import Segment from wagtail_personalisation.models import Segment
from wagtail_personalisation.utils import can_delete_pages
class SegmentModelIndexView(IndexView): class SegmentModelIndexView(IndexView):
@ -32,15 +38,52 @@ class SegmentModelDashboardView(IndexView):
] ]
class SegmentModelDeleteView(DeleteView):
def get_affected_page_objects(self):
return Page.objects.filter(pk__in=(
self.instance.get_used_pages().values_list('variant_id', flat=True)
))
def get_template_names(self):
return [
'modeladmin/wagtail_personalisation/segment/delete.html',
'modeladmin/delete.html',
]
def delete_instance(self):
page_variants = self.get_affected_page_objects()
if not can_delete_pages(page_variants, self.request.user):
raise PermissionDenied(
'User has no permission to delete variant page objects.'
)
# Deleting page objects triggers deletion of the personalisation
# metadata too because of models.CASCADE.
with transaction.atomic():
for variant in page_variants.iterator():
# Delete each one separately so signals are called.
variant.delete()
super().delete_instance()
def post(self, request, *args, **kwargs):
if not can_delete_pages(self.get_affected_page_objects(),
self.request.user):
context = self.get_context_data(
cannot_delete_page_variants_error=True,
)
return self.render_to_response(context)
return super().post(request, *args, **kwargs)
@modeladmin_register @modeladmin_register
class SegmentModelAdmin(ModelAdmin): class SegmentModelAdmin(ModelAdmin):
"""The model admin for the Segments administration interface.""" """The model admin for the Segments administration interface."""
model = Segment model = Segment
index_view_class = SegmentModelIndexView index_view_class = SegmentModelIndexView
dashboard_view_class = SegmentModelDashboardView dashboard_view_class = SegmentModelDashboardView
delete_view_class = SegmentModelDeleteView
menu_icon = 'fa-snowflake-o' menu_icon = 'fa-snowflake-o'
add_to_settings_menu = False add_to_settings_menu = False
list_display = ('name', 'persistent', 'match_any', 'status', list_display = ('name', 'persistent', 'match_any', 'enabled',
'page_count', 'variant_count', 'statistics') 'page_count', 'variant_count', 'statistics')
index_view_extra_js = ['js/commons.js', 'js/index.js'] index_view_extra_js = ['js/commons.js', 'js/index.js']
index_view_extra_css = ['css/index.css'] index_view_extra_css = ['css/index.css']
@ -139,3 +182,32 @@ def copy_page_view(request, page_id, segment_id):
return HttpResponseRedirect(edit_url) return HttpResponseRedirect(edit_url)
return HttpResponseForbidden() return HttpResponseForbidden()
# CSV download views
def segment_user_data(request, segment_id):
if request.user.has_perm('wagtailadmin.access_admin'):
segment = get_object_or_404(Segment, pk=segment_id)
response = HttpResponse(content_type='text/csv; charset=utf-8')
response['Content-Disposition'] = \
'attachment;filename=segment-%s-users.csv' % str(segment_id)
headers = ['Username']
for rule in segment.get_rules():
if rule.static:
headers.append(rule.get_column_header())
writer = csv.writer(response)
writer.writerow(headers)
for user in segment.static_users.all():
row = [user.username]
for rule in segment.get_rules():
if rule.static:
row.append(rule.get_user_info_string(user))
writer.writerow(row)
return response
return HttpResponseForbidden()

View File

@ -3,14 +3,18 @@ from __future__ import absolute_import, unicode_literals
import logging import logging
from django.conf.urls import include, url from django.conf.urls import include, url
from django.core.urlresolvers import reverse from django.db import transaction
from django.shortcuts import redirect, render
from django.template.defaultfilters import pluralize from django.template.defaultfilters import pluralize
from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.site_summary import SummaryItem, PagesSummaryItem from wagtail.admin import messages
from wagtail.wagtailadmin.widgets import Button, ButtonWithDropdownFromHook from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem
from wagtail.wagtailcore import hooks from wagtail.admin.views.pages import get_valid_next_url_from_request
from wagtail.wagtailcore.models import Page from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook
from wagtail.core import hooks
from wagtail.core.models import Page
from wagtail_personalisation import admin_urls, models, utils from wagtail_personalisation import admin_urls, models, utils
from wagtail_personalisation.adapters import get_segment_adapter from wagtail_personalisation.adapters import get_segment_adapter
@ -23,9 +27,7 @@ def register_admin_urls():
"""Adds the administration urls for the personalisation apps.""" """Adds the administration urls for the personalisation apps."""
return [ return [
url(r'^personalisation/', include( url(r'^personalisation/', include(
admin_urls, admin_urls, namespace='wagtail_personalisation')),
app_name='wagtail_personalisation',
namespace='wagtail_personalisation')),
] ]
@ -35,7 +37,7 @@ def set_visit_count(page, request, serve_args, serve_kwargs):
to a segment. to a segment.
:param page: The page being served :param page: The page being served
:type page: wagtail.wagtailcore.models.Page :type page: wagtail.core.models.Page
:param request: The http request :param request: The http request
:type request: django.http.HttpRequest :type request: django.http.HttpRequest
@ -49,7 +51,7 @@ def segment_user(page, request, serve_args, serve_kwargs):
"""Apply a segment to a visitor before serving the page. """Apply a segment to a visitor before serving the page.
:param page: The page being served :param page: The page being served
:type page: wagtail.wagtailcore.models.Page :type page: wagtail.core.models.Page
:param request: The http request :param request: The http request
:type request: django.http.HttpRequest :type request: django.http.HttpRequest
@ -57,18 +59,42 @@ def segment_user(page, request, serve_args, serve_kwargs):
adapter = get_segment_adapter(request) adapter = get_segment_adapter(request)
adapter.refresh() adapter.refresh()
forced_segment = request.GET.get('segment', None)
if request.user.is_superuser and forced_segment is not None:
segment = models.Segment.objects.filter(pk=forced_segment).first()
if segment:
adapter.set_segments([segment])
class UserbarSegmentedLinkItem:
def __init__(self, segment):
self.segment = segment
def render(self, request):
return f"""<div class="wagtail-userbar__item">
<a href="{request.path}?segment={self.segment.pk}"
class="wagtail-action">
Show as segment: {self.segment.name}</a></div>"""
@hooks.register('construct_wagtail_userbar')
def add_segment_link_items(request, items):
for item in models.Segment.objects.enabled():
items.append(UserbarSegmentedLinkItem(item))
return items
@hooks.register('before_serve_page') @hooks.register('before_serve_page')
def serve_variant(page, request, serve_args, serve_kwargs): def serve_variant(page, request, serve_args, serve_kwargs):
"""Apply a segment to a visitor before serving the page. """Apply a segment to a visitor before serving the page.
:param page: The page being served :param page: The page being served
:type page: wagtail.wagtailcore.models.Page :type page: wagtail.core.models.Page
:param request: The http request :param request: The http request
:type request: django.http.HttpRequest :type request: django.http.HttpRequest
:returns: A variant if one is available for the visitor's segment, :returns: A variant if one is available for the visitor's segment,
otherwise the original page otherwise the original page
:rtype: wagtail.wagtailcore.models.Page :rtype: wagtail.core.models.Page
""" """
user_segments = [] user_segments = []
@ -146,13 +172,24 @@ def page_listing_more_buttons(page, page_perms, is_parent=False):
priority=200) priority=200)
class CorrectedPagesSummaryPanel(PagesSummaryItem): class CorrectedPagesSummaryItem(PagesSummaryItem):
def get_context(self): def get_context(self):
context = super(CorrectedPagesSummaryPanel, self).get_context() # Perform the same check as Wagtail to get the correct count.
# Only correct the count when a root page is available to the user.
# The `PagesSummaryItem` will return a page count of 0 otherwise.
# https://github.com/wagtail/wagtail/blob/5c9ff23e229acabad406c42c4e13cbaea32e6c15/wagtail/admin/site_summary.py#L38
context = super().get_context()
root_page = context.get('root_page', None)
if root_page:
pages = utils.exclude_variants(
Page.objects.descendant_of(root_page, inclusive=True))
page_count = pages.count()
pages = utils.exclude_variants(Page.objects.all().specific()) if root_page.is_root():
page_count -= 1
context['total_pages'] = page_count
context['total_pages'] = len(pages) - 1
return context return context
@ -161,7 +198,7 @@ def add_corrected_pages_summary_panel(request, items):
"""Replaces the Pages summary panel to hide variants.""" """Replaces the Pages summary panel to hide variants."""
for index, item in enumerate(items): for index, item in enumerate(items):
if item.__class__ is PagesSummaryItem: if item.__class__ is PagesSummaryItem:
items[index] = CorrectedPagesSummaryPanel(request) items[index] = CorrectedPagesSummaryItem(request)
class SegmentSummaryPanel(SummaryItem): class SegmentSummaryPanel(SummaryItem):
@ -217,3 +254,54 @@ def add_personalisation_summary_panels(request, items):
items.append(SegmentSummaryPanel(request)) items.append(SegmentSummaryPanel(request))
items.append(PersonalisedPagesSummaryPanel(request)) items.append(PersonalisedPagesSummaryPanel(request))
items.append(VariantPagesSummaryPanel(request)) items.append(VariantPagesSummaryPanel(request))
@hooks.register('before_delete_page')
def delete_related_variants(request, page):
if not isinstance(page, models.PersonalisablePageMixin) \
or not page.personalisation_metadata.is_canonical:
return
# Get a list of related personalisation metadata for all the related
# variants.
variants_metadata = (
page.personalisation_metadata.variants_metadata
.select_related('variant')
)
next_url = get_valid_next_url_from_request(request)
if request.method == 'POST':
parent_id = page.get_parent().id
variants_metadata = variants_metadata.select_related('variant')
with transaction.atomic():
for metadata in variants_metadata.iterator():
# Call delete() on objects to trigger any signals or hooks.
metadata.variant.delete()
# Delete the page's main variant and the page itself.
page.personalisation_metadata.delete()
page.delete()
msg = _("Page '{0}' and its variants deleted.")
messages.success(
request,
msg.format(page.get_admin_display_title())
)
for fn in hooks.get_hooks('after_delete_page'):
result = fn(request, page)
if hasattr(result, 'status_code'):
return result
if next_url:
return redirect(next_url)
return redirect('wagtailadmin_explore', parent_id)
return render(
request,
'wagtailadmin/pages/wagtail_personalisation/confirm_delete.html', {
'page': page,
'descendant_count': page.get_descendant_count(),
'next': next_url,
'variants': Page.objects.filter(
pk__in=variants_metadata.values_list('variant_id')
)
}
)

View File

@ -9,7 +9,7 @@ pytest_plugins = [
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def django_db_setup(django_db_setup, django_db_blocker): def django_db_setup(django_db_setup, django_db_blocker):
from wagtail.wagtailcore.models import Page, Site from wagtail.core.models import Page, Site
with django_db_blocker.unblock(): with django_db_blocker.unblock():
# Remove some initial data that is brought by the tests.site module # Remove some initial data that is brought by the tests.site module

View File

@ -8,6 +8,7 @@ from tests.site.pages import models
class ContentPageFactory(PageFactory): class ContentPageFactory(PageFactory):
parent = None
title = 'Test page' title = 'Test page'
slug = factory.LazyAttribute(lambda obj: slugify(obj.title)) slug = factory.LazyAttribute(lambda obj: slugify(obj.title))

View File

@ -7,7 +7,7 @@ from wagtail_personalisation import models
class SegmentFactory(factory.DjangoModelFactory): class SegmentFactory(factory.DjangoModelFactory):
name = 'TestSegment' name = 'TestSegment'
status = models.Segment.STATUS_ENABLED enabled = True
class Meta: class Meta:
model = models.Segment model = models.Segment

View File

@ -1,5 +1,5 @@
import factory import factory
from wagtail.wagtailcore.models import Site from wagtail.core.models import Site
from tests.factories.page import ContentPageFactory from tests.factories.page import ContentPageFactory

View File

@ -44,3 +44,8 @@ class RequestFactory(BaseRequestFactory):
request.session = SessionStore() request.session = SessionStore()
request._messages = FallbackStorage(request) request._messages = FallbackStorage(request)
return request return request
@pytest.fixture
def user(django_user_model):
return django_user_model.objects.create(username='user')

View File

@ -1,10 +1,6 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import os import os
from pkg_resources import parse_version as V
import django
DATABASES = { DATABASES = {
'default': { 'default': {
@ -56,36 +52,28 @@ TEMPLATES = [
}, },
] ]
def get_middleware_settings(): MIDDLEWARE = (
return ( 'django.middleware.common.CommonMiddleware',
'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'wagtail.wagtailcore.middleware.SiteMiddleware', 'wagtail.core.middleware.SiteMiddleware',
) )
# Django 1.10 started to use "MIDDLEWARE" instead of "MIDDLEWARE_CLASSES".
if V(django.get_version()) < V('1.10'):
MIDDLEWARE_CLASSES = get_middleware_settings()
else:
MIDDLEWARE = get_middleware_settings()
INSTALLED_APPS = ( INSTALLED_APPS = (
'wagtail_personalisation', 'wagtail_personalisation',
'wagtail.contrib.modeladmin', 'wagtail.contrib.modeladmin',
'wagtail.wagtailsearch', 'wagtail.search',
'wagtail.wagtailsites', 'wagtail.sites',
'wagtail.wagtailusers', 'wagtail.users',
'wagtail.wagtailimages', 'wagtail.images',
'wagtail.wagtaildocs', 'wagtail.documents',
'wagtail.wagtailadmin', 'wagtail.admin',
'wagtail.wagtailcore', 'wagtail.core',
'taggit', 'taggit',

View File

@ -3,7 +3,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import django.db.models.deletion import django.db.models.deletion
import wagtail.wagtailcore.fields import wagtail.core.fields
from django.db import migrations, models from django.db import migrations, models
import wagtail_personalisation.models import wagtail_personalisation.models
@ -21,9 +21,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='ContentPage', name='ContentPage',
fields=[ fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), # noqa: E501
('subtitle', models.CharField(blank=True, default='', max_length=255)), ('subtitle', models.CharField(blank=True, default='', max_length=255)),
('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')), ('body', wagtail.core.fields.RichTextField(blank=True, default='')),
], ],
options={ options={
'abstract': False, 'abstract': False,

View File

@ -1,10 +1,9 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.11.1 on 2017-06-02 04:26
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import wagtail.wagtailcore.fields import wagtail.core.fields
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,9 +17,9 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='RegularPage', name='RegularPage',
fields=[ fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')), # noqa: E501
('subtitle', models.CharField(blank=True, default='', max_length=255)), ('subtitle', models.CharField(blank=True, default='', max_length=255)),
('body', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')), ('body', wagtail.core.fields.RichTextField(blank=True, default='')),
], ],
options={ options={
'abstract': False, 'abstract': False,

View File

@ -1,9 +1,7 @@
from __future__ import absolute_import, unicode_literals
from django.db import models from django.db import models
from wagtail.wagtailadmin.edit_handlers import FieldPanel from wagtail.admin.edit_handlers import FieldPanel
from wagtail.wagtailcore.fields import RichTextField from wagtail.core.fields import RichTextField
from wagtail.wagtailcore.models import Page from wagtail.core.models import Page
from wagtail_personalisation.models import PersonalisablePageMixin from wagtail_personalisation.models import PersonalisablePageMixin

View File

@ -2,12 +2,12 @@ from __future__ import absolute_import, unicode_literals
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
from wagtail.wagtailcore import urls as wagtail_urls from wagtail.core import urls as wagtail_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
urlpatterns = [ urlpatterns = [
url(r'^django-admin/', include(admin.site.urls)), url(r'^django-admin/', admin.site.urls),
url(r'^admin/', include(wagtailadmin_urls)), url(r'^admin/', include(wagtailadmin_urls)),
url(r'^documents/', include(wagtaildocs_urls)), url(r'^documents/', include(wagtaildocs_urls)),

View File

@ -20,6 +20,23 @@ def test_get_segments(rf):
assert segments == [segment_1, segment_2] assert segments == [segment_1, segment_2]
@pytest.mark.django_db
def test_get_segments_session(rf):
request = rf.get('/')
adapter = adapters.SessionSegmentsAdapter(request)
segment_1 = SegmentFactory(name='segment-1', persistent=True)
segment_2 = SegmentFactory(name='segment-2', persistent=True)
adapter.set_segments([segment_1, segment_2])
assert len(request.session['segments']) == 2
adapter._segment_cache = None
segments = adapter.get_segments()
assert segments == [segment_1, segment_2]
@pytest.mark.django_db @pytest.mark.django_db
def test_get_segment_by_id(rf): def test_get_segment_by_id(rf):
request = rf.get('/') request = rf.get('/')
@ -47,7 +64,7 @@ def test_refresh_removes_disabled(rf):
adapter.set_segments([segment_1, segment_2]) adapter.set_segments([segment_1, segment_2])
adapter = adapters.SessionSegmentsAdapter(request) adapter = adapters.SessionSegmentsAdapter(request)
segment_1.status = segment_1.STATUS_DISABLED segment_1.enabled = False
segment_1.save() segment_1.save()
adapter.refresh() adapter.refresh()

View File

@ -4,29 +4,25 @@ import datetime
import pytest import pytest
from tests.factories.page import ContentPageFactory from tests.factories.rule import QueryRuleFactory, ReferralRuleFactory
from tests.factories.rule import (
DayRuleFactory, DeviceRuleFactory, ReferralRuleFactory, TimeRuleFactory)
from tests.factories.segment import SegmentFactory from tests.factories.segment import SegmentFactory
from tests.factories.site import SiteFactory
from wagtail_personalisation.models import Segment from wagtail_personalisation.models import Segment
from wagtail_personalisation.rules import TimeRule from wagtail_personalisation.rules import TimeRule
# Factory tests # Factory tests
@pytest.mark.django_db @pytest.mark.django_db
def test_segment_create(): def test_segment_create():
factoried_segment = SegmentFactory() factoried_segment = SegmentFactory()
segment = Segment(name='TestSegment', status='enabled') segment = Segment(name='TestSegment', enabled=True)
TimeRule( TimeRule(
start_time=datetime.time(8, 0, 0), start_time=datetime.time(8, 0, 0),
end_time=datetime.time(23, 0, 0), end_time=datetime.time(23, 0, 0),
segment=segment) segment=segment)
assert factoried_segment.name == segment.name assert factoried_segment.name == segment.name
assert factoried_segment.status == segment.status assert factoried_segment.enabled == segment.enabled
@pytest.mark.django_db @pytest.mark.django_db
@ -37,3 +33,15 @@ def test_referral_rule_create():
segment=segment) segment=segment)
assert referral_rule.regex_string == 'test.test' assert referral_rule.regex_string == 'test.test'
@pytest.mark.django_db
def test_query_rule_create():
segment = SegmentFactory(name='Query')
query_rule = QueryRuleFactory(
parameter="query",
value="value",
segment=segment)
assert query_rule.parameter == 'query'
assert query_rule.value == 'value'

View File

@ -3,8 +3,12 @@ from __future__ import absolute_import, unicode_literals
import datetime import datetime
import pytest import pytest
from django.db.models import ProtectedError
from tests.factories.page import ContentPageFactory
from tests.factories.segment import SegmentFactory from tests.factories.segment import SegmentFactory
from tests.site.pages import models
from wagtail_personalisation.models import PersonalisablePageMetadata
from wagtail_personalisation.rules import TimeRule from wagtail_personalisation.rules import TimeRule
@ -25,3 +29,34 @@ def test_metadata_page_has_variants(segmented_page):
canonical = segmented_page.personalisation_metadata.canonical_page canonical = segmented_page.personalisation_metadata.canonical_page
assert canonical.personalisation_metadata.is_canonical assert canonical.personalisation_metadata.is_canonical
assert canonical.personalisation_metadata.has_variants assert canonical.personalisation_metadata.has_variants
@pytest.mark.django_db
def test_content_page_model():
page = ContentPageFactory()
qs = models.ContentPage.objects.all()
assert page in qs
@pytest.mark.django_db
def test_variant_can_be_deleted_without_error(segmented_page):
segmented_page.delete()
# Make sure the metadata gets deleted because of models.CASCADE.
with pytest.raises(PersonalisablePageMetadata.DoesNotExist):
segmented_page._personalisable_page_metadata.refresh_from_db()
@pytest.mark.django_db
def test_canonical_page_deletion_is_protected(segmented_page):
# When deleting canonical page without deleting variants, it should return
# an error. All variants should be deleted beforehand.
with pytest.raises(ProtectedError):
segmented_page.personalisation_metadata.canonical_page.delete()
@pytest.mark.django_db
def test_page_protection_when_deleting_segment(segmented_page):
segment = segmented_page.personalisation_metadata.segment
assert len(segment.get_used_pages())
with pytest.raises(ProtectedError):
segment.delete()

View File

@ -16,6 +16,8 @@ def test_time_rule_create():
segment=segment) segment=segment)
assert time_rule.start_time == datetime.time(8, 0, 0) assert time_rule.start_time == datetime.time(8, 0, 0)
@pytest.mark.django_db @pytest.mark.django_db
@freeze_time("10:00:00") @freeze_time("10:00:00")
def test_requesttime_segment(client, site): def test_requesttime_segment(client, site):

View File

@ -1,5 +1,9 @@
import pytest import pytest
from tests.factories.rule import VisitCountRuleFactory
from tests.factories.segment import SegmentFactory
from wagtail_personalisation.rules import VisitCountRule
@pytest.mark.django_db @pytest.mark.django_db
def test_visit_count(site, client): def test_visit_count(site, client):
@ -20,3 +24,56 @@ def test_visit_count(site, client):
visit_count = client.session['visit_count'] visit_count = client.session['visit_count']
assert visit_count[0]['count'] == 2 assert visit_count[0]['count'] == 2
assert visit_count[1]['count'] == 1 assert visit_count[1]['count'] == 1
@pytest.mark.django_db
def test_call_test_user_on_invalid_rule_fails(site, user, mocker):
rule = VisitCountRule()
assert not (rule.test_user(None, user))
@pytest.mark.django_db
def test_visit_count_call_test_user_with_user(site, client, user):
segment = SegmentFactory(name='VisitCount')
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
session = client.session
session['visit_count'] = [{'path': '/', 'count': 2}]
session.save()
client.force_login(user)
assert rule.test_user(None, user)
@pytest.mark.django_db
def test_visit_count_call_test_user_with_user_or_request_fails(site, client, user):
segment = SegmentFactory(name='VisitCount')
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
session = client.session
session['visit_count'] = [{'path': '/', 'count': 2}]
session.save()
client.force_login(user)
assert not rule.test_user(None)
@pytest.mark.django_db
def test_get_column_header(site):
segment = SegmentFactory(name='VisitCount')
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
assert rule.get_column_header() == 'Visit count - Test page'
@pytest.mark.django_db
def test_get_user_info_string_returns_count(site, client, user):
segment = SegmentFactory(name='VisitCount')
rule = VisitCountRuleFactory(counted_page=site.root_page, segment=segment)
session = client.session
session['visit_count'] = [{'path': '/', 'count': 2}]
session.save()
client.force_login(user)
assert rule.get_user_info_string(user) == '2'

View File

@ -0,0 +1,574 @@
from __future__ import absolute_import, unicode_literals
import datetime
import pytest
from django.forms.models import model_to_dict
from tests.factories.segment import SegmentFactory
from wagtail_personalisation.forms import SegmentAdminForm
from wagtail_personalisation.models import Segment
from wagtail_personalisation.rules import TimeRule, VisitCountRule
def form_with_data(segment, *rules):
model_fields = ['type', 'enabled', 'count', 'name', 'match_any', 'randomisation_percent']
class TestSegmentAdminForm(SegmentAdminForm):
class Meta:
model = Segment
fields = model_fields
data = model_to_dict(segment, model_fields)
for formset in TestSegmentAdminForm().formsets.values():
rule_data = {}
count = 0
for rule in rules:
if isinstance(rule, formset.model):
rule_data = model_to_dict(rule)
for key, value in rule_data.items():
data['{}-{}-{}'.format(formset.prefix, count, key)] = value
count += 1
data['{}-INITIAL_FORMS'.format(formset.prefix)] = 0
data['{}-TOTAL_FORMS'.format(formset.prefix)] = count
return TestSegmentAdminForm(data)
@pytest.mark.django_db
def test_user_added_to_static_segment_at_creation(site, user, mocker):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
instance = form.save()
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_user_not_added_to_full_static_segment_at_creation(site, django_user_model, mocker):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user',
side_effect=[True, True])
instance = form.save()
assert len(instance.static_users.all()) == 1
@pytest.mark.django_db
def test_anonymous_user_not_added_to_static_segment_at_creation(site, client, mocker):
session = client.session
session.save()
client.get(site.root_page.url)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
instance = form.save()
assert not instance.static_users.all()
assert mock_test_rule.call_count == 0
@pytest.mark.django_db
def test_match_any_correct_populates(site, django_user_model, mocker):
user = django_user_model.objects.create(username='first')
other_user = django_user_model.objects.create(username='second')
other_page = site.root_page.get_last_child()
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, match_any=True)
rule_1 = VisitCountRule(counted_page=site.root_page)
rule_2 = VisitCountRule(counted_page=other_page)
form = form_with_data(segment, rule_1, rule_2)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', side_effect=[True, False, True, False])
instance = form.save()
assert user in instance.static_users.all()
assert other_user in instance.static_users.all()
@pytest.mark.django_db
def test_mixed_static_dynamic_session_doesnt_generate_at_creation(site, mocker):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
static_rule = VisitCountRule(counted_page=site.root_page)
non_static_rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
)
form = form_with_data(segment, static_rule, non_static_rule)
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
instance = form.save()
assert not instance.static_users.all()
assert mock_test_rule.call_count == 0
@pytest.mark.django_db
def test_session_not_added_to_static_segment_after_creation(site, client, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=0)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert not instance.static_users.all()
@pytest.mark.django_db
def test_session_added_to_static_segment_after_creation(site, client, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert user in instance.static_users.all()
@pytest.mark.django_db
def test_anonymou_user_not_added_to_static_segment_after_creation(site, client):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.get(site.root_page.url)
assert not instance.static_users.all()
@pytest.mark.django_db
def test_session_not_added_to_static_segment_after_full(site, client, django_user_model):
user = django_user_model.objects.create(username='first')
other_user = django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
assert not instance.static_users.all()
session = client.session
client.force_login(user)
client.get(site.root_page.url)
assert instance.static_users.count() == 1
client.cookies.clear()
second_session = client.session
client.force_login(other_user)
client.get(site.root_page.url)
assert session.session_key != second_session.session_key
assert instance.static_users.count() == 1
assert user in instance.static_users.all()
assert other_user not in instance.static_users.all()
@pytest.mark.django_db
def test_sessions_not_added_to_static_segment_if_rule_not_static(mocker):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
segment=segment,
)
form = form_with_data(segment, rule)
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
instance = form.save()
assert not instance.static_users.all()
assert mock_test_rule.call_count == 0
@pytest.mark.django_db
def test_does_not_calculate_the_segment_again(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=2)
rule = VisitCountRule(counted_page=site.root_page, segment=segment)
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
instance = form.save()
assert user in instance.static_users.all()
mock_test_rule = mocker.patch('wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert mock_test_rule.call_count == 0
@pytest.mark.django_db
def test_non_static_rules_have_a_count():
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=0)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
segment=segment,
)
form = form_with_data(segment, rule)
assert not form.is_valid()
@pytest.mark.django_db
def test_static_segment_with_static_rules_needs_no_count(site):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=0)
rule = VisitCountRule(counted_page=site.root_page, segment=segment)
form = form_with_data(segment, rule)
assert form.is_valid()
@pytest.mark.django_db
def test_dynamic_segment_with_non_static_rules_have_a_count():
segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC, count=0)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
)
form = form_with_data(segment, rule)
assert form.is_valid(), form.errors
@pytest.mark.django_db
def test_randomisation_percentage_added_to_segment_at_creation(site, client, mocker, django_user_model):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
segment.randomisation_percent = 80
rule = VisitCountRule()
form = form_with_data(segment, rule)
instance = form.save()
assert instance.randomisation_percent == 80
@pytest.mark.django_db
def test_randomisation_percentage_min_zero(site, client, mocker, django_user_model):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
segment.randomisation_percent = -1
rule = VisitCountRule()
form = form_with_data(segment, rule)
assert not form.is_valid()
@pytest.mark.django_db
def test_randomisation_percentage_max_100(site, client, mocker, django_user_model):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
segment.randomisation_percent = 101
rule = VisitCountRule()
form = form_with_data(segment, rule)
assert not form.is_valid()
@pytest.mark.django_db
def test_in_static_segment_if_random_is_below_percentage(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
mocker.patch('random.randint', return_value=39)
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert instance.id == client.session['segments'][0]['id']
assert user in instance.static_users.all()
assert user not in instance.excluded_users.all()
@pytest.mark.django_db
def test_not_in_static_segment_if_random_is_above_percentage(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
mocker.patch('random.randint', return_value=41)
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert len(client.session['segments']) == 0
assert user not in instance.static_users.all()
assert user in instance.excluded_users.all()
@pytest.mark.django_db
def test_offered_dynamic_segment_if_random_is_below_percentage(site, client, mocker):
segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC,
randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
mocker.patch('random.randint', return_value=39)
session = client.session
session.save()
client.get(site.root_page.url)
assert len(client.session['excluded_segments']) == 0
assert instance.id == client.session['segments'][0]['id']
@pytest.mark.django_db
def test_not_offered_dynamic_segment_if_random_is_above_percentage(site, client, mocker):
segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC,
randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
mocker.patch('random.randint', return_value=41)
session = client.session
session.save()
client.get(site.root_page.url)
assert len(client.session['segments']) == 0
assert instance.id == client.session['excluded_segments'][0]['id']
@pytest.mark.django_db
def test_not_in_segment_if_percentage_is_0(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=0)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert len(client.session['segments']) == 0
assert user not in instance.static_users.all()
assert user in instance.excluded_users.all()
@pytest.mark.django_db
def test_always_in_segment_if_percentage_is_100(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=100)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert instance.id == client.session['segments'][0]['id']
assert user in instance.static_users.all()
assert user not in instance.excluded_users.all()
@pytest.mark.django_db
def test_not_added_to_static_segment_at_creation_if_random_above_percent(site, mocker, user):
mocker.patch('random.randint', return_value=41)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
instance = form.save()
assert user not in instance.static_users.all()
assert user in instance.excluded_users.all()
@pytest.mark.django_db
def test_added_to_static_segment_at_creation_if_random_below_percent(site, mocker, user):
mocker.patch('random.randint', return_value=39)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, randomisation_percent=40)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
instance = form.save()
assert user in instance.static_users.all()
assert user not in instance.excluded_users.all()
@pytest.mark.django_db
def test_rules_check_skipped_if_user_in_excluded(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_STATIC, count=1,
randomisation_percent=100)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
instance.excluded_users.add(user)
instance.save
mock_test_rule = mocker.patch(
'wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
session = client.session
session.save()
client.force_login(user)
client.get(site.root_page.url)
assert mock_test_rule.call_count == 0
assert len(client.session['segments']) == 0
assert user not in instance.static_users.all()
assert user in instance.excluded_users.all()
@pytest.mark.django_db
def test_rules_check_skipped_if_dynamic_segment_in_excluded(site, client, mocker, user):
segment = SegmentFactory.build(type=Segment.TYPE_DYNAMIC,
randomisation_percent=100)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
instance = form.save()
instance.persistent = True
instance.save()
session = client.session
session['excluded_segments'] = [{'id': instance.pk}]
session.save()
mock_test_rule = mocker.patch(
'wagtail_personalisation.adapters.SessionSegmentsAdapter._test_rules')
client.force_login(user)
client.get(site.root_page.url)
assert mock_test_rule.call_count == 0
assert len(client.session['segments']) == 0
@pytest.mark.django_db
def test_matched_user_count_added_to_segment_at_creation(site, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule()
form = form_with_data(segment, rule)
form.instance.type = Segment.TYPE_STATIC
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
instance = form.save()
assert mock_test_user.call_count == 2
instance.matched_users_count = 2
@pytest.mark.django_db
def test_count_users_matching_static_rules(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
assert form.count_matching_users([rule], True) is 2
@pytest.mark.django_db
def test_count_matching_users_excludes_staff(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second', is_staff=True)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
assert form.count_matching_users([rule], True) is 1
assert mock_test_user.call_count == 1
@pytest.mark.django_db
def test_count_matching_users_excludes_inactive(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second', is_active=False)
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = VisitCountRule(counted_page=site.root_page)
form = form_with_data(segment, rule)
mock_test_user = mocker.patch('wagtail_personalisation.rules.VisitCountRule.test_user', return_value=True)
assert form.count_matching_users([rule], True) is 1
assert mock_test_user.call_count == 1
@pytest.mark.django_db
def test_count_matching_users_only_counts_static_rules(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
rule = TimeRule(
start_time=datetime.time(0, 0, 0),
end_time=datetime.time(23, 59, 59),
segment=segment,
)
form = form_with_data(segment, rule)
mock_test_user = mocker.patch('wagtail_personalisation.rules.TimeRule.test_user')
assert form.count_matching_users([rule], True) is 0
assert mock_test_user.call_count == 0
@pytest.mark.django_db
def test_count_matching_users_handles_match_any(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
first_rule = VisitCountRule(counted_page=site.root_page)
other_page = site.root_page.get_last_child()
second_rule = VisitCountRule(counted_page=other_page)
form = form_with_data(segment, first_rule, second_rule)
mock_test_user = mocker.patch(
'wagtail_personalisation.rules.VisitCountRule.test_user',
side_effect=[True, False, True, False])
assert form.count_matching_users([first_rule, second_rule], True) is 2
mock_test_user.call_count == 4
@pytest.mark.django_db
def test_count_matching_users_handles_match_all(site, client, mocker, django_user_model):
django_user_model.objects.create(username='first')
django_user_model.objects.create(username='second')
segment = SegmentFactory.build(type=Segment.TYPE_STATIC)
first_rule = VisitCountRule(counted_page=site.root_page)
other_page = site.root_page.get_last_child()
second_rule = VisitCountRule(counted_page=other_page)
form = form_with_data(segment, first_rule, second_rule)
mock_test_user = mocker.patch(
'wagtail_personalisation.rules.VisitCountRule.test_user',
side_effect=[True, True, False, True])
assert form.count_matching_users([first_rule, second_rule], False) is 1
mock_test_user.call_count == 4

View File

@ -1,21 +1,38 @@
from wagtail_personalisation.utils import impersonate_other_page import pytest
from tests.factories.page import ContentPageFactory
from wagtail_personalisation.utils import (
can_delete_pages, impersonate_other_page)
class Page(object): @pytest.fixture
def __init__(self, path, depth, url_path, title): def rootpage():
self.path = path return ContentPageFactory(parent=None, path='/', depth=0, title='root')
self.depth = depth
self.url_path = url_path
self.title = title
def __eq__(self, other):
return self.__dict__ == other.__dict__
def test_impersonate_other_page(): @pytest.fixture
page = Page(path="/", depth=0, url_path="/", title="Hoi") def page(rootpage):
other_page = Page(path="/other", depth=1, url_path="/other", title="Doei") return ContentPageFactory(parent=rootpage, path='/hi', title='Hi')
impersonate_other_page(page, other_page)
assert page == other_page @pytest.fixture
def otherpage(rootpage):
return ContentPageFactory(parent=rootpage, path='/bye', title='Bye')
@pytest.mark.django_db
def test_impersonate_other_page(page, otherpage):
impersonate_other_page(page, otherpage)
assert page.title == otherpage.title == 'Bye'
assert page.path == otherpage.path
@pytest.mark.django_db
def test_can_delete_pages_with_superuser(rf, user, segmented_page):
user.is_superuser = True
assert can_delete_pages([segmented_page], user)
@pytest.mark.django_db
def test_cannot_delete_pages_with_standard_user(user, segmented_page):
assert not can_delete_pages([segmented_page], user)

110
tests/unit/test_views.py Normal file
View File

@ -0,0 +1,110 @@
import pytest
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from wagtail.core.models import Page
from wagtail_personalisation.models import Segment
from wagtail_personalisation.rules import VisitCountRule
from wagtail_personalisation.views import (
SegmentModelDeleteView, SegmentModelAdmin)
@pytest.mark.django_db
def test_segment_user_data_view_requires_admin_access(site, client, django_user_model):
user = django_user_model.objects.create(username='first')
segment = Segment(type=Segment.TYPE_STATIC, count=1)
segment.save()
client.force_login(user)
url = reverse('segment:segment_user_data', args=(segment.id,))
response = client.get(url)
assert response.status_code == 302
assert response.url == '/admin/login/?next=%s' % url
@pytest.mark.django_db
def test_segment_user_data_view(site, client, mocker, django_user_model):
user1 = django_user_model.objects.create(username='first')
user2 = django_user_model.objects.create(username='second')
admin_user = django_user_model.objects.create(
username='admin', is_superuser=True)
segment = Segment(type=Segment.TYPE_STATIC, count=1)
segment.save()
segment.static_users.add(user1)
segment.static_users.add(user2)
rule1 = VisitCountRule(counted_page=site.root_page, segment=segment)
rule2 = VisitCountRule(counted_page=site.root_page.get_last_child(),
segment=segment)
rule1.save()
rule2.save()
mocker.patch('wagtail_personalisation.rules.VisitCountRule.get_user_info_string',
side_effect=[3, 9, 0, 1])
client.force_login(admin_user)
response = client.get(
reverse('segment:segment_user_data', args=(segment.id,)))
assert response.status_code == 200
data_lines = response.content.decode().split("\n")
assert data_lines[0] == 'Username,Visit count - Test page,Visit count - Regular page\r'
assert data_lines[1] == 'first,3,9\r'
assert data_lines[2] == 'second,0,1\r'
@pytest.mark.django_db
def test_segment_delete_view_delete_instance(rf, segmented_page, user):
user.is_superuser = True
user.save()
segment = segmented_page.personalisation_metadata.segment
canonical_page = segmented_page.personalisation_metadata.canonical_page
variants_metadata = segment.get_used_pages()
page_variants = Page.objects.filter(pk__in=(
variants_metadata.values_list('variant_id', flat=True)
))
# Make sure all canonical page, variants and variants metadata exist
assert canonical_page
assert page_variants
assert variants_metadata
# Delete the segment via the method on the view.
request = rf.get('/'.format(segment.pk))
request.user = user
view = SegmentModelDeleteView(
instance_pk=str(segment.pk),
model_admin=SegmentModelAdmin()
)
view.request = request
view.delete_instance()
# Segment has been deleted.
with pytest.raises(segment.DoesNotExist):
segment.refresh_from_db()
# Canonical page stayed intact.
canonical_page.refresh_from_db()
# Variant pages and their metadata have been deleted.
assert not page_variants.all()
assert not variants_metadata.all()
@pytest.mark.django_db
def test_segment_delete_view_raises_permission_denied(rf, segmented_page, user):
segment = segmented_page.personalisation_metadata.segment
request = rf.get('/'.format(segment.pk))
request.user = user
view = SegmentModelDeleteView(
instance_pk=str(segment.pk),
model_admin=SegmentModelAdmin()
)
view.request = request
message = 'User have no permission to delete variant page objects.'
with pytest.raises(PermissionDenied, message=message):
view.delete_instance()

View File

@ -1,4 +1,5 @@
import pytest import pytest
from wagtail.core.models import Page
from tests.factories.segment import SegmentFactory from tests.factories.segment import SegmentFactory
from wagtail_personalisation import adapters, wagtail_hooks from wagtail_personalisation import adapters, wagtail_hooks
@ -60,3 +61,54 @@ def test_page_listing_more_buttons(site, rf, segmented_page):
result = wagtail_hooks.page_listing_more_buttons(page, []) result = wagtail_hooks.page_listing_more_buttons(page, [])
items = list(result) items = list(result)
assert len(items) == 3 assert len(items) == 3
@pytest.mark.django_db
def test_custom_delete_page_view_does_not_trigger_for_variants(
rf,
segmented_page
):
assert (
wagtail_hooks.delete_related_variants(rf.get('/'), segmented_page)
) is None
@pytest.mark.django_db
def test_custom_delete_page_view_triggers_for_canonical_pages(
rf,
segmented_page
):
assert (
wagtail_hooks.delete_related_variants(
rf.get('/'),
segmented_page.personalisation_metadata.canonical_page
)
) is not None
@pytest.mark.django_db
def test_custom_delete_page_view_deletes_variants(rf, segmented_page, user):
post_request = rf.post('/')
user.is_superuser = True
rf.user = user
canonical_page = segmented_page.personalisation_metadata.canonical_page
canonical_page_variant = canonical_page.personalisation_metadata
assert canonical_page_variant
variants = Page.objects.filter(pk__in=(
canonical_page.personalisation_metadata.variants_metadata.values_list('variant_id', flat=True)
))
variants_metadata = canonical_page.personalisation_metadata.variants_metadata
# Make sure there are variants that exist in the database.
assert len(variants.all())
assert len(variants_metadata.all())
wagtail_hooks.delete_related_variants(
post_request, segmented_page.personalisation_metadata.canonical_page
)
with pytest.raises(canonical_page.DoesNotExist):
canonical_page.refresh_from_db()
with pytest.raises(canonical_page_variant.DoesNotExist):
canonical_page_variant.refresh_from_db()
# Make sure all the variant pages have been deleted.
assert not len(variants.all())
assert not len(variants_metadata.all())

19
tox.ini
View File

@ -1,28 +1,25 @@
[tox] [tox]
envlist = py{27,35,36}-django{19,110,111}-wagtail{19,110},lint envlist = py{36}-django{20}-wagtail{20,21},lint
[testenv] [testenv]
basepython = python3.6
commands = coverage run --parallel -m pytest {posargs} commands = coverage run --parallel -m pytest {posargs}
extras = test extras = test
deps = deps =
django19: django>=1.9,<1.10 django20: django>=2.0,<2.1
django110: django>=1.10<1.11 wagtail20: wagtail>=2.0,<2.1
django111: django>=1.11,<1.12 wagtail21: wagtail>=2.1,<2.2
wagtail19: wagtail>=1.9,<1.10
wagtail110: wagtail>=1.10,<1.11
[testenv:coverage-report] [testenv:coverage-report]
basepython = python3.5 basepython = python3.6
deps = coverage deps = coverage
pip_pre = true pip_pre = true
skip_install = true skip_install = true
commands = commands =
coverage combine coverage report --include="src/**/" --omit="src/**/migrations/*.py"
coverage report
[testenv:lint] [testenv:lint]
basepython = python3.5 basepython = python3.6
deps = flake8 deps = flake8
commands = commands =
flake8 src tests setup.py flake8 src tests setup.py