timeback-sdk 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/.gitignore +3 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/PKG-INFO +1 -1
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/pyproject.toml +1 -1
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/heartbeat_handler.py +52 -46
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/submit_handler.py +29 -36
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity/handler.py +93 -71
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity_only/handler.py +90 -65
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/errors.py +4 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/verify.py +26 -27
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/utils.py +1 -0
- timeback_sdk-0.2.1/src/timeback/server/namespaces/lessons/next.py +51 -0
- timeback_sdk-0.2.1/tests/native/test_handler_exception_safety.py +560 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_lessons_handler_schema.py +11 -0
- timeback_sdk-0.2.0/src/timeback/server/namespaces/lessons/next.py +0 -38
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/README.md +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/pytest.ini +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/py.typed +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/adapters/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/adapters/django.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/adapters/fastapi.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/attempts.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/caliper.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/completion.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/progress.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/resolve.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/schema.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/submit.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/factory.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity_only/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/attempt_details_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/attempts_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/auth.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/complete_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/list_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/next_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/schema.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/start_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/submit_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/types.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/enrollments.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/profile.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/hooks.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/logger.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/oidc.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/resolve.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/activity/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/activity/record.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/activity/schema.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/attempt_details.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/attempts.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/complete.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/helpers.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/list.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/start.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/submit.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/types.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/user/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/user/get_profile.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/user/verify.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/handler_helpers.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/namespace_helpers.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/namespace_stub_shapes.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/recording_client.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/timeback.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/types.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/shared/__init__.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/shared/constants.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/shared/types.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_activity_record.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_activity_heartbeat.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_activity_submit.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_callback.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_only_callback.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_only_signin.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_only_signout.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_signin.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_signout.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_attempt_details.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_attempts.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_complete.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_list.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_next.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_start.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_submit.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_user_me.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_user_verify.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_attempt_details.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_attempts.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_complete.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_list.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_next.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_start.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_submit.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_user_get_profile.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_user_verify.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_caliper.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_handler_schema.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_progress.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_record_schema.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_record_submit_sends_one_or_two_events.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_canonical_activity_url.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_course_resolution.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_fastapi_adapter_response_annotation.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_handler_pct_complete_app.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_hooks.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_identity_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_lesson_complete_result.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_lessons_list_course_ref.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_logger.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_namespace_test_utils.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_oidc.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_resolve.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_submit_handler_hook_integration.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_types.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_user_handler.py +0 -0
- {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_utils.py +0 -0
{timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/heartbeat_handler.py
RENAMED
|
@@ -106,53 +106,59 @@ def create_heartbeat_handler(
|
|
|
106
106
|
api_env = map_env_for_api(env)
|
|
107
107
|
|
|
108
108
|
async def handler(request: Request) -> Response:
|
|
109
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
110
|
-
# Step 1: Authenticate
|
|
111
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
112
|
-
user_info = await get_activity_user_info(identity, request)
|
|
113
|
-
if not user_info:
|
|
114
|
-
return error_response("UNAUTHORIZED", "Unauthorized", 401)
|
|
115
|
-
|
|
116
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
117
|
-
# Step 2: Validate request
|
|
118
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
119
|
-
body = await request.json()
|
|
120
|
-
result = validate_heartbeat_request(body, app_config)
|
|
121
|
-
|
|
122
|
-
if isinstance(result, ValidationError):
|
|
123
|
-
return result.response
|
|
124
|
-
|
|
125
|
-
payload = result.payload
|
|
126
|
-
course_config = result.course
|
|
127
|
-
sensor = result.sensor
|
|
128
|
-
|
|
129
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
130
|
-
# Step 3: Check for duplicate (idempotency)
|
|
131
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
132
|
-
window_start_ms = _parse_iso_to_ms(payload.started_at)
|
|
133
|
-
window_key = f"{payload.run_id}:{window_start_ms}"
|
|
134
|
-
|
|
135
|
-
if _is_duplicate(window_key):
|
|
136
|
-
log.debug(
|
|
137
|
-
"Duplicate heartbeat, skipping: runId=%s, windowKey=%s", payload.run_id, window_key
|
|
138
|
-
)
|
|
139
|
-
return JSONResponse({"duplicate": True})
|
|
140
|
-
|
|
141
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
142
|
-
# Step 4: Verify course is synced
|
|
143
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
144
|
-
synced_course_id = (course_config.get("ids") or {}).get(api_env)
|
|
145
|
-
if not isinstance(synced_course_id, str) or not synced_course_id:
|
|
146
|
-
err = MissingSyncedCourseIdError(course_config, api_env)
|
|
147
|
-
log.warning("Course not synced: %s, env=%s", err.course, err.env)
|
|
148
|
-
return error_response("MISSING_SYNCED_COURSE_ID", str(err), 503)
|
|
149
|
-
|
|
150
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
151
|
-
# Step 5: Resolve user and send event
|
|
152
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
153
|
-
client = get_client()
|
|
154
|
-
|
|
155
109
|
try:
|
|
110
|
+
# ═══════════════════════════════════════════════════════════════
|
|
111
|
+
# Step 1: Authenticate
|
|
112
|
+
# ═══════════════════════════════════════════════════════════════
|
|
113
|
+
user_info = await get_activity_user_info(identity, request)
|
|
114
|
+
if not user_info:
|
|
115
|
+
return error_response("UNAUTHORIZED", "Unauthorized", 401)
|
|
116
|
+
|
|
117
|
+
# ═══════════════════════════════════════════════════════════════
|
|
118
|
+
# Step 2: Validate request
|
|
119
|
+
# ═══════════════════════════════════════════════════════════════
|
|
120
|
+
try:
|
|
121
|
+
body = await request.json()
|
|
122
|
+
except Exception:
|
|
123
|
+
return error_response("INVALID_PAYLOAD", "Malformed JSON in request body", 400)
|
|
124
|
+
|
|
125
|
+
result = validate_heartbeat_request(body, app_config)
|
|
126
|
+
|
|
127
|
+
if isinstance(result, ValidationError):
|
|
128
|
+
return result.response
|
|
129
|
+
|
|
130
|
+
payload = result.payload
|
|
131
|
+
course_config = result.course
|
|
132
|
+
sensor = result.sensor
|
|
133
|
+
|
|
134
|
+
# ═══════════════════════════════════════════════════════════════
|
|
135
|
+
# Step 3: Check for duplicate (idempotency)
|
|
136
|
+
# ═══════════════════════════════════════════════════════════════
|
|
137
|
+
window_start_ms = _parse_iso_to_ms(payload.started_at)
|
|
138
|
+
window_key = f"{payload.run_id}:{window_start_ms}"
|
|
139
|
+
|
|
140
|
+
if _is_duplicate(window_key):
|
|
141
|
+
log.debug(
|
|
142
|
+
"Duplicate heartbeat, skipping: runId=%s, windowKey=%s",
|
|
143
|
+
payload.run_id,
|
|
144
|
+
window_key,
|
|
145
|
+
)
|
|
146
|
+
return JSONResponse({"duplicate": True})
|
|
147
|
+
|
|
148
|
+
# ═══════════════════════════════════════════════════════════════
|
|
149
|
+
# Step 4: Verify course is synced
|
|
150
|
+
# ═══════════════════════════════════════════════════════════════
|
|
151
|
+
synced_course_id = (course_config.get("ids") or {}).get(api_env)
|
|
152
|
+
if not isinstance(synced_course_id, str) or not synced_course_id:
|
|
153
|
+
err = MissingSyncedCourseIdError(course_config, api_env)
|
|
154
|
+
log.warning("Course not synced: %s, env=%s", err.course, err.env)
|
|
155
|
+
return error_response("MISSING_SYNCED_COURSE_ID", str(err), 503)
|
|
156
|
+
|
|
157
|
+
# ═══════════════════════════════════════════════════════════════
|
|
158
|
+
# Step 5: Resolve user and send event
|
|
159
|
+
# ═══════════════════════════════════════════════════════════════
|
|
160
|
+
client = get_client()
|
|
161
|
+
|
|
156
162
|
timeback_id = (
|
|
157
163
|
user_info.timeback_id
|
|
158
164
|
if user_info.timeback_id
|
{timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/submit_handler.py
RENAMED
|
@@ -117,28 +117,32 @@ def create_submit_handler(
|
|
|
117
117
|
api_env = map_env_for_api(env)
|
|
118
118
|
|
|
119
119
|
async def handler(request: Request) -> Response:
|
|
120
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
121
|
-
# Step 1: Authenticate
|
|
122
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
123
|
-
user_info = await get_activity_user_info(identity, request)
|
|
124
|
-
if not user_info:
|
|
125
|
-
return error_response("UNAUTHORIZED", "Unauthorized", 401)
|
|
126
|
-
|
|
127
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
128
|
-
# Step 2: Validate request
|
|
129
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
130
|
-
body = await request.json()
|
|
131
|
-
result = validate_submit_request(body, app_config)
|
|
132
|
-
|
|
133
|
-
if isinstance(result, ValidationError):
|
|
134
|
-
return result.response
|
|
135
|
-
|
|
136
|
-
payload = result.payload
|
|
137
|
-
|
|
138
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
139
|
-
# Step 3: Delegate to record_completion
|
|
140
|
-
# ═══════════════════════════════════════════════════════════════════
|
|
141
120
|
try:
|
|
121
|
+
# ═══════════════════════════════════════════════════════════════
|
|
122
|
+
# Step 1: Authenticate
|
|
123
|
+
# ═══════════════════════════════════════════════════════════════
|
|
124
|
+
user_info = await get_activity_user_info(identity, request)
|
|
125
|
+
if not user_info:
|
|
126
|
+
return error_response("UNAUTHORIZED", "Unauthorized", 401)
|
|
127
|
+
|
|
128
|
+
# ═══════════════════════════════════════════════════════════════
|
|
129
|
+
# Step 2: Validate request
|
|
130
|
+
# ═══════════════════════════════════════════════════════════════
|
|
131
|
+
try:
|
|
132
|
+
body = await request.json()
|
|
133
|
+
except Exception:
|
|
134
|
+
return error_response("INVALID_PAYLOAD", "Malformed JSON in request body", 400)
|
|
135
|
+
|
|
136
|
+
result = validate_submit_request(body, app_config)
|
|
137
|
+
|
|
138
|
+
if isinstance(result, ValidationError):
|
|
139
|
+
return result.response
|
|
140
|
+
|
|
141
|
+
payload = result.payload
|
|
142
|
+
|
|
143
|
+
# ═══════════════════════════════════════════════════════════════
|
|
144
|
+
# Step 3: Delegate to record_completion
|
|
145
|
+
# ═══════════════════════════════════════════════════════════════
|
|
142
146
|
completion_result = await record_completion(
|
|
143
147
|
user_info=user_info,
|
|
144
148
|
body=body,
|
|
@@ -193,23 +197,12 @@ def create_submit_handler(
|
|
|
193
197
|
return error_response("INVALID_SENSOR_URL", str(e), 500)
|
|
194
198
|
|
|
195
199
|
except CaliperValidationError as e:
|
|
196
|
-
|
|
197
|
-
log.error(
|
|
198
|
-
"Caliper validation error: course=%s, id=%s, error=%s",
|
|
199
|
-
selector_desc,
|
|
200
|
-
payload.id,
|
|
201
|
-
str(e),
|
|
202
|
-
)
|
|
200
|
+
log.error("Caliper validation error: %s", str(e))
|
|
203
201
|
return error_response("INTERNAL_ERROR", str(e), 502, details=e.response)
|
|
204
202
|
|
|
205
203
|
except Exception as e:
|
|
206
|
-
|
|
207
|
-
log.error(
|
|
208
|
-
|
|
209
|
-
selector_desc,
|
|
210
|
-
payload.id,
|
|
211
|
-
str(e),
|
|
212
|
-
)
|
|
213
|
-
return error_response("INTERNAL_ERROR", str(e), 502)
|
|
204
|
+
message = str(e) if str(e) else "Unknown error"
|
|
205
|
+
log.error("Failed to submit activity: %s", message)
|
|
206
|
+
return error_response("INTERNAL_ERROR", message, 502)
|
|
214
207
|
|
|
215
208
|
return handler
|
|
@@ -85,74 +85,90 @@ def create_identity_handlers(
|
|
|
85
85
|
)
|
|
86
86
|
|
|
87
87
|
async def sso_signin(request: Request) -> Response:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
# Build state - default to {} (state always decodes to object)
|
|
105
|
-
state_data: dict | None = {}
|
|
106
|
-
if sso_config.build_state:
|
|
107
|
-
ctx = BuildStateContext(
|
|
108
|
-
request=request,
|
|
109
|
-
url=str(request.url),
|
|
88
|
+
try:
|
|
89
|
+
# Build callback URL
|
|
90
|
+
callback_url = sso_config.redirect_uri
|
|
91
|
+
if not callback_url:
|
|
92
|
+
base = str(request.base_url).rstrip("/")
|
|
93
|
+
path = str(request.url.path)
|
|
94
|
+
callback_path = path.replace("/signin", "/callback")
|
|
95
|
+
callback_url = f"{base}{callback_path}"
|
|
96
|
+
|
|
97
|
+
sso_log.debug(
|
|
98
|
+
"SSO sign-in initiated, env=%s, issuer=%s, client_id=%s",
|
|
99
|
+
env,
|
|
100
|
+
issuer,
|
|
101
|
+
sso_config.client_id,
|
|
110
102
|
)
|
|
111
|
-
state_data = sso_config.build_state(ctx)
|
|
112
|
-
|
|
113
|
-
# Generate auth URL
|
|
114
|
-
auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
|
|
115
|
-
return RedirectResponse(url=auth_url, status_code=302)
|
|
116
|
-
|
|
117
|
-
async def sso_callback(request: Request) -> Response:
|
|
118
|
-
code = request.query_params.get("code")
|
|
119
|
-
state_param = request.query_params.get("state")
|
|
120
|
-
error = request.query_params.get("error")
|
|
121
103
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
104
|
+
# Build state - default to {} (state always decodes to object)
|
|
105
|
+
state_data: dict | None = {}
|
|
106
|
+
if sso_config.build_state:
|
|
107
|
+
ctx = BuildStateContext(
|
|
108
|
+
request=request,
|
|
109
|
+
url=str(request.url),
|
|
110
|
+
)
|
|
111
|
+
state_data = sso_config.build_state(ctx)
|
|
126
112
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
113
|
+
# Generate auth URL
|
|
114
|
+
auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
|
|
115
|
+
return RedirectResponse(url=auth_url, status_code=302)
|
|
130
116
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
state=state,
|
|
135
|
-
request=request,
|
|
136
|
-
)
|
|
137
|
-
if sso_config.on_callback_error:
|
|
138
|
-
result = sso_config.on_callback_error(error_ctx)
|
|
139
|
-
return await result if inspect.isawaitable(result) else result
|
|
140
|
-
return JSONResponse({"error": error}, status_code=400)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
sso_log.error("SSO sign-in failed: %s", str(e))
|
|
119
|
+
return JSONResponse({"error": "SSO sign-in failed"}, status_code=500)
|
|
141
120
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
error_ctx = CallbackErrorContext(
|
|
145
|
-
error=Exception("Missing authorization code"),
|
|
146
|
-
error_code="missing_code",
|
|
147
|
-
state=state,
|
|
148
|
-
request=request,
|
|
149
|
-
)
|
|
150
|
-
if sso_config.on_callback_error:
|
|
151
|
-
result = sso_config.on_callback_error(error_ctx)
|
|
152
|
-
return await result if inspect.isawaitable(result) else result
|
|
153
|
-
return JSONResponse({"error": "Missing authorization code"}, status_code=400)
|
|
121
|
+
async def sso_callback(request: Request) -> Response:
|
|
122
|
+
state: dict | None = None
|
|
154
123
|
|
|
155
124
|
try:
|
|
125
|
+
code = request.query_params.get("code")
|
|
126
|
+
state_param = request.query_params.get("state")
|
|
127
|
+
error = request.query_params.get("error")
|
|
128
|
+
|
|
129
|
+
sso_log.debug("Received callback from IdP, has_code=%s, error=%s", bool(code), error)
|
|
130
|
+
|
|
131
|
+
# Parse state early for error context
|
|
132
|
+
try:
|
|
133
|
+
state = oidc.decode_state(state_param) if state_param else None
|
|
134
|
+
except Exception as decode_err:
|
|
135
|
+
sso_log.warning("Failed to decode state param: %s", str(decode_err))
|
|
136
|
+
state = None
|
|
137
|
+
|
|
138
|
+
if error:
|
|
139
|
+
error_desc = request.query_params.get("error_description")
|
|
140
|
+
sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
|
|
141
|
+
|
|
142
|
+
error_ctx = CallbackErrorContext(
|
|
143
|
+
error=Exception(error_desc or error),
|
|
144
|
+
error_code=error,
|
|
145
|
+
state=state,
|
|
146
|
+
request=request,
|
|
147
|
+
)
|
|
148
|
+
if sso_config.on_callback_error:
|
|
149
|
+
try:
|
|
150
|
+
result = sso_config.on_callback_error(error_ctx)
|
|
151
|
+
return await result if inspect.isawaitable(result) else result
|
|
152
|
+
except Exception as cb_err:
|
|
153
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
154
|
+
return JSONResponse({"error": error}, status_code=400)
|
|
155
|
+
|
|
156
|
+
if not code:
|
|
157
|
+
sso_log.error("Missing authorization code in callback")
|
|
158
|
+
error_ctx = CallbackErrorContext(
|
|
159
|
+
error=Exception("Missing authorization code"),
|
|
160
|
+
error_code="missing_code",
|
|
161
|
+
state=state,
|
|
162
|
+
request=request,
|
|
163
|
+
)
|
|
164
|
+
if sso_config.on_callback_error:
|
|
165
|
+
try:
|
|
166
|
+
result = sso_config.on_callback_error(error_ctx)
|
|
167
|
+
return await result if inspect.isawaitable(result) else result
|
|
168
|
+
except Exception as cb_err:
|
|
169
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
170
|
+
return JSONResponse({"error": "Missing authorization code"}, status_code=400)
|
|
171
|
+
|
|
156
172
|
# Build callback URL (same as signin)
|
|
157
173
|
callback_url = sso_config.redirect_uri
|
|
158
174
|
if not callback_url:
|
|
@@ -193,8 +209,12 @@ def create_identity_handlers(
|
|
|
193
209
|
)
|
|
194
210
|
|
|
195
211
|
if sso_config.on_callback_success:
|
|
196
|
-
|
|
197
|
-
|
|
212
|
+
try:
|
|
213
|
+
result = sso_config.on_callback_success(success_ctx)
|
|
214
|
+
return await result if inspect.isawaitable(result) else result
|
|
215
|
+
except Exception as cb_err:
|
|
216
|
+
sso_log.error("on_callback_success raised: %s", str(cb_err))
|
|
217
|
+
return JSONResponse({"error": "Callback handler failed"}, status_code=500)
|
|
198
218
|
|
|
199
219
|
return RedirectResponse(url="/", status_code=302)
|
|
200
220
|
|
|
@@ -207,13 +227,12 @@ def create_identity_handlers(
|
|
|
207
227
|
request=request,
|
|
208
228
|
)
|
|
209
229
|
if sso_config.on_callback_error:
|
|
210
|
-
|
|
211
|
-
|
|
230
|
+
try:
|
|
231
|
+
result = sso_config.on_callback_error(error_ctx)
|
|
232
|
+
return await result if inspect.isawaitable(result) else result
|
|
233
|
+
except Exception as cb_err:
|
|
234
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
212
235
|
|
|
213
|
-
# Map resolution errors consistently with other handlers:
|
|
214
|
-
# - ambiguous -> 409
|
|
215
|
-
# - not_found -> 404
|
|
216
|
-
# - missing_email / lookup_failed -> 502 (upstream dependency / identity provider mismatch)
|
|
217
236
|
if e.code == "timeback_user_ambiguous":
|
|
218
237
|
return JSONResponse({"error": str(e)}, status_code=409)
|
|
219
238
|
if e.code == "timeback_user_not_found":
|
|
@@ -229,8 +248,11 @@ def create_identity_handlers(
|
|
|
229
248
|
request=request,
|
|
230
249
|
)
|
|
231
250
|
if sso_config.on_callback_error:
|
|
232
|
-
|
|
233
|
-
|
|
251
|
+
try:
|
|
252
|
+
result = sso_config.on_callback_error(error_ctx)
|
|
253
|
+
return await result if inspect.isawaitable(result) else result
|
|
254
|
+
except Exception as cb_err:
|
|
255
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
234
256
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
235
257
|
|
|
236
258
|
def sso_signout() -> Response:
|
{timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity_only/handler.py
RENAMED
|
@@ -81,76 +81,94 @@ def create_identity_only_handlers(
|
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
async def identity_only_signin(request: Request) -> Response:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
# Build state - default to {} (state always decodes to object)
|
|
101
|
-
state_data: dict | None = {}
|
|
102
|
-
if identity.build_state:
|
|
103
|
-
ctx = BuildStateContext(
|
|
104
|
-
request=request,
|
|
105
|
-
url=str(request.url),
|
|
84
|
+
try:
|
|
85
|
+
# Build callback URL
|
|
86
|
+
callback_url = identity.redirect_uri
|
|
87
|
+
if not callback_url:
|
|
88
|
+
base = str(request.base_url).rstrip("/")
|
|
89
|
+
path = str(request.url.path)
|
|
90
|
+
callback_path = path.replace("/signin", "/callback")
|
|
91
|
+
callback_url = f"{base}{callback_path}"
|
|
92
|
+
|
|
93
|
+
sso_log.debug(
|
|
94
|
+
"SSO sign-in initiated (identity-only), env=%s, issuer=%s, client_id=%s",
|
|
95
|
+
env,
|
|
96
|
+
issuer,
|
|
97
|
+
identity.client_id,
|
|
106
98
|
)
|
|
107
|
-
state_data = identity.build_state(ctx)
|
|
108
|
-
|
|
109
|
-
# Generate auth URL
|
|
110
|
-
auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
|
|
111
|
-
return RedirectResponse(url=auth_url, status_code=302)
|
|
112
99
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
100
|
+
# Build state - default to {} (state always decodes to object)
|
|
101
|
+
state_data: dict | None = {}
|
|
102
|
+
if identity.build_state:
|
|
103
|
+
ctx = BuildStateContext(
|
|
104
|
+
request=request,
|
|
105
|
+
url=str(request.url),
|
|
106
|
+
)
|
|
107
|
+
state_data = identity.build_state(ctx)
|
|
117
108
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
109
|
+
# Generate auth URL
|
|
110
|
+
auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
|
|
111
|
+
return RedirectResponse(url=auth_url, status_code=302)
|
|
121
112
|
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
except Exception as e:
|
|
114
|
+
sso_log.error("SSO sign-in failed (identity-only): %s", str(e))
|
|
115
|
+
return JSONResponse({"error": "SSO sign-in failed"}, status_code=500)
|
|
124
116
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
|
|
117
|
+
async def identity_only_callback(request: Request) -> Response:
|
|
118
|
+
state: dict | None = None
|
|
128
119
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
try:
|
|
121
|
+
code = request.query_params.get("code")
|
|
122
|
+
state_param = request.query_params.get("state")
|
|
123
|
+
error = request.query_params.get("error")
|
|
124
|
+
|
|
125
|
+
sso_log.debug(
|
|
126
|
+
"Received callback from IdP (identity-only), has_code=%s, error=%s",
|
|
127
|
+
bool(code),
|
|
128
|
+
error,
|
|
134
129
|
)
|
|
135
|
-
if identity.on_callback_error:
|
|
136
|
-
result = identity.on_callback_error(error_ctx)
|
|
137
|
-
return await result if inspect.isawaitable(result) else result
|
|
138
|
-
return JSONResponse({"error": error}, status_code=400)
|
|
139
130
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
state=
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
131
|
+
# Parse state early for error context
|
|
132
|
+
try:
|
|
133
|
+
state = oidc.decode_state(state_param) if state_param else None
|
|
134
|
+
except Exception as decode_err:
|
|
135
|
+
sso_log.warning("Failed to decode state param: %s", str(decode_err))
|
|
136
|
+
state = None
|
|
137
|
+
|
|
138
|
+
if error:
|
|
139
|
+
error_desc = request.query_params.get("error_description")
|
|
140
|
+
sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
|
|
141
|
+
|
|
142
|
+
error_ctx = CallbackErrorContext(
|
|
143
|
+
error=Exception(error_desc or error),
|
|
144
|
+
error_code=error,
|
|
145
|
+
state=state,
|
|
146
|
+
request=request,
|
|
147
|
+
)
|
|
148
|
+
if identity.on_callback_error:
|
|
149
|
+
try:
|
|
150
|
+
result = identity.on_callback_error(error_ctx)
|
|
151
|
+
return await result if inspect.isawaitable(result) else result
|
|
152
|
+
except Exception as cb_err:
|
|
153
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
154
|
+
return JSONResponse({"error": error}, status_code=400)
|
|
155
|
+
|
|
156
|
+
if not code:
|
|
157
|
+
sso_log.error("Missing authorization code in callback")
|
|
158
|
+
error_ctx = CallbackErrorContext(
|
|
159
|
+
error=Exception("Missing authorization code"),
|
|
160
|
+
error_code="missing_code",
|
|
161
|
+
state=state,
|
|
162
|
+
request=request,
|
|
163
|
+
)
|
|
164
|
+
if identity.on_callback_error:
|
|
165
|
+
try:
|
|
166
|
+
result = identity.on_callback_error(error_ctx)
|
|
167
|
+
return await result if inspect.isawaitable(result) else result
|
|
168
|
+
except Exception as cb_err:
|
|
169
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
170
|
+
return JSONResponse({"error": "Missing authorization code"}, status_code=400)
|
|
152
171
|
|
|
153
|
-
try:
|
|
154
172
|
# Build callback URL (same as signin)
|
|
155
173
|
callback_url = identity.redirect_uri
|
|
156
174
|
if not callback_url:
|
|
@@ -175,8 +193,12 @@ def create_identity_only_handlers(
|
|
|
175
193
|
)
|
|
176
194
|
|
|
177
195
|
if identity.on_callback_success:
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
try:
|
|
197
|
+
result = identity.on_callback_success(success_ctx)
|
|
198
|
+
return await result if inspect.isawaitable(result) else result
|
|
199
|
+
except Exception as cb_err:
|
|
200
|
+
sso_log.error("on_callback_success raised: %s", str(cb_err))
|
|
201
|
+
return JSONResponse({"error": "Callback handler failed"}, status_code=500)
|
|
180
202
|
|
|
181
203
|
return RedirectResponse(url="/", status_code=302)
|
|
182
204
|
|
|
@@ -189,8 +211,11 @@ def create_identity_only_handlers(
|
|
|
189
211
|
request=request,
|
|
190
212
|
)
|
|
191
213
|
if identity.on_callback_error:
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
try:
|
|
215
|
+
result = identity.on_callback_error(error_ctx)
|
|
216
|
+
return await result if inspect.isawaitable(result) else result
|
|
217
|
+
except Exception as cb_err:
|
|
218
|
+
sso_log.error("on_callback_error raised: %s", str(cb_err))
|
|
194
219
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
195
220
|
|
|
196
221
|
def identity_only_signout() -> Response:
|
|
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
|
|
7
7
|
from ...lib.logger import create_scoped_logger
|
|
8
8
|
from ...lib.resolve import TimebackUserResolutionError, resolve_status_for_user_resolution_error
|
|
9
9
|
from ...lib.utils import error_response
|
|
10
|
+
from ...namespaces.lessons.next import LessonTypeMismatchError
|
|
10
11
|
|
|
11
12
|
if TYPE_CHECKING:
|
|
12
13
|
from starlette.responses import Response
|
|
@@ -31,6 +32,9 @@ def to_lesson_error_response(error: BaseException, message: str) -> Response:
|
|
|
31
32
|
resolve_status_for_user_resolution_error(error),
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
if isinstance(error, LessonTypeMismatchError):
|
|
36
|
+
return error_response("LESSON_TYPE_MISMATCH", str(error), 400)
|
|
37
|
+
|
|
34
38
|
detail = str(error) if isinstance(error, Exception) else "Unknown error"
|
|
35
39
|
log.error("%s: %s", message, detail)
|
|
36
40
|
return error_response("INTERNAL_ERROR", detail, 500)
|