File indexing completed on 2026-07-21 08:46:24
0001 """Small synchronous client for the corun-ai REST API."""
0002
0003 import json
0004 import logging
0005 from urllib.error import HTTPError, URLError
0006 from urllib.parse import urlencode
0007 from urllib.request import Request, urlopen
0008
0009 from decouple import config
0010
0011 logger = logging.getLogger(__name__)
0012
0013
0014 class CorunAPIError(RuntimeError):
0015 """Raised when corun-ai cannot service a request."""
0016
0017 def __init__(self, message, *, status=None, payload=None):
0018 super().__init__(message)
0019 self.status = status
0020 self.payload = payload
0021
0022
0023 def corun_base_url():
0024 from monitor_app.models import EXTERNAL_FACE_DEFAULT
0025 return str(config('CORUN_BASE_URL',
0026 default=EXTERNAL_FACE_DEFAULT + '/doc') or '').rstrip('/')
0027
0028
0029 def corun_api_token():
0030 return str(config('CORUN_API_TOKEN', default='') or '').strip()
0031
0032
0033 def corun_configured():
0034 return bool(corun_base_url() and corun_api_token())
0035
0036
0037 class CorunClient:
0038 """Token-authenticated corun-ai API client."""
0039
0040 def __init__(self, *, base_url=None, token=None, timeout=15):
0041 self.base_url = (base_url or corun_base_url()).rstrip('/')
0042 self.token = token if token is not None else corun_api_token()
0043 self.timeout = timeout
0044
0045 def _request(self, method, path, *, payload=None, query=None):
0046 if not self.base_url:
0047 raise CorunAPIError('CORUN_BASE_URL is not configured')
0048 if not self.token:
0049 raise CorunAPIError('CORUN_API_TOKEN is not configured')
0050
0051 url = f"{self.base_url}/api/v1/{path.lstrip('/')}"
0052 if query:
0053 clean_query = {
0054 key: value for key, value in query.items()
0055 if value not in (None, '', [])
0056 }
0057 if clean_query:
0058 url = f"{url}?{urlencode(clean_query, doseq=True)}"
0059
0060 body = None
0061 headers = {
0062 'Authorization': f'Token {self.token}',
0063 'Accept': 'application/json',
0064 'User-Agent': 'swf-monitor-corun-client/1.0',
0065 }
0066 if payload is not None:
0067 body = json.dumps(payload).encode('utf-8')
0068 headers['Content-Type'] = 'application/json'
0069
0070 req = Request(url, data=body, method=method.upper(), headers=headers)
0071 try:
0072 with urlopen(req, timeout=self.timeout) as resp:
0073 raw = resp.read().decode('utf-8')
0074 except HTTPError as exc:
0075 raw = exc.read().decode('utf-8', errors='replace')
0076 parsed = _parse_json(raw)
0077 detail = parsed if parsed is not None else raw
0078 raise CorunAPIError(
0079 f'corun-ai API returned HTTP {exc.code}: {detail}',
0080 status=exc.code,
0081 payload=parsed,
0082 ) from exc
0083 except URLError as exc:
0084 raise CorunAPIError(f'corun-ai API request failed: {exc.reason}') from exc
0085
0086 parsed = _parse_json(raw)
0087 if parsed is None:
0088 raise CorunAPIError('corun-ai API returned non-JSON response')
0089 return parsed
0090
0091 def create_page(self, *, section, content, title='', status='published',
0092 data=None, tags=None):
0093 return self._request('POST', 'pages/', payload={
0094 'section': section,
0095 'content': content,
0096 'title': title,
0097 'status': status,
0098 'data': data or {},
0099 'tags': tags or [],
0100 })
0101
0102 def create_version(self, group_id, *, content, title='', status='draft',
0103 data=None, tags=None):
0104 """New version of an existing page — corun-ai's correction path.
0105
0106 corun inherits the prior version's data/title and merges supplied
0107 keys (explicit null removes); omit what you don't mean to change —
0108 an empty value sent explicitly would overwrite inherited metadata.
0109 """
0110 payload = {'content': content, 'status': status}
0111 if title:
0112 payload['title'] = title
0113 if data is not None:
0114 payload['data'] = data
0115 if tags is not None:
0116 payload['tags'] = tags
0117 return self._request('POST', f'pages/{group_id}/versions/', payload=payload)
0118
0119 def list_versions(self, group_id):
0120 return self._request('GET', f'pages/{group_id}/versions/')
0121
0122 def get_page(self, group_id):
0123 return self._request('GET', f'pages/{group_id}/')
0124
0125 def list_pages(self, **filters):
0126 return self._request('GET', 'pages/', query=filters)
0127
0128 def list_all_pages(self, **filters):
0129 """Return every matching Page across the corun paginated API."""
0130 items = []
0131 offset = 0
0132 while True:
0133 payload = self.list_pages(
0134 **filters,
0135 limit=500,
0136 offset=offset,
0137 )
0138 batch = payload.get('items', []) if isinstance(payload, dict) else payload
0139 batch = batch if isinstance(batch, list) else []
0140 items.extend(batch)
0141 count = payload.get('count') if isinstance(payload, dict) else None
0142 if not batch or (isinstance(count, int) and len(items) >= count):
0143 return items
0144 offset += len(batch)
0145
0146 def list_comments(self, group_id):
0147 return self._request('GET', f'pages/{group_id}/comments/')
0148
0149 def create_comment(self, group_id, *, content, data=None):
0150 return self._request('POST', f'pages/{group_id}/comments/', payload={
0151 'content': content,
0152 'data': data or {},
0153 })
0154
0155
0156 def _parse_json(raw):
0157 if raw is None:
0158 return None
0159 raw = raw.strip()
0160 if not raw:
0161 return {}
0162 try:
0163 return json.loads(raw)
0164 except json.JSONDecodeError:
0165 logger.debug('Non-JSON corun-ai API response: %s', raw[:200])
0166 return None