├── tests
├── app
│ ├── __init__.py
│ ├── models.py
│ ├── urls.py
│ └── settings.py
├── fixtures
│ ├── facebook.3.json
│ ├── instagram.2.json
│ ├── twitter.2.json
│ └── twitter.3.json
├── factories.py
├── __init__.py
├── test_utils.py
├── test_models.py
├── test_blocks.py
├── test_views.py
└── test_feed.py
├── wagtailsocialfeed
├── migrations
│ ├── __init__.py
│ ├── 0003_auto_20161006_1021.py
│ ├── 0002_auto_20161005_1032.py
│ └── 0001_initial.py
├── __init__.py
├── templates
│ └── wagtailsocialfeed
│ │ ├── includes
│ │ └── feed_item.html
│ │ ├── social_feed_block.html
│ │ ├── social_feed_page.html
│ │ └── admin
│ │ ├── moderate.html
│ │ └── header.html
├── urls.py
├── managers.py
├── utils
│ ├── conf.py
│ ├── feed
│ │ ├── factory.py
│ │ ├── instagram.py
│ │ ├── twitter.py
│ │ ├── facebook.py
│ │ └── __init__.py
│ └── __init__.py
├── static
│ └── wagtailsocialfeed
│ │ ├── js
│ │ └── admin
│ │ │ └── moderate.js
│ │ └── css
│ │ └── admin
│ │ └── moderate-page.css
├── blocks.py
├── models.py
├── wagtail_hooks.py
└── views.py
├── docs
├── authors.rst
├── history.rst
├── readme.rst
├── contributing.rst
├── modules.rst
├── wagtailsocialfeed.migrations.rst
├── index.rst
├── wagtailsocialfeed.utils.rst
├── wagtailsocialfeed.utils.feed.rst
├── configuration.rst
├── wagtailsocialfeed.rst
├── installation.rst
├── usage.rst
├── Makefile
├── make.bat
└── conf.py
├── readthedocs.yml
├── .github
├── PULL_REQUEST_TEMPLATE.md
└── ISSUE_TEMPLATE.md
├── AUTHORS.rst
├── MANIFEST.in
├── .codecov.yml
├── .editorconfig
├── runtests.py
├── setup.cfg
├── .coveragerc
├── tox.ini
├── .gitignore
├── README.rst
├── LICENSE
├── CHANGELOG.rst
├── .travis.yml
├── setup.py
├── Makefile
└── CONTRIBUTING.rst
/tests/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/app/models.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/authors.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../AUTHORS.rst
2 |
--------------------------------------------------------------------------------
/docs/history.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../HISTORY.rst
2 |
--------------------------------------------------------------------------------
/docs/readme.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../README.rst
2 |
--------------------------------------------------------------------------------
/readthedocs.yml:
--------------------------------------------------------------------------------
1 | python:
2 | pip_install: true
3 |
--------------------------------------------------------------------------------
/docs/contributing.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CONTRIBUTING.rst
2 |
--------------------------------------------------------------------------------
/tests/fixtures/facebook.3.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/wagtailsocialfeed/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = '0.4.1'
2 |
--------------------------------------------------------------------------------
/docs/modules.rst:
--------------------------------------------------------------------------------
1 | wagtailsocialfeed
2 | =================
3 |
4 | .. toctree::
5 | :maxdepth: 4
6 |
7 | wagtailsocialfeed
8 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ### Thanks for contributing!
2 |
3 | Please make sure all the tests pass, before creating a pull-request.
4 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/templates/wagtailsocialfeed/includes/feed_item.html:
--------------------------------------------------------------------------------
1 |
{{ item.text }}
2 | {{ item.posted }}
3 | {% if item.image %}
4 | {{ image.image }}
5 |
6 | {% endif %}
7 |
--------------------------------------------------------------------------------
/AUTHORS.rst:
--------------------------------------------------------------------------------
1 | =======
2 | Credits
3 | =======
4 |
5 | Development Lead
6 | ----------------
7 |
8 | * Tim Leguijt
9 |
10 | Contributors
11 | ------------
12 |
13 | * Janneke Janssen
14 | * Mike Dingjan
15 | * Nazmul Hasan
16 | * Oktay Altay
17 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include AUTHORS.rst
2 | include CONTRIBUTING.rst
3 | include HISTORY.rst
4 | include LICENSE
5 | include README.rst
6 |
7 | recursive-include wagtailsocialfeed/static *
8 | recursive-include wagtailsocialfeed/templates *
9 |
10 | recursive-exclude * __pycache__
11 | recursive-exclude * *.py[co]
12 |
--------------------------------------------------------------------------------
/tests/app/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import include, url
2 |
3 | from wagtail.wagtailcore import urls as wagtail_urls
4 | from wagtail.wagtailadmin import urls as wagtailadmin_urls
5 |
6 | urlpatterns = [
7 | url(r'^cms/', include(wagtailadmin_urls)),
8 | url(r'', include(wagtail_urls)),
9 | ]
10 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/templates/wagtailsocialfeed/social_feed_block.html:
--------------------------------------------------------------------------------
1 | {% for item in feed %}
2 | {% if item.moderated %}
3 | {% include 'wagtailsocialfeed/includes/feed_item.html' with item=item.get_content %}
4 | {% else %}
5 | {% include 'wagtailsocialfeed/includes/feed_item.html' %}
6 | {% endif %}
7 | {% endfor %}
8 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | Found a bug, please fill out the sections below
2 |
3 | ### Issue summary
4 |
5 | A summary of the issue
6 |
7 | ### How to reproduce?
8 |
9 | Please fill in the steps to take to reproduce
10 |
11 | ### Technical details
12 |
13 | * Python, django, wagtail and wagtailtrans version
14 | * Browser version, if applicable
15 |
16 |
--------------------------------------------------------------------------------
/.codecov.yml:
--------------------------------------------------------------------------------
1 | ---
2 | coverage:
3 | precision: 2
4 | round: down
5 | range: "70...100"
6 |
7 | status:
8 | project:
9 | default:
10 | target: auto
11 | if_no_uploads: error
12 |
13 | patch:
14 | default:
15 | if_no_uploads: error
16 |
17 | changes: true
18 |
19 | comment:
20 | layout: "header, diff, tree"
21 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 4
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | charset = utf-8
11 | end_of_line = lf
12 |
13 | [*.bat]
14 | indent_style = tab
15 | end_of_line = crlf
16 |
17 | [LICENSE]
18 | insert_final_newline = false
19 |
20 | [Makefile]
21 | indent_style = tab
22 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 |
6 |
7 | def run():
8 | from django.core.management import execute_from_command_line
9 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.app.settings'
10 | os.environ.setdefault('DATABASE_NAME', ':memory:')
11 | execute_from_command_line([sys.argv[0], 'test'] + sys.argv[1:])
12 |
13 |
14 | if __name__ == '__main__':
15 | run()
16 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [bumpversion]
2 | current_version = 0.4.1
3 | commit = False
4 | tag = False
5 |
6 | [bumpversion:file:setup.py]
7 | search = version='{current_version}'
8 | replace = version='{new_version}'
9 |
10 | [bumpversion:file:wagtailsocialfeed/__init__.py]
11 | search = __version__ = '{current_version}'
12 | replace = __version__ = '{new_version}'
13 |
14 | [bdist_wheel]
15 | universal = 1
16 |
17 | [flake8]
18 | exclude = docs, wagtailsocialfeed/migrations/*.py
19 | ignore = E731,D100,D101,D102,D103,D104,D105,D205,D400
20 | max-line-length = 119
21 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/urls.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, unicode_literals
2 |
3 | from django.conf.urls import url
4 |
5 | from . import views
6 |
7 | urlpatterns = [
8 | url(r'^moderate/(?P\d+)/$',
9 | views.ModerateView.as_view(),
10 | name='moderate'),
11 | url(r'^moderate/(?P\d+)/(?P.+)/allow/$',
12 | views.ModerateAllowView.as_view(),
13 | name='allow'),
14 | url(r'^moderate/(?P\d+)/(?P.+)/remove/$',
15 | views.ModerateRemoveView.as_view(),
16 | name='remove'),
17 | ]
18 |
--------------------------------------------------------------------------------
/docs/wagtailsocialfeed.migrations.rst:
--------------------------------------------------------------------------------
1 | wagtailsocialfeed.migrations package
2 | ====================================
3 |
4 | Submodules
5 | ----------
6 |
7 | wagtailsocialfeed.migrations.0001_initial module
8 | ------------------------------------------------
9 |
10 | .. automodule:: wagtailsocialfeed.migrations.0001_initial
11 | :members:
12 | :undoc-members:
13 | :show-inheritance:
14 |
15 |
16 | Module contents
17 | ---------------
18 |
19 | .. automodule:: wagtailsocialfeed.migrations
20 | :members:
21 | :undoc-members:
22 | :show-inheritance:
23 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/templates/wagtailsocialfeed/social_feed_page.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ self.title }}
7 |
8 |
9 |
10 |
11 | {% for item in feed %}
12 |
13 | {% if item.moderated %}
14 | {% include 'wagtailsocialfeed/includes/feed_item.html' with item=item.get_content %}
15 | {% else %}
16 | {% include 'wagtailsocialfeed/includes/feed_item.html' %}
17 | {% endif %}
18 |
19 | {% endfor %}
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. wagtailsocialfeed documentation master file, created by
2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | Welcome to Wagtail Social Feeds documentation!
7 | ======================================
8 |
9 | Contents:
10 |
11 | .. toctree::
12 | :maxdepth: 2
13 |
14 | readme
15 | installation
16 | usage
17 | configuration
18 | contributing
19 |
20 | Indices and tables
21 | ==================
22 |
23 | * :ref:`genindex`
24 | * :ref:`modindex`
25 |
--------------------------------------------------------------------------------
/docs/wagtailsocialfeed.utils.rst:
--------------------------------------------------------------------------------
1 | wagtailsocialfeed.utils package
2 | ===============================
3 |
4 | Subpackages
5 | -----------
6 |
7 | .. toctree::
8 |
9 | wagtailsocialfeed.utils.feed
10 |
11 | Submodules
12 | ----------
13 |
14 | wagtailsocialfeed.utils.conf module
15 | -----------------------------------
16 |
17 | .. automodule:: wagtailsocialfeed.utils.conf
18 | :members:
19 | :undoc-members:
20 | :show-inheritance:
21 |
22 |
23 | Module contents
24 | ---------------
25 |
26 | .. automodule:: wagtailsocialfeed.utils
27 | :members:
28 | :undoc-members:
29 | :show-inheritance:
30 |
--------------------------------------------------------------------------------
/tests/factories.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import factory
4 |
5 | from wagtailsocialfeed.models import SocialFeedConfiguration, SocialFeedPage
6 |
7 |
8 | class SocialFeedConfigurationFactory(factory.DjangoModelFactory):
9 | username = factory.Faker('user_name')
10 |
11 | class Meta:
12 | model = SocialFeedConfiguration
13 |
14 |
15 | class SocialFeedPageFactory(factory.DjangoModelFactory):
16 | title = factory.Faker('word')
17 | path = '0000'
18 | depth = 0
19 |
20 | feedconfig = factory.SubFactory(SocialFeedConfigurationFactory)
21 |
22 | class Meta:
23 | model = SocialFeedPage
24 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/migrations/0003_auto_20161006_1021.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10.2 on 2016-10-06 08:21
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('wagtailsocialfeed', '0002_auto_20161005_1032'),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name='socialfeedconfiguration',
17 | name='source',
18 | field=models.CharField(choices=[('twitter', 'Twitter'), ('instagram', 'Instagram'), ('facebook', 'Facebook')], max_length=100, verbose_name='Feed source'),
19 | ),
20 | ]
21 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/migrations/0002_auto_20161005_1032.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10 on 2016-10-05 08:32
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | dependencies = [
12 | ('wagtailsocialfeed', '0001_initial'),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterField(
17 | model_name='socialfeedpage',
18 | name='feedconfig',
19 | field=models.ForeignKey(blank=True, help_text='Leave blank to show all the feeds.', null=True, on_delete=django.db.models.deletion.PROTECT, to='wagtailsocialfeed.SocialFeedConfiguration'),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 |
4 | source = wagtailsocialfeed
5 |
6 | omit =
7 | */migrations/*
8 |
9 | plugins =
10 | django_coverage_plugin
11 |
12 | [report]
13 | # Regexes for lines to exclude from consideration
14 | exclude_lines =
15 | # Have to re-enable the standard pragma
16 | pragma: no cover
17 |
18 | # Don't complain about missing debug-only code:
19 | def __repr__
20 | if self\.debug
21 |
22 | # Don't complain if tests don't hit defensive assertion code:
23 | raise AssertionError
24 | raise NotImplementedError
25 | return NotImplemented
26 |
27 | # Don't complain if non-runnable code isn't run:
28 | if 0:
29 | if __name__ == .__main__.:
30 |
31 | ignore_errors = True
32 |
33 | [html]
34 | directory = htmlcov
35 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/managers.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import dateutil.parser
4 | from django.db import models
5 |
6 |
7 | class ModeratedItemManager(models.Manager):
8 | def get_or_create_for(self, original_post):
9 | """
10 | Get an existing `ModeratedItem` based on the original_post
11 | or create a new one if it cannot be found.
12 |
13 | :param original_post:
14 | The original post as a JSON string or encoded JSON object
15 | """
16 | original_obj = json.loads(original_post)
17 |
18 | posted = dateutil.parser.parse(original_obj['posted'])
19 | external_id = original_obj['id']
20 | return self.get_or_create(
21 | external_id=external_id,
22 | defaults=dict(
23 | posted=posted,
24 | external_id=external_id,
25 | content=original_post))
26 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | skip_missing_interpreters = True
3 | skipsdist = True
4 | usedevelop = True
5 |
6 | envlist =
7 | py{27,33,34,35}-dj{18,19,110,111}-wt{18,19,110,111}
8 | flake8
9 |
10 |
11 | [testenv]
12 | install_command = pip install -e ".[testing]" -U {opts} {packages}
13 | commands =
14 | coverage run --source wagtailsocialfeed runtests.py {posargs}
15 | coverage xml
16 |
17 | basepython =
18 | py27: python2.7
19 | py34: python3.4
20 | py35: python3.5
21 |
22 | deps =
23 | dj18: django~=1.8.0
24 | dj19: django~=1.9.0
25 | dj110: django~=1.10.0
26 | dj111: django~=1.11.0
27 | wt18: wagtail>=1.8,<1.9
28 | wt19: wagtail>=1.9,<1.10
29 | wt110: wagtail>=1.10,<1.11
30 | wt111: wagtail>=1.11,<1.12
31 |
32 |
33 | [testenv:flake8]
34 | deps=flake8
35 | basepython=python3
36 | commands=flake8 wagtailsocialfeed/ tests/
37 | max-line-length=119
38 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/conf.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, unicode_literals
2 |
3 | from datetime import timedelta
4 |
5 | from django.conf import settings
6 |
7 | DEFAULTS = {
8 | 'CONFIG': {},
9 | 'CACHE_DURATION': 900,
10 | 'SEARCH_MAX_HISTORY': timedelta(weeks=26),
11 | 'FACEBOOK_FIELDS': [
12 | 'picture',
13 | 'story',
14 | 'from',
15 | 'name',
16 | 'privacy',
17 | 'is_expired',
18 | 'actions',
19 | 'updated_time',
20 | 'link',
21 | 'object_id',
22 | 'story_tags',
23 | 'created_time',
24 | 'is_hidden',
25 | 'type',
26 | 'id',
27 | 'status_type',
28 | 'icon',
29 | ],
30 | }
31 |
32 |
33 | def get_socialfeed_setting(name):
34 | return getattr(settings, 'WAGTAIL_SOCIALFEED_{}'.format(name),
35 | DEFAULTS[name])
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # Distribution / packaging
7 | .Python
8 | env/
9 | build/
10 | develop-eggs/
11 | dist/
12 | downloads/
13 | eggs/
14 | .eggs/
15 | *.egg-info/
16 | .installed.cfg
17 | *.egg
18 |
19 | # PyInstaller
20 | # Usually these files are written by a python script from a template
21 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
22 | *.manifest
23 | *.spec
24 |
25 | # Installer logs
26 | pip-log.txt
27 | pip-delete-this-directory.txt
28 |
29 | # Unit test / coverage reports
30 | htmlcov/
31 | .tox/
32 | .coverage
33 | .coverage.*
34 | .cache
35 | nosetests.xml
36 | coverage.xml
37 | *,cover
38 | .hypothesis/
39 |
40 | # Translations
41 | *.mo
42 | *.pot
43 |
44 | # Django stuff:
45 | *.log
46 |
47 | # Sphinx documentation
48 | docs/_build/
49 |
50 | # PyBuilder
51 | target/
52 | .coveralls.yml
53 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/static/wagtailsocialfeed/js/admin/moderate.js:
--------------------------------------------------------------------------------
1 | $(document).ready(function() {
2 | $('table#feeds tbody').on('click', 'td.status .status-actions a', function(e) {
3 | e.preventDefault();
4 | var url = $( this ).attr('href');
5 |
6 | var postId = $( this ).data('post_id');
7 | var originalpost = $( 'input#post_original_' + postId ).val();
8 | var $td = $( this ).parents('td.status');
9 |
10 | var postdata = {
11 | 'original': originalpost
12 | };
13 |
14 | $.post(url, postdata, function(data) {
15 | $td.addClass('new-state');
16 | if (data.allowed) {
17 | $td.addClass('allowed');
18 | }
19 | else {
20 | $td.removeClass('allowed');
21 | }
22 | })
23 | });
24 |
25 | $('table#feeds tbody').on('mouseleave', 'td.status.new-state', function(e) {
26 | $( this ).removeClass('new-state');
27 | });
28 | })
29 |
--------------------------------------------------------------------------------
/docs/wagtailsocialfeed.utils.feed.rst:
--------------------------------------------------------------------------------
1 | wagtailsocialfeed.utils.feed package
2 | ====================================
3 |
4 | Submodules
5 | ----------
6 |
7 | wagtailsocialfeed.utils.feed.factory module
8 | -------------------------------------------
9 |
10 | .. automodule:: wagtailsocialfeed.utils.feed.factory
11 | :members:
12 | :undoc-members:
13 | :show-inheritance:
14 |
15 | wagtailsocialfeed.utils.feed.instagram module
16 | ---------------------------------------------
17 |
18 | .. automodule:: wagtailsocialfeed.utils.feed.instagram
19 | :members:
20 | :undoc-members:
21 | :show-inheritance:
22 |
23 | wagtailsocialfeed.utils.feed.twitter module
24 | -------------------------------------------
25 |
26 | .. automodule:: wagtailsocialfeed.utils.feed.twitter
27 | :members:
28 | :undoc-members:
29 | :show-inheritance:
30 |
31 |
32 | Module contents
33 | ---------------
34 |
35 | .. automodule:: wagtailsocialfeed.utils.feed
36 | :members:
37 | :undoc-members:
38 | :show-inheritance:
39 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/static/wagtailsocialfeed/css/admin/moderate-page.css:
--------------------------------------------------------------------------------
1 | td.status {
2 | padding-left: 20px;
3 | }
4 |
5 | td.status .status-container {
6 | position: relative;
7 | text-align: center;
8 | }
9 |
10 | td.status i {
11 | font-size: 2em;
12 | color: #189370;
13 | visibility: visible;
14 | }
15 |
16 | td.status:hover i {
17 | visibility: hidden;
18 | }
19 |
20 | /*
21 | * Specific status states
22 | */
23 | td.status.allowed div.status-actions {
24 | visibility: hidden;
25 | position: relative;
26 | top: -1.5em;
27 | margin-top: 0px;
28 | height: 0px;
29 | }
30 |
31 |
32 | td.status:not(.new-state):hover div.status-actions {
33 | visibility: visible;
34 | }
35 |
36 | td.status.allowed.new-state:hover i {
37 | visibility: visible;
38 | }
39 |
40 | td.status:not(.allowed) i {
41 | display: none;
42 | }
43 |
44 | /*
45 | * Action button states
46 | */
47 | td.status:not(.allowed) .status-actions a.action-remove {
48 | display: none;
49 | }
50 |
51 | td.status.allowed .status-actions a.action-allow {
52 | display: none;
53 | }
54 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/feed/factory.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 |
3 | from .facebook import FacebookFeed, FacebookFeedItem
4 | from .instagram import InstagramFeed, InstagramFeedItem
5 | from .twitter import TwitterFeed, TwitterFeedItem
6 |
7 | FeedClasses = namedtuple('FeedClasses', ['feed', 'item'])
8 |
9 | FEED_CONFIG = {
10 | 'twitter': FeedClasses(TwitterFeed, TwitterFeedItem),
11 | 'instagram': FeedClasses(InstagramFeed, InstagramFeedItem),
12 | 'facebook': FeedClasses(FacebookFeed, FacebookFeedItem)
13 | }
14 |
15 |
16 | class FeedFactory(object):
17 | @classmethod
18 | def create(cls, source):
19 | try:
20 | return FEED_CONFIG[source].feed()
21 | except KeyError:
22 | raise NotImplementedError(
23 | "Feed class for type '{}' not available".format(source))
24 |
25 |
26 | class FeedItemFactory(object):
27 | @classmethod
28 | def get_class(cls, source):
29 | try:
30 | return FEED_CONFIG[source].item
31 | except KeyError:
32 | raise NotImplementedError(
33 | "FeedItem class for type '{}' not available".format(source))
34 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from .feed.factory import FeedFactory
4 |
5 |
6 | def get_feed_items(feedconfig, limit=0):
7 | """Return the items of a specific feed.
8 |
9 | :param feedconfig: the `SocialFeedConfiguration` to be used
10 | :param limit: limit the amount of items returned
11 | """
12 | if feedconfig.moderated:
13 | qs = feedconfig.moderated_items.all()
14 | if limit:
15 | return qs[:limit]
16 | return qs
17 |
18 | stream = FeedFactory.create(feedconfig.source)
19 | return stream.get_items(config=feedconfig, limit=limit)
20 |
21 |
22 | def get_feed_items_mix(feedconfigs, limit=0):
23 | """Return the items of all the feeds combined.
24 |
25 | :param feedconfigs: a list of `SocialFeedConfiguration` objects
26 | :param limit: limit the result set
27 | """
28 | items = []
29 | for config in feedconfigs:
30 | items.extend(get_feed_items(config))
31 |
32 | # Make sure the date-order is correct
33 | items = sorted(items, key=lambda x: x.posted, reverse=True)
34 | if limit:
35 | return items[:limit]
36 | return items
37 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ===============================
2 | Wagtail Social Feed
3 | ===============================
4 |
5 |
6 | .. image:: https://img.shields.io/pypi/v/wagtailsocialfeed.svg
7 | :target: https://pypi.python.org/pypi/wagtailsocialfeed
8 |
9 | .. image:: https://readthedocs.org/projects/wagtailsocialfeed/badge/?version=latest
10 | :target: https://wagtailsocialfeed.readthedocs.io/en/latest/?badge=latest
11 | :alt: Documentation Status
12 |
13 | .. image:: https://travis-ci.org/LUKKIEN/wagtailsocialfeed.svg?branch=master
14 | :target: https://travis-ci.org/LUKKIEN/wagtailsocialfeed
15 |
16 | .. image:: https://codecov.io/gh/LUKKIEN/wagtailsocialfeed/branch/master/graph/badge.svg
17 | :target: https://codecov.io/gh/LUKKIEN/wagtailsocialfeed
18 |
19 |
20 | A Wagtail module that provides pages and content blocks to show social media feeds
21 |
22 | * Documentation: https://wagtailsocialfeed.readthedocs.io.
23 |
24 |
25 | Features
26 | ========
27 |
28 | * A wagtail settings sections to configure social media sources
29 | * Social feed moderate view
30 | * Social feed content block
31 | * Social feed Page type
32 |
33 | .. image:: http://i.imgur.com/BOXiAh6.png
34 | :width: 728 px
35 |
36 | Implementations
37 | ---------------
38 | The following social media sources are supported:
39 |
40 | * Twitter
41 | * Facebook
42 | * Instagram
43 |
--------------------------------------------------------------------------------
/docs/configuration.rst:
--------------------------------------------------------------------------------
1 | =============
2 | Configuration
3 | =============
4 |
5 | The following configuration options are available:
6 |
7 | ``WAGTAIL_SOCIALFEED_CONFIG``
8 | -----------------------------
9 | The configuration for the social media accounts. ::
10 |
11 | WAGTAIL_SOCIALFEED_CONFIG = {
12 | 'twitter': {
13 | 'CONSUMER_KEY': 'SOME_KEY',
14 | 'CONSUMER_SECRET': 'SOME_SECRET',
15 | 'ACCESS_TOKEN_KEY': 'SOME_KEY',
16 | 'ACCESS_TOKEN_SECRET': 'SOME_SECRET'
17 | },
18 | 'facebook': {
19 | 'CLIENT_ID': 'SOME_ID',
20 | 'CLIENT_SECRET': 'SOME_SECRET',
21 | }
22 | }
23 |
24 | No credentials are needed for Instagram.
25 |
26 | Defaults to ``{}``
27 |
28 |
29 | ``WAGTAIL_SOCIALFEED_CACHE_DURATION``
30 | -------------------------------------
31 |
32 | The cache timeout (in seconds) for the social feed items
33 |
34 | Defaults to ``900``
35 |
36 |
37 | ``WAGTAIL_SOCIALFEED_SEARCH_MAX_HISTORY``
38 | -----------------------------------------
39 |
40 | The amount of time the module is allowed to search through the history of the social feed.
41 | This is only used for the moderator view. In the moderator view it is possible to enter
42 | a search query. For the search to return a usable data-set, multiple result-pages are requested
43 | from the social feed source. But it does need to have a limit.
44 |
45 | Defaults to ``timedelta(weeks=26)``
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Lukkien BV and individual contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of Lukkien nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/docs/wagtailsocialfeed.rst:
--------------------------------------------------------------------------------
1 | wagtailsocialfeed package
2 | =========================
3 |
4 | Subpackages
5 | -----------
6 |
7 | .. toctree::
8 |
9 | wagtailsocialfeed.migrations
10 | wagtailsocialfeed.utils
11 |
12 | Submodules
13 | ----------
14 |
15 | wagtailsocialfeed.blocks module
16 | -------------------------------
17 |
18 | .. automodule:: wagtailsocialfeed.blocks
19 | :members:
20 | :undoc-members:
21 | :show-inheritance:
22 |
23 | wagtailsocialfeed.managers module
24 | ---------------------------------
25 |
26 | .. automodule:: wagtailsocialfeed.managers
27 | :members:
28 | :undoc-members:
29 | :show-inheritance:
30 |
31 | wagtailsocialfeed.models module
32 | -------------------------------
33 |
34 | .. automodule:: wagtailsocialfeed.models
35 | :members:
36 | :undoc-members:
37 | :show-inheritance:
38 |
39 | wagtailsocialfeed.urls module
40 | -----------------------------
41 |
42 | .. automodule:: wagtailsocialfeed.urls
43 | :members:
44 | :undoc-members:
45 | :show-inheritance:
46 |
47 | wagtailsocialfeed.views module
48 | ------------------------------
49 |
50 | .. automodule:: wagtailsocialfeed.views
51 | :members:
52 | :undoc-members:
53 | :show-inheritance:
54 |
55 | wagtailsocialfeed.wagtail_hooks module
56 | --------------------------------------
57 |
58 | .. automodule:: wagtailsocialfeed.wagtail_hooks
59 | :members:
60 | :undoc-members:
61 | :show-inheritance:
62 |
63 |
64 | Module contents
65 | ---------------
66 |
67 | .. automodule:: wagtailsocialfeed
68 | :members:
69 | :undoc-members:
70 | :show-inheritance:
71 |
--------------------------------------------------------------------------------
/docs/installation.rst:
--------------------------------------------------------------------------------
1 | .. highlight:: shell
2 |
3 | ============
4 | Installation
5 | ============
6 |
7 |
8 | Stable release
9 | --------------
10 |
11 | To install Wagtail Social Feed, run this command in your terminal:
12 |
13 | .. code-block:: console
14 |
15 | $ pip install wagtailsocialfeed
16 |
17 | This is the preferred method to install Wagtail Social Feed, as it will always install the most recent stable release.
18 |
19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide
20 | you through the process.
21 |
22 | .. _pip: https://pip.pypa.io
23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/
24 |
25 |
26 | From sources
27 | ------------
28 |
29 | The sources for Wagtail Social Feed can be downloaded from the `Github repo`_.
30 |
31 | You can either clone the public repository:
32 |
33 | .. code-block:: console
34 |
35 | $ git clone git://github.com/LUKKIEN/wagtailsocialfeed
36 |
37 | Or download the `tarball`_:
38 |
39 | .. code-block:: console
40 |
41 | $ curl -OL https://github.com/LUKKIEN/wagtailsocialfeed/tarball/master
42 |
43 | Once you have a copy of the source, you can install it with:
44 |
45 | .. code-block:: console
46 |
47 | $ python setup.py install
48 |
49 | Configure Django
50 | ----------------
51 |
52 | Add ``wagtailsocialfeed`` and ``wagtail.contrib.modeladmin`` to your ``INSTALLED_APPS`` in settings:
53 |
54 | .. code-block:: python
55 |
56 | INSTALLED_APPS += [
57 | 'wagtailsocialfeed',
58 | 'wagtail.contrib.modeladmin',
59 | ]
60 |
61 |
62 | .. _Github repo: https://github.com/LUKKIEN/wagtailsocialfeed
63 | .. _tarball: https://github.com/LUKKIEN/wagtailsocialfeed/tarball/master
64 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | =========
2 | CHANGELOG
3 | =========
4 |
5 | 0.4.1 (13-12-2017)
6 | ==================
7 | * Fix on Instagram feed which was no longer available
8 |
9 | 0.4.0 (16-08-2017)
10 | ==================
11 | + Dropped support for Wagtail 1.6, 1.7
12 | + Added support for Wagtail 1.9, 1.10 and 1.11
13 |
14 | 0.3.0 (28-10-2016)
15 | ==================
16 | + Added dimensions when returning the attached Twitter Image
17 | + Fixes Facebook support: initially developed on `2.1` while the GraphAPI assumes `2.8`.
18 | + Added Facebook field configuration
19 | + Added Twitter options via default config
20 |
21 | 0.2.0 (06-10-2016)
22 | ==================
23 | + Added Facebook support
24 | + Added ability to mix all the feeds; just leave feedconfig empty in `SocialFeedPage` or `SocialFeedBlock`.
25 | + Made all returned data avaiable in `FeedItem` objects, even if it is not stored explicitly.
26 |
27 | 0.1.0 (27-09-2016)
28 | ==================
29 | + Fixed PyPI long_description format error
30 | + Fixed value_for_form error in FeedChooserBlock
31 |
32 | 0.1.dev4 (27-09-2016)
33 | =====================
34 | + Made looping over multiple result pages more DRY
35 | + Improved moderate page title
36 | + Fixed AttributeError in FeedChooserBlock.value_for_form
37 |
38 | 0.1.dev3 (11-09-2016)
39 | =====================
40 | + Updated license model to BSD
41 |
42 | 0.1.dev2 (04-09-2016)
43 | =====================
44 | + Added block type 'SocialFeedBlock'
45 | + Added SocialFeedModerateMenu which live detects configuration changes
46 | + Added FeedItem to consolidate the item/post structure
47 | + Added search functionality to the Feed objects
48 | + Dropped Wagtail 1.5 support in favour of using the IntegerBlock
49 |
50 | 0.1.dev1 (01-09-2016)
51 | =====================
52 | + First implementation
53 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | cache: pip
3 |
4 | matrix:
5 | include:
6 | # Wagtail 1.8
7 | - env: TOXENV=py27-dj18-wt18
8 | python: 2.7
9 | - env: TOXENV=py34-dj18-wt18
10 | python: 3.4
11 | - env: TOXENV=py35-dj18-wt18
12 | python: 3.5
13 | - env: TOXENV=py27-dj110-wt18
14 | python: 2.7
15 | - env: TOXENV=py34-dj110-wt18
16 | python: 3.4
17 | - env: TOXENV=py35-dj110-wt18
18 | python: 3.5
19 |
20 | # Wagtail 1.9
21 | - env: TOXENV=py27-dj18-wt19
22 | python: 2.7
23 | - env: TOXENV=py34-dj18-wt19
24 | python: 3.4
25 | - env: TOXENV=py35-dj18-wt19
26 | python: 3.5
27 | - env: TOXENV=py27-dj110-wt19
28 | python: 2.7
29 | - env: TOXENV=py34-dj110-wt19
30 | python: 3.4
31 | - env: TOXENV=py35-dj110-wt19
32 | python: 3.5
33 |
34 | # Wagtail 1.10
35 | - env: TOXENV=py27-dj18-wt110
36 | python: 2.7
37 | - env: TOXENV=py34-dj18-wt110
38 | python: 3.4
39 | - env: TOXENV=py35-dj18-wt110
40 | python: 3.5
41 | - env: TOXENV=py27-dj110-wt110
42 | python: 2.7
43 | - env: TOXENV=py34-dj110-wt110
44 | python: 3.4
45 | - env: TOXENV=py35-dj110-wt110
46 | python: 3.5
47 | - env: TOXENV=py27-dj111-wt110
48 | python: 2.7
49 | - env: TOXENV=py34-dj111-wt110
50 | python: 3.4
51 | - env: TOXENV=py35-dj111-wt110
52 | python: 3.5
53 |
54 | # Wagtail 1.11
55 | - env: TOXENV=py27-dj18-wt111
56 | python: 2.7
57 | - env: TOXENV=py34-dj18-wt111
58 | python: 3.4
59 | - env: TOXENV=py35-dj18-wt111
60 | python: 3.5
61 | - env: TOXENV=py27-dj110-wt111
62 | python: 2.7
63 | - env: TOXENV=py34-dj110-wt111
64 | python: 3.4
65 | - env: TOXENV=py35-dj110-wt111
66 | python: 3.5
67 | - env: TOXENV=py27-dj111-wt111
68 | python: 2.7
69 | - env: TOXENV=py34-dj111-wt111
70 | python: 3.4
71 | - env: TOXENV=py35-dj111-wt111
72 | python: 3.5
73 | install:
74 | - pip install -U codecov tox
75 | script:
76 | - tox
77 | after_success: codecov
78 |
--------------------------------------------------------------------------------
/docs/usage.rst:
--------------------------------------------------------------------------------
1 | =====
2 | Usage
3 | =====
4 |
5 | Setting up social feeds
6 | =======================
7 |
8 | From the Wagtail CMS settings menu you can access the 'Social feeds' section.
9 | Add social media sources by defining the social media platform, the user to track and define if it's has to be moderated or not
10 |
11 | Moderated feeds
12 | ===============
13 |
14 | When a social feed is marked as 'moderated' by default the latest posts of that feed are not visible to the visitors of your website.
15 | All posts have to be explicitally allowed before it will show up in a feed.
16 |
17 | All moderated social feeds will show up as a new item in your CMS admin menu.
18 | From there you will have an overview of all the latest posts, search through the posts and add/remove posts from the moderated list.
19 |
20 | .. image:: http://i.imgur.com/REcJPFw.png
21 | :width: 728 px
22 |
23 | Social feed page
24 | ================
25 |
26 | It's easy to add a page to your tree which shows the latest posts of a specific feed.
27 | When adding a page just choose 'Social Feed Page' and select your feedconfig.
28 |
29 | Styling the page
30 | ----------------
31 |
32 | You can override the default template by creating a ``wagtailsocialfeed/social_feed_page.html`` in your templates directory.
33 | All items are available in the ``{{ feed }}`` variable.
34 |
35 | Social feed blocks
36 | ==================
37 |
38 | You can also render the latest feed posts by using a ``SocialFeedBlock`` in your ``StreamField``.
39 | Make sure you define the ``SocialFeedBlock`` as allowed block-type in the ``StreamField``:
40 |
41 | .. code-block:: python
42 | from wagtailsocialfeed.blocks import SocialFeedBlock
43 |
44 | class YourBlocksPage(Page):
45 | body = StreamField([
46 | ...
47 | ('socialfeed', SocialFeedBlock()),
48 | ...
49 | ], null=True, blank=True)
50 |
51 | Styling the blocks
52 | ------------------
53 |
54 | You can override the default template by creating a ``wagtailsocialfeed/social_feed_block.html`` in your templates directory.
55 | All items are available in the ``{{ feed }}`` variable.
56 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 |
4 | from setuptools import setup, find_packages
5 |
6 | with open('README.rst') as readme_file:
7 | readme = readme_file.read()
8 |
9 | with open('CHANGELOG.rst') as changelog_file:
10 | changelog = changelog_file.read()
11 |
12 | install_requires = [
13 | 'wagtail>=1.6',
14 | 'twython>=3.0,<4.0',
15 | 'facepy>=1.0.8',
16 | 'wagtailfontawesome>=1.0',
17 | 'requests>=2.0',
18 | 'python-dateutil>=2.5',
19 | 'enum34',
20 | ]
21 |
22 | test_require = [
23 | 'responses>=0.5',
24 | 'factory-boy>=2.8,<2.9',
25 | 'beautifulsoup4>=4',
26 | 'coverage>=3.7.0',
27 | 'flake8>=2.2.0',
28 | 'isort>=4.2.0',
29 | 'tox>=2.3.1',
30 | 'cryptography==1.4',
31 | 'PyYAML==3.11',
32 | 'bumpversion==0.5.3',
33 | 'wheel==0.29.0',
34 | 'django-coverage-plugin==1.3.1',
35 | ]
36 |
37 | docs_require = [
38 | 'sphinx',
39 | 'sphinx_rtd_theme',
40 | ]
41 |
42 | setup(
43 | name='wagtailsocialfeed',
44 | version='0.4.1',
45 | description="A Wagtail module that provides pages and content blocks to show social media feeds", # NOQA
46 | long_description=readme + '\n\n' + changelog,
47 | author="Tim Leguijt",
48 | author_email='info@leguijtict.nl',
49 | url='https://github.com/LUKKIEN/wagtailsocialfeed',
50 | packages=find_packages(exclude=['tests*']),
51 | include_package_data=True,
52 | install_requires=install_requires,
53 | license='BSD',
54 | zip_safe=False,
55 | keywords='wagtailsocialfeed',
56 | classifiers=[
57 | 'Development Status :: 2 - Pre-Alpha',
58 | 'Intended Audience :: Developers',
59 | 'License :: OSI Approved :: BSD License',
60 | 'Natural Language :: English',
61 | "Programming Language :: Python :: 2",
62 | 'Programming Language :: Python :: 2.7',
63 | 'Programming Language :: Python :: 3',
64 | 'Programming Language :: Python :: 3.3',
65 | 'Programming Language :: Python :: 3.4',
66 | 'Programming Language :: Python :: 3.5',
67 | ],
68 | extras_require={
69 | 'testing': test_require,
70 | 'docs': docs_require,
71 | },
72 | )
73 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/blocks.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from django import forms
4 | from django.utils.translation import ugettext_lazy as _
5 | from wagtail.wagtailcore import blocks
6 |
7 | from .models import SocialFeedConfiguration
8 | from .utils import get_feed_items, get_feed_items_mix
9 |
10 |
11 | class FeedChooserBlock(blocks.ChooserBlock):
12 | target_model = SocialFeedConfiguration
13 | widget = forms.Select
14 |
15 | def value_for_form(self, value):
16 | if value:
17 | if isinstance(value, int):
18 | return value
19 | return value.pk
20 | return None
21 |
22 | def value_from_form(self, value):
23 | if value is None or isinstance(value, self.target_model):
24 | return value
25 | try:
26 | value = int(value)
27 | except ValueError:
28 | return None
29 |
30 | try:
31 | return self.target_model.objects.get(pk=value)
32 | except self.target_model.DoesNotExist:
33 | return None
34 |
35 | def to_python(self, value):
36 | if value:
37 | return self.target_model.objects.get(pk=value)
38 | return None
39 |
40 |
41 | class SocialFeedBlock(blocks.StructBlock):
42 | feedconfig = FeedChooserBlock(
43 | required=False,
44 | help_text=_("Select a feed configuration to show. Leave blank to show a mix of all the feeds"))
45 | limit = blocks.IntegerBlock(required=False, min_value=0)
46 |
47 | class Meta:
48 | icon = 'icon icon-fa-rss'
49 | template = 'wagtailsocialfeed/social_feed_block.html'
50 |
51 | def get_context(self, value, parent_context=None):
52 | if not parent_context:
53 | context = super(SocialFeedBlock, self).get_context(value)
54 | else:
55 | context = super(SocialFeedBlock, self).get_context(value, parent_context)
56 |
57 | feedconfig = value['feedconfig']
58 |
59 | if feedconfig:
60 | feed = get_feed_items(feedconfig, limit=value['limit'])
61 | else:
62 | feed = get_feed_items_mix(SocialFeedConfiguration.objects.all(),
63 | limit=value['limit'])
64 |
65 | context['feed'] = feed
66 |
67 | return context
68 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import json
4 | import re
5 | from functools import wraps
6 |
7 | import responses
8 |
9 |
10 | def _facebook(modified):
11 | with open('tests/fixtures/facebook.json', 'r') as feed_file:
12 | lines = feed_file.readlines()
13 | content = "".join(lines)
14 | feed = json.loads(content)
15 | if modified:
16 | feed = modified(feed)
17 |
18 | # oauth/access_token
19 |
20 | responses.add(responses.GET,
21 | re.compile('https?://graph.facebook.com/.*'),
22 | json=feed, status=200)
23 | try:
24 | return feed['data']
25 | except KeyError:
26 | return []
27 |
28 |
29 | def _twitter(modifier):
30 | with open('tests/fixtures/twitter.json', 'r') as feed_file:
31 | feed = json.loads("".join(feed_file.readlines()))
32 | if modifier:
33 | feed = modifier(feed)
34 | responses.add(responses.GET,
35 | re.compile('https?://api.twitter.com/.*'),
36 | json=feed, status=200)
37 | return feed
38 |
39 |
40 | def _instagram(modifier):
41 | with open('tests/fixtures/instagram.json', 'r') as feed_file:
42 | feed = json.loads("".join(feed_file.readlines()))
43 | if modifier:
44 | feed = modifier(feed)
45 | responses.add(responses.GET,
46 | re.compile('https?://www.instagram.com/.*'),
47 | json=feed, status=200)
48 | try:
49 | return feed['user']['media']['nodes']
50 | except KeyError:
51 | return []
52 |
53 |
54 | def feed_response(sources, modifier=None):
55 | def decorator(func):
56 | @wraps(func)
57 | @responses.activate
58 | def func_wrapper(obj, *args, **kwargs):
59 | source_list = sources
60 | feeds = []
61 | if type(sources) is not list:
62 | source_list = [sources]
63 |
64 | for source in source_list:
65 | if source == 'twitter':
66 | feeds.append(_twitter(modifier))
67 | elif source == 'instagram':
68 | feeds.append(_instagram(modifier))
69 | elif source == 'facebook':
70 | feeds.append(_facebook(modifier))
71 | feeds.extend(args)
72 | return func(obj, *feeds, **kwargs)
73 | return func_wrapper
74 | return decorator
75 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10 on 2016-08-31 12:49
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | initial = True
12 |
13 | dependencies = [
14 | ('wagtailcore', '0020_add_index_on_page_first_published_at'),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name='ModeratedItem',
20 | fields=[
21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22 | ('moderated', models.DateTimeField(auto_now_add=True)),
23 | ('posted', models.DateTimeField()),
24 | ('external_id', models.CharField(max_length=255)),
25 | ('content', models.TextField()),
26 | ],
27 | options={
28 | 'ordering': ['-posted'],
29 | },
30 | ),
31 | migrations.CreateModel(
32 | name='SocialFeedConfiguration',
33 | fields=[
34 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
35 | ('source', models.CharField(choices=[('twitter', 'Twitter'), ('instagram', 'Instagram')], max_length=100, verbose_name='Feed source')),
36 | ('username', models.CharField(max_length=255, verbose_name='User to track')),
37 | ('moderated', models.BooleanField(default=False)),
38 | ],
39 | ),
40 | migrations.CreateModel(
41 | name='SocialFeedPage',
42 | fields=[
43 | ('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')),
44 | ('feedconfig', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='wagtailsocialfeed.SocialFeedConfiguration')),
45 | ],
46 | options={
47 | 'abstract': False,
48 | },
49 | bases=('wagtailcore.page',),
50 | ),
51 | migrations.AddField(
52 | model_name='moderateditem',
53 | name='config',
54 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderated_items', to='wagtailsocialfeed.SocialFeedConfiguration'),
55 | ),
56 | ]
57 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/templates/wagtailsocialfeed/admin/moderate.html:
--------------------------------------------------------------------------------
1 | {% extends "wagtailadmin/generic/index.html" %}
2 | {% load i18n wagtailadmin_tags staticfiles %}
3 |
4 | {% block extra_css %}
5 | {{ block.super }}
6 |
7 | {% endblock %}
8 |
9 | {% block extra_js %}
10 | {{ block.super }}
11 |
12 | {% endblock %}
13 |
14 |
15 | {% block content %}
16 | {% url 'wagtailsocialfeed:moderate' pk=object.pk as search_url %}
17 | {% include "wagtailsocialfeed/admin/header.html" with title=view.page_title icon="rss" search_url=search_url %}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Date
28 | Post
29 | Image
30 |
31 |
32 |
33 | {% for post in feed %}
34 |
35 |
36 |
46 |
47 |
48 | {{ post.date }}
49 |
50 |
51 | {{ post.text }}
52 |
53 |
54 | {% if post.image.thumb %}
55 |
56 | {% endif %}
57 |
58 |
59 |
60 | {% endfor %}
61 |
62 |
63 | {% endblock %}
64 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/templates/wagtailsocialfeed/admin/header.html:
--------------------------------------------------------------------------------
1 | {% load i18n wagtailadmin_tags %}
2 | {% comment %}
3 |
4 | Variables accepted by this template:
5 |
6 | title
7 | subtitle
8 |
9 | search_url - if present, display a search box. This is a URL route name (taking no parameters) to be used as the action for that search box
10 | query_parameters - a query string (without the '?') to be placed after the search URL
11 |
12 | icon - name of an icon to place against the title
13 |
14 | tabbed - if true, add the classname 'tab-merged'
15 | merged - if true, add the classname 'merged'
16 |
17 | add_link - if present, display an 'add' button. This is a URL route name (taking no parameters) to be used as the link URL for the button
18 | add_text - text for the 'add' button
19 | {% endcomment %}
20 |
52 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/feed/instagram.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 |
4 | import requests
5 | from django.utils import timezone
6 |
7 | from . import AbstractFeed, AbstractFeedQuery, FeedError, FeedItem
8 |
9 | logger = logging.getLogger('wagtailsocialfeed')
10 |
11 |
12 | class InstagramFeedItem(FeedItem):
13 | """Implements instagram-specific behaviour"""
14 |
15 | @classmethod
16 | def get_post_date(cls, raw):
17 | if 'date' in raw:
18 | timestamp = None
19 | try:
20 | timestamp = float(raw['date'])
21 | except ValueError:
22 | return None
23 | return timezone.make_aware(
24 | datetime.datetime.fromtimestamp(timestamp), timezone=timezone.utc)
25 |
26 | return None
27 |
28 | @classmethod
29 | def from_raw(cls, raw):
30 | image = {}
31 | caption = None
32 | if 'display_src' in raw:
33 | image = {
34 | 'thumb': raw['thumbnail_resources'][1],
35 | 'small': raw['thumbnail_resources'][2],
36 | 'medium': raw['thumbnail_resources'][3],
37 | 'large': raw['thumbnail_resources'][4],
38 | 'original_link': "https://www.instagram.com/p/" + raw['code']
39 | }
40 |
41 | if 'caption' in raw:
42 | caption = raw['caption']
43 |
44 | return cls(
45 | id=raw['id'],
46 | type='instagram',
47 | text=caption,
48 | image_dict=image,
49 | posted=cls.get_post_date(raw),
50 | original_data=raw,
51 | )
52 |
53 |
54 | class InstagramFeedQuery(AbstractFeedQuery):
55 | def _get_load_kwargs(self, oldest_post):
56 | # Trick from twitter API doc to exclude the oldest post from
57 | # the next result-set
58 | return {'max_id': self.oldest_post['id']}
59 |
60 | def _search(self, raw_item):
61 | """Very basic search function"""
62 | return self.query_string.lower() in raw_item
63 |
64 | def _load(self, max_id=None):
65 | url = "https://www.instagram.com/{}/?__a=1".format(self.username)
66 | if max_id:
67 | url += "?max_id={}".format(max_id)
68 | resp = requests.get(url)
69 | if resp.status_code == 200:
70 | try:
71 | return resp.json()['user']['media']['nodes']
72 | except ValueError as e:
73 | raise FeedError(e)
74 | except KeyError as e:
75 | raise FeedError("No items could be found in the response")
76 | raise FeedError(resp.reason)
77 |
78 |
79 | class InstagramFeed(AbstractFeed):
80 | item_cls = InstagramFeedItem
81 | query_cls = InstagramFeedQuery
82 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: clean clean-test clean-pyc clean-build develop docs help
2 | .DEFAULT_GOAL := help
3 | define BROWSER_PYSCRIPT
4 | import os, webbrowser, sys
5 | try:
6 | from urllib import pathname2url
7 | except:
8 | from urllib.request import pathname2url
9 |
10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
11 | endef
12 | export BROWSER_PYSCRIPT
13 |
14 | define PRINT_HELP_PYSCRIPT
15 | import re, sys
16 |
17 | for line in sys.stdin:
18 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
19 | if match:
20 | target, help = match.groups()
21 | print("%-20s %s" % (target, help))
22 | endef
23 | export PRINT_HELP_PYSCRIPT
24 | BROWSER := python -c "$$BROWSER_PYSCRIPT"
25 |
26 | help:
27 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
28 |
29 | develop: clean ## install development env
30 | pip install -e .[testing,docs]
31 |
32 | clean: clean-build clean-pyc clean-test clean-docs ## remove all build, test, coverage and Python artifacts
33 |
34 | clean-docs:
35 | rm -rf docs/_build
36 |
37 | clean-build: ## remove build artifacts
38 | rm -fr build/
39 | rm -fr dist/
40 | rm -fr .eggs/
41 | find . -name '*.egg-info' -exec rm -fr {} +
42 | find . -name '*.egg' -exec rm -f {} +
43 |
44 | clean-pyc: ## remove Python file artifacts
45 | find . -name '*.pyc' -exec rm -f {} +
46 | find . -name '*.pyo' -exec rm -f {} +
47 | find . -name '*~' -exec rm -f {} +
48 | find . -name '__pycache__' -exec rm -fr {} +
49 |
50 | clean-test: ## remove test and coverage artifacts
51 | rm -fr .tox/
52 | rm -f .coverage
53 | rm -fr htmlcov/
54 |
55 | lint: ## check style with flake8
56 | flake8 --format=pylint `find wagtailsocialfeed -name '*.py' -type f -not -path '*/migrations/*'`
57 |
58 | test: ## run tests quickly with the default Python
59 |
60 | python runtests.py
61 |
62 | test-all: ## run tests on every Python version with tox
63 | tox
64 |
65 | coverage: ## check code coverage quickly with the default Python
66 |
67 | coverage run --source wagtailsocialfeed runtests.py
68 |
69 | coverage report -m
70 | coverage html
71 | $(BROWSER) htmlcov/index.html
72 |
73 | docs: ## generate Sphinx HTML documentation, including API docs
74 | rm -f docs/wagtailsocialfeed.rst
75 | rm -f docs/modules.rst
76 | sphinx-apidoc -o docs/ wagtailsocialfeed
77 | $(MAKE) -C docs clean
78 | $(MAKE) -C docs html
79 | $(BROWSER) docs/_build/html/index.html
80 |
81 | servedocs: docs ## compile the docs watching for changes
82 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
83 |
84 | release: dist ## package and upload a release
85 | twine upload -r lukkien dist/*
86 |
87 | dist: clean ## builds source and wheel package
88 | python setup.py sdist bdist_wheel
89 | ls -l dist
90 |
91 | install: clean ## install the package to the active Python's site-packages
92 | python setup.py install
93 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from wagtailsocialfeed.models import SocialFeedConfiguration
4 | from wagtailsocialfeed.utils import get_feed_items, get_feed_items_mix
5 | from wagtailsocialfeed.utils.feed.factory import FeedFactory
6 |
7 | from . import feed_response
8 | from .factories import SocialFeedConfigurationFactory
9 |
10 |
11 | class UtilTest(TestCase):
12 | """Test util methods."""
13 | def setUp(self):
14 | self.feedconfig = SocialFeedConfigurationFactory.create(
15 | source='twitter',
16 | username='someuser')
17 |
18 | @feed_response('twitter')
19 | def test_get_feed_items(self, tweets):
20 | items = get_feed_items(self.feedconfig)
21 | self.assertEquals(len(items), len(tweets))
22 |
23 | @feed_response(['twitter', 'instagram'])
24 | def test_get_feed_items_mix(self, tweets, instagram_posts):
25 | items = get_feed_items_mix(SocialFeedConfiguration.objects.all())
26 | # There is only a twitter configuration, so should just return the twitter items
27 | self.assertEquals(len(items), len(tweets))
28 |
29 | SocialFeedConfigurationFactory.create(
30 | source='instagram',
31 | username='someuser')
32 | items = get_feed_items_mix(SocialFeedConfiguration.objects.all())
33 | self.assertEquals(len(items), len(tweets) + len(instagram_posts))
34 |
35 | # Check if the date order is correct
36 | last_date = None
37 | for item in items:
38 | if last_date:
39 | self.assertLessEqual(item.posted, last_date)
40 | last_date = item.posted
41 |
42 | @feed_response(['twitter', 'instagram'])
43 | def test_get_feed_items_mix_moderated(self, tweets, instagram_posts):
44 | """Test the feed mix with one of the sources being moderated."""
45 | instagramconfig = SocialFeedConfigurationFactory.create(
46 | source='instagram',
47 | username='someuser',
48 | moderated=True)
49 |
50 | items = get_feed_items_mix(SocialFeedConfiguration.objects.all())
51 |
52 | # None of the instagram posts are moderated, so should only
53 | # return the twitter posts
54 | self.assertEquals(len(items), len(tweets))
55 | instagram_feed = FeedFactory.create('instagram')
56 | instagram_items = instagram_feed.get_items(instagramconfig)
57 | for item in instagram_items[:3]:
58 | instagramconfig.moderated_items.get_or_create_for(
59 | item.serialize())
60 |
61 | items = get_feed_items_mix(SocialFeedConfiguration.objects.all())
62 | self.assertEquals(len(items), len(tweets) + 3)
63 | # Check if the date order is correct
64 | last_date = None
65 | for item in items:
66 | if last_date:
67 | self.assertLessEqual(item.posted, last_date)
68 | last_date = item.posted
69 | self.assertEquals(len([i for i in items if i.type == 'instagram']), 3)
70 |
--------------------------------------------------------------------------------
/tests/app/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | import wagtail.wagtailcore
5 |
6 |
7 | def env(name, default=None):
8 | if sys.version_info < (3,):
9 | return os.environ.get(name, failobj=default)
10 | else:
11 | return os.environ.get(name, default=default)
12 |
13 |
14 | INSTALLED_APPS = [
15 | 'wagtailsocialfeed',
16 | # 'tests.app',
17 |
18 | 'taggit',
19 | 'modelcluster',
20 |
21 | 'wagtail.wagtailcore',
22 | 'wagtail.wagtailadmin',
23 | 'wagtail.wagtailusers',
24 | 'wagtail.wagtailsites',
25 | 'wagtail.wagtailforms',
26 | 'wagtail.wagtailimages',
27 | 'wagtail.wagtaildocs',
28 |
29 | 'django.contrib.admin',
30 | 'django.contrib.auth',
31 | 'django.contrib.contenttypes',
32 | 'django.contrib.sessions',
33 | 'django.contrib.staticfiles',
34 | ]
35 |
36 | wagtail_version = tuple(map(int, wagtail.wagtailcore.__version__.split('.')))
37 |
38 | ROOT_URLCONF = 'tests.app.urls'
39 |
40 | DATABASES = {
41 | 'default': {
42 | 'ENGINE': 'django.db.backends.sqlite3',
43 | 'NAME': env('DATABASE_NAME', 'test.sqlite3'),
44 | },
45 | }
46 | SECRET_KEY = 'not so secret'
47 |
48 | WAGTAIL_SITE_NAME = 'Wagtail Social Feed'
49 |
50 | DEBUG = True
51 |
52 | USE_TZ = True
53 | TIME_ZONE = 'Europe/Amsterdam'
54 |
55 | MIDDLEWARE_CLASSES = [
56 | 'django.contrib.sessions.middleware.SessionMiddleware',
57 | 'django.middleware.common.CommonMiddleware',
58 | 'django.middleware.csrf.CsrfViewMiddleware',
59 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
60 | 'django.contrib.messages.middleware.MessageMiddleware',
61 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
62 |
63 | 'wagtail.wagtailcore.middleware.SiteMiddleware',
64 | ]
65 |
66 | TEMPLATES = [
67 | {
68 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
69 | 'APP_DIRS': True,
70 | 'OPTIONS': {
71 | 'debug': True,
72 | 'context_processors': [
73 | 'django.contrib.auth.context_processors.auth',
74 | 'django.template.context_processors.debug',
75 | 'django.template.context_processors.i18n',
76 | 'django.template.context_processors.media',
77 | 'django.template.context_processors.static',
78 | 'django.template.context_processors.tz',
79 | 'django.template.context_processors.request',
80 | 'django.contrib.messages.context_processors.messages'
81 | ],
82 | },
83 | },
84 | ]
85 |
86 | STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static')
87 | STATIC_URL = '/static/'
88 |
89 | WAGTAIL_SOCIALFEED_CONFIG = {
90 | 'twitter': {
91 | 'CONSUMER_KEY': 'SOME_KEY',
92 | 'CONSUMER_SECRET': 'SOME_SECRET',
93 | 'ACCESS_TOKEN_KEY': 'SOME_KEY',
94 | 'ACCESS_TOKEN_SECRET': 'SOME_SECRET'
95 | },
96 | 'facebook': {
97 | 'CLIENT_ID': 'SOME_ID',
98 | 'CLIENT_SECRET': 'SOME_SECRET',
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | """
2 | test_models
3 | ----------------------------------
4 |
5 | Tests for `wagtailsocialfeed.models`.
6 | """
7 | from __future__ import unicode_literals
8 |
9 | from django.test import RequestFactory, TestCase
10 | from django.utils import six
11 |
12 | from wagtailsocialfeed.utils.feed.factory import FeedFactory
13 |
14 | from . import feed_response
15 | from .factories import SocialFeedConfigurationFactory, SocialFeedPageFactory
16 |
17 |
18 | class SocialFeedConfigurationTest(TestCase):
19 | def setUp(self):
20 | pass
21 |
22 | def test_str(self):
23 | twitter_config = SocialFeedConfigurationFactory.create(
24 | source='twitter', username='johndoe')
25 | instagram_config = SocialFeedConfigurationFactory.create(
26 | source='instagram', username='johndoe')
27 |
28 | self.assertEqual(six.text_type(twitter_config),
29 | 'twitter (@johndoe)')
30 | self.assertEqual(six.text_type(instagram_config),
31 | 'instagram (johndoe)')
32 |
33 |
34 | class ModeratedItemTest(TestCase):
35 | @feed_response('twitter')
36 | def setUp(self, tweets):
37 | self.feedconfig = SocialFeedConfigurationFactory(
38 | source='twitter', username='wagtailcms')
39 | feed = FeedFactory.create('twitter')
40 | items = feed.get_items(self.feedconfig)
41 | self.item, created = self.feedconfig.moderated_items.get_or_create_for(items[0].serialize())
42 |
43 | def test_str(self):
44 | self.assertEqual(
45 | six.text_type(self.item),
46 | "ModeratedItem (779235925826138112 posted 2016-09-23 08:28:16+00:00)")
47 |
48 |
49 | class SocialFeedPageTest(TestCase):
50 | def setUp(self):
51 | self.factory = RequestFactory()
52 |
53 | self.feedconfig = SocialFeedConfigurationFactory(
54 | source='twitter', username='wagtailcms')
55 | self.page = SocialFeedPageFactory.create(feedconfig=self.feedconfig)
56 |
57 | @feed_response('twitter')
58 | def test_serve(self, tweets):
59 | request = self.factory.get('/pages/feed/')
60 | resp = self.page.serve(request)
61 | resp.render()
62 |
63 | self.assertIn('feed', resp.context_data)
64 | self.assertEqual(len(resp.context_data['feed']), 17)
65 |
66 | @feed_response('twitter')
67 | def test_serve_moderated(self, tweets):
68 | self.feedconfig.moderated = True
69 | self.feedconfig.save()
70 |
71 | request = self.factory.get('/pages/feed/')
72 | resp = self.page.serve(request)
73 | resp.render()
74 | self.assertIn('feed', resp.context_data)
75 | self.assertEqual(len(resp.context_data['feed']), 0)
76 |
77 | # Now moderate some items
78 | feed = FeedFactory.create('twitter')
79 | items = feed.get_items(self.feedconfig)
80 | for item in items[:3]:
81 | self.feedconfig.moderated_items.get_or_create_for(
82 | item.serialize())
83 |
84 | resp = self.page.serve(request)
85 | resp.render()
86 | self.assertIn('feed', resp.context_data)
87 | self.assertEqual(len(resp.context_data['feed']), 3)
88 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/feed/twitter.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from dateutil import parser as dateparser
4 | from django.core.exceptions import ImproperlyConfigured
5 | from twython import Twython
6 | from wagtailsocialfeed.utils.conf import get_socialfeed_setting
7 |
8 | from . import AbstractFeed, AbstractFeedQuery, FeedItem
9 |
10 | logger = logging.getLogger('wagtailsocialfeed')
11 |
12 | settings = get_socialfeed_setting('CONFIG').get('twitter', None)
13 | if not settings:
14 | raise ImproperlyConfigured(
15 | "No twitter configuration defined in the settings. "
16 | "Make sure you define WAGTAIL_SOCIALFEED_CONFIG in your "
17 | "settings with at least a 'twitter' entry.")
18 |
19 |
20 | class TwitterFeedItem(FeedItem):
21 | @classmethod
22 | def get_post_date(cls, raw):
23 | # Use the dateutil parser because on some platforms
24 | # python's own strptime doesn't support the %z directive.
25 | # Format: '%a %b %d %H:%M:%S %z %Y'
26 | return dateparser.parse(raw.get('created_at'))
27 |
28 | @classmethod
29 | def from_raw(cls, raw):
30 | image = None
31 | extended = raw.get('extended_entities', None)
32 | if extended:
33 | image = process_images(extended.get('media', None))
34 | date = cls.get_post_date(raw)
35 | return cls(
36 | id=raw['id'],
37 | type='twitter',
38 | text=raw['text'],
39 | image_dict=image,
40 | posted=date,
41 | original_data=raw
42 | )
43 |
44 |
45 | def process_images(media_dict):
46 | images = {}
47 | if not media_dict:
48 | return images
49 | base_url = media_dict[0]['media_url_https']
50 | sizes = media_dict[0]['sizes']
51 |
52 | for size in sizes:
53 | images[size] = {
54 | 'url': '{0}:{1}'.format(base_url, size),
55 | 'width': sizes[size]['w'],
56 | 'height': sizes[size]['h'],
57 | }
58 | return images
59 |
60 |
61 | class TwitterFeedQuery(AbstractFeedQuery):
62 | def __init__(self, username, query_string):
63 | super(TwitterFeedQuery, self).__init__(username, query_string)
64 |
65 | self.twitter = Twython(settings['CONSUMER_KEY'],
66 | settings['CONSUMER_SECRET'],
67 | settings['ACCESS_TOKEN_KEY'],
68 | settings['ACCESS_TOKEN_SECRET'])
69 |
70 | def _get_load_kwargs(self, oldest_post):
71 | # Trick from twitter API doc to exclude the oldest post from
72 | # the next result-set
73 | return {'max_id': self.oldest_post['id'] - 1}
74 |
75 | def _search(self, raw_item):
76 | """Very basic search function"""
77 | return self.query_string.lower() in raw_item['text'].lower()
78 |
79 | def _load(self, max_id=None):
80 | """Return the raw data fetched from twitter."""
81 | options = settings.get('OPTIONS', {})
82 | return self.twitter.get_user_timeline(
83 | screen_name=self.username,
84 | trim_user=options.get('trim_user', True),
85 | contributor_details=options.get('contributor_details', False),
86 | include_rts=options.get('include_rts', False),
87 | max_id=max_id)
88 |
89 |
90 | class TwitterFeed(AbstractFeed):
91 | item_cls = TwitterFeedItem
92 | query_cls = TwitterFeedQuery
93 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/models.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import, unicode_literals
2 |
3 | from django.db import models
4 | from django.utils.functional import cached_property
5 | from django.utils.six import python_2_unicode_compatible
6 | from django.utils.translation import ugettext_lazy as _
7 | from wagtail.wagtailadmin.edit_handlers import FieldPanel
8 | from wagtail.wagtailcore.models import Page
9 |
10 | from .managers import ModeratedItemManager
11 | from .utils import get_feed_items, get_feed_items_mix
12 | from .utils.feed.factory import FeedItemFactory
13 |
14 |
15 | @python_2_unicode_compatible
16 | class SocialFeedConfiguration(models.Model):
17 | FEED_CHOICES = (
18 | ('twitter', _('Twitter')),
19 | ('instagram', _('Instagram')),
20 | ('facebook', _('Facebook'))
21 | )
22 |
23 | source = models.CharField(_('Feed source'),
24 | max_length=100,
25 | choices=FEED_CHOICES,
26 | blank=False)
27 | username = models.CharField(_('User to track'),
28 | max_length=255,
29 | blank=False)
30 | moderated = models.BooleanField(default=False)
31 |
32 | def __str__(self):
33 | name = self.username
34 | if self.source == 'twitter':
35 | name = "@{}".format(self.username)
36 | return "{} ({})".format(self.source, name)
37 |
38 |
39 | @python_2_unicode_compatible
40 | class ModeratedItem(models.Model):
41 | config = models.ForeignKey(SocialFeedConfiguration,
42 | related_name='moderated_items',
43 | on_delete=models.CASCADE)
44 | moderated = models.DateTimeField(auto_now_add=True)
45 | posted = models.DateTimeField(blank=False, null=False)
46 |
47 | external_id = models.CharField(max_length=255,
48 | blank=False)
49 | content = models.TextField(blank=False)
50 |
51 | objects = ModeratedItemManager()
52 |
53 | class Meta:
54 | ordering = ['-posted', ]
55 |
56 | def __str__(self):
57 | return "{}<{}> ({} posted {})".format(
58 | self.__class__.__name__,
59 | self.type,
60 | self.external_id,
61 | self.posted
62 | )
63 |
64 | def get_content(self):
65 | if not hasattr(self, '_feeditem'):
66 | item_cls = FeedItemFactory.get_class(self.config.source)
67 | self._feeditem = item_cls.from_moderated(self)
68 | return self._feeditem
69 |
70 | @cached_property
71 | def type(self):
72 | return self.config.source
73 |
74 |
75 | class SocialFeedPage(Page):
76 | feedconfig = models.ForeignKey(SocialFeedConfiguration,
77 | blank=True,
78 | null=True,
79 | on_delete=models.PROTECT,
80 | help_text=_("Leave blank to show all the feeds."))
81 |
82 | content_panels = Page.content_panels + [
83 | FieldPanel('feedconfig'),
84 | ]
85 |
86 | def get_context(self, request, *args, **kwargs):
87 | context = super(SocialFeedPage,
88 | self).get_context(request, *args, **kwargs)
89 | feed = None
90 | if self.feedconfig:
91 | feed = get_feed_items(self.feedconfig)
92 | else:
93 | feed = get_feed_items_mix(SocialFeedConfiguration.objects.all())
94 |
95 | context['feed'] = feed
96 | return context
97 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/wagtail_hooks.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from django.conf.urls import include, url
4 | from django.core.urlresolvers import reverse
5 | from django.db.models.signals import post_save
6 | from django.utils import six
7 | from django.utils.translation import ugettext_lazy as _
8 | from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register
9 | from wagtail.wagtailadmin.menu import Menu, MenuItem, SubmenuMenuItem
10 | from wagtail.wagtailcore import hooks
11 |
12 | from . import urls
13 | from .models import SocialFeedConfiguration
14 |
15 |
16 | class SocialFeedConfigurationAdmin(ModelAdmin):
17 | model = SocialFeedConfiguration
18 | menu_label = 'Social feeds' # TODO ditch this to use verbose_name_plural from model # NOQA
19 | menu_icon = 'icon icon-fa-rss'
20 | menu_order = 400
21 | add_to_settings_menu = True
22 | list_display = ('source', 'username')
23 | list_filter = ('source', )
24 |
25 |
26 | class SocialFeedModerateMenu(Menu):
27 | config_menu_items = {
28 | # Contains all submenu items mapped to the id's of the
29 | # `SocialFeedConfiguration` they link to
30 | }
31 |
32 | def __init__(self):
33 | # Iterate over existing configurations that are moderated
34 | config_qs = SocialFeedConfiguration.objects.filter(moderated=True)
35 | for config in config_qs:
36 | self.config_menu_items[config.id] = \
37 | self._create_moderate_menu_item(config)
38 |
39 | self._registered_menu_items = list(self.config_menu_items.values())
40 | self.construct_hook_name = None
41 |
42 | post_save.connect(self._update_menu, sender=SocialFeedConfiguration)
43 |
44 | def _update_menu(self, instance, **kwargs):
45 | """
46 | Call whenever a `SocialFeedCongiration` gets changed.
47 |
48 | When it is not moderated anymore, but exists in our menu, it should be
49 | removed.
50 | When it is moderated but does not exist yet, we should create a
51 | new menu item.
52 | """
53 | has_menu_item = instance.id in self.config_menu_items.keys()
54 |
55 | if not instance.moderated and has_menu_item:
56 | menu_item = self.config_menu_items.pop(instance.id)
57 | index = self._registered_menu_items.index(menu_item)
58 | del self._registered_menu_items[index]
59 | elif instance.moderated and not has_menu_item:
60 | menu_item = self._create_moderate_menu_item(instance)
61 | self.config_menu_items[instance.id] = menu_item
62 | self._registered_menu_items.append(menu_item)
63 |
64 | def _create_moderate_menu_item(self, config):
65 | """Create a submenu item for the moderate admin page."""
66 | url = reverse('wagtailsocialfeed:moderate', kwargs={'pk': config.pk})
67 | return MenuItem(six.text_type(config), url,
68 | classnames='icon icon-folder-inverse')
69 |
70 |
71 | modeladmin_register(SocialFeedConfigurationAdmin)
72 |
73 |
74 | @hooks.register('register_admin_urls')
75 | def register_admin_urls():
76 | return [
77 | url(r'^socialfeed/', include(urls, app_name='wagtailsocialfeed',
78 | namespace='wagtailsocialfeed')),
79 | ]
80 |
81 |
82 | @hooks.register('register_admin_menu_item')
83 | def register_socialfeed_menu():
84 | # Create the main socialfeed menu item
85 | socialfeed_menu = SocialFeedModerateMenu()
86 | return SubmenuMenuItem(
87 | _('Social feed'), socialfeed_menu, classnames='icon icon-fa-rss',
88 | order=800)
89 |
--------------------------------------------------------------------------------
/tests/test_blocks.py:
--------------------------------------------------------------------------------
1 | """
2 | test_blocks
3 | ----------------------------------
4 |
5 | Tests for `wagtailsocialfeed.blocks`.
6 |
7 | In Wagtail 1.8 the get_context accepts only one parameter and that's why its raising TypeError.
8 | In Combination of Django 1.11 and Wagtail 1.9, `ImportError` Exception is occurring in wagtailimage.
9 |
10 | from django.forms.widgets import flatatt
11 | ImportError: cannot import name flatatt
12 |
13 | because it's moved to utils
14 |
15 | """
16 |
17 | from __future__ import unicode_literals
18 |
19 | from django.test import RequestFactory, TestCase
20 |
21 | from bs4 import BeautifulSoup
22 | from wagtailsocialfeed.blocks import SocialFeedBlock
23 |
24 | from . import feed_response
25 | from .factories import SocialFeedConfigurationFactory, SocialFeedPageFactory
26 |
27 |
28 | class TestSocialFeedBlock(TestCase):
29 | def setUp(self):
30 | self.factory = RequestFactory()
31 |
32 | self.feedconfig = SocialFeedConfigurationFactory(
33 | source='twitter', username='wagtailcms')
34 | self.page = SocialFeedPageFactory.create(feedconfig=self.feedconfig)
35 |
36 | @feed_response('twitter')
37 | def test_render(self, tweets):
38 | block = SocialFeedBlock()
39 | value = {
40 | 'feedconfig': self.feedconfig,
41 | 'limit': 3
42 | }
43 | context = block.get_context(value)
44 | html = block.render(value)
45 |
46 | self.assertEqual(len(context['feed']), 3)
47 | self.assertIn("snipcart our pleasure! also posted to",
48 | html)
49 | self.assertIn(""It's elegant, flexible, and, IMHO, kicks ass"",
50 | html)
51 | self.assertIn("@snipcart your new Wagtail + Snipcart tutorial is awesssssome",
52 | html)
53 |
54 | def test_to_python(self):
55 | block = SocialFeedBlock()
56 | value = {
57 | 'feedconfig': self.feedconfig.pk,
58 | 'limit': 3
59 | }
60 | result = block.to_python(value)
61 | self.assertEqual(result['feedconfig'], self.feedconfig)
62 | self.assertEqual(result['limit'], 3)
63 |
64 | def test_render_form(self):
65 | block = SocialFeedBlock()
66 | value = {
67 | 'feedconfig': self.feedconfig,
68 | 'limit': 3
69 | }
70 | html = block.render_form(value, prefix='test')
71 | soup = BeautifulSoup(html, 'html.parser')
72 |
73 | self.assertEqual(
74 | soup.find(id='test-feedconfig').find_all('option')[1].text,
75 | 'twitter (@wagtailcms)')
76 |
77 | @feed_response('twitter')
78 | def test_get_context_with_parent_context(self, tweets):
79 | block = SocialFeedBlock()
80 |
81 | value = {
82 | 'feedconfig': self.feedconfig,
83 | 'limit': 3
84 | }
85 | parent_context = {'has_parent': 'parent context'}
86 |
87 | try:
88 | context = block.get_context(value, parent_context)
89 | self.assertEqual(context['has_parent'], parent_context['has_parent'])
90 | # In Wagtail 1.8 Function accepting only one parameter and that's why its raising TypeError
91 | except TypeError:
92 | self.skipTest(TestSocialFeedBlock)
93 |
94 | @feed_response('twitter')
95 | def test_get_context_without_parent_context(self, tweets):
96 | block = SocialFeedBlock()
97 |
98 | value = {
99 | 'feedconfig': self.feedconfig,
100 | 'limit': 3
101 | }
102 |
103 | context = block.get_context(value)
104 | self.assertEqual(context['value'], value)
105 |
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | .. highlight:: shell
2 |
3 | ============
4 | Contributing
5 | ============
6 |
7 | Contributions are welcome, and they are greatly appreciated! Every
8 | little bit helps, and credit will always be given.
9 |
10 | You can contribute in many ways:
11 |
12 | Types of Contributions
13 | ----------------------
14 |
15 | Report Bugs
16 | ~~~~~~~~~~~
17 |
18 | Report bugs at https://github.com/LUKKIEN/wagtailsocialfeed/issues.
19 |
20 | If you are reporting a bug, please include:
21 |
22 | * Your operating system name and version.
23 | * Any details about your local setup that might be helpful in troubleshooting.
24 | * Detailed steps to reproduce the bug.
25 |
26 | Fix Bugs
27 | ~~~~~~~~
28 |
29 | Look through the GitHub issues for bugs. Anything tagged with "bug"
30 | and "help wanted" is open to whoever wants to implement it.
31 |
32 | Implement Features
33 | ~~~~~~~~~~~~~~~~~~
34 |
35 | Look through the GitHub issues for features. Anything tagged with "enhancement"
36 | and "help wanted" is open to whoever wants to implement it.
37 |
38 | Write Documentation
39 | ~~~~~~~~~~~~~~~~~~~
40 |
41 | Wagtail Social Feed could always use more documentation, whether as part of the
42 | official Wagtail Social Feed docs, in docstrings, or even on the web in blog posts,
43 | articles, and such.
44 |
45 | Submit Feedback
46 | ~~~~~~~~~~~~~~~
47 |
48 | The best way to send feedback is to file an issue at https://github.com/LUKKIEN/wagtailsocialfeed/issues.
49 |
50 | If you are proposing a feature:
51 |
52 | * Explain in detail how it would work.
53 | * Keep the scope as narrow as possible, to make it easier to implement.
54 | * Remember that this is a volunteer-driven project, and that contributions
55 | are welcome :)
56 |
57 | Get Started!
58 | ------------
59 |
60 | Ready to contribute? Here's how to set up `wagtailsocialfeed` for local development.
61 |
62 | 1. Fork the `wagtailsocialfeed` repo on GitHub.
63 | 2. Clone your fork locally::
64 |
65 | $ git clone git@github.com:your_name_here/wagtailsocialfeed.git
66 |
67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development::
68 |
69 | $ mkvirtualenv wagtailsocialfeed
70 | $ cd wagtailsocialfeed/
71 | $ python setup.py develop
72 |
73 | 4. Create a branch for local development::
74 |
75 | $ git checkout -b name-of-your-bugfix-or-feature
76 |
77 | Now you can make your changes locally.
78 |
79 | 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox::
80 |
81 | $ flake8 wagtailsocialfeed tests
82 | $ python setup.py test or py.test
83 | $ tox
84 |
85 | To get flake8 and tox, just pip install them into your virtualenv.
86 |
87 | 6. Commit your changes and push your branch to GitHub::
88 |
89 | $ git add .
90 | $ git commit -m "Your detailed description of your changes."
91 | $ git push origin name-of-your-bugfix-or-feature
92 |
93 | 7. Submit a pull request through the GitHub website.
94 |
95 | Pull Request Guidelines
96 | -----------------------
97 |
98 | Before you submit a pull request, check that it meets these guidelines:
99 |
100 | 1. The pull request should include tests.
101 | 2. If the pull request adds functionality, the docs should be updated. Put
102 | your new functionality into a function with a docstring, and add the
103 | feature to the list in README.rst.
104 | 3. The pull request should work for Python 2.7, 3.3, 3.4 and 3.5 and for PyPy. Check
105 | https://travis-ci.org/tleguijt/wagtailsocialfeed/pull_requests
106 | and make sure that the tests pass for all supported Python versions.
107 |
108 | Tips
109 | ----
110 |
111 | To run a subset of tests::
112 |
113 |
114 | $ python -m unittest tests.test_wagtailsocialfeed
115 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | from django.http import JsonResponse
4 | from django.utils import six
5 | from django.utils.translation import ugettext_lazy as _
6 | from django.views.decorators.csrf import csrf_exempt
7 | from django.views.generic.base import View
8 | from django.views.generic.detail import DetailView
9 | from wagtail.wagtailadmin.forms import SearchForm
10 |
11 | from .models import ModeratedItem, SocialFeedConfiguration
12 | from .utils.feed.factory import FeedFactory
13 |
14 |
15 | class ModerateView(DetailView):
16 | """
17 | ModerateView.
18 |
19 | Collect the latest feeds and find out which ones are already
20 | moderated/allowed in the feed (already have an `ModeratedItem`
21 | associated with them
22 | """
23 |
24 | template_name = 'wagtailsocialfeed/admin/moderate.html'
25 | queryset = SocialFeedConfiguration.objects.filter(moderated=True)
26 |
27 | def page_title(self):
28 | return _('Moderating {}').format(self.object)
29 |
30 | def get_search_form(self):
31 | if 'q' in self.request.GET:
32 | return SearchForm(self.request.GET, placeholder=_("Search posts"))
33 | return SearchForm(placeholder=_("Search posts"))
34 |
35 | def get_context_data(self, **kwargs):
36 | context = super(ModerateView, self).get_context_data(**kwargs)
37 | feed = FeedFactory.create(self.object.source)
38 |
39 | form = self.get_search_form()
40 | query_string = None
41 | use_cache = True
42 |
43 | if form.is_valid():
44 | use_cache = False
45 | query_string = form.cleaned_data['q']
46 |
47 | items = feed.get_items(config=self.object, query_string=query_string,
48 | use_cache=use_cache)
49 |
50 | if self.object.moderated:
51 | allowed_ids = self.object.moderated_items.values_list(
52 | 'external_id', flat=True)
53 | for item in items:
54 | # Flag to see if this item is already allowed in
55 | # the feed or not
56 | item.allowed = item.id in allowed_ids
57 |
58 | context['feed'] = items
59 | context['search_form'] = form
60 |
61 | return context
62 |
63 |
64 | error_messages = {
65 | 'no_original':
66 | _('The original social feed post was not found in the POST data'),
67 | 'not_found': _('The moderated item with the given id could not be found')
68 | }
69 |
70 |
71 | class ModerateAllowView(View):
72 | @csrf_exempt
73 | def dispatch(self, *args, **kwargs):
74 | return super(ModerateAllowView, self).dispatch(*args, **kwargs)
75 |
76 | def post(self, request, pk, post_id):
77 | config = SocialFeedConfiguration.objects.get(pk=pk)
78 |
79 | if 'original' not in request.POST:
80 | err = {'message': six.text_type(error_messages['no_original'])}
81 | return JsonResponse(err, status=400)
82 |
83 | original = request.POST['original']
84 | item, created = config.moderated_items.get_or_create_for(original)
85 |
86 | return JsonResponse({
87 | 'message': 'The post is now allowed on the feed',
88 | 'post_id': post_id,
89 | 'allowed': True
90 | })
91 |
92 |
93 | class ModerateRemoveView(View):
94 | @csrf_exempt
95 | def dispatch(self, *args, **kwargs):
96 | return super(ModerateRemoveView, self).dispatch(*args, **kwargs)
97 |
98 | def post(self, request, pk, post_id):
99 | config = SocialFeedConfiguration.objects.get(pk=pk)
100 |
101 | try:
102 | item = config.moderated_items.get(external_id=post_id)
103 | except ModeratedItem.DoesNotExist:
104 | err = {'message': six.text_type(error_messages['not_found'])}
105 | return JsonResponse(err, status=404)
106 |
107 | item.delete()
108 |
109 | return JsonResponse({
110 | 'message': 'The post is removed from the feed',
111 | 'post_id': post_id,
112 | 'allowed': False
113 | })
114 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/feed/facebook.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from dateutil import parser as dateparser
4 | from django.core.exceptions import ImproperlyConfigured
5 | from facepy import GraphAPI
6 | from wagtailsocialfeed.utils.conf import get_socialfeed_setting
7 |
8 | from . import AbstractFeed, AbstractFeedQuery, FeedItem
9 |
10 |
11 | class PostType(Enum):
12 | status = 'status'
13 | photo = 'photo'
14 | link = 'link'
15 | video = 'video'
16 | offer = 'offer'
17 | event = 'event'
18 |
19 | def get_text_from(self, raw):
20 | """Get the display text from the raw data.
21 |
22 | Each post/object type has its different kind of data
23 | and so the text has to be distilled in different ways
24 | accoring to the object type.
25 |
26 | :param raw: the raw data
27 | """
28 | def _try_defaults():
29 | if 'message' in raw:
30 | return raw['message']
31 | return raw.get('story', '')
32 |
33 | if self is PostType.status:
34 | # Status
35 | return _try_defaults()
36 |
37 | if self is PostType.photo:
38 | # Photo
39 | if 'description' in raw:
40 | return raw['description']
41 | return _try_defaults()
42 |
43 | if self is PostType.link:
44 | # Link
45 | link = raw['link']
46 | if 'message' in raw:
47 | text = raw['message']
48 | if link not in text:
49 | text += " " + link
50 | return text
51 | return raw['link']
52 |
53 | return _try_defaults()
54 |
55 | # if self is PostType.video:
56 | # # implement
57 | # pass
58 | # elif self is PostType.offer:
59 | # # implement
60 | # pass
61 | # elif self is PostType.event:
62 | # # implement
63 | # pass
64 |
65 |
66 | class FacebookFeedItem(FeedItem):
67 | """Implements facebook-specific behaviour."""
68 |
69 | @classmethod
70 | def get_post_date(cls, raw):
71 | if 'created_time' in raw:
72 | return dateparser.parse(raw.get('created_time'))
73 | return None
74 |
75 | @classmethod
76 | def from_raw(cls, raw):
77 | item_type = PostType(raw['type'])
78 | image = {}
79 | if 'picture' in raw:
80 | image = {
81 | 'thumb': {'url': raw['picture']},
82 | # 'small': raw['images']['low_resolution'],
83 | # 'medium': raw['images']['standard_resolution'],
84 | # 'largel': None,
85 | }
86 |
87 | return cls(
88 | id=raw['id'],
89 | type='facebook',
90 | text=item_type.get_text_from(raw),
91 | image_dict=image,
92 | posted=cls.get_post_date(raw),
93 | original_data=raw,
94 | )
95 |
96 |
97 | class FacebookFeedQuery(AbstractFeedQuery):
98 | def __init__(self, username, query_string):
99 | super(FacebookFeedQuery, self).__init__(username, query_string)
100 |
101 | settings = get_socialfeed_setting('CONFIG').get('facebook', None)
102 | if not settings:
103 | raise ImproperlyConfigured(
104 | "No facebook configuration defined in the settings. "
105 | "Make sure you define WAGTAIL_SOCIALFEED_CONFIG in your "
106 | "settings with at least a 'facebook' entry.")
107 |
108 | graph = GraphAPI("{}|{}".format(settings['CLIENT_ID'], settings['CLIENT_SECRET']))
109 | required_fields = get_socialfeed_setting('FACEBOOK_FIELDS')
110 | self._paginator = graph.get('{}/posts?fields={}'.format(self.username, ','.join(required_fields)), page=True)
111 |
112 | def _search(self, raw_item):
113 | """Very basic search function"""
114 | all_strings = " ".join([
115 | raw_item.get('message', ''),
116 | raw_item.get('story', ''),
117 | raw_item.get('description', '')
118 | ])
119 | return self.query_string.lower() in all_strings.lower()
120 |
121 | def _load(self):
122 | raw = next(self._paginator)
123 | return raw['data']
124 |
125 |
126 | class FacebookFeed(AbstractFeed):
127 | item_cls = FacebookFeedItem
128 | query_cls = FacebookFeedQuery
129 |
--------------------------------------------------------------------------------
/tests/fixtures/instagram.2.json:
--------------------------------------------------------------------------------
1 | {
2 | "user":{
3 | "biography":null,
4 | "blocked_by_viewer":false,
5 | "country_block":false,
6 | "external_url":null,
7 | "external_url_linkshimmed":null,
8 | "followed_by":{
9 | "count":0
10 | },
11 | "followed_by_viewer":false,
12 | "follows":{
13 | "count":0
14 | },
15 | "follows_viewer":false,
16 | "full_name":"Oktay Altay",
17 | "has_blocked_viewer":false,
18 | "has_requested_viewer":false,
19 | "id":"6357903941",
20 | "is_private":false,
21 | "is_verified":false,
22 | "profile_pic_url":"https://scontent-frt3-2.cdninstagram.com/t51.2885-19/11906329_960233084022564_1448528159_a.jpg",
23 | "profile_pic_url_hd":"https://scontent-frt3-2.cdninstagram.com/t51.2885-19/11906329_960233084022564_1448528159_a.jpg",
24 | "requested_by_viewer":false,
25 | "username":"okkielukkien",
26 | "connected_fb_page":null,
27 | "media":{
28 | "nodes":[
29 | {
30 | "__typename":"GraphImage",
31 | "id":"1659576916259530282",
32 | "comments_disabled":false,
33 | "dimensions":{
34 | "height":1080,
35 | "width":1080
36 | },
37 | "gating_info":null,
38 | "media_preview":"ACoqyJSp+7n6kj+gqexgaaTC9hyT0/H/AOtzVRjWlp9wIEZmBIyOn/16T0Q1qy9cWWwcvn8Bj8iTWaEQOI5Ap3dGHBz6HFW59SjkXKqR/j/KsqSYSMGHGCDz9aUb9Sna3mXmsYz0LL+Of51F/Z3+3+n/ANepUugfvfmKseanqPzqiDFYVo2s8SQneNxGfwPt9QaqSpiltlJyO39al6opaMvXE8AhEZUhsg4A4HHr3rPkMRQBFwx9zVucokeChJ/vAnB+o/wxWWh5+nNCQ2ywRtpN1DHNRVRBqTpVNCEzk4HrjPPuPT6c1pXFZjdaQxZNzdJEx/vEfzGaqMoXuGPt0/M0OOaYKYifNNzSUlAH/9k=",
39 | "owner":{
40 | "id":"6357903941"
41 | },
42 | "thumbnail_src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/24274239_521909761497027_948702930737823744_n.jpg",
43 | "thumbnail_resources":[
44 | {
45 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s150x150/e35/24274239_521909761497027_948702930737823744_n.jpg",
46 | "config_width":150,
47 | "config_height":150
48 | },
49 | {
50 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s240x240/e35/24274239_521909761497027_948702930737823744_n.jpg",
51 | "config_width":240,
52 | "config_height":240
53 | },
54 | {
55 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s320x320/e35/24274239_521909761497027_948702930737823744_n.jpg",
56 | "config_width":320,
57 | "config_height":320
58 | },
59 | {
60 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s480x480/e35/24274239_521909761497027_948702930737823744_n.jpg",
61 | "config_width":480,
62 | "config_height":480
63 | },
64 | {
65 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/24274239_521909761497027_948702930737823744_n.jpg",
66 | "config_width":640,
67 | "config_height":640
68 | }
69 | ],
70 | "is_video":false,
71 | "code":"BcIAGmbFmYq",
72 | "date":1512057006,
73 | "display_src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/e35/24274239_521909761497027_948702930737823744_n.jpg",
74 | "caption":"Sheeeeeeeeeiittt",
75 | "comments":{
76 | "count":0
77 | },
78 | "likes":{
79 | "count":0
80 | }
81 | },
82 | {
83 | "__typename":"GraphImage",
84 | "id":"1643525570544788968",
85 | "comments_disabled":false,
86 | "dimensions":{
87 | "height":750,
88 | "width":750
89 | },
90 | "gating_info":null,
91 | "media_preview":"ACoqtMKgarLiqzVJbKssuxlXs3HvUtQT5JT03VNTJCikpKBGi5qs1Su1VzUFlaVgXQdwScVLVFm/fj8qu1ZIlFJRQBZJqMmlppqSigIm87cRx61copKokKSiigD/2Q==",
92 | "owner":{
93 | "id":"6357903941"
94 | },
95 | "thumbnail_src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
96 | "thumbnail_resources":[
97 | {
98 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s150x150/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
99 | "config_width":150,
100 | "config_height":150
101 | },
102 | {
103 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s240x240/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
104 | "config_width":240,
105 | "config_height":240
106 | },
107 | {
108 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s320x320/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
109 | "config_width":320,
110 | "config_height":320
111 | },
112 | {
113 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s480x480/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
114 | "config_width":480,
115 | "config_height":480
116 | },
117 | {
118 | "src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
119 | "config_width":640,
120 | "config_height":640
121 | }
122 | ],
123 | "is_video":false,
124 | "code":"BbO-cxzl-Xo",
125 | "date":1510143537,
126 | "display_src":"https://scontent-amt2-1.cdninstagram.com/t51.2885-15/e35/23347771_2011313125820237_3567622259528957952_n.jpg",
127 | "comments":{
128 | "count":0
129 | },
130 | "likes":{
131 | "count":0
132 | }
133 | }
134 | ],
135 | "count":2,
136 | "page_info":{
137 | "has_next_page":false,
138 | "end_cursor":"AQA8_o6TQB4X4ZPtwYD4zvqYE3Xhg6BvzF0J485EIUjR139_YHKmHdb__Qxzh0ZeynA"
139 | }
140 | },
141 | "saved_media":{
142 | "nodes":[
143 |
144 | ],
145 | "count":0,
146 | "page_info":{
147 | "has_next_page":false,
148 | "end_cursor":null
149 | }
150 | }
151 | },
152 | "logging_page_id":"profilePage_6357903941"
153 | }
154 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # User-friendly check for sphinx-build
11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
13 | endif
14 |
15 | # Internal variables.
16 | PAPEROPT_a4 = -D latex_paper_size=a4
17 | PAPEROPT_letter = -D latex_paper_size=letter
18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
19 | # the i18n builder cannot share the environment and doctrees with the others
20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
21 |
22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
23 |
24 | help:
25 | @echo "Please use \`make ' where is one of"
26 | @echo " html to make standalone HTML files"
27 | @echo " dirhtml to make HTML files named index.html in directories"
28 | @echo " singlehtml to make a single large HTML file"
29 | @echo " pickle to make pickle files"
30 | @echo " json to make JSON files"
31 | @echo " htmlhelp to make HTML files and a HTML help project"
32 | @echo " qthelp to make HTML files and a qthelp project"
33 | @echo " devhelp to make HTML files and a Devhelp project"
34 | @echo " epub to make an epub"
35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
36 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
38 | @echo " text to make text files"
39 | @echo " man to make manual pages"
40 | @echo " texinfo to make Texinfo files"
41 | @echo " info to make Texinfo files and run them through makeinfo"
42 | @echo " gettext to make PO message catalogs"
43 | @echo " changes to make an overview of all changed/added/deprecated items"
44 | @echo " xml to make Docutils-native XML files"
45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes"
46 | @echo " linkcheck to check all external links for integrity"
47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
48 |
49 | clean:
50 | rm -rf $(BUILDDIR)/*
51 |
52 | html:
53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
54 | @echo
55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
56 |
57 | dirhtml:
58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
59 | @echo
60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
61 |
62 | singlehtml:
63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
64 | @echo
65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
66 |
67 | pickle:
68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
69 | @echo
70 | @echo "Build finished; now you can process the pickle files."
71 |
72 | json:
73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
74 | @echo
75 | @echo "Build finished; now you can process the JSON files."
76 |
77 | htmlhelp:
78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
79 | @echo
80 | @echo "Build finished; now you can run HTML Help Workshop with the" \
81 | ".hhp project file in $(BUILDDIR)/htmlhelp."
82 |
83 | qthelp:
84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
85 | @echo
86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/wagtailsocialfeed.qhcp"
89 | @echo "To view the help file:"
90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/wagtailsocialfeed.qhc"
91 |
92 | devhelp:
93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
94 | @echo
95 | @echo "Build finished."
96 | @echo "To view the help file:"
97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/wagtailsocialfeed"
98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/wagtailsocialfeed"
99 | @echo "# devhelp"
100 |
101 | epub:
102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
103 | @echo
104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
105 |
106 | latex:
107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
108 | @echo
109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
111 | "(use \`make latexpdf' here to do that automatically)."
112 |
113 | latexpdf:
114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
115 | @echo "Running LaTeX files through pdflatex..."
116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
118 |
119 | latexpdfja:
120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
121 | @echo "Running LaTeX files through platex and dvipdfmx..."
122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
124 |
125 | text:
126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
127 | @echo
128 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
129 |
130 | man:
131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
132 | @echo
133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
134 |
135 | texinfo:
136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
137 | @echo
138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
139 | @echo "Run \`make' in that directory to run these through makeinfo" \
140 | "(use \`make info' here to do that automatically)."
141 |
142 | info:
143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
144 | @echo "Running Texinfo files through makeinfo..."
145 | make -C $(BUILDDIR)/texinfo info
146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
147 |
148 | gettext:
149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
150 | @echo
151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
152 |
153 | changes:
154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
155 | @echo
156 | @echo "The overview file is in $(BUILDDIR)/changes."
157 |
158 | linkcheck:
159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
160 | @echo
161 | @echo "Link check complete; look for any errors in the above output " \
162 | "or in $(BUILDDIR)/linkcheck/output.txt."
163 |
164 | doctest:
165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
166 | @echo "Testing of doctests in the sources finished, look at the " \
167 | "results in $(BUILDDIR)/doctest/output.txt."
168 |
169 | xml:
170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
171 | @echo
172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
173 |
174 | pseudoxml:
175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
176 | @echo
177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
178 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=_build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
10 | set I18NSPHINXOPTS=%SPHINXOPTS% .
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. xml to make Docutils-native XML files
37 | echo. pseudoxml to make pseudoxml-XML files for display purposes
38 | echo. linkcheck to check all external links for integrity
39 | echo. doctest to run all doctests embedded in the documentation if enabled
40 | goto end
41 | )
42 |
43 | if "%1" == "clean" (
44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
45 | del /q /s %BUILDDIR%\*
46 | goto end
47 | )
48 |
49 |
50 | %SPHINXBUILD% 2> nul
51 | if errorlevel 9009 (
52 | echo.
53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
54 | echo.installed, then set the SPHINXBUILD environment variable to point
55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
56 | echo.may add the Sphinx directory to PATH.
57 | echo.
58 | echo.If you don't have Sphinx installed, grab it from
59 | echo.http://sphinx-doc.org/
60 | exit /b 1
61 | )
62 |
63 | if "%1" == "html" (
64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
65 | if errorlevel 1 exit /b 1
66 | echo.
67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
68 | goto end
69 | )
70 |
71 | if "%1" == "dirhtml" (
72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
73 | if errorlevel 1 exit /b 1
74 | echo.
75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
76 | goto end
77 | )
78 |
79 | if "%1" == "singlehtml" (
80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
81 | if errorlevel 1 exit /b 1
82 | echo.
83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
84 | goto end
85 | )
86 |
87 | if "%1" == "pickle" (
88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
89 | if errorlevel 1 exit /b 1
90 | echo.
91 | echo.Build finished; now you can process the pickle files.
92 | goto end
93 | )
94 |
95 | if "%1" == "json" (
96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
97 | if errorlevel 1 exit /b 1
98 | echo.
99 | echo.Build finished; now you can process the JSON files.
100 | goto end
101 | )
102 |
103 | if "%1" == "htmlhelp" (
104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
105 | if errorlevel 1 exit /b 1
106 | echo.
107 | echo.Build finished; now you can run HTML Help Workshop with the ^
108 | .hhp project file in %BUILDDIR%/htmlhelp.
109 | goto end
110 | )
111 |
112 | if "%1" == "qthelp" (
113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
114 | if errorlevel 1 exit /b 1
115 | echo.
116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
117 | .qhcp project file in %BUILDDIR%/qthelp, like this:
118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\wagtailsocialfeed.qhcp
119 | echo.To view the help file:
120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\wagtailsocialfeed.ghc
121 | goto end
122 | )
123 |
124 | if "%1" == "devhelp" (
125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished.
129 | goto end
130 | )
131 |
132 | if "%1" == "epub" (
133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
134 | if errorlevel 1 exit /b 1
135 | echo.
136 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
137 | goto end
138 | )
139 |
140 | if "%1" == "latex" (
141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
142 | if errorlevel 1 exit /b 1
143 | echo.
144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
145 | goto end
146 | )
147 |
148 | if "%1" == "latexpdf" (
149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
150 | cd %BUILDDIR%/latex
151 | make all-pdf
152 | cd %BUILDDIR%/..
153 | echo.
154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
155 | goto end
156 | )
157 |
158 | if "%1" == "latexpdfja" (
159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
160 | cd %BUILDDIR%/latex
161 | make all-pdf-ja
162 | cd %BUILDDIR%/..
163 | echo.
164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex.
165 | goto end
166 | )
167 |
168 | if "%1" == "text" (
169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
170 | if errorlevel 1 exit /b 1
171 | echo.
172 | echo.Build finished. The text files are in %BUILDDIR%/text.
173 | goto end
174 | )
175 |
176 | if "%1" == "man" (
177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
178 | if errorlevel 1 exit /b 1
179 | echo.
180 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
181 | goto end
182 | )
183 |
184 | if "%1" == "texinfo" (
185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
186 | if errorlevel 1 exit /b 1
187 | echo.
188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
189 | goto end
190 | )
191 |
192 | if "%1" == "gettext" (
193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
194 | if errorlevel 1 exit /b 1
195 | echo.
196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
197 | goto end
198 | )
199 |
200 | if "%1" == "changes" (
201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
202 | if errorlevel 1 exit /b 1
203 | echo.
204 | echo.The overview file is in %BUILDDIR%/changes.
205 | goto end
206 | )
207 |
208 | if "%1" == "linkcheck" (
209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
210 | if errorlevel 1 exit /b 1
211 | echo.
212 | echo.Link check complete; look for any errors in the above output ^
213 | or in %BUILDDIR%/linkcheck/output.txt.
214 | goto end
215 | )
216 |
217 | if "%1" == "doctest" (
218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
219 | if errorlevel 1 exit /b 1
220 | echo.
221 | echo.Testing of doctests in the sources finished, look at the ^
222 | results in %BUILDDIR%/doctest/output.txt.
223 | goto end
224 | )
225 |
226 | if "%1" == "xml" (
227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
228 | if errorlevel 1 exit /b 1
229 | echo.
230 | echo.Build finished. The XML files are in %BUILDDIR%/xml.
231 | goto end
232 | )
233 |
234 | if "%1" == "pseudoxml" (
235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
236 | if errorlevel 1 exit /b 1
237 | echo.
238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
239 | goto end
240 | )
241 |
242 | :end
243 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import json
4 |
5 | from django.contrib.auth import get_user_model
6 | from django.core.urlresolvers import reverse
7 | from django.test import TestCase
8 | from django.utils.encoding import force_text
9 |
10 | from bs4 import BeautifulSoup
11 | from wagtailsocialfeed.models import ModeratedItem
12 | from wagtailsocialfeed.utils.feed.factory import FeedFactory
13 |
14 | from . import feed_response
15 | from .factories import SocialFeedConfigurationFactory
16 |
17 |
18 | class ModerateTestMixin(object):
19 | def setUp(self):
20 | self.feedconfig = SocialFeedConfigurationFactory.create(
21 | source='twitter',
22 | username='wagtailcms',
23 | moderated=True)
24 |
25 | self.user = get_user_model().objects.create_user(
26 | 'john', 'john@doe.com', 'test')
27 | self.admin = get_user_model().objects.create_superuser(
28 | 'admin', 'admin@doe.com', 'test')
29 |
30 |
31 | class ModerateViewTest(ModerateTestMixin, TestCase):
32 | def setUp(self):
33 | super(ModerateViewTest, self).setUp()
34 | self.url = reverse('wagtailsocialfeed:moderate',
35 | kwargs={'pk': self.feedconfig.id})
36 |
37 | def test_permissions(self):
38 | resp = self.client.get(self.url)
39 | self.assertRedirects(resp, '/cms/login/?next={}'.format(self.url))
40 |
41 | self.client.login(username='john', password='test')
42 | resp = self.client.get(self.url)
43 | self.assertRedirects(resp, '/cms/login/?next={}'.format(self.url))
44 |
45 | def test_404(self):
46 | self.feedconfig.moderated = False
47 | self.feedconfig.save()
48 | self.client.login(username='admin', password='test')
49 |
50 | resp = self.client.get(self.url)
51 | self.assertEqual(resp.status_code, 404)
52 |
53 | @feed_response('twitter')
54 | def test_view(self, tweets):
55 | self.client.login(username='admin', password='test')
56 |
57 | resp = self.client.get(self.url)
58 | self.assertEqual(resp.status_code, 200)
59 | soup = BeautifulSoup(resp.content, 'html.parser')
60 |
61 | # The original post should be included to be used for
62 | # moderated allow/remove
63 | rows = soup.tbody.find_all('tr')
64 |
65 | # Substract the columns from the first row/tweet
66 | columns = rows[0].find_all('td')
67 | post_id = tweets[0]['id']
68 | self.assertEqual(columns[0].input['id'],
69 | 'post_original_{}'.format(post_id))
70 |
71 | url_allow = reverse('wagtailsocialfeed:allow',
72 | kwargs={'pk': self.feedconfig.id,
73 | 'post_id': post_id})
74 | url_remove = reverse('wagtailsocialfeed:remove',
75 | kwargs={'pk': self.feedconfig.id,
76 | 'post_id': post_id})
77 |
78 | allow_a_element = columns[0].find(attrs={'class': 'action-allow'})
79 | remove_a_element = columns[0].find(attrs={'class': 'action-remove'})
80 | self.assertEqual(allow_a_element['href'], url_allow)
81 | self.assertEqual(remove_a_element['href'], url_remove)
82 |
83 | # @feed_response('twitter')
84 | # def test_post_allow(self, tweets):
85 | # self.client.login(username='admin', password='test')
86 | # resp = self.client.get(self.url)
87 | # soup = BeautifulSoup(resp.content, 'html.parser')
88 | # rows = soup.tbody.find_all('tr')
89 | # columns = rows[0].find_all('td')
90 | # post_id = tweets[0]['id']
91 | #
92 | # url_allow = reverse('wagtailsocialfeed:allow',
93 | # kwargs={'pk': self.feedconfig.id,
94 | # 'post_id': post_id})
95 | # print columns[0].input['value']
96 |
97 |
98 | class ModerateAllowViewTest(ModerateTestMixin, TestCase):
99 | @feed_response('twitter')
100 | def setUp(self, tweets):
101 | super(ModerateAllowViewTest, self).setUp()
102 | self.feed = FeedFactory.create('twitter')
103 | self.items = self.feed.get_items(self.feedconfig)
104 | self.post = self.items[0]
105 | self.url = reverse('wagtailsocialfeed:allow',
106 | kwargs={'pk': self.feedconfig.id,
107 | 'post_id': self.post.id})
108 |
109 | def test_post_permissions(self):
110 | resp = self.client.post(self.url)
111 | self.assertRedirects(resp, '/cms/login/?next={}'.format(self.url))
112 |
113 | self.client.login(username='john', password='test')
114 | resp = self.client.post(self.url)
115 | self.assertRedirects(resp, '/cms/login/?next={}'.format(self.url))
116 |
117 | def test_http_methods(self):
118 | self.client.login(username='admin', password='test')
119 | resp = self.client.get(self.url)
120 | self.assertEqual(resp.status_code, 405)
121 |
122 | def test_post(self):
123 | self.client.login(username='admin', password='test')
124 |
125 | # Sanity check
126 | self.assertEqual(ModeratedItem.objects.count(), 0)
127 |
128 | # Test with missing data
129 | resp = self.client.post(self.url)
130 | self.assertEqual(resp.status_code, 400)
131 | json_resp = json.loads(force_text(resp.content))
132 | self.assertEqual(
133 | json_resp['message'],
134 | 'The original social feed post was not found in the POST data')
135 |
136 | # Now for the correct way
137 | data = {
138 | 'original': self.post.serialize()
139 | }
140 | resp = self.client.post(self.url, data=data)
141 | self.assertEqual(resp.status_code, 200)
142 | self.assertEqual(ModeratedItem.objects.count(), 1)
143 |
144 | # Idempotent?
145 | resp = self.client.post(self.url, data=data)
146 | self.assertEqual(resp.status_code, 200)
147 | self.assertEqual(ModeratedItem.objects.count(), 1)
148 |
149 |
150 | class ModerateRemoveViewTest(ModerateTestMixin, TestCase):
151 | @feed_response('twitter')
152 | def setUp(self, tweets):
153 | super(ModerateRemoveViewTest, self).setUp()
154 | self.feed = FeedFactory.create('twitter')
155 | self.items = self.feed.get_items(self.feedconfig)
156 | self.post = self.items[0]
157 | self.feedconfig.moderated_items.get_or_create_for(
158 | self.post.serialize())
159 | self.url = reverse('wagtailsocialfeed:remove',
160 | kwargs={'pk': self.feedconfig.id,
161 | 'post_id': self.post.id})
162 |
163 | def test_post_permissions(self):
164 | resp = self.client.post(self.url)
165 | self.assertRedirects(resp, '/cms/login/?next={}'.format(self.url))
166 |
167 | self.client.login(username='john', password='test')
168 | resp = self.client.post(self.url)
169 | self.assertRedirects(resp, '/cms/login/?next={}'.format(self.url))
170 |
171 | def test_http_methods(self):
172 | self.client.login(username='admin', password='test')
173 | resp = self.client.get(self.url)
174 | self.assertEqual(resp.status_code, 405)
175 |
176 | def test_post(self):
177 | self.client.login(username='admin', password='test')
178 |
179 | # Sanity check
180 | self.assertEqual(ModeratedItem.objects.count(), 1)
181 |
182 | # Test with missing data
183 | resp = self.client.post(self.url)
184 | self.assertEqual(resp.status_code, 200)
185 | self.assertEqual(ModeratedItem.objects.count(), 0)
186 |
187 | # Should not be found
188 | resp = self.client.post(self.url)
189 | self.assertEqual(resp.status_code, 404)
190 |
--------------------------------------------------------------------------------
/wagtailsocialfeed/utils/feed/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import unicode_literals
2 |
3 | import json
4 | import logging
5 | import datetime
6 | from dateutil.tz import tzutc
7 |
8 | from django.core.cache import cache
9 | from django.utils import six
10 |
11 | from wagtailsocialfeed.utils.conf import get_socialfeed_setting
12 |
13 | logger = logging.getLogger('wagtailsocialfeed')
14 |
15 |
16 | def date_handler(obj):
17 | if hasattr(obj, 'isoformat'):
18 | return obj.isoformat()
19 | else: # pragma: no cover
20 | raise TypeError
21 |
22 |
23 | class FeedError(Exception):
24 | pass
25 |
26 |
27 | class FeedItem(object):
28 | def __init__(self, id, type, text, posted, image_dict, *args, **kwargs):
29 | self.id = six.text_type(id) # Ensure it's a string
30 | self.type = type
31 | self.text = text
32 | self.posted = posted
33 | self.image_dict = image_dict
34 | self.original_data = kwargs.get('original_data', {})
35 |
36 | def __repr__(self):
37 | return "{} ({} posted {})".format(self.__class__.__name__, self.id, self.posted)
38 |
39 | @classmethod
40 | def get_post_date(cls, raw):
41 | raise NotImplementedError
42 |
43 | @property
44 | def image(self):
45 | """
46 | Convenience property to be used in templates.
47 |
48 | Can be used in templates as such::
49 |
50 | item.image.small.url
51 | """
52 | return self.image_dict
53 |
54 | def __getattribute__(self, name):
55 | """
56 | Look for attributes in both the `FeedItem` as well as the original data.
57 |
58 | Return an `AttributeError` when the attribute can't be found in either
59 | of the two sources
60 | """
61 | try:
62 | return object.__getattribute__(self, name)
63 | except AttributeError as e:
64 | original_data = object.__getattribute__(self, 'original_data')
65 | if name in original_data:
66 | return original_data[name]
67 | raise e
68 |
69 | def serialize(self):
70 | return json.dumps(vars(self), default=date_handler)
71 |
72 | @classmethod
73 | def from_moderated(cls, moderated):
74 | """Create an `FeedItem` object from a `ModeratedItem`"""
75 | source = json.loads(moderated.content)
76 |
77 | # We could convert source['posted'] to a proper DateTime
78 | # object but why bother when it is saved in
79 | # moderated.posted as well
80 | source['posted'] = moderated.posted
81 | source['type'] = moderated.type
82 | item = cls(**source)
83 | return item
84 |
85 |
86 | class AbstractFeedQuery(object):
87 | """
88 | Query class to facilitate the actual fetch on the feed source.
89 |
90 | Needs to have a source-specific implementation.
91 | Once implemented for, say, twitter, it can be used as follows:
92 |
93 | query = TwitterFeedQuery()
94 | tweets = query()
95 |
96 | or, in order to fetch some older tweets:
97 |
98 | older_tweets = query(max_id=)
99 | """
100 | def __init__(self, username, query_string):
101 | self.username = username
102 | self.query_string = query_string
103 |
104 | # Things needed for the paginator
105 | self.exhausted = False
106 | self.oldest_post = None
107 |
108 | def get_paginator(self):
109 | while not self.exhausted:
110 | kwargs, result = {}, []
111 | if self.oldest_post:
112 | kwargs = self._get_load_kwargs(self.oldest_post)
113 | try:
114 | result, self.oldest_post = self.__load(**kwargs)
115 | finally:
116 | if not result:
117 | self.exhausted = True
118 | yield result, self.oldest_post
119 |
120 | def _get_load_kwargs(self, oldest_post):
121 | """Get the kwargs needed to `self._load()` to get the correct results."""
122 | return {}
123 |
124 | def __load(self, **kwargs):
125 | """Private method to load the raw results and the oldest post in the
126 | result set.
127 |
128 | We return the oldest post as well because the raw data might be
129 | filtered based on the query_string, but we still need the oldest
130 | post to check the date to determine weather we should stop our
131 | search or continue
132 |
133 | It will call the protected `_load()` method, perform
134 | a search when needed and store the oldest post.
135 | """
136 | raw = self._load(**kwargs)
137 |
138 | if not raw:
139 | return raw, None
140 |
141 | oldest_post = raw[-1]
142 | if self.query_string:
143 | raw = list(filter(self._search, raw))
144 | return raw, oldest_post
145 |
146 | def _search(self, raw_item):
147 | raise NotImplementedError("_search() needs to be implemented by the subclas")
148 |
149 | def _load(self, **kwargs):
150 | raise NotImplementedError("_load() needs to be implemented by the subclass")
151 |
152 |
153 | class AbstractFeed(object):
154 | """
155 | All feed implementations should subclass this class.
156 |
157 | This base class provides caching functionality.
158 | The subclass needs to take care of actually fetching the feed from the
159 | online source and converting them to `FeedItem`s.
160 | """
161 |
162 | def get_items(self, config, limit=0, query_string=None, use_cache=True):
163 | """
164 | Return a list of `FeedItem`s and handle caching.
165 |
166 | :param config: `SocialFeedConfiguration` to use
167 | :param limit: limit the output. Use 0 or None for no limit (default=0)
168 | :param query_string: the search term to filter on (default=None)
169 | :param use_cache: utilize the cache store/retrieve the results
170 | (default=True)
171 | """
172 | cls_name = self.__class__.__name__
173 | cache_key = 'socialfeed:{}:data:{}'.format(cls_name, config.id)
174 | if query_string:
175 | cache_key += ":q-{}".format(query_string)
176 |
177 | data = cache.get(cache_key, []) if use_cache else None
178 | if data:
179 | logger.debug("Getting data from cache ({})".format(cache_key))
180 | else:
181 | logger.debug("Fetching data online")
182 |
183 | data_raw = self._fetch_online(config=config, query_string=query_string)
184 | data = list(map(self._convert_raw_item, data_raw))
185 |
186 | if use_cache:
187 | logger.debug("Storing data in cache ({})".format(cache_key))
188 | cache.set(cache_key, data,
189 | get_socialfeed_setting('CACHE_DURATION'))
190 |
191 | if limit:
192 | return data[:limit]
193 | return data
194 |
195 | def _more_history_allowed(self, oldest_date):
196 | """
197 | Determine if we should load more history.
198 |
199 | This is used when searching for specific posts
200 | using the `query_string` argument.
201 |
202 | :param oldest_date: date of the oldest post in the last set returned
203 | """
204 | now = datetime.datetime.now(tzutc())
205 | last_allowed = now - get_socialfeed_setting('SEARCH_MAX_HISTORY')
206 | return oldest_date > last_allowed
207 |
208 | def _fetch_online(self, config, query_string=None):
209 | """
210 | Fetch the data from the online source.
211 |
212 | By default it will query just one result-page from the online source.
213 | When a `query_string` is given, multiple pages can be retrieved in order
214 | to increase the changes of returning a useful result-set.
215 | The size of the history to be searched through is specified in `SEARCH_MAX_HISTORY`
216 | (see `_more_history_allowed` for the specific implementation).
217 |
218 | :param config: `SocialFeedConfiguration` to use
219 | :param query_string: the search term to filter on (default=None)
220 | """
221 | if not hasattr(self, 'query_cls'):
222 | raise NotImplementedError('query_cls needs to be defined')
223 |
224 | query = self.query_cls(config.username, query_string)
225 | paginator = query.get_paginator()
226 | raw, oldest_post = next(paginator)
227 | if query_string:
228 | # If we have a query_string, we should fetch the users timeline a
229 | # couple of times to dig a bit into the history.
230 | for _raw, _oldest_post in paginator:
231 | if not _raw:
232 | break
233 |
234 | oldest_post_date = self._convert_raw_item(oldest_post).posted
235 | if not self._more_history_allowed(oldest_post_date):
236 | break
237 |
238 | if _oldest_post['id'] == oldest_post['id']:
239 | logger.warning("Trying to fetch older items but received "
240 | "same result set. Breaking the loop.")
241 | break
242 | oldest_post = _oldest_post
243 | raw += _raw
244 | return raw
245 |
246 | def _convert_raw_item(self, raw):
247 | """Convert a raw data-dict into a FeedItem subclass."""
248 | item = self.item_cls.from_raw(raw)
249 | assert isinstance(item, FeedItem)
250 | return item
251 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | #
4 | # wagtailsocialfeed documentation build configuration file, created by
5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013.
6 | #
7 | # This file is execfile()d with the current directory set to its
8 | # containing dir.
9 | #
10 | # Note that not all possible configuration values are present in this
11 | # autogenerated file.
12 | #
13 | # All configuration values have a default; values that are commented out
14 | # serve to show the default.
15 | import sys
16 | import os
17 | import sphinx_rtd_theme
18 |
19 | # If extensions (or modules to document with autodoc) are in another
20 | # directory, add these directories to sys.path here. If the directory is
21 | # relative to the documentation root, use os.path.abspath to make it
22 | # absolute, like shown here.
23 | #sys.path.insert(0, os.path.abspath('.'))
24 |
25 | # Get the project root dir, which is the parent dir of this
26 | cwd = os.getcwd()
27 | project_root = os.path.dirname(cwd)
28 |
29 | # Insert the project root dir as the first element in the PYTHONPATH.
30 | # This lets us ensure that the source package is imported, and that its
31 | # version is used.
32 | sys.path.insert(0, project_root)
33 |
34 | import wagtailsocialfeed
35 |
36 | # -- General configuration ---------------------------------------------
37 |
38 | # If your documentation needs a minimal Sphinx version, state it here.
39 | #needs_sphinx = '1.0'
40 |
41 | # Add any Sphinx extension module names here, as strings. They can be
42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
43 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
44 |
45 | # Add any paths that contain templates here, relative to this directory.
46 | templates_path = ['_templates']
47 |
48 | # The suffix of source filenames.
49 | source_suffix = '.rst'
50 |
51 | # The encoding of source files.
52 | #source_encoding = 'utf-8-sig'
53 |
54 | # The master toctree document.
55 | master_doc = 'index'
56 |
57 | # General information about the project.
58 | project = u'Wagtail Social Feed'
59 | copyright = u"2016, Tim Leguijt"
60 |
61 | # The version info for the project you're documenting, acts as replacement
62 | # for |version| and |release|, also used in various other places throughout
63 | # the built documents.
64 | #
65 | # The short X.Y version.
66 | version = wagtailsocialfeed.__version__
67 | # The full version, including alpha/beta/rc tags.
68 | release = wagtailsocialfeed.__version__
69 |
70 | # The language for content autogenerated by Sphinx. Refer to documentation
71 | # for a list of supported languages.
72 | #language = None
73 |
74 | # There are two options for replacing |today|: either, you set today to
75 | # some non-false value, then it is used:
76 | #today = ''
77 | # Else, today_fmt is used as the format for a strftime call.
78 | #today_fmt = '%B %d, %Y'
79 |
80 | # List of patterns, relative to source directory, that match files and
81 | # directories to ignore when looking for source files.
82 | exclude_patterns = ['_build', ]
83 |
84 | # The reST default role (used for this markup: `text`) to use for all
85 | # documents.
86 | #default_role = None
87 |
88 | # If true, '()' will be appended to :func: etc. cross-reference text.
89 | #add_function_parentheses = True
90 |
91 | # If true, the current module name will be prepended to all description
92 | # unit titles (such as .. function::).
93 | #add_module_names = True
94 |
95 | # If true, sectionauthor and moduleauthor directives will be shown in the
96 | # output. They are ignored by default.
97 | #show_authors = False
98 |
99 | # The name of the Pygments (syntax highlighting) style to use.
100 | pygments_style = 'sphinx'
101 |
102 | # A list of ignored prefixes for module index sorting.
103 | #modindex_common_prefix = []
104 |
105 | # If true, keep warnings as "system message" paragraphs in the built
106 | # documents.
107 | #keep_warnings = False
108 |
109 |
110 | # -- Options for HTML output -------------------------------------------
111 |
112 | # The theme to use for HTML and HTML Help pages. See the documentation for
113 | # a list of builtin themes.
114 | #html_theme = 'default'
115 | html_theme = "sphinx_rtd_theme"
116 |
117 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
118 |
119 | # Theme options are theme-specific and customize the look and feel of a
120 | # theme further. For a list of options available for each theme, see the
121 | # documentation.
122 | #html_theme_options = {}
123 |
124 | # Add any paths that contain custom themes here, relative to this directory.
125 | #html_theme_path = []
126 |
127 | # The name for this set of Sphinx documents. If None, it defaults to
128 | # " v documentation".
129 | #html_title = None
130 |
131 | # A shorter title for the navigation bar. Default is the same as
132 | # html_title.
133 | #html_short_title = None
134 |
135 | # The name of an image file (relative to this directory) to place at the
136 | # top of the sidebar.
137 | #html_logo = None
138 |
139 | # The name of an image file (within the static path) to use as favicon
140 | # of the docs. This file should be a Windows icon file (.ico) being
141 | # 16x16 or 32x32 pixels large.
142 | #html_favicon = None
143 |
144 | # Add any paths that contain custom static files (such as style sheets)
145 | # here, relative to this directory. They are copied after the builtin
146 | # static files, so a file named "default.css" will overwrite the builtin
147 | # "default.css".
148 | html_static_path = ['_static']
149 |
150 | # If not '', a 'Last updated on:' timestamp is inserted at every page
151 | # bottom, using the given strftime format.
152 | #html_last_updated_fmt = '%b %d, %Y'
153 |
154 | # If true, SmartyPants will be used to convert quotes and dashes to
155 | # typographically correct entities.
156 | #html_use_smartypants = True
157 |
158 | # Custom sidebar templates, maps document names to template names.
159 | #html_sidebars = {}
160 |
161 | # Additional templates that should be rendered to pages, maps page names
162 | # to template names.
163 | #html_additional_pages = {}
164 |
165 | # If false, no module index is generated.
166 | #html_domain_indices = True
167 |
168 | # If false, no index is generated.
169 | #html_use_index = True
170 |
171 | # If true, the index is split into individual pages for each letter.
172 | #html_split_index = False
173 |
174 | # If true, links to the reST sources are added to the pages.
175 | #html_show_sourcelink = True
176 |
177 | # If true, "Created using Sphinx" is shown in the HTML footer.
178 | # Default is True.
179 | #html_show_sphinx = True
180 |
181 | # If true, "(C) Copyright ..." is shown in the HTML footer.
182 | # Default is True.
183 | #html_show_copyright = True
184 |
185 | # If true, an OpenSearch description file will be output, and all pages
186 | # will contain a tag referring to it. The value of this option
187 | # must be the base URL from which the finished HTML is served.
188 | #html_use_opensearch = ''
189 |
190 | # This is the file name suffix for HTML files (e.g. ".xhtml").
191 | #html_file_suffix = None
192 |
193 | # Output file base name for HTML help builder.
194 | htmlhelp_basename = 'wagtailsocialfeeddoc'
195 |
196 |
197 | # -- Options for LaTeX output ------------------------------------------
198 |
199 | latex_elements = {
200 | # The paper size ('letterpaper' or 'a4paper').
201 | #'papersize': 'letterpaper',
202 |
203 | # The font size ('10pt', '11pt' or '12pt').
204 | #'pointsize': '10pt',
205 |
206 | # Additional stuff for the LaTeX preamble.
207 | #'preamble': '',
208 | }
209 |
210 | # Grouping the document tree into LaTeX files. List of tuples
211 | # (source start file, target name, title, author, documentclass
212 | # [howto/manual]).
213 | latex_documents = [
214 | ('index', 'wagtailsocialfeed.tex',
215 | u'Wagtail Social Feed Documentation',
216 | u'Tim Leguijt', 'manual'),
217 | ]
218 |
219 | # The name of an image file (relative to this directory) to place at
220 | # the top of the title page.
221 | #latex_logo = None
222 |
223 | # For "manual" documents, if this is true, then toplevel headings
224 | # are parts, not chapters.
225 | #latex_use_parts = False
226 |
227 | # If true, show page references after internal links.
228 | #latex_show_pagerefs = False
229 |
230 | # If true, show URL addresses after external links.
231 | #latex_show_urls = False
232 |
233 | # Documents to append as an appendix to all manuals.
234 | #latex_appendices = []
235 |
236 | # If false, no module index is generated.
237 | #latex_domain_indices = True
238 |
239 |
240 | # -- Options for manual page output ------------------------------------
241 |
242 | # One entry per manual page. List of tuples
243 | # (source start file, name, description, authors, manual section).
244 | man_pages = [
245 | ('index', 'wagtailsocialfeed',
246 | u'Wagtail Form Blocks Documentation',
247 | [u'Tim Leguijt'], 1)
248 | ]
249 |
250 | # If true, show URL addresses after external links.
251 | #man_show_urls = False
252 |
253 |
254 | # -- Options for Texinfo output ----------------------------------------
255 |
256 | # Grouping the document tree into Texinfo files. List of tuples
257 | # (source start file, target name, title, author,
258 | # dir menu entry, description, category)
259 | texinfo_documents = [
260 | ('index', 'wagtailsocialfeed',
261 | u'Wagtail Social Feed Documentation',
262 | u'Tim Leguijt',
263 | 'wagtailsocialfeed',
264 | 'One line description of project.',
265 | 'Miscellaneous'),
266 | ]
267 |
268 | # Documents to append as an appendix to all manuals.
269 | #texinfo_appendices = []
270 |
271 | # If false, no module index is generated.
272 | #texinfo_domain_indices = True
273 |
274 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
275 | #texinfo_show_urls = 'footnote'
276 |
277 | # If true, do not generate a @detailmenu in the "Top" node's menu.
278 | #texinfo_no_detailmenu = False
279 |
--------------------------------------------------------------------------------
/tests/test_feed.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | import re
4 |
5 | import responses
6 | from dateutil.tz import tzutc
7 | from django.core.cache import cache
8 | from django.test import TestCase, override_settings
9 | from django.utils import timezone
10 |
11 | from wagtailsocialfeed.utils.feed import AbstractFeed, FeedError, FeedItem
12 | from wagtailsocialfeed.utils.feed.facebook import (FacebookFeed,
13 | FacebookFeedItem)
14 | from wagtailsocialfeed.utils.feed.factory import FeedFactory
15 | from wagtailsocialfeed.utils.feed.instagram import (InstagramFeed,
16 | InstagramFeedItem)
17 | from wagtailsocialfeed.utils.feed.twitter import TwitterFeed, TwitterFeedItem
18 |
19 | from . import feed_response
20 | from .factories import SocialFeedConfigurationFactory
21 |
22 |
23 | class AbstractFeedTest(TestCase):
24 | def setUp(self):
25 | self.feedconfig = SocialFeedConfigurationFactory.create(
26 | source='abstract',
27 | username='someuser')
28 | self.feed = AbstractFeed()
29 |
30 | def test_get_items(self):
31 | with self.assertRaises(NotImplementedError):
32 | self.feed.get_items(self.feedconfig)
33 |
34 |
35 | class FeedFactoryTest(TestCase):
36 | def test_create(self):
37 | self.assertIsInstance(FeedFactory.create('twitter'), TwitterFeed)
38 | self.assertIsInstance(FeedFactory.create('instagram'), InstagramFeed)
39 | self.assertIsInstance(FeedFactory.create('facebook'), FacebookFeed)
40 | with self.assertRaises(NotImplementedError):
41 | FeedFactory.create('ello')
42 |
43 |
44 | class TwitterFeedTest(TestCase):
45 | def setUp(self):
46 | cache.clear()
47 | self.feedconfig = SocialFeedConfigurationFactory.create(
48 | source='twitter',
49 | username='wagtailcms')
50 | self.stream = FeedFactory.create('twitter')
51 | self.cache_key = 'socialfeed:{}:data:{}'.format(
52 | 'TwitterFeed', self.feedconfig.id)
53 |
54 | @feed_response('twitter')
55 | def test_feed(self, feed):
56 | self.assertIsNone(cache.get(self.cache_key))
57 | stream = self.stream.get_items(config=self.feedconfig)
58 |
59 | self.assertIsNotNone(cache.get(self.cache_key))
60 | self.assertEqual(len(stream), 17)
61 | for item in stream:
62 | assert isinstance(item, FeedItem)
63 | self.assertEqual(
64 | stream[0].posted,
65 | datetime.datetime(2016, 9, 23, 8, 28, 16, tzinfo=timezone.utc))
66 | self.assertIsNone(stream[0].image_dict)
67 |
68 | self.assertIsNotNone(stream[-1].image_dict)
69 | base_url = 'https://pbs.twimg.com/media/CnpYVx0UkAEdCpU.jpg'
70 |
71 | self.assertEqual(stream[-1].image_dict['small']['url'],
72 | base_url + ":small")
73 | self.assertEqual(stream[-1].image_dict['small']['width'], 680)
74 | self.assertEqual(stream[-1].image_dict['small']['height'], 546)
75 |
76 | self.assertEqual(stream[-1].image_dict['thumb']['url'],
77 | base_url + ":thumb")
78 | self.assertEqual(stream[-1].image_dict['thumb']['width'], 150)
79 | self.assertEqual(stream[-1].image_dict['thumb']['height'], 150)
80 |
81 | self.assertEqual(stream[-1].image_dict['medium']['url'],
82 | base_url + ":medium")
83 | self.assertEqual(stream[-1].image_dict['medium']['width'], 1200)
84 | self.assertEqual(stream[-1].image_dict['medium']['height'], 963)
85 |
86 | self.assertEqual(stream[-1].image_dict['large']['url'],
87 | base_url + ":large")
88 | self.assertEqual(stream[-1].image_dict['large']['width'], 2048)
89 | self.assertEqual(stream[-1].image_dict['large']['height'], 1643)
90 |
91 | #The following data is not explicitly stored, but should still be accessible
92 | self.assertEqual(stream[0].in_reply_to_user_id, 1252591452)
93 |
94 | @responses.activate
95 | def test_search(self):
96 | with open('tests/fixtures/twitter.json', 'r') as feed_file:
97 | page1 = json.loads("".join(feed_file.readlines()))
98 | with open('tests/fixtures/twitter.2.json', 'r') as feed_file:
99 | page2 = json.loads("".join(feed_file.readlines()))
100 |
101 | responses.add(responses.GET,
102 | re.compile('(?!.*max_id=\d*)https?://api.twitter.com.*'),
103 | json=page1, status=200)
104 |
105 | responses.add(responses.GET,
106 | re.compile('(?=.*max_id=\d*)https?://api.twitter.com.*'),
107 | json=page2, status=200)
108 |
109 | q = "release"
110 | cache_key = "{}:q-{}".format(self.cache_key, q)
111 |
112 | self.assertIsNone(cache.get(cache_key))
113 |
114 | # Ensure we set the SEARCH_MAX_HISTORY big enough for both twitter
115 | # pages to be included
116 | now = datetime.datetime.now(tzutc())
117 | last_post_date = TwitterFeedItem.get_post_date(page2[-1])
118 | delta = (now - last_post_date) + datetime.timedelta(seconds=10)
119 | with override_settings(WAGTAIL_SOCIALFEED_SEARCH_MAX_HISTORY=delta):
120 | stream = self.stream.get_items(config=self.feedconfig,
121 | query_string=q)
122 |
123 | self.assertIsNotNone(cache.get(cache_key))
124 | self.assertEqual(len(stream), 2)
125 | for s in stream:
126 | self.assertIn('release', s.text)
127 |
128 | @feed_response('twitter')
129 | def test_without_cache(self, feed):
130 | self.assertIsNone(cache.get(self.cache_key))
131 | stream = self.stream.get_items(config=self.feedconfig,
132 | use_cache=False)
133 | self.assertIsNone(cache.get(self.cache_key))
134 | self.assertEqual(len(stream), 17)
135 |
136 |
137 | def _tamper_date(resp):
138 | """
139 | Alter instagram response so that it returns
140 | some unexpected data
141 | """
142 | resp['user']['media']['nodes'][0]['date'] = "not_a_timestamp"
143 | return resp
144 |
145 |
146 | def _remove_items(resp):
147 | return {"message": 'Instagram did some drastic changes!'}
148 |
149 |
150 | class InstagramFeedTest(TestCase):
151 | def setUp(self):
152 | cache.clear()
153 | self.feedconfig = SocialFeedConfigurationFactory.create(
154 | source='instagram')
155 | self.stream = FeedFactory.create('instagram')
156 | self.cache_key = 'socialfeed:{}:data:{}'.format(
157 | 'InstagramFeed', self.feedconfig.id)
158 |
159 | @feed_response('instagram')
160 | def test_feed(self, feed):
161 | self.assertIsNone(cache.get(self.cache_key))
162 | stream = self.stream.get_items(config=self.feedconfig)
163 |
164 | self.assertIsNotNone(cache.get(self.cache_key))
165 | self.assertEqual(len(stream), 12)
166 | for item in stream:
167 | assert isinstance(item, FeedItem)
168 | self.assertEqual(
169 | stream[0].posted,
170 | datetime.datetime(2017, 11, 15, 21, 55, 44, tzinfo=timezone.utc))
171 | self.assertEqual(stream[0].image_dict['small']['src'],
172 | "https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s320x320/e35/c86.0.908.908/23507082_173663316554801_3781761610851287040_n.jpg" # NOQA
173 | )
174 | self.assertEqual(stream[0].image_dict['thumb']['src'],
175 | "https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s240x240/e35/c86.0.908.908/23507082_173663316554801_3781761610851287040_n.jpg" # NOQA
176 | )
177 | self.assertEqual(stream[0].image_dict['medium']['src'],
178 | "https://scontent-amt2-1.cdninstagram.com/t51.2885-15/s480x480/e35/c86.0.908.908/23507082_173663316554801_3781761610851287040_n.jpg" # NOQA
179 | )
180 |
181 | # The following data is not explicitly stored, but should still be accessible
182 | self.assertEqual(stream[0].code, "Bbh7J7JlCRn")
183 |
184 | @feed_response('instagram', modifier=_tamper_date)
185 | def test_feed_unexpected_date_format(self, feed):
186 | stream = self.stream.get_items(config=self.feedconfig)
187 | self.assertIsNone(stream[0].posted)
188 |
189 | @feed_response('instagram', modifier=_remove_items)
190 | def test_feed_unexpected_response(self, feed):
191 | with self.assertRaises(FeedError):
192 | self.stream.get_items(config=self.feedconfig)
193 |
194 |
195 | class FacebookFeedTest(TestCase):
196 | def setUp(self):
197 | cache.clear()
198 | self.feedconfig = SocialFeedConfigurationFactory.create(
199 | source='facebook')
200 | self.stream = FeedFactory.create('facebook')
201 | self.cache_key = 'socialfeed:{}:data:{}'.format('FacebookFeed', self.feedconfig.id)
202 |
203 | @feed_response('facebook')
204 | def test_feed(self, feed):
205 | self.assertIsNone(cache.get(self.cache_key))
206 |
207 | stream = self.stream.get_items(config=self.feedconfig)
208 |
209 | self.assertIsNotNone(cache.get(self.cache_key))
210 | self.assertEqual(len(stream), 25)
211 |
212 | for item in stream:
213 | self.assertIsInstance(item, FeedItem)
214 | self.assertEqual(
215 | stream[0].posted,
216 | datetime.datetime(2016, 10, 4, 14, 48, 9, tzinfo=timezone.utc))
217 |
218 | self.assertEqual(stream[0].image_dict['thumb']['url'],
219 | "https://scontent.xx.fbcdn.net/v/t1.0-0/s130x130/14606290_1103282596374848_3084561525150401400_n.jpg?oh=4a993e12211341d2304724a5822b1fbf&oe=58628491" # NOQA
220 | )
221 |
222 | # The following data is not explicitly stored, but should still be accessible
223 | self.assertEqual(stream[0].icon, "https://www.facebook.com/images/icons/photo.gif")
224 |
225 | @responses.activate
226 | @override_settings(WAGTAIL_SOCIALFEED_SEARCH_MAX_HISTORY=datetime.timedelta(weeks=500))
227 | def test_search(self):
228 | with open('tests/fixtures/facebook.json', 'r') as feed_file:
229 | page1 = json.loads("".join(feed_file.readlines()))
230 | with open('tests/fixtures/facebook.2.json', 'r') as feed_file:
231 | page2 = json.loads("".join(feed_file.readlines()))
232 |
233 | responses.add(
234 | responses.GET,
235 | re.compile('(?!.*paging_token)https?://graph.facebook.com.*'),
236 | json=page1, status=200)
237 |
238 | responses.add(
239 | responses.GET,
240 | re.compile('(?=.*paging_token)https?://graph.facebook.com.*'),
241 | json=page2, status=200)
242 |
243 | q = "tutorials"
244 | cache_key = "{}:q-{}".format(self.cache_key, q)
245 |
246 | self.assertIsNone(cache.get(cache_key))
247 | # Ensure we set the SEARCH_MAX_HISTORY big enough for both facebook
248 | # pages to be included
249 | now = datetime.datetime.now(tzutc())
250 | last_post_date = FacebookFeedItem.get_post_date(page2['data'][-1])
251 | delta = (now - last_post_date) + datetime.timedelta(seconds=10)
252 | with override_settings(WAGTAIL_SOCIALFEED_SEARCH_MAX_HISTORY=delta):
253 | stream = self.stream.get_items(config=self.feedconfig,
254 | query_string=q)
255 |
256 | self.assertIsNotNone(cache.get(cache_key))
257 | self.assertEqual(len(stream), 2)
258 | for s in stream:
259 | self.assertIn('tutorials', s.text)
260 |
--------------------------------------------------------------------------------
/tests/fixtures/twitter.2.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "contributors": null,
4 | "truncated": false,
5 | "text": "@sect2k Hvala, Mitja!",
6 | "is_quote_status": false,
7 | "in_reply_to_status_id": 715087251299049472,
8 | "id": 715093704269238272,
9 | "favorite_count": 1,
10 | "source": "Twitter for Mac ",
11 | "retweeted": false,
12 | "coordinates": null,
13 | "entities": {
14 | "symbols": [],
15 | "user_mentions": [
16 | {
17 | "id": 218079492,
18 | "indices": [
19 | 0,
20 | 7
21 | ],
22 | "id_str": "218079492",
23 | "screen_name": "sect2k",
24 | "name": "Mitja Pagon"
25 | }
26 | ],
27 | "hashtags": [],
28 | "urls": []
29 | },
30 | "in_reply_to_screen_name": "sect2k",
31 | "in_reply_to_user_id": 218079492,
32 | "retweet_count": 0,
33 | "id_str": "715093704269238272",
34 | "favorited": false,
35 | "user": {
36 | "id": 2253779814,
37 | "id_str": "2253779814"
38 | },
39 | "geo": null,
40 | "in_reply_to_user_id_str": "218079492",
41 | "lang": "is",
42 | "created_at": "Wed Mar 30 08:29:59 +0000 2016",
43 | "in_reply_to_status_id_str": "715087251299049472",
44 | "place": null
45 | },
46 | {
47 | "contributors": null,
48 | "truncated": false,
49 | "text": "@tim_heap yes!",
50 | "is_quote_status": false,
51 | "in_reply_to_status_id": 713223376928571392,
52 | "id": 713324868733898753,
53 | "favorite_count": 0,
54 | "source": "Twitter for iPhone ",
55 | "retweeted": false,
56 | "coordinates": null,
57 | "entities": {
58 | "symbols": [],
59 | "user_mentions": [
60 | {
61 | "id": 16362527,
62 | "indices": [
63 | 0,
64 | 9
65 | ],
66 | "id_str": "16362527",
67 | "screen_name": "tim_heap",
68 | "name": "Tim Heap"
69 | }
70 | ],
71 | "hashtags": [],
72 | "urls": []
73 | },
74 | "in_reply_to_screen_name": "tim_heap",
75 | "in_reply_to_user_id": 16362527,
76 | "retweet_count": 0,
77 | "id_str": "713324868733898753",
78 | "favorited": false,
79 | "user": {
80 | "id": 2253779814,
81 | "id_str": "2253779814"
82 | },
83 | "geo": null,
84 | "in_reply_to_user_id_str": "16362527",
85 | "lang": "und",
86 | "created_at": "Fri Mar 25 11:21:16 +0000 2016",
87 | "in_reply_to_status_id_str": "713223376928571392",
88 | "place": null
89 | },
90 | {
91 | "contributors": null,
92 | "truncated": false,
93 | "text": "@keimlink it's 90% now, should be 100% by April.",
94 | "is_quote_status": false,
95 | "in_reply_to_status_id": 711971638644842497,
96 | "id": 711975920647720960,
97 | "favorite_count": 0,
98 | "source": "Twitter for iPhone ",
99 | "retweeted": false,
100 | "coordinates": null,
101 | "entities": {
102 | "symbols": [],
103 | "user_mentions": [
104 | {
105 | "id": 44300359,
106 | "indices": [
107 | 0,
108 | 9
109 | ],
110 | "id_str": "44300359",
111 | "screen_name": "keimlink",
112 | "name": "Markus Zapke"
113 | }
114 | ],
115 | "hashtags": [],
116 | "urls": []
117 | },
118 | "in_reply_to_screen_name": "keimlink",
119 | "in_reply_to_user_id": 44300359,
120 | "retweet_count": 0,
121 | "id_str": "711975920647720960",
122 | "favorited": false,
123 | "user": {
124 | "id": 2253779814,
125 | "id_str": "2253779814"
126 | },
127 | "geo": null,
128 | "in_reply_to_user_id_str": "44300359",
129 | "lang": "en",
130 | "created_at": "Mon Mar 21 18:01:02 +0000 2016",
131 | "in_reply_to_status_id_str": "711971638644842497",
132 | "place": null
133 | },
134 | {
135 | "contributors": null,
136 | "truncated": false,
137 | "text": "@keimlink there's (hopefully) a Wagtail sprint happening in Utrecht on June 16th-17th. Would you like to join us?",
138 | "is_quote_status": false,
139 | "in_reply_to_status_id": 711915883434741760,
140 | "id": 711952302266044416,
141 | "favorite_count": 2,
142 | "source": "Twitter for iPhone ",
143 | "retweeted": false,
144 | "coordinates": null,
145 | "entities": {
146 | "symbols": [],
147 | "user_mentions": [
148 | {
149 | "id": 44300359,
150 | "indices": [
151 | 0,
152 | 9
153 | ],
154 | "id_str": "44300359",
155 | "screen_name": "keimlink",
156 | "name": "Markus Zapke"
157 | }
158 | ],
159 | "hashtags": [],
160 | "urls": []
161 | },
162 | "in_reply_to_screen_name": "keimlink",
163 | "in_reply_to_user_id": 44300359,
164 | "retweet_count": 0,
165 | "id_str": "711952302266044416",
166 | "favorited": false,
167 | "user": {
168 | "id": 2253779814,
169 | "id_str": "2253779814"
170 | },
171 | "geo": null,
172 | "in_reply_to_user_id_str": "44300359",
173 | "lang": "en",
174 | "created_at": "Mon Mar 21 16:27:10 +0000 2016",
175 | "in_reply_to_status_id_str": "711915883434741760",
176 | "place": null
177 | },
178 | {
179 | "contributors": null,
180 | "truncated": false,
181 | "text": "@keimlink that sounds v interesting. Say hello to @gasmanic (Wagtail lead dev).",
182 | "is_quote_status": false,
183 | "in_reply_to_status_id": 710060207381028864,
184 | "id": 711914982598959104,
185 | "favorite_count": 0,
186 | "source": "Twitter for iPhone ",
187 | "retweeted": false,
188 | "coordinates": null,
189 | "entities": {
190 | "symbols": [],
191 | "user_mentions": [
192 | {
193 | "id": 44300359,
194 | "indices": [
195 | 0,
196 | 9
197 | ],
198 | "id_str": "44300359",
199 | "screen_name": "keimlink",
200 | "name": "Markus Zapke"
201 | },
202 | {
203 | "id": 7021712,
204 | "indices": [
205 | 50,
206 | 59
207 | ],
208 | "id_str": "7021712",
209 | "screen_name": "gasmanic",
210 | "name": "Matt Westcott"
211 | }
212 | ],
213 | "hashtags": [],
214 | "urls": []
215 | },
216 | "in_reply_to_screen_name": "keimlink",
217 | "in_reply_to_user_id": 44300359,
218 | "retweet_count": 0,
219 | "id_str": "711914982598959104",
220 | "favorited": false,
221 | "user": {
222 | "id": 2253779814,
223 | "id_str": "2253779814"
224 | },
225 | "geo": null,
226 | "in_reply_to_user_id_str": "44300359",
227 | "lang": "en",
228 | "created_at": "Mon Mar 21 13:58:53 +0000 2016",
229 | "in_reply_to_status_id_str": "710060207381028864",
230 | "place": null
231 | },
232 | {
233 | "contributors": null,
234 | "truncated": false,
235 | "text": "@janneke_janssen natuurlijk!",
236 | "is_quote_status": false,
237 | "in_reply_to_status_id": 709317086489632768,
238 | "id": 709317273253711872,
239 | "favorite_count": 1,
240 | "source": "Twitter for Mac ",
241 | "retweeted": false,
242 | "coordinates": null,
243 | "entities": {
244 | "symbols": [],
245 | "user_mentions": [
246 | {
247 | "id": 20221324,
248 | "indices": [
249 | 0,
250 | 16
251 | ],
252 | "id_str": "20221324",
253 | "screen_name": "janneke_janssen",
254 | "name": "Janneke Janssen"
255 | }
256 | ],
257 | "hashtags": [],
258 | "urls": []
259 | },
260 | "in_reply_to_screen_name": "janneke_janssen",
261 | "in_reply_to_user_id": 20221324,
262 | "retweet_count": 0,
263 | "id_str": "709317273253711872",
264 | "favorited": false,
265 | "user": {
266 | "id": 2253779814,
267 | "id_str": "2253779814"
268 | },
269 | "geo": null,
270 | "in_reply_to_user_id_str": "20221324",
271 | "lang": "nl",
272 | "created_at": "Mon Mar 14 09:56:31 +0000 2016",
273 | "in_reply_to_status_id_str": "709317086489632768",
274 | "place": null
275 | },
276 | {
277 | "contributors": null,
278 | "truncated": false,
279 | "text": "Beloved translators! We have some new strings that need updating for 1.4, mainly around revisions and collections: https://t.co/0UKhSVp0Hb",
280 | "is_quote_status": false,
281 | "in_reply_to_status_id": null,
282 | "id": 709315224671756288,
283 | "favorite_count": 3,
284 | "source": "Twitter for Mac ",
285 | "retweeted": false,
286 | "coordinates": null,
287 | "entities": {
288 | "symbols": [],
289 | "user_mentions": [],
290 | "hashtags": [],
291 | "urls": [
292 | {
293 | "url": "https://t.co/0UKhSVp0Hb",
294 | "indices": [
295 | 115,
296 | 138
297 | ],
298 | "expanded_url": "https://www.transifex.com/torchbox/wagtail/",
299 | "display_url": "transifex.com/torchbox/wagta\u2026"
300 | }
301 | ]
302 | },
303 | "in_reply_to_screen_name": null,
304 | "in_reply_to_user_id": null,
305 | "retweet_count": 5,
306 | "id_str": "709315224671756288",
307 | "favorited": false,
308 | "user": {
309 | "id": 2253779814,
310 | "id_str": "2253779814"
311 | },
312 | "geo": null,
313 | "in_reply_to_user_id_str": null,
314 | "possibly_sensitive": false,
315 | "lang": "en",
316 | "created_at": "Mon Mar 14 09:48:22 +0000 2016",
317 | "in_reply_to_status_id_str": null,
318 | "place": null
319 | },
320 | {
321 | "contributors": null,
322 | "truncated": false,
323 | "text": "@chrxr p.s. kudos to @mr__winter and @joshbarrnz for the new edit bird.",
324 | "is_quote_status": false,
325 | "in_reply_to_status_id": 707620786266382336,
326 | "id": 707621578218086401,
327 | "favorite_count": 0,
328 | "source": "Twitter for Mac ",
329 | "retweeted": false,
330 | "coordinates": null,
331 | "entities": {
332 | "symbols": [],
333 | "user_mentions": [
334 | {
335 | "id": 187567879,
336 | "indices": [
337 | 0,
338 | 6
339 | ],
340 | "id_str": "187567879",
341 | "screen_name": "chrxr",
342 | "name": "Chris Rogers"
343 | },
344 | {
345 | "id": 2327445441,
346 | "indices": [
347 | 21,
348 | 32
349 | ],
350 | "id_str": "2327445441",
351 | "screen_name": "mr__winter",
352 | "name": "Thomas Winter"
353 | },
354 | {
355 | "id": 263525564,
356 | "indices": [
357 | 37,
358 | 48
359 | ],
360 | "id_str": "263525564",
361 | "screen_name": "joshbarrnz",
362 | "name": "Josh Barr"
363 | }
364 | ],
365 | "hashtags": [],
366 | "urls": []
367 | },
368 | "in_reply_to_screen_name": "WagtailCMS",
369 | "in_reply_to_user_id": 2253779814,
370 | "retweet_count": 0,
371 | "id_str": "707621578218086401",
372 | "favorited": false,
373 | "user": {
374 | "id": 2253779814,
375 | "id_str": "2253779814"
376 | },
377 | "geo": null,
378 | "in_reply_to_user_id_str": "2253779814",
379 | "lang": "en",
380 | "created_at": "Wed Mar 09 17:38:25 +0000 2016",
381 | "in_reply_to_status_id_str": "707620786266382336",
382 | "place": null
383 | },
384 | {
385 | "contributors": null,
386 | "truncated": false,
387 | "text": "@chrxr quick work! Let us know if you spot any issues.",
388 | "is_quote_status": false,
389 | "in_reply_to_status_id": 707613971755495424,
390 | "id": 707620786266382336,
391 | "favorite_count": 0,
392 | "source": "Twitter for Mac ",
393 | "retweeted": false,
394 | "coordinates": null,
395 | "entities": {
396 | "symbols": [],
397 | "user_mentions": [
398 | {
399 | "id": 187567879,
400 | "indices": [
401 | 0,
402 | 6
403 | ],
404 | "id_str": "187567879",
405 | "screen_name": "chrxr",
406 | "name": "Chris Rogers"
407 | }
408 | ],
409 | "hashtags": [],
410 | "urls": []
411 | },
412 | "in_reply_to_screen_name": "chrxr",
413 | "in_reply_to_user_id": 187567879,
414 | "retweet_count": 0,
415 | "id_str": "707620786266382336",
416 | "favorited": false,
417 | "user": {
418 | "id": 2253779814,
419 | "id_str": "2253779814"
420 | },
421 | "geo": null,
422 | "in_reply_to_user_id_str": "187567879",
423 | "lang": "en",
424 | "created_at": "Wed Mar 09 17:35:17 +0000 2016",
425 | "in_reply_to_status_id_str": "707613971755495424",
426 | "place": null
427 | },
428 | {
429 | "contributors": null,
430 | "truncated": false,
431 | "text": "We just pushed the release candidate for 1.4 - see https://t.co/XNrJ26wpAC. Please help us test it: \u2018pip install wagtail==1.4rc1\u2019",
432 | "is_quote_status": false,
433 | "in_reply_to_status_id": null,
434 | "id": 707608440319807489,
435 | "favorite_count": 7,
436 | "source": "Twitter for Mac ",
437 | "retweeted": false,
438 | "coordinates": null,
439 | "entities": {
440 | "symbols": [],
441 | "user_mentions": [],
442 | "hashtags": [],
443 | "urls": [
444 | {
445 | "url": "https://t.co/XNrJ26wpAC",
446 | "indices": [
447 | 51,
448 | 74
449 | ],
450 | "expanded_url": "http://docs.wagtail.io/en/latest/releases/1.4.html",
451 | "display_url": "docs.wagtail.io/en/latest/rele\u2026"
452 | }
453 | ]
454 | },
455 | "in_reply_to_screen_name": null,
456 | "in_reply_to_user_id": null,
457 | "retweet_count": 8,
458 | "id_str": "707608440319807489",
459 | "favorited": false,
460 | "user": {
461 | "id": 2253779814,
462 | "id_str": "2253779814"
463 | },
464 | "geo": null,
465 | "in_reply_to_user_id_str": null,
466 | "possibly_sensitive": false,
467 | "lang": "en",
468 | "created_at": "Wed Mar 09 16:46:13 +0000 2016",
469 | "in_reply_to_status_id_str": null,
470 | "place": null
471 | },
472 | {
473 | "contributors": null,
474 | "truncated": false,
475 | "text": "@grahamdumpleton @openshift OK, thanks, that\u2019s something we can look at.",
476 | "is_quote_status": false,
477 | "in_reply_to_status_id": 707530549821243393,
478 | "id": 707535416518434817,
479 | "favorite_count": 0,
480 | "source": "Twitter for Mac ",
481 | "retweeted": false,
482 | "coordinates": null,
483 | "entities": {
484 | "symbols": [],
485 | "user_mentions": [
486 | {
487 | "id": 187863503,
488 | "indices": [
489 | 0,
490 | 16
491 | ],
492 | "id_str": "187863503",
493 | "screen_name": "GrahamDumpleton",
494 | "name": "Graham Dumpleton"
495 | },
496 | {
497 | "id": 17620820,
498 | "indices": [
499 | 17,
500 | 27
501 | ],
502 | "id_str": "17620820",
503 | "screen_name": "openshift",
504 | "name": "Red Hat OpenShift"
505 | }
506 | ],
507 | "hashtags": [],
508 | "urls": []
509 | },
510 | "in_reply_to_screen_name": "GrahamDumpleton",
511 | "in_reply_to_user_id": 187863503,
512 | "retweet_count": 0,
513 | "id_str": "707535416518434817",
514 | "favorited": false,
515 | "user": {
516 | "id": 2253779814,
517 | "id_str": "2253779814"
518 | },
519 | "geo": null,
520 | "in_reply_to_user_id_str": "187863503",
521 | "lang": "en",
522 | "created_at": "Wed Mar 09 11:56:03 +0000 2016",
523 | "in_reply_to_status_id_str": "707530549821243393",
524 | "place": null
525 | },
526 | {
527 | "contributors": null,
528 | "truncated": false,
529 | "text": "@grahamdumpleton Wow. We need to investigate this properly. What can we do to make Wagtail as easy as possible to install on @openshift?",
530 | "is_quote_status": false,
531 | "in_reply_to_status_id": 707523962842972160,
532 | "id": 707528428396929024,
533 | "favorite_count": 0,
534 | "source": "Twitter for Mac ",
535 | "retweeted": false,
536 | "coordinates": null,
537 | "entities": {
538 | "symbols": [],
539 | "user_mentions": [
540 | {
541 | "id": 187863503,
542 | "indices": [
543 | 0,
544 | 16
545 | ],
546 | "id_str": "187863503",
547 | "screen_name": "GrahamDumpleton",
548 | "name": "Graham Dumpleton"
549 | },
550 | {
551 | "id": 17620820,
552 | "indices": [
553 | 125,
554 | 135
555 | ],
556 | "id_str": "17620820",
557 | "screen_name": "openshift",
558 | "name": "Red Hat OpenShift"
559 | }
560 | ],
561 | "hashtags": [],
562 | "urls": []
563 | },
564 | "in_reply_to_screen_name": "GrahamDumpleton",
565 | "in_reply_to_user_id": 187863503,
566 | "retweet_count": 0,
567 | "id_str": "707528428396929024",
568 | "favorited": false,
569 | "user": {
570 | "id": 2253779814,
571 | "id_str": "2253779814"
572 | },
573 | "geo": null,
574 | "in_reply_to_user_id_str": "187863503",
575 | "lang": "en",
576 | "created_at": "Wed Mar 09 11:28:17 +0000 2016",
577 | "in_reply_to_status_id_str": "707523962842972160",
578 | "place": null
579 | },
580 | {
581 | "contributors": null,
582 | "truncated": false,
583 | "text": "@grahamdumpleton @openshift What\u2019s the command?",
584 | "is_quote_status": false,
585 | "in_reply_to_status_id": 707522157979553792,
586 | "id": 707523219088138240,
587 | "favorite_count": 0,
588 | "source": "Twitter for Mac ",
589 | "retweeted": false,
590 | "coordinates": null,
591 | "entities": {
592 | "symbols": [],
593 | "user_mentions": [
594 | {
595 | "id": 187863503,
596 | "indices": [
597 | 0,
598 | 16
599 | ],
600 | "id_str": "187863503",
601 | "screen_name": "GrahamDumpleton",
602 | "name": "Graham Dumpleton"
603 | },
604 | {
605 | "id": 17620820,
606 | "indices": [
607 | 17,
608 | 27
609 | ],
610 | "id_str": "17620820",
611 | "screen_name": "openshift",
612 | "name": "Red Hat OpenShift"
613 | }
614 | ],
615 | "hashtags": [],
616 | "urls": []
617 | },
618 | "in_reply_to_screen_name": "GrahamDumpleton",
619 | "in_reply_to_user_id": 187863503,
620 | "retweet_count": 0,
621 | "id_str": "707523219088138240",
622 | "favorited": false,
623 | "user": {
624 | "id": 2253779814,
625 | "id_str": "2253779814"
626 | },
627 | "geo": null,
628 | "in_reply_to_user_id_str": "187863503",
629 | "lang": "en",
630 | "created_at": "Wed Mar 09 11:07:35 +0000 2016",
631 | "in_reply_to_status_id_str": "707522157979553792",
632 | "place": null
633 | },
634 | {
635 | "contributors": null,
636 | "truncated": false,
637 | "text": "@_ChrisMay we're reading your tweet from the top of Lion's Head! (actually we're not. But we wish we were).",
638 | "is_quote_status": false,
639 | "in_reply_to_status_id": 707297386276724736,
640 | "id": 707339896353112065,
641 | "favorite_count": 0,
642 | "source": "Twitter for iPhone ",
643 | "retweeted": false,
644 | "coordinates": null,
645 | "entities": {
646 | "symbols": [],
647 | "user_mentions": [
648 | {
649 | "id": 15874287,
650 | "indices": [
651 | 0,
652 | 10
653 | ],
654 | "id_str": "15874287",
655 | "screen_name": "_ChrisMay",
656 | "name": "Chris May"
657 | }
658 | ],
659 | "hashtags": [],
660 | "urls": []
661 | },
662 | "in_reply_to_screen_name": "_ChrisMay",
663 | "in_reply_to_user_id": 15874287,
664 | "retweet_count": 0,
665 | "id_str": "707339896353112065",
666 | "favorited": false,
667 | "user": {
668 | "id": 2253779814,
669 | "id_str": "2253779814"
670 | },
671 | "geo": null,
672 | "in_reply_to_user_id_str": "15874287",
673 | "lang": "en",
674 | "created_at": "Tue Mar 08 22:59:07 +0000 2016",
675 | "in_reply_to_status_id_str": "707297386276724736",
676 | "place": null
677 | }
678 | ]
--------------------------------------------------------------------------------
/tests/fixtures/twitter.3.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "contributors": null,
4 | "truncated": false,
5 | "text": "En route to the Netherlands, where we're meeting friends from @Lukkien, @fourdigits, @springloadnz and others, for a focused 2 day sprint.",
6 | "is_quote_status": false,
7 | "in_reply_to_status_id": null,
8 | "id": 743097156727373824,
9 | "favorite_count": 17,
10 | "source": "Twitter for iPhone ",
11 | "retweeted": true,
12 | "coordinates": null,
13 | "entities": {
14 | "symbols": [],
15 | "user_mentions": [
16 | {
17 | "id": 77296759,
18 | "indices": [
19 | 62,
20 | 70
21 | ],
22 | "id_str": "77296759",
23 | "screen_name": "Lukkien",
24 | "name": "Lukkien"
25 | },
26 | {
27 | "id": 60896415,
28 | "indices": [
29 | 72,
30 | 83
31 | ],
32 | "id_str": "60896415",
33 | "screen_name": "fourdigits",
34 | "name": "Four Digits"
35 | },
36 | {
37 | "id": 68809088,
38 | "indices": [
39 | 85,
40 | 98
41 | ],
42 | "id_str": "68809088",
43 | "screen_name": "springloadnz",
44 | "name": "Springloadnz"
45 | }
46 | ],
47 | "hashtags": [],
48 | "urls": []
49 | },
50 | "in_reply_to_screen_name": null,
51 | "in_reply_to_user_id": null,
52 | "retweet_count": 6,
53 | "id_str": "743097156727373824",
54 | "favorited": true,
55 | "user": {
56 | "id": 2253779814,
57 | "id_str": "2253779814"
58 | },
59 | "geo": null,
60 | "in_reply_to_user_id_str": null,
61 | "lang": "en",
62 | "created_at": "Wed Jun 15 15:05:42 +0000 2016",
63 | "in_reply_to_status_id_str": null,
64 | "place": null
65 | },
66 | {
67 | "contributors": null,
68 | "truncated": false,
69 | "text": "\u2018Privagal\u2019 looks interesting - an open source web app for sharing private photos, built on Wagtail: https://t.co/SoTWOpaYy1",
70 | "is_quote_status": false,
71 | "in_reply_to_status_id": null,
72 | "id": 740181681353719808,
73 | "favorite_count": 5,
74 | "source": "Twitter for Mac ",
75 | "retweeted": false,
76 | "coordinates": null,
77 | "entities": {
78 | "symbols": [],
79 | "user_mentions": [],
80 | "hashtags": [],
81 | "urls": [
82 | {
83 | "url": "https://t.co/SoTWOpaYy1",
84 | "indices": [
85 | 100,
86 | 123
87 | ],
88 | "expanded_url": "https://github.com/ychab/privagal",
89 | "display_url": "github.com/ychab/privagal"
90 | }
91 | ]
92 | },
93 | "in_reply_to_screen_name": null,
94 | "in_reply_to_user_id": null,
95 | "retweet_count": 1,
96 | "id_str": "740181681353719808",
97 | "favorited": false,
98 | "user": {
99 | "id": 2253779814,
100 | "id_str": "2253779814"
101 | },
102 | "geo": null,
103 | "in_reply_to_user_id_str": null,
104 | "possibly_sensitive": false,
105 | "lang": "en",
106 | "created_at": "Tue Jun 07 14:00:39 +0000 2016",
107 | "in_reply_to_status_id_str": null,
108 | "place": null
109 | },
110 | {
111 | "contributors": null,
112 | "truncated": false,
113 | "text": "Marvel at @PeaceCorps's beautiful new site, built on Wagtail by @ForumOne: https://t.co/7cT8nMNdZm",
114 | "is_quote_status": false,
115 | "in_reply_to_status_id": null,
116 | "id": 739744503737782272,
117 | "favorite_count": 1,
118 | "source": "Twitter for Mac ",
119 | "retweeted": false,
120 | "coordinates": null,
121 | "entities": {
122 | "symbols": [],
123 | "user_mentions": [
124 | {
125 | "id": 9109712,
126 | "indices": [
127 | 10,
128 | 21
129 | ],
130 | "id_str": "9109712",
131 | "screen_name": "PeaceCorps",
132 | "name": "Peace Corps"
133 | },
134 | {
135 | "id": 15202683,
136 | "indices": [
137 | 64,
138 | 73
139 | ],
140 | "id_str": "15202683",
141 | "screen_name": "ForumOne",
142 | "name": "Forum One"
143 | }
144 | ],
145 | "hashtags": [],
146 | "urls": [
147 | {
148 | "url": "https://t.co/7cT8nMNdZm",
149 | "indices": [
150 | 75,
151 | 98
152 | ],
153 | "expanded_url": "https://www.peacecorps.gov",
154 | "display_url": "peacecorps.gov"
155 | }
156 | ]
157 | },
158 | "in_reply_to_screen_name": null,
159 | "in_reply_to_user_id": null,
160 | "retweet_count": 2,
161 | "id_str": "739744503737782272",
162 | "favorited": false,
163 | "user": {
164 | "id": 2253779814,
165 | "id_str": "2253779814"
166 | },
167 | "geo": null,
168 | "in_reply_to_user_id_str": null,
169 | "possibly_sensitive": false,
170 | "lang": "en",
171 | "created_at": "Mon Jun 06 09:03:28 +0000 2016",
172 | "in_reply_to_status_id_str": null,
173 | "place": null
174 | },
175 | {
176 | "contributors": null,
177 | "truncated": false,
178 | "text": "@emilymhorsman https://t.co/GQCLQEcJCv is clever! Will you come over here and teach us React?",
179 | "is_quote_status": false,
180 | "in_reply_to_status_id": null,
181 | "id": 738799718793420800,
182 | "favorite_count": 2,
183 | "source": "Twitter for Mac ",
184 | "retweeted": false,
185 | "coordinates": null,
186 | "entities": {
187 | "symbols": [],
188 | "user_mentions": [
189 | {
190 | "id": 2364600889,
191 | "indices": [
192 | 0,
193 | 14
194 | ],
195 | "id_str": "2364600889",
196 | "screen_name": "emilymhorsman",
197 | "name": "lilac emily"
198 | }
199 | ],
200 | "hashtags": [],
201 | "urls": [
202 | {
203 | "url": "https://t.co/GQCLQEcJCv",
204 | "indices": [
205 | 15,
206 | 38
207 | ],
208 | "expanded_url": "https://github.com/emilyhorsman/wagtail-react-deck",
209 | "display_url": "github.com/emilyhorsman/w\u2026"
210 | }
211 | ]
212 | },
213 | "in_reply_to_screen_name": "emilymhorsman",
214 | "in_reply_to_user_id": 2364600889,
215 | "retweet_count": 1,
216 | "id_str": "738799718793420800",
217 | "favorited": false,
218 | "user": {
219 | "id": 2253779814,
220 | "id_str": "2253779814"
221 | },
222 | "geo": null,
223 | "in_reply_to_user_id_str": "2364600889",
224 | "possibly_sensitive": false,
225 | "lang": "en",
226 | "created_at": "Fri Jun 03 18:29:13 +0000 2016",
227 | "in_reply_to_status_id_str": null,
228 | "place": null
229 | },
230 | {
231 | "contributors": null,
232 | "truncated": false,
233 | "text": "Congrats to @OpenStax at @RiceUniversity on their new Wagtail site for free open textbooks and new learning tech: https://t.co/K0vpgz2Y4v",
234 | "is_quote_status": false,
235 | "in_reply_to_status_id": null,
236 | "id": 738797044748451840,
237 | "favorite_count": 8,
238 | "source": "Twitter for Mac ",
239 | "retweeted": false,
240 | "coordinates": null,
241 | "entities": {
242 | "symbols": [],
243 | "user_mentions": [
244 | {
245 | "id": 435929732,
246 | "indices": [
247 | 12,
248 | 21
249 | ],
250 | "id_str": "435929732",
251 | "screen_name": "OpenStax",
252 | "name": "OpenStax"
253 | },
254 | {
255 | "id": 19720630,
256 | "indices": [
257 | 25,
258 | 40
259 | ],
260 | "id_str": "19720630",
261 | "screen_name": "RiceUniversity",
262 | "name": "Rice University"
263 | }
264 | ],
265 | "hashtags": [],
266 | "urls": [
267 | {
268 | "url": "https://t.co/K0vpgz2Y4v",
269 | "indices": [
270 | 114,
271 | 137
272 | ],
273 | "expanded_url": "https://openstax.org",
274 | "display_url": "openstax.org"
275 | }
276 | ]
277 | },
278 | "in_reply_to_screen_name": null,
279 | "in_reply_to_user_id": null,
280 | "retweet_count": 7,
281 | "id_str": "738797044748451840",
282 | "favorited": false,
283 | "user": {
284 | "id": 2253779814,
285 | "id_str": "2253779814"
286 | },
287 | "geo": null,
288 | "in_reply_to_user_id_str": null,
289 | "possibly_sensitive": false,
290 | "lang": "en",
291 | "created_at": "Fri Jun 03 18:18:36 +0000 2016",
292 | "in_reply_to_status_id_str": null,
293 | "place": null
294 | },
295 | {
296 | "contributors": null,
297 | "truncated": false,
298 | "text": "@overcastio \u00deakka \u00fe\u00e9r!",
299 | "is_quote_status": false,
300 | "in_reply_to_status_id": 735397496856399872,
301 | "id": 735410742065795076,
302 | "favorite_count": 1,
303 | "source": "Twitter for Mac ",
304 | "retweeted": false,
305 | "coordinates": null,
306 | "entities": {
307 | "symbols": [],
308 | "user_mentions": [
309 | {
310 | "id": 1246803390,
311 | "indices": [
312 | 0,
313 | 11
314 | ],
315 | "id_str": "1246803390",
316 | "screen_name": "OvercastIO",
317 | "name": "Overcast Software"
318 | }
319 | ],
320 | "hashtags": [],
321 | "urls": []
322 | },
323 | "in_reply_to_screen_name": "OvercastIO",
324 | "in_reply_to_user_id": 1246803390,
325 | "retweet_count": 1,
326 | "id_str": "735410742065795076",
327 | "favorited": false,
328 | "user": {
329 | "id": 2253779814,
330 | "id_str": "2253779814"
331 | },
332 | "geo": null,
333 | "in_reply_to_user_id_str": "1246803390",
334 | "lang": "is",
335 | "created_at": "Wed May 25 10:02:38 +0000 2016",
336 | "in_reply_to_status_id_str": "735397496856399872",
337 | "place": null
338 | },
339 | {
340 | "contributors": null,
341 | "truncated": false,
342 | "text": "@overcastio any chance you could help with https://t.co/pDfnAdycsM ?",
343 | "is_quote_status": false,
344 | "in_reply_to_status_id": null,
345 | "id": 735396691789123584,
346 | "favorite_count": 0,
347 | "source": "Twitter for Mac ",
348 | "retweeted": false,
349 | "coordinates": null,
350 | "entities": {
351 | "symbols": [],
352 | "user_mentions": [
353 | {
354 | "id": 1246803390,
355 | "indices": [
356 | 0,
357 | 11
358 | ],
359 | "id_str": "1246803390",
360 | "screen_name": "OvercastIO",
361 | "name": "Overcast Software"
362 | }
363 | ],
364 | "hashtags": [],
365 | "urls": [
366 | {
367 | "url": "https://t.co/pDfnAdycsM",
368 | "indices": [
369 | 43,
370 | 66
371 | ],
372 | "expanded_url": "https://www.transifex.com/torchbox/wagtail/language/is_IS/",
373 | "display_url": "transifex.com/torchbox/wagta\u2026"
374 | }
375 | ]
376 | },
377 | "in_reply_to_screen_name": "OvercastIO",
378 | "in_reply_to_user_id": 1246803390,
379 | "retweet_count": 0,
380 | "id_str": "735396691789123584",
381 | "favorited": false,
382 | "user": {
383 | "id": 2253779814,
384 | "id_str": "2253779814"
385 | },
386 | "geo": null,
387 | "in_reply_to_user_id_str": "1246803390",
388 | "possibly_sensitive": false,
389 | "lang": "en",
390 | "created_at": "Wed May 25 09:06:48 +0000 2016",
391 | "in_reply_to_status_id_str": null,
392 | "place": null
393 | },
394 | {
395 | "contributors": null,
396 | "truncated": false,
397 | "text": "@chrxr thanks Chris! Do give it a good workout.",
398 | "is_quote_status": false,
399 | "in_reply_to_status_id": 735000556817809408,
400 | "id": 735017334155411456,
401 | "favorite_count": 0,
402 | "source": "Twitter for iPhone ",
403 | "retweeted": false,
404 | "coordinates": null,
405 | "entities": {
406 | "symbols": [],
407 | "user_mentions": [
408 | {
409 | "id": 187567879,
410 | "indices": [
411 | 0,
412 | 6
413 | ],
414 | "id_str": "187567879",
415 | "screen_name": "chrxr",
416 | "name": "Chris Rogers"
417 | }
418 | ],
419 | "hashtags": [],
420 | "urls": []
421 | },
422 | "in_reply_to_screen_name": "chrxr",
423 | "in_reply_to_user_id": 187567879,
424 | "retweet_count": 0,
425 | "id_str": "735017334155411456",
426 | "favorited": false,
427 | "user": {
428 | "id": 2253779814,
429 | "id_str": "2253779814"
430 | },
431 | "geo": null,
432 | "in_reply_to_user_id_str": "187567879",
433 | "lang": "en",
434 | "created_at": "Tue May 24 07:59:23 +0000 2016",
435 | "in_reply_to_status_id_str": "735000556817809408",
436 | "place": null
437 | },
438 | {
439 | "contributors": null,
440 | "truncated": false,
441 | "text": "The release candidate for 1.5 is out. Pretty please pip install wagtail==1.5rc1 and help us test it. https://t.co/3BxyTr2GL6",
442 | "is_quote_status": false,
443 | "in_reply_to_status_id": null,
444 | "id": 734759069009989632,
445 | "favorite_count": 7,
446 | "source": "Twitter for Mac ",
447 | "retweeted": false,
448 | "coordinates": null,
449 | "entities": {
450 | "symbols": [],
451 | "user_mentions": [],
452 | "hashtags": [],
453 | "urls": [
454 | {
455 | "url": "https://t.co/3BxyTr2GL6",
456 | "indices": [
457 | 101,
458 | 124
459 | ],
460 | "expanded_url": "http://docs.wagtail.io/en/latest/releases/1.5.html",
461 | "display_url": "docs.wagtail.io/en/latest/rele\u2026"
462 | }
463 | ]
464 | },
465 | "in_reply_to_screen_name": null,
466 | "in_reply_to_user_id": null,
467 | "retweet_count": 8,
468 | "id_str": "734759069009989632",
469 | "favorited": false,
470 | "user": {
471 | "id": 2253779814,
472 | "id_str": "2253779814"
473 | },
474 | "geo": null,
475 | "in_reply_to_user_id_str": null,
476 | "possibly_sensitive": false,
477 | "lang": "en",
478 | "created_at": "Mon May 23 14:53:07 +0000 2016",
479 | "in_reply_to_status_id_str": null,
480 | "place": null
481 | },
482 | {
483 | "contributors": null,
484 | "truncated": false,
485 | "text": "@jacquesjamieson try a 4 hour prototype with each and then decide. Ping us if you get stuck!",
486 | "is_quote_status": false,
487 | "in_reply_to_status_id": 732484858656657413,
488 | "id": 732494038985674754,
489 | "favorite_count": 1,
490 | "source": "Twitter for Mac ",
491 | "retweeted": false,
492 | "coordinates": null,
493 | "entities": {
494 | "symbols": [],
495 | "user_mentions": [
496 | {
497 | "id": 3391878732,
498 | "indices": [
499 | 0,
500 | 16
501 | ],
502 | "id_str": "3391878732",
503 | "screen_name": "JacquesJamieson",
504 | "name": "Jacques Jamieson"
505 | }
506 | ],
507 | "hashtags": [],
508 | "urls": []
509 | },
510 | "in_reply_to_screen_name": "JacquesJamieson",
511 | "in_reply_to_user_id": 3391878732,
512 | "retweet_count": 0,
513 | "id_str": "732494038985674754",
514 | "favorited": false,
515 | "user": {
516 | "id": 2253779814,
517 | "id_str": "2253779814"
518 | },
519 | "geo": null,
520 | "in_reply_to_user_id_str": "3391878732",
521 | "lang": "en",
522 | "created_at": "Tue May 17 08:52:42 +0000 2016",
523 | "in_reply_to_status_id_str": "732484858656657413",
524 | "place": null
525 | },
526 | {
527 | "contributors": null,
528 | "truncated": false,
529 | "text": "YOUR SITES WILL BE ASSIMILATED. RESISTANCE IS FUTILE. https://t.co/FVMHlPgAHv",
530 | "is_quote_status": true,
531 | "in_reply_to_status_id": null,
532 | "id": 725707403849797633,
533 | "favorite_count": 19,
534 | "source": "Twitter for Mac ",
535 | "quoted_status_id": 725675807964839936,
536 | "retweeted": false,
537 | "coordinates": null,
538 | "quoted_status": {
539 | "contributors": null,
540 | "truncated": false,
541 | "text": "Well Wagtail appears to be the CMS I always wanted, thanks @torchbox!\n\nNew problem: resist the urge to migrate every site I\u2019m maintaining.",
542 | "is_quote_status": false,
543 | "in_reply_to_status_id": null,
544 | "id": 725675807964839936,
545 | "favorite_count": 15,
546 | "source": "Twitter for Mac ",
547 | "retweeted": false,
548 | "coordinates": null,
549 | "entities": {
550 | "symbols": [],
551 | "user_mentions": [
552 | {
553 | "id": 19401839,
554 | "indices": [
555 | 59,
556 | 68
557 | ],
558 | "id_str": "19401839",
559 | "screen_name": "torchbox",
560 | "name": "Torchbox"
561 | }
562 | ],
563 | "hashtags": [],
564 | "urls": []
565 | },
566 | "in_reply_to_screen_name": null,
567 | "in_reply_to_user_id": null,
568 | "retweet_count": 3,
569 | "id_str": "725675807964839936",
570 | "favorited": false,
571 | "user": {
572 | "id": 102982202,
573 | "id_str": "102982202"
574 | },
575 | "geo": null,
576 | "in_reply_to_user_id_str": null,
577 | "lang": "en",
578 | "created_at": "Thu Apr 28 13:19:29 +0000 2016",
579 | "in_reply_to_status_id_str": null,
580 | "place": null
581 | },
582 | "entities": {
583 | "symbols": [],
584 | "user_mentions": [],
585 | "hashtags": [],
586 | "urls": [
587 | {
588 | "url": "https://t.co/FVMHlPgAHv",
589 | "indices": [
590 | 54,
591 | 77
592 | ],
593 | "expanded_url": "https://twitter.com/aymericaugustin/status/725675807964839936",
594 | "display_url": "twitter.com/aymericaugusti\u2026"
595 | }
596 | ]
597 | },
598 | "in_reply_to_screen_name": null,
599 | "in_reply_to_user_id": null,
600 | "retweet_count": 12,
601 | "id_str": "725707403849797633",
602 | "favorited": false,
603 | "user": {
604 | "id": 2253779814,
605 | "id_str": "2253779814"
606 | },
607 | "geo": null,
608 | "in_reply_to_user_id_str": null,
609 | "possibly_sensitive": false,
610 | "lang": "en",
611 | "created_at": "Thu Apr 28 15:25:02 +0000 2016",
612 | "quoted_status_id_str": "725675807964839936",
613 | "in_reply_to_status_id_str": null,
614 | "place": null
615 | },
616 | {
617 | "contributors": null,
618 | "truncated": false,
619 | "text": "@aymericaugustin merci \u00e0 vous, Aymeric!",
620 | "is_quote_status": false,
621 | "in_reply_to_status_id": 725675807964839936,
622 | "id": 725706446181163013,
623 | "favorite_count": 1,
624 | "source": "Twitter for Mac ",
625 | "retweeted": false,
626 | "coordinates": null,
627 | "entities": {
628 | "symbols": [],
629 | "user_mentions": [
630 | {
631 | "id": 102982202,
632 | "indices": [
633 | 0,
634 | 16
635 | ],
636 | "id_str": "102982202",
637 | "screen_name": "aymericaugustin",
638 | "name": "Aymeric Augustin"
639 | }
640 | ],
641 | "hashtags": [],
642 | "urls": []
643 | },
644 | "in_reply_to_screen_name": "aymericaugustin",
645 | "in_reply_to_user_id": 102982202,
646 | "retweet_count": 0,
647 | "id_str": "725706446181163013",
648 | "favorited": false,
649 | "user": {
650 | "id": 2253779814,
651 | "id_str": "2253779814"
652 | },
653 | "geo": null,
654 | "in_reply_to_user_id_str": "102982202",
655 | "lang": "fr",
656 | "created_at": "Thu Apr 28 15:21:14 +0000 2016",
657 | "in_reply_to_status_id_str": "725675807964839936",
658 | "place": null
659 | },
660 | {
661 | "contributors": null,
662 | "truncated": false,
663 | "text": "Are you within striking distance of Wellington, NZ tonight? Cancel everything! https://t.co/Aha4XG2PuO",
664 | "is_quote_status": false,
665 | "in_reply_to_status_id": null,
666 | "id": 725047109054009344,
667 | "favorite_count": 2,
668 | "source": "Twitter for Mac ",
669 | "retweeted": false,
670 | "coordinates": null,
671 | "entities": {
672 | "symbols": [],
673 | "user_mentions": [],
674 | "hashtags": [],
675 | "urls": [
676 | {
677 | "url": "https://t.co/Aha4XG2PuO",
678 | "indices": [
679 | 79,
680 | 102
681 | ],
682 | "expanded_url": "http://www.meetup.com/Wellington-Wagtail-CMS-Meetup/events/230329388/",
683 | "display_url": "meetup.com/Wellington-Wag\u2026"
684 | }
685 | ]
686 | },
687 | "in_reply_to_screen_name": null,
688 | "in_reply_to_user_id": null,
689 | "retweet_count": 2,
690 | "id_str": "725047109054009344",
691 | "favorited": false,
692 | "user": {
693 | "id": 2253779814,
694 | "id_str": "2253779814"
695 | },
696 | "geo": null,
697 | "in_reply_to_user_id_str": null,
698 | "possibly_sensitive": false,
699 | "lang": "en",
700 | "created_at": "Tue Apr 26 19:41:16 +0000 2016",
701 | "in_reply_to_status_id_str": null,
702 | "place": null
703 | },
704 | {
705 | "contributors": null,
706 | "truncated": false,
707 | "text": "@JayGreasley my friends call me Waggers :)",
708 | "is_quote_status": false,
709 | "in_reply_to_status_id": 723784702293893120,
710 | "id": 723822241536946176,
711 | "favorite_count": 2,
712 | "source": "Twitter for iPhone ",
713 | "retweeted": false,
714 | "coordinates": null,
715 | "entities": {
716 | "symbols": [],
717 | "user_mentions": [
718 | {
719 | "id": 14233500,
720 | "indices": [
721 | 0,
722 | 12
723 | ],
724 | "id_str": "14233500",
725 | "screen_name": "JayGreasley",
726 | "name": "Jay Greasley"
727 | }
728 | ],
729 | "hashtags": [],
730 | "urls": []
731 | },
732 | "in_reply_to_screen_name": "JayGreasley",
733 | "in_reply_to_user_id": 14233500,
734 | "retweet_count": 0,
735 | "id_str": "723822241536946176",
736 | "favorited": false,
737 | "user": {
738 | "id": 2253779814,
739 | "id_str": "2253779814"
740 | },
741 | "geo": null,
742 | "in_reply_to_user_id_str": "14233500",
743 | "lang": "en",
744 | "created_at": "Sat Apr 23 10:34:05 +0000 2016",
745 | "in_reply_to_status_id_str": "723784702293893120",
746 | "place": null
747 | },
748 | {
749 | "contributors": null,
750 | "truncated": false,
751 | "text": "@jaygreasley snippets are probably more straightforward but both are possible. See also https://t.co/e81uWtsRcu (in master, coming in 1.5)",
752 | "is_quote_status": false,
753 | "in_reply_to_status_id": 723574210229088257,
754 | "id": 723588043102408704,
755 | "favorite_count": 1,
756 | "source": "Twitter for Mac ",
757 | "retweeted": false,
758 | "coordinates": null,
759 | "entities": {
760 | "symbols": [],
761 | "user_mentions": [
762 | {
763 | "id": 14233500,
764 | "indices": [
765 | 0,
766 | 12
767 | ],
768 | "id_str": "14233500",
769 | "screen_name": "JayGreasley",
770 | "name": "Jay Greasley"
771 | }
772 | ],
773 | "hashtags": [],
774 | "urls": [
775 | {
776 | "url": "https://t.co/e81uWtsRcu",
777 | "indices": [
778 | 88,
779 | 111
780 | ],
781 | "expanded_url": "http://docs.wagtail.io/en/latest/reference/contrib/modeladmin.html",
782 | "display_url": "docs.wagtail.io/en/latest/refe\u2026"
783 | }
784 | ]
785 | },
786 | "in_reply_to_screen_name": "JayGreasley",
787 | "in_reply_to_user_id": 14233500,
788 | "retweet_count": 0,
789 | "id_str": "723588043102408704",
790 | "favorited": false,
791 | "user": {
792 | "id": 2253779814,
793 | "id_str": "2253779814"
794 | },
795 | "geo": null,
796 | "in_reply_to_user_id_str": "14233500",
797 | "possibly_sensitive": false,
798 | "lang": "en",
799 | "created_at": "Fri Apr 22 19:03:27 +0000 2016",
800 | "in_reply_to_status_id_str": "723574210229088257",
801 | "place": null
802 | },
803 | {
804 | "contributors": null,
805 | "truncated": false,
806 | "text": "Today we\u2019re having a mini sprint with @andyjbabic to get https://t.co/96erNnGRhT into Wagtail 1.5",
807 | "is_quote_status": false,
808 | "in_reply_to_status_id": null,
809 | "id": 720257260388237312,
810 | "favorite_count": 10,
811 | "source": "Twitter for Mac ",
812 | "retweeted": false,
813 | "coordinates": null,
814 | "entities": {
815 | "symbols": [],
816 | "user_mentions": [
817 | {
818 | "id": 34655915,
819 | "indices": [
820 | 38,
821 | 49
822 | ],
823 | "id_str": "34655915",
824 | "screen_name": "andyjbabic",
825 | "name": "Andy Babic"
826 | }
827 | ],
828 | "hashtags": [],
829 | "urls": [
830 | {
831 | "url": "https://t.co/96erNnGRhT",
832 | "indices": [
833 | 57,
834 | 80
835 | ],
836 | "expanded_url": "https://github.com/rkhleics/wagtailmodeladmin",
837 | "display_url": "github.com/rkhleics/wagta\u2026"
838 | }
839 | ]
840 | },
841 | "in_reply_to_screen_name": null,
842 | "in_reply_to_user_id": null,
843 | "retweet_count": 4,
844 | "id_str": "720257260388237312",
845 | "favorited": false,
846 | "user": {
847 | "id": 2253779814,
848 | "id_str": "2253779814"
849 | },
850 | "geo": null,
851 | "in_reply_to_user_id_str": null,
852 | "possibly_sensitive": false,
853 | "lang": "en",
854 | "created_at": "Wed Apr 13 14:28:07 +0000 2016",
855 | "in_reply_to_status_id_str": null,
856 | "place": null
857 | }
858 | ]
--------------------------------------------------------------------------------