PyBlackboard-LMS 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. blackboard_api/__init__.py +27 -0
  2. blackboard_api/api_quota.py +59 -0
  3. blackboard_api/auth.py +77 -0
  4. blackboard_api/client.py +228 -0
  5. blackboard_api/config.py +55 -0
  6. blackboard_api/errors.py +26 -0
  7. blackboard_api/facades/__init__.py +19 -0
  8. blackboard_api/facades/api_quota.py +19 -0
  9. blackboard_api/facades/courses.py +96 -0
  10. blackboard_api/facades/enrollments.py +247 -0
  11. blackboard_api/facades/resources.py +56 -0
  12. blackboard_api/facades/terms.py +42 -0
  13. blackboard_api/facades/users.py +81 -0
  14. blackboard_api/identifiers.py +81 -0
  15. blackboard_api/resources/__init__.py +15 -0
  16. blackboard_api/resources/courses.py +150 -0
  17. blackboard_api/resources/enrollment_roles.py +16 -0
  18. blackboard_api/resources/enrollments.py +151 -0
  19. blackboard_api/resources/nodes.py +99 -0
  20. blackboard_api/resources/terms.py +71 -0
  21. blackboard_api/resources/users.py +145 -0
  22. blackboard_api/services/__init__.py +5 -0
  23. blackboard_api/services/courses.py +60 -0
  24. blackboard_api/services/enrollments.py +239 -0
  25. blackboard_api/services/users.py +28 -0
  26. blackboard_api/transport.py +141 -0
  27. blackboard_cli/__init__.py +1 -0
  28. blackboard_cli/__main__.py +5 -0
  29. blackboard_cli/application/__init__.py +1 -0
  30. blackboard_cli/cli.py +596 -0
  31. blackboard_cli/converters/__init__.py +30 -0
  32. blackboard_cli/converters/common.py +25 -0
  33. blackboard_cli/converters/courses.py +15 -0
  34. blackboard_cli/converters/enrollments.py +15 -0
  35. blackboard_cli/converters/generic.py +34 -0
  36. blackboard_cli/converters/nodes.py +25 -0
  37. blackboard_cli/converters/roles.py +15 -0
  38. blackboard_cli/converters/users.py +15 -0
  39. blackboard_cli/encoding.py +14 -0
  40. blackboard_cli/output/__init__.py +8 -0
  41. blackboard_cli/output/csv.py +35 -0
  42. blackboard_cli/output/dataframe.py +12 -0
  43. blackboard_cli/output/excel.py +23 -0
  44. blackboard_cli/output/table.py +15 -0
  45. pyblackboard_lms-0.1.0.dist-info/METADATA +175 -0
  46. pyblackboard_lms-0.1.0.dist-info/RECORD +50 -0
  47. pyblackboard_lms-0.1.0.dist-info/WHEEL +5 -0
  48. pyblackboard_lms-0.1.0.dist-info/entry_points.txt +2 -0
  49. pyblackboard_lms-0.1.0.dist-info/licenses/LICENSE +21 -0
  50. pyblackboard_lms-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,27 @@
