openedx-mcp 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.

Potentially problematic release.


This version of openedx-mcp might be problematic. Click here for more details.

@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ default_app_config = "openedx_mcp.apps.MCPLmsConfig"
openedx_mcp/admin.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Django admin for MCP keys — this IS the "console" for key creation.
3
+
4
+ No custom MFE, no GraphQL. An is_staff user goes to /admin/openedx_mcp/mcpkey/,
5
+ clicks Add, picks the acting user + scopes, and saves. The raw key is generated
6
+ server-side and surfaced ONCE via a success message (it is never stored in the
7
+ clear, so it cannot be shown again). Thereafter the row shows only a prefix.
8
+ """
9
+ from datetime import timedelta
10
+
11
+ from django.conf import settings
12
+ from django.contrib import admin, messages
13
+ from django.utils import timezone
14
+
15
+ from .models import MCPAuditLog, MCPKey, generate_raw_key
16
+
17
+
18
+ @admin.register(MCPKey)
19
+ class MCPKeyAdmin(admin.ModelAdmin):
20
+ list_display = ("name", "user", "key_prefix", "scopes", "is_revoked", "expires_at",
21
+ "last_used_at", "created_at")
22
+ list_filter = ("is_revoked", "created_at")
23
+ search_fields = ("name", "user__username", "user__email", "key_prefix")
24
+ readonly_fields = ("key_prefix", "key_hash", "last_used_at", "created_at")
25
+ actions = ["revoke_keys"]
26
+
27
+ def get_fields(self, request, obj=None):
28
+ if obj is None: # add form
29
+ return ("user", "name", "scopes", "expires_at")
30
+ return ("user", "name", "scopes", "is_revoked", "expires_at",
31
+ "key_prefix", "last_used_at", "created_at")
32
+
33
+ def save_model(self, request, obj, form, change):
34
+ if not change: # creating: mint the secret now, show it once
35
+ raw = generate_raw_key()
36
+ obj.set_raw_key(raw)
37
+ # Auto-set expiry from the configured default TTL when left blank.
38
+ if obj.expires_at is None:
39
+ ttl_days = getattr(settings, "OPENEDX_MCP_DEFAULT_KEY_TTL_DAYS", None)
40
+ if ttl_days:
41
+ obj.expires_at = timezone.now() + timedelta(days=ttl_days)
42
+ super().save_model(request, obj, form, change)
43
+ messages.warning(
44
+ request,
45
+ "MCP key created. Copy it now — it will not be shown again:\n%s" % raw,
46
+ )
47
+ else:
48
+ super().save_model(request, obj, form, change)
49
+
50
+ @admin.action(description="Revoke selected keys")
51
+ def revoke_keys(self, request, queryset):
52
+ n = queryset.update(is_revoked=True)
53
+ self.message_user(request, f"Revoked {n} key(s).", messages.SUCCESS)
54
+
55
+
56
+ @admin.register(MCPAuditLog)
57
+ class MCPAuditLogAdmin(admin.ModelAdmin):
58
+ list_display = ("created_at", "tool", "outcome", "user", "affected_count")
59
+ list_filter = ("outcome", "tool", "created_at")
60
+ search_fields = ("tool", "user__username", "error")
61
+ readonly_fields = [f.name for f in MCPAuditLog._meta.fields]
62
+
63
+ def has_add_permission(self, request):
64
+ return False # append-only, written by the facade
65
+
66
+ def has_change_permission(self, request, obj=None):
67
+ return False
File without changes
File without changes
@@ -0,0 +1,95 @@
1
+ """
2
+ Authentication + authorization for the MCP facade.
3
+
4
+ Two accepted credentials, in priority order:
5
+
6
+ 1. X-MCP-Key — an openedx_mcp.MCPKey bearer token (the primary path; keys are
7
+ minted from the Django admin). Resolves to the key's acting user.
8
+ 2. The host platform's own JwtAuthentication / SessionAuthentication — so the
9
+ same endpoints can also be driven directly with an Open edX JWT that already
10
+ carries is_staff/is_superuser (oauth_dispatch encodes both claims). This is
11
+ what the key-management endpoints themselves use.
12
+
13
+ Whichever path authenticates, the authorization rule is identical and re-checked
14
+ LIVE on every request: the acting user must be is_staff or is_superuser right now.
15
+ A key does not cache privilege — demote the user and every key they hold dies on
16
+ the next call. `scopes` on a key only narrow, never widen.
17
+ """
18
+ import logging
19
+
20
+ from django.contrib.auth import get_user_model
21
+ from django.utils import timezone
22
+ from rest_framework import authentication, exceptions, permissions
23
+
24
+ from ...models import MCPKey, hash_key
25
+
26
+ log = logging.getLogger(__name__)
27
+ User = get_user_model()
28
+
29
+ MCP_KEY_HEADER = "X-MCP-Key"
30
+
31
+
32
+ class MCPKeyAuthentication(authentication.BaseAuthentication):
33
+ """Authenticate by X-MCP-Key. Returns None (not an error) when absent, so it
34
+ composes with JWT/Session authenticators on the same view."""
35
+
36
+ def authenticate(self, request):
37
+ raw = request.headers.get(MCP_KEY_HEADER)
38
+ if not raw:
39
+ return None
40
+
41
+ key = MCPKey.objects.filter(key_hash=hash_key(raw)).select_related("user").first()
42
+ if key is None:
43
+ raise exceptions.AuthenticationFailed("Invalid MCP key.")
44
+ if key.is_revoked:
45
+ raise exceptions.AuthenticationFailed("This MCP key has been revoked.")
46
+ if key.is_expired:
47
+ raise exceptions.AuthenticationFailed("This MCP key has expired.")
48
+
49
+ # Best-effort last-used telemetry; never blocks the request.
50
+ MCPKey.objects.filter(pk=key.pk).update(last_used_at=timezone.now())
51
+ return (key.user, key)
52
+
53
+ def authenticate_header(self, request):
54
+ return MCP_KEY_HEADER
55
+
56
+
57
+ def get_key(request):
58
+ """The MCPKey backing this request, or None when authed by JWT/session."""
59
+ auth = getattr(request, "auth", None)
60
+ return auth if isinstance(auth, MCPKey) else None
61
+
62
+
63
+ def granted_scopes(request):
64
+ """Scopes available on this request. A JWT/session admin (no key) has all
65
+ scopes; a key is limited to its own granted set."""
66
+ key = get_key(request)
67
+ if key is None:
68
+ from ...models import ALL_SCOPES
69
+ return set(ALL_SCOPES)
70
+ return key.granted_scopes()
71
+
72
+
73
+ class IsStaffOrSuperuser(permissions.BasePermission):
74
+ """Live authority check — the acting user must currently be staff/superuser.
75
+ This is the single gate; it runs on every request regardless of credential."""
76
+
77
+ message = "This endpoint requires a staff or superuser account."
78
+
79
+ def has_permission(self, request, view):
80
+ user = getattr(request, "user", None)
81
+ return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser))
82
+
83
+
84
+ def require_scopes(request, *needed):
85
+ """Raise PermissionDenied unless the request's credential carries every scope.
86
+ Superuser bypasses scope narrowing entirely."""
87
+ if request.user.is_superuser:
88
+ return
89
+ have = granted_scopes(request)
90
+ missing = set(needed) - have
91
+ if missing:
92
+ raise exceptions.PermissionDenied(
93
+ {"error": "Missing scope(s).", "required": sorted(needed),
94
+ "missing": sorted(missing)}
95
+ )
@@ -0,0 +1,105 @@
1
+ """CMS course-authoring tool endpoints. Loaded only in the Studio process
2
+ (/api/mcp/cms/). Reads need read; writes need write:courses; delete adds
3
+ destructive. Draft edits apply immediately; delete/publish use confirm handshake."""
4
+ from rest_framework.response import Response
5
+
6
+ from ...models import Scope
7
+ from ...native import authoring as nau
8
+ from .base import MCPView
9
+ from .guards import audited_write
10
+
11
+
12
+ class CourseOutlineView(MCPView):
13
+ def get(self, request, course_id):
14
+ outline = nau.read_outline(request.user, course_id)
15
+ if outline is None:
16
+ return Response({"error": "No such course."}, status=404)
17
+ return Response(outline)
18
+
19
+
20
+ class CreateCourseView(MCPView):
21
+ @audited_write("create_course", scope=Scope.WRITE_COURSES, require_confirm=True)
22
+ def post(self, request, confirmed=True):
23
+ d = request.data
24
+ if not confirmed:
25
+ return Response({"would_create": {"org": d["org"], "number": d["number"],
26
+ "run": d["run"], "name": d["display_name"]}})
27
+ return Response(nau.create_course(request.user, d["org"], d["number"], d["run"],
28
+ d["display_name"], d.get("extra_fields"))), 1
29
+
30
+
31
+ class CloneCourseView(MCPView):
32
+ @audited_write("clone_course", scope=Scope.WRITE_COURSES, require_confirm=True)
33
+ def post(self, request, confirmed=True):
34
+ d = request.data
35
+ if not confirmed:
36
+ return Response({"would_clone": d["source_course_id"],
37
+ "into": f"{d['org']}+{d['number']}+{d['run']}"})
38
+ return Response(nau.clone_course(request.user, d["source_course_id"], d["org"],
39
+ d["number"], d["run"], d.get("display_name"))), 1
40
+
41
+
42
+ class DeleteCourseView(MCPView):
43
+ @audited_write("delete_course", scope=Scope.DESTRUCTIVE, require_confirm=True)
44
+ def post(self, request, confirmed=True):
45
+ d = request.data
46
+ if not confirmed:
47
+ return Response({"would_delete": d["course_id"]})
48
+ return Response(nau.delete_course(request.user, d["course_id"],
49
+ keep_instructors=d.get("keep_instructors", False))), 1
50
+
51
+
52
+ class CreateBlockView(MCPView):
53
+ @audited_write("create_xblock", scope=Scope.WRITE_COURSES, require_confirm=False)
54
+ def post(self, request, confirmed=True):
55
+ d = request.data
56
+ return Response(nau.create_block(request.user, d["parent_locator"], d["category"],
57
+ d.get("display_name"), d.get("boilerplate"))), 1
58
+
59
+
60
+ class CreateBlockTreeView(MCPView):
61
+ """Create a whole subtree (sections→subsections→units→components) in one call."""
62
+ @audited_write("create_xblock", scope=Scope.WRITE_COURSES, require_confirm=False)
63
+ def post(self, request, confirmed=True):
64
+ d = request.data
65
+ result = nau.create_block_tree(request.user, d["parent_locator"], d["nodes"])
66
+ # affected = count of blocks created across the tree
67
+ def _count(nodes):
68
+ return sum(1 + _count(n.get("children", [])) for n in nodes)
69
+ return Response(result), _count(result.get("created", []))
70
+
71
+
72
+ class UpdateBlockView(MCPView):
73
+ @audited_write("update_xblock", scope=Scope.WRITE_COURSES, require_confirm=False)
74
+ def post(self, request, confirmed=True):
75
+ d = request.data
76
+ return Response(nau.update_block(request.user, d["locator"], data=d.get("data"),
77
+ metadata=d.get("metadata"), fields=d.get("fields"),
78
+ publish=d.get("publish"))), 1
79
+
80
+
81
+ class PublishBlockView(MCPView):
82
+ @audited_write("publish_xblock", scope=Scope.WRITE_COURSES, require_confirm=True)
83
+ def post(self, request, confirmed=True):
84
+ d = request.data
85
+ if not confirmed:
86
+ return Response({"would_publish": d["locator"]})
87
+ return Response(nau.publish_block(request.user, d["locator"])), 1
88
+
89
+
90
+ class DeleteBlockView(MCPView):
91
+ @audited_write("delete_xblock", scope=Scope.WRITE_COURSES, require_confirm=True)
92
+ def post(self, request, confirmed=True):
93
+ d = request.data
94
+ if not confirmed:
95
+ return Response({"would_delete": d["locator"]})
96
+ return Response(nau.delete_block(request.user, d["locator"])), 1
97
+
98
+
99
+ class UpdateCourseSettingsView(MCPView):
100
+ @audited_write("update_course_settings", scope=Scope.WRITE_COURSES, require_confirm=False)
101
+ def post(self, request, confirmed=True):
102
+ d = request.data
103
+ return Response(nau.update_course_settings(
104
+ request.user, d["course_id"], details=d.get("details"),
105
+ grading=d.get("grading"), advanced=d.get("advanced"))), 1
@@ -0,0 +1,24 @@
1
+ """
2
+ Shared DRF base for every MCP facade view.
3
+
4
+ Authentication accepts, in order: the MCP key, an Open edX JWT bearer (carries
5
+ is_staff/is_superuser), or a session. Authorization is the single live
6
+ IsStaffOrSuperuser gate. These platform auth classes only import inside a running
7
+ LMS/CMS, which is the only place these URL configs are loaded.
8
+ """
9
+ from rest_framework.views import APIView
10
+
11
+ from .auth import IsStaffOrSuperuser, MCPKeyAuthentication
12
+
13
+
14
+ def _platform_auth_classes():
15
+ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
16
+ from rest_framework.authentication import SessionAuthentication
17
+ return [MCPKeyAuthentication, JwtAuthentication, SessionAuthentication]
18
+
19
+
20
+ class MCPView(APIView):
21
+ permission_classes = [IsStaffOrSuperuser]
22
+
23
+ def get_authenticators(self):
24
+ return [cls() for cls in _platform_auth_classes()]
@@ -0,0 +1,23 @@
1
+ """CMS (Studio) URL config — mounted at ^api/mcp/cms/ by MCPCmsConfig.
2
+ Only the authoring tools live here; they need the modulestore (CMS-writable)."""
3
+ from django.urls import path
4
+
5
+ from . import authoring_views as av
6
+
7
+ app_name = "openedx_mcp_cms"
8
+
9
+ COURSE_ID = "<path:course_id>"
10
+
11
+ urlpatterns = [
12
+ path(f"courses/{COURSE_ID}/outline/", av.CourseOutlineView.as_view(), name="outline"),
13
+ path("courses/create/", av.CreateCourseView.as_view(), name="create-course"),
14
+ path("courses/clone/", av.CloneCourseView.as_view(), name="clone-course"),
15
+ path("courses/delete/", av.DeleteCourseView.as_view(), name="delete-course"),
16
+ path("courses/settings/", av.UpdateCourseSettingsView.as_view(), name="course-settings"),
17
+
18
+ path("blocks/create/", av.CreateBlockView.as_view(), name="create-block"),
19
+ path("blocks/create-tree/", av.CreateBlockTreeView.as_view(), name="create-block-tree"),
20
+ path("blocks/update/", av.UpdateBlockView.as_view(), name="update-block"),
21
+ path("blocks/publish/", av.PublishBlockView.as_view(), name="publish-block"),
22
+ path("blocks/delete/", av.DeleteBlockView.as_view(), name="delete-block"),
23
+ ]
@@ -0,0 +1,196 @@
1
+ """
2
+ Safety rails for state-changing MCP tools.
3
+
4
+ An MCP key is driven by an autonomous agent, not a human clicking a button. The
5
+ failure mode designed against is a *looping* agent — a retry storm that mass-
6
+ enrols or deletes. Four rails, applied by the `audited_write` decorator:
7
+
8
+ 1. Live authority re-check (the IsStaffOrSuperuser permission already does this
9
+ per request; audited_write assumes it ran).
10
+ 2. Rate limit — per (key, tool) fixed window. Turns a runaway loop into ~N calls.
11
+ 3. Confirm token — a write with no token performs a DRY RUN and returns a preview
12
+ plus a single-use token bound to a fingerprint of the exact payload. Re-send
13
+ with the token to apply. Changing the payload invalidates the token.
14
+ 4. Append-only audit — intent recorded before the write; the write is refused if
15
+ the record cannot be persisted.
16
+
17
+ The confirm-token store and rate-limit counter use the ambient Django cache. In
18
+ production that must be a shared backend (Redis/memcached); under LocMemCache
19
+ (dev) both are per-process.
20
+ """
21
+ import functools
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ import secrets
26
+ import time
27
+
28
+ from django.core.cache import cache
29
+ from rest_framework import status
30
+ from rest_framework.response import Response
31
+
32
+ from ...models import MCPAuditLog
33
+ from .auth import get_key, require_scopes
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ CONFIRM_TOKEN_TTL_SECONDS = 300
38
+ _CONFIRM_PREFIX = "mcp:confirm:"
39
+ _RL_PREFIX = "mcp:rl:"
40
+
41
+ # Per-(key, tool) fixed-window limits: (max_calls, window_seconds). Tuned to the
42
+ # blast radius of one call — mass tools get the tightest budget.
43
+ RATE_LIMITS = {
44
+ "enroll_user": (60, 60),
45
+ "unenroll_user": (60, 60),
46
+ "bulk_enroll": (5, 300),
47
+ "create_user": (20, 300),
48
+ "set_role": (30, 60),
49
+ "deactivate_user": (10, 300),
50
+ "create_course": (10, 300),
51
+ "clone_course": (5, 600),
52
+ "delete_course": (5, 600),
53
+ "create_xblock": (200, 60),
54
+ "update_xblock": (200, 60),
55
+ "publish_xblock": (100, 60),
56
+ "delete_xblock": (60, 60),
57
+ "update_course_settings": (60, 60),
58
+ }
59
+ DEFAULT_RATE_LIMIT = (30, 60)
60
+
61
+ _SENSITIVE = {"confirm_token", "password", "key", "secret", "token"}
62
+ _MAX_LIST_SAMPLE = 20
63
+
64
+
65
+ def _fingerprint(tool, user_id, payload):
66
+ blob = json.dumps({"tool": tool, "user": str(user_id), "payload": payload},
67
+ sort_keys=True, default=str)
68
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
69
+
70
+
71
+ def issue_confirm_token(tool, user_id, payload):
72
+ token = secrets.token_urlsafe(24)
73
+ cache.set(f"{_CONFIRM_PREFIX}{token}", _fingerprint(tool, user_id, payload),
74
+ CONFIRM_TOKEN_TTL_SECONDS)
75
+ return token
76
+
77
+
78
+ def consume_confirm_token(token, tool, user_id, payload):
79
+ if not token:
80
+ return False
81
+ ck = f"{_CONFIRM_PREFIX}{token}"
82
+ stored = cache.get(ck)
83
+ if not stored or stored != _fingerprint(tool, user_id, payload):
84
+ return False
85
+ cache.delete(ck) # single use
86
+ return True
87
+
88
+
89
+ def check_rate_limit(tool, key_id):
90
+ """Fixed-window counter per (key, tool). Fails OPEN on cache errors."""
91
+ limit, window = RATE_LIMITS.get(tool, DEFAULT_RATE_LIMIT)
92
+ try:
93
+ window_start = int(time.time()) // window
94
+ ck = f"{_RL_PREFIX}{key_id}:{tool}:{window_start}"
95
+ cache.add(ck, 0, window)
96
+ try:
97
+ count = cache.incr(ck)
98
+ except ValueError:
99
+ cache.set(ck, 1, window)
100
+ count = 1
101
+ return (count <= limit), window
102
+ except Exception as exc: # noqa: BLE001 — fail open, audit is the real guard
103
+ log.warning("rate-limit check failed tool=%s key=%s: %s", tool, key_id, exc)
104
+ return True, 0
105
+
106
+
107
+ def _redact(payload):
108
+ out = {}
109
+ for k, v in (payload or {}).items():
110
+ if k.lower() in _SENSITIVE:
111
+ out[k] = "[redacted]"
112
+ elif isinstance(v, list):
113
+ out[k] = {"count": len(v), "sample": [str(x)[:200] for x in v[:_MAX_LIST_SAMPLE]],
114
+ "truncated": len(v) > _MAX_LIST_SAMPLE}
115
+ elif isinstance(v, dict):
116
+ out[k] = _redact(v)
117
+ elif isinstance(v, str):
118
+ out[k] = v[:500]
119
+ else:
120
+ out[k] = v
121
+ return out
122
+
123
+
124
+ def _unpack(result):
125
+ if isinstance(result, tuple):
126
+ return result[0], result[1]
127
+ return result, 0
128
+
129
+
130
+ def audited_write(tool, scope="", require_confirm=True):
131
+ """Wrap a DRF view method with the write rails.
132
+
133
+ The wrapped method is called as `method(self, request, *args, confirmed=bool,
134
+ **kwargs)` and must return a Response or `(Response, affected_count)`. When
135
+ `confirmed` is False it MUST NOT write — return a preview of what *would*
136
+ happen. With require_confirm=False the method is always called confirmed=True.
137
+ """
138
+ def decorator(view_method):
139
+ @functools.wraps(view_method)
140
+ def wrapper(self, request, *args, **kwargs):
141
+ if scope:
142
+ require_scopes(request, scope) # raises PermissionDenied on miss
143
+
144
+ key = get_key(request)
145
+ key_id = key.pk if key else None
146
+ user_id = request.user.id
147
+ payload = request.data if isinstance(request.data, dict) else {}
148
+ plan = {k: v for k, v in payload.items() if k != "confirm_token"}
149
+ summary = _redact(plan)
150
+
151
+ def _audit(outcome, affected=0, error=""):
152
+ MCPAuditLog.objects.create(
153
+ key=key, user=request.user, tool=tool, scope=scope,
154
+ outcome=outcome, request_summary=summary,
155
+ affected_count=affected, error=error[:2000],
156
+ )
157
+
158
+ allowed, window = check_rate_limit(tool, key_id or user_id)
159
+ if not allowed:
160
+ _audit("denied", error="rate limited")
161
+ limit, _ = RATE_LIMITS.get(tool, DEFAULT_RATE_LIMIT)
162
+ return Response(
163
+ {"error": "Rate limit exceeded for this tool.",
164
+ "limit": limit, "window_seconds": window},
165
+ status=status.HTTP_429_TOO_MANY_REQUESTS,
166
+ )
167
+
168
+ if require_confirm:
169
+ confirmed = consume_confirm_token(payload.get("confirm_token"),
170
+ tool, user_id, plan)
171
+ if not confirmed:
172
+ _audit("dry_run")
173
+ resp, _ = _unpack(view_method(self, request, *args, confirmed=False, **kwargs))
174
+ if resp.status_code < 400 and isinstance(resp.data, dict):
175
+ resp.data["dry_run"] = True
176
+ resp.data["confirm_token"] = issue_confirm_token(tool, user_id, plan)
177
+ resp.data["confirm_expires_in_seconds"] = CONFIRM_TOKEN_TTL_SECONDS
178
+ resp.data["next_step"] = (
179
+ "Re-send the identical request with this confirm_token to apply. "
180
+ "Single-use, valid only for this exact payload."
181
+ )
182
+ return resp
183
+
184
+ _audit("attempt")
185
+ try:
186
+ result = view_method(self, request, *args, confirmed=True, **kwargs)
187
+ except Exception as exc: # noqa: BLE001 — record then re-raise
188
+ _audit("error", error=str(exc))
189
+ raise
190
+ resp, affected = _unpack(result)
191
+ _audit("success" if resp.status_code < 400 else "error",
192
+ affected=affected,
193
+ error="" if resp.status_code < 400 else str(getattr(resp, "data", ""))[:500])
194
+ return resp
195
+ return wrapper
196
+ return decorator
@@ -0,0 +1,100 @@
1
+ """LMS ops endpoints — certificates, async reports (grade export), retirement.
2
+ Reads require staff/superuser; writes add scope + rails via audited_write."""
3
+ from rest_framework.response import Response
4
+
5
+ from ...models import Scope
6
+ from ...native import certificates as nc
7
+ from ...native import reports as nrep
8
+ from ...native import retirement as nret
9
+ from .base import MCPView
10
+ from .guards import audited_write
11
+
12
+
13
+ # --- certificates ---
14
+
15
+ class UserCertificateView(MCPView):
16
+ def get(self, request, username, course_id):
17
+ return Response(nc.get_user_certificate(username, course_id))
18
+
19
+
20
+ class UserCertificatesView(MCPView):
21
+ def get(self, request, username):
22
+ return Response(nc.list_user_certificates(username))
23
+
24
+
25
+ class CourseCertificatesView(MCPView):
26
+ def get(self, request, course_id):
27
+ return Response(nc.list_course_certificates(
28
+ course_id, limit=int(request.query_params.get("limit", 200)),
29
+ offset=int(request.query_params.get("offset", 0))))
30
+
31
+
32
+ class GenerateCertificateView(MCPView):
33
+ @audited_write("generate_certificate", scope=Scope.WRITE_USERS, require_confirm=False)
34
+ def post(self, request, confirmed=True):
35
+ d = request.data
36
+ # Batch when no username given (whole course / a student_set).
37
+ if not d.get("username"):
38
+ return Response(nc.generate_course_certificates(
39
+ request, d["course_id"], student_set=d.get("student_set"))), 1
40
+ return Response(nc.generate_user_certificate(d["username"], d["course_id"])), 1
41
+
42
+
43
+ class RegenerateCertificatesView(MCPView):
44
+ @audited_write("generate_certificate", scope=Scope.WRITE_USERS, require_confirm=True)
45
+ def post(self, request, confirmed=True):
46
+ d = request.data
47
+ if not confirmed:
48
+ return Response({"would_regenerate": d["course_id"], "statuses": d["statuses"]})
49
+ return Response(nc.regenerate_course_certificates(
50
+ request, d["course_id"], d["statuses"])), 1
51
+
52
+
53
+ class InvalidateCertificateView(MCPView):
54
+ @audited_write("invalidate_certificate", scope=Scope.DESTRUCTIVE, require_confirm=True)
55
+ def post(self, request, confirmed=True):
56
+ d = request.data
57
+ if not confirmed:
58
+ return Response({"would_invalidate": d["username"], "course_id": d["course_id"]})
59
+ return Response(nc.invalidate_certificate(
60
+ request, d["username"], d["course_id"], notes=d.get("notes", ""))), 1
61
+
62
+
63
+ # --- async reports (grade export) ---
64
+
65
+ class SubmitReportView(MCPView):
66
+ @audited_write("submit_report", scope=Scope.READ, require_confirm=False)
67
+ def post(self, request, confirmed=True):
68
+ d = request.data
69
+ return Response(nrep.submit_report(
70
+ request, d["course_id"], kind=d.get("kind", "grades"),
71
+ features=d.get("features"))), 1
72
+
73
+
74
+ class ReportTasksView(MCPView):
75
+ def get(self, request, course_id):
76
+ return Response(nrep.list_tasks(course_id))
77
+
78
+
79
+ class ReportDownloadsView(MCPView):
80
+ def get(self, request, course_id):
81
+ return Response(nrep.list_report_downloads(
82
+ course_id, config_name=request.query_params.get("config", "GRADES_DOWNLOAD")))
83
+
84
+
85
+ # --- account retirement ---
86
+
87
+ class RetirementStatusView(MCPView):
88
+ def get(self, request, username):
89
+ return Response(nret.retirement_status(username))
90
+
91
+
92
+ class RequestRetirementView(MCPView):
93
+ @audited_write("request_retirement", scope=Scope.DESTRUCTIVE, require_confirm=True)
94
+ def post(self, request, confirmed=True):
95
+ d = request.data
96
+ full = d.get("full", True)
97
+ if not confirmed:
98
+ return Response({"would_retire": d["username"],
99
+ "mode": "full (irreversible, clears PII)" if full else "queue-only"})
100
+ return Response(nret.request_retirement(d["username"], full=full)), 1