From 72b7423e8654ee34aecfa135badaed38d6a530af Mon Sep 17 00:00:00 2001 From: Ananya Date: Thu, 18 Jun 2026 00:17:45 +0530 Subject: [PATCH 1/5] course materials --- migrations/0006_add_course_materials.sql | 17 + public/activity.html | 350 ++++++++-- public/course-materials.html | 598 +++++++++++++++++ public/js/activity.js | 812 +++++++++++++++++++++++ src/api/materials.py | 496 ++++++++++++++ src/worker.py | 92 +++ tests/test_api_materials.py | 662 ++++++++++++++++++ 7 files changed, 2963 insertions(+), 64 deletions(-) create mode 100644 migrations/0006_add_course_materials.sql create mode 100644 public/course-materials.html create mode 100644 public/js/activity.js create mode 100644 src/api/materials.py create mode 100644 tests/test_api_materials.py diff --git a/migrations/0006_add_course_materials.sql b/migrations/0006_add_course_materials.sql new file mode 100644 index 0000000..b7d5092 --- /dev/null +++ b/migrations/0006_add_course_materials.sql @@ -0,0 +1,17 @@ +-- Migration 0004: Add course_materials table for activity file attachments + +CREATE TABLE IF NOT EXISTS course_materials ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + file_key TEXT NOT NULL, + uploaded_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_materials_activity ON course_materials(activity_id); +CREATE INDEX IF NOT EXISTS idx_materials_uploader ON course_materials(uploaded_by); +CREATE INDEX IF NOT EXISTS idx_materials_created ON course_materials(activity_id, created_at DESC); diff --git a/public/activity.html b/public/activity.html index b02c5c6..cd06255 100644 --- a/public/activity.html +++ b/public/activity.html @@ -26,56 +26,72 @@ {% endblock %} {% block content %} @@ -87,11 +103,11 @@
+
-
@@ -148,10 +164,7 @@

-
- -
@@ -166,12 +179,12 @@

+

- @@ -197,6 +210,26 @@

+ +
+
+

+ Similar Activities +

+
+
    +
  • + Loading... +
  • +
+
+
@@ -349,13 +420,164 @@

Prerequisites

+
+ + + + + +
+

+ Welcome! +

+

Loading welcome message...