1
+ import logging
2
+
3
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
4
+
5
+ from .client import BlackboardAPI
6
+ from .identifiers import InvalidIdentifierError
7
+ from .errors import (
8
+ BlackboardAPIError,
9
+ AuthenticationError,
10
+ NotFoundError,
11
+ QuotaExhaustedError,
12
+ WriteNotEnabledError,
13
+ ResponseFormatError,
14
+ TransportError,
15
+ )
16
+
17
+ __all__ = [
18
+ "BlackboardAPI",
19
+ "BlackboardAPIError",
20
+ "AuthenticationError",
21
+ "NotFoundError",
22
+ "QuotaExhaustedError",
23
+ "WriteNotEnabledError",
24
+ "ResponseFormatError",
25
+ "TransportError",
26
+ "InvalidIdentifierError",
27
+ ]
@@ -0,0 +1,59 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+ import requests
5
+
6
+ from .errors import QuotaExhaustedError
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class ApiQuotaState:
13
+ max_requests_per_day: Optional[int] = None
14
+ remaining: Optional[int] = None
15
+ retry_after: Optional[int] = None
16
+
17
+ def update_from_response(self, response: requests.Response) -> None:
18
+ quota_limit = response.headers.get("X-Rate-Limit-Limit")
19
+ remaining = response.headers.get("X-Rate-Limit-Remaining")
20
+ retry_after = response.headers.get("Retry-After")
21
+ if remaining is None and (
22
+ self.max_requests_per_day is not None or self.remaining is not None
23
+ ):
24
+ logger.warning(
25
+ "Blackboard did not send the remaining-requests header; "
26
+ "preserving the last known value."
27
+ )
28
+ parsed_limit = _parse_int(quota_limit)
29
+ parsed_remaining = _parse_int(remaining)
30
+ parsed_retry_after = _parse_int(retry_after)
31
+ if quota_limit is not None and parsed_limit is None:
32
+ logger.warning(
33
+ "Invalid X-Rate-Limit-Limit header; preserving the prior value."
34
+ )
35
+ if remaining is not None and parsed_remaining is None:
36
+ logger.warning(
37
+ "Invalid X-Rate-Limit-Remaining header; preserving the prior "
38
+ "value."
39
+ )
40
+ if retry_after is not None and parsed_retry_after is None:
41
+ logger.warning(
42
+ "Invalid Retry-After header; preserving the prior value."
43
+ )
44
+ if parsed_limit is not None:
45
+ self.max_requests_per_day = parsed_limit
46
+ if parsed_remaining is not None:
47
+ self.remaining = parsed_remaining
48
+ if parsed_retry_after is not None:
49
+ self.retry_after = parsed_retry_after
50
+ if self.remaining == 0:
51
+ logger.error("Blackboard reported zero remaining API requests")
52
+ raise QuotaExhaustedError("Blackboard API quota is exhausted")
53
+
54
+
55
+ def _parse_int(value: str | None) -> int | None:
56
+ try:
57
+ return int(value)
58
+ except (TypeError, ValueError):
59
+ return None
blackboard_api/auth.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import logging
5
+
6
+ import requests
7
+
8
+ from .errors import AuthenticationError, TransportError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class AuthService:
14
+ def __init__(
15
+ self, base_url: str, client_id: str, client_secret: str, transport: Any
16
+ ) -> None:
17
+ self.base_url = base_url.rstrip("/")
18
+ self.client_id = client_id
19
+ self.client_secret = client_secret
20
+ self._transport = transport
21
+ self.token = None
22
+ self.token_expires_at = 0
23
+
24
+ def is_token_expired(self, now: float) -> bool:
25
+ return self.token is None or now >= self.token_expires_at
26
+
27
+ def request_access_token(self) -> dict[str, Any]:
28
+ logger.debug("Requesting OAuth token from Blackboard")
29
+ try:
30
+ response = self._transport.request(
31
+ "POST",
32
+ f"{self.base_url}/learn/api/public/v1/oauth2/token",
33
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
34
+ data={"grant_type": "client_credentials"},
35
+ auth=(self.client_id, self.client_secret),
36
+ track_api_quota=False,
37
+ )
38
+ except TransportError as exc:
39
+ logger.warning("Could not reach the authentication endpoint")
40
+ raise AuthenticationError(
41
+ "Could not reach the authentication endpoint"
42
+ ) from exc
43
+ try:
44
+ response.raise_for_status()
45
+ data = response.json()
46
+ except requests.exceptions.HTTPError as exc:
47
+ logger.warning("Blackboard rejected OAuth authentication")
48
+ raise AuthenticationError(
49
+ "Blackboard rejected authentication credentials"
50
+ ) from exc
51
+ except ValueError as exc:
52
+ logger.warning("Blackboard returned invalid JSON during authentication")
53
+ raise AuthenticationError("Authentication JSON response is invalid") from exc
54
+ if not isinstance(data, dict) or not data.get("access_token"):
55
+ logger.warning("OAuth response does not contain a valid access_token")
56
+ raise AuthenticationError(
57
+ "Authentication response does not contain access_token"
58
+ )
59
+ return data
60
+
61
+ def authenticate(self, now: float) -> str:
62
+ if not self.is_token_expired(now):
63
+ logger.debug("Reusing valid OAuth token")
64
+ return self.token
65
+ data = self.request_access_token()
66
+ self.token = data["access_token"]
67
+ try:
68
+ expires_in = max(1, int(data.get("expires_in", 3600)))
69
+ except (TypeError, ValueError) as exc:
70
+ raise AuthenticationError(
71
+ "Authentication response contains invalid expires_in"
72
+ ) from exc
73
+ # Prevent very short-lived tokens from expiring immediately.
74
+ skew = min(60, max(0, expires_in // 10))
75
+ self.token_expires_at = now + expires_in - skew
76
+ logger.debug("OAuth token obtained; expiry uses a safety margin")
77
+ return self.token
@@ -0,0 +1,228 @@
1
+ import time
2
+ import logging
3
+ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
4
+
5
+ import requests
6
+ from typing import Any
7
+
8
+ from .auth import AuthService
9
+ from .config import api_config_from_environment
10
+ from .errors import (
11
+ NotFoundError,
12
+ QuotaExhaustedError,
13
+ WriteNotEnabledError,
14
+ ResponseFormatError,
15
+ )
16
+ from .api_quota import ApiQuotaState
17
+ from .transport import Transport
18
+ from .resources.courses import CourseResource
19
+ from .resources.users import UserResource
20
+ from .resources.enrollments import EnrollmentResource
21
+ from .resources.nodes import NodeResource
22
+ from .resources.enrollment_roles import EnrollmentRoleResource
23
+ from .resources.terms import TermResource
24
+ from .services.enrollments import EnrollmentService
25
+ from .services.users import UserService
26
+ from .services.courses import CourseService, TermService
27
+ from .facades.resources import NodeFacade, EnrollmentRoleFacade
28
+ from .facades.enrollments import EnrollmentFacade
29
+ from .facades.users import UserFacade
30
+ from .facades.courses import CourseFacade
31
+ from .facades.terms import TermFacade
32
+ from .facades.api_quota import ApiQuotaFacade
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class BlackboardAPI:
38
+ def __init__(
39
+ self, url: str | None = None, client_id: str | None = None,
40
+ client_secret: str | None = None,
41
+ env_file: str | None = None,
42
+ enable_write: bool = False,
43
+ max_retries: int | None = None,
44
+ results_per_page: int = 100,
45
+ ) -> None:
46
+ if url is None and client_id is None and client_secret is None:
47
+ if env_file is None:
48
+ raise ValueError(
49
+ "env_file is required when credentials are not provided"
50
+ )
51
+ config = api_config_from_environment(env_file)
52
+ url = config["url"]
53
+ client_id = config["client_id"]
54
+ client_secret = config["client_secret"]
55
+ timeout = (config["connect_timeout"], config["read_timeout"])
56
+ elif not all((url, client_id, client_secret)):
57
+ raise ValueError("Provide all three credentials or none")
58
+ else:
59
+ timeout = (10, 60)
60
+ parsed = urlparse(url)
61
+ if parsed.scheme != "https" or not parsed.netloc:
62
+ raise ValueError("BB_INSTANCE_URL must be a valid HTTPS URL")
63
+ self._url = url.rstrip("/")
64
+ self.enable_write = enable_write
65
+ if not isinstance(self.enable_write, bool):
66
+ raise TypeError("enable_write must be a boolean")
67
+ self.results_per_page = results_per_page
68
+ self._api_quota = ApiQuotaState()
69
+ self._transport = Transport(
70
+ self._api_quota,
71
+ timeout=timeout,
72
+ max_retries=(3 if max_retries is None else max_retries),
73
+ )
74
+ self._auth = AuthService(
75
+ self._url, client_id, client_secret, self._transport
76
+ )
77
+ self._courses_resource = CourseResource(self)
78
+ self._users_resource = UserResource(self)
79
+ self._enrollments_resource = EnrollmentResource(self)
80
+ self._nodes_resource = NodeResource(self)
81
+ self._enrollment_roles_resource = EnrollmentRoleResource(self)
82
+ self._terms_resource = TermResource(self)
83
+ self._enrollment_service = EnrollmentService(
84
+ self._enrollments_resource, self._enrollment_roles_resource
85
+ )
86
+ self._user_service = UserService(self._users_resource)
87
+ self._course_service = CourseService(
88
+ self._courses_resource, self._terms_resource
89
+ )
90
+ self._term_service = TermService(
91
+ self._courses_resource, self._terms_resource
92
+ )
93
+ self.courses = CourseFacade(self._courses_resource, self._course_service)
94
+ self.users = UserFacade(self._users_resource, self._user_service)
95
+ self.enrollments = EnrollmentFacade(
96
+ self._enrollments_resource, self._enrollment_service
97
+ )
98
+ self.nodes = NodeFacade(self._nodes_resource)
99
+ self.enrollment_roles = EnrollmentRoleFacade(self._enrollment_roles_resource)
100
+ self.terms = TermFacade(self._terms_resource, self._term_service)
101
+ self.api_quota = ApiQuotaFacade(
102
+ self._get_api_quota,
103
+ )
104
+
105
+ @property
106
+ def token(self) -> str | None:
107
+ return self._auth.token
108
+
109
+ @property
110
+ def api_quota_remaining(self) -> int | None:
111
+ return self._api_quota.remaining
112
+
113
+ @property
114
+ def max_requests_per_day(self) -> int | None:
115
+ return self._api_quota.max_requests_per_day
116
+
117
+ @property
118
+ def results_per_page(self) -> int:
119
+ """Return the default number of results requested per collection page."""
120
+ return self._results_per_page
121
+
122
+ @results_per_page.setter
123
+ def results_per_page(self, value: int) -> None:
124
+ """Set the default collection result count for future requests."""
125
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
126
+ raise ValueError("results_per_page must be a positive integer")
127
+ self._results_per_page = value
128
+
129
+ def _ensure_api_quota(self) -> None:
130
+ """Probe Blackboard once when no quota headers are known yet."""
131
+ if (
132
+ self._api_quota.max_requests_per_day is None
133
+ and self._api_quota.remaining is None
134
+ ):
135
+ self._update_api_quota()
136
+
137
+ def _get_api_quota(self) -> dict[str, int | None]:
138
+ self._ensure_api_quota()
139
+ return {
140
+ "remaining": self.api_quota_remaining,
141
+ "max_requests_per_day": self.max_requests_per_day,
142
+ }
143
+
144
+ def _authenticate(self) -> None:
145
+ previous_token = self._auth.token
146
+ token = self._auth.authenticate(time.time())
147
+ if token != previous_token:
148
+ self._update_api_quota()
149
+
150
+ def _get_headers(self) -> dict[str, str]:
151
+ self._authenticate()
152
+ return {
153
+ "Authorization": f"Bearer {self.token}",
154
+ "Content-Type": "application/json",
155
+ }
156
+
157
+ def _request(self, method: str, path_or_url: str, **kwargs: Any) -> requests.Response:
158
+ if not self.enable_write and method.upper() not in {"GET", "HEAD", "OPTIONS"}:
159
+ logger.warning("Writes are disabled; blocked %s", method.upper())
160
+ raise WriteNotEnabledError(
161
+ f"{method.upper()} requires enable_write=True"
162
+ )
163
+ url = (
164
+ path_or_url
165
+ if path_or_url.startswith("http")
166
+ else urljoin(self._url + "/", path_or_url.lstrip("/"))
167
+ )
168
+ headers = kwargs.pop("headers", None)
169
+ if headers is None:
170
+ headers = self._get_headers()
171
+ response = self._transport.request(method, url, headers=headers, **kwargs)
172
+ logger.debug("HTTP request completed: %s", method.upper())
173
+ try:
174
+ response.raise_for_status()
175
+ except requests.exceptions.HTTPError as exc:
176
+ if response.status_code == 404:
177
+ raise NotFoundError(f"Resource not found: {url}") from exc
178
+ raise
179
+ return response
180
+
181
+ def _request_json(self, method: str, path: str, **kwargs: Any) -> Any:
182
+ try:
183
+ return self._request(method, path, **kwargs).json()
184
+ except ValueError as exc:
185
+ raise ResponseFormatError(
186
+ f"Invalid JSON response for {method.upper()} {path}"
187
+ ) from exc
188
+
189
+ def _iter_paginated(self, path: str):
190
+ """Yield collection items one page at a time without accumulating them."""
191
+ url = BlackboardAPI._with_results_per_page(self, path)
192
+ while url:
193
+ data = self._request_json("GET", url)
194
+ if not isinstance(data, dict):
195
+ raise ResponseFormatError(
196
+ "Blackboard collection is not a JSON object"
197
+ )
198
+ if "results" not in data:
199
+ raise ResponseFormatError("Collection does not contain results")
200
+ results = data["results"]
201
+ paging = data.get("paging", {})
202
+ if not isinstance(results, list):
203
+ raise ResponseFormatError("Collection results is not a list")
204
+ if not isinstance(paging, dict):
205
+ raise ResponseFormatError("Collection paging is not an object")
206
+ next_page = paging.get("nextPage")
207
+ if next_page is not None and not isinstance(next_page, str):
208
+ raise ResponseFormatError("Collection paging.nextPage is not text")
209
+ yield from results
210
+ url = next_page
211
+
212
+ def _with_results_per_page(self, path: str) -> str:
213
+ """Add the configured pagination limit without changing an explicit one."""
214
+ parsed = urlparse(path)
215
+ query = parse_qsl(parsed.query, keep_blank_values=True)
216
+ if not any(key == "limit" for key, _ in query):
217
+ query.append((
218
+ "limit",
219
+ str(getattr(self, "_results_per_page", 100)),
220
+ ))
221
+ return urlunparse(parsed._replace(query=urlencode(query)))
222
+
223
+ def _update_api_quota(self) -> dict[str, int | None]:
224
+ self._request("GET", "/learn/api/public/v1/users/me")
225
+ return {
226
+ "max_requests_per_day": self._api_quota.max_requests_per_day,
227
+ "remaining": self._api_quota.remaining,
228
+ }
@@ -0,0 +1,55 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ DEFAULT_CONNECT_TIMEOUT_SECONDS = 10
6
+ DEFAULT_READ_TIMEOUT_SECONDS = 60
7
+
8
+
9
+ def load_environment(path: str | Path) -> dict[str, str]:
10
+ env_path = Path(path)
11
+ if not env_path.is_file():
12
+ raise FileNotFoundError(f"Environment file does not exist: {env_path}")
13
+ values = {}
14
+ for line_number, raw_line in enumerate(env_path.read_text(encoding="utf-8").splitlines(), 1):
15
+ line = raw_line.strip()
16
+ if not line or line.startswith("#"):
17
+ continue
18
+ if "=" not in line:
19
+ raise ValueError(f"Invalid format in {env_path}, line {line_number}")
20
+ key, value = line.split("=", 1)
21
+ value = value.strip()
22
+ if len(value) >= 2 and value[0] == value[-1] and value[0] == value[-1] and value[0] in {"'", '"'}:
23
+ value = value[1:-1]
24
+ values[key.strip()] = value
25
+ return values
26
+
27
+
28
+ def _positive_int(value: Any, name: str, default: int) -> int:
29
+ value = default if value in (None, "") else value
30
+ try:
31
+ value = int(value)
32
+ except (TypeError, ValueError) as exc:
33
+ raise ValueError(f"{name} must be a positive integer") from exc
34
+ if value <= 0:
35
+ raise ValueError(f"{name} must be a positive integer")
36
+ return value
37
+
38
+
39
+ def api_config_from_environment(path: str | Path) -> dict[str, Any]:
40
+ values = load_environment(path)
41
+ required = {
42
+ "BB_INSTANCE_URL": values.get("BB_INSTANCE_URL"),
43
+ "APP_KEY": values.get("APP_KEY"),
44
+ "APP_SECRET": values.get("APP_SECRET"),
45
+ }
46
+ missing = [name for name, value in required.items() if not value]
47
+ if missing:
48
+ raise ValueError("Missing required environment variables: " + ", ".join(missing))
49
+ return {
50
+ "url": required["BB_INSTANCE_URL"],
51
+ "client_id": required["APP_KEY"],
52
+ "client_secret": required["APP_SECRET"],
53
+ "connect_timeout": _positive_int(values.get("BB_REQUEST_CONNECT_TIMEOUT") or os.environ.get("BB_REQUEST_CONNECT_TIMEOUT"), "connect timeout", DEFAULT_CONNECT_TIMEOUT_SECONDS),
54
+ "read_timeout": _positive_int(values.get("BB_REQUEST_READ_TIMEOUT") or os.environ.get("BB_REQUEST_READ_TIMEOUT"), "read timeout", DEFAULT_READ_TIMEOUT_SECONDS),
55
+ }
@@ -0,0 +1,26 @@
1
+ class BlackboardAPIError(RuntimeError):
2
+ """Base exception for the Blackboard client."""
3
+
4
+
5
+ class AuthenticationError(BlackboardAPIError):
6
+ pass
7
+
8
+
9
+ class QuotaExhaustedError(BlackboardAPIError):
10
+ """The quota reported by Blackboard is exactly zero."""
11
+
12
+
13
+ class TransportError(BlackboardAPIError):
14
+ pass
15
+
16
+
17
+ class ResponseFormatError(BlackboardAPIError):
18
+ """The Blackboard response has an unexpected format."""
19
+
20
+
21
+ class NotFoundError(BlackboardAPIError):
22
+ """Blackboard could not find the requested resource."""
23
+
24
+
25
+ class WriteNotEnabledError(BlackboardAPIError):
26
+ """The operation is blocked because writes are not enabled."""
@@ -0,0 +1,19 @@
1
+ """Public facades grouped by resource."""
2
+
3
+ from .resources import ResourceFacade, NodeFacade, EnrollmentRoleFacade
4
+ from .enrollments import EnrollmentFacade
5
+ from .users import UserFacade
6
+ from .courses import CourseFacade
7
+ from .terms import TermFacade
8
+ from .api_quota import ApiQuotaFacade
9
+
10
+ __all__ = [
11
+ "ResourceFacade",
12
+ "NodeFacade",
13
+ "EnrollmentRoleFacade",
14
+ "EnrollmentFacade",
15
+ "UserFacade",
16
+ "CourseFacade",
17
+ "TermFacade",
18
+ "ApiQuotaFacade",
19
+ ]
@@ -0,0 +1,19 @@
1
+ """Public facade for the API usage quota."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+
8
+ class ApiQuotaFacade:
9
+ """Expose API quota state separately from pagination settings."""
10
+
11
+ def __init__(
12
+ self,
13
+ get_state: Callable[[], dict[str, int | None]],
14
+ ) -> None:
15
+ self._get_state = get_state
16
+
17
+ def get(self) -> dict[str, int | None]:
18
+ """Return remaining requests and the daily request maximum."""
19
+ return self._get_state()
@@ -0,0 +1,96 @@
1
+ """Public facade for atomic and composite course operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from .resources import ResourceFacade
9
+
10
+
11
+ class CourseFacade(ResourceFacade):
12
+ """Expose course resource operations and convenience services."""
13
+
14
+ def __init__(self, resource: Any, service: Any) -> None:
15
+ super().__init__(resource)
16
+ self._service = service
17
+
18
+ def list(self) -> list[dict]:
19
+ return self._resource.list()
20
+
21
+ def iter(self) -> Iterator[dict]:
22
+ return self._resource.iter()
23
+
24
+ def get(self, *, course_identifier: str) -> dict:
25
+ return self._resource.get(course_identifier=course_identifier)
26
+
27
+ def create(self, data: dict) -> dict:
28
+ """Create a course without Blackboard-generated ``id`` or UUID."""
29
+ return self._resource.create(data)
30
+
31
+ def update(self, *, course_identifier: str, data: dict) -> dict:
32
+ return self._resource.update(
33
+ course_identifier=course_identifier, data=data
34
+ )
35
+
36
+ def delete(self, *, course_identifier: str) -> None:
37
+ return self._resource.delete(course_identifier=course_identifier)
38
+
39
+ def set_available(self, *, course_identifier: str) -> dict:
40
+ return self._resource.set_available(course_identifier=course_identifier)
41
+
42
+ def set_unavailable(self, *, course_identifier: str) -> dict:
43
+ return self._resource.set_unavailable(course_identifier=course_identifier)
44
+
45
+ def set_disabled(self, *, course_identifier: str) -> dict:
46
+ return self._resource.set_disabled(course_identifier=course_identifier)
47
+
48
+ def assign_node(
49
+ self,
50
+ *,
51
+ course_identifier: str,
52
+ node_identifier: str,
53
+ primary: bool | None = None,
54
+ ) -> dict:
55
+ return self._resource.assign_node(
56
+ course_identifier=course_identifier,
57
+ node_identifier=node_identifier,
58
+ primary=primary,
59
+ )
60
+
61
+ def unassign_node(
62
+ self, *, course_identifier: str, node_identifier: str
63
+ ) -> None:
64
+ return self._resource.unassign_node(
65
+ course_identifier=course_identifier,
66
+ node_identifier=node_identifier,
67
+ )
68
+
69
+ def list_by_node(self, *, node_identifier: str) -> list[dict]:
70
+ return self._resource.list_by_node(node_identifier=node_identifier)
71
+
72
+ def iter_by_node(self, *, node_identifier: str) -> Iterator[dict]:
73
+ return self._resource.iter_by_node(node_identifier=node_identifier)
74
+
75
+ def assign_term(
76
+ self, *, course_identifier: str, term_identifier: str
77
+ ) -> dict:
78
+ return self._service.assign_term(
79
+ course_identifier=course_identifier,
80
+ term_identifier=term_identifier,
81
+ )
82
+
83
+ def unassign_term(self, *, course_identifier: str) -> dict:
84
+ return self._service.unassign_term(
85
+ course_identifier=course_identifier
86
+ )
87
+
88
+ def list_by_term(self, *, term_identifier: str) -> list[dict]:
89
+ """List courses assigned to a term by primary ID or ``externalId``."""
90
+ return self._service.list_by_term(term_identifier=term_identifier)
91
+
92
+ def get_copy_history(
93
+ self, *, course_identifier: str
94
+ ) -> list[dict] | None:
95
+ """Return the copy history of a given course."""
96
+ return self._service.get_copy_history(course_identifier=course_identifier)