├── 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 | 28 | 29 | 30 | 31 | 32 | 33 | {% for post in feed %} 34 | 35 | 47 | 50 | 53 | 58 | 59 | 60 | {% endfor %} 61 | 62 |
DatePostImage
36 |
37 | 38 |
39 | Remove 41 | Allow 43 |
44 | 45 |
46 |
48 |

{{ post.date }}

49 |
51 | {{ post.text }} 52 | 54 | {% if post.image.thumb %} 55 | 56 | {% endif %} 57 |
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 |
21 |
22 |
23 |
24 |

{{ title }}{% if subtitle %} {{ subtitle }}{% endif %}

25 |
26 | {% if search_url %} 27 |
28 |
    29 | {% for field in search_form %} 30 | {% include "wagtailadmin/shared/field_as_li.html" with field=field field_classes="field-small iconfield" input_classes="icon-search" %} 31 | {% endfor %} 32 |
  • 33 |
34 |
35 | {% endif %} 36 |
37 |
38 | {% usage_count_enabled as uc_enabled %} 39 | {% if uc_enabled and usage_object %} 40 | 43 | {% endif %} 44 | {% if add_link %} 45 |
46 | {{ add_text }} 47 |
48 | {% endif %} 49 |
50 |
51 |
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 | ] --------------------------------------------------------------------------------