Compare commits

..

70 Commits

Author SHA1 Message Date
Ben Lopatin 977885967d Update makefile 2016-06-13 11:41:28 -04:00
Ben Lopatin b9066ca637 Version bump 0.5.0 2016-06-13 11:37:12 -04:00
Ben Lopatin 77254f441c Merge pull request #3 from msfernandes/badges_endpoint
Added badges endpoint to pydiscourse
2016-06-10 11:12:26 -04:00
Matheus Fernandes 66089011f7 Added 'user-badges' endpoint
Signed-off-by: Matheus Fernandes <matheus.souza.fernandes@gmail.com>
2016-06-10 11:46:56 -03:00
Matheus Fernandes fe317b6be8 Added badges endpoint to pydiscourse
Signed-off-by: Matheus Fernandes <matheus.souza.fernandes@gmail.com>
2016-06-10 10:59:03 -03:00
Ben Lopatin 15e82aacd1 Add specific methods for interface to groups
Hide 'verbs' from users.
2016-05-04 08:28:44 -04:00
Ben Lopatin 008f21d6fe Merge pull request #2 from citadelgrad/master
Added partial groups support
2016-05-04 07:36:59 -04:00
Scott Nixon 6baf51bbe1 Added partial groups support
Groups method returns the list of all groups.
Created group_owners which allows you to add and delete owners.
Created group_members which allows you to add and delete members.
2016-05-03 16:11:10 -07:00
Ben Lopatin 8304e7b2f5 Version bump 0.3.2 2016-04-17 11:36:09 -04:00
Ben Lopatin 5806beef34 Merge pull request #1 from danielzohar/master
Only return `nonce` from given payload
2016-04-17 11:32:41 -04:00
Daniel Zohar f905a957f4 Only return nonce from given payload 2016-04-17 16:00:35 +01:00
Ben Lopatin 06ca2c5a58 Update changelog to include in package description 2016-04-11 11:36:25 -04:00
Ben Lopatin bde4325776 Add autodoc generation 2016-04-08 18:45:57 -04:00
Ben Lopatin 6b7e570475 Update README
[ci skip]
2016-04-08 18:27:02 -04:00
Ben Lopatin 3659724f11 Remove duplicate requests dependency in tox config 2016-04-08 18:03:10 -04:00
Ben Lopatin 1e151fc51f Remove module import from setup.py
Get the version number from the file, not by importing and thus trying
to import requests
2016-04-08 17:57:19 -04:00
Ben Lopatin 63f120ddca Require requests for tests 2016-04-08 17:47:26 -04:00
Ben Lopatin 9cb96eaf76 Version bump 2016-04-08 17:38:16 -04:00
Ben Lopatin c5207759a8 Fix empty response handling 2016-04-08 17:36:18 -04:00
Ben Lopatin b14cd502ce Allow client import from top level module 2016-04-08 17:35:25 -04:00
Ben Lopatin 3a3bb843e5 Check for docstrings with flake8
[ci skip]
2016-04-08 13:59:18 -04:00
Ben Lopatin a2f961aebb Formatting cleanup and flake8 configuration
[ci skip]
2016-04-08 13:56:41 -04:00
Ben Lopatin bd508cdcee Version bump 0.3.0 2016-04-08 13:50:46 -04:00
Ben Lopatin 46051fd248 Fix unsuspend test 2016-04-08 13:47:43 -04:00
Ben Lopatin eed4df564d Add unsuspend method and un/suspend method tests 2016-04-08 13:43:05 -04:00
Ben Lopatin 6ac6a1fd2d Add client module docstring stubs 2016-04-08 13:43:05 -04:00
Ben Lopatin adf3f2ddbc Order imports 2016-04-08 13:43:05 -04:00
Ben Lopatin 2daebbfa23 Add test for external_id method 2016-04-08 13:43:05 -04:00
Ben Lopatin 9a23db7e43 Consolidate SSO tests
No good reason to separate these for now
2016-04-08 13:43:05 -04:00
Ben Lopatin 3be87f19dd Add Travis badge
[ci skip]
2016-04-07 17:50:27 -04:00
Ben Lopatin b7d4286c44 Remove requirements file from travis.yml 2016-04-07 17:46:28 -04:00
Ben Lopatin c155c8a60d Cleanup for packaging 2016-04-07 17:41:54 -04:00
Ben Lopatin dd8d9562ff Remove Python 2.6 support
Not worth including if it poses *any* problems whatsoever
2016-04-07 17:08:00 -04:00
Ben Lopatin c6a43f0304 Add pypy, pypy3, and add Python 2.6 back to test matrix 2016-04-07 17:02:56 -04:00
Ben Lopatin ca52920690 Read README as setup.py long description
Previous code caused serious problems building package
2016-04-07 16:57:44 -04:00
Ben Lopatin db934494e2 Update documentation including README 2016-04-07 16:56:12 -04:00
Ben Lopatin 6dbbe74776 Switch fully to reStructuredText in README 2016-04-07 16:48:01 -04:00
Ben Lopatin 04bb9c550b Add Python 3.5, drop Python 2.6 2016-04-07 16:45:57 -04:00
Guillaume Beraudo 65e398343b Add sync_sso method 2016-01-19 18:06:03 +01:00
Guillaume Beraudo df1d274a33 Add sso_payload helper method 2016-01-19 18:06:03 +01:00
Guillaume Beraudo 9cc771f381 Fix pydiscoursecli option in README 2016-01-19 15:53:20 +01:00
Guillaume Beraudo 66cd4ab5de Add by_external_id method
Allow retrieving user information based on the external SSO id.
The returned internal id can be used with logout.
2016-01-19 15:53:20 +01:00
Guillaume Beraudo bd79423ba9 Send header to accept json responses
This is necessary for by_external method.
Otherwise, an error page is returned.
2016-01-19 15:53:20 +01:00
Guillaume Beraudo 65abb8119f Add log_out method 2016-01-19 15:53:20 +01:00
Guillaume Beraudo f0f3256e01 Fix python 3.4 support 2016-01-19 15:53:20 +01:00
Julia Grace ab00eb9cf7 Merge pull request #12 from tindie/added_method_private_msgs_unread
added method to get unread private messages
2015-04-22 11:27:00 -07:00
Julia Grace 3ccf20212e added method to get unread private messages 2015-04-22 11:22:51 -07:00
Julia Grace e008d1865e Merge pull request #9 from citadelgrad/master
Added two admin methods. Account suspension and user listing with search
2015-04-22 11:14:09 -07:00
Julia Grace 5506eacaeb Merge pull request #10 from tindie/fix_topic_test
fixed test_topic()'
2015-04-22 11:05:09 -07:00
Julia Grace 29cb1ce14b fixed test_topic()' 2015-04-22 10:51:39 -07:00
Julia Grace 4e69083284 Merge pull request #8 from RauchF/master
Python 3 compatibility, new GET method (/admin/users/list/)
2015-03-31 10:36:28 -07:00
Scott Nixon 05a58b1d62 Added two admin methods. Account suspension and user listing with search 2015-02-25 13:18:36 -08:00
Felix Rauch cc03b5cc08 Add method to GET list of users (/admin)
This allows to grab all users, as used in the Discourse admin UI at
/admin/users/list. By default grabs all active users, but also accepts
the other views like "staff", "suspended", etc.
2014-12-19 01:22:13 +01:00
Felix Rauch 385e35b322 Add parentheses around print statement for Python 3.x
Python 3.x actually requires parentheses around the arguments to
print().
This shouldn't break compatibility with Python 2.4/2.6, but fixes a
syntax error with Python 3.x.
2014-12-19 00:52:53 +01:00
Julia Grace 9c6097a3d4 Merge pull request #6 from kportertx/patch-1
Potential bug in update_post
2014-12-05 14:10:16 -08:00
Kevin Porter 204eb1478a Potential bug in update_post 2014-12-03 18:48:57 -08:00
Marc Sibson e64b990dc8 Merge branch 'master' of github.com:tindie/pydiscourse 2014-08-06 15:25:15 -07:00
Marc Sibson e358300085 fix topic to avoid redirect 2014-08-06 15:25:05 -07:00
Marc Sibson b8cb201652 Merge pull request #3 from tindie/more-good-errors
try harder to give verbose error messages
2014-07-16 14:29:01 -07:00
Marc Sibson cba141724d try harder to give verbose error messages 2014-07-16 14:26:11 -07:00
Marc Sibson d64651e655 add post update 2014-06-23 22:27:28 -07:00
Marc Sibson 9d5d835910 update docs for SSO 2014-06-13 15:37:42 +00:00
Marc Sibson 9eb5f3466b Merge branch 'master' of github.com:tindie/pydiscourse 2014-06-13 15:34:15 +00:00
Marc Sibson d66e636078 improve category support 2014-06-13 15:34:02 +00:00
Marc Sibson a381089497 Merge pull request #2 from tindie/f/sso
add SSO support, thanks James Potter
2014-06-06 13:12:32 -07:00
Marc Sibson aaa18ee0c6 add user preferences 2014-06-02 11:16:14 -07:00
Marc Sibson a07280975a fixes for py26, py34 tests 2014-05-28 22:50:18 -07:00
Marc Sibson cb0244652d add SSO support, thanks James Potter 2014-05-28 10:18:48 -07:00
Marc Sibson 4a56a43bd5 start thinking about 3.4 support 2014-05-27 22:30:06 -07:00
Marc Sibson 4a366a1b97 tox and testing for py2.6, py2.7 2014-05-27 22:24:57 -07:00
26 changed files with 1337 additions and 310 deletions
+7 -7
View File
@@ -1,10 +1,10 @@
sudo: false
language: python
python:
- "2.7"
- "2.7"
- "3.4"
- "3.5"
- "pypy"
- "pypy3"
install:
- "pip install -r requirements.dev.txt"
- "pip install ."
script: nosetests
script: python setup.py test
+6
View File
@@ -0,0 +1,6 @@
(Based on original authors list and may be incomplete)
Marc Sibson
James Potter
Ben Lopatin
Daniel Zohar
+58
View File
@@ -0,0 +1,58 @@
============
Contributing
============
For patches, please ensure that all existing tests pass, that you have adequate
tests added as necessary, and that all code is documented! The latter is
critical. If you add or update an existing function, class, or module, please
ensure you add a docstring or ensure the existing docstring is up-to-date.
Please use `Google docstring format
<http://sphinxcontrib-napoleon.readthedocs.org/en/latest/example_google.html>`_.
This *will* be enforced.
Testing
=======
The best way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
pip install tox
detox
Or it's slightly faster cousin `detox
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
pip install detox
detox
Alternatively, you can run the self test with the following commands::
pip install -r requirements.dev.txt
pip install -e .
python setup.py test
Live Testing
============
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev].
For the impatient here is the quick and dirty version::
git clone git@github.com:discourse/discourse.git
cd discourse
vagrant up
vagrant ssh
cd /vagrant
bundle install
bundle exec rake db:migrate
bundle exec rails s
Once running you can access the Discourse install at http://localhost:4000.
[discoursedev]: https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md "Discourse Vagrant"
TODO
====
Refer to, https://github.com/discourse/discourse_api/blob/master/routes.txt for
a list of all operations available in Discourse.
-30
View File
@@ -1,30 +0,0 @@
Development
------------
Refer to, https://github.com/discourse/discourse_api/blob/master/routes.txt for a list of all operations available in Discourse.
Unit tests
--------------
You can run the self test with the following commands::
pip install -r requirements.dev.txt
pip install -e .
nosetests
Live Testing
-----------------
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev].
For the impatient here is the quick and dirty version::
git clone git@github.com:discourse/discourse.git
cd discourse
vagrant up
vagrant ssh
cd /vagrant
bundle install
bundle exec rake db:migrate
bundle exec rails s
Once running you can access the Discourse install at http://localhost:4000.
[discoursedev]: https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md "Discourse Vagrant"
+43
View File
@@ -0,0 +1,43 @@
.. :changelog:
Release history
===============
0.5.0
-----
* Adds badges functionality
0.4.0
-----
* Adds initial groups functionality
0.3.2
-----
* SSO functionality fixes
0.3.1
-----
* Fix how empty responses are handled
0.3.0
-----
* Added method to unsuspend suspended user
0.2.0
-----
* Inital fork, including gberaudo's changes
* Packaging cleanup, dropping Python 2.6 support and adding Python 3.5, PyPy,
PyPy3
* Packaging on PyPI
0.1.0.dev
---------
All pre-PyPI development
+6
View File
@@ -0,0 +1,6 @@
include setup.py
include README.rst
include MANIFEST.in
include HISTORY.rst
include LICENSE
recursive-include pydiscourse
+62
View File
@@ -0,0 +1,62 @@
.PHONY: clean-pyc clean-build docs clean
clean: clean-build clean-pyc clean-test-all
clean-build:
@rm -rf build/
@rm -rf dist/
@rm -rf *.egg-info
clean-pyc:
-@find . -name '*.pyc' -follow -print0 | xargs -0 rm -f &> /dev/null
-@find . -name '*.pyo' -follow -print0 | xargs -0 rm -f &> /dev/null
-@find . -name '__pycache__' -type d -follow -print0 | xargs -0 rm -rf &> /dev/null
clean-test:
rm -rf .coverage coverage*
rm -rf tests/.coverage test/coverage*
rm -rf htmlcov/
clean-test-all: clean-test
rm -rf .tox/
lint:
flake8 pydiscourse
test: ## Run test suite against current Python path
python setup.py test
test-coverage: clean-test
-py.test ${COVER_FLAGS} ${TEST_FLAGS}
@exit_code=$?
@-coverage html
@exit ${exit_code}
test-all: ## Run all tox test environments, parallelized
detox
check: clean-build clean-pyc clean-test lint test-coverage
build: clean ## Create distribution files for release
python setup.py sdist bdist_wheel
release: build ## Create distribution files and publish to PyPI
python setup.py check -r -s
twine upload dist/*
sdist: clean ##sdist Create source distribution only
python setup.py sdist
ls -l dist
api-docs: ## Build autodocs from docstrings
sphinx-apidoc -f -o docs pydiscourse
manual-docs: ## Build written docs
$(MAKE) -C docs clean
$(MAKE) -C docs html
docs: api-docs manual-docs ## Builds and open docs
open docs/_build/html/index.html
help:
@perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
-33
View File
@@ -1,33 +0,0 @@
pydiscourse
------------
A Python library for the Discourse API.
Its pretty basic right now but you need to start somewhere.
Examples
-----------
Create a client connection to a Discourse server::
from pydiscourse.client import DiscourseClient
client = DiscourseClient('http://example.com', api_username='username', api_key='areallylongstringfromdiscourse')
Get info about a user::
user = client.user('eviltrout')
print user
user_topics = client.topics_by('johnsmith')
print user_topics
Create a new user::
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
Command line
----------------
To help experiment with the Discourse API, pydiscourse provides a simple command line client::
export DISCOURSE_API_KEY=your_master_key
pydiscoursecli --host=http://yourhost --api-username=system latest_topics
pydiscoursecli --host=http://yourhost --api-username=system topics_by johnsmith
pydiscoursecli --host=http://yourhost --api-username=system user eviltrout
+73
View File
@@ -0,0 +1,73 @@
===========
pydiscourse
===========
.. image:: https://secure.travis-ci.org/bennylope/pydiscourse.svg?branch=master
:alt: Build Status
:target: http://travis-ci.org/bennylope/pydiscourse
A Python library for working with Discourse.
This is a fork of the original Tindie version. It was forked to include fixes,
additional functionality, and to distribute a package on PyPI.
Goals
=====
* Exceptional documentation
* Support all supported Python versions
* Provide functional parity with the Discourse API, for the currently supported
version of Discourse (something of a moving target)
The order here is important. The Discourse API is itself poorly documented so
the level of documentation in the Python client is critical.
Installation
============
::
pip install pydiscourse
Examples
========
Create a client connection to a Discourse server::
from pydiscourse import DiscourseClient
client = DiscourseClient(
'http://example.com',
api_username='username',
api_key='areallylongstringfromdiscourse')
Get info about a user::
user = client.user('eviltrout')
print user
user_topics = client.topics_by('johnsmith')
print user_topics
Create a new user::
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
Implement SSO for Discourse with your Python server::
@login_required
def discourse_sso_view(request):
payload = request.GET.get('sso')
signature = request.GET.get('sig')
nonce = sso_validate(payload, signature, SECRET)
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
return redirect('http://discuss.example.com' + url)
Command line
============
To help experiment with the Discourse API, pydiscourse provides a simple command line client::
export DISCOURSE_API_KEY=your_master_key
pydiscoursecli --host-http://yourhost --api-user-system latest_topics
pydiscoursecli --host-http://yourhost --api-user-system topics_by johnsmith
pydiscoursecli --host-http://yourhost --api-user-system user eviltrout
+3 -5
View File
@@ -28,9 +28,7 @@ import os
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
]
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -53,9 +51,9 @@ copyright = u'2014, Marc Sibson'
# built documents.
#
# The short X.Y version.
version = '0.1'
version = '0.5'
# The full version, including alpha/beta/rc tags.
release = '0.1'
release = '0.5.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
+3 -2
View File
@@ -1,4 +1,5 @@
Development
===============
.. include:: ../DEVELOP.md
:start-line: 2
.. include:: ../CONTRIBUTING.rst
:start-line: 3
+3 -2
View File
@@ -1,4 +1,5 @@
Introduction
==============
.. include:: ../README.md
:start-line: 2
.. include:: ../README.rst
:start-line: 3
+7
View File
@@ -0,0 +1,7 @@
pydiscourse
===========
.. toctree::
:maxdepth: 4
pydiscourse
+46
View File
@@ -0,0 +1,46 @@
pydiscourse package
===================
Submodules
----------
pydiscourse.client module
-------------------------
.. automodule:: pydiscourse.client
:members:
:undoc-members:
:show-inheritance:
pydiscourse.exceptions module
-----------------------------
.. automodule:: pydiscourse.exceptions
:members:
:undoc-members:
:show-inheritance:
pydiscourse.main module
-----------------------
.. automodule:: pydiscourse.main
:members:
:undoc-members:
:show-inheritance:
pydiscourse.sso module
----------------------
.. automodule:: pydiscourse.sso
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: pydiscourse
:members:
:undoc-members:
:show-inheritance:
+5 -1
View File
@@ -1 +1,5 @@
__version__ = '0.1.0.dev'
# -*- coding: utf-8 -*-
__version__ = '0.5.0'
from pydiscourse.client import DiscourseClient
-160
View File
@@ -1,160 +0,0 @@
""" A higher level API wrapper for communicating with a Discourse instance
EXPERIMENTAL, subject to complete and radical change
Goal
------
A pythonic wrapper around the discourse API that minimizes requests by lazy loading of data.
"""
from datetime import datetime
def datetime_from(date):
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
# XXX not handling timezone, but we're not using it yet
return datetime.strptime(date[:-6], DATE_FORMAT)
class DiscourseUser(object):
username = None
userid = None
avatar_template = None
default_avatar_size = 40
def avatar(self, size=None):
if size is None:
size = self.default_avatar_size
return self.avatar_template.replace(u'{size}', unicode(size))
def __repr__(self):
return '<DiscourseUser {0} {1}>'.format(self.userid, self.username)
@classmethod
def from_summary(cls, summary):
instance = cls()
instance.username = summary['username']
instance.userid = summary['id']
instance.avatar_template = summary['avatar_template']
return instance
class DiscourseUserSet(object):
def __init__(self, users):
self.users = users
self.byname = {u.username: u for u in users}
self.byid = {u.userid: u for u in users}
def __iter__(self):
return iter(self.users)
def __getitem__(self, item):
try:
return self.byname[item]
except KeyError:
return self.byid[item]
@classmethod
def from_response(cls, response):
users = [DiscourseUser.from_summary(u) for u in response.get('users', [])]
return cls(users)
class DiscoursePost(object):
default_avatar_size = 40
def avatar(self, size=None):
if size is None:
size = self.default_avatar_size
return self.avatar_template.replace(u'{size}', unicode(size))
def date_created(self):
return datetime_from(self.data['created_at'])
@classmethod
def from_dict(cls, data):
instance = cls()
instance.data = data
return instance
def __getattr__(self, attr):
return self.data[attr]
class DiscourseTopic(object):
def __init__(self):
self.posts = None
self.data = None
self.raw_response = None
def created_by(self):
return DiscourseUser.from_summary(self.data['details']['created_by'])
def created_at(self):
return datetime_from(self.data['created_at'])
def last_posted_at(self):
return datetime_from(self.data['last_posted_at'])
def num_unread(self):
if self.data['unseen']:
return 1
return self.data['new_posts']
def participants(self):
return [DiscourseUser.from_summary(u) for u in self.data['participants']]
@classmethod
def from_response(cls, response):
instance = DiscourseTopic.from_dict(response)
instance.raw_response = response
instance.posts = []
for post in response['post_stream']['posts']:
instance.posts.append(DiscoursePost.from_dict(post))
return instance
@classmethod
def from_dict(cls, data):
instance = cls()
instance.data = data
return instance
def fetch_remaining_posts(self, discourse):
""" The initial topic response is paginated, this makes another request to get additional posts
"""
if self.data['posts_count'] > len(self.posts):
missing = self.data['post_stream']['stream'][len(self.posts):]
response = discourse.posts(self.id, missing)
for post in response['post_stream']['posts']:
self.posts.append(DiscoursePost.from_dict(post))
def __getattr__(self, attr):
return self.data[attr]
def __repr__(self):
return u'<Topic {0}>'.format(self.title)
class DiscourseTopicSet(object):
def __init__(self, topics):
self.topics = topics
def all(self):
return self.topics
def __iter__(self):
return iter(self.topics)
def __len__(self):
return len(self.topics)
@classmethod
def from_response(cls, response):
topics = response.get('topic_list', {}).get('topics', [])
topics = [DiscourseTopic.from_dict(t) for t in topics]
return cls(topics)
Executable → Regular
+704 -32
View File
@@ -1,112 +1,456 @@
#!/usr/bin/env python
"""
Core API client module
"""
import logging
import requests
from pydiscourse.exceptions import DiscourseError, DiscourseServerError, DiscourseClientError
from pydiscourse.exceptions import (
DiscourseError, DiscourseServerError, DiscourseClientError)
from pydiscourse.sso import sso_payload
log = logging.getLogger('pydiscourse.client')
# HTTP verbs to be used as non string literals
DELETE = "DELETE"
GET = "GET"
POST = "POST"
PUT = "PUT"
class DiscourseClient(object):
""" A basic client for the Discourse API that implements the raw API
"""Discourse API client"""
This class will attempt to remain roughly similar to the discourse_api rails API
"""
def __init__(self, host, api_username, api_key, timeout=None):
"""
Initialize the client
Args:
host: full domain name including scheme for the Discourse API
api_username: username to connect with
api_key: API key to connect with
timeout: optional timeout for the request (in seconds)
Returns:
"""
self.host = host
self.api_username = api_username
self.api_key = api_key
self.timeout = timeout
def user(self, username):
"""
Get user information for a specific user
TODO: include sample data returned
TODO: what happens when no user is found?
Args:
username: username to return
Returns:
dict of user information
"""
return self._get('/users/{0}.json'.format(username))['user']
def create_user(self, name, username, email, password, **kwargs):
""" active='true', to avoid sending activation emails
"""
Create a Discourse user
Set keyword argument active='true' to avoid sending activation emails
TODO: allow optional password and generate a random one
Args:
name: the full name of the new user
username: their username (this is a key... that they can change)
email: their email, will be used for activation and summary emails
password: their initial password
**kwargs: ???? what else can be sent through?
Returns:
????
"""
r = self._get('/users/hp.json')
challenge = r['challenge'][::-1] # reverse challenge, discourse security check
confirmations = r['value']
return self._post('/users', name=name, username=username, email=email,
password=password, password_confirmation=confirmations, challenge=challenge, **kwargs)
password=password, password_confirmation=confirmations,
challenge=challenge, **kwargs)
def user_by_external_id(self, external_id):
"""
Args:
external_id:
Returns:
"""
response = self._get("/users/by-external/{0}".format(external_id))
return response['user']
by_external_id = user_by_external_id
def log_out(self, userid):
"""
Args:
userid:
Returns:
"""
return self._post('/admin/users/{0}/log_out'.format(userid))
def trust_level(self, userid, level):
"""
Args:
userid:
level:
Returns:
"""
return self._put('/admin/users/{0}/trust_level'.format(userid), level=level)
def suspend(self, userid, duration, reason):
"""
Suspend a user's account
Args:
userid: the Discourse user ID
duration: the length of time in days for which a user's account
should be suspended
reason: the reason for suspending the account
Returns:
????
"""
return self._put('/admin/users/{0}/suspend'.format(userid),
duration=duration, reason=reason)
def unsuspend(self, userid):
"""
Unsuspends a user's account
Args:
userid: the Discourse user ID
Returns:
None???
"""
return self._put('/admin/users/{0}/unsuspend'.format(userid))
def list_users(self, type, **kwargs):
"""
optional user search: filter='test@example.com' or filter='scott'
Args:
type:
**kwargs:
Returns:
"""
return self._get('/admin/users/list/{0}.json'.format(type), **kwargs)
def update_avatar_from_url(self, username, url, **kwargs):
"""
Args:
username:
url:
**kwargs:
Returns:
"""
return self._post('/users/{0}/preferences/avatar'.format(username), file=url, **kwargs)
def update_avatar_image(self, username, img, **kwargs):
"""
Args:
username:
img:
**kwargs:
Returns:
"""
files = {'file': img}
return self._post('/users/{0}/preferences/avatar'.format(username), files=files, **kwargs)
def toggle_gravatar(self, username, state=True, **kwargs):
url = '/users/{0}/preferences/avatar/toggle'.format(username)
"""
Args:
username:
state:
**kwargs:
Returns:
"""
url = '/users/{0}/preferences/avatar/toggle'.format(username)
if bool(state):
kwargs['use_uploaded_avatar'] = 'true'
else:
kwargs['use_uploaded_avatar'] = 'false'
return self._put(url, **kwargs)
def pick_avatar(self, username, gravatar=True, generated=False, **kwargs):
"""
Args:
username:
gravatar:
generated:
**kwargs:
Returns:
"""
url = '/users/{0}/preferences/avatar/pick'.format(username)
return self._put(url, **kwargs)
def update_email(self, username, email, **kwargs):
"""
Args:
username:
email:
**kwargs:
Returns:
"""
return self._put('/users/{0}/preferences/email'.format(username), email=email, **kwargs)
def update_user(self, username, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
return self._put('/users/{0}'.format(username), **kwargs)
def update_username(self, username, new_username, **kwargs):
return self._put('/users/{0}/preferences/username'.format(username), username=new_username, **kwargs)
"""
Args:
username:
new_username:
**kwargs:
Returns:
"""
return self._put('/users/{0}/preferences/username'.format(username),
username=new_username, **kwargs)
def set_preference(self, username=None, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
if username is None:
username = self.api_username
return self._put(u'/users/{0}'.format(username), **kwargs)
def sync_sso(self, **kwargs):
"""
expect sso_secret, name, username, email, external_id, avatar_url,
avatar_force_update
Args:
**kwargs:
Returns:
"""
sso_secret = kwargs.pop('sso_secret')
payload = sso_payload(sso_secret, **kwargs)
return self._post('/admin/users/sync_sso?{0}'.format(payload), **kwargs)
def generate_api_key(self, userid, **kwargs):
"""
Args:
userid:
**kwargs:
Returns:
"""
return self._post('/admin/users/{0}/generate_api_key'.format(userid), **kwargs)
def delete_user(self, userid, **kwargs):
"""
block_email='true'
block_ip='false'
block_urls='false'
Args:
userid:
**kwargs:
Returns:
"""
return self._delete('/admin/users/{0}.json'.format(userid), **kwargs)
def users(self, filter=None, **kwargs):
"""
Args:
filter:
**kwargs:
Returns:
"""
if filter is None:
filter = 'active'
return self._get('/admin/users/list/{0}.json'.format(filter), **kwargs)
def private_messages(self, username=None, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
if username is None:
username = self.api_username
return self._get('/topics/private-messages/{0}.json'.format(username), **kwargs)
def private_messages_unread(self, username=None, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
if username is None:
username = self.api_username
return self._get('/topics/private-messages-unread/{0}.json'.format(username), **kwargs)
def hot_topics(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/hot.json', **kwargs)
def latest_topics(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/latest.json', **kwargs)
def new_topics(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/new.json', **kwargs)
def topic(self, topic_id, **kwargs):
return self._get('/t/{0}.json'.format(topic_id), **kwargs)
def topic(self, slug, topic_id, **kwargs):
"""
Args:
slug:
topic_id:
**kwargs:
Returns:
"""
return self._get('/t/{0}/{1}.json'.format(slug, topic_id), **kwargs)
def post(self, topic_id, post_id, **kwargs):
"""
Args:
topic_id:
post_id:
**kwargs:
Returns:
"""
return self._get('/t/{0}/{1}.json'.format(topic_id, post_id), **kwargs)
def posts(self, topic_id, post_ids=None, **kwargs):
""" Get a set of posts from a topic
"""
Get a set of posts from a topic
Args:
topic_id:
post_ids: a list of post ids from the topic stream
**kwargs:
Returns:
post_ids: a list of post ids from the topic stream
"""
if post_ids:
kwargs['post_ids[]'] = post_ids
return self._get('/t/{0}/posts.json'.format(topic_id), **kwargs)
def topic_timings(self, topic_id, time, timings={}, **kwargs):
""" Set time spent reading a post
time: overall time for the topic
timings = { post_number: ms }
"""
Set time spent reading a post
A side effect of this is to mark the post as read
Args:
topic_id: { post_number: ms }
time: overall time for the topic (in what unit????)
timings:
**kwargs:
Returns:
"""
kwargs['topic_id'] = topic_id
kwargs['topic_time'] = time
@@ -116,18 +460,68 @@ class DiscourseClient(object):
return self._post('/topics/timings', **kwargs)
def topic_posts(self, topic_id, **kwargs):
"""
Args:
topic_id:
**kwargs:
Returns:
"""
return self._get('/t/{0}/posts.json'.format(topic_id), **kwargs)
def create_post(self, content, **kwargs):
""" int: topic_id the topic to reply too
"""
Args:
content:
**kwargs:
Returns:
"""
return self._post('/posts', raw=content, **kwargs)
def update_post(self, post_id, content, edit_reason='', **kwargs):
"""
Args:
post_id:
content:
edit_reason:
**kwargs:
Returns:
"""
kwargs['post[raw]'] = content
kwargs['post[edit_reason]'] = edit_reason
return self._put('/posts/{0}'.format(post_id), **kwargs)
def topics_by(self, username, **kwargs):
"""
Args:
username:
**kwargs:
Returns:
"""
url = '/topics/created-by/{0}.json'.format(username)
return self._get(url, **kwargs)['topic_list']['topics']
def invite_user_to_topic(self, user_email, topic_id):
"""
Args:
user_email:
topic_id:
Returns:
"""
kwargs = {
'email': user_email,
'topic_id': topic_id,
@@ -135,45 +529,322 @@ class DiscourseClient(object):
return self._post('/t/{0}/invite.json'.format(topic_id), **kwargs)
def search(self, term, **kwargs):
"""
Args:
term:
**kwargs:
Returns:
"""
kwargs['term'] = term
return self._get('/search.json', **kwargs)
def badges(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/admin/badges.json', **kwargs)
def grant_badge_to(self, username, badge_id, **kwargs):
"""
Args:
username:
badge_id:
**kwargs:
Returns:
"""
return self._post('/user_badges', username=username, badge_id=badge_id, **kwargs)
def user_badges(self, username, **kwargs):
"""
Args:
username:
Returns:
"""
return self._get('/user-badges/{}.json'.format(username))
def create_category(self, name, color, text_color='FFFFFF',
permissions=None, parent=None, **kwargs):
"""
Args:
name:
color:
text_color:
permissions: dict of 'everyone', 'admins', 'moderators', 'staff' with values of ???
parent:
**kwargs:
Returns:
"""
kwargs['name'] = name
kwargs['color'] = color
kwargs['text_color'] = text_color
if permissions is None and 'permissions' not in kwargs:
permissions = {'everyone': '1'}
for key, value in permissions.items():
kwargs['permissions[{0}]'.format(key)] = value
if parent:
parent_id = None
for category in self.categories():
if category['name'] == parent:
parent_id = category['id']
continue
if not parent_id:
raise DiscourseClientError(u'{0} not found'.format(parent))
kwargs['parent_category_id'] = parent_id
return self._post('/categories', **kwargs)
def categories(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
return self._get('/categories.json', **kwargs)['category_list']['categories']
def category(self, name, parent=None, **kwargs):
"""
Args:
name:
parent:
**kwargs:
Returns:
"""
if parent:
name = u'{0}/{1}'.format(parent, name)
return self._get(u'/category/{0}.json'.format(name), **kwargs)
def site_settings(self, **kwargs):
"""
Args:
**kwargs:
Returns:
"""
for setting, value in kwargs.items():
setting = setting.replace(' ', '_')
self._request('PUT', '/admin/site_settings/{0}'.format(setting), {setting: value})
self._request(PUT, '/admin/site_settings/{0}'.format(setting), {setting: value})
def groups(self, **kwargs):
"""
Returns a list of all groups.
Returns:
List of dictionaries of groups
[
{
'alias_level': 0,
'automatic': True,
'automatic_membership_email_domains': None,
'automatic_membership_retroactive': False,
'grant_trust_level': None,
'has_messages': True,
'id': 1,
'incoming_email': None,
'mentionable': False,
'name': 'admins',
'notification_level': 2,
'primary_group': False,
'title': None,
'user_count': 9,
'visible': True
},
{
'alias_level': 0,
'automatic': True,
'automatic_membership_email_domains': None,
'automatic_membership_retroactive': False,
'grant_trust_level': None,
'has_messages': False,
'id': 0,
'incoming_email': None,
'mentionable': False,
'name': 'everyone',
'notification_level': None,
'primary_group': False,
'title': None,
'user_count': 0,
'visible': True
}
]
"""
return self._get("/admin/groups.json", **kwargs)
def add_group_owner(self, groupid, username):
"""
Add an owner to a group by username
Args:
groupid: the ID of the group
username: the new owner usernmae
Returns:
JSON API response
"""
return self._put("/admin/groups/{0}/owners.json".format(groupid), usernames=username)
def delete_group_owner(self, groupid, userid):
"""
Deletes an owner from a group by user ID
Does not delete the user from Discourse.
Args:
groupid: the ID of the group
userid: the ID of the user
Returns:
JSON API response
"""
return self._delete("/admin/groups/{0}/owners.json".format(groupid), user_id=userid)
def add_group_member(self, groupid, username):
"""
Add a member to a group by username
Args:
groupid: the ID of the group
username: the new member usernmae
Returns:
JSON API response
Raises:
DiscourseError if user is already member of group
"""
return self._put("/admin/groups/{0}/members.json".format(groupid), usernames=username)
def delete_group_member(self, groupid, userid):
"""
Deletes a member from a group by user ID
Does not delete the user from Discourse.
Args:
groupid: the ID of the group
userid: the ID of the user
Returns:
JSON API response
"""
return self._delete("/admin/groups/{0}/members.json".format(groupid), user_id=userid)
def _get(self, path, **kwargs):
return self._request('GET', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(GET, path, kwargs)
def _put(self, path, **kwargs):
return self._request('PUT', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(PUT, path, kwargs)
def _post(self, path, **kwargs):
return self._request('POST', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(POST, path, kwargs)
def _delete(self, path, **kwargs):
return self._request('DELETE', path, kwargs)
"""
Args:
path:
**kwargs:
Returns:
"""
return self._request(DELETE, path, kwargs)
def _request(self, verb, path, params):
"""
Executes HTTP request to API and handles response
Args:
verb: HTTP verb as string: GET, DELETE, PUT, POST
path: the path on the Discourse API
params: dictionary of parameters to include to the API
Returns:
"""
params['api_key'] = self.api_key
if 'api_username' not in params:
params['api_username'] = self.api_username
url = self.host + path
response = requests.request(verb, url, allow_redirects=False, params=params, timeout=self.timeout)
headers = {'Accept': 'application/json; charset=utf-8'}
response = requests.request(
verb, url, allow_redirects=False, params=params, headers=headers,
timeout=self.timeout)
log.debug('response %s: %s', response.status_code, repr(response.text))
if not response.ok:
if response.reason:
msg = response.reason
else:
try:
msg = u','.join(response.json()['errors'])
except (ValueError, TypeError, KeyError):
try:
msg = u','.join(response.json()['errors'])
except (ValueError, TypeError, KeyError):
if response.reason:
msg = response.reason
else:
msg = u'{0}: {1}'.format(response.status_code, response.text)
if 400 <= response.status_code < 500:
@@ -182,13 +853,14 @@ class DiscourseClient(object):
raise DiscourseServerError(msg, response=response)
if response.status_code == 302:
raise DiscourseError('Unexpected Redirect, invalid api key or host?', response=response)
raise DiscourseError(
'Unexpected Redirect, invalid api key or host?', response=response)
json_content = 'application/json; charset=utf-8'
content_type = response.headers['content-type']
if content_type != json_content:
# some calls return empty html documents
if response.content == ' ':
if not response.content.strip():
return None
raise DiscourseError('Invalid Response, expecting "{0}" got "{1}"'.format(
+11 -4
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python
import cmd
import json
import logging
import optparse
import os
import pydoc
import sys
import os
import logging
from pydiscourse.client import DiscourseClient, DiscourseError
@@ -30,7 +31,7 @@ class DiscourseCmd(cmd.Cmd):
try:
return method(*args, **kwargs)
except DiscourseError as e:
print e, e.response.text
print(e, e.response.text)
return e.response
return wrapper
@@ -57,8 +58,14 @@ def main():
op.add_option('--api-user', default='system')
op.add_option('-v', '--verbose', action='store_true')
api_key = os.environ['DISCOURSE_API_KEY']
options, args = op.parse_args()
if not options.host.startswith('http'):
op.error('host must include protocol, eg http://')
api_key = os.environ.get('DISCOURSE_API_KEY')
if not api_key:
op.error('please set DISCOURSE_API_KEY')
client = DiscourseClient(options.host, options.api_user, api_key)
if options.verbose:
+96
View File
@@ -0,0 +1,96 @@
"""
Utilities to implement Single Sign On for Discourse with a Python managed
authentication DB
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
Thanks to James Potter for the heavy lifting, detailed at
https://meta.discourse.org/t/sso-example-for-django/14258
A SSO request handler might look something like
@login_required
def discourse_sso_view(request):
payload = request.GET.get('sso')
signature = request.GET.get('sig')
try:
nonce = sso_validate(payload, signature, SECRET)
except DiscourseError as e:
return HTTP400(e.args[0])
url = sso_redirect_url(nonce, SECRET, request.user.email,
request.user.id, request.user.username)
return redirect('http://discuss.example.com' + url)
"""
from base64 import b64encode, b64decode
import hmac
import hashlib
try: # py3
from urllib.parse import unquote, urlencode, parse_qs
except ImportError:
from urllib import unquote, urlencode
from urlparse import parse_qs
from pydiscourse.exceptions import DiscourseError
def sso_validate(payload, signature, secret):
"""
payload: provided by Discourse HTTP call to your SSO endpoint as sso GET param
signature: provided by Discourse HTTP call to your SSO endpoint as sig GET param
secret: the secret key you entered into Discourse sso secret
return value: The nonce used by discourse to validate the redirect URL
"""
if None in [payload, signature]:
raise DiscourseError('No SSO payload or signature.')
if not secret:
raise DiscourseError('Invalid secret..')
payload = unquote(payload)
if not payload:
raise DiscourseError('Invalid payload..')
decoded = b64decode(payload.encode('utf-8')).decode('utf-8')
if 'nonce' not in decoded:
raise DiscourseError('Invalid payload..')
h = hmac.new(secret.encode('utf-8'), payload.encode('utf-8'), digestmod=hashlib.sha256)
this_signature = h.hexdigest()
if this_signature != signature:
raise DiscourseError('Payload does not match signature.')
# Discourse returns querystring encoded value. We only need `nonce`
qs = parse_qs(decoded)
return qs['nonce'][0]
def sso_payload(secret, **kwargs):
return_payload = b64encode(urlencode(kwargs).encode('utf-8'))
h = hmac.new(secret.encode('utf-8'), return_payload, digestmod=hashlib.sha256)
query_string = urlencode({'sso': return_payload, 'sig': h.hexdigest()})
return query_string
def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
"""
nonce: returned by sso_validate()
secret: the secret key you entered into Discourse sso secret
user_email: email address of the user who logged in
user_id: the internal id of the logged in user
user_username: username of the logged in user
return value: URL to redirect users back to discourse, now logged in as user_username
"""
kwargs.update({
'nonce': nonce,
'email': email,
'external_id': external_id,
'username': username
})
return '/session/sso_login?%s' % sso_payload(secret, **kwargs)
-3
View File
@@ -1,3 +0,0 @@
requests
nose
mock
+2
View File
@@ -0,0 +1,2 @@
[wheel]
universal = 1
+24 -27
View File
@@ -1,40 +1,33 @@
import codecs
import os
from setuptools import setup, find_packages
def read(fname):
return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read()
README = open('README.rst').read()
HISTORY = open('HISTORY.rst').read().replace('.. :changelog:', '')
# Provided as an attribute, so you can append to these instead
# of replicating them:
standard_exclude = ["*.py", "*.pyc", "*$py.class", "*~", ".*", "*.bak"]
standard_exclude_directories = [
".*", "CVS", "_darcs", "./build", "./dist", "EGG-INFO", "*.egg-info"
]
NAME = "pydiscourse"
DESCRIPTION = "A Python library for the Discourse API"
AUTHOR = "Marc Sibson"
AUTHOR_EMAIL = "sibson@gmail.com"
URL = "https://github.com/tindie/pydiscourse"
PACKAGE = "pydiscourse"
VERSION = __import__(PACKAGE).__version__
with open("pydiscourse/__init__.py", "r") as module_file:
for line in module_file:
if line.startswith("__version__"):
version_string = line.split("=")[1]
VERSION = version_string.strip().replace("'", "")
setup(
name=NAME,
name="pydiscourse",
version=VERSION,
description=DESCRIPTION,
long_description=read("README.md"),
author=AUTHOR,
author_email=AUTHOR_EMAIL,
description="A Python library for the Discourse API",
long_description=README + '\n\n' + HISTORY,
author="Marc Sibson and contributors",
author_email="ben+pydiscourse@benlopatin.com",
license="BSD",
url=URL,
url="https://github.com/bennylope/pydiscourse",
packages=find_packages(exclude=["tests.*", "tests"]),
install_requires=[
'requests>=2.0.0',
],
tests_require=[
'mock',
],
test_suite='tests',
entry_points={
'console_scripts': [
'pydiscoursecli = pydiscourse.main:main'
@@ -47,6 +40,10 @@ setup(
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
'Programming Language :: Python :: Implementation :: PyPy',
],
zip_safe=False,
)
View File
+83 -4
View File
@@ -1,8 +1,18 @@
import sys
import unittest
import mock
from pydiscourse import client
import sys
if sys.version_info < (3,):
def b(x):
return x
else:
import codecs
def b(x):
return codecs.latin_1_encode(x)[0]
def prepare_response(request):
# we need to mocked response to look a little more real
@@ -10,8 +20,12 @@ def prepare_response(request):
class ClientBaseTestCase(unittest.TestCase):
"""
"""
def setUp(self):
self.host = 'testhost'
self.host = 'http://testhost'
self.api_username = 'testuser'
self.api_key = 'testkey'
@@ -31,6 +45,32 @@ class ClientBaseTestCase(unittest.TestCase):
self.assertEqual(kwargs, params)
class TestClientRequests(ClientBaseTestCase):
"""
Tests for common request handling
"""
@mock.patch('pydiscourse.client.requests')
def test_empty_content_http_ok(self, mocked_requests):
"""Empty content should not raise error
Critical to test against *bytestrings* rather than unicode
"""
mocked_response = mock.MagicMock()
mocked_response.content = b(' ')
mocked_response.status_code = 200
mocked_response.headers = {"content-type": "text/plain; charset=utf-8"}
assert "content-type" in mocked_response.headers
mocked_requests.request = mock.MagicMock()
mocked_requests.request.return_value = mocked_response
resp = self.client._request('GET', '/users/admin/1/unsuspend', {})
self.assertIsNone(resp)
@mock.patch('requests.request')
class TestUser(ClientBaseTestCase):
@@ -59,7 +99,31 @@ class TestUser(ClientBaseTestCase):
def test_update_username(self, request):
prepare_response(request)
self.client.update_username('someuser', 'newname')
self.assertRequestCalled(request, 'PUT', '/users/someuser/preferences/username', username='newname')
self.assertRequestCalled(request, 'PUT',
'/users/someuser/preferences/username',
username='newname')
def test_by_external_id(self, request):
prepare_response(request)
self.client.by_external_id(123)
self.assertRequestCalled(request, 'GET',
'/users/by-external/123')
def test_suspend_user(self, request):
prepare_response(request)
self.client.suspend(123, 1, "Testing")
self.assertRequestCalled(request, 'PUT', '/admin/users/123/suspend',
duration=1, reason="Testing")
def test_unsuspend_user(self, request):
prepare_response(request)
self.client.unsuspend(123)
self.assertRequestCalled(request, 'PUT', '/admin/users/123/unsuspend')
def test_user_bagdes(self, request):
prepare_response(request)
self.client.user_badges('username')
self.assertRequestCalled(request, 'GET', '/user-badges/{}.json'.format('username'))
@mock.patch('requests.request')
@@ -82,8 +146,8 @@ class TestTopics(ClientBaseTestCase):
def test_topic(self, request):
prepare_response(request)
self.client.topic(22)
self.assertRequestCalled(request, 'GET', '/t/22.json')
self.client.topic('some-test-slug', 22)
self.assertRequestCalled(request, 'GET', '/t/some-test-slug/22.json')
def test_topics_by(self, request):
prepare_response(request)
@@ -111,3 +175,18 @@ class MiscellaneousTests(ClientBaseTestCase):
r = self.client.categories()
self.assertRequestCalled(request, 'GET', '/categories.json')
self.assertEqual(r, request().json()['category_list']['categories'])
def test_users(self, request):
prepare_response(request)
self.client.users()
self.assertRequestCalled(request, 'GET', '/admin/users/list/active.json')
def test_badges(self, request):
prepare_response(request)
self.client.badges()
self.assertRequestCalled(request, 'GET', '/admin/badges.json')
def test_grant_badge_to(self, request):
prepare_response(request)
self.client.grant_badge_to('username', 1)
self.assertRequestCalled(request, 'POST', '/user_badges', username='username', badge_id=1)
+74
View File
@@ -0,0 +1,74 @@
from base64 import b64decode
try: # py26
import unittest2 as unittest
except ImportError:
import unittest
try: # py3
from urllib.parse import unquote
from urllib.parse import urlparse, parse_qs
except ImportError:
from urlparse import urlparse, parse_qs
from urllib import unquote
from pydiscourse import sso
from pydiscourse.exceptions import DiscourseError
class SSOTestCase(unittest.TestCase):
def setUp(self):
# values from https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
self.secret = 'd836444a9e4084d5b224a60c208dce14'
self.nonce = 'cb68251eefb5211e58c00ff1395f0c0b'
self.payload = 'bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A'
self.signature = '2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56'
self.name = u'sam'
self.username = u'samsam'
self.external_id = u'hello123'
self.email = u'test@test.com'
self.redirect_url = u'/session/sso_login?sso=bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImbmFtZT1z%0AYW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b'
def test_missing_args(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(None, self.signature, self.secret)
with self.assertRaises(DiscourseError):
sso.sso_validate('', self.signature, self.secret)
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, None, self.secret)
def test_invalid_signature(self):
with self.assertRaises(DiscourseError):
sso.sso_validate(self.payload, 'notavalidsignature', self.secret)
def test_valid_nonce(self):
nonce = sso.sso_validate(self.payload, self.signature, self.secret)
self.assertEqual(nonce, self.nonce)
def test_valid_redirect_url(self):
url = sso.sso_redirect_url(self.nonce, self.secret, self.email, self.external_id, self.username, name='sam')
self.assertIn('/session/sso_login', url[:20])
# check its valid, using our own handy validator
params = parse_qs(urlparse(url).query)
payload = params['sso'][0]
sso.sso_validate(payload, params['sig'][0], self.secret)
# check the params have all the data we expect
payload = b64decode(payload.encode('utf-8')).decode('utf-8')
payload = unquote(payload)
payload = dict((p.split('=') for p in payload.split('&')))
self.assertEqual(payload, {
'username': self.username,
'nonce': self.nonce,
'external_id': self.external_id,
'name': self.name,
'email': self.email
})
+21
View File
@@ -0,0 +1,21 @@
[tox]
envlist = py27, py34, py35, pypy, pypy3
[testenv]
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
commands = python setup.py test
[testenv:flake8]
basepython=python
deps=
flake8
flake8_docstrings
commands=
flake8 pydiscourse
[flake8]
ignore = E126,E128
max-line-length = 99
exclude = .ropeproject
max-complexity = 10