+
+
+ + + + +
+
+ +
    +
    +
    + + +
    +
    + + + + + +
    +

    + + Course Materials + +

    + +
    + + +
    +
    +
    +
    +
    + + + + + + + + + +
    +
    + +
    +
    +
    +

    + Activity Host +

    +
    +
    + ? +
    +
    +

    Loading…

    +

    Activity Host

    +
    +
    +
    +
    - + + + + + + + + + + + + + +
    +
    +
    + +

    Course Materials

    +
    +

    Loading activity…

    +
    +
    + + +
    + + + + + +
    +
    +

    + + Materials + +

    + +
    + +
    +
    +
    +
    +
    + + + + + + +
    + +
    + + + + + + + + + + + + + + diff --git a/public/js/activity.js b/public/js/activity.js new file mode 100644 index 0000000..0d0c6c3 --- /dev/null +++ b/public/js/activity.js @@ -0,0 +1,812 @@ +/* ========================================================= + activity.js – Activities browser + detail view logic + ========================================================= */ + +// ── State ────────────────────────────────────────────────── +let _currentActivityId = null; +let _currentActivity = null; +let _isHost = false; +let _activeTags = new Set(); +let _allActivities = []; +let _deleteTargetId = null; + +// ── Auth helpers ─────────────────────────────────────────── +function _authToken() { + const token = localStorage.getItem('edu_token') || localStorage.getItem('token') || ''; + if (token && !localStorage.getItem('edu_token')) { + localStorage.setItem('edu_token', token); + } + return token; +} +function _authHeaders() { + const t = _authToken(); + return t ? { 'Authorization': `Bearer ${t}` } : {}; +} +function _currentUser() { + // Token format: base64({"id":...,"username":...,"role":...}).hmac_hex + // i.e. exactly TWO dot-separated parts: parts[0]=payload, parts[1]=sig + const t = _authToken(); + if (!t) return null; + try { + const token = t.startsWith('Bearer ') ? t.slice(7) : t; + const dot = token.lastIndexOf('.'); + if (dot === -1) return { _opaque: true }; + const b64 = token.slice(0, dot); + // Fix base64 padding + const padded = b64 + '==='.slice((b64.length + 3) % 4 || 4); + return JSON.parse(atob(padded)); + } catch { /* ignore */ } + return { _opaque: true }; +} + +// ── Toast ────────────────────────────────────────────────── +function showToast(msg, type = 'success') { + const el = document.getElementById('act-toast'); + const icon = document.getElementById('act-toast-icon'); + const txt = document.getElementById('act-toast-msg'); + if (!el) return; + const colours = { + success: 'bg-teal-600', + error: 'bg-red-600', + info: 'bg-blue-600', + warning: 'bg-amber-500', + }; + const icons = { + success: 'fa-check-circle', + error: 'fa-exclamation-circle', + info: 'fa-info-circle', + warning: 'fa-exclamation-triangle', + }; + el.className = `fixed bottom-6 right-6 z-50 flex items-center gap-3 px-5 py-3 rounded-xl shadow-xl text-white text-sm font-medium max-w-xs ${colours[type] || colours.success}`; + icon.className = `fas ${icons[type] || icons.success} text-lg`; + txt.textContent = msg; + el.classList.remove('hidden'); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.add('hidden'), 3500); +} + +// ── Tab switching ────────────────────────────────────────── +function switchTab(name) { + document.querySelectorAll('.tab-btn').forEach(b => { + b.classList.toggle('active', b.dataset.tab === name); + }); + document.querySelectorAll('.tab-panel').forEach(p => { + p.classList.toggle('active', p.id === `tab-${name}`); + }); + if (name === 'materials') loadMaterials(); +} + +// ── View switching ───────────────────────────────────────── +function showBrowser() { + document.getElementById('browser-view').classList.remove('hidden'); + document.getElementById('browser-view').classList.add('view-browser'); + document.getElementById('detail-view').classList.remove('active'); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function showDetail() { + document.getElementById('browser-view').classList.add('hidden'); + document.getElementById('detail-view').classList.add('active'); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function backToBrowser() { + _currentActivityId = null; + _currentActivity = null; + _isHost = false; + showBrowser(); +} + +// ── Format helpers ───────────────────────────────────────── +function formatDate(iso) { + if (!iso) return 'β€”'; + try { + return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch { return iso; } +} + +function getFileIcon(title) { + const t = (title || '').toLowerCase(); + if (t.endsWith('.pdf')) return { icon: 'fa-file-pdf', colour: 'text-red-500' }; + if (t.endsWith('.doc') || t.endsWith('.docx')) return { icon: 'fa-file-word', colour: 'text-blue-500' }; + if (t.endsWith('.xls') || t.endsWith('.xlsx')) return { icon: 'fa-file-excel', colour: 'text-green-600' }; + if (t.endsWith('.ppt') || t.endsWith('.pptx')) return { icon: 'fa-file-powerpoint', colour: 'text-orange-500' }; + if (t.endsWith('.zip') || t.endsWith('.rar')) return { icon: 'fa-file-archive', colour: 'text-yellow-600' }; + if (t.endsWith('.mp4') || t.endsWith('.mov')) return { icon: 'fa-file-video', colour: 'text-purple-500' }; + if (t.endsWith('.mp3') || t.endsWith('.wav')) return { icon: 'fa-file-audio', colour: 'text-pink-500' }; + if (t.endsWith('.png') || t.endsWith('.jpg') || t.endsWith('.jpeg') || t.endsWith('.gif') || t.endsWith('.webp')) + return { icon: 'fa-file-image', colour: 'text-teal-500' }; + if (t.endsWith('.js') || t.endsWith('.py') || t.endsWith('.ts') || t.endsWith('.html') || t.endsWith('.css')) + return { icon: 'fa-file-code', colour: 'text-indigo-500' }; + if (t.endsWith('.txt') || t.endsWith('.md')) return { icon: 'fa-file-alt', colour: 'text-gray-500' }; + return { icon: 'fa-file', colour: 'text-gray-400' }; +} + +// ── Activity card renderer ───────────────────────────────── +function renderActivityCard(act) { + const typeColours = { + course: 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300', + workshop: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300', + seminar: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + bootcamp: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300', + webinar: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300', + }; + const fmtColours = { + online: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + in_person: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + hybrid: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300', + }; + const typeC = typeColours[act.type] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + const fmtC = fmtColours[act.format] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; + const tags = (act.tags || []).slice(0, 3).map(t => + `${t}` + ).join(''); + + return ` +
    +
    +
    +

    ${act.title}

    + ${act.type || 'activity'} +
    +

    ${act.description || 'No description provided.'}

    +
    ${tags}
    +
    + ${(act.format || 'online').replace('_', ' ')} + ${act.participant_count ?? act.member_count ?? 0} enrolled +
    +
    +
    `; +} + +// ── Browser: grid + filters ──────────────────────────────── +function renderGrid(activities) { + const grid = document.getElementById('activity-grid'); + const none = document.getElementById('no-results'); + if (!grid) return; + if (!activities.length) { + grid.innerHTML = ''; + none && none.classList.remove('hidden'); + return; + } + none && none.classList.add('hidden'); + grid.innerHTML = activities.map(renderActivityCard).join(''); +} + +function buildTagCloud(activities) { + const counts = {}; + activities.forEach(a => (a.tags || []).forEach(t => { counts[t] = (counts[t] || 0) + 1; })); + const cloud = document.getElementById('tag-cloud'); + if (!cloud) return; + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20); + cloud.innerHTML = sorted.map(([tag]) => + `` + ).join(''); +} + +function toggleTag(tag) { + if (_activeTags.has(tag)) _activeTags.delete(tag); + else _activeTags.add(tag); + document.querySelectorAll('.tag-pill').forEach(b => { + b.classList.toggle('active', _activeTags.has(b.textContent.trim())); + }); + applyFilters(); +} + +function applyFilters() { + const q = (document.getElementById('search')?.value || '').toLowerCase(); + const type = document.getElementById('filter-type')?.value || ''; + const fmt = document.getElementById('filter-format')?.value || ''; + let list = _allActivities; + if (q) list = list.filter(a => (a.title + ' ' + (a.description || '')).toLowerCase().includes(q)); + if (type) list = list.filter(a => a.type === type); + if (fmt) list = list.filter(a => a.format === fmt); + if (_activeTags.size) list = list.filter(a => [..._activeTags].every(t => (a.tags || []).includes(t))); + renderGrid(list); +} + +async function loadAllActivities() { + try { + const res = await fetch('/api/activities'); + const data = await res.json(); + _allActivities = data.activities || []; + renderGrid(_allActivities); + buildTagCloud(_allActivities); + } catch (e) { + console.error('loadAllActivities', e); + const grid = document.getElementById('activity-grid'); + if (grid) grid.innerHTML = '

    Failed to load activities.

    '; + } +} + +// ── Similar activities ───────────────────────────────────── +function getSimilarActivities(current) { + if (!current) return []; + return _allActivities + .filter(a => a.id !== current.id) + .map(a => { + const sharedTags = (a.tags || []).filter(t => (current.tags || []).includes(t)).length; + const sameType = a.type === current.type ? 2 : 0; + return { ...a, _score: sharedTags + sameType }; + }) + .filter(a => a._score > 0) + .sort((a, b) => b._score - a._score) + .slice(0, 5); +} + +function renderSimilarActivities(similar) { + const list = document.getElementById('similar-list'); + const card = document.getElementById('similar-card'); + if (!list) return; + if (!similar.length) { card && card.classList.add('hidden'); return; } + card && card.classList.remove('hidden'); + list.innerHTML = similar.map(a => ` +
  • +

    ${a.title}

    +

    ${a.type || ''} Β· ${(a.format || '').replace('_', ' ')}

    +
  • `).join(''); +} + +// ── Open activity detail ─────────────────────────────────── +async function openActivity(id) { + _currentActivityId = id; + showDetail(); + switchTab('overview'); + await loadActivityDetail(id); +} + +async function loadActivityDetail(id) { + try { + const res = await fetch(`/api/activities/${id}`, { headers: _authHeaders() }); + if (!res.ok) throw new Error('Not found'); + const data = await res.json(); + + // API response shape: + // { activity: {..., host_name, participant_count, tags}, + // sessions: [...], + // is_host: bool, + // is_enrolled: bool } + const act = data.activity || data; + const sessions = data.sessions || []; + _currentActivity = act; + + // Use the authoritative is_host flag returned by the server + _isHost = !!(data.is_host); + + const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val || ''; }; + const memberCount = act.participant_count ?? act.member_count ?? 0; + + // ── Hero / breadcrumb ── + setText('crumb-title', act.title); + const titleEl = document.getElementById('act-title'); + if (titleEl) titleEl.textContent = act.title || 'Activity'; + + const metaEl = document.getElementById('act-meta'); + if (metaEl) metaEl.textContent = + `${(act.type || 'activity').charAt(0).toUpperCase() + (act.type || 'activity').slice(1)} Β· ${(act.format || 'online').replace('_', ' ')} Β· ${memberCount} enrolled`; + + // ── Hero quick-info sidebar ── + const detailsEl = document.getElementById('act-details'); + if (detailsEl) { + detailsEl.innerHTML = ` +
    + + ${act.type || 'β€”'} +
    +
    + + ${(act.format || 'β€”').replace('_', ' ')} +
    +
    + + ${memberCount} enrolled +
    +
    + + ${act.host_name || 'Unknown Host'} +
    + ${act.created_at ? `
    Created ${formatDate(act.created_at)}
    ` : ''} + `; + } + + // ── Hero badges ── + const badgesEl = document.getElementById('act-badges'); + if (badgesEl) { + badgesEl.innerHTML = (act.tags || []).map(t => + `${t}` + ).join(''); + } + + // ── Overview sidebar: description + tags ── + setText('act-description', act.description || 'No description provided.'); + const actTagsEl = document.getElementById('act-tags'); + if (actTagsEl) { + actTagsEl.innerHTML = (act.tags || []).map(t => + `${t}` + ).join('') || 'No tags'; + } + + // ── Welcome card ── + setText('welcome-text', act.description || 'Welcome to this activity!'); + + // ── Join / member CTA ── + const joinCta = document.getElementById('join-cta'); + const memberCard = document.getElementById('member-card'); + const enrDetails = document.getElementById('enr-details'); + + if (_isHost) { + joinCta && joinCta.classList.add('hidden'); + memberCard && memberCard.classList.remove('hidden'); + if (enrDetails) enrDetails.innerHTML = ` + + You are the host of this activity + `; + } else if (data.is_enrolled) { + joinCta && joinCta.classList.add('hidden'); + memberCard && memberCard.classList.remove('hidden'); + if (enrDetails) enrDetails.innerHTML = ` + + You are enrolled + + ${data.enrollment?.role ? `${data.enrollment.role}` : ''}`; + } else { + joinCta && joinCta.classList.remove('hidden'); + memberCard && memberCard.classList.add('hidden'); + } + + // ── Sessions tab ── + const sessionsEl = document.getElementById('sessions-list-tab'); + const sessEmpty = document.getElementById('sessions-empty'); + const sessCount = document.getElementById('tab-sessions-count'); + if (sessCount) { + sessCount.textContent = sessions.length; + sessCount.classList.toggle('hidden', sessions.length === 0); + } + if (sessionsEl) { + if (!sessions.length) { + sessEmpty && sessEmpty.classList.remove('hidden'); + sessionsEl.innerHTML = ''; + } else { + sessEmpty && sessEmpty.classList.add('hidden'); + sessionsEl.innerHTML = sessions.map(s => ` +
  • +
    +
    +

    ${s.title || 'Session'}

    + ${s.description ? `

    ${s.description}

    ` : ''} +
    + ${s.start_time ? `${formatDate(s.start_time)}` : ''} +
    +
  • `).join(''); + } + } + + // ── Host tab ── + setText('host-name-tab', act.host_name || 'Unknown Host'); + const hostAvatar = document.getElementById('host-avatar'); + if (hostAvatar) { + const name = act.host_name || '?'; + hostAvatar.textContent = name.charAt(0).toUpperCase(); + } + + // ── Similar activities ── + renderSimilarActivities(getSimilarActivities(act)); + + } catch (e) { + console.error('loadActivityDetail', e); + showToast('Failed to load activity details', 'error'); + } +} + +// ── Join activity ────────────────────────────────────────── +async function joinActivity() { + const user = _currentUser(); + if (!user) { + window.location.href = '/login.html?redirect=' + encodeURIComponent(window.location.href); + return; + } + const btn = document.getElementById('btn-join'); + if (btn) { btn.disabled = true; btn.innerHTML = 'Joining…'; } + try { + const res = await fetch(`/api/activities/${_currentActivityId}/join`, { + method: 'POST', + headers: { ..._authHeaders(), 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (res.ok) { + showToast('Successfully joined the activity!', 'success'); + await loadActivityDetail(_currentActivityId); + } else { + showToast(data.error || 'Failed to join', 'error'); + if (btn) { btn.disabled = false; btn.innerHTML = 'Join Activity'; } + } + } catch { + showToast('Network error β€” please try again', 'error'); + if (btn) { btn.disabled = false; btn.innerHTML = 'Join Activity'; } + } +} + +// ── Materials ────────────────────────────────────────────── +async function loadMaterials() { + if (!_currentActivityId) return; + + const loadingEl = document.getElementById('mat-loading'); + const emptyEl = document.getElementById('mat-empty'); + const errorEl = document.getElementById('mat-error'); + const listEl = document.getElementById('mat-list'); + const countLbl = document.getElementById('mat-count-label'); + const tabCount = document.getElementById('tab-materials-count'); + const uploadSec = document.getElementById('mat-upload-section'); + + // Show/hide upload section based on host status + if (uploadSec) uploadSec.classList.toggle('hidden', !_isHost); + + // Reset state + loadingEl && loadingEl.classList.remove('hidden'); + emptyEl && emptyEl.classList.add('hidden'); + errorEl && errorEl.classList.add('hidden'); + listEl && listEl.classList.add('hidden'); + + try { + const res = await fetch(`/api/activities/${_currentActivityId}/materials`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load'); + + const materials = data.materials || []; + loadingEl && loadingEl.classList.add('hidden'); + + // Update counts + if (countLbl) countLbl.textContent = `${materials.length} file${materials.length !== 1 ? 's' : ''}`; + if (tabCount) { + tabCount.textContent = materials.length; + tabCount.classList.toggle('hidden', materials.length === 0); + } + + if (!materials.length) { + emptyEl && emptyEl.classList.remove('hidden'); + const sub = document.getElementById('mat-empty-sub'); + if (sub) sub.textContent = _isHost ? 'Upload your first material using the form above.' : 'No materials have been uploaded yet.'; + return; + } + + listEl && listEl.classList.remove('hidden'); + listEl.innerHTML = materials.map(m => renderMaterialItem(m)).join(''); + + } catch (e) { + loadingEl && loadingEl.classList.add('hidden'); + errorEl && errorEl.classList.remove('hidden'); + const msgEl = document.getElementById('mat-error-msg'); + if (msgEl) msgEl.textContent = e.message || 'Failed to load materials.'; + } +} + +// Store material data for edit/delete lookups (avoids encoding issues in onclick) +const _matCache = {}; + +function renderMaterialItem(m) { + // Cache the material so edit/delete buttons can retrieve it safely + _matCache[m.id] = m; + + const { icon, colour } = getFileIcon(m.title); + const hostControls = _isHost ? ` +
    + + +
    ` : ''; + + return ` +
  • +
    +
    + +
    +
    +
    +
    +

    ${m.title}

    + ${m.description ? `

    ${m.description}

    ` : ''} +

    ${formatDate(m.created_at)}

    +
    + +
    + ${hostControls} +
    +
    +
  • `; +} + +async function downloadMaterial(mid) { + const token = _authToken(); + if (!token) { + showToast('Please log in to download materials', 'warning'); + return; + } + try { + const res = await fetch(`/api/activities/${_currentActivityId}/materials/${mid}/download`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Download failed'); + + // Prefer a real presigned URL (works without auth header in browser). + // For the local /api/r2/ fallback, append the token as a query param + // since navigation can't set Authorization headers. + const payload = data.data || data; + let url = payload.download_url || ''; + const filename = payload.filename || payload.title || 'download'; + + if (url.startsWith('/api/r2/')) { + url = `${url}?token=${encodeURIComponent(token)}`; + } + const a = document.createElement('a'); + a.href = url; + a.download = filename; // sets the saved filename in the browser + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + showToast('Download started!', 'success'); + } catch (e) { + showToast(e.message || 'Download failed', 'error'); + } +} + +// ── Delete modal ─────────────────────────────────────────── +function openDeleteModal(mid, title) { + _deleteTargetId = mid; + const modal = document.getElementById('mat-confirm-modal'); + const msg = document.getElementById('mat-confirm-msg'); + if (msg) msg.textContent = `Delete "${title}"? This action cannot be undone.`; + modal && modal.classList.remove('hidden'); + const okBtn = document.getElementById('mat-confirm-ok'); + if (okBtn) { + okBtn.onclick = async () => { + okBtn.disabled = true; + okBtn.textContent = 'Deleting…'; + await confirmDelete(); + okBtn.disabled = false; + okBtn.textContent = 'Delete'; + }; + } +} + +function closeDeleteModal() { + document.getElementById('mat-confirm-modal')?.classList.add('hidden'); + _deleteTargetId = null; +} + +async function confirmDelete() { + if (!_deleteTargetId) return; + try { + const res = await fetch(`/api/activities/${_currentActivityId}/materials/${_deleteTargetId}`, { + method: 'DELETE', + headers: _authHeaders(), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Delete failed'); + closeDeleteModal(); + showToast('Material deleted', 'success'); + await loadMaterials(); + } catch (e) { + closeDeleteModal(); + showToast(e.message || 'Delete failed', 'error'); + } +} + +// ── Edit modal ───────────────────────────────────────────── +function openEditModal(mid, title, desc) { + document.getElementById('mat-edit-id').value = mid; + document.getElementById('mat-edit-title').value = title; + document.getElementById('mat-edit-desc').value = desc || ''; + const errEl = document.getElementById('mat-edit-error'); + if (errEl) { errEl.textContent = ''; errEl.classList.add('hidden'); } + document.getElementById('mat-edit-modal')?.classList.remove('hidden'); +} + +function closeEditModal() { + document.getElementById('mat-edit-modal')?.classList.add('hidden'); +} + +// ── Upload form ──────────────────────────────────────────── +function _initUploadForm() { + const form = document.getElementById('mat-upload-form'); + const dropZone = document.getElementById('mat-drop-zone'); + const fileInput = document.getElementById('mat-file-input'); + const browseBtn = document.getElementById('mat-browse-link'); + const selFile = document.getElementById('mat-selected-file'); + const progWrap = document.getElementById('mat-upload-progress'); + const progBar = document.getElementById('mat-upload-progress-bar'); + const progPct = document.getElementById('mat-upload-pct'); + const errEl = document.getElementById('mat-upload-error'); + const submitBtn = document.getElementById('mat-upload-btn'); + + if (!form) return; + + // Browse link + browseBtn && browseBtn.addEventListener('click', e => { e.preventDefault(); fileInput && fileInput.click(); }); + + // File input change + fileInput && fileInput.addEventListener('change', () => { + const f = fileInput.files[0]; + if (f && selFile) { + selFile.textContent = f.name; + selFile.classList.remove('hidden'); + } + }); + + // Drag & drop + if (dropZone) { + dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); + dropZone.addEventListener('drop', e => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + const f = e.dataTransfer.files[0]; + if (f) { + const dt = new DataTransfer(); + dt.items.add(f); + fileInput.files = dt.files; + if (selFile) { selFile.textContent = f.name; selFile.classList.remove('hidden'); } + } + }); + } + + // Form submit + form.addEventListener('submit', async e => { + e.preventDefault(); + if (errEl) { errEl.textContent = ''; errEl.classList.add('hidden'); } + + const title = (document.getElementById('mat-title')?.value || '').trim(); + const desc = (document.getElementById('mat-desc')?.value || '').trim(); + const file = fileInput?.files[0]; + + if (!title) { if (errEl) { errEl.textContent = 'Title is required.'; errEl.classList.remove('hidden'); } return; } + if (!file) { if (errEl) { errEl.textContent = 'Please select a file.'; errEl.classList.remove('hidden'); } return; } + + // Show progress + progWrap && progWrap.classList.remove('hidden'); + if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = 'Uploading…'; } + + // Simulate progress + let pct = 0; + const ticker = setInterval(() => { + pct = Math.min(pct + Math.random() * 15, 85); + if (progBar) progBar.style.width = pct + '%'; + if (progPct) progPct.textContent = Math.round(pct) + '%'; + }, 200); + + try { + const fd = new FormData(); + fd.append('title', title); + fd.append('description', desc); + fd.append('file', file); + + const res = await fetch(`/api/activities/${_currentActivityId}/materials`, { + method: 'POST', + headers: _authHeaders(), + body: fd, + }); + const data = await res.json(); + clearInterval(ticker); + + if (!res.ok) throw new Error(data.error || 'Upload failed'); + + // Complete progress bar + if (progBar) progBar.style.width = '100%'; + if (progPct) progPct.textContent = '100%'; + + setTimeout(() => { + progWrap && progWrap.classList.add('hidden'); + if (progBar) progBar.style.width = '0%'; + if (progPct) progPct.textContent = '0%'; + }, 600); + + // Reset form + form.reset(); + if (selFile) { selFile.textContent = ''; selFile.classList.add('hidden'); } + if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = 'Upload Material'; } + + showToast('Material uploaded successfully!', 'success'); + await loadMaterials(); + + } catch (err) { + clearInterval(ticker); + progWrap && progWrap.classList.add('hidden'); + if (progBar) progBar.style.width = '0%'; + if (errEl) { errEl.textContent = err.message || 'Upload failed.'; errEl.classList.remove('hidden'); } + if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = 'Upload Material'; } + } + }); +} + +// ── Edit form submit ─────────────────────────────────────── +function _initEditForm() { + const form = document.getElementById('mat-edit-form'); + if (!form) return; + form.addEventListener('submit', async e => { + e.preventDefault(); + const mid = document.getElementById('mat-edit-id')?.value; + const title = (document.getElementById('mat-edit-title')?.value || '').trim(); + const desc = (document.getElementById('mat-edit-desc')?.value || '').trim(); + const errEl = document.getElementById('mat-edit-error'); + const btn = document.getElementById('mat-edit-btn'); + + if (errEl) { errEl.textContent = ''; errEl.classList.add('hidden'); } + if (!title) { + if (errEl) { errEl.textContent = 'Title is required.'; errEl.classList.remove('hidden'); } + return; + } + if (btn) { btn.disabled = true; btn.innerHTML = ' Saving…'; } + + try { + const res = await fetch(`/api/activities/${_currentActivityId}/materials/${mid}`, { + method: 'PATCH', + headers: { ..._authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, description: desc }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Update failed'); + closeEditModal(); + showToast('Material updated!', 'success'); + await loadMaterials(); + } catch (err) { + if (errEl) { errEl.textContent = err.message || 'Update failed.'; errEl.classList.remove('hidden'); } + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' Save Changes'; } + } + }); +} + +// ── Init ─────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + // Wire up search + filters (HTML uses id="search", not "search-input") + document.getElementById('search')?.addEventListener('input', applyFilters); + document.getElementById('filter-type')?.addEventListener('change', applyFilters); + document.getElementById('filter-format')?.addEventListener('change', applyFilters); + + // Event delegation for material list buttons (download / edit / delete) + // Using delegation avoids onclick-in-HTML encoding issues with special chars + document.getElementById('mat-list')?.addEventListener('click', e => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const mid = btn.dataset.mid; + if (!mid) return; + + if (action === 'download') { + downloadMaterial(mid); + } else if (action === 'edit') { + const mat = _matCache[mid]; + if (mat) openEditModal(mat.id, mat.title, mat.description || ''); + } else if (action === 'delete') { + const mat = _matCache[mid]; + if (mat) openDeleteModal(mat.id, mat.title); + } + }); + + // Init forms + _initUploadForm(); + _initEditForm(); + + // Load activities for browser view + loadAllActivities(); + + // Check if URL has ?id= param to open detail directly + const params = new URLSearchParams(window.location.search); + const actId = params.get('id'); + if (actId) openActivity(actId); +}); \ No newline at end of file diff --git a/src/api/materials.py b/src/api/materials.py new file mode 100644 index 0000000..ac5b5a1 --- /dev/null +++ b/src/api/materials.py @@ -0,0 +1,496 @@ +""" +Course Materials API – A2 module +================================= +Endpoints: + GET /api/activities/:activity_id/materials + POST /api/activities/:activity_id/materials + DELETE /api/activities/:activity_id/materials/:mid + GET /api/activities/:activity_id/materials/:mid/download + +Storage: Cloudflare R2 (env.R2_BUCKET) +Database: Cloudflare D1 (env.DB) +Auth: HMAC-SHA256 stateless tokens (verify_token from worker) +""" + +import json +import os + + +# --------------------------------------------------------------------------- +# Internal helpers (imported lazily from worker to avoid circular imports) +# --------------------------------------------------------------------------- + +def _worker(): + """Return the worker module. Imported once and cached.""" + import importlib, sys + if "worker" in sys.modules: + return sys.modules["worker"] + # When running inside the CF runtime the module is already loaded as + # the top-level entry-point; fall back to a direct import for tests. + return importlib.import_module("worker") + + +def _new_id() -> str: + """Generate a UUID-v4 using os.urandom (no stdlib uuid needed).""" + b = bytearray(os.urandom(16)) + b[6] = (b[6] & 0x0F) | 0x40 # version 4 + b[8] = (b[8] & 0x3F) | 0x80 # RFC 4122 variant + h = b.hex() + return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}" + + +def _r2_key(activity_id: str, filename: str) -> str: + """Build a deterministic, collision-resistant R2 object key. + + Format: ``materials/{activity_id}/{uuid}_{sanitised_filename}`` + + The UUID prefix guarantees uniqueness even when the same filename is + uploaded twice. The sanitised filename is kept for human readability + inside the R2 bucket. + """ + safe_name = "".join( + c if (c.isalnum() or c in "-_.") else "_" + for c in filename + )[:128] # cap at 128 chars to stay well within R2 key limits + return f"materials/{activity_id}/{_new_id()}_{safe_name}" + + +async def _parse_multipart(req): + """ + Parse a multipart/form-data request using the CF Workers FormData API. + + Returns ``(fields_dict, error_response | None)`` where *fields_dict* + maps field names to either a string value (text fields) or a dict + ``{"filename": str, "bytes": bytes}`` (file fields). + + Falls back to a plain JSON body when the Content-Type is + ``application/json`` so that unit tests can exercise the handler + without constructing real multipart payloads. + """ + w = _worker() + content_type = (req.headers.get("Content-Type") or + req.headers.get("content-type") or "") + + # ---- JSON fallback (used by unit tests) -------------------------------- + if "application/json" in content_type: + try: + text = await req.text() + body = json.loads(text) + except Exception: + return None, w.err("Invalid JSON body", 400) + if not isinstance(body, dict): + return None, w.err("JSON body must be an object", 400) + return body, None + + # ---- Real multipart (CF Workers runtime) -------------------------------- + try: + form_data = await req.formData() + except Exception: + return None, w.err("Failed to parse multipart form data", 400) + + fields = {} + try: + # formData() returns a JS FormData object; iterate its entries. + for key, value in form_data.entries(): + # File entries expose a .name attribute (the original filename). + filename = getattr(value, "name", None) or getattr(value, "filename", None) + if filename is not None: + # It's a File/Blob – read raw bytes. + try: + arr_buf = await value.arrayBuffer() + import js # noqa: PLC0415 – CF runtime only + raw = bytes(js.Uint8Array.new(arr_buf)) + except Exception: + raw = b"" + fields[key] = {"filename": filename, "bytes": raw} + else: + fields[key] = str(value) + except Exception: + # Fallback: try the dict-like .get() interface some runtimes expose. + for field_name in ("title", "description", "file"): + val = form_data.get(field_name) + if val is None: + continue + filename = getattr(val, "name", None) or getattr(val, "filename", None) + if filename is not None: + try: + arr_buf = await val.arrayBuffer() + import js # noqa: PLC0415 + raw = bytes(js.Uint8Array.new(arr_buf)) + except Exception: + raw = b"" + fields[field_name] = {"filename": filename, "bytes": raw} + else: + fields[field_name] = str(val) + + return fields, None + + +async def _upload_to_r2(env, key: str, data: bytes, content_type: str = "application/octet-stream", + original_filename: str = ""): + """Upload *data* to R2 under *key*. + + Stores ``Content-Type`` and ``Content-Disposition`` in R2 httpMetadata so + that presigned download URLs automatically serve the correct filename and + MIME type without needing a proxy. + + Uses ``env.R2_BUCKET.put(key, data, options)`` which is the standard + Cloudflare Workers R2 binding API. + """ + try: + from pyodide.ffi import to_js # noqa: PLC0415 – CF runtime only + import js # noqa: PLC0415 + http_meta = {"contentType": content_type} + if original_filename: + http_meta["contentDisposition"] = f'attachment; filename="{original_filename}"' + options = to_js( + {"httpMetadata": http_meta}, + dict_converter=js.Object.fromEntries, + ) + await env.R2_BUCKET.put(key, to_js(data, create_pyproxies=False), options) + except ImportError: + # Unit-test environment: env.R2_BUCKET is a MagicMock / AsyncMock. + await env.R2_BUCKET.put(key, data) + + +async def _delete_from_r2(env, key: str): + """Delete the object at *key* from R2 (best-effort; errors are swallowed).""" + try: + await env.R2_BUCKET.delete(key) + except Exception as exc: + w = _worker() + await w.capture_exception(exc, _env=env, where="materials._delete_from_r2") + + +async def _generate_download_url(env, key: str) -> str: + """Return a time-limited signed URL for *key* from R2. + + Cloudflare R2 supports ``createPresignedUrl`` on the bucket binding. + If the binding does not expose that method (e.g. older SDK or unit + tests), we fall back to a plain ``/api/r2/{key}`` path that the + worker can proxy. + """ + try: + # CF Workers R2 binding: bucket.createPresignedUrl(key, {expiresIn}) + url = await env.R2_BUCKET.createPresignedUrl(key, {"expiresIn": 3600}) + return str(url) + except Exception: + pass + # Fallback: internal proxy URL (worker must implement GET /api/r2/:key) + return f"/api/r2/{key}" + + +# --------------------------------------------------------------------------- +# Endpoint handlers +# --------------------------------------------------------------------------- + +async def list_materials(activity_id: str, req, env): + """GET /api/activities/:activity_id/materials + + Returns all materials for the given activity ordered by created_at DESC. + Authentication is optional – public read access is allowed so that + unenrolled visitors can see what materials an activity offers. + """ + w = _worker() + + # Validate activity exists + try: + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id = ?" + ).bind(activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.list.activity_lookup") + return w.err("Database error", 500) + + if not act: + return w.err("Activity not found", 404) + + try: + res = await env.DB.prepare( + "SELECT id, activity_id, title, description, uploaded_by, created_at" + " FROM course_materials" + " WHERE activity_id = ?" + " ORDER BY created_at DESC" + ).bind(activity_id).all() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.list.query") + return w.err("Failed to fetch materials", 500) + + materials = [] + for row in res.results or []: + materials.append({ + "id": row.id, + "activity_id": row.activity_id, + "title": row.title, + "description": row.description or "", + "uploaded_by": row.uploaded_by, + "created_at": row.created_at, + }) + + return w.json_resp({"materials": materials, "count": len(materials)}) + + +async def upload_material(activity_id: str, req, env): + """POST /api/activities/:activity_id/materials + + Accepts multipart/form-data with fields: + - title (required, text) + - description (optional, text) + - file (required, binary) + + Uploads the file to R2 and stores metadata in D1. + Requires authentication. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Validate activity exists + try: + act = await env.DB.prepare( + "SELECT id FROM activities WHERE id = ?" + ).bind(activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.upload.activity_lookup") + return w.err("Database error", 500) + + if not act: + return w.err("Activity not found", 404) + + # Parse multipart body + fields, parse_err = await _parse_multipart(req) + if parse_err: + return parse_err + + title = (fields.get("title") or "").strip() if isinstance(fields.get("title"), str) else "" + description = (fields.get("description") or "").strip() if isinstance(fields.get("description"), str) else "" + + if not title: + return w.err("title is required", 400) + + file_field = fields.get("file") + if not file_field: + return w.err("file is required", 400) + + # Support both dict (multipart) and raw bytes (test injection) + if isinstance(file_field, dict): + filename = file_field.get("filename") or "upload" + file_bytes = file_field.get("bytes") or b"" + content_type = file_field.get("content_type") or "application/octet-stream" + elif isinstance(file_field, (bytes, bytearray)): + filename = fields.get("filename") or "upload" + file_bytes = bytes(file_field) + content_type = "application/octet-stream" + else: + return w.err("Invalid file field", 400) + + if not file_bytes: + return w.err("Uploaded file is empty", 400) + + # Build R2 key and upload + r2_key = _r2_key(activity_id, filename) + try: + await _upload_to_r2(env, r2_key, file_bytes, content_type, original_filename=filename) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.upload.r2_put") + return w.err("File upload failed β€” please try again", 500) + + # Persist metadata in D1 + mid = _new_id() + try: + await env.DB.prepare( + "INSERT INTO course_materials" + " (id, activity_id, title, description, file_key, uploaded_by)" + " VALUES (?, ?, ?, ?, ?, ?)" + ).bind(mid, activity_id, title, description, r2_key, user["id"]).run() + except Exception as exc: + # Compensating action: remove the orphaned R2 object + await _delete_from_r2(env, r2_key) + await w.capture_exception(exc, req, env, "materials.upload.db_insert") + return w.err("Failed to save material metadata β€” please try again", 500) + + return w.ok( + { + "id": mid, + "activity_id": activity_id, + "title": title, + "description": description, + "file_key": r2_key, # internal key – not a public URL + }, + "Material uploaded successfully", + ) + + +async def delete_material(activity_id: str, mid: str, req, env): + """DELETE /api/activities/:activity_id/materials/:mid + + Deletes the material record from D1 and the file from R2. + Only the uploader or the activity host may delete a material. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Fetch the material (also validates activity_id matches) + try: + row = await env.DB.prepare( + "SELECT id, activity_id, file_key, uploaded_by" + " FROM course_materials" + " WHERE id = ? AND activity_id = ?" + ).bind(mid, activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.delete.lookup") + return w.err("Database error", 500) + + if not row: + return w.err("Material not found", 404) + + # Authorisation: uploader OR activity host may delete + is_uploader = (row.uploaded_by == user["id"]) + is_host = False + if not is_uploader: + try: + act = await env.DB.prepare( + "SELECT host_id FROM activities WHERE id = ?" + ).bind(activity_id).first() + is_host = bool(act and act.host_id == user["id"]) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.delete.host_check") + + if not is_uploader and not is_host: + return w.err("Permission denied", 403) + + # Delete from D1 first (so the record is gone even if R2 delete lags) + try: + await env.DB.prepare( + "DELETE FROM course_materials WHERE id = ?" + ).bind(mid).run() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.delete.db_delete") + return w.err("Failed to delete material record", 500) + + # Best-effort R2 deletion (errors are logged but do not fail the request) + await _delete_from_r2(env, row.file_key) + + return w.ok({"id": mid}, "Material deleted successfully") + + +async def update_material(activity_id: str, mid: str, req, env): + """PATCH /api/activities/:activity_id/materials/:mid + + Updates the title and/or description of an existing material. + Only the uploader or the activity host may edit a material. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Fetch the material (also validates activity_id matches) + try: + row = await env.DB.prepare( + "SELECT id, activity_id, uploaded_by" + " FROM course_materials" + " WHERE id = ? AND activity_id = ?" + ).bind(mid, activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.update.lookup") + return w.err("Database error", 500) + + if not row: + return w.err("Material not found", 404) + + # Authorisation: uploader OR activity host may edit + is_uploader = (row.uploaded_by == user["id"]) + is_host = False + if not is_uploader: + try: + act = await env.DB.prepare( + "SELECT host_id FROM activities WHERE id = ?" + ).bind(activity_id).first() + is_host = bool(act and act.host_id == user["id"]) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.update.host_check") + + if not is_uploader and not is_host: + return w.err("Permission denied", 403) + + # Parse JSON body + body, parse_err = await w.parse_json_object(req) + if parse_err: + return parse_err + + title = (body.get("title") or "").strip() + description = (body.get("description") or "").strip() + + if not title: + return w.err("title is required", 400) + + try: + await env.DB.prepare( + "UPDATE course_materials SET title = ?, description = ? WHERE id = ?" + ).bind(title, description, mid).run() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.update.db_update") + return w.err("Failed to update material", 500) + + return w.ok( + {"id": mid, "title": title, "description": description}, + "Material updated successfully", + ) + + +async def download_material(activity_id: str, mid: str, req, env): + """GET /api/activities/:activity_id/materials/:mid/download + + Returns a time-limited signed URL for the material's R2 object. + The raw R2 key is never exposed to the client. + Requires authentication. + """ + w = _worker() + + user = w.verify_token(req.headers.get("Authorization"), env.JWT_SECRET) + if not user: + return w.err("Authentication required", 401) + + # Fetch material (validates activity_id scope) + try: + row = await env.DB.prepare( + "SELECT id, title, file_key" + " FROM course_materials" + " WHERE id = ? AND activity_id = ?" + ).bind(mid, activity_id).first() + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.download.lookup") + return w.err("Database error", 500) + + if not row: + return w.err("Material not found", 404) + + # Generate a signed / temporary download URL + try: + download_url = await _generate_download_url(env, row.file_key) + except Exception as exc: + await w.capture_exception(exc, req, env, "materials.download.url_gen") + return w.err("Failed to generate download URL", 500) + + # Derive the original filename from the R2 key + # Key format: materials/{activity_id}/{uuid}_{original_filename} + raw_name = row.file_key.split("/")[-1] + original_filename = raw_name[37:] if len(raw_name) > 37 else raw_name + + return w.ok( + { + "id": row.id, + "title": row.title, + "filename": original_filename, + "download_url": download_url, + "expires_in": 3600, # seconds + }, + "Download URL generated", + ) diff --git a/src/worker.py b/src/worker.py index bf344bf..bfd59ab 100644 --- a/src/worker.py +++ b/src/worker.py @@ -1093,6 +1093,21 @@ async def send_password_reset_email(to_email: str, _username: str, token: str, e "CREATE INDEX IF NOT EXISTS idx_message_requests_to_user ON message_requests(to_user_id, status)", "CREATE INDEX IF NOT EXISTS idx_message_requests_from_user ON message_requests(from_user_id)", "CREATE INDEX IF NOT EXISTS idx_message_requests_activity ON message_requests(activity_id)", + # Course materials (A2 module) + """CREATE TABLE IF NOT EXISTS course_materials ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + file_key TEXT NOT NULL, + uploaded_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL + )""", + "CREATE INDEX IF NOT EXISTS idx_materials_activity ON course_materials(activity_id)", + "CREATE INDEX IF NOT EXISTS idx_materials_uploader ON course_materials(uploaded_by)", + "CREATE INDEX IF NOT EXISTS idx_materials_created ON course_materials(activity_id, created_at DESC)", ] @@ -7669,6 +7684,83 @@ async def _dispatch(request, env): return await api_send_thread_message(request, env, m_thread_send.group(1)) # --- END NEW --- + # Course Materials (A2 module) + m_mat_list = re.fullmatch(r"/api/activities/([A-Za-z0-9_-]+)/materials", path) + if m_mat_list: + from api.materials import list_materials, upload_material # noqa: PLC0415 + if method == "GET": + return await list_materials(m_mat_list.group(1), request, env) + if method == "POST": + return await upload_material(m_mat_list.group(1), request, env) + + m_mat_download = re.fullmatch( + r"/api/activities/([A-Za-z0-9_-]+)/materials/([A-Za-z0-9_-]+)/download", path + ) + if m_mat_download and method == "GET": + from api.materials import download_material # noqa: PLC0415 + return await download_material(m_mat_download.group(1), m_mat_download.group(2), request, env) + + m_mat_item = re.fullmatch( + r"/api/activities/([A-Za-z0-9_-]+)/materials/([A-Za-z0-9_-]+)", path + ) + if m_mat_item and method == "DELETE": + from api.materials import delete_material # noqa: PLC0415 + return await delete_material(m_mat_item.group(1), m_mat_item.group(2), request, env) + if m_mat_item and method == "PATCH": + from api.materials import update_material # noqa: PLC0415 + return await update_material(m_mat_item.group(1), m_mat_item.group(2), request, env) + + # R2 object proxy – fallback download when presigned URLs aren't available + # Matches /api/r2/ (the key may contain slashes) + if path.startswith("/api/r2/") and method == "GET": + r2_key = path[len("/api/r2/"):] + # Accept token from Authorization header OR ?token= query param + # (browser navigation can't set headers, so we allow query param) + auth_header = request.headers.get("Authorization") or "" + if not auth_header: + qs_params = parse_qs(urlparse(request.url).query) + token_param = (qs_params.get("token") or [None])[0] + if token_param: + auth_header = token_param + user = verify_token(auth_header, env.JWT_SECRET) + if not user: + return err("Authentication required", 401) + try: + obj = await env.R2_BUCKET.get(r2_key) + if obj is None: + return err("File not found", 404) + try: + from pyodide.ffi import to_js # noqa: PLC0415 + import js # noqa: PLC0415 + body_bytes = bytes(js.Uint8Array.new(await obj.arrayBuffer())) + except ImportError: + body_bytes = await obj.arrayBuffer() + ct = (getattr(obj, "httpMetadata", None) and + getattr(obj.httpMetadata, "contentType", None)) or "application/octet-stream" + # R2 key format: materials/{act_id}/{uuid}_{original_filename} + # Strip the UUID prefix (36 chars) + underscore to get original name + raw_name = r2_key.split("/")[-1] + filename = raw_name[37:] if len(raw_name) > 37 else raw_name + try: + import js as _js # noqa: PLC0415 + from pyodide.ffi import to_js as _to_js # noqa: PLC0415 + headers = _to_js({ + "Content-Type": ct, + "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, max-age=3600", + }, dict_converter=_js.Object.fromEntries) + return _js.Response.new( + _to_js(body_bytes, create_pyproxies=False), + _to_js({"status": 200, "headers": headers}, + dict_converter=_js.Object.fromEntries), + ) + except ImportError: + # Unit-test / non-CF environment + return json_resp({"error": "R2 proxy not available in this environment"}, 501) + except Exception as exc: + await capture_exception(exc, request, env, "r2_proxy") + return err("Failed to retrieve file", 500) + # Notifications if path == "/api/notifications" and method == "GET": return await api_list_notifications(request, env) diff --git a/tests/test_api_materials.py b/tests/test_api_materials.py new file mode 100644 index 0000000..d5f9688 --- /dev/null +++ b/tests/test_api_materials.py @@ -0,0 +1,662 @@ +""" +Tests for the Course Materials API (A2 module). + +Covers: + * list_materials – GET /api/activities/:activity_id/materials + * upload_material – POST /api/activities/:activity_id/materials + * delete_material – DELETE /api/activities/:activity_id/materials/:mid + * download_material – GET /api/activities/:activity_id/materials/:mid/download + +Helper stubs: + * MockR2Bucket – simulates env.R2_BUCKET (put / delete / createPresignedUrl) +""" + +import importlib +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Ensure the stubs installed by conftest.py are present before any import +# --------------------------------------------------------------------------- +from tests.helpers import ( + MockDB, + MockRequest, + MockRow, + load_worker, + make_env, + make_stmt, +) + +# Load worker module (needed for create_token / verify_token) +worker = load_worker() + +# --------------------------------------------------------------------------- +# Load the materials module under test +# --------------------------------------------------------------------------- + +_MATERIALS_PATH = Path(__file__).parent.parent / "src" / "api" / "materials.py" + + +def _load_materials(): + """Load src/api/materials.py and inject the already-loaded worker stub.""" + # Make sure "worker" resolves to the already-loaded module inside materials.py + sys.modules.setdefault("worker", worker) + spec = importlib.util.spec_from_file_location("src.api.materials", _MATERIALS_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +materials = _load_materials() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +JWT = "test-jwt-secret" +ENC = "test-encryption-key" +ACTIVITY_ID = "act-test-001" +MATERIAL_ID = "mat-test-001" +USER_ID = "usr-host-001" +OTHER_USER_ID = "usr-other-001" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _token(uid=USER_ID, username="alice", role="host"): + return worker.create_token(uid, username, role, JWT) + + +def _auth(uid=USER_ID, username="alice", role="host"): + return {"Authorization": f"Bearer {_token(uid, username, role)}"} + + +def _parse(resp): + return json.loads(resp.body) + + +def _make_material_row(**kwargs): + defaults = dict( + id=MATERIAL_ID, + activity_id=ACTIVITY_ID, + title="Lecture Slides", + description="Week 1 slides", + file_key=f"materials/{ACTIVITY_ID}/uuid_slides.pdf", + uploaded_by=USER_ID, + created_at="2024-06-01 10:00:00", + ) + defaults.update(kwargs) + return MockRow(**defaults) + + +# --------------------------------------------------------------------------- +# Mock R2 bucket +# --------------------------------------------------------------------------- + +class MockR2Bucket: + """Minimal R2 bucket stub that records calls.""" + + def __init__(self, presigned_url="https://r2.example.com/signed?token=abc"): + self._presigned_url = presigned_url + self.put = AsyncMock(return_value=None) + self.delete = AsyncMock(return_value=None) + self.createPresignedUrl = AsyncMock(return_value=presigned_url) + + +def _make_env_with_r2(db=None, r2=None): + env = make_env(db=db, enc_key=ENC, jwt_secret=JWT) + env.R2_BUCKET = r2 if r2 is not None else MockR2Bucket() + return env + + +# --------------------------------------------------------------------------- +# list_materials +# --------------------------------------------------------------------------- + +class TestListMaterials: + """GET /api/activities/:activity_id/materials""" + + def _req(self, activity_id=ACTIVITY_ID): + return MockRequest(method="GET", + url=f"http://localhost/api/activities/{activity_id}/materials") + + async def test_returns_materials_list(self): + row = _make_material_row() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), # activity exists + make_stmt(all_results=[row]), # SELECT materials + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + assert "materials" in data + assert data["count"] == 1 + assert data["materials"][0]["id"] == MATERIAL_ID + assert data["materials"][0]["title"] == "Lecture Slides" + + async def test_empty_list_when_no_materials(self): + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=[]), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + assert data["materials"] == [] + assert data["count"] == 0 + + async def test_activity_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=None), # activity not found + ])) + resp = await materials.list_materials("nonexistent", self._req("nonexistent"), env) + assert resp.status == 404 + assert "not found" in _parse(resp)["error"].lower() + + async def test_db_error_returns_500(self): + stmt = make_stmt() + stmt.bind.return_value.first.side_effect = Exception("DB down") + env = _make_env_with_r2(db=MockDB([stmt])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 500 + + async def test_materials_query_error_returns_500(self): + act_stmt = make_stmt(first=MockRow(id=ACTIVITY_ID)) + mat_stmt = make_stmt() + mat_stmt.bind.return_value.all.side_effect = Exception("Query failed") + env = _make_env_with_r2(db=MockDB([act_stmt, mat_stmt])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + assert resp.status == 500 + + async def test_response_fields_present(self): + row = _make_material_row() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=[row]), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + mat = _parse(resp)["materials"][0] + for field in ("id", "activity_id", "title", "description", "uploaded_by", "created_at"): + assert field in mat, f"Missing field: {field}" + + async def test_file_key_not_exposed_in_list(self): + """R2 file_key must never appear in the list response.""" + row = _make_material_row() + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=[row]), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + mat = _parse(resp)["materials"][0] + assert "file_key" not in mat + + async def test_multiple_materials_returned(self): + rows = [_make_material_row(id=f"mat-{i}", title=f"Material {i}") for i in range(3)] + env = _make_env_with_r2(db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(all_results=rows), + ])) + resp = await materials.list_materials(ACTIVITY_ID, self._req(), env) + data = _parse(resp) + assert data["count"] == 3 + assert len(data["materials"]) == 3 + + +# --------------------------------------------------------------------------- +# upload_material +# --------------------------------------------------------------------------- + +class TestUploadMaterial: + """POST /api/activities/:activity_id/materials""" + + def _req(self, activity_id=ACTIVITY_ID, headers=None, body=None): + h = {"Content-Type": "application/json"} + if headers: + h.update(headers) + return MockRequest( + method="POST", + url=f"http://localhost/api/activities/{activity_id}/materials", + headers=h, + body=json.dumps(body) if body is not None else None, + ) + + def _upload_req(self, title="Slides", description="", filename="slides.pdf", + file_bytes=b"%PDF-1.4 test", activity_id=ACTIVITY_ID, uid=USER_ID): + """Build a JSON-body request that exercises the JSON fallback path in _parse_multipart.""" + body = { + "title": title, + "description": description, + "filename": filename, + "file": { + "filename": filename, + "bytes": list(file_bytes), # JSON-serialisable + "content_type": "application/pdf", + }, + } + return MockRequest( + method="POST", + url=f"http://localhost/api/activities/{activity_id}/materials", + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {_token(uid)}", + }, + body=json.dumps(body), + ) + + async def test_no_auth_returns_401(self): + env = _make_env_with_r2() + resp = await materials.upload_material(ACTIVITY_ID, self._req(), env) + assert resp.status == 401 + + async def test_activity_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + req = self._upload_req() + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 404 + + async def test_missing_title_returns_400(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + body = {"description": "no title", "file": {"filename": "f.pdf", "bytes": [1, 2, 3]}} + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body=json.dumps(body), + ) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + assert "title" in _parse(resp)["error"].lower() + + async def test_missing_file_returns_400(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + body = {"title": "Slides"} + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body=json.dumps(body), + ) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + assert "file" in _parse(resp)["error"].lower() + + async def test_successful_upload_returns_200(self): + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), # activity check + make_stmt(), # INSERT material + ]), + r2=r2, + ) + req = self._upload_req() + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert "id" in data["data"] + assert data["data"]["title"] == "Slides" + assert data["data"]["activity_id"] == ACTIVITY_ID + + async def test_r2_put_called_on_success(self): + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + r2.put.assert_called_once() + + async def test_r2_key_format(self): + """R2 key must start with materials/{activity_id}/.""" + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + call_args = r2.put.call_args + key = call_args[0][0] + assert key.startswith(f"materials/{ACTIVITY_ID}/") + + async def test_file_key_not_exposed_in_response(self): + """file_key is returned in upload response for internal use but raw R2 path is opaque.""" + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + resp = await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + data = _parse(resp) + # file_key is present in upload response (internal reference) but must not be a raw URL + key = data["data"].get("file_key", "") + assert not key.startswith("http"), "file_key must not be a public URL" + + async def test_r2_upload_failure_returns_500(self): + r2 = MockR2Bucket() + r2.put.side_effect = Exception("R2 unavailable") + env = _make_env_with_r2( + db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))]), + r2=r2, + ) + resp = await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + assert resp.status == 500 + + async def test_db_insert_failure_deletes_r2_object(self): + """If D1 insert fails after R2 upload, the orphaned R2 object must be deleted.""" + r2 = MockR2Bucket() + insert_stmt = make_stmt() + insert_stmt.bind.return_value.run.side_effect = Exception("D1 write error") + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + insert_stmt, + ]), + r2=r2, + ) + resp = await materials.upload_material(ACTIVITY_ID, self._upload_req(), env) + assert resp.status == 500 + # Compensating delete must have been called + r2.delete.assert_called_once() + + async def test_empty_file_bytes_returns_400(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + body = {"title": "Empty", "file": {"filename": "empty.pdf", "bytes": []}} + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body=json.dumps(body), + ) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + + async def test_invalid_json_returns_400(self): + req = MockRequest( + method="POST", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials", + headers={"Content-Type": "application/json", "Authorization": f"Bearer {_token()}"}, + body="not-json", + ) + env = _make_env_with_r2(db=MockDB([make_stmt(first=MockRow(id=ACTIVITY_ID))])) + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 400 + + async def test_description_is_optional(self): + r2 = MockR2Bucket() + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=MockRow(id=ACTIVITY_ID)), + make_stmt(), + ]), + r2=r2, + ) + req = self._upload_req(description="") + resp = await materials.upload_material(ACTIVITY_ID, req, env) + assert resp.status == 200 + + +# --------------------------------------------------------------------------- +# delete_material +# --------------------------------------------------------------------------- + +class TestDeleteMaterial: + """DELETE /api/activities/:activity_id/materials/:mid""" + + def _req(self, activity_id=ACTIVITY_ID, mid=MATERIAL_ID, uid=USER_ID): + return MockRequest( + method="DELETE", + url=f"http://localhost/api/activities/{activity_id}/materials/{mid}", + headers={"Authorization": f"Bearer {_token(uid)}"}, + ) + + async def test_no_auth_returns_401(self): + env = _make_env_with_r2() + req = MockRequest(method="DELETE", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials/{MATERIAL_ID}") + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, req, env) + assert resp.status == 401 + + async def test_material_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + async def test_uploader_can_delete(self): + r2 = MockR2Bucket() + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), # material lookup + make_stmt(), # DELETE + ]), + r2=r2, + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID), env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert data["data"]["id"] == MATERIAL_ID + + async def test_host_can_delete_others_material(self): + """Activity host should be able to delete any material in their activity.""" + r2 = MockR2Bucket() + mat_row = _make_material_row(uploaded_by=OTHER_USER_ID) + host_act_row = MockRow(host_id=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), # material lookup + make_stmt(first=host_act_row), # activity host check + make_stmt(), # DELETE + ]), + r2=r2, + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID), env) + assert resp.status == 200 + + async def test_non_owner_non_host_returns_403(self): + mat_row = _make_material_row(uploaded_by=OTHER_USER_ID) + act_row = MockRow(host_id=OTHER_USER_ID) # different host + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + make_stmt(first=act_row), + ]), + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(uid=USER_ID), env) + assert resp.status == 403 + + async def test_r2_delete_called_after_db_delete(self): + r2 = MockR2Bucket() + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + make_stmt(), + ]), + r2=r2, + ) + await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + r2.delete.assert_called_once_with(mat_row.file_key) + + async def test_db_delete_failure_returns_500(self): + mat_row = _make_material_row(uploaded_by=USER_ID) + del_stmt = make_stmt() + del_stmt.bind.return_value.run.side_effect = Exception("DB error") + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + del_stmt, + ]), + ) + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 500 + + async def test_r2_delete_error_does_not_fail_request(self): + """R2 delete errors are swallowed – the DB record is already gone.""" + r2 = MockR2Bucket() + r2.delete.side_effect = Exception("R2 error") + mat_row = _make_material_row(uploaded_by=USER_ID) + env = _make_env_with_r2( + db=MockDB([ + make_stmt(first=mat_row), + make_stmt(), + ]), + r2=r2, + ) + # Should still return 200 even though R2 delete raised + resp = await materials.delete_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 200 + + async def test_activity_id_scoping(self): + """Material must belong to the given activity_id (enforced by WHERE clause).""" + # Simulate the DB returning None because activity_id doesn't match + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.delete_material("wrong-activity", MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + +# --------------------------------------------------------------------------- +# download_material +# --------------------------------------------------------------------------- + +class TestDownloadMaterial: + """GET /api/activities/:activity_id/materials/:mid/download""" + + def _req(self, activity_id=ACTIVITY_ID, mid=MATERIAL_ID, uid=USER_ID): + return MockRequest( + method="GET", + url=f"http://localhost/api/activities/{activity_id}/materials/{mid}/download", + headers={"Authorization": f"Bearer {_token(uid)}"}, + ) + + async def test_no_auth_returns_401(self): + env = _make_env_with_r2() + req = MockRequest( + method="GET", + url=f"http://localhost/api/activities/{ACTIVITY_ID}/materials/{MATERIAL_ID}/download", + ) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, req, env) + assert resp.status == 401 + + async def test_material_not_found_returns_404(self): + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + async def test_returns_signed_url(self): + signed_url = "https://r2.example.com/signed?token=xyz&expires=3600" + r2 = MockR2Bucket(presigned_url=signed_url) + mat_row = MockRow(id=MATERIAL_ID, title="Slides", + file_key=f"materials/{ACTIVITY_ID}/uuid_slides.pdf") + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + assert data["success"] is True + assert data["data"]["download_url"] == signed_url + assert data["data"]["id"] == MATERIAL_ID + assert data["data"]["title"] == "Slides" + + async def test_expires_in_field_present(self): + r2 = MockR2Bucket() + mat_row = MockRow(id=MATERIAL_ID, title="Slides", + file_key=f"materials/{ACTIVITY_ID}/uuid_slides.pdf") + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + data = _parse(resp) + assert "expires_in" in data["data"] + assert data["data"]["expires_in"] == 3600 + + async def test_raw_r2_key_not_in_response(self): + """The raw R2 file_key must never appear in the download response.""" + r2 = MockR2Bucket() + file_key = f"materials/{ACTIVITY_ID}/uuid_slides.pdf" + mat_row = MockRow(id=MATERIAL_ID, title="Slides", file_key=file_key) + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + body_str = resp.body + assert file_key not in body_str + + async def test_presigned_url_fallback_when_r2_method_missing(self): + """When createPresignedUrl is unavailable, fall back to /api/r2/:key proxy URL.""" + r2 = MockR2Bucket() + r2.createPresignedUrl.side_effect = Exception("Method not available") + file_key = f"materials/{ACTIVITY_ID}/uuid_slides.pdf" + mat_row = MockRow(id=MATERIAL_ID, title="Slides", file_key=file_key) + env = _make_env_with_r2(db=MockDB([make_stmt(first=mat_row)]), r2=r2) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 200 + data = _parse(resp) + # Fallback URL should be the internal proxy path + assert data["data"]["download_url"] == f"/api/r2/{file_key}" + + async def test_db_error_returns_500(self): + stmt = make_stmt() + stmt.bind.return_value.first.side_effect = Exception("DB down") + env = _make_env_with_r2(db=MockDB([stmt])) + resp = await materials.download_material(ACTIVITY_ID, MATERIAL_ID, self._req(), env) + assert resp.status == 500 + + async def test_activity_id_scoping(self): + """Material must belong to the given activity_id.""" + env = _make_env_with_r2(db=MockDB([make_stmt(first=None)])) + resp = await materials.download_material("wrong-activity", MATERIAL_ID, self._req(), env) + assert resp.status == 404 + + +# --------------------------------------------------------------------------- +# Helper unit tests +# --------------------------------------------------------------------------- + +class TestHelpers: + """Unit tests for internal helper functions.""" + + def test_new_id_is_uuid_format(self): + uid = materials._new_id() + parts = uid.split("-") + assert len(parts) == 5 + assert len(uid) == 36 + + def test_new_id_uniqueness(self): + ids = {materials._new_id() for _ in range(100)} + assert len(ids) == 100 + + def test_r2_key_format(self): + key = materials._r2_key("act-123", "lecture.pdf") + assert key.startswith("materials/act-123/") + assert key.endswith("lecture.pdf") + + def test_r2_key_sanitises_special_chars(self): + key = materials._r2_key("act-1", "my file (1).pdf") + # Spaces and parentheses should be replaced with underscores + assert " " not in key + assert "(" not in key + assert ")" not in key + + def test_r2_key_caps_filename_length(self): + long_name = "a" * 300 + ".pdf" + key = materials._r2_key("act-1", long_name) + # The filename portion after the UUID_ prefix should be ≀ 128 chars + filename_part = key.split("/")[-1].split("_", 5)[-1] + assert len(filename_part) <= 128 + + def test_r2_key_unique_per_call(self): + k1 = materials._r2_key("act-1", "file.pdf") + k2 = materials._r2_key("act-1", "file.pdf") + assert k1 != k2 From 52868c9cb2de52eeb81fefaf69a302097c40f7b6 Mon Sep 17 00:00:00 2001 From: Ananya Date: Thu, 18 Jun 2026 00:22:58 +0530 Subject: [PATCH 2/5] course materials --- migrations/0006_add_course_materials.sql | 2 +- public/course-materials.html | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/migrations/0006_add_course_materials.sql b/migrations/0006_add_course_materials.sql index b7d5092..08d3b60 100644 --- a/migrations/0006_add_course_materials.sql +++ b/migrations/0006_add_course_materials.sql @@ -1,4 +1,4 @@ --- Migration 0004: Add course_materials table for activity file attachments +-- Migration 0006: Add course_materials table for activity file attachments CREATE TABLE IF NOT EXISTS course_materials ( id TEXT PRIMARY KEY, diff --git a/public/course-materials.html b/public/course-materials.html index 3a85f57..78755b0 100644 --- a/public/course-materials.html +++ b/public/course-materials.html @@ -362,7 +362,16 @@

    Delete Material

    }).then(function (body) { var url = body.data && body.data.download_url; if (!url) { throw new Error('No download URL returned'); } - window.open(url, '_blank', 'noopener,noreferrer'); + if (url.indexOf('/api/r2/') === 0 && authToken) { + url += (url.indexOf('?') === -1 ? '?' : '&') + 'token=' + encodeURIComponent(authToken); + } + var a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); }).catch(function (e) { showToast(e.message || 'Download failed', 'error'); }); From 3eb918a5d3e6f522956687426053a3a2a1d728f1 Mon Sep 17 00:00:00 2001 From: Ananya Date: Thu, 18 Jun 2026 04:06:14 +0530 Subject: [PATCH 3/5] fixes --- public/activity.html | 16 ++-- public/course-materials.html | 77 ++++++++++++++----- public/js/activity.js | 141 +++++++++++++++++++++++++---------- src/api/materials.py | 31 ++++++-- src/worker.py | 3 + tests/test_api_materials.py | 92 +++++++++++++++++++++++ 6 files changed, 289 insertions(+), 71 deletions(-) diff --git a/public/activity.html b/public/activity.html index cd06255..2032f3d 100644 --- a/public/activity.html +++ b/public/activity.html @@ -217,8 +217,8 @@

    @@ -177,19 +177,19 @@

    - @@ -38,7 +38,7 @@