├── .coveragerc ├── .editorconfig ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── ci_requirements.txt ├── knowledge_share ├── __init__.py ├── apps.py ├── conf.py ├── exceptions.py ├── models.py ├── rss_feeds.py ├── templatetags │ ├── __init__.py │ └── microblog.py ├── twitter_helpers.py ├── urls.py └── views.py ├── manage.py ├── runtests.py ├── setup.cfg ├── setup.py ├── test_requirements.txt ├── tests ├── __init__.py ├── fake_project_urls.py ├── models.py ├── settings.py ├── test_models.py ├── test_rss_feeds.py ├── test_twitter_helpers.py ├── test_views.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | 4 | [report] 5 | omit = 6 | *site-packages* 7 | *tests* 8 | *.tox* 9 | show_missing = True 10 | exclude_lines = 11 | raise NotImplementedError 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Distribution / packaging 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | env/ 22 | venv/ 23 | 24 | # Installer logs 25 | pip-log.txt 26 | 27 | # Unit test / coverage reports 28 | .coverage 29 | .tox 30 | nosetests.xml 31 | htmlcov 32 | 33 | # Translations 34 | *.mo 35 | 36 | # Mr Developer 37 | .mr.developer.cfg 38 | .project 39 | .pydevproject 40 | 41 | # Pycharm/Intellij 42 | .idea 43 | 44 | # Complexity 45 | output/*.html 46 | output/*/index.html 47 | 48 | # Sphinx 49 | docs/_build 50 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git://github.com/pre-commit/pre-commit-hooks 2 | sha: v0.9.2 3 | hooks: 4 | - id: check-added-large-files 5 | args: ['--maxkb=500'] 6 | - id: check-byte-order-marker 7 | - id: check-case-conflict 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: debug-statements 11 | - id: detect-private-key 12 | 13 | - repo: local 14 | hooks: 15 | - id: isort 16 | name: isort-local 17 | entry: isort -rc . 18 | language: system 19 | always_run: true 20 | files: \.py$ 21 | - id: flake8 22 | name: flake8-local 23 | entry: python -m flake8 24 | language: system 25 | files: \.py$ 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | 5 | python: 6 | - "2.7" 7 | - "3.4" 8 | - "3.5" 9 | - "3.6" 10 | 11 | matrix: 12 | fast_finish: true 13 | 14 | install: 15 | - pip install "Django>=1.11.19,<2.2" # from test_requirements.txt 16 | - pip install -r ci_requirements.txt 17 | - pip install tox-travis 18 | 19 | script: 20 | - coverage erase 21 | - tox 22 | 23 | after_success: 24 | - coverage combine --append 25 | - coverage report -m 26 | - pip install codecov 27 | - codecov 28 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Vinta Software 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 2 | Changelog 3 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 4 | 5 | 0.2.1 (2019-04-10) 6 | ------------------ 7 | * Drop support to Python 3.3 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2017, Vinta Software 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include LICENSE 3 | include README.rst 4 | recursive-include knowledge_share *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build 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 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 14 | 15 | help: 16 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 17 | 18 | clean: clean-build clean-pyc 19 | 20 | clean-build: ## remove build artifacts 21 | rm -fr build/ 22 | rm -fr dist/ 23 | rm -fr *.egg-info 24 | 25 | clean-pyc: ## remove Python file artifacts 26 | find . -name '*.pyc' -exec rm -f {} + 27 | find . -name '*.pyo' -exec rm -f {} + 28 | find . -name '*~' -exec rm -f {} + 29 | 30 | lint: ## check style with flake8 31 | flake8 knowledge_share tests 32 | 33 | test: ## run tests quickly with the default Python 34 | python runtests.py tests 35 | 36 | test-all: ## run tests on every Python version with tox 37 | tox 38 | 39 | coverage: ## check code coverage quickly with the default Python 40 | coverage run --source knowledge_share runtests.py tests 41 | coverage report -m 42 | coverage html 43 | $(BROWSER) htmlcov/index.html 44 | 45 | release: clean ## package and upload a release 46 | python setup.py sdist upload 47 | python setup.py bdist_wheel upload 48 | 49 | sdist: clean ## package 50 | python setup.py sdist 51 | ls -l dist 52 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Django Knowledge Share 3 | ============================= 4 | 5 | .. image:: https://badge.fury.io/py/django-knowledge-share.svg 6 | :target: https://badge.fury.io/py/django-knowledge-share 7 | 8 | .. image:: https://travis-ci.org/vintasoftware/django-knowledge-share.svg?branch=master 9 | :target: https://travis-ci.org/vintasoftware/django-knowledge-share 10 | 11 | .. image:: https://codecov.io/gh/vintasoftware/django-knowledge-share/branch/master/graph/badge.svg 12 | :target: https://codecov.io/gh/vintasoftware/django-knowledge-share 13 | 14 | Microblog app used to share quick knowledge. This code powers Vinta's lessons learned 15 | running at http://www.vinta.com.br/lessons-learned/. 16 | 17 | The posts are created via slack using a custom command and are automatically posted on twitter. 18 | 19 | Quickstart 20 | ---------- 21 | 22 | Install Django Knowledge Share:: 23 | 24 | pip install django-knowledge-share 25 | 26 | Create an app for your microblog:: 27 | 28 | python manage.py startapp microblog 29 | 30 | Add it to your `INSTALLED_APPS`: 31 | 32 | .. code-block:: python 33 | 34 | INSTALLED_APPS = ( 35 | ... 36 | "microblog", 37 | "knowledge_share", 38 | ... 39 | ) 40 | 41 | In your urls.py add the urls entry:: 42 | 43 | url(r'^', include('knowledge_share.urls', namespace='microblog')), 44 | 45 | In your microblog/models.py create your models by inheriting from the abstract models: 46 | 47 | .. code-block:: python 48 | 49 | # customize those models as needed 50 | from knowledge_share import models as knowledge_share_abstract_models 51 | 52 | 53 | class MicroBlogPost(knowledge_share_abstract_models.MicroBlogPostBase): 54 | pass 55 | 56 | 57 | class MicroBlogCategory(knowledge_share_abstract_models.MicroBlogCategoryBase): 58 | pass 59 | 60 | Then create and run your migrations:: 61 | 62 | python manage.py makemigrations 63 | python manage.py migrate 64 | 65 | 66 | Documentation 67 | ------------- 68 | 69 | Models 70 | ~~~~~~ 71 | 72 | You can see the available models and it's fields `here 73 | `_. They are all abstract and you need to create an instance of it (see Quickstart section). 74 | 75 | Slack Integration 76 | ~~~~~~~~~~~~~~~~~ 77 | 78 | Create a custom command in this page: `https://my.slack.com/services/new/slash-commands `_. 79 | 80 | Set the url to your slack endpoint, by default https://yoursite.com/microblog/integrations/slack-slash/ 81 | Copy the generated token and add to your settings.py as "SLACK_TOKEN='your-token'". 82 | To send a new post use ``/yourcommand This is a blog post content [Category, Another Category]`` 83 | 84 | Twitter Integration 85 | ~~~~~~~~~~~~~~~~~~~ 86 | 87 | You will need to set the following settings using your twitter data:: 88 | 89 | TWITTER_API_KEY 90 | TWITTER_API_SECRET 91 | TWITTER_ACCESS_TOKEN 92 | TWITTER_ACCESS_TOKEN_SECRET 93 | 94 | Whenever new posts are created it will be posted to twitter. 95 | 96 | Template tags 97 | ~~~~~~~~~~~~~ 98 | 99 | Whenever you are showing the content of the post you should use:: 100 | 101 | {% load microblog %} 102 | 103 | {{ post.content|convert_to_html }} 104 | 105 | If you want to create a link with the content to be shared you can use:: 106 | 107 | {% load microblog %} 108 | 109 | 110 | Share on twitter 111 | 112 | 113 | RSS Feed 114 | ~~~~~~~~ 115 | 116 | There is a RSS feed served by default at /microblog/feed/. 117 | 118 | Configuration 119 | ~~~~~~~~~~~~~ 120 | 121 | The following configurations are available: 122 | 123 | .. code-block:: python 124 | 125 | # settings.py 126 | 127 | # name of the app created with your microblog's models 128 | KNOWLEDGE_APP_NAME = 'microblog' 129 | # the title of the rss feed (available at: /microblog/feed/) 130 | KNOWLEDGE_FEED_TITLE = 'microblog' 131 | # the link of the feed 132 | KNOWLEDGE_FEED_LINK = '/microblog/' 133 | # Either to use twitter or not 134 | KNOWLEDGE_USE_TWITTER = True 135 | 136 | 137 | Running Tests 138 | ------------- 139 | 140 | :: 141 | 142 | source /bin/activate 143 | (myenv) $ pip install tox 144 | (myenv) $ tox 145 | 146 | Credits 147 | ------- 148 | 149 | Tools used in rendering this package: 150 | 151 | * Cookiecutter_ 152 | * `cookiecutter-djangopackage`_ 153 | 154 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 155 | .. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage 156 | -------------------------------------------------------------------------------- /ci_requirements.txt: -------------------------------------------------------------------------------- 1 | # from old requirements_test.txt 2 | coverage==4.3.3 3 | mock>=1.0.1 4 | flake8>=2.1.0 5 | tox>=1.7.0 6 | codecov>=2.0.0 7 | responses==0.5.1 8 | model-mommy==1.3.1 9 | git+git://github.com/vintasoftware/django_markdown.git@02c6fa050090b086b527de127b87f702de055dac#egg=django-markdown 10 | -------------------------------------------------------------------------------- /knowledge_share/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.2.1' 2 | -------------------------------------------------------------------------------- /knowledge_share/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from django.apps import AppConfig 3 | 4 | 5 | class KnowledgeShareConfig(AppConfig): 6 | name = 'knowledge_share' 7 | -------------------------------------------------------------------------------- /knowledge_share/conf.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import lazy 2 | 3 | obj = object() 4 | 5 | 6 | def _call_getattr(opt, value): 7 | from django.conf import settings 8 | if value is obj: 9 | return getattr(settings, opt) 10 | return getattr(settings, opt, value) 11 | 12 | 13 | def _lazy_get_settings(opt, ttype, default_value=obj): 14 | return lazy(_call_getattr, ttype)(opt, default_value) 15 | 16 | # Lazyly read configurations to allow @override_settings 17 | 18 | 19 | KNOWLEDGE_APP_NAME = _lazy_get_settings('KNOWLEDGE_APP_NAME', str, 'microblog') 20 | KNOWLEDGE_HOST_NAME = _lazy_get_settings('KNOWLEDGE_HOST_NAME', str) 21 | KNOWLEDGE_FEED_TITLE = _lazy_get_settings('KNOWLEDGE_FEED_TITLE', str, 'microblog') 22 | KNOWLEDGE_FEED_LINK = _lazy_get_settings('KNOWLEDGE_FEED_LINK', str, '/microblog/') 23 | KNOWLEDGE_USE_TWITTER = _lazy_get_settings('KNOWLEDGE_USE_TWITTER', bool, True) 24 | -------------------------------------------------------------------------------- /knowledge_share/exceptions.py: -------------------------------------------------------------------------------- 1 | class BadRequest(Exception): 2 | def __init__(self, message): 3 | self.message = message 4 | 5 | def __str__(self): 6 | return repr(self.message) 7 | -------------------------------------------------------------------------------- /knowledge_share/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | 4 | import misaka 5 | import pytz 6 | try: # django <= 1.6 7 | from django.core.urlresolvers import reverse 8 | except ImportError: # from django 1.7 to django 2.0 (and more) 9 | from django.urls import reverse 10 | from django.db import models 11 | from django.utils.text import slugify 12 | from django.utils.translation import ugettext_lazy as _ 13 | from django_markdown.models import MarkdownField 14 | 15 | from knowledge_share.conf import KNOWLEDGE_APP_NAME 16 | 17 | URL_REGEX_AND_SPACES = '\s+http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+\s' 18 | 19 | 20 | class MicroBlogCategoryBase(models.Model): 21 | name = models.CharField(max_length=200) 22 | 23 | class Meta: 24 | verbose_name_plural = _("categories") 25 | abstract = True 26 | 27 | @property 28 | def hashtag(self): 29 | joined_name = ''.join(self.name.title().split()) 30 | return '#{}'.format(joined_name) 31 | 32 | def __str__(self): 33 | return self.name 34 | 35 | 36 | class MicroBlogPostBase(models.Model): 37 | title = models.CharField( 38 | blank=True, 39 | max_length=100, 40 | verbose_name=_('Title') 41 | ) 42 | slug = models.SlugField( 43 | _('slug'), 44 | max_length=255, 45 | blank=True, 46 | db_index=True, 47 | unique=True 48 | ) 49 | content = MarkdownField() 50 | pub_date = models.DateTimeField(verbose_name=_('date published')) 51 | # This should be 'categories' but keeping it for backward compatibility 52 | category = models.ManyToManyField(KNOWLEDGE_APP_NAME + '.MicroBlogCategory') 53 | posted_on_twitter = models.BooleanField(default=False) 54 | 55 | class Meta: 56 | abstract = True 57 | verbose_name_plural = _("posts") 58 | 59 | def _remove_url_from_content(self): 60 | return re.sub(URL_REGEX_AND_SPACES, ' ', self.content) 61 | 62 | def _content_to_slug(self): 63 | content = self._remove_url_from_content() 64 | new_slug = re.sub('<[^<]+?>', '', misaka.html(content)) 65 | new_slug = new_slug.split() 66 | new_slug = '-'.join(new_slug[:6]) 67 | return new_slug 68 | 69 | def get_absolute_url(self): 70 | return reverse(KNOWLEDGE_APP_NAME + ':microblog-post', kwargs={'slug': self.slug}) 71 | 72 | def save(self, *args, **kwargs): 73 | if 'timezone' in kwargs: 74 | tz = pytz.timezone(kwargs['timezone']) 75 | pub_date = tz.localize(pub_date) 76 | else: 77 | pub_date = pytz.utc.localize(datetime.datetime.now()) 78 | 79 | if not self.id: 80 | self.slug = slugify(self._content_to_slug()) 81 | self.pub_date = pub_date 82 | super(MicroBlogPostBase, self).save(*args, **kwargs) 83 | -------------------------------------------------------------------------------- /knowledge_share/rss_feeds.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.syndication.views import Feed 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | from knowledge_share.conf import KNOWLEDGE_APP_NAME 6 | 7 | MicroBlogPost = apps.get_model(KNOWLEDGE_APP_NAME, 'MicroBlogPost') 8 | 9 | 10 | class MicroblogRssFeed(Feed): 11 | title = "Lessons Learned" 12 | link = "/lessons-learned/" 13 | description = _("Updates on changes and additions to Lessons Learned.") 14 | 15 | def items(self): 16 | return MicroBlogPost.objects.order_by('-pub_date') 17 | 18 | def item_title(self, item): 19 | return item.title 20 | 21 | def item_description(self, item): 22 | return item.content 23 | 24 | def item_link(self, item): 25 | return item.get_absolute_url() 26 | -------------------------------------------------------------------------------- /knowledge_share/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-knowledge-share/89661c9c376b730712cfae760912646035094b51/knowledge_share/templatetags/__init__.py -------------------------------------------------------------------------------- /knowledge_share/templatetags/microblog.py: -------------------------------------------------------------------------------- 1 | import misaka 2 | from django import template 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter 8 | def format_post(post): 9 | from knowledge_share.twitter_helpers import format_twitter_post_to_share 10 | return format_twitter_post_to_share(post) 11 | 12 | 13 | @register.filter 14 | def convert_to_html(content): 15 | html_content = misaka.html(content, extensions=( 16 | 'fenced-code', 'autolink', 'strikethrough', 17 | 'underline', 'highlight', 'quote', 'math', 'no-intra-emphasis' 18 | )) 19 | html_content = html_content.replace(' MAX_TWEET_SIZE - category_size: 37 | tweet += ELLIPSIS 38 | break 39 | 40 | if tweet: 41 | tweet += ' ' 42 | tweet_size += 1 43 | 44 | tweet += word 45 | tweet_size += word_size 46 | 47 | fmt_tweet = '{}{}'.format(tweet, post_url) 48 | if category_size: 49 | fmt_tweet = '{} {}'.format(fmt_tweet, post_category) 50 | return fmt_tweet 51 | 52 | 53 | def format_twitter_post(post): 54 | # Markdown to text 55 | html_post_content = convert_to_html(post.content) 56 | text_post_content = BeautifulSoup(html_post_content, "lxml").text 57 | 58 | # Getting microblog post link 59 | base_url = KNOWLEDGE_HOST_NAME.rstrip('/') 60 | post_url = post.get_absolute_url().lstrip('/') 61 | full_post_url = '{}/{}'.format(base_url, post_url) 62 | 63 | post_words = text_post_content.split(' ') 64 | category = post.category.first() 65 | category_hashtag = '' 66 | if category: 67 | category_hashtag = category.hashtag 68 | tweet_content = create_content(post_words, full_post_url, category_hashtag) 69 | return tweet_content 70 | 71 | 72 | def format_twitter_post_to_share(post): 73 | return quote_plus(format_twitter_post(post)) 74 | 75 | 76 | def post_microblog_post_on_twitter(microblog_post): 77 | api = Twitter( 78 | api_key=settings.TWITTER_API_KEY, 79 | api_secret=settings.TWITTER_API_SECRET, 80 | access_token=settings.TWITTER_ACCESS_TOKEN, 81 | access_token_secret=settings.TWITTER_ACCESS_TOKEN_SECRET 82 | ) 83 | post_content = format_twitter_post(microblog_post) 84 | try: 85 | api.statuses_update().post(params={'status': post_content}) 86 | microblog_post.posted_on_twitter = True 87 | microblog_post.save() 88 | except ClientError: 89 | logger.error( 90 | "Tried to post a microblog post on Twitter but got a ClientError, " 91 | "check your twitter keys.") 92 | raise 93 | -------------------------------------------------------------------------------- /knowledge_share/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from .rss_feeds import MicroblogRssFeed 4 | from .views import MicroblogPostView, SlackSlashWebHookView 5 | 6 | urlpatterns = [ 7 | url(r'^microblog/integrations/slack-slash/$', 8 | SlackSlashWebHookView.as_view(), name='microblog-slack-slash'), 9 | url(r'^microblog/feed/$', MicroblogRssFeed(), name='microblog-feed'), 10 | url(r'^microblog/(?P[\w-]+)/$', MicroblogPostView.as_view(), name='microblog-post'), 11 | ] 12 | -------------------------------------------------------------------------------- /knowledge_share/views.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.http import JsonResponse 4 | from django.utils.decorators import method_decorator 5 | from django.views import generic 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.views.generic import DetailView 8 | 9 | from knowledge_share.conf import KNOWLEDGE_APP_NAME, KNOWLEDGE_USE_TWITTER 10 | from knowledge_share.exceptions import BadRequest 11 | from knowledge_share.twitter_helpers import post_microblog_post_on_twitter 12 | from tapioca.exceptions import ClientError 13 | 14 | MicroBlogPost = apps.get_model(KNOWLEDGE_APP_NAME, 'MicroBlogPost') 15 | MicroBlogCategory = apps.get_model(KNOWLEDGE_APP_NAME, 'MicroBlogCategory') 16 | 17 | 18 | def _normalize_and_split_data(text): 19 | # Remove first and last itens and split the string into a list. 20 | text = text.strip() 21 | last_open_sqbra = text.rfind('[') 22 | message = text 23 | categories_str = '' 24 | if text.endswith(']') and last_open_sqbra != -1: 25 | categories_str = text[last_open_sqbra + 1:-1] 26 | message = text[:last_open_sqbra] 27 | return message.strip(), categories_str 28 | 29 | 30 | def _clean_category_name(category_name): 31 | return category_name.lower().strip() 32 | 33 | 34 | class SlackSlashWebHookView(generic.View): 35 | @method_decorator(csrf_exempt) 36 | def dispatch(self, request, *args, **kwargs): 37 | token = request.POST.get('token') 38 | try: 39 | if not token or token != settings.SLACK_TOKEN: 40 | raise BadRequest('Invalid Slack token') 41 | 42 | return super(SlackSlashWebHookView, self).dispatch(request, *args, **kwargs) 43 | except BadRequest as e: 44 | return self.bad_request(e.message) 45 | 46 | def format_response_success_text(self, new_post, twitter_error): 47 | edit_msg = ( 48 | 'Thanks for the post! {}\n' 49 | ).format( 50 | '(it worked! But twitter posting failed)' if twitter_error else '') 51 | 52 | return edit_msg 53 | 54 | def get_params(self, **kwargs): 55 | data = self.request.POST 56 | kwargs['text_param'] = data['text'] 57 | return kwargs 58 | 59 | def bad_request(self, text): 60 | response = { 61 | 'response_type': 'in_channel', 62 | 'text': text, 63 | } 64 | return JsonResponse(response, status=400) 65 | 66 | def get_or_create_microblogpost(self, params, categories, **kwargs): 67 | new_post, created = MicroBlogPost.objects.get_or_create( 68 | **kwargs 69 | ) 70 | if categories: 71 | category_post = categories.split(',') 72 | for item in category_post: 73 | category_name = _clean_category_name(item) 74 | category_item, _ = ( 75 | MicroBlogCategory.objects.get_or_create( 76 | name=category_name)) 77 | new_post.category.add(category_item) 78 | return new_post, created 79 | 80 | def post(self, request, *args, **kwargs): 81 | try: 82 | params = self.get_params() 83 | except KeyError: 84 | raise BadRequest('Invalid/Missing some information') 85 | 86 | try: 87 | content, categories = _normalize_and_split_data(params['text_param']) 88 | except ValueError: 89 | text = ( 90 | 'Hey, your post failed! \n Make sure that ' 91 | 'you used this expression: Content [Categories]' 92 | ) 93 | raise BadRequest(text) 94 | 95 | new_post, created = self.get_or_create_microblogpost(params, categories, 96 | content=content, title='') 97 | try: 98 | if created and KNOWLEDGE_USE_TWITTER: 99 | post_microblog_post_on_twitter(new_post) 100 | except ClientError: 101 | twitter_error = True 102 | else: 103 | twitter_error = False 104 | response_text = self.format_response_success_text(new_post, twitter_error) 105 | response = { 106 | 'response_type': 'in_channel', 107 | 'text': response_text 108 | } 109 | return JsonResponse(response, status=200) 110 | 111 | 112 | class MicroblogPostView(DetailView): 113 | model = MicroBlogPost 114 | template_name = KNOWLEDGE_APP_NAME + '/microblog_post.html' 115 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 3 | from __future__ import absolute_import, unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | import django 9 | from django.conf import settings 10 | from django.test.utils import get_runner 11 | 12 | 13 | def run_tests(*test_args): 14 | if not test_args: 15 | test_args = ['tests'] 16 | 17 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 18 | django.setup() 19 | TestRunner = get_runner(settings) 20 | test_runner = TestRunner() 21 | failures = test_runner.run_tests(test_args) 22 | sys.exit(bool(failures)) 23 | 24 | 25 | if __name__ == '__main__': 26 | run_tests(*sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:knowledge_share/__init__.py] 7 | 8 | [wheel] 9 | universal = 1 10 | 11 | [flake8] 12 | ignore = D203 13 | exclude = 14 | .git, 15 | .tox 16 | docs/source/conf.py, 17 | build, 18 | dist 19 | max-line-length = 119 20 | 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import sys 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def get_version(*file_paths): 14 | """Retrieves the version from knowledge_share/__init__.py""" 15 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 16 | version_file = open(filename).read() 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 18 | version_file, re.M) 19 | if version_match: 20 | return version_match.group(1) 21 | raise RuntimeError('Unable to find version string.') 22 | 23 | 24 | version = get_version("knowledge_share", "__init__.py") 25 | 26 | 27 | if sys.argv[-1] == 'publish': 28 | try: 29 | import wheel 30 | print("Wheel version: ", wheel.__version__) 31 | except ImportError: 32 | print('Wheel library missing. Please run "pip install wheel"') 33 | sys.exit() 34 | os.system('python setup.py sdist upload') 35 | os.system('python setup.py bdist_wheel upload') 36 | sys.exit() 37 | 38 | if sys.argv[-1] == 'tag': 39 | print("Tagging the version on git:") 40 | os.system("git tag -a %s -m 'version %s'" % (version, version)) 41 | os.system("git push --tags") 42 | sys.exit() 43 | 44 | readme = open('README.rst').read() 45 | 46 | setup( 47 | name='django-knowledge-share', 48 | version=version, 49 | description="""App to create a microblog for sharing knowledge.""", 50 | long_description=readme, 51 | author='Vinta Software', 52 | author_email='contact@vinta.com.br', 53 | url='https://github.com/vintasoftware/django-knowledge-share', 54 | packages=[ 55 | 'knowledge_share', 56 | ], 57 | include_package_data=True, 58 | install_requires=[ 59 | 'misaka>=2.0.0,<2.2', 60 | 'tapioca-twitter>=0.8.2,<0.9', 61 | 'lxml>=4.5.0,<4.6.0', 62 | 'beautifulsoup4>=4.8,<4.9', 63 | 'pytz>=2017.2', 64 | 'django-markdown @ git+https://github.com/vintasoftware/django_markdown@02c6fa050090b086b527de127b87f702de055dac#egg=django-markdown' 65 | ], 66 | license="MIT", 67 | zip_safe=False, 68 | keywords='django-knowledge-share', 69 | classifiers=[ 70 | 'Development Status :: 3 - Alpha', 71 | 'Framework :: Django', 72 | 'Framework :: Django :: 1.8', 73 | 'Framework :: Django :: 1.9', 74 | 'Framework :: Django :: 1.10', 75 | 'Framework :: Django :: 1.11', 76 | 'Framework :: Django :: 2.0', 77 | 'Intended Audience :: Developers', 78 | 'License :: OSI Approved :: BSD License', 79 | 'Natural Language :: English', 80 | 'Programming Language :: Python :: 2', 81 | 'Programming Language :: Python :: 2.7', 82 | 'Programming Language :: Python :: 3', 83 | 'Programming Language :: Python :: 3.4', 84 | 'Programming Language :: Python :: 3.5', 85 | 'Programming Language :: Python :: 3.6', 86 | ], 87 | ) 88 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.0 2 | beautifulsoup4==4.8.2 3 | cffi==1.9.1 4 | Django>=1.11.19,<2.2 5 | Markdown==2.6.7 6 | misaka==2.1.0 7 | packaging==16.8 8 | pycparser==2.17 9 | pytz>=2017.2 10 | pyparsing==2.1.10 11 | six==1.10.0 12 | # from old requirements_dev.txt 13 | bumpversion==0.5.3 14 | flake8>=2.1.0 15 | isort==4.2.5 16 | wheel==0.29.0 17 | git+git://github.com/vintasoftware/django_markdown.git@02c6fa050090b086b527de127b87f702de055dac#egg=django-markdown 18 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/django-knowledge-share/89661c9c376b730712cfae760912646035094b51/tests/__init__.py -------------------------------------------------------------------------------- /tests/fake_project_urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.conf.urls import include, url 5 | 6 | urlpatterns = [ 7 | url(r'^', include('tests.urls', namespace='tests')), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from knowledge_share import models as knowledge_share_abstract_models 2 | 3 | 4 | class MicroBlogPost(knowledge_share_abstract_models.MicroBlogPostBase): 5 | pass 6 | 7 | 8 | class MicroBlogCategory(knowledge_share_abstract_models.MicroBlogCategoryBase): 9 | pass 10 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | import django 5 | 6 | DEBUG = True 7 | USE_TZ = True 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk" 11 | 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "django.db.backends.sqlite3", 15 | "NAME": ":memory:", 16 | } 17 | } 18 | 19 | ROOT_URLCONF = "tests.fake_project_urls" 20 | 21 | INSTALLED_APPS = [ 22 | "django.contrib.auth", 23 | "django.contrib.contenttypes", 24 | "django.contrib.sites", 25 | "knowledge_share", 26 | 27 | "tests", 28 | ] 29 | 30 | SITE_ID = 1 31 | 32 | if django.VERSION >= (1, 10): 33 | MIDDLEWARE = () 34 | else: 35 | MIDDLEWARE_CLASSES = () 36 | 37 | KNOWLEDGE_APP_NAME = 'tests' 38 | KNOWLEDGE_HOST_NAME = 'http://www.vinta.com.br' 39 | TWITTER_API_KEY = '' 40 | TWITTER_API_SECRET = '' 41 | TWITTER_ACCESS_TOKEN = '' 42 | TWITTER_ACCESS_TOKEN_SECRET = '' 43 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from knowledge_share.conf import KNOWLEDGE_APP_NAME 4 | from model_mommy import mommy 5 | 6 | 7 | class MicroBlogCategoryTest(TestCase): 8 | def test_format_hashtag_category(self): 9 | category = mommy.make( 10 | KNOWLEDGE_APP_NAME + '.MicroBlogCategory', 11 | name='chrome' 12 | ) 13 | self.assertEqual(category.hashtag, '#Chrome') 14 | 15 | def test_format_hashtag_category_with_space(self): 16 | category = mommy.make( 17 | KNOWLEDGE_APP_NAME + '.MicroBlogCategory', 18 | name='chrome extension' 19 | ) 20 | self.assertEqual(category.hashtag, '#ChromeExtension') 21 | 22 | def test_str(self): 23 | category = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogCategory') 24 | self.assertEquals(str(category), category.name) 25 | 26 | 27 | class MicroBlogPostTest(TestCase): 28 | 29 | def test_remove_http_url_from_content(self): 30 | text = ( 31 | 'This is the http post text http://some.url.com more text' 32 | 'with more text This is the post' 33 | ) 34 | microblog_post = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogPost', content=text) 35 | text_response = ( 36 | 'This is the http post text more text' 37 | 'with more text This is the post' 38 | ) 39 | self.assertEqual(microblog_post._remove_url_from_content(), text_response) 40 | 41 | def test_remove_https_url_from_content(self): 42 | text = ( 43 | 'This is the https post text http://some.url.com more text' 44 | 'with more text This is the post' 45 | ) 46 | microblog_post = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogPost', content=text) 47 | text_response = ( 48 | 'This is the https post text more text' 49 | 'with more text This is the post' 50 | ) 51 | self.assertEqual(microblog_post._remove_url_from_content(), text_response) 52 | 53 | def test_content_to_slug(self): 54 | text = ( 55 | 'This is the post text ' 56 | 'with more text This is the post' 57 | ) 58 | microblog_post = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogPost', content=text) 59 | slug = microblog_post._content_to_slug() 60 | self.assertEqual(slug, 'This-is-the-post-text-with') 61 | -------------------------------------------------------------------------------- /tests/test_rss_feeds.py: -------------------------------------------------------------------------------- 1 | try: # django <= 1.6 2 | from django.core.urlresolvers import reverse 3 | except ImportError: # from django 1.7 to django 2.0 (and more) 4 | from django.urls import reverse 5 | from django.test import TestCase 6 | 7 | from model_mommy import mommy 8 | 9 | 10 | class MicroblogRssFeedTests(TestCase): 11 | url_name = 'tests:microblog-feed' 12 | 13 | def setUp(self): 14 | self.content = 'this is a microblog post' 15 | mommy.make('tests.MicroBlogPost', content=self.content) 16 | self.view_url = reverse(self.url_name) 17 | 18 | def test_get(self): 19 | response = self.client.get(self.view_url) 20 | self.assertEqual(response.status_code, 200) 21 | 22 | def test_get_has_content(self): 23 | response = self.client.get(self.view_url) 24 | # a very simple test to check if the content is in the response 25 | self.assertIn(self.content, response.content.decode('utf-8')) 26 | -------------------------------------------------------------------------------- /tests/test_twitter_helpers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from knowledge_share.conf import KNOWLEDGE_APP_NAME 4 | from knowledge_share.twitter_helpers import (format_twitter_post, 5 | format_twitter_post_to_share) 6 | from model_mommy import mommy 7 | 8 | 9 | class FormatTwitterPostTests(TestCase): 10 | def test_post_format_to_post_directly(self): 11 | text = ( 12 | 'This is the post text http://some.url.com more text' 13 | 'with more text This is the post text with more text This is the post text' 14 | 'with more text This is the post text with more text This is the post text' 15 | 'with more text This is the post text with more text This is the post text' 16 | 'with more text This is the post text with more text This is the post text' 17 | ) 18 | post = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogPost', content=text, posted_on_twitter=True) 19 | formated = format_twitter_post(post) 20 | response = ( 21 | 'This is the post text http://some.url.com more text' 22 | 'with more text This is the post text with more text This is the post text' 23 | 'with more text This is the post text with more text This is the post text' 24 | 'with more text This is the post text with more text... ' 25 | 'http://www.vinta.com.br/lessons-learned/this-is-the-post-text-more/' 26 | ) 27 | self.assertEqual(formated, response) 28 | 29 | def test_post_format_to_post_directly_with_category(self): 30 | text = ( 31 | 'This is the post text http://some.url.com more text' 32 | 'with more text This is the post text with more text This is the post text' 33 | 'with more text This is the post text with more text This is the post text' 34 | 'with more text This is the post text with more text This is the post text' 35 | 'with more text This is the post text with more text This is the post text' 36 | ) 37 | 38 | category = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogCategory', name="chrome") 39 | post = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogPost', content=text, posted_on_twitter=True) 40 | post.category.add(category) 41 | formated = format_twitter_post(post) 42 | response = ( 43 | 'This is the post text http://some.url.com more text' 44 | 'with more text This is the post text with more text This is the post text' 45 | 'with more text This is the post text with more text This is the post text' 46 | 'with more text This is the post text with... ' 47 | 'http://www.vinta.com.br/lessons-learned/this-is-the-post-text-more/' 48 | ' #Chrome' 49 | ) 50 | self.assertEqual(formated, response) 51 | 52 | def test_post_format_to_share(self): 53 | text = ( 54 | 'This is the post text http://some.url.com more text' 55 | 'with more text This is the post text with more text This is the post text' 56 | 'with more text This is the post text with more text This is the post text' 57 | 'with more text This is the post text with more text This is the post text' 58 | 'with more text This is the post text with more text This is the post text' 59 | ) 60 | post = mommy.make(KNOWLEDGE_APP_NAME + '.MicroBlogPost', content=text, posted_on_twitter=True) 61 | formated = format_twitter_post_to_share(post) 62 | response = ( 63 | 'This+is+the+post+text+http%3A%2F%2Fsome.url.com+more+textwith+more+' 64 | 'text+This+is+the+post+text+with+more+text+This+is+the+post+text' 65 | 'with+more+text+This+is+the+post+text+with+more+text+This+is+the+post+text' 66 | 'with+more+text+This+is+the+post+text+with+more+text...+http%3A%2F%2Fwww.' 67 | 'vinta.com.br%2Flessons-learned%2Fthis-is-the-post-text-more%2F' 68 | ) 69 | self.assertEqual(formated, response) 70 | 71 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | try: # django <= 1.6 4 | from django.core.urlresolvers import reverse 5 | except ImportError: # from django 1.7 to django 2.0 (and more) 6 | from django.urls import reverse 7 | from django.test import TestCase, override_settings 8 | from django.test.client import Client 9 | from tests.models import MicroBlogPost 10 | 11 | import mock 12 | import responses 13 | from knowledge_share.views import (_clean_category_name, 14 | _normalize_and_split_data) 15 | 16 | 17 | @override_settings( 18 | SLACK_TOKEN='1234', 19 | ) 20 | class SlackSlashWebHookViewTests(TestCase): 21 | url_name = 'tests:microblog-slack-slash' 22 | 23 | def setUp(self): 24 | self.view_url = reverse(self.url_name) 25 | self.client = Client(HTTP_HOST='localtest.com') 26 | self.post_params = { 27 | 'text': 'My blog Post [category]', 28 | 'token': '1234', 29 | } 30 | 31 | def test_post_with_invalid_params(self): 32 | response = self.client.post(self.view_url) 33 | self.assertEqual(response.status_code, 400) 34 | 35 | def test_post_with_invalid_token_params(self): 36 | self.post_params['token'] = '123' 37 | response = self.client.post(self.view_url, self.post_params) 38 | self.assertEqual(response.status_code, 400) 39 | 40 | @responses.activate 41 | def test_post_with_valid_params(self): 42 | responses.add( 43 | responses.POST, 44 | 'https://api.twitter.com/1.1/statuses/update.json', 45 | body='{"success": "created"}', status=200, 46 | content_type='application/json' 47 | ) 48 | response = self.client.post(self.view_url, self.post_params) 49 | self.assertEqual(response.status_code, 200) 50 | 51 | @override_settings(KNOWLEDGE_USE_TWITTER=False) 52 | def test_post_without_categories(self): 53 | self.post_params['text'] = 'My blog Post' 54 | response = self.client.post(self.view_url, self.post_params) 55 | self.assertEqual(response.status_code, 200) 56 | microblog_post = MicroBlogPost.objects.first() 57 | self.assertEqual(microblog_post.content, 'My blog Post') 58 | self.assertEqual(microblog_post.category.count(), 0) 59 | 60 | @responses.activate 61 | def test_post_with_valid_params_create_an_object(self): 62 | responses.add( 63 | responses.POST, 64 | 'https://api.twitter.com/1.1/statuses/update.json', 65 | body='{"success": "created"}', status=200, 66 | content_type='application/json' 67 | ) 68 | self.client.post(self.view_url, self.post_params) 69 | microblog_post = MicroBlogPost.objects.first() 70 | self.assertEqual(microblog_post.content, 'My blog Post') 71 | 72 | @responses.activate 73 | def test_post_with_valid_params_post_on_twitter(self): 74 | responses.add( 75 | responses.POST, 76 | 'https://api.twitter.com/1.1/statuses/update.json', 77 | body='{"success": "created"}', status=200, 78 | content_type='application/json' 79 | ) 80 | self.client.post(self.view_url, self.post_params) 81 | microblog_post = MicroBlogPost.objects.first() 82 | self.assertTrue(microblog_post.posted_on_twitter) 83 | 84 | @responses.activate 85 | def test_post_create_category_tags(self): 86 | responses.add( 87 | responses.POST, 88 | 'https://api.twitter.com/1.1/statuses/update.json', 89 | body='{"success": "created"}', status=200, 90 | content_type='application/json' 91 | ) 92 | self.client.post(self.view_url, self.post_params) 93 | microblog_post = MicroBlogPost.objects.first() 94 | category = microblog_post.category.first() 95 | self.assertTrue(category.name, 'category') 96 | 97 | @mock.patch('knowledge_share.twitter_helpers.logger') 98 | @responses.activate 99 | def test_post_with_twitter_error(self, mocked): 100 | responses.add( 101 | responses.POST, 102 | 'https://api.twitter.com/1.1/statuses/update.json', 103 | body='{"success": "created"}', status=400, 104 | content_type='application/json' 105 | ) 106 | response = self.client.post(self.view_url, self.post_params) 107 | mocked.error.assert_called_once_with( 108 | "Tried to post a microblog post on Twitter but got a ClientError," 109 | " check your twitter keys.") 110 | self.assertIn('(it worked! But twitter posting failed)', 111 | json.loads(response.content.decode('utf-8'))['text']) 112 | 113 | 114 | class SlackSlashCommandHelpersTest(TestCase): 115 | 116 | def test_normalize_and_split_data(self): 117 | content = _normalize_and_split_data('My blog Post[category]') 118 | self.assertEqual(len(content), 2) 119 | self.assertEqual(content[0], 'My blog Post') 120 | self.assertEqual(content[1], 'category') 121 | 122 | def test_normalize_and_split_data_with_square_braces(self): 123 | content = _normalize_and_split_data('A list is like this foo[1], awesome.') 124 | self.assertEqual(len(content), 2) 125 | self.assertEqual(content[0], 'A list is like this foo[1], awesome.') 126 | self.assertEqual(content[1], '') 127 | 128 | def test_normalize_and_split_data_with_square_braces_and_category(self): 129 | content = _normalize_and_split_data( 130 | 'A list is like this foo[1][Python]') 131 | self.assertEqual(len(content), 2) 132 | self.assertEqual(content[0], 'A list is like this foo[1]') 133 | self.assertEqual(content[1], 'Python') 134 | 135 | def test_normalize_and_split_data_with_square_braces_and_space_category(self): 136 | content = _normalize_and_split_data( 137 | 'A list is like this foo[1] [Python]') 138 | self.assertEqual(len(content), 2) 139 | self.assertEqual(content[0], 'A list is like this foo[1]') 140 | self.assertEqual(content[1], 'Python') 141 | 142 | def test_normalize_and_split_data_with_multiple_categories(self): 143 | content = _normalize_and_split_data('My blog Post[Python, Django]') 144 | self.assertEqual(len(content), 2) 145 | self.assertEqual(content[0], 'My blog Post') 146 | self.assertEqual(content[1], 'Python, Django') 147 | 148 | def test_normalize_without_category(self): 149 | content = _normalize_and_split_data('My blog Post') 150 | self.assertEqual(len(content), 2) 151 | self.assertEqual(content[0], 'My blog Post') 152 | self.assertEqual(content[1], '') 153 | 154 | def test_clean_category_name(self): 155 | category = _clean_category_name(' Category') 156 | self.assertEqual(category, 'category') 157 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.conf.urls import url 5 | 6 | from knowledge_share import urls as knowledge_share_urls 7 | from knowledge_share.views import MicroblogPostView 8 | 9 | app_name = "tests" 10 | 11 | urlpatterns = [ 12 | url(r'^lessons-learned/(?P[\w-]+)/$', MicroblogPostView.as_view(), name='microblog-post'), 13 | ] + knowledge_share_urls.urlpatterns[:2] 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py27,py34,py35,py36}-django-18 4 | {py27,py34,py35,py36}-django-19 5 | {py27,py34,py35,py36}-django-110 6 | {py27,py34,py35,py36}-django-111 7 | {py34,py35,py36}-django-20 8 | 9 | [testenv] 10 | setenv = 11 | PYTHONPATH = {toxinidir}:{toxinidir}/knowledge_share 12 | commands = coverage run --source knowledge_share runtests.py 13 | deps = 14 | django-18: Django>=1.8,<1.9 15 | django-19: Django>=1.9,<1.10 16 | django-110: Django>=1.10,<1.11 17 | django-111: Django>=1.11,<2.0 18 | django-20: Django>=2.0,<2.2 19 | -r{toxinidir}/ci_requirements.txt 20 | basepython = 21 | py36: python3.6 22 | py35: python3.5 23 | py34: python3.4 24 | py27: python2.7 25 | --------------------------------------------------------------------------------