Skip to content

Commit dfd6baf

Browse files
authored
Add knowledge base API support
1 parent 7b68689 commit dfd6baf

4 files changed

Lines changed: 242 additions & 3 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "retailcrm"
7-
version = "5.2.1"
7+
version = "5.4.0"
88
authors = [
99
{ name="Alex Lushpai", email="lushpai@gmail.com" },
1010
]

‎retailcrm/versions/base.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ def post(self, url, version=True):
4747

4848
return Response(response.status_code, response.json())
4949

50+
def post_file(self, url, content):
51+
"""Post original file bytes or a binary file object."""
52+
response = requests.post(
53+
self.api_url + '/' + self.api_version + url,
54+
data=content,
55+
headers={'X-API-KEY': self.api_key,
56+
'Content-Type': 'application/octet-stream'},
57+
)
58+
self.parameters = {}
59+
return Response(response.status_code, response.json())
60+
5061
def api_versions(self):
5162
"""
5263
:return: Response

‎retailcrm/versions/v5.py‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import json
8+
from urllib.parse import quote
89

910
from retailcrm.versions.base import Base
1011

@@ -881,11 +882,14 @@ def files(self, filters=None, limit=20, page=1):
881882

882883
def files_upload(self, file, site=None):
883884
"""
884-
:param file: objects
885+
:param file: bytes, a binary file object, or a legacy dictionary
885886
:param site: string
886887
:return: Response
887888
"""
888889

890+
if isinstance(file, (bytes, bytearray)) or hasattr(file, 'read'):
891+
return self.post_file('/files/upload', file)
892+
889893
self.parameters['file'] = json.dumps(file)
890894

891895
if site is not None:
@@ -2143,3 +2147,72 @@ def statistic_update(self):
21432147
"""
21442148

21452149
return self.get('/statistic/update')
2150+
2151+
def knowledge_base_sections(self):
2152+
"""Return all sections without pagination."""
2153+
return self.get('/knowledge-base/sections')
2154+
2155+
def knowledge_base_section_create(self, section):
2156+
"""Create a section from name and optional parentId fields."""
2157+
self.parameters['section'] = json.dumps(section)
2158+
return self.post('/knowledge-base/sections/create')
2159+
2160+
def knowledge_base_section_edit(self, uid, section):
2161+
"""Update supplied fields only; parentId=0 or None moves to the root."""
2162+
self.parameters['section'] = json.dumps(section)
2163+
return self.post('/knowledge-base/sections/' + quote(str(uid), safe='') + '/edit')
2164+
2165+
def knowledge_base_section_delete(self, uid):
2166+
"""Delete a section together with its subsections and documents."""
2167+
return self.post('/knowledge-base/sections/' + quote(str(uid), safe='') + '/delete')
2168+
2169+
def knowledge_base_documents(self, filters=None, limit=20, cursor=None):
2170+
"""List documents using cursor pagination, including Markdown and file URLs.
2171+
2172+
Pass pagination.nextCursor as cursor; an empty nextCursor ends the list.
2173+
2174+
Filters: ids, externalIds, sectionId, directOnly, availableForCopilot,
2175+
hasFile, search. directOnly without sectionId selects root documents.
2176+
"""
2177+
self.parameters['filter'] = {
2178+
name: int(value) if isinstance(value, bool) else value
2179+
for name, value in (filters or {}).items()
2180+
}
2181+
self.parameters['limit'] = limit
2182+
if cursor is not None:
2183+
self.parameters['cursor'] = cursor
2184+
return self.get('/knowledge-base/documents')
2185+
2186+
def knowledge_base_document(self, uid, uid_type='externalId'):
2187+
"""Get a document by externalId (default) or internal id."""
2188+
if uid_type != 'externalId':
2189+
self.parameters['by'] = uid_type
2190+
return self.get('/knowledge-base/documents/' + quote(str(uid), safe=''))
2191+
2192+
def knowledge_base_document_create(self, document):
2193+
"""Create a document. Attach an original file through files_edit."""
2194+
self.parameters['document'] = json.dumps(document)
2195+
return self.post('/knowledge-base/documents/create')
2196+
2197+
def knowledge_base_document_edit(self, uid, document, uid_type='externalId'):
2198+
"""Update only supplied fields, preserving False, zero, empty text and None."""
2199+
self.parameters['document'] = json.dumps(document)
2200+
if uid_type != 'externalId':
2201+
self.parameters['by'] = uid_type
2202+
return self.post('/knowledge-base/documents/' + quote(str(uid), safe='') + '/edit')
2203+
2204+
def knowledge_base_document_delete(self, uid, uid_type='externalId'):
2205+
"""Delete a document by externalId (default) or internal id."""
2206+
if uid_type != 'externalId':
2207+
self.parameters['by'] = uid_type
2208+
return self.post('/knowledge-base/documents/' + quote(str(uid), safe='') + '/delete')
2209+
2210+
def knowledge_base_documents_history(self, filters=None, limit=20, page=1):
2211+
"""Read changes using sinceId, documentId, startDate and endDate filters.
2212+
2213+
Content changes contain hashes rather than text. Sections have no history.
2214+
"""
2215+
self.parameters['filter'] = filters or {}
2216+
self.parameters['limit'] = limit
2217+
self.parameters['page'] = page
2218+
return self.get('/knowledge-base/documents/history')

