Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7898ff3ff1 | |||
| 9709744b33 | |||
| 22e236a009 | |||
| 0857a9cfe7 | |||
| 30e2068b4d | |||
| 227924f098 | |||
| 201ff3d717 | |||
| 393422f964 | |||
| d9e0af7e59 | |||
| 227c3fb469 | |||
| 0378a38d87 | |||
| 5c12e06e58 | |||
| 27363e5fa7 | |||
| 8be16c34ff | |||
| 8971629bcb | |||
| 7e237c6b68 | |||
| 062904fd2a | |||
| 0d1eb1b816 | |||
| 457abc559f | |||
| 3b7b7d2490 | |||
| 1d74e2f1b7 | |||
| e254926726 | |||
| ff40a1c8c0 | |||
| 7054b9118f | |||
| b5ccf244a5 | |||
| 901a53a10d | |||
| 71cb943e55 | |||
| f6b4c02fc0 | |||
| 4eaff3a790 | |||
| 864b1b047f | |||
| 69bdc5f76f | |||
| 27c76de371 | |||
| baaa049dc6 | |||
| bbe216ef8c | |||
| d9a5c081a9 | |||
| f61ffdbcdb | |||
| 8a0e742abd | |||
| 1a22796e8e | |||
| 0177c46356 | |||
| ef5f8523d8 | |||
| 96f9ea4b50 | |||
| 9b11c7d06a | |||
| 20c1915cbe | |||
| d5b9aacf01 | |||
| bc8a2907b9 | |||
| 1f595c3e7f | |||
| 188decb02a | |||
| f4bd3e3b17 | |||
| f74722dfb8 | |||
| d101264391 | |||
| 3e94eaee05 | |||
| 5763ba6ee8 | |||
| 099993a379 | |||
| d887772b30 | |||
| e5d1ef2f02 | |||
| ee2769d0b9 | |||
| b2f6e1df96 | |||
| 0008bfdf0a | |||
| 2eb6d672a0 | |||
| a68cb0244f | |||
| c0566f2aad | |||
| fc6a78c948 | |||
| df30e1acc8 | |||
| ec730ec026 | |||
| 7fce4dc129 | |||
| 2bdfdb85ec | |||
| f27ed47206 | |||
| cc9f35b5f3 | |||
| 689e0981a0 | |||
| 712f9282b1 | |||
| b69a142811 | |||
| ce7038b05d | |||
| ffbd47868d | |||
| 5040b24dcc | |||
| e7906a0568 | |||
| 11a82695c5 | |||
| f1e7ee069c | |||
| 6e31953118 | |||
| 2ad158e195 | |||
| 719035e9a9 | |||
| 3a4af08827 | |||
| 5d334f1d80 | |||
| 361bf77949 | |||
| aeb763c42c | |||
| 69867b3c10 | |||
| c3ae5b3c76 | |||
| d02ab15d3f | |||
| 9a8641e596 | |||
| 802f018519 | |||
| be74c4e5b7 | |||
| 9198a1d549 | |||
| 53cc24744f | |||
| 9dcf5832b6 | |||
| 10c27d6338 | |||
| 89f12f707b | |||
| 0cef55a02f | |||
| faa8895321 | |||
| e434edb2ea | |||
| 9601d96701 | |||
| d5ce2d78dc | |||
| 84b59d2e4f | |||
| 1970e53059 | |||
| 0c7b60fef8 | |||
| de6e758be6 | |||
| 0f2efa8e74 |
@@ -0,0 +1,3 @@
|
||||
c0db7215c95dbd31770ade1fc6ea65aa426d4590
|
||||
0177c46356b9d0fc4b93f09aab7a224643a3685e
|
||||
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Publish package to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools wheel twine
|
||||
|
||||
- name: Build and publish distribution
|
||||
if: (github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags')) || github.event_name == 'release'
|
||||
env:
|
||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
||||
run: |
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Tests
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
name: Test on Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox tox-gh-actions
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
@@ -36,3 +36,9 @@ coverage.xml
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# Pyenv
|
||||
.python-version
|
||||
|
||||
# PyCharm
|
||||
.idea
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
sudo: false
|
||||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- "pypy"
|
||||
- "pypy3"
|
||||
|
||||
script: python setup.py test
|
||||
@@ -9,4 +9,7 @@ Scott Nixon
|
||||
Jason Dorweiler
|
||||
Pierre-Alain Dupont
|
||||
Karl Goetz
|
||||
|
||||
Alex Kerney
|
||||
Gustav <https://github.com/dkgv>
|
||||
Sebastian2023 <https://github.com/Sebastian2023>
|
||||
Dominik George <https://github.com/Natureshadow>
|
||||
+6
-3
@@ -43,7 +43,7 @@ Or it's slightly faster cousin `detox
|
||||
|
||||
Alternatively, you can run the self test with the following commands::
|
||||
|
||||
pip install -r requirements.dev.txt
|
||||
pip install -r requirements.txt
|
||||
pip install -e .
|
||||
python setup.py test
|
||||
|
||||
@@ -69,5 +69,8 @@ Once running you can access the Discourse install at http://localhost:4000.
|
||||
TODO
|
||||
====
|
||||
|
||||
Refer to, https://github.com/discourse/discourse_api/blob/master/routes.txt for
|
||||
a list of all operations available in Discourse.
|
||||
For a list of all operations:
|
||||
|
||||
you can just run rake routes inside of the discourse repo to get an up to date list
|
||||
|
||||
Or check the old [`routes.txt`](https://github.com/discourse/discourse_api/blob/aa75df6cd851f0666f9e8071c4ef9dfdd39fc8f8/routes.txt) file, though this is certainly outdated.
|
||||
|
||||
+41
@@ -3,6 +3,47 @@
|
||||
Release history
|
||||
===============
|
||||
|
||||
1.3.0
|
||||
-----
|
||||
|
||||
- Add fix for handling global Discourse timeouts
|
||||
- Add group owners
|
||||
- Update API for add_group_owner
|
||||
|
||||
1.2.0
|
||||
-----
|
||||
|
||||
- BREAKING? Dropped support for Python 2.7, 3.4, 3.5
|
||||
- Added numerous new endpoint queries
|
||||
- Updated category querying
|
||||
|
||||
1.1.2
|
||||
-----
|
||||
|
||||
- Fix for Discourse users API change
|
||||
|
||||
1.1.1
|
||||
-----
|
||||
|
||||
- Fix for empty dictionary and 413 API response
|
||||
- Fix for getting member groups
|
||||
|
||||
1.1.0
|
||||
-----
|
||||
|
||||
- Added ability to follow redirects in requests
|
||||
|
||||
1.0.0
|
||||
-----
|
||||
|
||||
- Authenticate with headers
|
||||
|
||||
0.9.0
|
||||
-----
|
||||
|
||||
- Added rate limiting support
|
||||
- Added some support for user activation
|
||||
|
||||
0.8.0
|
||||
-----
|
||||
|
||||
|
||||
+7
-2
@@ -2,9 +2,14 @@
|
||||
pydiscourse
|
||||
===========
|
||||
|
||||
.. image:: https://secure.travis-ci.org/bennylope/pydiscourse.svg?branch=master
|
||||
.. image:: https://github.com/bennylope/pydiscourse/workflows/Tests/badge.svg
|
||||
:alt: Build Status
|
||||
:target: http://travis-ci.org/bennylope/pydiscourse
|
||||
:target: https://github.com/bennylope/pydiscourse/actions
|
||||
|
||||
.. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg
|
||||
:alt: Check out the Docs
|
||||
:target: https://discourse.readthedocs.io/en/latest/
|
||||
|
||||
|
||||
A Python library for working with Discourse.
|
||||
|
||||
|
||||
+2
-2
@@ -51,9 +51,9 @@ copyright = u'2014, Marc Sibson'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.8'
|
||||
version = '1.1'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.8.0'
|
||||
release = '1.1.1'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
__version__ = "0.8.0"
|
||||
|
||||
from pydiscourse.client import DiscourseClient
|
||||
@@ -1,13 +0,0 @@
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
|
||||
class DiscourseError(HTTPError):
|
||||
""" A generic error while attempting to communicate with Discourse """
|
||||
|
||||
|
||||
class DiscourseServerError(DiscourseError):
|
||||
""" The Discourse Server encountered an error while processing the request """
|
||||
|
||||
|
||||
class DiscourseClientError(DiscourseError):
|
||||
""" An invalid request has been made """
|
||||
@@ -0,0 +1,2 @@
|
||||
pytest==6.2.5
|
||||
pytest-cov==3.0.0
|
||||
@@ -1,2 +1,47 @@
|
||||
[wheel]
|
||||
[metadata]
|
||||
name = pydiscourse
|
||||
version = attr: pydiscourse.__version__
|
||||
author = "Marc Sibson and contributors"
|
||||
author_email = "ben@benlopatin.com"
|
||||
license = "MIT"
|
||||
url = https://github.com/bennylope/pydiscourse
|
||||
description = "A Python library for the Discourse API"
|
||||
long_description = file: README.rst, HISTORY.rst
|
||||
platforms =
|
||||
OS Independent
|
||||
|
||||
[options]
|
||||
zip_safe = False
|
||||
include_package_data = True
|
||||
packages = find:
|
||||
package_dir =
|
||||
=src
|
||||
install_requires =
|
||||
requests>=2.4.2
|
||||
typing; python_version<"3.6"
|
||||
classifiers =
|
||||
Development Status :: 5 - Production/Stable
|
||||
Environment :: Web Environment
|
||||
Intended Audience :: Developers
|
||||
License :: OSI Approved :: MIT License
|
||||
Operating System :: OS Independent
|
||||
Programming Language :: Python
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
|
||||
[options.packages.find]
|
||||
where=src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
pydiscoursecli = pydiscourse.main:main
|
||||
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[build-system]
|
||||
requires =
|
||||
setuptools >= "40.9.0"
|
||||
wheel
|
||||
|
||||
@@ -1,49 +1,7 @@
|
||||
from setuptools import setup, find_packages
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
See setup.cfg for packaging settings
|
||||
"""
|
||||
|
||||
|
||||
README = open('README.rst').read()
|
||||
HISTORY = open('HISTORY.rst').read().replace('.. :changelog:', '')
|
||||
|
||||
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="pydiscourse",
|
||||
version=VERSION,
|
||||
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="https://github.com/bennylope/pydiscourse",
|
||||
packages=find_packages(exclude=["tests.*", "tests"]),
|
||||
install_requires=[
|
||||
'requests>=2.4.2',
|
||||
],
|
||||
tests_require=[
|
||||
'mock',
|
||||
],
|
||||
test_suite='tests',
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'pydiscoursecli = pydiscourse.main:main'
|
||||
]
|
||||
},
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Developers",
|
||||
"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,
|
||||
)
|
||||
from setuptools import setup
|
||||
setup()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Python client for the Discourse API."""
|
||||
|
||||
__version__ = "1.3.0"
|
||||
|
||||
from pydiscourse.client import DiscourseClient
|
||||
|
||||
|
||||
__all__ = ["DiscourseClient"]
|
||||
@@ -3,11 +3,17 @@ Core API client module
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from pydiscourse.exceptions import (
|
||||
DiscourseError, DiscourseServerError, DiscourseClientError
|
||||
DiscourseError,
|
||||
DiscourseServerError,
|
||||
DiscourseClientError,
|
||||
DiscourseRateLimitedError,
|
||||
)
|
||||
from pydiscourse.sso import sso_payload
|
||||
|
||||
@@ -58,6 +64,15 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._get("/users/{0}.json".format(username))["user"]
|
||||
|
||||
def approve(self, user_id):
|
||||
return self._get("/admin/users/{0}/approve.json".format(user_id))
|
||||
|
||||
def activate(self, user_id):
|
||||
return self._put("/admin/users/{0}/activate.json".format(user_id))
|
||||
|
||||
def deactivate(self, user_id):
|
||||
return self._put("/admin/users/{0}/deactivate.json".format(user_id))
|
||||
|
||||
def user_all(self, user_id):
|
||||
"""
|
||||
Get all user information for a specific user, needs to be admin
|
||||
@@ -69,6 +84,74 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._get("/admin/users/{0}.json".format(user_id))
|
||||
|
||||
def invite(self, email, group_names, custom_message, **kwargs):
|
||||
"""
|
||||
Invite a user by email to join your forum
|
||||
|
||||
Args:
|
||||
email: their email, will be used for activation and summary emails
|
||||
group_names: the group names
|
||||
custom_message: message to include
|
||||
**kwargs: ???? what else can be sent through?
|
||||
|
||||
Returns:
|
||||
API response body (dict)
|
||||
|
||||
"""
|
||||
return self._post(
|
||||
"/invites",
|
||||
email=email,
|
||||
group_names=group_names,
|
||||
custom_message=custom_message,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def invite_link(self, email, group_names, custom_message, **kwargs):
|
||||
"""
|
||||
Generate an invite link for a user to join your forum
|
||||
|
||||
Args:
|
||||
email: their email, will be used for activation and summary emails
|
||||
group_names: the group names
|
||||
custom_message: message to include
|
||||
**kwargs: ???? what else can be sent through?
|
||||
|
||||
Returns:
|
||||
Invite link
|
||||
|
||||
"""
|
||||
return self._post(
|
||||
"/invites/link",
|
||||
email=email,
|
||||
group_names=group_names,
|
||||
custom_message=custom_message,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def user_by_id(self, pk):
|
||||
"""
|
||||
Get user from ID
|
||||
|
||||
Args:
|
||||
pk: user id
|
||||
|
||||
Returns:
|
||||
user
|
||||
"""
|
||||
return self._get("/admin/users/{0}.json".format(pk))
|
||||
|
||||
def user_by_email(self, email):
|
||||
"""
|
||||
Get user from email
|
||||
|
||||
Args:
|
||||
email: user email
|
||||
|
||||
Returns:
|
||||
user
|
||||
"""
|
||||
return self._get("/admin/users/list/all.json?email={0}".format(email))
|
||||
|
||||
def create_user(self, name, username, email, password, **kwargs):
|
||||
"""
|
||||
Create a Discourse user
|
||||
@@ -88,7 +171,7 @@ class DiscourseClient(object):
|
||||
????
|
||||
|
||||
"""
|
||||
r = self._get("/users/hp.json")
|
||||
r = self._get("/session/hp.json")
|
||||
challenge = r["challenge"][::-1] # reverse challenge, discourse security check
|
||||
confirmations = r["value"]
|
||||
return self._post(
|
||||
@@ -153,8 +236,11 @@ class DiscourseClient(object):
|
||||
????
|
||||
|
||||
"""
|
||||
suspend_until = (datetime.now() + timedelta(days=duration)).isoformat()
|
||||
return self._put(
|
||||
"/admin/users/{0}/suspend".format(userid), duration=duration, reason=reason
|
||||
"/admin/users/{0}/suspend".format(userid),
|
||||
suspend_until=suspend_until,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def unsuspend(self, userid):
|
||||
@@ -309,7 +395,7 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._put(
|
||||
"/users/{0}/preferences/username".format(username),
|
||||
username=new_username,
|
||||
new_username=new_username,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -416,6 +502,25 @@ class DiscourseClient(object):
|
||||
"/topics/private-messages-unread/{0}.json".format(username), **kwargs
|
||||
)
|
||||
|
||||
def category_topics(self, category_id, **kwargs):
|
||||
"""
|
||||
Returns a list of all topics in a category.
|
||||
|
||||
Args:
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
JSON API response
|
||||
|
||||
"""
|
||||
return self._get(
|
||||
"/c/{0}.json".format(category_id),
|
||||
override_request_kwargs={"allow_redirects": True},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Doesn't work on recent Discourse versions (2014+)
|
||||
# https://github.com/discourse/discourse_api/pull/204
|
||||
def hot_topics(self, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -427,6 +532,15 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._get("/hot.json", **kwargs)
|
||||
|
||||
def top_topics(self, **kwargs):
|
||||
"""
|
||||
Get top topics
|
||||
|
||||
Returns:
|
||||
List of top topics
|
||||
"""
|
||||
return self._get("/top.json", **kwargs)
|
||||
|
||||
def latest_topics(self, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -462,6 +576,20 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._get("/t/{0}/{1}.json".format(slug, topic_id), **kwargs)
|
||||
|
||||
def delete_topic(self, topic_id, **kwargs):
|
||||
"""
|
||||
Remove a topic
|
||||
|
||||
Args:
|
||||
category_id:
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
JSON API response
|
||||
|
||||
"""
|
||||
return self._delete(u"/t/{0}".format(topic_id), **kwargs)
|
||||
|
||||
def post(self, topic_id, post_id, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -475,6 +603,36 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._get("/t/{0}/{1}.json".format(topic_id, post_id), **kwargs)
|
||||
|
||||
def post_action_users(self, post_id, post_action_type_id=None, **kwargs):
|
||||
"""
|
||||
|
||||
Args:
|
||||
post_id: int
|
||||
post_action_type_id: Optional[int]
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
# https://meta.discourse.org/t/getting-who-liked-a-post-from-the-api/103618
|
||||
|
||||
kwargs["id"] = post_id
|
||||
if post_action_type_id is not None:
|
||||
kwargs["post_action_type_id"] = post_action_type_id
|
||||
return self._get("/post_action_users", **kwargs)
|
||||
|
||||
def post_by_id(self, post_id, **kwargs):
|
||||
"""
|
||||
Get a post from its id
|
||||
Args:
|
||||
post_id: id of the post
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
post
|
||||
"""
|
||||
return self._get("/posts/{0}.json".format(post_id), **kwargs)
|
||||
|
||||
def posts(self, topic_id, post_ids=None, **kwargs):
|
||||
"""
|
||||
Get a set of posts from a topic
|
||||
@@ -491,6 +649,21 @@ class DiscourseClient(object):
|
||||
kwargs["post_ids[]"] = post_ids
|
||||
return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
|
||||
|
||||
def latest_posts(self, before=None, **kwargs):
|
||||
"""
|
||||
List latest posts across topics
|
||||
|
||||
Args:
|
||||
before: Load posts with an id lower than this value. Useful for pagination.
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
if before:
|
||||
kwargs["before"] = before
|
||||
return self._get("/posts.json", **kwargs)
|
||||
|
||||
def topic_timings(self, topic_id, time, timings={}, **kwargs):
|
||||
"""
|
||||
Set time spent reading a post
|
||||
@@ -527,7 +700,7 @@ class DiscourseClient(object):
|
||||
|
||||
def update_topic(self, topic_url, title, **kwargs):
|
||||
"""
|
||||
Update a topic
|
||||
Update a topic
|
||||
|
||||
Args:
|
||||
topic_url:
|
||||
@@ -603,6 +776,14 @@ class DiscourseClient(object):
|
||||
kwargs["post[edit_reason]"] = edit_reason
|
||||
return self._put("/posts/{0}".format(post_id), **kwargs)
|
||||
|
||||
def reset_bump_date(self, topic_id, **kwargs):
|
||||
"""
|
||||
Reset bump date
|
||||
|
||||
See https://meta.discourse.org/t/what-is-a-bump/105562
|
||||
"""
|
||||
return self._put("/t/{0}/reset-bump-date".format(topic_id), **kwargs)
|
||||
|
||||
def topics_by(self, username, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -702,7 +883,7 @@ class DiscourseClient(object):
|
||||
text_color: hex color without number symbol
|
||||
permissions: dict of 'everyone', 'admins', 'moderators', 'staff' with values of ???
|
||||
parent: name of the category
|
||||
parent_category_id:
|
||||
parent_category_id:
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
@@ -743,21 +924,18 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._get("/categories.json", **kwargs)["category_list"]["categories"]
|
||||
|
||||
def category(self, name, parent=None, **kwargs):
|
||||
def category(self, category_id, parent=None, **kwargs):
|
||||
"""
|
||||
|
||||
Args:
|
||||
name:
|
||||
parent:
|
||||
category_id:
|
||||
**kwargs:
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
if parent:
|
||||
name = u"{0}/{1}".format(parent, name)
|
||||
|
||||
return self._get(u"/category/{0}.json".format(name), **kwargs)
|
||||
return self._get(u"/c/{0}/show.json".format(category_id), **kwargs)
|
||||
|
||||
def delete_category(self, category_id, **kwargs):
|
||||
"""
|
||||
@@ -772,12 +950,32 @@ class DiscourseClient(object):
|
||||
"""
|
||||
return self._delete(u"/categories/{0}".format(category_id), **kwargs)
|
||||
|
||||
def get_site_info(self):
|
||||
"""
|
||||
Get site info to fetch all categories and subcategories
|
||||
"""
|
||||
return self._get("/site.json")
|
||||
|
||||
def get_site_settings(self):
|
||||
"""
|
||||
Get site settings
|
||||
"""
|
||||
return self._get("/admin/site_settings.json")
|
||||
|
||||
def category_latest_topics(self, name, parent=None, **kwargs):
|
||||
"""
|
||||
Get latest topics from a category
|
||||
"""
|
||||
if parent:
|
||||
name = u"{0}/{1}".format(parent, name)
|
||||
return self._get(u"/c/{0}/l/latest.json".format(name), **kwargs)
|
||||
|
||||
def site_settings(self, **kwargs):
|
||||
"""
|
||||
Update site settings
|
||||
|
||||
Args:
|
||||
settings:
|
||||
**kwargs:
|
||||
**kwargs: key-value of properties to update
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -850,13 +1048,13 @@ class DiscourseClient(object):
|
||||
]
|
||||
|
||||
"""
|
||||
return self._get("/admin/groups.json", **kwargs)
|
||||
return self._get("/groups/search.json", **kwargs)
|
||||
|
||||
def group(self, group_name):
|
||||
"""
|
||||
Get all infos of a group by group name
|
||||
"""
|
||||
return self._get("/groups/{0}/members.json".format(group_name))
|
||||
return self._get("/groups/{0}.json".format(group_name))
|
||||
|
||||
def create_group(
|
||||
self,
|
||||
@@ -936,7 +1134,24 @@ class DiscourseClient(object):
|
||||
|
||||
"""
|
||||
return self._put(
|
||||
"/admin/groups/{0}/owners.json".format(groupid), usernames=username
|
||||
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": username}
|
||||
)
|
||||
|
||||
def add_group_owners(self, groupid, usernames):
|
||||
"""
|
||||
Add a list of owners to a group by usernames
|
||||
|
||||
Args:
|
||||
groupid: the ID of the group
|
||||
username: the list of new owner usernames
|
||||
|
||||
Returns:
|
||||
JSON API response
|
||||
|
||||
"""
|
||||
usernames = ",".join(usernames)
|
||||
return self._put(
|
||||
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": usernames}
|
||||
)
|
||||
|
||||
def delete_group_owner(self, groupid, userid):
|
||||
@@ -1192,7 +1407,29 @@ class DiscourseClient(object):
|
||||
kwargs["parent_tag_name"] = parent_tag_name
|
||||
return self._post("/tag_groups", json=True, **kwargs)["tag_group"]
|
||||
|
||||
def _get(self, path, **kwargs):
|
||||
def data_explorer_query(self, query_id, **kwargs):
|
||||
"""
|
||||
Run a query with database explorer plugin.
|
||||
Requires discourse-data-explorer installed
|
||||
https://github.com/discourse/discourse-data-explorer
|
||||
"""
|
||||
return self._post(
|
||||
"/admin/plugins/explorer/queries/{}/run".format(query_id), **kwargs
|
||||
)
|
||||
|
||||
def notifications(self, category_id, **kwargs):
|
||||
"""
|
||||
Get notifications
|
||||
|
||||
Args:
|
||||
category_id
|
||||
**kwargs:
|
||||
notification_level=(int)
|
||||
|
||||
"""
|
||||
return self._post("/category/{}/notifications".format(category_id), **kwargs)
|
||||
|
||||
def _get(self, path, override_request_kwargs=None, **kwargs):
|
||||
"""
|
||||
|
||||
Args:
|
||||
@@ -1202,9 +1439,11 @@ class DiscourseClient(object):
|
||||
Returns:
|
||||
|
||||
"""
|
||||
return self._request(GET, path, params=kwargs)
|
||||
return self._request(
|
||||
GET, path, params=kwargs, override_request_kwargs=override_request_kwargs
|
||||
)
|
||||
|
||||
def _put(self, path, json=False, **kwargs):
|
||||
def _put(self, path, json=False, override_request_kwargs=None, **kwargs):
|
||||
"""
|
||||
|
||||
Args:
|
||||
@@ -1215,12 +1454,18 @@ class DiscourseClient(object):
|
||||
|
||||
"""
|
||||
if not json:
|
||||
return self._request(PUT, path, data=kwargs)
|
||||
return self._request(
|
||||
PUT, path, data=kwargs, override_request_kwargs=override_request_kwargs
|
||||
)
|
||||
|
||||
else:
|
||||
return self._request(PUT, path, json=kwargs)
|
||||
return self._request(
|
||||
PUT, path, json=kwargs, override_request_kwargs=override_request_kwargs
|
||||
)
|
||||
|
||||
def _post(self, path, files={}, json=False, **kwargs):
|
||||
def _post(
|
||||
self, path, files=None, json=False, override_request_kwargs=None, **kwargs
|
||||
):
|
||||
"""
|
||||
|
||||
Args:
|
||||
@@ -1231,12 +1476,24 @@ class DiscourseClient(object):
|
||||
|
||||
"""
|
||||
if not json:
|
||||
return self._request(POST, path, files=files, data=kwargs)
|
||||
return self._request(
|
||||
POST,
|
||||
path,
|
||||
files=files,
|
||||
data=kwargs,
|
||||
override_request_kwargs=override_request_kwargs,
|
||||
)
|
||||
|
||||
else:
|
||||
return self._request(POST, path, files=files, json=kwargs)
|
||||
return self._request(
|
||||
POST,
|
||||
path,
|
||||
files=files,
|
||||
json=kwargs,
|
||||
override_request_kwargs=override_request_kwargs,
|
||||
)
|
||||
|
||||
def _delete(self, path, **kwargs):
|
||||
def _delete(self, path, override_request_kwargs=None, **kwargs):
|
||||
"""
|
||||
|
||||
Args:
|
||||
@@ -1246,9 +1503,20 @@ class DiscourseClient(object):
|
||||
Returns:
|
||||
|
||||
"""
|
||||
return self._request(DELETE, path, params=kwargs)
|
||||
return self._request(
|
||||
DELETE, path, params=kwargs, override_request_kwargs=override_request_kwargs
|
||||
)
|
||||
|
||||
def _request(self, verb, path, params={}, files={}, data={}, json={}):
|
||||
def _request(
|
||||
self,
|
||||
verb,
|
||||
path,
|
||||
params=None,
|
||||
files=None,
|
||||
data=None,
|
||||
json=None,
|
||||
override_request_kwargs=None,
|
||||
):
|
||||
"""
|
||||
Executes HTTP request to API and handles response
|
||||
|
||||
@@ -1256,42 +1524,93 @@ class DiscourseClient(object):
|
||||
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
|
||||
override_request_kwargs: dictionary of requests.request
|
||||
keyword arguments to override defaults
|
||||
|
||||
Returns:
|
||||
dictionary of response body data or None
|
||||
|
||||
"""
|
||||
params["api_key"] = self.api_key
|
||||
if "api_username" not in params:
|
||||
params["api_username"] = self.api_username
|
||||
override_request_kwargs = override_request_kwargs or {}
|
||||
|
||||
url = self.host + path
|
||||
|
||||
headers = {"Accept": "application/json; charset=utf-8"}
|
||||
response = requests.request(
|
||||
verb,
|
||||
url,
|
||||
allow_redirects=False,
|
||||
params=params,
|
||||
files=files,
|
||||
data=data,
|
||||
json=json,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
headers = {
|
||||
"Accept": "application/json; charset=utf-8",
|
||||
"Api-Key": self.api_key,
|
||||
"Api-Username": self.api_username,
|
||||
}
|
||||
|
||||
log.debug("response %s: %s", response.status_code, repr(response.text))
|
||||
if not response.ok:
|
||||
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)
|
||||
# How many times should we retry if rate limited
|
||||
retry_count = 4
|
||||
# Extra time (on top of that required by API) to wait on a retry.
|
||||
retry_backoff = 1
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
raise DiscourseClientError(msg, response=response)
|
||||
while retry_count > 0:
|
||||
request_kwargs = dict(
|
||||
allow_redirects=False,
|
||||
params=params,
|
||||
files=files,
|
||||
data=data,
|
||||
json=json,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
raise DiscourseServerError(msg, response=response)
|
||||
request_kwargs.update(override_request_kwargs)
|
||||
|
||||
response = requests.request(verb, url, **request_kwargs)
|
||||
|
||||
log.debug("response %s: %s", response.status_code, repr(response.text))
|
||||
if response.ok:
|
||||
break
|
||||
if not response.ok:
|
||||
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:
|
||||
if 429 == response.status_code:
|
||||
# This codepath relies on wait_seconds from Discourse v2.0.0.beta3 / v1.9.3 or higher.
|
||||
content_type = response.headers.get("Content-Type")
|
||||
if content_type is not None and "application/json" in content_type:
|
||||
ret = response.json()
|
||||
wait_delay = (
|
||||
retry_backoff + ret["extras"]["wait_seconds"]
|
||||
) # how long to back off for.
|
||||
else:
|
||||
# We got an early 429 error without a proper JSON body
|
||||
ret = response.content
|
||||
wait_delay = retry_backoff + 10
|
||||
|
||||
limit_name = response.headers.get(
|
||||
"Discourse-Rate-Limit-Error-Code", "<unknown>")
|
||||
|
||||
log.info(
|
||||
"We have been rate limited (limit: {2}) and will wait {0} seconds ({1} retries left)".format(
|
||||
wait_delay, retry_count, limit_name
|
||||
)
|
||||
)
|
||||
if retry_count > 1:
|
||||
time.sleep(wait_delay)
|
||||
retry_count -= 1
|
||||
log.debug("API returned {0}".format(ret))
|
||||
continue
|
||||
else:
|
||||
raise DiscourseClientError(msg, response=response)
|
||||
|
||||
# Any other response.ok resulting in False
|
||||
raise DiscourseServerError(msg, response=response)
|
||||
|
||||
if retry_count == 0:
|
||||
raise DiscourseRateLimitedError(
|
||||
"Number of rate limit retries exceeded. Increase retry_backoff or retry_count",
|
||||
response=response,
|
||||
)
|
||||
|
||||
if response.status_code == 302:
|
||||
raise DiscourseError(
|
||||
@@ -1317,7 +1636,10 @@ class DiscourseClient(object):
|
||||
except ValueError:
|
||||
raise DiscourseError("failed to decode response", response=response)
|
||||
|
||||
if "errors" in decoded:
|
||||
# Checking "errors" length because
|
||||
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run)
|
||||
# sends an empty errors array
|
||||
if "errors" in decoded and len(decoded["errors"]) > 0:
|
||||
message = decoded.get("message")
|
||||
if not message:
|
||||
message = u",".join(decoded["errors"])
|
||||
@@ -0,0 +1,19 @@
|
||||
"""API exceptions."""
|
||||
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
|
||||
class DiscourseError(HTTPError):
|
||||
"""A generic error while attempting to communicate with Discourse"""
|
||||
|
||||
|
||||
class DiscourseServerError(DiscourseError):
|
||||
"""The Discourse Server encountered an error while processing the request"""
|
||||
|
||||
|
||||
class DiscourseClientError(DiscourseError):
|
||||
"""An invalid request has been made"""
|
||||
|
||||
|
||||
class DiscourseRateLimitedError(DiscourseError):
|
||||
"""Request required more than the permissible number of retries"""
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""Simple command line interface for making Discourse API queries."""
|
||||
|
||||
import cmd
|
||||
import json
|
||||
import logging
|
||||
@@ -12,15 +14,19 @@ from pydiscourse.client import DiscourseClient, DiscourseError
|
||||
|
||||
|
||||
class DiscourseCmd(cmd.Cmd):
|
||||
"""Handles CLI commands"""
|
||||
|
||||
prompt = "discourse>"
|
||||
output = sys.stdout
|
||||
|
||||
def __init__(self, client):
|
||||
"""Initialize command"""
|
||||
cmd.Cmd.__init__(self)
|
||||
self.client = client
|
||||
self.prompt = "%s>" % self.client.host
|
||||
|
||||
def __getattr__(self, attr):
|
||||
"""Gets attributes with dynamic name handling"""
|
||||
if attr.startswith("do_"):
|
||||
method = getattr(self.client, attr[3:])
|
||||
|
||||
@@ -48,6 +54,7 @@ class DiscourseCmd(cmd.Cmd):
|
||||
raise AttributeError
|
||||
|
||||
def postcmd(self, result, line):
|
||||
"""Writes output of the command to console"""
|
||||
try:
|
||||
json.dump(
|
||||
result, self.output, sort_keys=True, indent=4, separators=(",", ": ")
|
||||
@@ -57,6 +64,7 @@ class DiscourseCmd(cmd.Cmd):
|
||||
|
||||
|
||||
def main():
|
||||
"""Runs the CLI application"""
|
||||
op = optparse.OptionParser()
|
||||
op.add_option("--host", default="http://localhost:4000")
|
||||
op.add_option("--api-user", default="system")
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
Utilities to implement Single Sign On for Discourse with a Python managed
|
||||
authentication DB
|
||||
"""Implement Single Sign On for Discourse with a Python managed auth DB.
|
||||
|
||||
https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045
|
||||
|
||||
@@ -26,23 +24,20 @@ 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 urllib.parse import unquote, urlencode, parse_qs
|
||||
|
||||
from pydiscourse.exceptions import DiscourseError
|
||||
|
||||
|
||||
def sso_validate(payload, signature, secret):
|
||||
"""
|
||||
"""Validates SSO payload.
|
||||
|
||||
Args:
|
||||
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
|
||||
return value: The nonce used by discourse to validate the redirect URL
|
||||
"""
|
||||
if None in [payload, signature]:
|
||||
raise DiscourseError("No SSO payload or signature.")
|
||||
@@ -72,6 +67,7 @@ def sso_validate(payload, signature, secret):
|
||||
|
||||
|
||||
def sso_payload(secret, **kwargs):
|
||||
"""Returns an encoded SSO payload"""
|
||||
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()})
|
||||
@@ -79,14 +75,16 @@ def sso_payload(secret, **kwargs):
|
||||
|
||||
|
||||
def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
|
||||
"""
|
||||
"""Returns the Discourse redirection URL.
|
||||
|
||||
Args:
|
||||
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
|
||||
return value: URL to redirect users back to discourse, now logged in as user_username
|
||||
"""
|
||||
kwargs.update(
|
||||
{
|
||||
+12
-7
@@ -1,10 +1,10 @@
|
||||
import sys
|
||||
import unittest
|
||||
import mock
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from pydiscourse import client
|
||||
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3,):
|
||||
|
||||
@@ -46,12 +46,12 @@ class ClientBaseTestCase(unittest.TestCase):
|
||||
self.assertEqual(args[0], verb)
|
||||
self.assertEqual(args[1], self.host + url)
|
||||
|
||||
kwargs = kwargs["params"]
|
||||
self.assertEqual(kwargs.pop("api_username"), self.api_username)
|
||||
self.assertEqual(kwargs.pop("api_key"), self.api_key)
|
||||
headers = kwargs["headers"]
|
||||
self.assertEqual(headers.pop("Api-Username"), self.api_username)
|
||||
self.assertEqual(headers.pop("Api-Key"), self.api_key)
|
||||
|
||||
if verb == "GET":
|
||||
self.assertEqual(kwargs, params)
|
||||
self.assertEqual(kwargs["params"], params)
|
||||
|
||||
|
||||
class TestClientRequests(ClientBaseTestCase):
|
||||
@@ -179,9 +179,14 @@ class TestTopics(ClientBaseTestCase):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("requests.request")
|
||||
@mock.patch("pydiscourse.client.requests.request")
|
||||
class MiscellaneousTests(ClientBaseTestCase):
|
||||
|
||||
def test_latest_posts(self, request):
|
||||
prepare_response(request)
|
||||
r = self.client.latest_posts(before=54321)
|
||||
self.assertRequestCalled(request, "GET", "/posts.json", before=54321)
|
||||
|
||||
def test_search(self, request):
|
||||
prepare_response(request)
|
||||
self.client.search("needle")
|
||||
|
||||
+3
-12
@@ -1,18 +1,9 @@
|
||||
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
|
||||
import unittest
|
||||
|
||||
from urllib.parse import unquote
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from pydiscourse import sso
|
||||
from pydiscourse.exceptions import DiscourseError
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
[tox]
|
||||
envlist = py27, py34, py35, pypy, pypy3
|
||||
envlist = py37, py38, py39, py310
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.7: py37
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
|
||||
[testenv]
|
||||
setenv =
|
||||
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
|
||||
commands = python setup.py test
|
||||
commands = pytest {posargs} --cov=pydiscourse
|
||||
deps =
|
||||
-r{toxinidir}/requirements.txt
|
||||
|
||||
[testenv:flake8]
|
||||
basepython=python
|
||||
@@ -12,10 +21,10 @@ deps=
|
||||
flake8
|
||||
flake8_docstrings
|
||||
commands=
|
||||
flake8 pydiscourse
|
||||
flake8 src/pydiscourse --docstring-convention google --ignore D415
|
||||
|
||||
[flake8]
|
||||
ignore = E126,E128
|
||||
max-line-length = 99
|
||||
max-line-length = 119
|
||||
exclude = .ropeproject
|
||||
max-complexity = 10
|
||||
|
||||
Reference in New Issue
Block a user