Compare commits
No commits in common. "master" and "test-against-python311" have entirely different histories.
master
...
test-again
3
.coveragerc
Normal file
3
.coveragerc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[run]
|
||||||
|
|
||||||
|
include: pydiscourse/*
|
||||||
@ -1,11 +1,3 @@
|
|||||||
c0db7215c95dbd31770ade1fc6ea65aa426d4590
|
c0db7215c95dbd31770ade1fc6ea65aa426d4590
|
||||||
0177c46356b9d0fc4b93f09aab7a224643a3685e
|
0177c46356b9d0fc4b93f09aab7a224643a3685e
|
||||||
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
|
f6b4c02fc0f144dffc88cdd48b8261a69228d2f0
|
||||||
2a3036f0395a810b0941522bfb1ca80b159525ce
|
|
||||||
c49d29620dfb867f73ebb6be84b5e1ba922fadc9
|
|
||||||
dc498679cc6769acafe19cf0083f40154ffdcff8
|
|
||||||
7ab58533b759d1ff879476a5703051b201afd835
|
|
||||||
fe4f67c04160a76948d810848ae082713ea6b5ed
|
|
||||||
2aac9a20beb19a6a052286f73f5d0f5bf76ed758
|
|
||||||
2be1a46c1da497e136818b5ef77708b8c5b69e57
|
|
||||||
31db8017bc90978b879c5caa7f1cd4777d19a27e
|
|
||||||
|
|||||||
24
.github/workflows/test.yml
vendored
24
.github/workflows/test.yml
vendored
@ -3,35 +3,13 @@ name: Tests
|
|||||||
on: [ push, pull_request ]
|
on: [ push, pull_request ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install ruff
|
|
||||||
|
|
||||||
- name: Lint with Ruff
|
|
||||||
run: ruff .
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
|
||||||
needs: lint
|
|
||||||
|
|
||||||
name: Test on Python ${{ matrix.python-version }}
|
name: Test on Python ${{ matrix.python-version }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -36,9 +36,3 @@ coverage.xml
|
|||||||
|
|
||||||
# Sphinx documentation
|
# Sphinx documentation
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# Pyenv
|
|
||||||
.python-version
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
.idea
|
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
exclude: "docs|.git|.tox"
|
|
||||||
default_stages: [ commit ]
|
|
||||||
fail_fast: true
|
|
||||||
|
|
||||||
repos:
|
|
||||||
|
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
|
||||||
rev: "v0.0.286"
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.7.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
args:
|
|
||||||
- --config=pyproject.toml
|
|
||||||
- src/pydiscourse
|
|
||||||
- tests
|
|
||||||
@ -19,9 +19,9 @@ Reviewing and merging pull requests is work, so whatever you can do to make this
|
|||||||
easier for the package maintainer not only speed up the process of getting your
|
easier for the package maintainer not only speed up the process of getting your
|
||||||
changes merged but also ensure they are. These few guidelines help significantly.
|
changes merged but also ensure they are. These few guidelines help significantly.
|
||||||
If they are confusing or you need help understanding how to accomplish them,
|
If they are confusing or you need help understanding how to accomplish them,
|
||||||
please ask for help in an issue.
|
please ask for help in an issue.
|
||||||
|
|
||||||
- Please do make sure your changeset represents a *discrete update*. If you would like to fix formatting, by all means, but don't mix that up with a bug fix. Those are separate PRs.
|
- Please do make sure your chnageset represents a *discrete update*. If you would like to fix formatting, by all means, but don't mix that up with a bug fix. Those are separate PRs.
|
||||||
- Please do make sure that both your pull request description and your commits are meaningful and descriptive. Rebase first, if need be.
|
- Please do make sure that both your pull request description and your commits are meaningful and descriptive. Rebase first, if need be.
|
||||||
- Please do make sure your changeset does not include more commits than necessary. Rebase first, if need be.
|
- Please do make sure your changeset does not include more commits than necessary. Rebase first, if need be.
|
||||||
- Please do make sure the changeset is not very big. If you have a large change propose it in an issue first.
|
- Please do make sure the changeset is not very big. If you have a large change propose it in an issue first.
|
||||||
@ -30,27 +30,10 @@ please ask for help in an issue.
|
|||||||
Testing
|
Testing
|
||||||
=======
|
=======
|
||||||
|
|
||||||
Running tests
|
The best way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
|
||||||
-------------
|
|
||||||
|
|
||||||
The simplest way to quickly and repeatedly run tests while developing a feature or fix
|
|
||||||
is to use `pytest` in your current Python environment.
|
|
||||||
|
|
||||||
After installing the test dependencies::
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
Your can run the tests with `pytest`::
|
|
||||||
|
|
||||||
pytest --cov=src/pydiscourse
|
|
||||||
|
|
||||||
This will ensure you get coverage reporting.
|
|
||||||
|
|
||||||
The most comprehensive way to run the tests is with `tox <http://tox.readthedocs.org/en/latest/>`_::
|
|
||||||
|
|
||||||
pip install tox
|
pip install tox
|
||||||
tox
|
detox
|
||||||
|
|
||||||
Or it's slightly faster cousin `detox
|
Or it's slightly faster cousin `detox
|
||||||
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
|
<https://pypi.python.org/pypi/detox>`_ which will parallelize test runs::
|
||||||
@ -58,29 +41,16 @@ Or it's slightly faster cousin `detox
|
|||||||
pip install detox
|
pip install detox
|
||||||
detox
|
detox
|
||||||
|
|
||||||
Writing tests
|
Alternatively, you can run the self test with the following commands::
|
||||||
-------------
|
|
||||||
|
|
||||||
The primary modules of the library have coverage requirements, so you should
|
pip install -r requirements.dev.txt
|
||||||
write a test or tests when you add a new feature.
|
pip install -e .
|
||||||
|
python setup.py test
|
||||||
**At a bare minimum a test should show which Discourse API endpoint is called,
|
|
||||||
using which HTTP method, and returning any necessary data for the new function/method.**
|
|
||||||
|
|
||||||
In most cases this can be accomplished quite simply by using the `discourse_request`
|
|
||||||
fixture, which allows for mocking the HTTP request in the `requests` library. In some cases
|
|
||||||
this may be insufficient, and you may want to directly use the `requests_mock` mocking
|
|
||||||
fixture.
|
|
||||||
|
|
||||||
If in the course of writing your test you see a `requests_mock.NoMockAddress` exception
|
|
||||||
raised then either the *method* or the *path* (including querystring) - or both! - in
|
|
||||||
either your mock OR your new API client method is incorrect.
|
|
||||||
|
|
||||||
Live Testing
|
Live Testing
|
||||||
============
|
============
|
||||||
|
|
||||||
You can test against a Discourse instance by following the [Official Discourse developement instructions][discoursedev].
|
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::
|
For the impatient here is the quick and dirty version::
|
||||||
|
|
||||||
git clone git@github.com:discourse/discourse.git
|
git clone git@github.com:discourse/discourse.git
|
||||||
|
|||||||
39
HISTORY.rst
39
HISTORY.rst
@ -3,45 +3,6 @@
|
|||||||
Release history
|
Release history
|
||||||
===============
|
===============
|
||||||
|
|
||||||
|
|
||||||
1.7.0
|
|
||||||
-----
|
|
||||||
|
|
||||||
- Possible breaking change: Change `search()` term paramater from `term` to `q`,
|
|
||||||
fixes search. Thanks @weber-s
|
|
||||||
- Add support for Python 3.12
|
|
||||||
|
|
||||||
1.6.1
|
|
||||||
-----
|
|
||||||
|
|
||||||
- Adds `posts_by_number` endpoint from @Dettorer
|
|
||||||
|
|
||||||
1.6.0
|
|
||||||
-----
|
|
||||||
|
|
||||||
- Breaking: `toggle_gravatar`, `pick_avatar`, `create_group` now *require*
|
|
||||||
keyword arguments where keyword arguments were used. This *may* break existing
|
|
||||||
code if you have not referenced these by keyword!
|
|
||||||
- Introduced `ruff` and `black` into pre-commit hook
|
|
||||||
- Added `lint` job to GitHub Actions, tests will run if and only if lint job
|
|
||||||
passes.
|
|
||||||
- Sundry code cleanup
|
|
||||||
|
|
||||||
1.5.0
|
|
||||||
-----
|
|
||||||
|
|
||||||
- Owner creation endpoint update from @akhmerov
|
|
||||||
- Python 3.11 support from @Dettorer
|
|
||||||
- Group membership fixes from @inducer
|
|
||||||
- Rate limiting fixes from @inducer
|
|
||||||
- Latest posts endpoint from @max-lancaster
|
|
||||||
|
|
||||||
|
|
||||||
1.4.0
|
|
||||||
-----
|
|
||||||
|
|
||||||
- Documented here as skipped release
|
|
||||||
|
|
||||||
1.3.0
|
1.3.0
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
|||||||
70
README.rst
70
README.rst
@ -2,13 +2,9 @@
|
|||||||
pydiscourse
|
pydiscourse
|
||||||
===========
|
===========
|
||||||
|
|
||||||
.. image:: https://img.shields.io/pypi/v/pydiscourse?color=blue
|
.. image:: https://github.com/bennylope/pydiscourse/workflows/Tests/badge.svg
|
||||||
:alt: PyPI
|
|
||||||
:target: https://pypi.org/project/pydiscourse/
|
|
||||||
|
|
||||||
.. image:: https://github.com/pydiscourse/pydiscourse/workflows/Tests/badge.svg
|
|
||||||
:alt: Build Status
|
:alt: Build Status
|
||||||
:target: https://github.com/pydiscourse/pydiscourse/actions
|
:target: https://github.com/bennylope/pydiscourse/actions
|
||||||
|
|
||||||
.. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg
|
.. image:: https://img.shields.io/badge/Check%20out%20the-Docs-blue.svg
|
||||||
:alt: Check out the Docs
|
:alt: Check out the Docs
|
||||||
@ -41,52 +37,42 @@ Installation
|
|||||||
Examples
|
Examples
|
||||||
========
|
========
|
||||||
|
|
||||||
Create a client connection to a Discourse server:
|
Create a client connection to a Discourse server::
|
||||||
|
|
||||||
.. code:: python
|
from pydiscourse import DiscourseClient
|
||||||
|
client = DiscourseClient(
|
||||||
|
'http://example.com',
|
||||||
|
api_username='username',
|
||||||
|
api_key='areallylongstringfromdiscourse')
|
||||||
|
|
||||||
from pydiscourse import DiscourseClient
|
Get info about a user::
|
||||||
client = DiscourseClient(
|
|
||||||
'http://example.com',
|
|
||||||
api_username='username',
|
|
||||||
api_key='areallylongstringfromdiscourse')
|
|
||||||
|
|
||||||
Get info about a user:
|
user = client.user('eviltrout')
|
||||||
|
print user
|
||||||
|
|
||||||
.. code:: python
|
user_topics = client.topics_by('johnsmith')
|
||||||
|
print user_topics
|
||||||
|
|
||||||
user = client.user('eviltrout')
|
Create a new user::
|
||||||
print user
|
|
||||||
|
|
||||||
user_topics = client.topics_by('johnsmith')
|
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
|
||||||
print user_topics
|
|
||||||
|
|
||||||
Create a new user:
|
Implement SSO for Discourse with your Python server::
|
||||||
|
|
||||||
.. code:: python
|
@login_required
|
||||||
|
def discourse_sso_view(request):
|
||||||
user = client.create_user('The Black Knight', 'blacknight', 'knight@python.org', 'justafleshwound')
|
payload = request.GET.get('sso')
|
||||||
|
signature = request.GET.get('sig')
|
||||||
Implement SSO for Discourse with your Python server:
|
nonce = sso_validate(payload, signature, SECRET)
|
||||||
|
url = sso_redirect_url(nonce, SECRET, request.user.email, request.user.id, request.user.username)
|
||||||
.. code:: python
|
return redirect('http://discuss.example.com' + url)
|
||||||
|
|
||||||
@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
|
Command line
|
||||||
============
|
============
|
||||||
|
|
||||||
To help experiment with the Discourse API, pydiscourse provides a simple command line client:
|
To help experiment with the Discourse API, pydiscourse provides a simple command line client::
|
||||||
|
|
||||||
.. code:: bash
|
export DISCOURSE_API_KEY=your_master_key
|
||||||
|
pydiscoursecli --host-http://yourhost --api-user-system latest_topics
|
||||||
export DISCOURSE_API_KEY=your_master_key
|
pydiscoursecli --host-http://yourhost --api-user-system topics_by johnsmith
|
||||||
pydiscoursecli --host-http://yourhost --api-user-system latest_topics
|
pydiscoursecli --host-http://yourhost --api-user-system user eviltrout
|
||||||
pydiscoursecli --host-http://yourhost --api-user-system topics_by johnsmith
|
|
||||||
pydiscoursecli --host-http://yourhost --api-user-system user eviltrout
|
|
||||||
|
|||||||
@ -51,9 +51,9 @@ copyright = u'2014, Marc Sibson'
|
|||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '1.7'
|
version = '1.1'
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = '1.7.0'
|
release = '1.1.1'
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
[tool.black]
|
|
||||||
line-length=120
|
|
||||||
target-version = ["py311"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
exclude = [
|
|
||||||
".tox",
|
|
||||||
".git",
|
|
||||||
"build",
|
|
||||||
"dist",
|
|
||||||
"docs",
|
|
||||||
".ropeproject",
|
|
||||||
]
|
|
||||||
ignore = [
|
|
||||||
"S101", # Assertions good, actually
|
|
||||||
"TRY003", # For now not worth back tracking
|
|
||||||
]
|
|
||||||
line-length = 240
|
|
||||||
select = [
|
|
||||||
"S", # Security (formerly B when using Bandit directly)
|
|
||||||
"E",
|
|
||||||
"F",
|
|
||||||
"N",
|
|
||||||
"W",
|
|
||||||
"COM", # commas
|
|
||||||
"PT", # pytest
|
|
||||||
"UP", # Upgrade Python syntax
|
|
||||||
"T",
|
|
||||||
"A", # built-in shadowing
|
|
||||||
"FBT", # Boolean traps
|
|
||||||
"BLE", # Blind exceptions
|
|
||||||
"PIE",
|
|
||||||
"TRY",
|
|
||||||
"ERA", # eradicate commented out code
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.flake8-pytest-style]
|
|
||||||
fixture-parentheses = false
|
|
||||||
mark-parentheses = true
|
|
||||||
parametrize-names-type = "tuple"
|
|
||||||
parametrize-values-row-type = "tuple"
|
|
||||||
|
|
||||||
[tool.ruff.mccabe]
|
|
||||||
# Unlike Flake8, default to a complexity level of 10.
|
|
||||||
max-complexity = 10
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
|
||||||
include = ["src/pydiscourse/*"]
|
|
||||||
|
|
||||||
@ -1,9 +1,2 @@
|
|||||||
pre-commit==3.3.3
|
pytest==6.2.5
|
||||||
ruff==0.0.286
|
pytest-cov==3.0.0
|
||||||
pytest==7.4.0
|
|
||||||
pytest-cov==4.1.0
|
|
||||||
pytest-mock==3.11.1 # https://github.com/pytest-dev/pytest-mock/
|
|
||||||
pytest-socket==0.6.0 # https://github.com/miketheman/pytest-socket
|
|
||||||
requests-mock==1.11.0 # https://github.com/jamielennox/requests-mock
|
|
||||||
pytest-subtests==0.11.0 # https://github.com/pytest-dev/pytest-subtests
|
|
||||||
pytest-icdiff==0.6 # https://pypi.org/project/pytest-icdiff/
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ package_dir =
|
|||||||
=src
|
=src
|
||||||
install_requires =
|
install_requires =
|
||||||
requests>=2.4.2
|
requests>=2.4.2
|
||||||
|
typing; python_version<"3.6"
|
||||||
classifiers =
|
classifiers =
|
||||||
Development Status :: 5 - Production/Stable
|
Development Status :: 5 - Production/Stable
|
||||||
Environment :: Web Environment
|
Environment :: Web Environment
|
||||||
@ -25,11 +26,10 @@ classifiers =
|
|||||||
License :: OSI Approved :: MIT License
|
License :: OSI Approved :: MIT License
|
||||||
Operating System :: OS Independent
|
Operating System :: OS Independent
|
||||||
Programming Language :: Python
|
Programming Language :: Python
|
||||||
|
Programming Language :: Python :: 3.7
|
||||||
Programming Language :: Python :: 3.8
|
Programming Language :: Python :: 3.8
|
||||||
Programming Language :: Python :: 3.9
|
Programming Language :: Python :: 3.9
|
||||||
Programming Language :: Python :: 3.10
|
Programming Language :: Python :: 3.10
|
||||||
Programming Language :: Python :: 3.11
|
|
||||||
Programming Language :: Python :: 3.12
|
|
||||||
|
|
||||||
[options.packages.find]
|
[options.packages.find]
|
||||||
where=src
|
where=src
|
||||||
|
|||||||
1
setup.py
1
setup.py
@ -1,3 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
See setup.cfg for packaging settings
|
See setup.cfg for packaging settings
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
"""Python client for the Discourse API."""
|
"""Python client for the Discourse API."""
|
||||||
|
|
||||||
__version__ = "1.7.0"
|
__version__ = "1.3.0"
|
||||||
|
|
||||||
from pydiscourse.client import DiscourseClient
|
from pydiscourse.client import DiscourseClient
|
||||||
|
|
||||||
|
|||||||
@ -27,15 +27,7 @@ POST = "POST"
|
|||||||
PUT = "PUT"
|
PUT = "PUT"
|
||||||
|
|
||||||
|
|
||||||
def now() -> datetime:
|
class DiscourseClient(object):
|
||||||
"""Returns the current UTC time.
|
|
||||||
|
|
||||||
This function enables simple mocking for freezing time.
|
|
||||||
"""
|
|
||||||
return datetime.utcnow()
|
|
||||||
|
|
||||||
|
|
||||||
class DiscourseClient:
|
|
||||||
"""Discourse API client"""
|
"""Discourse API client"""
|
||||||
|
|
||||||
def __init__(self, host, api_username, api_key, timeout=None):
|
def __init__(self, host, api_username, api_key, timeout=None):
|
||||||
@ -70,16 +62,16 @@ class DiscourseClient:
|
|||||||
dict of user information
|
dict of user information
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/users/{username}.json")["user"]
|
return self._get("/users/{0}.json".format(username))["user"]
|
||||||
|
|
||||||
def approve(self, user_id):
|
def approve(self, user_id):
|
||||||
return self._get(f"/admin/users/{user_id}/approve.json")
|
return self._get("/admin/users/{0}/approve.json".format(user_id))
|
||||||
|
|
||||||
def activate(self, user_id):
|
def activate(self, user_id):
|
||||||
return self._put(f"/admin/users/{user_id}/activate.json")
|
return self._put("/admin/users/{0}/activate.json".format(user_id))
|
||||||
|
|
||||||
def deactivate(self, user_id):
|
def deactivate(self, user_id):
|
||||||
return self._put(f"/admin/users/{user_id}/deactivate.json")
|
return self._put("/admin/users/{0}/deactivate.json".format(user_id))
|
||||||
|
|
||||||
def user_all(self, user_id):
|
def user_all(self, user_id):
|
||||||
"""
|
"""
|
||||||
@ -90,7 +82,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
dict of user information
|
dict of user information
|
||||||
"""
|
"""
|
||||||
return self._get(f"/admin/users/{user_id}.json")
|
return self._get("/admin/users/{0}.json".format(user_id))
|
||||||
|
|
||||||
def invite(self, email, group_names, custom_message, **kwargs):
|
def invite(self, email, group_names, custom_message, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -111,7 +103,7 @@ class DiscourseClient:
|
|||||||
email=email,
|
email=email,
|
||||||
group_names=group_names,
|
group_names=group_names,
|
||||||
custom_message=custom_message,
|
custom_message=custom_message,
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def invite_link(self, email, group_names, custom_message, **kwargs):
|
def invite_link(self, email, group_names, custom_message, **kwargs):
|
||||||
@ -133,7 +125,7 @@ class DiscourseClient:
|
|||||||
email=email,
|
email=email,
|
||||||
group_names=group_names,
|
group_names=group_names,
|
||||||
custom_message=custom_message,
|
custom_message=custom_message,
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def user_by_id(self, pk):
|
def user_by_id(self, pk):
|
||||||
@ -146,7 +138,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
user
|
user
|
||||||
"""
|
"""
|
||||||
return self._get(f"/admin/users/{pk}.json")
|
return self._get("/admin/users/{0}.json".format(pk))
|
||||||
|
|
||||||
def user_by_email(self, email):
|
def user_by_email(self, email):
|
||||||
"""
|
"""
|
||||||
@ -158,7 +150,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
user
|
user
|
||||||
"""
|
"""
|
||||||
return self._get(f"/admin/users/list/all.json?email={email}")
|
return self._get("/admin/users/list/all.json?email={0}".format(email))
|
||||||
|
|
||||||
def create_user(self, name, username, email, password, **kwargs):
|
def create_user(self, name, username, email, password, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -190,7 +182,7 @@ class DiscourseClient:
|
|||||||
password=password,
|
password=password,
|
||||||
password_confirmation=confirmations,
|
password_confirmation=confirmations,
|
||||||
challenge=challenge,
|
challenge=challenge,
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def user_by_external_id(self, external_id):
|
def user_by_external_id(self, external_id):
|
||||||
@ -202,7 +194,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
response = self._get(f"/users/by-external/{external_id}")
|
response = self._get("/users/by-external/{0}".format(external_id))
|
||||||
return response["user"]
|
return response["user"]
|
||||||
|
|
||||||
by_external_id = user_by_external_id
|
by_external_id = user_by_external_id
|
||||||
@ -216,7 +208,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._post(f"/admin/users/{userid}/log_out")
|
return self._post("/admin/users/{0}/log_out".format(userid))
|
||||||
|
|
||||||
def trust_level(self, userid, level):
|
def trust_level(self, userid, level):
|
||||||
"""
|
"""
|
||||||
@ -228,18 +220,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._put(f"/admin/users/{userid}/trust_level", level=level)
|
return self._put("/admin/users/{0}/trust_level".format(userid), level=level)
|
||||||
|
|
||||||
def anonymize(self, userid):
|
|
||||||
"""
|
|
||||||
|
|
||||||
Args:
|
|
||||||
userid: the Discourse user ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self._put(f"/admin/users/{userid}/anonymize")
|
|
||||||
|
|
||||||
def suspend(self, userid, duration, reason):
|
def suspend(self, userid, duration, reason):
|
||||||
"""
|
"""
|
||||||
@ -255,9 +236,9 @@ class DiscourseClient:
|
|||||||
????
|
????
|
||||||
|
|
||||||
"""
|
"""
|
||||||
suspend_until = (now() + timedelta(days=duration)).isoformat()
|
suspend_until = (datetime.now() + timedelta(days=duration)).isoformat()
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/admin/users/{userid}/suspend",
|
"/admin/users/{0}/suspend".format(userid),
|
||||||
suspend_until=suspend_until,
|
suspend_until=suspend_until,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
)
|
)
|
||||||
@ -272,21 +253,21 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
None???
|
None???
|
||||||
"""
|
"""
|
||||||
return self._put(f"/admin/users/{userid}/unsuspend")
|
return self._put("/admin/users/{0}/unsuspend".format(userid))
|
||||||
|
|
||||||
def list_users(self, user_type, **kwargs):
|
def list_users(self, type, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
optional user search: filter='test@example.com' or filter='scott'
|
optional user search: filter='test@example.com' or filter='scott'
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_type:
|
type:
|
||||||
**kwargs:
|
**kwargs:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/admin/users/list/{user_type}.json", **kwargs)
|
return self._get("/admin/users/list/{0}.json".format(type), **kwargs)
|
||||||
|
|
||||||
def update_avatar_from_url(self, username, url, **kwargs):
|
def update_avatar_from_url(self, username, url, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -300,9 +281,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._post(
|
return self._post(
|
||||||
f"/users/{username}/preferences/avatar",
|
"/users/{0}/preferences/avatar".format(username), file=url, **kwargs
|
||||||
file=url,
|
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_avatar_image(self, username, img, **kwargs):
|
def update_avatar_image(self, username, img, **kwargs):
|
||||||
@ -320,12 +299,10 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
files = {"file": img}
|
files = {"file": img}
|
||||||
return self._post(
|
return self._post(
|
||||||
f"/users/{username}/preferences/avatar",
|
"/users/{0}/preferences/avatar".format(username), files=files, **kwargs
|
||||||
files=files,
|
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def toggle_gravatar(self, username, *, state=True, **kwargs):
|
def toggle_gravatar(self, username, state=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -336,14 +313,14 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
url = f"/users/{username}/preferences/avatar/toggle"
|
url = "/users/{0}/preferences/avatar/toggle".format(username)
|
||||||
if bool(state):
|
if bool(state):
|
||||||
kwargs["use_uploaded_avatar"] = "true"
|
kwargs["use_uploaded_avatar"] = "true"
|
||||||
else:
|
else:
|
||||||
kwargs["use_uploaded_avatar"] = "false"
|
kwargs["use_uploaded_avatar"] = "false"
|
||||||
return self._put(url, **kwargs)
|
return self._put(url, **kwargs)
|
||||||
|
|
||||||
def pick_avatar(self, username, *, gravatar=True, generated=False, **kwargs):
|
def pick_avatar(self, username, gravatar=True, generated=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -355,7 +332,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
url = f"/users/{username}/preferences/avatar/pick"
|
url = "/users/{0}/preferences/avatar/pick".format(username)
|
||||||
return self._put(url, **kwargs)
|
return self._put(url, **kwargs)
|
||||||
|
|
||||||
def update_avatar(self, username, url, **kwargs):
|
def update_avatar(self, username, url, **kwargs):
|
||||||
@ -373,9 +350,9 @@ class DiscourseClient:
|
|||||||
kwargs["synchronous"] = "true"
|
kwargs["synchronous"] = "true"
|
||||||
upload_response = self._post("/uploads", url=url, **kwargs)
|
upload_response = self._post("/uploads", url=url, **kwargs)
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/users/{username}/preferences/avatar/pick",
|
"/users/{0}/preferences/avatar/pick".format(username),
|
||||||
upload_id=upload_response["id"],
|
upload_id=upload_response["id"],
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_email(self, username, email, **kwargs):
|
def update_email(self, username, email, **kwargs):
|
||||||
@ -390,9 +367,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/users/{username}/preferences/email",
|
"/users/{0}/preferences/email".format(username), email=email, **kwargs
|
||||||
email=email,
|
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_user(self, username, **kwargs):
|
def update_user(self, username, **kwargs):
|
||||||
@ -405,7 +380,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._put(f"/users/{username}", json=True, **kwargs)
|
return self._put("/users/{0}".format(username), json=True, **kwargs)
|
||||||
|
|
||||||
def update_username(self, username, new_username, **kwargs):
|
def update_username(self, username, new_username, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -419,9 +394,9 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/users/{username}/preferences/username",
|
"/users/{0}/preferences/username".format(username),
|
||||||
new_username=new_username,
|
new_username=new_username,
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_preference(self, username=None, **kwargs):
|
def set_preference(self, username=None, **kwargs):
|
||||||
@ -436,7 +411,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
if username is None:
|
if username is None:
|
||||||
username = self.api_username
|
username = self.api_username
|
||||||
return self._put(f"/users/{username}", **kwargs)
|
return self._put(u"/users/{0}".format(username), **kwargs)
|
||||||
|
|
||||||
def sync_sso(self, **kwargs):
|
def sync_sso(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -452,7 +427,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
sso_secret = kwargs.pop("sso_secret")
|
sso_secret = kwargs.pop("sso_secret")
|
||||||
payload = sso_payload(sso_secret, **kwargs)
|
payload = sso_payload(sso_secret, **kwargs)
|
||||||
return self._post(f"/admin/users/sync_sso?{payload}", **kwargs)
|
return self._post("/admin/users/sync_sso?{0}".format(payload), **kwargs)
|
||||||
|
|
||||||
def generate_api_key(self, userid, **kwargs):
|
def generate_api_key(self, userid, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -464,7 +439,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._post(f"/admin/users/{userid}/generate_api_key", **kwargs)
|
return self._post("/admin/users/{0}/generate_api_key".format(userid), **kwargs)
|
||||||
|
|
||||||
def delete_user(self, userid, **kwargs):
|
def delete_user(self, userid, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -480,22 +455,22 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._delete(f"/admin/users/{userid}.json", **kwargs)
|
return self._delete("/admin/users/{0}.json".format(userid), **kwargs)
|
||||||
|
|
||||||
def users(self, filter_name=None, **kwargs):
|
def users(self, filter=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filter_name:
|
filter:
|
||||||
**kwargs:
|
**kwargs:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if filter_name is None:
|
if filter is None:
|
||||||
filter_name = "active"
|
filter = "active"
|
||||||
|
|
||||||
return self._get(f"/admin/users/list/{filter_name}.json", **kwargs)
|
return self._get("/admin/users/list/{0}.json".format(filter), **kwargs)
|
||||||
|
|
||||||
def private_messages(self, username=None, **kwargs):
|
def private_messages(self, username=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -509,7 +484,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
if username is None:
|
if username is None:
|
||||||
username = self.api_username
|
username = self.api_username
|
||||||
return self._get(f"/topics/private-messages/{username}.json", **kwargs)
|
return self._get("/topics/private-messages/{0}.json".format(username), **kwargs)
|
||||||
|
|
||||||
def private_messages_unread(self, username=None, **kwargs):
|
def private_messages_unread(self, username=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -524,8 +499,7 @@ class DiscourseClient:
|
|||||||
if username is None:
|
if username is None:
|
||||||
username = self.api_username
|
username = self.api_username
|
||||||
return self._get(
|
return self._get(
|
||||||
f"/topics/private-messages-unread/{username}.json",
|
"/topics/private-messages-unread/{0}.json".format(username), **kwargs
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def category_topics(self, category_id, **kwargs):
|
def category_topics(self, category_id, **kwargs):
|
||||||
@ -540,9 +514,9 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(
|
return self._get(
|
||||||
f"/c/{category_id}.json",
|
"/c/{0}.json".format(category_id),
|
||||||
override_request_kwargs={"allow_redirects": True},
|
override_request_kwargs={"allow_redirects": True},
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
# Doesn't work on recent Discourse versions (2014+)
|
# Doesn't work on recent Discourse versions (2014+)
|
||||||
@ -600,7 +574,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/t/{slug}/{topic_id}.json", **kwargs)
|
return self._get("/t/{0}/{1}.json".format(slug, topic_id), **kwargs)
|
||||||
|
|
||||||
def delete_topic(self, topic_id, **kwargs):
|
def delete_topic(self, topic_id, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -614,7 +588,7 @@ class DiscourseClient:
|
|||||||
JSON API response
|
JSON API response
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._delete(f"/t/{topic_id}", **kwargs)
|
return self._delete(u"/t/{0}".format(topic_id), **kwargs)
|
||||||
|
|
||||||
def post(self, topic_id, post_id, **kwargs):
|
def post(self, topic_id, post_id, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -627,7 +601,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/t/{topic_id}/{post_id}.json", **kwargs)
|
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):
|
def post_action_users(self, post_id, post_action_type_id=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -657,20 +631,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
post
|
post
|
||||||
"""
|
"""
|
||||||
return self._get(f"/posts/{post_id}.json", **kwargs)
|
return self._get("/posts/{0}.json".format(post_id), **kwargs)
|
||||||
|
|
||||||
def post_by_number(self, topic_id, post_number, **kwargs):
|
|
||||||
"""
|
|
||||||
Get a post from its number inside a specific topic
|
|
||||||
Args:
|
|
||||||
topic_id: the topic the post belongs to
|
|
||||||
post_number: the number of the post inside the topic
|
|
||||||
**kwargs:
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
post
|
|
||||||
"""
|
|
||||||
return self._get(f"/posts/by_number/{topic_id}/{post_number}", **kwargs)
|
|
||||||
|
|
||||||
def posts(self, topic_id, post_ids=None, **kwargs):
|
def posts(self, topic_id, post_ids=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -686,22 +647,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
if post_ids:
|
if post_ids:
|
||||||
kwargs["post_ids[]"] = post_ids
|
kwargs["post_ids[]"] = post_ids
|
||||||
return self._get(f"/t/{topic_id}/posts.json", **kwargs)
|
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):
|
def topic_timings(self, topic_id, time, timings={}, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -721,7 +667,7 @@ class DiscourseClient:
|
|||||||
kwargs["topic_id"] = topic_id
|
kwargs["topic_id"] = topic_id
|
||||||
kwargs["topic_time"] = time
|
kwargs["topic_time"] = time
|
||||||
for post_num, timing in timings.items():
|
for post_num, timing in timings.items():
|
||||||
kwargs[f"timings[{post_num}]"] = timing
|
kwargs["timings[{0}]".format(post_num)] = timing
|
||||||
|
|
||||||
return self._post("/topics/timings", **kwargs)
|
return self._post("/topics/timings", **kwargs)
|
||||||
|
|
||||||
@ -735,7 +681,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/t/{topic_id}/posts.json", **kwargs)
|
return self._get("/t/{0}/posts.json".format(topic_id), **kwargs)
|
||||||
|
|
||||||
def update_topic(self, topic_url, title, **kwargs):
|
def update_topic(self, topic_url, title, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -750,16 +696,10 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
kwargs["title"] = title
|
kwargs["title"] = title
|
||||||
return self._put(f"{topic_url}", **kwargs)
|
return self._put("{}".format(topic_url), **kwargs)
|
||||||
|
|
||||||
def create_post(
|
def create_post(
|
||||||
self,
|
self, content, category_id=None, topic_id=None, title=None, tags=[], **kwargs
|
||||||
content,
|
|
||||||
category_id=None,
|
|
||||||
topic_id=None,
|
|
||||||
title=None,
|
|
||||||
tags=[],
|
|
||||||
**kwargs,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -782,7 +722,7 @@ class DiscourseClient:
|
|||||||
title=title,
|
title=title,
|
||||||
raw=content,
|
raw=content,
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
**kwargs,
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_topic_status(self, topic_id, status, enabled, **kwargs):
|
def update_topic_status(self, topic_id, status, enabled, **kwargs):
|
||||||
@ -803,7 +743,7 @@ class DiscourseClient:
|
|||||||
kwargs["enabled"] = "true"
|
kwargs["enabled"] = "true"
|
||||||
else:
|
else:
|
||||||
kwargs["enabled"] = "false"
|
kwargs["enabled"] = "false"
|
||||||
return self._put(f"/t/{topic_id}/status", **kwargs)
|
return self._put("/t/{0}/status".format(topic_id), **kwargs)
|
||||||
|
|
||||||
def update_post(self, post_id, content, edit_reason="", **kwargs):
|
def update_post(self, post_id, content, edit_reason="", **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -819,7 +759,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
kwargs["post[raw]"] = content
|
kwargs["post[raw]"] = content
|
||||||
kwargs["post[edit_reason]"] = edit_reason
|
kwargs["post[edit_reason]"] = edit_reason
|
||||||
return self._put(f"/posts/{post_id}", **kwargs)
|
return self._put("/posts/{0}".format(post_id), **kwargs)
|
||||||
|
|
||||||
def reset_bump_date(self, topic_id, **kwargs):
|
def reset_bump_date(self, topic_id, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -827,7 +767,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
See https://meta.discourse.org/t/what-is-a-bump/105562
|
See https://meta.discourse.org/t/what-is-a-bump/105562
|
||||||
"""
|
"""
|
||||||
return self._put(f"/t/{topic_id}/reset-bump-date", **kwargs)
|
return self._put("/t/{0}/reset-bump-date".format(topic_id), **kwargs)
|
||||||
|
|
||||||
def topics_by(self, username, **kwargs):
|
def topics_by(self, username, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -839,7 +779,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
url = f"/topics/created-by/{username}.json"
|
url = "/topics/created-by/{0}.json".format(username)
|
||||||
return self._get(url, **kwargs)["topic_list"]["topics"]
|
return self._get(url, **kwargs)["topic_list"]["topics"]
|
||||||
|
|
||||||
def invite_user_to_topic(self, user_email, topic_id):
|
def invite_user_to_topic(self, user_email, topic_id):
|
||||||
@ -853,19 +793,19 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
kwargs = {"email": user_email, "topic_id": topic_id}
|
kwargs = {"email": user_email, "topic_id": topic_id}
|
||||||
return self._post(f"/t/{topic_id}/invite.json", **kwargs)
|
return self._post("/t/{0}/invite.json".format(topic_id), **kwargs)
|
||||||
|
|
||||||
def search(self, q, **kwargs):
|
def search(self, term, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
q:
|
term:
|
||||||
**kwargs:
|
**kwargs:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
kwargs["q"] = q
|
kwargs["term"] = term
|
||||||
return self._get("/search.json", **kwargs)
|
return self._get("/search.json", **kwargs)
|
||||||
|
|
||||||
def badges(self, **kwargs):
|
def badges(self, **kwargs):
|
||||||
@ -891,10 +831,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._post(
|
return self._post(
|
||||||
"/user_badges",
|
"/user_badges", username=username, badge_id=badge_id, **kwargs
|
||||||
username=username,
|
|
||||||
badge_id=badge_id,
|
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def user_badges(self, username, **kwargs):
|
def user_badges(self, username, **kwargs):
|
||||||
@ -906,7 +843,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/user-badges/{username}.json")
|
return self._get("/user-badges/{}.json".format(username))
|
||||||
|
|
||||||
def user_emails(self, username, **kwargs):
|
def user_emails(self, username, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -918,16 +855,10 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._get(f"/u/{username}/emails.json")
|
return self._get("/u/{}/emails.json".format(username))
|
||||||
|
|
||||||
def create_category(
|
def create_category(
|
||||||
self,
|
self, name, color, text_color="FFFFFF", permissions=None, parent=None, **kwargs
|
||||||
name,
|
|
||||||
color,
|
|
||||||
text_color="FFFFFF",
|
|
||||||
permissions=None,
|
|
||||||
parent=None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -951,7 +882,7 @@ class DiscourseClient:
|
|||||||
permissions = {"everyone": "1"}
|
permissions = {"everyone": "1"}
|
||||||
|
|
||||||
for key, value in permissions.items():
|
for key, value in permissions.items():
|
||||||
kwargs[f"permissions[{key}]"] = value
|
kwargs["permissions[{0}]".format(key)] = value
|
||||||
|
|
||||||
if parent:
|
if parent:
|
||||||
parent_id = None
|
parent_id = None
|
||||||
@ -961,7 +892,7 @@ class DiscourseClient:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not parent_id:
|
if not parent_id:
|
||||||
raise DiscourseClientError(f"{parent} not found")
|
raise DiscourseClientError(u"{0} not found".format(parent))
|
||||||
|
|
||||||
kwargs["parent_category_id"] = parent_id
|
kwargs["parent_category_id"] = parent_id
|
||||||
|
|
||||||
@ -989,19 +920,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self._get(f"/c/{category_id}/show.json", **kwargs)
|
return self._get(u"/c/{0}/show.json".format(category_id), **kwargs)
|
||||||
|
|
||||||
def update_category(self, category_id, **kwargs):
|
|
||||||
"""
|
|
||||||
|
|
||||||
Args:
|
|
||||||
category_id:
|
|
||||||
**kwargs:
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self._put(f"/categories/{category_id}", json=True, **kwargs)
|
|
||||||
|
|
||||||
def delete_category(self, category_id, **kwargs):
|
def delete_category(self, category_id, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1014,7 +933,7 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._delete(f"/categories/{category_id}", **kwargs)
|
return self._delete(u"/categories/{0}".format(category_id), **kwargs)
|
||||||
|
|
||||||
def get_site_info(self):
|
def get_site_info(self):
|
||||||
"""
|
"""
|
||||||
@ -1033,8 +952,8 @@ class DiscourseClient:
|
|||||||
Get latest topics from a category
|
Get latest topics from a category
|
||||||
"""
|
"""
|
||||||
if parent:
|
if parent:
|
||||||
name = f"{parent}/{name}"
|
name = u"{0}/{1}".format(parent, name)
|
||||||
return self._get(f"/c/{name}/l/latest.json", **kwargs)
|
return self._get(u"/c/{0}/l/latest.json".format(name), **kwargs)
|
||||||
|
|
||||||
def site_settings(self, **kwargs):
|
def site_settings(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1049,9 +968,7 @@ class DiscourseClient:
|
|||||||
for setting, value in kwargs.items():
|
for setting, value in kwargs.items():
|
||||||
setting = setting.replace(" ", "_")
|
setting = setting.replace(" ", "_")
|
||||||
self._request(
|
self._request(
|
||||||
PUT,
|
PUT, "/admin/site_settings/{0}".format(setting), {setting: value}
|
||||||
f"/admin/site_settings/{setting}",
|
|
||||||
{setting: value},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def customize_site_texts(self, site_texts, **kwargs):
|
def customize_site_texts(self, site_texts, **kwargs):
|
||||||
@ -1068,9 +985,7 @@ class DiscourseClient:
|
|||||||
for site_text, value in site_texts.items():
|
for site_text, value in site_texts.items():
|
||||||
kwargs = {"site_text": {"value": value}}
|
kwargs = {"site_text": {"value": value}}
|
||||||
self._put(
|
self._put(
|
||||||
f"/admin/customize/site_texts/{site_text}",
|
"/admin/customize/site_texts/{0}".format(site_text), json=True, **kwargs
|
||||||
json=True,
|
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def groups(self, **kwargs):
|
def groups(self, **kwargs):
|
||||||
@ -1124,12 +1039,11 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
Get all infos of a group by group name
|
Get all infos of a group by group name
|
||||||
"""
|
"""
|
||||||
return self._get(f"/groups/{group_name}.json")
|
return self._get("/groups/{0}.json".format(group_name))
|
||||||
|
|
||||||
def create_group(
|
def create_group(
|
||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
*,
|
|
||||||
title="",
|
title="",
|
||||||
visible=True,
|
visible=True,
|
||||||
alias_level=0,
|
alias_level=0,
|
||||||
@ -1141,7 +1055,7 @@ class DiscourseClient:
|
|||||||
flair_url=None,
|
flair_url=None,
|
||||||
flair_bg_color=None,
|
flair_bg_color=None,
|
||||||
flair_color=None,
|
flair_color=None,
|
||||||
**kwargs,
|
**kwargs
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@ -1167,7 +1081,9 @@ class DiscourseClient:
|
|||||||
kwargs["automatic_membership_retroactive"] = automatic_membership_retroactive
|
kwargs["automatic_membership_retroactive"] = automatic_membership_retroactive
|
||||||
kwargs["primary_group"] = primary_group
|
kwargs["primary_group"] = primary_group
|
||||||
kwargs["automatic"] = automatic
|
kwargs["automatic"] = automatic
|
||||||
kwargs["automatic_membership_email_domains"] = automatic_membership_email_domains
|
kwargs[
|
||||||
|
"automatic_membership_email_domains"
|
||||||
|
] = automatic_membership_email_domains
|
||||||
kwargs["grant_trust_level"] = grant_trust_level
|
kwargs["grant_trust_level"] = grant_trust_level
|
||||||
kwargs["flair_url"] = flair_url
|
kwargs["flair_url"] = flair_url
|
||||||
kwargs["flair_bg_color"] = flair_bg_color
|
kwargs["flair_bg_color"] = flair_bg_color
|
||||||
@ -1188,7 +1104,7 @@ class DiscourseClient:
|
|||||||
JSON API response
|
JSON API response
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._delete(f"/admin/groups/{groupid}.json")
|
return self._delete("/admin/groups/{0}.json".format(groupid))
|
||||||
|
|
||||||
def add_group_owner(self, groupid, username):
|
def add_group_owner(self, groupid, username):
|
||||||
"""
|
"""
|
||||||
@ -1202,7 +1118,9 @@ class DiscourseClient:
|
|||||||
JSON API response
|
JSON API response
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self.add_group_owners(groupid, [username])
|
return self._put(
|
||||||
|
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": username}
|
||||||
|
)
|
||||||
|
|
||||||
def add_group_owners(self, groupid, usernames):
|
def add_group_owners(self, groupid, usernames):
|
||||||
"""
|
"""
|
||||||
@ -1218,8 +1136,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
usernames = ",".join(usernames)
|
usernames = ",".join(usernames)
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/groups/{groupid}/owners.json",
|
"/admin/groups/{0}/owners.json".format(groupid), **{"group[usernames]": usernames}
|
||||||
usernames=usernames,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_group_owner(self, groupid, userid):
|
def delete_group_owner(self, groupid, userid):
|
||||||
@ -1237,37 +1154,23 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._delete(
|
return self._delete(
|
||||||
f"/admin/groups/{groupid}/owners.json",
|
"/admin/groups/{0}/owners.json".format(groupid), user_id=userid
|
||||||
user_id=userid,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def group_owners(self, group_name):
|
def group_owners(self, group_name):
|
||||||
"""
|
"""
|
||||||
Get all owners of a group by group name
|
Get all owners of a group by group name
|
||||||
"""
|
"""
|
||||||
group = self._get(f"/groups/{group_name}/members.json")
|
group = self._get("/groups/{0}/members.json".format(group_name))
|
||||||
return group["owners"]
|
return group["owners"]
|
||||||
|
|
||||||
def _get_paginated_list(self, url, name, offset, **kwargs):
|
|
||||||
result = []
|
|
||||||
initial_offset = offset
|
|
||||||
while True:
|
|
||||||
kwargs["offset"] = offset
|
|
||||||
response = self._get(url, **kwargs)
|
|
||||||
nreturned = len(response[name])
|
|
||||||
result.extend(response[name])
|
|
||||||
offset += nreturned
|
|
||||||
|
|
||||||
if response["meta"]["total"] == len(result) - initial_offset:
|
|
||||||
return result
|
|
||||||
if nreturned == 0:
|
|
||||||
raise RuntimeError("more items expected, but none returned")
|
|
||||||
|
|
||||||
def group_members(self, group_name, offset=0, **kwargs):
|
def group_members(self, group_name, offset=0, **kwargs):
|
||||||
"""
|
"""
|
||||||
Get all members of a group by group name
|
Get all members of a group by group name
|
||||||
"""
|
"""
|
||||||
return self._get_paginated_list(f"/groups/{group_name}/members.json", "members", offset, **kwargs)
|
kwargs["offset"] = offset
|
||||||
|
group = self._get("/groups/{0}/members.json".format(group_name), **kwargs)
|
||||||
|
return group["members"]
|
||||||
|
|
||||||
def add_group_member(self, groupid, username):
|
def add_group_member(self, groupid, username):
|
||||||
"""
|
"""
|
||||||
@ -1285,8 +1188,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/groups/{groupid}/members.json",
|
"/admin/groups/{0}/members.json".format(groupid), usernames=username
|
||||||
usernames=username,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_group_members(self, groupid, usernames):
|
def add_group_members(self, groupid, usernames):
|
||||||
@ -1306,9 +1208,7 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
usernames = ",".join(usernames)
|
usernames = ",".join(usernames)
|
||||||
return self._put(
|
return self._put(
|
||||||
f"/groups/{groupid}/members.json",
|
"/admin/groups/{0}/members.json".format(groupid), usernames=usernames
|
||||||
usernames=usernames,
|
|
||||||
json=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_user_to_group(self, groupid, userid):
|
def add_user_to_group(self, groupid, userid):
|
||||||
@ -1326,9 +1226,9 @@ class DiscourseClient:
|
|||||||
DiscourseError if user is already member of group
|
DiscourseError if user is already member of group
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._post(f"/admin/users/{userid}/groups", group_id=groupid)
|
return self._post("/admin/users/{0}/groups".format(userid), group_id=groupid)
|
||||||
|
|
||||||
def delete_group_member(self, groupid, username):
|
def delete_group_member(self, groupid, userid):
|
||||||
"""
|
"""
|
||||||
Deletes a member from a group by user ID
|
Deletes a member from a group by user ID
|
||||||
|
|
||||||
@ -1336,13 +1236,15 @@ class DiscourseClient:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
groupid: the ID of the group
|
groupid: the ID of the group
|
||||||
username: the user name of the user
|
userid: the ID of the user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON API response
|
JSON API response
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._request(DELETE, f"/groups/{groupid}/members.json", json={"usernames": username})
|
return self._delete(
|
||||||
|
"/admin/groups/{0}/members.json".format(groupid), user_id=userid
|
||||||
|
)
|
||||||
|
|
||||||
def color_schemes(self, **kwargs):
|
def color_schemes(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1374,7 +1276,9 @@ class DiscourseClient:
|
|||||||
kwargs["enabled"] = "true"
|
kwargs["enabled"] = "true"
|
||||||
else:
|
else:
|
||||||
kwargs["enabled"] = "false"
|
kwargs["enabled"] = "false"
|
||||||
kwargs["colors"] = [{"name": name, "hex": color} for name, color in colors.items()]
|
kwargs["colors"] = [
|
||||||
|
{"name": name, "hex": color} for name, color in colors.items()
|
||||||
|
]
|
||||||
kwargs = {"color_scheme": kwargs}
|
kwargs = {"color_scheme": kwargs}
|
||||||
return self._post("/admin/color_schemes.json", json=True, **kwargs)
|
return self._post("/admin/color_schemes.json", json=True, **kwargs)
|
||||||
|
|
||||||
@ -1416,7 +1320,7 @@ class DiscourseClient:
|
|||||||
kwargs["locked"] = "true"
|
kwargs["locked"] = "true"
|
||||||
else:
|
else:
|
||||||
kwargs["locked"] = "false"
|
kwargs["locked"] = "false"
|
||||||
return self._put(f"/admin/users/{user_id}/trust_level_lock", **kwargs)
|
return self._put("/admin/users/{}/trust_level_lock".format(user_id), **kwargs)
|
||||||
|
|
||||||
def block(self, user_id, **kwargs):
|
def block(self, user_id, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1429,23 +1333,23 @@ class DiscourseClient:
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._put(f"/admin/users/{user_id}/block", **kwargs)
|
return self._put("/admin/users/{}/block".format(user_id), **kwargs)
|
||||||
|
|
||||||
def upload_image(self, image, upload_type, synchronous, **kwargs):
|
def upload_image(self, image, type, synchronous, **kwargs):
|
||||||
"""
|
"""
|
||||||
Upload image or avatar
|
Upload image or avatar
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name:
|
name:
|
||||||
file:
|
file:
|
||||||
upload_type: one of "avatar" "profile_background" "card_background" "custom_emoji" "composer"
|
type:
|
||||||
synchronous:
|
synchronous:
|
||||||
**kwargs:
|
**kwargs:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
kwargs["type"] = upload_type
|
kwargs["type"] = type
|
||||||
if bool(synchronous):
|
if bool(synchronous):
|
||||||
kwargs["synchronous"] = "true"
|
kwargs["synchronous"] = "true"
|
||||||
else:
|
else:
|
||||||
@ -1453,20 +1357,20 @@ class DiscourseClient:
|
|||||||
files = {"file": open(image, "rb")}
|
files = {"file": open(image, "rb")}
|
||||||
return self._post("/uploads.json", files=files, **kwargs)
|
return self._post("/uploads.json", files=files, **kwargs)
|
||||||
|
|
||||||
def user_actions(self, username, actions_filter, offset=0, **kwargs):
|
def user_actions(self, username, filter, offset=0, **kwargs):
|
||||||
"""
|
"""
|
||||||
List all possible user actions
|
List all possible user actions
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username:
|
username:
|
||||||
actions_filter:
|
filter:
|
||||||
**kwargs:
|
**kwargs:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
kwargs["username"] = username
|
kwargs["username"] = username
|
||||||
kwargs["filter"] = actions_filter
|
kwargs["filter"] = filter
|
||||||
kwargs["offset"] = offset
|
kwargs["offset"] = offset
|
||||||
return self._get("/user_actions.json", **kwargs)["user_actions"]
|
return self._get("/user_actions.json", **kwargs)["user_actions"]
|
||||||
|
|
||||||
@ -1495,8 +1399,7 @@ class DiscourseClient:
|
|||||||
https://github.com/discourse/discourse-data-explorer
|
https://github.com/discourse/discourse-data-explorer
|
||||||
"""
|
"""
|
||||||
return self._post(
|
return self._post(
|
||||||
f"/admin/plugins/explorer/queries/{query_id}/run",
|
"/admin/plugins/explorer/queries/{}/run".format(query_id), **kwargs
|
||||||
**kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def notifications(self, category_id, **kwargs):
|
def notifications(self, category_id, **kwargs):
|
||||||
@ -1509,13 +1412,7 @@ class DiscourseClient:
|
|||||||
notification_level=(int)
|
notification_level=(int)
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return self._post(f"/category/{category_id}/notifications", **kwargs)
|
return self._post("/category/{}/notifications".format(category_id), **kwargs)
|
||||||
|
|
||||||
def about(self):
|
|
||||||
"""
|
|
||||||
Get site info
|
|
||||||
"""
|
|
||||||
return self._get("/about.json")
|
|
||||||
|
|
||||||
def _get(self, path, override_request_kwargs=None, **kwargs):
|
def _get(self, path, override_request_kwargs=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1528,13 +1425,10 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._request(
|
return self._request(
|
||||||
GET,
|
GET, path, params=kwargs, override_request_kwargs=override_request_kwargs
|
||||||
path,
|
|
||||||
params=kwargs,
|
|
||||||
override_request_kwargs=override_request_kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _put(self, path, *, json=False, override_request_kwargs=None, **kwargs):
|
def _put(self, path, json=False, override_request_kwargs=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -1546,28 +1440,16 @@ class DiscourseClient:
|
|||||||
"""
|
"""
|
||||||
if not json:
|
if not json:
|
||||||
return self._request(
|
return self._request(
|
||||||
PUT,
|
PUT, path, data=kwargs, override_request_kwargs=override_request_kwargs
|
||||||
path,
|
|
||||||
data=kwargs,
|
|
||||||
override_request_kwargs=override_request_kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return self._request(
|
return self._request(
|
||||||
PUT,
|
PUT, path, json=kwargs, override_request_kwargs=override_request_kwargs
|
||||||
path,
|
|
||||||
json=kwargs,
|
|
||||||
override_request_kwargs=override_request_kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _post(
|
def _post(
|
||||||
self,
|
self, path, files=None, json=False, override_request_kwargs=None, **kwargs
|
||||||
path,
|
|
||||||
*,
|
|
||||||
files=None,
|
|
||||||
json=False,
|
|
||||||
override_request_kwargs=None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -1607,10 +1489,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return self._request(
|
return self._request(
|
||||||
DELETE,
|
DELETE, path, params=kwargs, override_request_kwargs=override_request_kwargs
|
||||||
path,
|
|
||||||
params=kwargs,
|
|
||||||
override_request_kwargs=override_request_kwargs,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
@ -1672,12 +1551,12 @@ class DiscourseClient:
|
|||||||
break
|
break
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
try:
|
try:
|
||||||
msg = ",".join(response.json()["errors"])
|
msg = u",".join(response.json()["errors"])
|
||||||
except (ValueError, TypeError, KeyError):
|
except (ValueError, TypeError, KeyError):
|
||||||
if response.reason:
|
if response.reason:
|
||||||
msg = response.reason
|
msg = response.reason
|
||||||
else:
|
else:
|
||||||
msg = f"{response.status_code}: {response.text}"
|
msg = u"{0}: {1}".format(response.status_code, response.text)
|
||||||
|
|
||||||
if 400 <= response.status_code < 500:
|
if 400 <= response.status_code < 500:
|
||||||
if 429 == response.status_code:
|
if 429 == response.status_code:
|
||||||
@ -1685,21 +1564,26 @@ class DiscourseClient:
|
|||||||
content_type = response.headers.get("Content-Type")
|
content_type = response.headers.get("Content-Type")
|
||||||
if content_type is not None and "application/json" in content_type:
|
if content_type is not None and "application/json" in content_type:
|
||||||
ret = response.json()
|
ret = response.json()
|
||||||
wait_delay = retry_backoff + ret["extras"]["wait_seconds"] # how long to back off for.
|
wait_delay = (
|
||||||
|
retry_backoff + ret["extras"]["wait_seconds"]
|
||||||
|
) # how long to back off for.
|
||||||
else:
|
else:
|
||||||
# We got an early 429 error without a proper JSON body
|
# We got an early 429 error without a proper JSON body
|
||||||
ret = response.content
|
ret = response.content
|
||||||
wait_delay = retry_backoff + 10
|
wait_delay = retry_backoff + 10
|
||||||
|
|
||||||
limit_name = response.headers.get("Discourse-Rate-Limit-Error-Code", "<unknown>")
|
limit_name = response.headers.get(
|
||||||
|
"Discourse-Rate-Limit-Error-Code", "<unknown>")
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
f"We have been rate limited (limit: {limit_name}) and will wait {wait_delay} seconds ({retry_count} retries left)",
|
"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:
|
if retry_count > 1:
|
||||||
time.sleep(wait_delay)
|
time.sleep(wait_delay)
|
||||||
retry_count -= 1
|
retry_count -= 1
|
||||||
log.debug(f"API returned {ret}")
|
log.debug("API returned {0}".format(ret))
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
raise DiscourseClientError(msg, response=response)
|
raise DiscourseClientError(msg, response=response)
|
||||||
@ -1715,8 +1599,7 @@ class DiscourseClient:
|
|||||||
|
|
||||||
if response.status_code == 302:
|
if response.status_code == 302:
|
||||||
raise DiscourseError(
|
raise DiscourseError(
|
||||||
"Unexpected Redirect, invalid api key or host?",
|
"Unexpected Redirect, invalid api key or host?", response=response
|
||||||
response=response,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
json_content = "application/json; charset=utf-8"
|
json_content = "application/json; charset=utf-8"
|
||||||
@ -1727,22 +1610,24 @@ class DiscourseClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
raise DiscourseError(
|
raise DiscourseError(
|
||||||
f'Invalid Response, expecting "{json_content}" got "{content_type}"',
|
'Invalid Response, expecting "{0}" got "{1}"'.format(
|
||||||
|
json_content, content_type
|
||||||
|
),
|
||||||
response=response,
|
response=response,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decoded = response.json()
|
decoded = response.json()
|
||||||
except ValueError as err:
|
except ValueError:
|
||||||
raise DiscourseError("failed to decode response", response=response) from err
|
raise DiscourseError("failed to decode response", response=response)
|
||||||
|
|
||||||
# Checking "errors" length because
|
# Checking "errors" length because
|
||||||
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run) # noqa: ERA001
|
# data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run)
|
||||||
# sends an empty errors array
|
# sends an empty errors array
|
||||||
if "errors" in decoded and len(decoded["errors"]) > 0:
|
if "errors" in decoded and len(decoded["errors"]) > 0:
|
||||||
message = decoded.get("message")
|
message = decoded.get("message")
|
||||||
if not message:
|
if not message:
|
||||||
message = ",".join(decoded["errors"])
|
message = u",".join(decoded["errors"])
|
||||||
raise DiscourseError(message, response=response)
|
raise DiscourseError(message, response=response)
|
||||||
|
|
||||||
return decoded
|
return decoded
|
||||||
|
|||||||
@ -38,7 +38,7 @@ class DiscourseCmd(cmd.Cmd):
|
|||||||
return method(*args, **kwargs)
|
return method(*args, **kwargs)
|
||||||
|
|
||||||
except DiscourseError as e:
|
except DiscourseError as e:
|
||||||
sys.stderr.write(f"{e}, {e.response.text}\n")
|
print(e, e.response.text)
|
||||||
return e.response
|
return e.response
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@ -57,11 +57,7 @@ class DiscourseCmd(cmd.Cmd):
|
|||||||
"""Writes output of the command to console"""
|
"""Writes output of the command to console"""
|
||||||
try:
|
try:
|
||||||
json.dump(
|
json.dump(
|
||||||
result,
|
result, self.output, sort_keys=True, indent=4, separators=(",", ": ")
|
||||||
self.output,
|
|
||||||
sort_keys=True,
|
|
||||||
indent=4,
|
|
||||||
separators=(",", ": "),
|
|
||||||
)
|
)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
self.output.write(result.text)
|
self.output.write(result.text)
|
||||||
|
|||||||
@ -43,20 +43,18 @@ def sso_validate(payload, signature, secret):
|
|||||||
raise DiscourseError("No SSO payload or signature.")
|
raise DiscourseError("No SSO payload or signature.")
|
||||||
|
|
||||||
if not secret:
|
if not secret:
|
||||||
raise DiscourseError("Invalid secret.")
|
raise DiscourseError("Invalid secret..")
|
||||||
|
|
||||||
payload = unquote(payload)
|
payload = unquote(payload)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise DiscourseError("Invalid payload.")
|
raise DiscourseError("Invalid payload..")
|
||||||
|
|
||||||
decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
|
decoded = b64decode(payload.encode("utf-8")).decode("utf-8")
|
||||||
if "nonce" not in decoded:
|
if "nonce" not in decoded:
|
||||||
raise DiscourseError("Invalid payload.")
|
raise DiscourseError("Invalid payload..")
|
||||||
|
|
||||||
h = hmac.new(
|
h = hmac.new(
|
||||||
secret.encode("utf-8"),
|
secret.encode("utf-8"), payload.encode("utf-8"), digestmod=hashlib.sha256
|
||||||
payload.encode("utf-8"),
|
|
||||||
digestmod=hashlib.sha256,
|
|
||||||
)
|
)
|
||||||
this_signature = h.hexdigest()
|
this_signature = h.hexdigest()
|
||||||
|
|
||||||
@ -94,7 +92,7 @@ def sso_redirect_url(nonce, secret, email, external_id, username, **kwargs):
|
|||||||
"email": email,
|
"email": email,
|
||||||
"external_id": external_id,
|
"external_id": external_id,
|
||||||
"username": username,
|
"username": username,
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return "/session/sso_login?%s" % sso_payload(secret, **kwargs)
|
return "/session/sso_login?%s" % sso_payload(secret, **kwargs)
|
||||||
|
|||||||
@ -1,136 +0,0 @@
|
|||||||
"""Test fixtures."""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydiscourse import client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_secret():
|
|
||||||
return "d836444a9e4084d5b224a60c208dce14"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_nonce():
|
|
||||||
return "cb68251eefb5211e58c00ff1395f0c0b"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_payload():
|
|
||||||
return "bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def sso_signature():
|
|
||||||
return "2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def name():
|
|
||||||
return "sam"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def username():
|
|
||||||
return "samsam"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def external_id():
|
|
||||||
return "hello123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def email():
|
|
||||||
return "test@test.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def redirect_url(sso_payload):
|
|
||||||
return f"/session/sso_login?sso={sso_payload}YW0mdXNlcm5hbWU9c2Ftc2FtJmVtYWlsPXRlc3QlNDB0ZXN0LmNvbSZleHRl%0Acm5hbF9pZD1oZWxsbzEyMw%3D%3D%0A&sig=1c884222282f3feacd76802a9dd94e8bc8deba5d619b292bed75d63eb3152c0b"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_host():
|
|
||||||
return "http://testhost"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_api_username():
|
|
||||||
return "testuser"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_api_key():
|
|
||||||
return "testkey"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def discourse_client(discourse_host, discourse_api_username, discourse_api_key):
|
|
||||||
return client.DiscourseClient(
|
|
||||||
discourse_host,
|
|
||||||
discourse_api_username,
|
|
||||||
discourse_api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _frozen_time(mocker):
|
|
||||||
now = mocker.patch("pydiscourse.client.now")
|
|
||||||
now.return_value = datetime.datetime(
|
|
||||||
2023,
|
|
||||||
8,
|
|
||||||
13,
|
|
||||||
12,
|
|
||||||
30,
|
|
||||||
15,
|
|
||||||
tzinfo=datetime.timezone.utc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def discourse_request(discourse_host, discourse_client, requests_mock):
|
|
||||||
"""Fixture for mocking Discourse API requests.
|
|
||||||
|
|
||||||
The only request arguments are the method and the path.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
>>> def test_something(discourse_request):
|
|
||||||
>>> request = discourse_request(
|
|
||||||
>>> "put", # the method, case-insensitive
|
|
||||||
>>> "/the-path.json?q=4", # the absolute path with query, NO host
|
|
||||||
>>> headers={'content-type': 'text/plain'}, # override default headers
|
|
||||||
>>> content=b"ERROR", # override bytestring response
|
|
||||||
>>> )
|
|
||||||
|
|
||||||
If `content` is provided, that will be used as the response body.
|
|
||||||
If `json` is provided, then the body will return the given JSON-
|
|
||||||
compatable Python structure (e.g. dictionary).
|
|
||||||
If neither is given then the return `json` will be an empty
|
|
||||||
dictionary (`{}`).
|
|
||||||
|
|
||||||
Returns a function for inserting sensible default values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def inner(method, path, headers=None, json=None, content=None):
|
|
||||||
full_path = f"{discourse_host}{path}"
|
|
||||||
if not headers:
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Api-Key": discourse_client.api_key,
|
|
||||||
"Api-Username": discourse_client.api_username,
|
|
||||||
}
|
|
||||||
|
|
||||||
kwargs = {}
|
|
||||||
if content:
|
|
||||||
kwargs["content"] = content
|
|
||||||
elif json:
|
|
||||||
kwargs["json"] = json
|
|
||||||
else:
|
|
||||||
kwargs["json"] = {}
|
|
||||||
|
|
||||||
return requests_mock.request(method, full_path, headers=headers, **kwargs)
|
|
||||||
|
|
||||||
return inner
|
|
||||||
@ -1,220 +1,211 @@
|
|||||||
"""Tests for the client methods."""
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
import urllib.parse
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
from pydiscourse import client
|
||||||
|
|
||||||
|
|
||||||
def test_empty_content_http_ok(discourse_host, discourse_client, discourse_request):
|
if sys.version_info < (3,):
|
||||||
"""Empty content should not raise error
|
|
||||||
|
|
||||||
Critical to test against *bytestrings* rather than unicode
|
def b(x):
|
||||||
"""
|
return x
|
||||||
discourse_request(
|
|
||||||
"get",
|
|
||||||
"/users/admin/1/unsuspend",
|
else:
|
||||||
headers={"Content-Type": "text/plain; charset=utf-8"},
|
import codecs
|
||||||
content=b" ",
|
|
||||||
|
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
|
||||||
|
request.return_value = mock.MagicMock(
|
||||||
|
headers={"content-type": "application/json; charset=utf-8"}
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = discourse_client._request("GET", "/users/admin/1/unsuspend", {})
|
|
||||||
|
|
||||||
assert resp is None
|
class ClientBaseTestCase(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.host = "http://testhost"
|
||||||
|
self.api_username = "testuser"
|
||||||
|
self.api_key = "testkey"
|
||||||
|
|
||||||
|
self.client = client.DiscourseClient(self.host, self.api_username, self.api_key)
|
||||||
|
|
||||||
|
def assertRequestCalled(self, request, verb, url, **params):
|
||||||
|
self.assertTrue(request.called)
|
||||||
|
|
||||||
|
args, kwargs = request.call_args
|
||||||
|
|
||||||
|
self.assertEqual(args[0], verb)
|
||||||
|
self.assertEqual(args[1], self.host + url)
|
||||||
|
|
||||||
|
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"], params)
|
||||||
|
|
||||||
|
|
||||||
class TestUserManagement:
|
class TestClientRequests(ClientBaseTestCase):
|
||||||
def test_get_user(self, discourse_host, discourse_client, discourse_request):
|
"""
|
||||||
request = discourse_request(
|
Tests for common request handling
|
||||||
"get",
|
"""
|
||||||
"/users/someuser.json",
|
|
||||||
json={"user": "someuser"},
|
@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):
|
||||||
|
|
||||||
|
def test_user(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
self.client.user("someuser")
|
||||||
|
self.assertRequestCalled(request, "GET", "/users/someuser.json")
|
||||||
|
|
||||||
|
def test_create_user(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
self.client.create_user(
|
||||||
|
"Test User", "testuser", "test@example.com", "notapassword"
|
||||||
)
|
)
|
||||||
discourse_client.user("someuser")
|
self.assertEqual(request.call_count, 2)
|
||||||
|
|
||||||
assert request.called_once
|
# XXX incomplete
|
||||||
|
|
||||||
def test_users(self, discourse_client, discourse_request):
|
def test_update_email(self, request):
|
||||||
request = discourse_request("get", "/admin/users/list/active.json")
|
prepare_response(request)
|
||||||
discourse_client.users()
|
email = "test@example.com"
|
||||||
assert request.called_once
|
self.client.update_email("someuser", email)
|
||||||
|
self.assertRequestCalled(
|
||||||
def test_create_user(self, discourse_host, discourse_client, discourse_request):
|
request, "PUT", "/users/someuser/preferences/email", email=email
|
||||||
session_request = discourse_request(
|
|
||||||
"get",
|
|
||||||
"/session/hp.json",
|
|
||||||
json={"challenge": "challenge", "value": "value"},
|
|
||||||
)
|
|
||||||
user_request = discourse_request("post", "/users")
|
|
||||||
discourse_client.create_user(
|
|
||||||
"Test User",
|
|
||||||
"testuser",
|
|
||||||
"test@example.com",
|
|
||||||
"notapassword",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert session_request.called_once
|
def test_update_user(self, request):
|
||||||
assert user_request.called_once
|
prepare_response(request)
|
||||||
|
self.client.update_user("someuser", a="a", b="b")
|
||||||
|
self.assertRequestCalled(request, "PUT", "/users/someuser", a="a", b="b")
|
||||||
|
|
||||||
def test_update_email(self, discourse_host, discourse_client, discourse_request):
|
def test_update_username(self, request):
|
||||||
request = discourse_request("put", "/users/someuser/preferences/email")
|
prepare_response(request)
|
||||||
discourse_client.update_email("someuser", "newmeail@example.com")
|
self.client.update_username("someuser", "newname")
|
||||||
|
self.assertRequestCalled(
|
||||||
assert request.called_once
|
request, "PUT", "/users/someuser/preferences/username", username="newname"
|
||||||
|
|
||||||
def test_update_user(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("put", "/users/someuser")
|
|
||||||
discourse_client.update_user("someuser", a="a", b="b")
|
|
||||||
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_update_username(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("put", "/users/someuser/preferences/username")
|
|
||||||
discourse_client.update_username("someuser", "newname")
|
|
||||||
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_by_external_id(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request(
|
|
||||||
"get",
|
|
||||||
"/users/by-external/123",
|
|
||||||
json={"user": "123"},
|
|
||||||
)
|
)
|
||||||
discourse_client.by_external_id(123)
|
|
||||||
|
|
||||||
assert request.called_once
|
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_anonymize(self, discourse_client, discourse_request):
|
def test_suspend_user(self, request):
|
||||||
request = discourse_request("put", "/admin/users/123/anonymize")
|
prepare_response(request)
|
||||||
discourse_client.anonymize(123)
|
self.client.suspend(123, 1, "Testing")
|
||||||
|
self.assertRequestCalled(
|
||||||
assert request.called_once
|
request, "PUT", "/admin/users/123/suspend", duration=1, reason="Testing"
|
||||||
|
|
||||||
@pytest.mark.usefixtures("_frozen_time")
|
|
||||||
def test_suspend_user(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("put", "/admin/users/123/suspend")
|
|
||||||
discourse_client.suspend(123, 1, "Testing")
|
|
||||||
|
|
||||||
assert request.called_once
|
|
||||||
assert request.last_request.method == "PUT"
|
|
||||||
|
|
||||||
request_payload = urllib.parse.parse_qs(request.last_request.text)
|
|
||||||
|
|
||||||
assert request_payload["reason"] == ["Testing"]
|
|
||||||
assert request_payload["suspend_until"] == ["2023-08-14T12:30:15+00:00"]
|
|
||||||
|
|
||||||
def test_unsuspend_user(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("put", "/admin/users/123/unsuspend")
|
|
||||||
discourse_client.unsuspend(123)
|
|
||||||
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_user_bagdes(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/user-badges/myusername.json")
|
|
||||||
discourse_client.user_badges("myusername")
|
|
||||||
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
|
|
||||||
class TestTopics:
|
|
||||||
def test_hot_topics(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/hot.json")
|
|
||||||
discourse_client.hot_topics()
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_latest_topics(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/latest.json")
|
|
||||||
discourse_client.latest_topics()
|
|
||||||
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_new_topics(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/new.json")
|
|
||||||
discourse_client.new_topics()
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_topic(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/t/some-test-slug/22.json")
|
|
||||||
discourse_client.topic("some-test-slug", 22)
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_topics_by(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request(
|
|
||||||
"get",
|
|
||||||
"/topics/created-by/someuser.json",
|
|
||||||
json={"topic_list": {"topics": []}},
|
|
||||||
)
|
)
|
||||||
discourse_client.topics_by("someuser")
|
|
||||||
|
|
||||||
assert request.called_once
|
def test_unsuspend_user(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
self.client.unsuspend(123)
|
||||||
|
self.assertRequestCalled(request, "PUT", "/admin/users/123/unsuspend")
|
||||||
|
|
||||||
def test_invite_user_to_topic(self, discourse_client, discourse_request):
|
def test_user_bagdes(self, request):
|
||||||
request = discourse_request("post", "/t/22/invite.json")
|
prepare_response(request)
|
||||||
discourse_client.invite_user_to_topic("test@example.com", 22)
|
self.client.user_badges("username")
|
||||||
assert request.called_once
|
self.assertRequestCalled(
|
||||||
|
request, "GET", "/user-badges/{}.json".format("username")
|
||||||
request_payload = urllib.parse.parse_qs(request.last_request.text)
|
|
||||||
|
|
||||||
assert request_payload["email"] == ["test@example.com"]
|
|
||||||
assert request_payload["topic_id"] == ["22"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPosts:
|
|
||||||
def test_latest_posts(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/posts.json?before=54321")
|
|
||||||
discourse_client.latest_posts(before=54321)
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_post_by_number(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/posts/by_number/8796/5")
|
|
||||||
discourse_client.post_by_number(8796, 5)
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearch:
|
|
||||||
def test_search(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("get", "/search.json?q=needle")
|
|
||||||
discourse_client.search(q="needle")
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
|
|
||||||
class TestCategories:
|
|
||||||
def test_categories(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request(
|
|
||||||
"get",
|
|
||||||
"/categories.json",
|
|
||||||
json={"category_list": {"categories": []}},
|
|
||||||
)
|
)
|
||||||
discourse_client.categories()
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_update_category(self, discourse_client, discourse_request):
|
|
||||||
request = discourse_request("put", "/categories/123")
|
|
||||||
discourse_client.update_category(123, a="a", b="b")
|
|
||||||
|
|
||||||
request_payload = request.last_request.json()
|
|
||||||
|
|
||||||
assert request_payload["a"] == "a"
|
|
||||||
assert request_payload["b"] == "b"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBadges:
|
@mock.patch("requests.request")
|
||||||
def test_badges(self, discourse_client, discourse_request):
|
class TestTopics(ClientBaseTestCase):
|
||||||
request = discourse_request("get", "/admin/badges.json")
|
|
||||||
discourse_client.badges()
|
|
||||||
assert request.called_once
|
|
||||||
|
|
||||||
def test_grant_badge_to(self, discourse_client, discourse_request):
|
def test_hot_topics(self, request):
|
||||||
request = discourse_request("post", "/user_badges")
|
prepare_response(request)
|
||||||
discourse_client.grant_badge_to("username", 1)
|
self.client.hot_topics()
|
||||||
|
self.assertRequestCalled(request, "GET", "/hot.json")
|
||||||
|
|
||||||
request_payload = urllib.parse.parse_qs(request.last_request.text)
|
def test_latest_topics(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
self.client.latest_topics()
|
||||||
|
self.assertRequestCalled(request, "GET", "/latest.json")
|
||||||
|
|
||||||
assert request_payload["username"] == ["username"]
|
def test_new_topics(self, request):
|
||||||
assert request_payload["badge_id"] == ["1"]
|
prepare_response(request)
|
||||||
|
self.client.new_topics()
|
||||||
|
self.assertRequestCalled(request, "GET", "/new.json")
|
||||||
|
|
||||||
|
def test_topic(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
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)
|
||||||
|
r = self.client.topics_by("someuser")
|
||||||
|
self.assertRequestCalled(request, "GET", "/topics/created-by/someuser.json")
|
||||||
|
self.assertEqual(r, request().json()["topic_list"]["topics"])
|
||||||
|
|
||||||
|
def invite_user_to_topic(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
email = "test@example.com"
|
||||||
|
self.client.invite_user_to_topic(email, 22)
|
||||||
|
self.assertRequestCalled(
|
||||||
|
request, "POST", "/t/22/invite.json", email=email, topic_id=22
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAbout:
|
@mock.patch("pydiscourse.client.requests.request")
|
||||||
def test_about(self, discourse_client, discourse_request):
|
class MiscellaneousTests(ClientBaseTestCase):
|
||||||
request = discourse_request("get", "/about.json")
|
|
||||||
discourse_client.about()
|
def test_search(self, request):
|
||||||
assert request.called_once
|
prepare_response(request)
|
||||||
|
self.client.search("needle")
|
||||||
|
self.assertRequestCalled(request, "GET", "/search.json", term="needle")
|
||||||
|
|
||||||
|
def test_categories(self, request):
|
||||||
|
prepare_response(request)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|||||||
@ -1,95 +1,76 @@
|
|||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pydiscourse import sso
|
from pydiscourse import sso
|
||||||
from pydiscourse.exceptions import DiscourseError
|
from pydiscourse.exceptions import DiscourseError
|
||||||
|
|
||||||
|
|
||||||
def test_sso_validate_missing_payload():
|
class SSOTestCase(unittest.TestCase):
|
||||||
with pytest.raises(DiscourseError) as excinfo:
|
|
||||||
sso.sso_validate(None, "abc", "123")
|
|
||||||
|
|
||||||
assert excinfo.value.args[0] == "No SSO payload or signature."
|
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_sso_validate_empty_payload():
|
def test_missing_args(self):
|
||||||
with pytest.raises(DiscourseError) as excinfo:
|
with self.assertRaises(DiscourseError):
|
||||||
sso.sso_validate("", "abc", "123")
|
sso.sso_validate(None, self.signature, self.secret)
|
||||||
|
|
||||||
assert excinfo.value.args[0] == "Invalid payload."
|
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_sso_validate_missing_signature():
|
def test_invalid_signature(self):
|
||||||
with pytest.raises(DiscourseError) as excinfo:
|
with self.assertRaises(DiscourseError):
|
||||||
sso.sso_validate("sig", None, "123")
|
sso.sso_validate(self.payload, "notavalidsignature", self.secret)
|
||||||
|
|
||||||
assert excinfo.value.args[0] == "No SSO payload or signature."
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_secret", [None, ""])
|
self.assertIn("/session/sso_login", url[:20])
|
||||||
def test_sso_validate_missing_secret(bad_secret):
|
|
||||||
with pytest.raises(DiscourseError) as excinfo:
|
|
||||||
sso.sso_validate("payload", "signature", bad_secret)
|
|
||||||
|
|
||||||
assert excinfo.value.args[0] == "Invalid secret."
|
# 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("&")))
|
||||||
|
|
||||||
def test_sso_validate_invalid_signature(sso_payload, sso_signature, sso_secret):
|
self.assertEqual(
|
||||||
with pytest.raises(DiscourseError) as excinfo:
|
payload,
|
||||||
sso.sso_validate("Ym9i", sso_signature, sso_secret)
|
{
|
||||||
|
"username": self.username,
|
||||||
assert excinfo.value.args[0] == "Invalid payload."
|
"nonce": self.nonce,
|
||||||
|
"external_id": self.external_id,
|
||||||
|
"name": self.name,
|
||||||
def test_sso_validate_invalid_payload_nonce(sso_payload, sso_secret):
|
"email": self.email,
|
||||||
with pytest.raises(DiscourseError) as excinfo:
|
},
|
||||||
sso.sso_validate(sso_payload, "notavalidsignature", sso_secret)
|
)
|
||||||
|
|
||||||
assert excinfo.value.args[0] == "Payload does not match signature."
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_nonce(sso_payload, sso_signature, sso_secret, sso_nonce):
|
|
||||||
generated_nonce = sso.sso_validate(sso_payload, sso_signature, sso_secret)
|
|
||||||
assert generated_nonce == sso_nonce
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_redirect_url(
|
|
||||||
sso_secret,
|
|
||||||
sso_nonce,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
username,
|
|
||||||
external_id,
|
|
||||||
redirect_url,
|
|
||||||
):
|
|
||||||
url = sso.sso_redirect_url(
|
|
||||||
sso_nonce,
|
|
||||||
sso_secret,
|
|
||||||
email,
|
|
||||||
external_id,
|
|
||||||
username,
|
|
||||||
name="sam",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "/session/sso_login" in 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], sso_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("&"))
|
|
||||||
|
|
||||||
assert payload == {
|
|
||||||
"username": username,
|
|
||||||
"nonce": sso_nonce,
|
|
||||||
"external_id": external_id,
|
|
||||||
"name": name,
|
|
||||||
"email": email,
|
|
||||||
}
|
|
||||||
|
|||||||
24
tox.ini
24
tox.ini
@ -1,28 +1,30 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py38, py39, py310, py311
|
envlist = py37, py38, py39, py310
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
|
3.7: py37
|
||||||
3.8: py38
|
3.8: py38
|
||||||
3.9: py39
|
3.9: py39
|
||||||
3.10: py310
|
3.10: py310
|
||||||
3.11: py311
|
|
||||||
3.12: py312
|
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
setenv =
|
setenv =
|
||||||
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
|
PYTHONPATH = {toxinidir}:{toxinidir}/pydiscourse
|
||||||
commands =
|
commands = pytest {posargs} --cov=pydiscourse
|
||||||
pytest {posargs} --cov=pydiscourse
|
|
||||||
coverage report -m --include='**/pydiscourse/client.py' --fail-under=46
|
|
||||||
coverage report -m --include='**/pydiscourse/sso.py' --fail-under=100
|
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements.txt
|
-r{toxinidir}/requirements.txt
|
||||||
|
|
||||||
[testenv:ruff]
|
[testenv:flake8]
|
||||||
basepython=python
|
basepython=python
|
||||||
skip_install=true
|
|
||||||
deps=
|
deps=
|
||||||
ruff
|
flake8
|
||||||
|
flake8_docstrings
|
||||||
commands=
|
commands=
|
||||||
ruff .
|
flake8 src/pydiscourse --docstring-convention google --ignore D415
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
ignore = E126,E128
|
||||||
|
max-line-length = 119
|
||||||
|
exclude = .ropeproject
|
||||||
|
max-complexity = 10
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user