‎tests/v5_tests.py‎

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
RetailCRM API client v5 tests
55
"""
66

7-
from urllib.parse import urlencode
7+
from urllib.parse import urlencode, parse_qs, urlsplit
88
import unittest
9+
import io
910
import os
1011
import retailcrm
1112
import pook
@@ -5823,3 +5824,157 @@ def test_statistic_update(self):
58235824

58245825
self.assertTrue(response.is_successful(), True)
58255826
self.assertTrue(response.get_status_code() < 400, True)
5827+
5828+
@pook.on
5829+
def test_knowledge_base_write_methods(self):
5830+
cases = [
5831+
('knowledge_base_section_create', ({'name': 'Shipping'},),
5832+
'sections/create', 'section', {'name': 'Shipping'}, None),
5833+
('knowledge_base_section_edit', (7, {'parentId': 0}),
5834+
'sections/7/edit', 'section', {'parentId': 0}, None),
5835+
('knowledge_base_section_delete', (7,),
5836+
'sections/7/delete', None, None, None),
5837+
('knowledge_base_document_create', ({'title': 'Shipping', 'externalId': 'doc-7'},),
5838+
'documents/create', 'document', {'title': 'Shipping', 'externalId': 'doc-7'}, None),
5839+
('knowledge_base_document_edit', (7, {'content': '', 'availableForCopilot': False, 'sectionId': None}, 'id'),
5840+
'documents/7/edit', 'document', {'content': '', 'availableForCopilot': False, 'sectionId': None}, 'id'),
5841+
('knowledge_base_document_edit', ('doc-7', {'externalId': None}),
5842+
'documents/doc-7/edit', 'document', {'externalId': None}, None),
5843+
('knowledge_base_document_delete', (7, 'id'),
5844+
'documents/7/delete', None, None, 'id'),
5845+
('knowledge_base_document_delete', ('doc-7',),
5846+
'documents/doc-7/delete', None, None, None),
5847+
]
5848+
for method, args, path, field, payload, by in cases:
5849+
with self.subTest(method=method, args=args):
5850+
parameters = {}
5851+
if field:
5852+
parameters[field] = json.dumps(payload)
5853+
if by:
5854+
parameters['by'] = by
5855+
request = (pook.post(os.getenv('RETAILCRM_URL') + '/api/v5/knowledge-base/' + path)
5856+
.headers({'X-API-KEY': os.getenv('RETAILCRM_KEY')}))
5857+
if parameters:
5858+
request.body(urlencode(parameters))
5859+
else:
5860+
request.filter(lambda req, _: not req.body)
5861+
mock = request.reply(200).headers(self.__header).json({'success': True, 'id': 7})
5862+
result = getattr(self.client, method)(*args)
5863+
self.assertTrue(mock.mock.done)
5864+
self.assertTrue(result.is_successful())
5865+
self.assertEqual(7, result.get_response()['id'])
5866+
self.assertEqual({}, self.client.parameters)
5867+
5868+
@pook.on
5869+
def test_knowledge_base_read_methods(self):
5870+
cases = [
5871+
('knowledge_base_sections', (), 'sections', {}, {'sections': [{'id': 1, 'parentId': None}]}),
5872+
('knowledge_base_document', ('doc-7',), 'documents/doc-7', {},
5873+
{'document': {'id': 7, 'content': None, 'file': None}}),
5874+
('knowledge_base_document', (7, 'id'), 'documents/7', {'by': ['id']},
5875+
{'document': {'id': 7, 'file': {'id': 9, 'url': 'https://example.com/file'}}}),
5876+
('knowledge_base_documents', ({'ids': [7, 8], 'externalIds': ['doc-7'], 'hasFile': False,
5877+
'directOnly': True, 'availableForCopilot': False,
5878+
'search': 'Shipping'}, 20, 'opaque+/='),
5879+
'documents', {'filter[ids][]': ['7', '8'],
5880+
'filter[externalIds][]': ['doc-7'], 'filter[hasFile]': ['0'],
5881+
'filter[directOnly]': ['1'], 'filter[availableForCopilot]': ['0'],
5882+
'filter[search]': ['Shipping'], 'limit': ['20'], 'cursor': ['opaque+/=']},
5883+
{'documents': [{'id': 7, 'content': '# Shipping', 'availableForCopilot': False}]}),
5884+
('knowledge_base_documents_history', ({'sinceId': 12, 'documentId': 7,
5885+
'startDate': '2026-10-26 10:00:00',
5886+
'endDate': '2026-10-26 11:00:00'},),
5887+
'documents/history', {'filter[sinceId]': ['12'], 'filter[documentId]': ['7'],
5888+
'filter[startDate]': ['2026-10-26 10:00:00'],
5889+
'filter[endDate]': ['2026-10-26 11:00:00'], 'limit': ['20'], 'page': ['1']},
5890+
{'history': [{'id': 13, 'field': 'section', 'oldValue': None, 'newValue': {'id': 2}}]}),
5891+
]
5892+
for method, args, path, query, body in cases:
5893+
with self.subTest(method=method, args=args):
5894+
body = dict(body, success=True)
5895+
url = os.getenv('RETAILCRM_URL') + '/api/v5/knowledge-base/' + path
5896+
mock = (pook.get(url).headers({'X-API-KEY': os.getenv('RETAILCRM_KEY')})
5897+
.filter(lambda req, _, expected=query: parse_qs(urlsplit(req.rawurl).query) == expected)
5898+
.reply(200).headers(self.__header).json(body))
5899+
result = getattr(self.client, method)(*args)
5900+
self.assertTrue(mock.mock.done)
5901+
self.assertEqual(body, result.get_response())
5902+
self.assertEqual({}, self.client.parameters)
5903+
5904+
@pook.on
5905+
def test_knowledge_base_documents_cursor_pagination(self):
5906+
responses = [
5907+
{'success': True, 'documents': [{'id': 7}], 'pagination': {'nextCursor': 'next+/='}},
5908+
{'success': True, 'documents': [{'id': 8}], 'pagination': {'nextCursor': ''}},
5909+
]
5910+
ids, cursor = [], None
5911+
for body in responses:
5912+
query = {'limit': 20}
5913+
if cursor:
5914+
query['cursor'] = cursor
5915+
url = os.getenv('RETAILCRM_URL') + '/api/v5/knowledge-base/documents?' + urlencode(query)
5916+
mock = (pook.get(url).headers({'X-API-KEY': os.getenv('RETAILCRM_KEY')})
5917+
.reply(200).headers(self.__header).json(body))
5918+
result = self.client.knowledge_base_documents(limit=20, cursor=cursor)
5919+
self.assertTrue(mock.mock.done)
5920+
payload = result.get_response()
5921+
ids.extend(doc['id'] for doc in payload['documents'])
5922+
cursor = payload['pagination']['nextCursor']
5923+
self.assertEqual([7, 8], ids)
5924+
self.assertEqual('', cursor)
5925+
5926+
@pook.on
5927+
def test_knowledge_base_file_attachment(self):
5928+
file = {'id': 9, 'filename': 'manual.pdf', 'attachment': [{'knowledgeBaseDocument': {'id': 7}}]}
5929+
mock = (pook.post(os.getenv('RETAILCRM_URL') + '/api/v5/files/9/edit')
5930+
.headers({'X-API-KEY': os.getenv('RETAILCRM_KEY')})
5931+
.body(self.dictionaryEncode('file', file))
5932+
.reply(200).headers(self.__header).json({'success': True, 'file': file}))
5933+
result = self.client.files_edit(file)
5934+
self.assertTrue(mock.mock.done)
5935+
self.assertEqual(file, result.get_response()['file'])
5936+
5937+
@pook.on
5938+
def test_knowledge_base_api_errors(self):
5939+
for status in (400, 403, 404, 500):
5940+
with self.subTest(status=status):
5941+
mock = (pook.get(os.getenv('RETAILCRM_URL') + '/api/v5/knowledge-base/documents/missing')
5942+
.headers({'X-API-KEY': os.getenv('RETAILCRM_KEY')})
5943+
.reply(status).headers(self.__header).json({'success': False, 'errorMsg': 'Failure'}))
5944+
result = self.client.knowledge_base_document('missing')
5945+
self.assertTrue(mock.mock.done)
5946+
self.assertFalse(result.is_successful())
5947+
self.assertEqual(status, result.get_status_code())
5948+
self.assertEqual('Failure', result.get_error_msg())
5949+
5950+
@pook.on
5951+
def test_knowledge_base_default_pagination(self):
5952+
for method in ('knowledge_base_documents', 'knowledge_base_documents_history'):
5953+
with self.subTest(method=method):
5954+
path, query = 'documents', {'limit': 20}
5955+
if method == 'knowledge_base_documents_history':
5956+
path += '/history'
5957+
query['page'] = 1
5958+
url = os.getenv('RETAILCRM_URL') + '/api/v5/knowledge-base/' + path + '?' + urlencode(query)
5959+
mock = (pook.get(url).headers({'X-API-KEY': os.getenv('RETAILCRM_KEY')})
5960+
.reply(200).headers(self.__header).json({'success': True}))
5961+
result = getattr(self.client, method)()
5962+
self.assertTrue(mock.mock.done)
5963+
self.assertTrue(result.is_successful())
5964+
self.assertEqual({}, self.client.parameters)
5965+
5966+
@pook.on
5967+
def test_files_upload_original_file(self):
5968+
for content in (b'original bytes', io.BytesIO(b'original bytes')):
5969+
with self.subTest(content=type(content).__name__):
5970+
mock = (pook.post(os.getenv('RETAILCRM_URL') + '/api/v5/files/upload')
5971+
.headers({'X-API-KEY': os.getenv('RETAILCRM_KEY'),
5972+
'Content-Type': 'application/octet-stream'})
5973+
.filter(lambda req, _: (req.body.getvalue().decode()
5974+
if hasattr(req.body, 'getvalue') else req.body) == 'original bytes')
5975+
.reply(201).headers(self.__header).json({'success': True, 'file': {'id': 9}}))
5976+
result = self.client.files_upload(content)
5977+
self.assertTrue(mock.mock.done)
5978+
self.assertTrue(result.is_successful())
5979+
self.assertEqual(9, result.get_response()['file']['id'])
5980+
self.assertEqual({}, self.client.parameters)

0 commit comments

Comments
 (0)