Compare commits
73 Commits
f/objectapi
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| acdcb03283 | |||
| 6bd73fdd5c | |||
| c13b456b79 | |||
| 977885967d | |||
| b9066ca637 | |||
| 77254f441c | |||
| 66089011f7 | |||
| fe317b6be8 | |||
| 15e82aacd1 | |||
| 008f21d6fe | |||
| 6baf51bbe1 | |||
| 8304e7b2f5 | |||
| 5806beef34 | |||
| f905a957f4 | |||
| 06ca2c5a58 | |||
| bde4325776 | |||
| 6b7e570475 | |||
| 3659724f11 | |||
| 1e151fc51f | |||
| 63f120ddca | |||
| 9cb96eaf76 | |||
| c5207759a8 | |||
| b14cd502ce | |||
| 3a3bb843e5 | |||
| a2f961aebb | |||
| bd508cdcee | |||
| 46051fd248 | |||
| eed4df564d | |||
| 6ac6a1fd2d | |||
| adf3f2ddbc | |||
| 2daebbfa23 | |||
| 9a23db7e43 | |||
| 3be87f19dd | |||
| b7d4286c44 | |||
| c155c8a60d | |||
| dd8d9562ff | |||
| c6a43f0304 | |||
| ca52920690 | |||
| db934494e2 | |||
| 6dbbe74776 | |||
| 04bb9c550b | |||
| 65e398343b | |||
| df1d274a33 | |||
| 9cc771f381 | |||
| 66cd4ab5de | |||
| bd79423ba9 | |||
| 65abb8119f | |||
| f0f3256e01 | |||
| ab00eb9cf7 | |||
| 3ccf20212e | |||
| e008d1865e | |||
| 5506eacaeb | |||
| 29cb1ce14b | |||
| 4e69083284 | |||
| 05a58b1d62 | |||
| cc03b5cc08 | |||
| 385e35b322 | |||
| 9c6097a3d4 | |||
| 204eb1478a | |||
| e64b990dc8 | |||
| e358300085 | |||
| b8cb201652 | |||
| cba141724d | |||
| d64651e655 | |||
| 9d5d835910 | |||
| 9eb5f3466b | |||
| d66e636078 | |||
| a381089497 | |||
| aaa18ee0c6 | |||
| a07280975a | |||
| cb0244652d | |||
| 4a56a43bd5 | |||
| 4a366a1b97 |
+7
-7
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
(Based on original authors list and may be incomplete)
|
||||
|
||||
Marc Sibson
|
||||
James Potter
|
||||
Ben Lopatin
|
||||
Daniel Zohar
|
||||
Matheus Fernandes
|
||||
Scott Nixon
|
||||
@@ -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
@@ -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"
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
.. :changelog:
|
||||
|
||||
Release history
|
||||
===============
|
||||
|
||||
0.6.0
|
||||
-----
|
||||
|
||||
* Adds method to add user to group by user ID
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
include setup.py
|
||||
include README.rst
|
||||
include MANIFEST.in
|
||||
include HISTORY.rst
|
||||
include LICENSE
|
||||
recursive-include pydiscourse
|
||||
@@ -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}'
|
||||
@@ -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
@@ -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
@@ -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.6'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.1'
|
||||
release = '0.6.0'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
Development
|
||||
===============
|
||||
.. include:: ../DEVELOP.md
|
||||
:start-line: 2
|
||||
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
:start-line: 3
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
Introduction
|
||||
==============
|
||||
.. include:: ../README.md
|
||||
:start-line: 2
|
||||
|
||||
.. include:: ../README.rst
|
||||
:start-line: 3
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
pydiscourse
|
||||
===========
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
pydiscourse
|
||||
@@ -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:
|
||||
@@ -1 +1,5 @@
|
||||
__version__ = '0.1.0.dev'
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
__version__ = '0.6.0'
|
||||
|
||||
from pydiscourse.client import DiscourseClient
|
||||
|
||||
Executable → Regular
+721
-32
@@ -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,339 @@ 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 add_user_to_group(self, groupid, userid):
|
||||
"""
|
||||
Add a member to a group by with user id.
|
||||
|
||||
Args:
|
||||
groupid: the ID of the group
|
||||
userid: the member id
|
||||
|
||||
Returns:
|
||||
JSON API response
|
||||
|
||||
Raises:
|
||||
DiscourseError if user is already member of group
|
||||
|
||||
"""
|
||||
return self._post("/admin/users/{0}/groups".format(userid), group_id=groupid)
|
||||
|
||||
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 +870,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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -1,3 +0,0 @@
|
||||
requests
|
||||
nose
|
||||
mock
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+83
-4
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user