diff --git a/.ci-setup/pdfGen/crontab b/.ci-setup/crontab similarity index 77% rename from .ci-setup/pdfGen/crontab rename to .ci-setup/crontab index 32623a3a58..b1298d5e39 100644 --- a/.ci-setup/pdfGen/crontab +++ b/.ci-setup/crontab @@ -3,7 +3,8 @@ BASH_ENV=/container.env PATH=/tmp/texlive/bin/x86_64-linux:/tmp/texlive/bin/aarch64-linux:/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bundle/bin 10,15,20,25,30,35,40,45,50,55 * * * * /doubtfire/lib/shell/generate_pdfs.sh -0 5 * * 1,3,5 /doubtfire/lib/shell/check_plagiarism.sh +0,10,20,30,40,50 * * * * /doubtfire/lib/shell/send_overseer_notifications.sh +0 5 * * * /doubtfire/lib/shell/check_plagiarism.sh 0 8 * * * /doubtfire/lib/shell/portfolio_autogen_check.sh 0 7 * * 1 /doubtfire/lib/shell/send_weekly_emails.sh 0 1 * * * /doubtfire/lib/shell/sync_enrolments.sh diff --git a/.ci-setup/pdfGen/msmtprc b/.ci-setup/msmtprc similarity index 100% rename from .ci-setup/pdfGen/msmtprc rename to .ci-setup/msmtprc diff --git a/.ci-setup/pdfGen/entry_point.sh b/.ci-setup/pdfGen/entry_point.sh deleted file mode 100755 index b36221fd7a..0000000000 --- a/.ci-setup/pdfGen/entry_point.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# Start the run once job. -echo "Docker container has been started" - -# Save the docker user environment -declare -p | grep -Ev 'BASHOPTS|BASH_VERSINFO|EUID|PPID|SHELLOPTS|UID' > /container.env -cat /container.env - -# Ensure log is present -touch /var/log/cron.log - -sleep 1 - -# Setup crontab - clear then load with file -crontab -r -crontab /etc/cron.d/container_cronjob - -echo "RESET CRONTAB" >> /var/log/cron.log - -# Ensure mail settings are accessible only by root -chown root:root /etc/msmtprc -chmod 600 /etc/msmtprc - -# Run cron and follow log -chmod 644 /etc/cron.d/container_cronjob && cron -f && tail -f /var/log/cron.log > /proc/1/fd/1 2>/proc/1/fd/2 diff --git a/.ci-setup/texlive-install.sh b/.ci-setup/texlive-install.sh deleted file mode 100755 index 52e7844df4..0000000000 --- a/.ci-setup/texlive-install.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -set -e -APP_PATH="$(readlink -f "$(dirname "$0")")" -TEX_COMPILER=lualatex - -CTAN_REPO="https://mirror.aarnet.edu.au/pub/CTAN/systems/texlive/tlnet" - -# See if there is a cached version of TL available -# shellcheck disable=SC2155 -export PATH="/tmp/texlive/bin/$(uname -m)-linux:$PATH" -if ! command -v "$TEX_COMPILER" > /dev/null; then - echo "----------------------------------------" - echo "Downloading texlive installer archive from CTAN:" - wget "$CTAN_REPO/install-tl-unx.tar.gz" - tar -xzf install-tl-unx.tar.gz - cd install-tl-20* - - echo "----------------------------------------" - echo "Installing texlive using profile:" - cat "${APP_PATH}"/texlive.profile - echo - ./install-tl --profile="${APP_PATH}/texlive.profile" -repository "$CTAN_REPO" - - echo "----------------------------------------" - echo "Set tlmgr repository:" - tlmgr option repository "$CTAN_REPO" - - echo "----------------------------------------" - echo "Installing additional texlive packages:" - tlmgr install fontawesome minted fvextra catchfile xstring framed lastpage tcolorbox environ pdfcol tikzfill markdown paralist csvsimple upquote tagpdf - - echo "----------------------------------------" - echo "Ensuring the newpax package is sufficiently up to date:" - if NEWPAX_VERSION=$(tlmgr info --only-installed --data cat-version newpax) ; then - if [[ $(echo "$NEWPAX_VERSION < 0.53" | bc) == 1 ]]; then - echo >&2 "Package newpax version lower than 0.53 contain several bugs that are now fixed, giving up."; exit 1; - else - echo "Version $NEWPAX_VERSION found." - fi - else - echo >&2 "Package newpax not found!"; exit 1; - fi - - cd .. - - # Keep no backups (not required, simply makes cache bigger) - tlmgr option -- autobackup 0 -fi - -echo "----------------------------------------" -echo "Installation complete, verifying installation of $TEX_COMPILER." -command -v "$TEX_COMPILER" >/dev/null 2>&1 || { echo >&2 "$TEX_COMPILER is not found."; exit 1; } -# Do a test compile recommended by https://www.tug.org/texlive/quickinstall.html -"$TEX_COMPILER" small2e || { echo >&2 "Failed to process test file with $TEX_COMPILER."; exit 1; } -# remove test files -rm small2e.aux small2e.log small2e.pdf diff --git a/.ci-setup/texlive.profile b/.ci-setup/texlive.profile deleted file mode 100644 index ca5e417b65..0000000000 --- a/.ci-setup/texlive.profile +++ /dev/null @@ -1,11 +0,0 @@ -selected_scheme scheme-medium -TEXDIR /tmp/texlive -TEXMFCONFIG ~/.texlive/texmf-config -TEXMFHOME ~/texmf -TEXMFLOCAL /tmp/texlive/texmf-local -TEXMFSYSCONFIG /tmp/texlive/texmf-config -TEXMFSYSVAR /tmp/texlive/texmf-var -TEXMFVAR ~/.texlive/texmf-var -option_doc 0 -option_src 0 -instopt_write18_restricted 1 diff --git a/.dockerignore b/.dockerignore index d8fd5e2e36..69259b9e9f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,30 @@ Dockerfile .git +.github +.docker +.bundle +.env +.env.* +!.env.example +.npmrc +.gem/credentials +.ssh +.aws +.config/gcloud build +coverage dist +log node_modules +tmp vendor student-work +config/master.key +config/credentials +config/credentials.yml.enc +**/*.key +**/*.pem +**/*.p12 +**/*.pfx +**/*.jks +**/*.keystore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..fbfc423f8a --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Optional values for development through the legacy root docker-compose.yml. +# Copy to .env. Database authentication remains the safe default. Never commit +# an institution credential or reuse a production registration. +DF_AUTH_METHOD=database +DF_AAF_ISSUER_URL= +DF_AAF_AUDIENCE_URL=http://localhost:3000 +DF_AAF_CALLBACK_URL=http://localhost:3000/api/auth/jwt +DF_AAF_IDENTITY_PROVIDER_URL= +DF_AAF_UNIQUE_URL= +DF_AAF_AUTH_SIGNOUT_URL= +DF_SECRET_KEY_AAF= diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000..53de1f9cbd --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Jan 11 2022: Repository wide format (support zeitwerk mode loader) +f4050e826af83166e45b0b4f2af14179b40305dd diff --git a/.gitattributes b/.gitattributes index d56abbf304..66330e6c40 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Set the default behavior, in case people don't have core.autocrlf set. * text=auto eol=lf + +# Exclude test files from GitHub language statistics +test_files/** linguist-vendored diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..1cc4d7d71b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# The App key is available only to the default-branch policy job. Keep every +# workflow and the evaluator itself under lead review. +/.github/workflows/ @ontrack-features-t2-2026/ontrack-leads +/.github/review-policy/ @ontrack-features-t2-2026/ontrack-leads +/.github/CODEOWNERS @ontrack-features-t2-2026/ontrack-leads diff --git a/.github/review-policy/README.md b/.github/review-policy/README.md new file mode 100644 index 0000000000..bcf9e76f76 --- /dev/null +++ b/.github/review-policy/README.md @@ -0,0 +1,51 @@ +# OnTrack pull-request review policy + +The required status context `ontrack/review-policy` passes when the current pull +request head has either: + +- one approval from a current `ontrack-leads` member; or +- two approvals from distinct current `ontrack-contributors` members. + +Approvals from the pull-request author, bots, stale commits, dismissed reviews, +or reviewers whose latest actionable review requests changes do not count. + +## Security model + +`ontrack-review-policy-signal.yml` is unprivileged and never checks out pull-request +code. A completed signal wakes `ontrack-review-policy.yml` through `workflow_run`. +The evaluator workflow checks out only this directory from the protected default +branch, then mints a short-lived token for the organization-owned GitHub App. + +The App is installed only on the three OnTrack Doubtfire repositories and has: + +- organization Members: read; +- repository Metadata: read (mandatory); +- repository Pull requests: read; and +- repository Commit statuses: write. + +The App has no contents, workflow, administration, merge, or webhook permission. +Its private key is held in `ONTRACK_REVIEW_APP_PRIVATE_KEY` in the +`ontrack-review-policy` environment, which only permits the protected `11.0.x` +branch. Its numeric App ID is held in `ONTRACK_REVIEW_APP_ID`. + +The evaluator reports on the pull-request head commit. GitHub gates on the test merge +commit whenever that commit carries a status and only falls back to the head when it +carries none, so reporting on the test merge commit would move the merge gate onto a +commit that carries none of this repository's other checks. The head is also stable, +where the test merge commit is recomputed every time the base branch moves. +A five-minute reconciliation covers team membership and base-branch changes that +do not emit a pull-request review event. Unchanged results are not republished, +which avoids GitHub's per-commit status limit. + +## Ruleset integration + +Keep the native one-overall-approval rule, stale-review dismissal, and conversation +resolution. Require `ontrack/review-policy` with the OnTrack Review Policy App as +its expected source. Remove the native `ontrack-leads` required-reviewer entry only +after the App status has been observed and made required; otherwise GitHub combines +the native team rules with AND semantics. + +Changes to any workflow, the evaluator, or CODEOWNERS should continue to require +one `ontrack-leads` approval through a path-specific native reviewer rule. This is +necessary because any default-branch workflow could otherwise reference the App's +environment secret. diff --git a/.github/review-policy/evaluate.mjs b/.github/review-policy/evaluate.mjs new file mode 100644 index 0000000000..5def1495f6 --- /dev/null +++ b/.github/review-policy/evaluate.mjs @@ -0,0 +1,529 @@ +import { createSign } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const API_VERSION = '2022-11-28'; +const POLICY_CONTEXT = 'ontrack/review-policy'; +const APP_BOT_LOGIN = 'ontrack-review-policy-t2-2026[bot]'; +const PAGE_SIZE = 100; +const MAX_PAGES = 50; +const ALLOWED_REPOSITORIES = new Set([ + 'doubtfire-deploy', + 'doubtfire-api', + 'doubtfire-web', +]); + +function normalizeLogin(login) { + return String(login || '').toLowerCase(); +} + +function encodeJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +export function createAppJwt(appId, privateKey, nowSeconds = Math.floor(Date.now() / 1000)) { + if (!/^\d+$/.test(String(appId))) { + throw new Error('ONTRACK_REVIEW_APP_ID must be a numeric GitHub App ID.'); + } + if (!String(privateKey).includes('PRIVATE KEY')) { + throw new Error('ONTRACK_REVIEW_APP_PRIVATE_KEY is missing or invalid.'); + } + + const header = encodeJson({ alg: 'RS256', typ: 'JWT' }); + const payload = encodeJson({ + iat: nowSeconds - 60, + exp: nowSeconds + 540, + iss: String(appId), + }); + const unsigned = `${header}.${payload}`; + const signer = createSign('RSA-SHA256'); + signer.update(unsigned); + signer.end(); + const signature = signer.sign(privateKey).toString('base64url'); + return `${unsigned}.${signature}`; +} + +export function approvedReviewers(reviews, headSha, authorLogin) { + const author = normalizeLogin(authorLogin); + const latestActionableReview = new Map(); + const actionableStates = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']); + + const ordered = [...reviews].sort((left, right) => { + const leftTime = Date.parse(left.submitted_at || 0) || 0; + const rightTime = Date.parse(right.submitted_at || 0) || 0; + return leftTime - rightTime || Number(left.id || 0) - Number(right.id || 0); + }); + + for (const review of ordered) { + const login = normalizeLogin(review.user?.login); + const state = String(review.state || '').toUpperCase(); + if (!login || login === author || review.user?.type === 'Bot') { + continue; + } + // A comment after an approval does not revoke the approval. + if (!actionableStates.has(state)) { + continue; + } + latestActionableReview.set(login, review); + } + + return new Set( + [...latestActionableReview.entries()] + .filter(([, review]) => ( + String(review.state || '').toUpperCase() === 'APPROVED' + && review.commit_id === headSha + )) + .map(([login]) => login), + ); +} + +export function evaluatePolicy(approved, leadMembers, contributorMembers) { + const leads = new Set([...leadMembers].map(normalizeLogin)); + const contributors = new Set([...contributorMembers].map(normalizeLogin)); + let leadApprovals = 0; + let contributorApprovals = 0; + + for (const login of approved) { + const normalized = normalizeLogin(login); + if (leads.has(normalized)) { + leadApprovals += 1; + } + if (contributors.has(normalized)) { + contributorApprovals += 1; + } + } + + return { + leadApprovals, + contributorApprovals, + passes: leadApprovals >= 1 || contributorApprovals >= 2, + }; +} + +export function pullRequestNumbersFromWorkflowRun(workflowRun) { + const numbers = new Set(); + for (const pullRequest of workflowRun?.pull_requests || []) { + const number = Number(pullRequest?.number); + if (Number.isSafeInteger(number) && number > 0) { + numbers.add(number); + } + } + + const title = String(workflowRun?.display_title || ''); + const titleMatch = title.match(/\bPR #([1-9]\d{0,9})\b/); + if (titleMatch) { + const number = Number(titleMatch[1]); + if (Number.isSafeInteger(number)) { + numbers.add(number); + } + } + return [...numbers]; +} + +export function safeError(error) { + return String(error?.message || error || 'Unknown error') + .replace(/gh[opsu]_[A-Za-z0-9.\-_]{36,}/g, '[redacted token]') + .replace( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, + '[redacted private key]', + ) + .slice(0, 500); +} + +function repositoryParts(repository) { + const match = String(repository || '').match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/); + if (!match) { + throw new Error('GITHUB_REPOSITORY is invalid.'); + } + return { owner: match[1], repo: match[2] }; +} + +class GitHubApi { + constructor(apiUrl, token) { + this.apiUrl = String(apiUrl || 'https://api.github.com').replace(/\/$/, ''); + this.token = token; + } + + async request(path, { method = 'GET', body, expected = [200] } = {}) { + const response = await fetch(`${this.apiUrl}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${this.token}`, + 'User-Agent': 'ontrack-review-policy', + 'X-GitHub-Api-Version': API_VERSION, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!expected.includes(response.status)) { + const requestId = response.headers.get('x-github-request-id'); + throw new Error( + `GitHub API ${method} ${path} returned ${response.status}` + + (requestId ? ` (request ${requestId})` : ''), + ); + } + + if (response.status === 204) { + return null; + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async paginate(path) { + const items = []; + const separator = path.includes('?') ? '&' : '?'; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const batch = await this.request( + `${path}${separator}per_page=${PAGE_SIZE}&page=${page}`, + ); + if (!Array.isArray(batch)) { + throw new Error(`Expected a list from GitHub API path ${path}.`); + } + items.push(...batch); + if (batch.length < PAGE_SIZE) { + return items; + } + } + throw new Error(`GitHub API pagination exceeded ${MAX_PAGES} pages for ${path}.`); + } +} + +async function mintInstallationToken({ apiUrl, owner, repo, appId, privateKey }) { + const appJwt = createAppJwt(appId, privateKey); + const appApi = new GitHubApi(apiUrl, appJwt); + const installation = await appApi.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/installation`, + ); + const tokenResponse = await appApi.request( + `/app/installations/${installation.id}/access_tokens`, + { + method: 'POST', + expected: [201], + body: { + repositories: [repo], + permissions: { + members: 'read', + pull_requests: 'read', + statuses: 'write', + }, + }, + }, + ); + + if (!tokenResponse?.token) { + throw new Error('GitHub did not return an installation access token.'); + } + // Generated tokens are not repository secrets, so mask them explicitly. + console.log(`::add-mask::${tokenResponse.token}`); + return tokenResponse.token; +} + +async function teamMembers(api, owner, teamSlug) { + const members = await api.paginate( + `/orgs/${encodeURIComponent(owner)}/teams/${encodeURIComponent(teamSlug)}/members`, + ); + return new Set(members.map((member) => normalizeLogin(member.login))); +} + +async function openPullRequests(api, owner, repo) { + return api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=open`, + ); +} + +async function pullRequest(api, owner, repo, number) { + return api.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`, + ); +} + +async function reviewsForPullRequest(api, owner, repo, number) { + return api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/reviews`, + ); +} + +function runUrl(repository, runId) { + return `https://github.com/${repository}/actions/runs/${runId}`; +} + +async function setPolicyStatus(api, owner, repo, sha, state, description, targetUrl) { + if (!/^[0-9a-f]{40}$/i.test(String(sha || ''))) { + throw new Error('Cannot publish the review policy without a valid commit SHA.'); + } + const clippedDescription = description.slice(0, 140); + try { + const statuses = await api.paginate( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` + + `/commits/${encodeURIComponent(sha)}/statuses`, + ); + const latest = statuses.find((status) => ( + status.context === POLICY_CONTEXT + && normalizeLogin(status.creator?.login) === APP_BOT_LOGIN + )); + if (latest?.state === state && latest?.description === clippedDescription) { + return false; + } + } catch (error) { + // Deduplication is only an optimization. Always attempt the fail-closed write + // when status history cannot be read but the status endpoint may still work. + console.warn(`::warning::Status deduplication failed: ${safeError(error)}`); + } + + await api.request( + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/statuses/${sha}`, + { + method: 'POST', + expected: [201], + body: { + state, + context: POLICY_CONTEXT, + description: clippedDescription, + target_url: targetUrl, + }, + }, + ); + return true; +} + +export { setPolicyStatus }; + +// Report on the pull-request head. GitHub gates on the test merge commit whenever that +// commit carries a status and only falls back to the head when it carries none, and the +// test merge commit carries none of this repository's checks. Reporting there would move +// the whole merge gate onto a commit that CI never sees. The head is also stable while +// the test merge commit is recomputed every time the base branch moves. +export function statusShaForPullRequest(pullRequestToReport) { + return pullRequestToReport.head?.sha; +} + +function reviewDigest(reviews) { + return reviews + .map((review) => ( + `${review.id}:${review.state}:${review.commit_id}:` + + `${review.submitted_at}:${review.user?.id}:${review.user?.login}` + )) + .sort() + .join('|'); +} + +function samePullRequestVersion(left, right) { + return ( + left.state === right.state + && left.draft === right.draft + && left.head?.sha === right.head?.sha + && left.base?.ref === right.base?.ref + && left.base?.sha === right.base?.sha + && left.mergeable === right.mergeable + ); +} + +async function evaluatePullRequest({ + api, + owner, + repo, + pullRequest: current, + leads, + contributors, + targetUrl, + attempt = 0, +}) { + // Re-fetch before evaluating so a delayed workflow_run never trusts its event's + // old head or merge SHA. + current = await pullRequest(api, owner, repo, current.number); + if (current.state !== 'open') { + return; + } + + const statusSha = statusShaForPullRequest(current); + if (current.draft) { + await setPolicyStatus( + api, + owner, + repo, + statusSha, + 'pending', + 'Waiting for the pull request to be marked ready for review', + targetUrl, + ); + console.log(`PR #${current.number}: draft`); + return; + } + + const firstReviews = await reviewsForPullRequest(api, owner, repo, current.number); + const checked = await pullRequest(api, owner, repo, current.number); + const secondReviews = await reviewsForPullRequest(api, owner, repo, current.number); + const live = await pullRequest(api, owner, repo, current.number); + if (checked.state !== 'open' || live.state !== 'open') { + return; + } + const liveStatusSha = statusShaForPullRequest(live); + if ( + !samePullRequestVersion(current, checked) + || !samePullRequestVersion(checked, live) + || reviewDigest(firstReviews) !== reviewDigest(secondReviews) + || liveStatusSha !== statusSha + ) { + if (attempt >= 1) { + throw new Error('Pull request changed repeatedly during evaluation.'); + } + console.log(`PR #${current.number}: changed during evaluation; retrying once`); + return evaluatePullRequest({ + api, + owner, + repo, + pullRequest: live, + leads, + contributors, + targetUrl, + attempt: attempt + 1, + }); + } + + const approved = approvedReviewers( + secondReviews, + live.head.sha, + live.user?.login, + ); + const result = evaluatePolicy(approved, leads, contributors); + const state = result.passes ? 'success' : 'pending'; + const description = result.passes + ? `Passed: ${result.leadApprovals}/1 lead or ${result.contributorApprovals}/2 contributors` + : `Waiting: ${result.leadApprovals}/1 lead or ${result.contributorApprovals}/2 contributors`; + + await setPolicyStatus(api, owner, repo, liveStatusSha, state, description, targetUrl); + console.log( + `PR #${current.number}: lead=${result.leadApprovals}, ` + + `contributors=${result.contributorApprovals}, status=${state}`, + ); +} + +async function eventPayload() { + const payloadPath = process.env.GITHUB_EVENT_PATH; + if (!payloadPath) { + return {}; + } + return JSON.parse(await readFile(payloadPath, 'utf8')); +} + +async function pullRequestsToEvaluate(api, owner, repo, eventName, payload) { + const open = await openPullRequests(api, owner, repo); + if (eventName === 'workflow_run') { + // Treat workflow_run fields only as untrusted locators. Match them against + // live open PRs fetched with the App token before evaluating anything. + const numbers = new Set(pullRequestNumbersFromWorkflowRun(payload.workflow_run)); + const headSha = payload.workflow_run?.head_sha; + const linked = open.filter((candidate) => ( + numbers.has(candidate.number) + || candidate.head?.sha === headSha + || candidate.merge_commit_sha === headSha + )); + return linked.length > 0 ? linked : open; + } + return open; +} + +export async function main() { + const repository = process.env.GITHUB_REPOSITORY; + const { owner, repo } = repositoryParts(repository); + if (owner !== 'ontrack-features-t2-2026' || !ALLOWED_REPOSITORIES.has(repo)) { + throw new Error('This evaluator only runs for the three approved OnTrack repositories.'); + } + + const appId = process.env.ONTRACK_REVIEW_APP_ID; + const privateKey = process.env.ONTRACK_REVIEW_APP_PRIVATE_KEY; + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; + const token = await mintInstallationToken({ + apiUrl, + owner, + repo, + appId, + privateKey, + }); + const api = new GitHubApi(apiUrl, token); + const payload = await eventPayload(); + const eventName = process.env.GITHUB_EVENT_NAME || ''; + const pullRequests = await pullRequestsToEvaluate(api, owner, repo, eventName, payload); + + if (pullRequests.length === 0) { + console.log('No open pull requests require review-policy evaluation.'); + return; + } + + const leadTeam = process.env.ONTRACK_LEAD_TEAM || 'ontrack-leads'; + const contributorTeam = process.env.ONTRACK_CONTRIBUTOR_TEAM || 'ontrack-contributors'; + let leads; + let contributors; + try { + [leads, contributors] = await Promise.all([ + teamMembers(api, owner, leadTeam), + teamMembers(api, owner, contributorTeam), + ]); + } catch (error) { + const targetUrl = runUrl(repository, process.env.GITHUB_RUN_ID); + for (const current of pullRequests) { + try { + const sha = statusShaForPullRequest(current); + await setPolicyStatus( + api, + owner, + repo, + sha, + 'error', + 'OnTrack team membership could not be verified', + targetUrl, + ); + } catch (statusError) { + console.error(`::error::${safeError(statusError)}`); + } + } + throw error; + } + + const targetUrl = runUrl(repository, process.env.GITHUB_RUN_ID); + const failures = []; + for (const current of pullRequests) { + try { + await evaluatePullRequest({ + api, + owner, + repo, + pullRequest: current, + leads, + contributors, + targetUrl, + }); + } catch (error) { + failures.push(error); + try { + const sha = statusShaForPullRequest(current); + await setPolicyStatus( + api, + owner, + repo, + sha, + 'error', + 'OnTrack review policy evaluation failed', + targetUrl, + ); + } catch (statusError) { + console.error(`::error::${safeError(statusError)}`); + } + console.error(`::error::PR #${current.number}: ${safeError(error)}`); + } + } + + if (failures.length > 0) { + throw new Error(`${failures.length} pull-request evaluation(s) failed.`); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(`::error::${safeError(error)}`); + process.exitCode = 1; + }); +} diff --git a/.github/review-policy/evaluate.test.mjs b/.github/review-policy/evaluate.test.mjs new file mode 100644 index 0000000000..f85928c683 --- /dev/null +++ b/.github/review-policy/evaluate.test.mjs @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import { test } from 'node:test'; + +import { + approvedReviewers, + createAppJwt, + evaluatePolicy, + pullRequestNumbersFromWorkflowRun, + safeError, + setPolicyStatus, + statusShaForPullRequest, +} from './evaluate.mjs'; + +function review({ + id, + login, + state = 'APPROVED', + commit = 'head', + submitted = `2026-08-24T00:00:${String(id).padStart(2, '0')}Z`, + type = 'User', +}) { + return { + id, + state, + commit_id: commit, + submitted_at: submitted, + user: { login, type }, + }; +} + +test('one lead approval passes', () => { + const result = evaluatePolicy( + new Set(['lead']), + new Set(['lead']), + new Set(['lead', 'contributor']), + ); + assert.equal(result.passes, true); + assert.equal(result.leadApprovals, 1); +}); + +test('two distinct contributor approvals pass', () => { + const result = evaluatePolicy( + new Set(['contributor-a', 'contributor-b']), + new Set(['lead']), + new Set(['lead', 'contributor-a', 'contributor-b']), + ); + assert.equal(result.passes, true); + assert.equal(result.contributorApprovals, 2); +}); + +test('one contributor approval does not pass', () => { + const result = evaluatePolicy( + new Set(['contributor-a']), + new Set(['lead']), + new Set(['lead', 'contributor-a']), + ); + assert.equal(result.passes, false); +}); + +test('duplicate approvals from one reviewer count once', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'Contributor-A' }), + review({ id: 2, login: 'contributor-a' }), + ], 'head', 'author'); + assert.deepEqual([...approved], ['contributor-a']); +}); + +test('a later comment does not revoke an approval', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'contributor-a' }), + review({ id: 2, login: 'contributor-a', state: 'COMMENTED' }), + ], 'head', 'author'); + assert.deepEqual([...approved], ['contributor-a']); +}); + +test('a later changes-requested review revokes an approval', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'contributor-a' }), + review({ id: 2, login: 'contributor-a', state: 'CHANGES_REQUESTED' }), + ], 'head', 'author'); + assert.deepEqual([...approved], []); +}); + +test('stale, author, and bot approvals are ignored', () => { + const approved = approvedReviewers([ + review({ id: 1, login: 'stale', commit: 'old-head' }), + review({ id: 2, login: 'author' }), + review({ id: 3, login: 'review-bot[bot]', type: 'Bot' }), + ], 'head', 'author'); + assert.deepEqual([...approved], []); +}); + +test('workflow run PR numbers are deduplicated and validated', () => { + assert.deepEqual( + pullRequestNumbersFromWorkflowRun({ + display_title: 'OnTrack review policy signal for PR #42', + pull_requests: [{ number: 42 }, { number: 17 }, { number: 0 }], + }), + [42, 17], + ); +}); + +test('workflow run ignores unsafe or implausibly large PR numbers', () => { + assert.deepEqual( + pullRequestNumbersFromWorkflowRun({ + display_title: 'OnTrack review policy signal for PR #12345678901', + pull_requests: [{ number: Number.MAX_SAFE_INTEGER + 1 }, { number: -4 }], + }), + [], + ); +}); + +test('GitHub App JWT has a valid RSA signature and bounded lifetime', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + const now = 1_800_000_000; + const jwt = createAppJwt('4699573', privateKeyPem, now); + const [header, payload, signature] = jwt.split('.'); + assert.equal( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + publicKey, + Buffer.from(signature, 'base64url'), + ), + true, + ); + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + assert.equal(claims.iss, '4699573'); + assert.equal(claims.iat, now - 60); + assert.equal(claims.exp, now + 540); +}); + +test('classic and stateless GitHub App tokens are fully redacted from errors', () => { + const classicToken = `ghs_${'a'.repeat(36)}`; + const statelessToken = `ghs_${'A'.repeat(170)}.${'b'.repeat(170)}.${'C'.repeat(170)}`; + + assert.equal( + safeError(new Error(`classic ${classicToken} token`)), + 'classic [redacted token] token', + ); + assert.equal( + safeError(new Error(`stateless ${statelessToken} token`)), + 'stateless [redacted token] token', + ); +}); + +test('unchanged App status is not republished and spoofed sources are ignored', async () => { + const posts = []; + const api = { + async paginate() { + return [ + { + context: 'ontrack/review-policy', + state: 'success', + description: 'Passed', + creator: { login: 'not-the-policy-app[bot]' }, + }, + { + context: 'ontrack/review-policy', + state: 'pending', + description: 'Waiting', + creator: { login: 'ontrack-review-policy-t2-2026[bot]' }, + }, + ]; + }, + async request(path, options) { + posts.push({ path, options }); + return {}; + }, + }; + + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'a'.repeat(40), + 'pending', + 'Waiting', + 'https://example.test/run', + ), + false, + ); + assert.equal(posts.length, 0); + + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'a'.repeat(40), + 'success', + 'Passed', + 'https://example.test/run', + ), + true, + ); + assert.equal(posts.length, 1); +}); + +test('status-history failure does not suppress a fail-closed write', async () => { + const posts = []; + const warnings = []; + const originalWarn = console.warn; + console.warn = (message) => warnings.push(message); + const api = { + async paginate() { + throw new Error('history unavailable'); + }, + async request(path, options) { + posts.push({ path, options }); + return {}; + }, + }; + + try { + assert.equal( + await setPolicyStatus( + api, + 'owner', + 'repo', + 'b'.repeat(40), + 'error', + 'Evaluation failed', + 'https://example.test/run', + ), + true, + ); + assert.equal(posts.length, 1); + assert.equal(posts[0].options.body.state, 'error'); + assert.equal(warnings.length, 1); + } finally { + console.warn = originalWarn; + } +}); + +test('the status is reported on the head, never on the test merge commit', () => { + assert.equal( + statusShaForPullRequest({ head: { sha: 'head' }, merge_commit_sha: 'test-merge' }), + 'head', + ); +}); + +test('a conflicting pull request with no test merge commit still reports on its head', () => { + assert.equal( + statusShaForPullRequest({ head: { sha: 'head' }, merge_commit_sha: null }), + 'head', + ); +}); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8bf61dea80..238ba2fed2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,16 +13,16 @@ name: "CodeQL" on: push: - branches: ["development"] - pull_request: - # The branches below must be a subset of the branches above - branches: ["development"] + branches: ["11.0.x", "development"] + # CodeQL is a required check, so it must report for pull requests targeting + # any protected shared branch rather than only the branches listed above. + pull_request: {} schedule: - cron: "45 20 * * 3" jobs: analyze: - name: Analyze + name: CodeQL runs-on: ubuntu-latest permissions: actions: read @@ -38,11 +38,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@6d786de4d6f3531a740e445b53a42b622bbbace8 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -55,7 +55,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@6d786de4d6f3531a740e445b53a42b622bbbace8 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -68,4 +68,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@6d786de4d6f3531a740e445b53a42b622bbbace8 # v3 diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index c9461b31e7..102f6d189a 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -1,14 +1,10 @@ -name: create-doubtfire-deployment +name: Legacy image validation (non-publishing) on: - push: - tags: - - "v*" - # branches: - # - '*.x' - # - 'development' - # - 'main' - deployment: workflow_dispatch: + +permissions: + contents: read + jobs: docker-deploy-development-image: if: github.repository_owner == 'doubtfire-lms' @@ -16,30 +12,25 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Setup meta for development image id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: lmsdoubtfire/doubtfire-api tags: | type=semver,pattern={{major}}.{{minor}}.x-dev + type=sha,prefix=manual- - name: Build and push api server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: false tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: ${{ steps.docker_meta.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} docker-api-server: @@ -48,18 +39,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Setup meta for api server id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: lmsdoubtfire/apiServer tags: | @@ -68,15 +53,18 @@ jobs: type=semver,pattern=prod-{{version}} type=semver,pattern=prod-{{major}}.{{minor}} type=semver,pattern=prod-{{major}} + type=sha,prefix=manual- - name: Build and push api server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: file: deployApi.Dockerfile context: . - push: ${{ github.event_name != 'pull_request' }} + push: false tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: ${{ steps.docker_meta.outputs.labels }} + sbom: true + provenance: mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} docker-app-server: @@ -85,18 +73,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Login to DockerHub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Setup meta for app server id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: lmsdoubtfire/appServer tags: | @@ -105,14 +87,17 @@ jobs: type=semver,pattern=prod-{{version}} type=semver,pattern=prod-{{major}}.{{minor}} type=semver,pattern=prod-{{major}} + type=sha,prefix=manual- - name: Build and push app server id: docker_build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: file: deployAppSvr.Dockerfile context: . tags: ${{ steps.docker_meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - push: ${{ github.event_name != 'pull_request' }} + labels: ${{ steps.docker_meta.outputs.labels }} + push: false + sbom: true + provenance: mode=max - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/notify-teams-pr.yml b/.github/workflows/notify-teams-pr.yml new file mode 100644 index 0000000000..164a85b240 --- /dev/null +++ b/.github/workflows/notify-teams-pr.yml @@ -0,0 +1,260 @@ +# NOTE ON THE BASE BRANCH. GitHub loads a `pull_request_target` workflow from the +# repository DEFAULT branch (11.0.x), not from the pull request's base branch. That +# changed on 2025-12-08. So this file is merged into 11.0.x deliberately, against the +# usual CONTRIBUTING rule, and a copy on feature/notifications would be dead code. +# It fires for pull requests into every base branch, which is what we want. +name: Notify Teams when a pull request opens + +on: + pull_request_target: + types: + - opened + - reopened + - ready_for_review + # - review_requested # matches "reviewer alerts" most directly, but adds one + # # message per requested reviewer. Decide, do not default. + +# Removes all GITHUB_TOKEN scopes, which this workflow does not need. It does NOT +# restrict secrets.* - the webhook is protected by the guards below, not by this. +permissions: {} + +jobs: + notify-teams: + name: Post pull request notification to Teams + # The job guard makes the file inert if it ever travels to thoth-tech or + # doubtfire-lms. The step below decides who gets a notification: a pull request + # from a branch in this repository always does, and a fork pull request only if + # its author is in one of the two OnTrack teams. + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Check pull request author team membership + id: team-membership + shell: bash + env: + TEAM_MEMBERSHIP_TOKEN: ${{ secrets.TEAM_MEMBERSHIP_ACCESS }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + # A branch in this repository can only be pushed by someone who already + # has write access, so those pull requests need no lookup. That is also + # the only reason Dependabot notifies: dependabot[bot] is in neither + # team and 404s on both. The team check below is for fork pull requests, + # which is where the webhook actually needed protecting. + if [[ "${PR_HEAD_REPO}" == "${REPOSITORY}" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + if [[ -z "${TEAM_MEMBERSHIP_TOKEN:-}" ]]; then + echo "::error::The TEAM_MEMBERSHIP_ACCESS secret is not configured." + exit 1 + fi + + # The login goes into a URL path segment, so percent-encode it. curl also + # reads [ ] { } * in a URL as a glob and exits before sending anything, so + # --globoff below. Between them a login like dependabot[bot] is safe. + author_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["PR_AUTHOR"], safe=""))')" + + for team in ontrack-contributors ontrack-leads; do + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --globoff \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${TEAM_MEMBERSHIP_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --output "${RUNNER_TEMP}/team-membership.json" \ + --write-out '%{http_code}' \ + "https://api.github.com/orgs/ontrack-features-t2-2026/teams/${team}/memberships/${author_encoded}" \ + )" || http_status="000" + + case "${http_status}" in + 200) + membership_state="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])' \ + < "${RUNNER_TEMP}/team-membership.json")" + # "pending" is a teammate who has not accepted the org invitation + # yet. They are on the team and they review, so notify them too. + if [[ "${membership_state}" == "active" || "${membership_state}" == "pending" ]]; then + echo "eligible=true" >> "${GITHUB_OUTPUT}" + exit 0 + fi + ;; + 404) ;; + *) + echo "::error::GitHub returned HTTP ${http_status} while checking team membership." + exit 1 + ;; + esac + done + + # A skip is a decision, so say so on the run summary. Without this the job + # goes green and looks the same as one that posted a message. + echo "::warning::No Teams notification sent. ${PR_AUTHOR} opened this pull request from a fork and is in neither ontrack-contributors nor ontrack-leads." + echo "eligible=false" >> "${GITHUB_OUTPUT}" + + - name: Build and send Teams notification + if: steps.team-membership.outputs.eligible == 'true' + shell: bash + env: + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_PR_WEBHOOK_URL }} + PAYLOAD_PATH: ${{ runner.temp }}/teams-pr-notification.json + + REPOSITORY: ${{ github.repository }} + PR_ACTION: ${{ github.event.action }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_DRAFT: ${{ github.event.pull_request.draft }} + PR_HEAD: ${{ github.event.pull_request.head.label }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + + run: | + set -euo pipefail + + if [[ -z "${TEAMS_WEBHOOK_URL:-}" ]]; then + echo "::error::The TEAMS_PR_WEBHOOK_URL Actions secret is not configured." + exit 1 + fi + + python3 - <<'PY' + import json + import os + import re + + + def plain(value): + """Teams renders these fields as Markdown, and titles and branch names + come from anyone who can open a pull request. Removing [ ] < > and + backticks stops a title forging a Markdown link or code span. + Parentheses are left alone so `feat(scope): ...` still reads + properly. Note this does NOT stop Teams auto-linking a bare URL in + a title, it only stops the link TEXT being controlled.""" + value = " ".join(value.split()) + return re.sub(r"[\[\]<>`]", " ", value)[:200] + + + headline = { + "opened": "New pull request opened", + "reopened": "Pull request reopened", + "ready_for_review": "Pull request is ready for review", + }.get(os.environ["PR_ACTION"], "Pull request updated") + + status = ( + "Draft" + if os.environ.get("PR_DRAFT", "").lower() == "true" + else "Ready for review" + ) + + card = { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "wrap": True, + "text": headline, + }, + { + "type": "TextBlock", + "wrap": True, + "text": f"PR #{os.environ['PR_NUMBER']}: {plain(os.environ['PR_TITLE'])}", + }, + { + "type": "FactSet", + "facts": [ + { + "title": "Repository", + "value": plain(os.environ["REPOSITORY"]), + }, + { + "title": "Author", + "value": plain(os.environ["PR_AUTHOR"]), + }, + { + "title": "Status", + "value": status, + }, + { + "title": "Branches", + "value": ( + f"{plain(os.environ['PR_HEAD'])} -> " + f"{plain(os.environ['PR_BASE'])}" + ), + }, + ], + }, + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "Open pull request", + "url": os.environ["PR_URL"], + } + ], + } + + payload = { + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": card, + } + ], + } + + with open( + os.environ["PAYLOAD_PATH"], + "w", + encoding="utf-8", + ) as payload_file: + json.dump( + payload, + payload_file, + ensure_ascii=False, + ) + PY + + http_status="$( + curl \ + --proto '=https' \ + --tlsv1.2 \ + --silent \ + --show-error \ + --connect-timeout 10 \ + --max-time 30 \ + --header 'Content-Type: application/json' \ + --data-binary "@${PAYLOAD_PATH}" \ + --output "${RUNNER_TEMP}/teams-response.txt" \ + --write-out '%{http_code}' \ + --url "${TEAMS_WEBHOOK_URL}" + )" || http_status="000" + + # Uncomment to debug a failing webhook. The body comes from a third party + # and lands in a PUBLIC Actions log, and GitHub only masks an exact + # full-value match of a secret. Read it once, then comment it out again. + # cat "${RUNNER_TEMP}/teams-response.txt" + + if [[ "${http_status}" != 2* ]]; then + echo "::error::Teams webhook returned HTTP ${http_status}." + exit 1 + fi + + echo "Teams webhook returned HTTP ${http_status} for PR #${PR_NUMBER}." + echo "That means the request was accepted. It does not prove the message rendered in the channel." diff --git a/.github/workflows/ontrack-review-policy-signal.yml b/.github/workflows/ontrack-review-policy-signal.yml new file mode 100644 index 0000000000..d87949cbbc --- /dev/null +++ b/.github/workflows/ontrack-review-policy-signal.yml @@ -0,0 +1,32 @@ +name: OnTrack review policy signal +run-name: "OnTrack review policy signal for PR #${{ github.event.pull_request.number || 'all' }}" + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + - converted_to_draft + pull_request_review: + types: + - submitted + - edited + - dismissed + workflow_dispatch: + +# This workflow deliberately has no permissions and never checks out pull-request +# code or secrets. Its completion wakes the trusted evaluator on the default +# branch; manual runs safely reconcile every open pull request. +permissions: {} + +jobs: + signal: + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-24.04 + timeout-minutes: 1 + steps: + - name: Signal policy evaluation + run: ':' diff --git a/.github/workflows/ontrack-review-policy.yml b/.github/workflows/ontrack-review-policy.yml new file mode 100644 index 0000000000..61c194b103 --- /dev/null +++ b/.github/workflows/ontrack-review-policy.yml @@ -0,0 +1,56 @@ +name: OnTrack review policy evaluator + +on: + workflow_run: + workflows: + - OnTrack review policy signal + types: + - completed + push: + branches: + - 11.0.x + schedule: + # Reconcile team membership and base-branch changes even when no PR event fires. + - cron: '3-58/5 * * * *' + +# The repository token is used only to check out the trusted evaluator from the +# default branch. The GitHub App token is separately scoped to member/PR reads and +# commit-status writes. +permissions: + contents: read + +concurrency: + # Serialize signals for the same PR without allowing unrelated PR activity to + # replace a queued revocation. Scheduled and base-push reconciliations share a + # separate group. Every run re-reads authoritative GitHub state. + group: ontrack-review-policy-${{ github.repository }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.display_title || 'reconcile' }} + cancel-in-progress: false + +jobs: + evaluate: + if: github.repository_owner == 'ontrack-features-t2-2026' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: + name: ontrack-review-policy + deployment: false + + steps: + - name: Check out the trusted policy evaluator + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: refs/heads/11.0.x + fetch-depth: 1 + persist-credentials: false + sparse-checkout: .github/review-policy + + - name: Verify policy logic + run: node --test .github/review-policy/evaluate.test.mjs + + - name: Evaluate current pull-request approvals + env: + ONTRACK_REVIEW_APP_ID: ${{ vars.ONTRACK_REVIEW_APP_ID }} + ONTRACK_REVIEW_APP_PRIVATE_KEY: ${{ secrets.ONTRACK_REVIEW_APP_PRIVATE_KEY }} + ONTRACK_CONTRIBUTOR_TEAM: ontrack-contributors + ONTRACK_LEAD_TEAM: ontrack-leads + run: node .github/review-policy/evaluate.mjs diff --git a/.github/workflows/production-images.yml b/.github/workflows/production-images.yml new file mode 100644 index 0000000000..c3e75d2bb8 --- /dev/null +++ b/.github/workflows/production-images.yml @@ -0,0 +1,56 @@ +name: Production image builds + +on: + pull_request: + push: + branches: + - "*.x" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.name }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: API + dockerfile: deployApi.Dockerfile + cache_scope: production-api + - name: app worker + dockerfile: deployAppSvr.Dockerfile + cache_scope: production-app + - name: TeX Live helper + dockerfile: texlive.Dockerfile + cache_scope: production-texlive + - name: JPlag helper + dockerfile: jplag.Dockerfile + cache_scope: production-jplag + + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Build production image without publishing + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: false + sbom: true + provenance: mode=max + cache-from: type=gha,scope=${{ matrix.cache_scope }} + cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }} diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 482af4d787..e31df75fc6 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,13 +1,24 @@ name: Unit Tests on: push: + branches: + - "*.x" + - development + - main + - master + tags: + - "v*" paths-ignore: - "*.md" - "docs/**" - pull_request: - paths-ignore: - - "*.md" - - "docs/**" + # This check is required by the repository ruleset, so it must report for + # every pull request, including documentation-only changes. + pull_request: {} + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true env: RAILS_ENV: "test" @@ -22,15 +33,31 @@ env: DF_TEST_DB_DATABASE: "doubtfire-test" DF_TEST_DB_USERNAME: "dfire" DF_TEST_DB_PASSWORD: "pwd" - OVERSEER_ENABLED: "0" + OVERSEER_ENABLED: "true" DF_ENCRYPTION_PRIMARY_KEY: "AMLOMYA5GV8B4fTK3VKMhVGn8WdvUW8g" DF_ENCRYPTION_DETERMINISTIC_KEY: "anlmuJ6cB3bN3biXRbYvmPsC5ALPFqGG" DF_ENCRYPTION_KEY_DERIVATION_SALT: "hzPR8D4qpOnAg7VeAhkhWw6JmmzKJB10" DF_REDIS_SIDEKIQ_URL: "redis://redis:6379/0" + LATEX_CONTAINER_NAME: doubtfire-texlive + LATEX_BUILD_PATH: /texlive/shell/latex_build.sh + LTI_SHARED_API_SECRET: "abc123" + LTI_ENABLED: true jobs: - unit-tests: + unit_test_shards: + name: Unit Tests (shard ${{ matrix.shard }}/20) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] + env: + TEST_SHARD_COUNT: "20" + TEST_SHARD_NUMBER: ${{ matrix.shard }} + TEST_SHARD_MANIFEST: /doubtfire/tmp/test-shard-manifests/shard-${{ matrix.shard }}.txt + TEST_SHARD_RUN_COUNT: /doubtfire/tmp/test-shard-run-counts/shard-${{ matrix.shard }}.txt + TEST_SHARD_EXECUTED_RUNNABLES: /doubtfire/tmp/test-shard-executed-runnables/shard-${{ matrix.shard }}.txt + TEST_RUNNABLE_INVENTORY: /doubtfire/tmp/test-runnable-inventory.txt services: mariadb: image: mariadb @@ -46,24 +73,113 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Plan test shard + id: plan_shard + run: | + TEST_SHARD_MANIFEST=tmp/test-shard-manifests/shard-${{ matrix.shard }}.txt \ + TEST_SHARD_GITHUB_OUTPUT="$GITHUB_OUTPUT" \ + ruby script/test_shard.rb --dry-run + echo "seed_date=$(date -u +%F)" >> "$GITHUB_OUTPUT" + - name: Restore populated test database + id: seeded_database_cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + tmp/ci-seeded-database.sql.gz + tmp/ci-seeded-student-work.tar.gz + key: seeded-test-database-v5-${{ runner.os }}-${{ steps.plan_shard.outputs.seed_date }}-${{ hashFiles('.github/workflows/push.yml', '.dockerignore', 'Dockerfile', 'Gemfile', 'Gemfile.lock', 'Rakefile', 'app/**/*', 'config/**/*', 'db/**/*', 'docker-entrypoint.sh', 'lib/**/*', 'script/prepare_test_database.sh', 'test/factories/**/*', 'test_files/**/*') }} - name: Set up docker buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - name: Build TexLive image + if: ${{ steps.plan_shard.outputs.needs_texlive == 'true' }} + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: texlive.Dockerfile + push: false + load: true + tags: doubtfire-texlive-development:local + cache-from: type=gha,scope=texlive + cache-to: ${{ steps.plan_shard.outputs.writes_texlive_cache == 'true' && 'type=gha,mode=max,scope=texlive' || '' }} + - name: Build JPlag image + if: ${{ steps.plan_shard.outputs.needs_jplag == 'true' }} + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: jplag.Dockerfile + push: false + load: true + tags: doubtfire-jplag-development:local + cache-from: type=gha,scope=jplag + cache-to: ${{ steps.plan_shard.outputs.writes_jplag_cache == 'true' && 'type=gha,mode=max,scope=jplag' || '' }} - name: Build base doubtfire-api development image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . push: false load: true tags: doubtfire-api-development:local - cache-from: type=gha - cache-to: type=gha,mode=max - - name: Populate database - uses: addnab/docker-run-action@v3 + cache-from: type=gha,scope=doubtfire-api + cache-to: ${{ matrix.shard == 1 && 'type=gha,mode=max,scope=doubtfire-api' || '' }} + - name: Start TexLive service + id: start_texlive + if: ${{ steps.plan_shard.outputs.needs_texlive == 'true' }} + uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a + with: + image: doubtfire-texlive-development:local + options: > + --name ${{ env.LATEX_CONTAINER_NAME }} + -v ${{ github.workspace }}/student-work:/student-work + -v ${{ github.workspace }}/public/assets/images:/doubtfire/public/assets/images + -v ${{ github.workspace }}/test_files:/doubtfire/test_files + -v ${{ github.workspace }}/tmp/rails-latex:/workdir/texlive-latex + --detach + run: sleep infinity + - name: Test TexLive container + if: ${{ steps.plan_shard.outputs.needs_texlive == 'true' }} + uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a + with: + image: doubtfire-api-development:local + options: > + -t + -v ${{ github.workspace }}:/doubtfire + -v /var/run/docker.sock:/var/run/docker.sock + run: docker exec -t ${{ env.LATEX_CONTAINER_NAME }} lualatex -v + - name: Start JPlag service + id: start_jplag + if: ${{ steps.plan_shard.outputs.needs_jplag == 'true' }} + uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a + with: + image: doubtfire-jplag-development:local + options: > + --name jplag + -v ${{ github.workspace }}/student-work:/student-work + -v ${{ github.workspace }}/tmp/jplag:/tmp/jplag + -v ${{ github.workspace }}/test_files/submissions/jplag:/test_files + --detach + run: sleep infinity + - name: Test JPlag service + if: ${{ steps.plan_shard.outputs.needs_jplag == 'true' }} + uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a with: image: doubtfire-api-development:local options: > + -t -v ${{ github.workspace }}:/doubtfire + -v /var/run/docker.sock:/var/run/docker.sock + run: docker exec -e TERM=xterm -i jplag java -jar /jplag/jplag-jar-with-dependencies.jar /test_files -l java --similarity-threshold=0.30 -M RUN -r test.jplag + - name: Prepare populated database + uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a + env: + SEEDED_DATABASE_CACHE_HIT: ${{ steps.seeded_database_cache.outputs.cache-hit }} + with: + image: doubtfire-api-development:local + shell: bash + options: > + -v ${{ github.workspace }}:/doubtfire + -v ${{ github.workspace }}/student-work:/student-work + -v /var/run/docker.sock:/var/run/docker.sock -e RAILS_ENV -e DF_STUDENT_WORK_DIR -e DF_INSTITUTION_HOST @@ -81,13 +197,49 @@ jobs: -e DF_ENCRYPTION_DETERMINISTIC_KEY -e DF_ENCRYPTION_KEY_DERIVATION_SALT -e DF_REDIS_SIDEKIQ_URL - run: bundle exec rake db:populate + -e LATEX_CONTAINER_NAME + -e LATEX_BUILD_PATH + -e LTI_SHARED_API_SECRET + -e LTI_ENABLED + -e SEEDED_DATABASE_CACHE_HIT + run: script/prepare_test_database.sh + - name: Verify populated database schema + run: git diff --exit-code -- db/schema.rb + - name: Snapshot populated test database + if: ${{ steps.seeded_database_cache.outputs.cache-hit != 'true' && matrix.shard == 1 }} + run: | + set -euo pipefail + database_container_id="$(docker ps --filter ancestor=mariadb --format '{{.ID}}' | head -n 1)" + if [ -z "$database_container_id" ]; then + echo "Unable to find the MariaDB service container." + exit 1 + fi + mkdir -p tmp + docker exec "$database_container_id" mariadb-dump \ + --user="$DF_TEST_DB_USERNAME" \ + --password="$DF_TEST_DB_PASSWORD" \ + --single-transaction \ + --skip-comments \ + "$DF_TEST_DB_DATABASE" | + gzip -1 > tmp/ci-seeded-database.sql.gz + tar -C student-work -czf tmp/ci-seeded-student-work.tar.gz . + - name: Save populated test database + if: ${{ steps.seeded_database_cache.outputs.cache-hit != 'true' && matrix.shard == 1 }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + tmp/ci-seeded-database.sql.gz + tmp/ci-seeded-student-work.tar.gz + key: ${{ steps.seeded_database_cache.outputs.cache-primary-key }} - name: Run unit tests - uses: addnab/docker-run-action@v3 + uses: maus007/docker-run-action-fork@207a4e2a8ebf7e4b985656ba990b1e53715dce2a with: image: doubtfire-api-development:local options: > -v ${{ github.workspace }}:/doubtfire + -v ${{ github.workspace }}/student-work:/student-work + -v /var/run/docker.sock:/var/run/docker.sock + -v ${{ github.workspace }}/tmp/jplag:/tmp/jplag -e RAILS_ENV -e DF_STUDENT_WORK_DIR -e DF_INSTITUTION_HOST @@ -105,4 +257,128 @@ jobs: -e DF_ENCRYPTION_DETERMINISTIC_KEY -e DF_ENCRYPTION_KEY_DERIVATION_SALT -e DF_REDIS_SIDEKIQ_URL - run: TERM=xterm bundle exec rails test + -e LATEX_CONTAINER_NAME + -e LATEX_BUILD_PATH + -e LTI_SHARED_API_SECRET + -e LTI_ENABLED + -e TEST_SHARD_COUNT + -e TEST_SHARD_NUMBER + -e TEST_SHARD_MANIFEST + -e TEST_SHARD_RUN_COUNT + -e TEST_SHARD_EXECUTED_RUNNABLES + -e TEST_RUNNABLE_INVENTORY + run: TERM=xterm bundle exec ruby script/test_shard.rb + - name: Upload test shard evidence + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: unit-test-shard-manifest-${{ matrix.shard }} + path: | + tmp/test-shard-manifests/shard-${{ matrix.shard }}.txt + tmp/test-shard-run-counts/shard-${{ matrix.shard }}.txt + tmp/test-shard-executed-runnables/shard-${{ matrix.shard }}.txt + tmp/test-runnable-inventory.txt + if-no-files-found: error + - name: Stop TexLive service + if: ${{ always() && steps.start_texlive.outcome == 'success' }} + run: docker rm -f ${{ env.LATEX_CONTAINER_NAME }} + - name: Stop JPlag service + if: ${{ always() && steps.start_jplag.outcome == 'success' }} + run: docker rm -f jplag + + unit-tests: + name: unit-tests + if: ${{ always() }} + needs: unit_test_shards + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Download test shard manifests + id: download_manifests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: unit-test-shard-manifest-* + path: tmp/all-test-shard-manifests + merge-multiple: true + - name: Verify exact test shard union + id: verify_manifests + run: | + manifest_dir=tmp/all-test-shard-manifests/test-shard-manifests + run_count_dir=tmp/all-test-shard-manifests/test-shard-run-counts + executed_runnables_dir=tmp/all-test-shard-manifests/test-shard-executed-runnables + inventory_path=tmp/all-test-shard-manifests/test-runnable-inventory.txt + + manifest_count=$(find "$manifest_dir" -type f -name 'shard-*.txt' | wc -l) + if [ "$manifest_count" -ne 20 ]; then + echo "::error::Expected 20 shard manifests, found $manifest_count." + exit 1 + fi + + ruby script/test_shard.rb --list-runnables | LC_ALL=C sort > expected-tests.txt + cat "$manifest_dir"/shard-*.txt | LC_ALL=C sort > assigned-tests.txt + LC_ALL=C uniq -d assigned-tests.txt > duplicate-tests.txt + + if [ -s duplicate-tests.txt ]; then + echo "::error::One or more test runnables were assigned to multiple shards." + cat duplicate-tests.txt + exit 1 + fi + + LC_ALL=C uniq assigned-tests.txt > assigned-tests-unique.txt + diff -u expected-tests.txt assigned-tests-unique.txt + + run_count_file_count=$(find "$run_count_dir" -type f -name 'shard-*.txt' | wc -l) + if [ "$run_count_file_count" -ne 20 ]; then + echo "::error::Expected 20 shard run-count files, found $run_count_file_count." + exit 1 + fi + if [ ! -s "$inventory_path" ]; then + echo "::error::The canonical Minitest runnable inventory is missing." + exit 1 + fi + for run_count_path in "$run_count_dir"/shard-*.txt; do + if ! grep -Eq '^[0-9]+$' "$run_count_path"; then + echo "::error::Invalid shard run count in $run_count_path." + exit 1 + fi + done + + expected_run_count=$(wc -l < "$inventory_path") + actual_run_count=$(awk '{ total += $1 } END { print total + 0 }' "$run_count_dir"/shard-*.txt) + if [ "$actual_run_count" -ne "$expected_run_count" ]; then + echo "::error::Shards executed $actual_run_count tests, expected $expected_run_count." + exit 1 + fi + + executed_runnables_file_count=$(find "$executed_runnables_dir" -type f -name 'shard-*.txt' | wc -l) + if [ "$executed_runnables_file_count" -ne 20 ]; then + echo "::error::Expected 20 executed-runnable files, found $executed_runnables_file_count." + exit 1 + fi + cat "$executed_runnables_dir"/shard-*.txt | LC_ALL=C sort > actual-executed-runnables.txt + LC_ALL=C uniq -d actual-executed-runnables.txt > duplicate-executed-runnables.txt + if [ -s duplicate-executed-runnables.txt ]; then + echo "::error::One or more Minitest runnables executed more than once." + cat duplicate-executed-runnables.txt + exit 1 + fi + LC_ALL=C sort "$inventory_path" > expected-executed-runnables.txt + diff -u expected-executed-runnables.txt actual-executed-runnables.txt + echo "Verified exact execution parity for $actual_run_count Minitest runnables." + - name: Confirm all unit test shards passed + if: ${{ always() }} + env: + SHARD_RESULT: ${{ needs.unit_test_shards.result }} + MANIFEST_DOWNLOAD_RESULT: ${{ steps.download_manifests.outcome }} + MANIFEST_VERIFY_RESULT: ${{ steps.verify_manifests.outcome }} + run: | + if [ "$MANIFEST_DOWNLOAD_RESULT" != "success" ] || [ "$MANIFEST_VERIFY_RESULT" != "success" ]; then + echo "::error::Test shard manifest verification did not succeed "\ + "(download: $MANIFEST_DOWNLOAD_RESULT, verify: $MANIFEST_VERIFY_RESULT)." + exit 1 + fi + if [ "$SHARD_RESULT" != "success" ]; then + echo "::error::One or more unit test shards did not succeed (result: $SHARD_RESULT)." + exit 1 + fi diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index ead1ef0c49..cd489c76b0 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -5,10 +5,9 @@ on: paths-ignore: - "*.md" - "docs/**" - pull_request: - paths-ignore: - - "*.md" - - "docs/**" + # This check is required by the repository ruleset, so it must report for + # every pull request, including documentation-only changes. + pull_request: {} permissions: contents: read @@ -20,12 +19,12 @@ jobs: BUNDLE_WITHOUT: default doc job cable storage ujs test db steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Set up Ruby 3.1 - uses: ruby/setup-ruby@v1 + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: - ruby-version: 3.1 + ruby-version: 3.4 bundler-cache: true - name: Run RuboCop diff --git a/.github/workflows/weekly-integration-prs.yml b/.github/workflows/weekly-integration-prs.yml new file mode 100644 index 0000000000..badee4e239 --- /dev/null +++ b/.github/workflows/weekly-integration-prs.yml @@ -0,0 +1,98 @@ +name: Weekly integration PRs + +on: + schedule: + - cron: "17 9 * * 1" + timezone: Australia/Melbourne + workflow_dispatch: + +permissions: + contents: read + +jobs: + ensure-pr: + if: github.repository_owner == 'ontrack-features-t2-2026' + name: ${{ matrix.source }} -> ${{ matrix.target }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + strategy: + fail-fast: false + max-parallel: 3 + matrix: + include: + - source: feature/cross-unit + target: 11.0.x + - source: feature/notifications + target: 11.0.x + - source: feature/peer-progress-indicator + target: 11.0.x + + concurrency: + group: ${{ github.workflow }}-${{ matrix.target }}-${{ matrix.source }} + cancel-in-progress: false + + steps: + - name: Ensure pull request exists + shell: bash + env: + GH_TOKEN: ${{ secrets.INTEGRATION_BOT_TOKEN }} + SOURCE_BRANCH: ${{ matrix.source }} + TARGET_BRANCH: ${{ matrix.target }} + run: | + set -euo pipefail + + if [[ -z "${GH_TOKEN:-}" ]]; then + echo "::error::INTEGRATION_BOT_TOKEN is not configured." + exit 1 + fi + + existing="$( + gh api --method GET "repos/$GITHUB_REPOSITORY/pulls" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + -f state=open \ + -f head="${GITHUB_REPOSITORY_OWNER}:${SOURCE_BRANCH}" \ + -f base="$TARGET_BRANCH" \ + -F per_page=1 \ + --jq '.[0].html_url // empty' + )" + + if [[ -n "$existing" ]]; then + echo "Pull request already open: $existing" + exit 0 + fi + + target_ref="$(jq -rn --arg ref "$TARGET_BRANCH" '$ref | @uri')" + source_ref="$(jq -rn --arg ref "$SOURCE_BRANCH" '$ref | @uri')" + + for ref in "$target_ref" "$source_ref"; do + if ! gh api "repos/$GITHUB_REPOSITORY/branches/${ref}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --silent >/dev/null 2>&1; then + echo "Branch $ref no longer exists in $GITHUB_REPOSITORY; skipping." + exit 0 + fi + done + comparison="$( + gh api "repos/$GITHUB_REPOSITORY/compare/${target_ref}...${source_ref}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + --jq '[.ahead_by, (.files | length)] | @tsv' + )" + read -r ahead changed_files <<< "$comparison" + + if [[ "$ahead" == "0" || "$changed_files" == "0" ]]; then + echo "Nothing to merge from $SOURCE_BRANCH into $TARGET_BRANCH." + exit 0 + fi + + gh api --method POST "repos/$GITHUB_REPOSITORY/pulls" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + -f title="Integration: $SOURCE_BRANCH into $TARGET_BRANCH" \ + -f head="$SOURCE_BRANCH" \ + -f base="$TARGET_BRANCH" \ + -f body="Created by the scheduled integration workflow. The source branch is intentionally retained after merge." \ + --jq '.html_url' diff --git a/.gitignore b/.gitignore index 3ef1f7a03a..624d3e54b1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ # Ignore locally installed gems /vendor/bundle/ -# Ignore the bin folder made with the app:update:bin task -/bin +# Ignore generated binstubs except the Rails launcher required by Rails/Puma restart. +/bin/* +!/bin/rails # Ignore the default SQLite database. /db/*.sqlite3 @@ -31,9 +32,17 @@ student-work/ .DS_Store .env .env* +!.env.example +/config/credentials/*.yml.enc +/config/credentials/*.key +/config/master.key # IDE configs .idea/ .byebug_history coverage/ _history + +# Institution specific config +config/*_setting.rb +!config/no_institution_setting.rb diff --git a/.rubocop.yml b/.rubocop.yml index a9294627dd..465365ef41 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,6 +9,12 @@ AllCops: TargetRubyVersion: 3.1 NewCops: disable +plugins: + - rubocop-rails + # - rubocop-performance + # - rubocop-minitest + # - rubocop-factory_bot + Style/HashSyntax: EnforcedShorthandSyntax: never diff --git a/CHANGELOG.md b/CHANGELOG.md index 4744417cbc..1459963b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,1608 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [11.0.0-31](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-30...v11.0.0-31) (2026-07-08) + + +### Bug Fixes + +* run lualatex a third time to ensure correct page references ([fc40bde](https://github.com/b0ink/doubtfire-deploy/commit/fc40bdea6414870c6457f5266838cb44d6296fcd)) + +## [11.0.0-30](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-29...v11.0.0-30) (2026-07-04) + + +### Bug Fixes + +* ensure admins can manage communications ([25bcafc](https://github.com/b0ink/doubtfire-deploy/commit/25bcafcdc00d76523080f6a198170b452f646923)) + +## [11.0.0-29](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-28...v11.0.0-29) (2026-07-02) + + +### Bug Fixes + +* allow students to get submission history timestamps ([a4bb79a](https://github.com/b0ink/doubtfire-deploy/commit/a4bb79ac27425fa64ed711e932d595cefa2a5598)) + +## [11.0.0-28](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-27...v11.0.0-28) (2026-07-01) + + +### Bug Fixes + +* only allow convenors to pull overflow stats ([d5ca748](https://github.com/b0ink/doubtfire-deploy/commit/d5ca74843fa16040dec81fa9bd66d315736059a0)) + +## [11.0.0-27](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-26...v11.0.0-27) (2026-07-01) + + +### Bug Fixes + +* prevent convenors with observer only perms to be made main convenor ([19a459c](https://github.com/b0ink/doubtfire-deploy/commit/19a459c544a14d3c155d665ba90ead11c3c50288)) + +## [11.0.0-26](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-25...v11.0.0-26) (2026-06-29) + + +### Bug Fixes + +* avoid skipping task names containing 'name' during import ([4c2e194](https://github.com/b0ink/doubtfire-deploy/commit/4c2e19472f73deeaee271ed5b7d5dc66b01f7836)) +* improve error when adding prerequisite to non existing task def ([f217c61](https://github.com/b0ink/doubtfire-deploy/commit/f217c61cf666dae7c781a393fbd9b544dcb32f24)) + +## [11.0.0-25](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-24...v11.0.0-25) (2026-06-29) + + +### Bug Fixes + +* ensure pii is not submitted to sentry ([d98d318](https://github.com/b0ink/doubtfire-deploy/commit/d98d318adc1c5153af9a844d95fdbbe2fde3903b)) + +## [11.0.0-24](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-23...v11.0.0-24) (2026-06-29) + + +### Bug Fixes + +* ensure non text comments cant be deleted ([964e957](https://github.com/b0ink/doubtfire-deploy/commit/964e957c4a00f1dadb2d992583d3f2bb717aa58b)) + +## [11.0.0-23](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-22...v11.0.0-23) (2026-06-26) + + +### Bug Fixes + +* ensure jplag scan continues on failed report ([3215c08](https://github.com/b0ink/doubtfire-deploy/commit/3215c0811aa8c9eb6425cb9d4e080f2fc042e6fb)) + +## [11.0.0-22](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-21...v11.0.0-22) (2026-06-26) + +## [11.0.0-21](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-20...v11.0.0-21) (2026-06-26) + + +### Bug Fixes + +* support mysql 8 reserved keyword in task completion stats query ([87fe813](https://github.com/b0ink/doubtfire-deploy/commit/87fe8136ea5e9f7011cee3b304bbe5c8a68c7d6d)) + +## [11.0.0-20](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-19...v11.0.0-20) (2026-06-25) + + +### Features + +* capture daily snapshots of aggregated task completion data ([#607](https://github.com/b0ink/doubtfire-deploy/issues/607)) ([86b2040](https://github.com/b0ink/doubtfire-deploy/commit/86b2040bf48f494b1e06919fcce50243fe5a0240)) + + +### Bug Fixes + +* ensure overseer image is pulled before running test ([0e0ce34](https://github.com/b0ink/doubtfire-deploy/commit/0e0ce34160dce4e23fc9deea2f035119c5d378dd)) +* relocate submission history on unit code change ([d9085b5](https://github.com/b0ink/doubtfire-deploy/commit/d9085b509b7995ae9d41e8e3973dd57b011a166d)) + +## [11.0.0-19](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-18...v11.0.0-19) (2026-06-25) + +## [11.0.0-18](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-17...v11.0.0-18) (2026-06-25) + + +### Bug Fixes + +* rename tunnel route to avoid ad blockers ([baf4f69](https://github.com/b0ink/doubtfire-deploy/commit/baf4f692be7558bca51fab8daa89b16f3daf2088)) + +## [11.0.0-17](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-16...v11.0.0-17) (2026-06-25) + +## [11.0.0-16](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-15...v11.0.0-16) (2026-06-25) + + +### Features + +* add sentry tunnel support ([3eecd2f](https://github.com/b0ink/doubtfire-deploy/commit/3eecd2f8e7733e79307830e173e6687fc4d2632f)) + +## [11.0.0-15](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-14...v11.0.0-15) (2026-06-24) + + +### Bug Fixes + +* check grade value is valid json ([3dd6106](https://github.com/b0ink/doubtfire-deploy/commit/3dd6106bb65eb739c81f8eab5332684a915d0c9f)) + +## [11.0.0-14](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-13...v11.0.0-14) (2026-06-24) + + +### Bug Fixes + +* avoid serialisation ([1a47564](https://github.com/b0ink/doubtfire-deploy/commit/1a475640e211d3dec7e3b5034b3f0efddaa6e29d)) +* check grade value is valid json ([ea7c5db](https://github.com/b0ink/doubtfire-deploy/commit/ea7c5db44e3f1751cf6d93028a07f0f82cca3bec)) + +## [11.0.0-13](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-12...v11.0.0-13) (2026-06-24) + + +### Bug Fixes + +* allow nullable grade values ([8b586f9](https://github.com/b0ink/doubtfire-deploy/commit/8b586f9b76c9cc82f71a33bcace5532e4ad9c4ec)) +* avoid json default ([3a0c879](https://github.com/b0ink/doubtfire-deploy/commit/3a0c879398a62469524f6e49410cccf5da0a2a33)) + +## [11.0.0-12](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-11...v11.0.0-12) (2026-06-24) + + +### Features + +* customisable grades ([#638](https://github.com/b0ink/doubtfire-deploy/issues/638)) ([57cb9e2](https://github.com/b0ink/doubtfire-deploy/commit/57cb9e250b7fbfe9eb477021c9077d86d4b696ab)) + +## [11.0.0-11](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-10...v11.0.0-11) (2026-06-18) + + +### Features + +* engagement passport ([#631](https://github.com/b0ink/doubtfire-deploy/issues/631)) ([d32145e](https://github.com/b0ink/doubtfire-deploy/commit/d32145ef62c7ad014524e5e3610844b7bda20b76)) + + +### Bug Fixes + +* serve public web cal as raw text ([6836a20](https://github.com/b0ink/doubtfire-deploy/commit/6836a20e582473eff4edb41e2fd1f6c4a5a9751d)) + +## [11.0.0-10](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-9...v11.0.0-10) (2026-06-17) + + +### Features + +* add column for days awaiting feedback including breaks ([f7b877c](https://github.com/b0ink/doubtfire-deploy/commit/f7b877c9759089ce1bb4c4e99490a039c0761181)) +* submission history ([#636](https://github.com/b0ink/doubtfire-deploy/issues/636)) ([4f95d61](https://github.com/b0ink/doubtfire-deploy/commit/4f95d61c8d897183ce83ca6d96b06570e51e4def)) + +## [11.0.0-9](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-8...v11.0.0-9) (2026-06-11) + + +### Features + +* expose route to fetch tasks waiting for pdf gen ([5d3407e](https://github.com/b0ink/doubtfire-deploy/commit/5d3407e2c897fa05f6e5c6ee41d8e0379fb6a367)) +* overflow task claim logs ([#614](https://github.com/b0ink/doubtfire-deploy/issues/614)) ([065180f](https://github.com/b0ink/doubtfire-deploy/commit/065180f248db7e80dd86f0491822e2051a9fa220)) + + +### Bug Fixes + +* ensure more reliable test ([93599d2](https://github.com/b0ink/doubtfire-deploy/commit/93599d27d736ebfaf7b997dfd82d764a45195881)) + +## [11.0.0-8](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-7...v11.0.0-8) (2026-06-09) + + +### Features + +* communications system ([#617](https://github.com/b0ink/doubtfire-deploy/issues/617)) ([9724909](https://github.com/b0ink/doubtfire-deploy/commit/97249090eac3f7e214991f7c665f92768948b6db)) + +## [11.0.0-7](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-6...v11.0.0-7) (2026-06-08) + + +### Bug Fixes + +* ensure start and target dates set correctly if unit start date is a monday ([0717d90](https://github.com/b0ink/doubtfire-deploy/commit/0717d9051181811c7d58dd453abf076b87cb0444)) +* handle incomplete notebook metadata and markdown lists in pdfs ([a01e4d7](https://github.com/b0ink/doubtfire-deploy/commit/a01e4d7e7ca7ca255b2080f1f3e44da48db4c270)) +* normalize notebook source lines ([b950e55](https://github.com/b0ink/doubtfire-deploy/commit/b950e55ad801fbdd6f3dafb3732aefc0b6cc6987)) + +## [11.0.0-6](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-5...v11.0.0-6) (2026-06-04) + +## [11.0.0-5](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-4...v11.0.0-5) (2026-06-04) + + +### Features + +* notify students when overseer tests fail ([#627](https://github.com/b0ink/doubtfire-deploy/issues/627)) ([4280df3](https://github.com/b0ink/doubtfire-deploy/commit/4280df3a6446920b4ae3c9108d28421ab9b3e6c5)) + + +### Bug Fixes + +* correctly get task ([1949b42](https://github.com/b0ink/doubtfire-deploy/commit/1949b42258355b843d7d0e5563be040d4d859164)) + +## [11.0.0-4](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-3...v11.0.0-4) (2026-06-04) + + +### Features + +* suspend any stale overseer assessments ([678f386](https://github.com/b0ink/doubtfire-deploy/commit/678f3866632d174713ab46bc7876c43dd2df56cd)) + + +### Bug Fixes + +* ensure we check for queued jobs ([3790c9f](https://github.com/b0ink/doubtfire-deploy/commit/3790c9f3dc4899d9785f6996a77a5b550f1c5d87)) + +## [11.0.0-3](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-2...v11.0.0-3) (2026-06-04) + + +### Bug Fixes + +* allow error log emails with no backtrace ([f5b383f](https://github.com/b0ink/doubtfire-deploy/commit/f5b383fdeda016dd7d391325f04700356cc58da5)) + +## [11.0.0-2](https://github.com/b0ink/doubtfire-deploy/compare/v11.0.0-1...v11.0.0-2) (2026-06-04) + + +### Features + +* clear submission in process with no active sidekiq job ([#626](https://github.com/b0ink/doubtfire-deploy/issues/626)) ([bb453ce](https://github.com/b0ink/doubtfire-deploy/commit/bb453ce00f540bcf1d64d70e18c6d97b6f8bdc5a)) + +## [11.0.0-1](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-110...v11.0.0-1) (2026-06-03) + + +### Features + +* support zip file submissions ([#624](https://github.com/b0ink/doubtfire-deploy/issues/624)) ([d5d8742](https://github.com/b0ink/doubtfire-deploy/commit/d5d874214fe31f494b5a30f67f7286e711ba45b2)) + +## [10.0.0](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-110...v10.0.0) (2026-06-03) + + +### Features + +* support zip file submissions ([#624](https://github.com/b0ink/doubtfire-deploy/issues/624)) ([d5d8742](https://github.com/b0ink/doubtfire-deploy/commit/d5d874214fe31f494b5a30f67f7286e711ba45b2)) + +## [10.0.0-110](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-109...v10.0.0-110) (2026-06-03) + +## [10.0.0-109](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-108...v10.0.0-109) (2026-05-13) + + +### Bug Fixes + +* ensure recent feedback has been left for fix ([a824b1b](https://github.com/b0ink/doubtfire-api/commit/a824b1b5fc4f47a12be33d410962af31fe810c89)) + +## [10.0.0-108](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-107...v10.0.0-108) (2026-05-13) + + +### Features + +* enable sentry error logging ([#620](https://github.com/b0ink/doubtfire-api/issues/620)) ([11cc264](https://github.com/b0ink/doubtfire-api/commit/11cc264b83d2c5e161b1926e12c496f2fd9add13)) + +## [10.0.0-107](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-106...v10.0.0-107) (2026-05-05) + +## [10.0.0-106](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-105...v10.0.0-106) (2026-04-28) + + +### Features + +* enable comment editing ([#613](https://github.com/b0ink/doubtfire-api/issues/613)) ([4096f87](https://github.com/b0ink/doubtfire-api/commit/4096f8716b63f4ca5239f1f0ff7a4591096ee10d)) + +## [10.0.0-105](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-104...v10.0.0-105) (2026-04-27) + +## [10.0.0-104](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-103...v10.0.0-104) (2026-04-23) + + +### Features + +* import/export overseer steps via task definition csv ([#604](https://github.com/b0ink/doubtfire-api/issues/604)) ([5dbd575](https://github.com/b0ink/doubtfire-api/commit/5dbd57588c06cb69eae35c5eab12d757bbe0bec5)) + + +### Bug Fixes + +* prevent duplicate submission requests ([#611](https://github.com/b0ink/doubtfire-api/issues/611)) ([1d056ca](https://github.com/b0ink/doubtfire-api/commit/1d056ca36aca7d9de8977b7a96783ed7a8d05367)) + +## [10.0.0-103](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-102...v10.0.0-103) (2026-04-18) + +## [10.0.0-102](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-101...v10.0.0-102) (2026-04-18) + +## [10.0.0-101](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-100...v10.0.0-101) (2026-04-18) + + +### Features + +* batch upload feedback csv ([#609](https://github.com/b0ink/doubtfire-api/issues/609)) ([5baa171](https://github.com/b0ink/doubtfire-api/commit/5baa171baff4343161e13e79613b81eb985acc29)) +* query pinned tasks in task explorer ([7a10685](https://github.com/b0ink/doubtfire-api/commit/7a10685138a99eac0719aa2a37f8f883167edc45)) + +## [10.0.0-100](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-99...v10.0.0-100) (2026-04-15) + + +### Features + +* add enforce feedback unit setting ([4d1c6ee](https://github.com/b0ink/doubtfire-api/commit/4d1c6ee8d227b1d4b0ac57bbeea30f20f47e8b71)) + +## [10.0.0-99](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-98...v10.0.0-99) (2026-04-13) + + +### Features + +* ensure tutorials are reassigned before deleting a unit role ([0bba827](https://github.com/b0ink/doubtfire-api/commit/0bba8279da40a8674f802b961302b84560855e76)) + +## [10.0.0-98](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-97...v10.0.0-98) (2026-03-27) + + +### Features + +* move tii and active record secrets to rails credentials ([#601](https://github.com/b0ink/doubtfire-api/issues/601)) ([4357f5c](https://github.com/b0ink/doubtfire-api/commit/4357f5c17db2d937aa0ab573567d45f41cadcf5b)) + +## [10.0.0-97](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-96...v10.0.0-97) (2026-03-27) + + +### Features + +* prefer encrypted rails credentials over env vars ([#600](https://github.com/b0ink/doubtfire-api/issues/600)) ([b1946bd](https://github.com/b0ink/doubtfire-api/commit/b1946bd8105e70e4baff1e0547b1b728d8a731c8)) + +## [10.0.0-96](https://github.com/b0ink/doubtfire-api/compare/v10.0.0-95...v10.0.0-96) (2026-03-26) + + +### Features + +* allow admin users to view any unit analytics ([#597](https://github.com/b0ink/doubtfire-api/issues/597)) ([eb2195a](https://github.com/b0ink/doubtfire-api/commit/eb2195a18d148264eb0570cdfc3ecfa6b577e1f9)) +* pause days awaiting feedback during breaks ([#599](https://github.com/b0ink/doubtfire-api/issues/599)) ([0aa2208](https://github.com/b0ink/doubtfire-api/commit/0aa220863329083b1fe7d53fc2a4f9ee9a3f9386)) + +## [10.0.0-95](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-94...v10.0.0-95) (2026-03-24) + + +### Bug Fixes + +* normalise test to ensure its consistent ([#594](https://github.com/b0ink/doubtfire-deploy/issues/594)) ([a607486](https://github.com/b0ink/doubtfire-deploy/commit/a607486e9953e28b2bd6b04e9a135a4190b87c6e)) + +## [10.0.0-94](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-93...v10.0.0-94) (2026-03-23) + + +### Features + +* require discussion before marking complete ([#587](https://github.com/b0ink/doubtfire-deploy/issues/587)) ([034a5bd](https://github.com/b0ink/doubtfire-deploy/commit/034a5bda2bb4b77d789168c21c923d7ffb07b284)) +* track when portfolio was submitted for compile ([5979655](https://github.com/b0ink/doubtfire-deploy/commit/5979655ed013cf3430730c0290e5fdb5c3920de2)) +* track when portfolio was submitted for compile ([#591](https://github.com/b0ink/doubtfire-deploy/issues/591)) ([1b9f1da](https://github.com/b0ink/doubtfire-deploy/commit/1b9f1da3803d799ea60cfc901c7434cdfb942278)) + +## [10.0.0-93](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-92...v10.0.0-93) (2026-03-13) + + +### Bug Fixes + +* normalise file keys on task definition import ([b652aae](https://github.com/b0ink/doubtfire-deploy/commit/b652aae756c287db2c30d9afe2475302223ee1db)) + +## [10.0.0-92](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-91...v10.0.0-92) (2026-03-11) + + +### Bug Fixes + +* check for active overflow task claim ([873c278](https://github.com/b0ink/doubtfire-deploy/commit/873c2787f862d518f74e230fb38a1c8c7b6907ab)) + +## [10.0.0-91](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-90...v10.0.0-91) (2026-03-09) + +## [10.0.0-90](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-89...v10.0.0-90) (2026-03-09) + +## [10.0.0-89](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-88...v10.0.0-89) (2026-03-09) + + +### Bug Fixes + +* get reply target ([e4fbcdf](https://github.com/b0ink/doubtfire-deploy/commit/e4fbcdff35a9f552ae998e0ab0126aab6ef69b2d)) + +## [10.0.0-88](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-87...v10.0.0-88) (2026-03-09) + +## [10.0.0-87](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-86...v10.0.0-87) (2026-03-09) + + +### Features + +* notify tutors via email for tutor notes ([#584](https://github.com/b0ink/doubtfire-deploy/issues/584)) ([dccf2df](https://github.com/b0ink/doubtfire-deploy/commit/dccf2dfebb06efa2b24ac3939cf9c7f027c69112)) + +## [10.0.0-86](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-85...v10.0.0-86) (2026-03-06) + +## [10.0.0-85](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-84...v10.0.0-85) (2026-03-06) + +## [10.0.0-84](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-83...v10.0.0-84) (2026-03-04) + + +### Bug Fixes + +* ignore notes sent from self ([7287f0f](https://github.com/b0ink/doubtfire-deploy/commit/7287f0f442b80c1d17f51644d183cea38a0af2c1)) + +## [10.0.0-83](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-82...v10.0.0-83) (2026-03-04) + + +### Bug Fixes + +* ensure stdout doesnt exceed text limit ([8f5fbb6](https://github.com/b0ink/doubtfire-deploy/commit/8f5fbb624448088aebf0fc506dfa1ffc6770df79)) + +## [10.0.0-82](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-81...v10.0.0-82) (2026-03-03) + +## [10.0.0-81](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-80...v10.0.0-81) (2026-03-03) + +## [10.0.0-80](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-79...v10.0.0-80) (2026-03-02) + +## [10.0.0-79](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-78...v10.0.0-79) (2026-03-01) + +## [10.0.0-78](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-77...v10.0.0-78) (2026-02-28) + +## [10.0.0-77](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-76...v10.0.0-77) (2026-02-26) + + +### Features + +* return available server disk space ([#579](https://github.com/b0ink/doubtfire-deploy/issues/579)) ([e2c9fac](https://github.com/b0ink/doubtfire-deploy/commit/e2c9fac1b105ae74c77938ae2777356c778770f4)) + +## [10.0.0-76](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-75...v10.0.0-76) (2026-02-25) + + +### Features + +* overflow marking ([#572](https://github.com/b0ink/doubtfire-deploy/issues/572)) ([a4990d2](https://github.com/b0ink/doubtfire-deploy/commit/a4990d21da7518cd4c01b943b7ae8d7e45e21810)) + +## [10.0.0-75](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-74...v10.0.0-75) (2026-02-24) + +## [10.0.0-74](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-73...v10.0.0-74) (2026-02-24) + +## [10.0.0-73](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-72...v10.0.0-73) (2026-02-24) + + +### Features + +* per grade start dates ([#577](https://github.com/b0ink/doubtfire-deploy/issues/577)) ([618165a](https://github.com/b0ink/doubtfire-deploy/commit/618165a439917e5cabe02585cd30910104353610)) + +## [10.0.0-72](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-71...v10.0.0-72) (2026-02-23) + + +### Features + +* allow custom target dates per target grade ([#575](https://github.com/b0ink/doubtfire-deploy/issues/575)) ([bf4ec01](https://github.com/b0ink/doubtfire-deploy/commit/bf4ec01cb082a5c18e8cebe9addb2e4a38632b71)) + + +### Bug Fixes + +* skip overseer if no active steps ([d010ef6](https://github.com/b0ink/doubtfire-deploy/commit/d010ef6501540090d3dca061eebcb14f3b7b5b35)) + +## [10.0.0-71](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-70...v10.0.0-71) (2026-02-21) + +## [10.0.0-70](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-69...v10.0.0-70) (2026-02-19) + +## [10.0.0-69](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-68...v10.0.0-69) (2026-02-18) + + +### Features + +* tutor notes + mentorship + moderation + escalation ([#564](https://github.com/b0ink/doubtfire-deploy/issues/564)) ([587f6e6](https://github.com/b0ink/doubtfire-deploy/commit/587f6e6ac3ef3f840eed06a4d2ec1df8e35b6ee9)) + +## [10.0.0-68](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-67...v10.0.0-68) (2026-02-17) + +## [10.0.0-67](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-66...v10.0.0-67) (2026-02-16) + + +### Bug Fixes + +* dont overwrite overseer step status when param is absent ([#571](https://github.com/b0ink/doubtfire-deploy/issues/571)) ([400ad4a](https://github.com/b0ink/doubtfire-deploy/commit/400ad4a83bc03b69f96f4d6dc5a92aada81612dc)) + +## [10.0.0-66](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-65...v10.0.0-66) (2026-02-11) + + +### Features + +* download staff notes csv ([#558](https://github.com/b0ink/doubtfire-deploy/issues/558)) ([9975694](https://github.com/b0ink/doubtfire-deploy/commit/99756942442d669c5d9f2e62c6c88fcbca9a057c)) +* recursively assess dependent tasks if prerequisite changed to fix ([#567](https://github.com/b0ink/doubtfire-deploy/issues/567)) ([d7bc5b4](https://github.com/b0ink/doubtfire-deploy/commit/d7bc5b40ff9b2ab074a72be17d0b3f5e8478da6d)) + + +### Bug Fixes + +* check correct user permissions when toggling observer only ([#560](https://github.com/b0ink/doubtfire-deploy/issues/560)) ([339b649](https://github.com/b0ink/doubtfire-deploy/commit/339b6493ec73432ef74e96c9983fb17d0659de92)) +* ensure bulk enrolment validates member ([bd2d1c0](https://github.com/b0ink/doubtfire-deploy/commit/bd2d1c095610fdf1c4b4d663ecb5a89e140f95e6)) +* null check staff ([eef82f5](https://github.com/b0ink/doubtfire-deploy/commit/eef82f5643abb38b764f80625f40846478b0dff6)) +* throw error on failed user validation ([aa59f39](https://github.com/b0ink/doubtfire-deploy/commit/aa59f392ab0b75cbebcd9116d5938c9f17702563)) + +## [10.0.0-65](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-64...v10.0.0-65) (2026-01-05) + + +### Features + +* attention required task status ([#557](https://github.com/b0ink/doubtfire-deploy/issues/557)) ([88e0c7a](https://github.com/b0ink/doubtfire-deploy/commit/88e0c7a9e65a71aed0b9a8453d75af0aa52a09f4)) +* custom project task deadlines ([#550](https://github.com/b0ink/doubtfire-deploy/issues/550)) ([2a50638](https://github.com/b0ink/doubtfire-deploy/commit/2a5063848d9cde0b0277f6e2463613945d720b99)) +* overseer pipeline ([#559](https://github.com/b0ink/doubtfire-deploy/issues/559)) ([d2e49bf](https://github.com/b0ink/doubtfire-deploy/commit/d2e49bfd57e69281923458b8853226a85c9da390)) + +## [10.0.0-64](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-63...v10.0.0-64) (2025-12-03) + +## [10.0.0-63](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-62...v10.0.0-63) (2025-12-03) + + +### Features + +* add basic health endpoint ([#553](https://github.com/b0ink/doubtfire-deploy/issues/553)) ([e106eeb](https://github.com/b0ink/doubtfire-deploy/commit/e106eebf331bd7f1b12ff1a65fa590d4fba8d9e9)) + +## [10.0.0-62](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-61...v10.0.0-62) (2025-12-03) + + +### Bug Fixes + +* set correct mount ([84b08d8](https://github.com/b0ink/doubtfire-deploy/commit/84b08d8c3e6a18e38585792c873fa174e9bd338a)) + +## [10.0.0-61](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-60...v10.0.0-61) (2025-12-03) + +## [10.0.0-60](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-59...v10.0.0-60) (2025-12-03) + +## [10.0.0-59](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-58...v10.0.0-59) (2025-12-03) + + +### Features + +* discussion prompts ([#546](https://github.com/b0ink/doubtfire-deploy/issues/546)) ([ab38360](https://github.com/b0ink/doubtfire-deploy/commit/ab38360a5c847a1db0d03547fc432b000a61ebb9)) + +## [10.0.0-58](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-57...v10.0.0-58) (2025-11-25) + + +### Bug Fixes + +* allow pdf gen and overseer job to be queued at the same time ([d1d839b](https://github.com/b0ink/doubtfire-deploy/commit/d1d839ba10ee369faf354d758dd1c746bea9e4a9)) + +## [10.0.0-57](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-56...v10.0.0-57) (2025-11-25) + + +### Features + +* env option to skip jplag submission clusters ([#551](https://github.com/b0ink/doubtfire-deploy/issues/551)) ([5e3b4cc](https://github.com/b0ink/doubtfire-deploy/commit/5e3b4cc437fd296519e904a37e53305b579140dd)) + + +### Bug Fixes + +* ensure long comments are appended on a new page [#303](https://github.com/b0ink/doubtfire-deploy/issues/303) ([148cbba](https://github.com/b0ink/doubtfire-deploy/commit/148cbba601020eb41d975bb39f17a72378fc36ad)) + +## [10.0.0-56](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-55...v10.0.0-56) (2025-11-10) + + +### Bug Fixes + +* dont revert feedback exceeded tasks to assess in portfolio ([#536](https://github.com/b0ink/doubtfire-deploy/issues/536)) ([1762bee](https://github.com/b0ink/doubtfire-deploy/commit/1762bee328a6e434fbab11fc6f5dbeab3224d798)) +* revert overdue tasks for assess in portfolio status ([4cf18b4](https://github.com/b0ink/doubtfire-deploy/commit/4cf18b4f55e5abce9e788650f62eb71a6a43c27c)) +* set tutorial stream ([4fd98a5](https://github.com/b0ink/doubtfire-deploy/commit/4fd98a5b4297f5f11ea95fd2474bddc47d69b1f1)) + +## [10.0.0-55](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-54...v10.0.0-55) (2025-11-06) + + +### Features + +* option to restrict assessments to tutors in the same tutorial stream ([#537](https://github.com/b0ink/doubtfire-deploy/issues/537)) ([e291279](https://github.com/b0ink/doubtfire-deploy/commit/e291279aa82201d40623bd7162850b132a30b1df)) +* revert submissions to ready for feedback if extension applied ([#544](https://github.com/b0ink/doubtfire-deploy/issues/544)) ([1ae5e36](https://github.com/b0ink/doubtfire-deploy/commit/1ae5e36cc7c4e27ad555c892555c8b1031e92ccc)) + +## [10.0.0-54](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-53...v10.0.0-54) (2025-11-06) + +## [10.0.0-54](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-53...v10.0.0-54) (2025-11-06) + +## [10.0.0-53](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-52...v10.0.0-53) (2025-11-05) + + +### Features + +* track portfolio assessor + import grades via csv ([#542](https://github.com/b0ink/doubtfire-deploy/issues/542)) ([f546211](https://github.com/b0ink/doubtfire-deploy/commit/f546211832c5d74e3a5df1d0ff89a8a49aa15a61)) + +## [10.0.0-52](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-51...v10.0.0-52) (2025-11-03) + +## [10.0.0-51](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-50...v10.0.0-51) (2025-11-03) + + +### Bug Fixes + +* concurrent safe pdf generation ([#543](https://github.com/b0ink/doubtfire-deploy/issues/543)) ([d656446](https://github.com/b0ink/doubtfire-deploy/commit/d656446d3ed920c78a16df02b775d9ff2c52ca19)) + +## [10.0.0-50](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-49...v10.0.0-50) (2025-11-01) + + +### Bug Fixes + +* select correct portfolio tasks ([#541](https://github.com/b0ink/doubtfire-deploy/issues/541)) ([e27762b](https://github.com/b0ink/doubtfire-deploy/commit/e27762b17887e7a24ef284a5e36998a2107f8707)) + +## [10.0.0-49](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-48...v10.0.0-49) (2025-10-30) + + +### Bug Fixes + +* filter out both plan and discuss comments ([c1c3e36](https://github.com/b0ink/doubtfire-deploy/commit/c1c3e3636a8a2d21bc047815695f883b08e4a504)) + +## [10.0.0-48](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-47...v10.0.0-48) (2025-10-30) + + +### Bug Fixes + +* ensure pax is included to persist pdf hyperlinks ([e4692aa](https://github.com/b0ink/doubtfire-deploy/commit/e4692aa61798d9f4c2ea429d726e899c302d789c)) + +## [10.0.0-47](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-46...v10.0.0-47) (2025-10-29) + +## [10.0.0-46](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-45...v10.0.0-46) (2025-10-24) + + +### Bug Fixes + +* ensure learning outcome description text wraps ([c3c65c9](https://github.com/b0ink/doubtfire-deploy/commit/c3c65c91bd0b0c8391f1851abd843c7e812864c9)) + +## [10.0.0-45](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-44...v10.0.0-45) (2025-10-23) + + +### Bug Fixes + +* convert query to array ([2147b17](https://github.com/b0ink/doubtfire-deploy/commit/2147b17a37fca8c5c1e05d8fd040870eb541511b)) + +## [10.0.0-44](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-43...v10.0.0-44) (2025-10-20) + +## [10.0.0-43](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-42...v10.0.0-43) (2025-10-17) + +## [10.0.0-42](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-41...v10.0.0-42) (2025-10-17) + + +### Features + +* campus timezone ([#527](https://github.com/b0ink/doubtfire-deploy/issues/527)) ([acb570a](https://github.com/b0ink/doubtfire-deploy/commit/acb570a9e8b866191181a9a57917b28a9033bc0b)) +* download marking sessions for tutor ([#525](https://github.com/b0ink/doubtfire-deploy/issues/525)) ([a25dce7](https://github.com/b0ink/doubtfire-deploy/commit/a25dce752474bdc5044a141331a86d50d875ac1a)) + +## [10.0.0-41](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-40...v10.0.0-41) (2025-10-16) + + +### Bug Fixes + +* only trigger assess in portfolio validation when field is about to change ([#526](https://github.com/b0ink/doubtfire-deploy/issues/526)) ([9af06a5](https://github.com/b0ink/doubtfire-deploy/commit/9af06a52ff836c1694f8665dff8b45ed60de41b9)) + +## [10.0.0-40](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-39...v10.0.0-40) (2025-10-13) + + +### Features + +* jplag base code ([#522](https://github.com/b0ink/doubtfire-deploy/issues/522)) ([d9bb186](https://github.com/b0ink/doubtfire-deploy/commit/d9bb1862d87c9365a8d8f39cfdca9852d93f764a)) + +## [10.0.0-39](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-38...v10.0.0-39) (2025-10-13) + +## [10.0.0-38](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-37...v10.0.0-38) (2025-10-12) + + +### Features + +* tutor times 10.0.x ([#518](https://github.com/b0ink/doubtfire-deploy/issues/518)) ([#509](https://github.com/b0ink/doubtfire-deploy/issues/509)) ([3c1cb7e](https://github.com/b0ink/doubtfire-deploy/commit/3c1cb7e93dea50d1f78a7c251e2151dfc9b4089e)), closes [#4](https://github.com/b0ink/doubtfire-deploy/issues/4) [#5](https://github.com/b0ink/doubtfire-deploy/issues/5) [#6](https://github.com/b0ink/doubtfire-deploy/issues/6) [#7](https://github.com/b0ink/doubtfire-deploy/issues/7) [#8](https://github.com/b0ink/doubtfire-deploy/issues/8) + + +### Bug Fixes + +* **numbas:** allow '.' in username param for SCORM route ([dc86026](https://github.com/b0ink/doubtfire-deploy/commit/dc86026a6261516270d1cdf55b1517a95cd0e77f)) + +## [10.0.0-37](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-36...v10.0.0-37) (2025-09-29) + +## [10.0.0-36](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-35...v10.0.0-36) (2025-09-28) + + +### Features + +* ensure overdue tasks update to aip when task definition enables aip only ([0e216cd](https://github.com/b0ink/doubtfire-deploy/commit/0e216cdb8d553d72b663a7ff7649e801c75c5e53)) +* ensure overdue tasks update to aip when unit enables aip ([3599ae6](https://github.com/b0ink/doubtfire-deploy/commit/3599ae6ec7538e0ae310bb671c4d86f99762620b)) + + +### Bug Fixes + +* assert assess in portfolio status ([9dfc62c](https://github.com/b0ink/doubtfire-deploy/commit/9dfc62ca67c5884a59a1027fafaeab6d329b9529)) + +## [10.0.0-35](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-34...v10.0.0-35) (2025-09-25) + + +### Features + +* add observer updating ([74f01a9](https://github.com/b0ink/doubtfire-deploy/commit/74f01a91fdde6c2712e6927154f2fde0b97f9220)) +* init observer only unit role permissions ([a302a59](https://github.com/b0ink/doubtfire-deploy/commit/a302a597bd73abbe67c5e19d22c24e44bd45bf5e)) + +## [10.0.0-34](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-33...v10.0.0-34) (2025-09-24) + + +### Bug Fixes + +* ensure all files are copied first before running jplag ([4ff5f4a](https://github.com/b0ink/doubtfire-deploy/commit/4ff5f4a5f14a12e1f0e556f44acaac7741724e0e)) +* remove old plagiarism links that are below the new threshold ([3abe8e4](https://github.com/b0ink/doubtfire-deploy/commit/3abe8e4d79a69e0832b1263ab3060b2ab8643d06)) + +## [10.0.0-33](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-32...v10.0.0-33) (2025-09-18) + + +### Bug Fixes + +* ensure task throws validation errors on save ([de240bd](https://github.com/b0ink/doubtfire-deploy/commit/de240bd24f66ac9d57d5d84488eaafa1a379d27c)) +* skip deadline validation for units with flexible due dates ([b317d85](https://github.com/b0ink/doubtfire-deploy/commit/b317d85138c1442fa7f867c4825d970eccdd3cfc)) + +## [10.0.0-32](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-31...v10.0.0-32) (2025-09-17) + + +### Features + +* enable lms context and unit deeplinking ([0fa850f](https://github.com/b0ink/doubtfire-deploy/commit/0fa850f661ad4fa0aa15450ee5704bbee6e8cc99)) +* init lti authentication ([182c60c](https://github.com/b0ink/doubtfire-deploy/commit/182c60c78bdc1e1cf0ce9cccf773b358a9a63d7e)) +* lti user enrolment ([c3b2282](https://github.com/b0ink/doubtfire-deploy/commit/c3b2282ad21b6561da788a3730d31f92dcf91b7c)) +* retrieve grades for a list of students ([e588873](https://github.com/b0ink/doubtfire-deploy/commit/e588873f6f061ca63bf30852463df016e86712a4)) +* sync enrolments from lti ([46196fa](https://github.com/b0ink/doubtfire-deploy/commit/46196fa5973c1b96c12ea81cfb26b53824945f54)) + + +### Bug Fixes + +* check for env var string value ([4ecc22c](https://github.com/b0ink/doubtfire-deploy/commit/4ecc22cab83bd880e80766fbf72386cbafdc2c3e)) +* use given name for first name ([5627bf0](https://github.com/b0ink/doubtfire-deploy/commit/5627bf0909c939004c8e0e7ca7f184784c7990ad)) + +## [10.0.0-31](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-30...v10.0.0-31) (2025-09-16) + + +### Bug Fixes + +* check task status on status comments only ([8fbe65c](https://github.com/b0ink/doubtfire-deploy/commit/8fbe65c3ab6586012409f69a0c49eecf29a2ced2)) +* ensure simulated jplag submissions doesnt run in prod ([2f2183e](https://github.com/b0ink/doubtfire-deploy/commit/2f2183e912bb6ea54dd763438db952d4ae506936)) +* ensure tasks in assess in portfolio state meet prerequisite task status ([b7a4efa](https://github.com/b0ink/doubtfire-deploy/commit/b7a4efac9f777e71db9f62a649a054ced29b9303)) +* expose projects spec_con_days ([bd7bfee](https://github.com/b0ink/doubtfire-deploy/commit/bd7bfeeeb9ea0dc8e514e867b5f4fca19ff67f1e)) + +## [10.0.0-30](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-29...v10.0.0-30) (2025-09-15) + + +### Bug Fixes + +* ensure correct order of unit and task definition ids ([d941055](https://github.com/b0ink/doubtfire-deploy/commit/d9410556d67d1cbeab16730e34642cda0bc460b5)) + +## [10.0.0-29](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-28...v10.0.0-29) (2025-09-15) + + +### Features + +* assess in portfolio status ([b6f0cd4](https://github.com/b0ink/doubtfire-deploy/commit/b6f0cd49715d2ce24078a0506427ab548c8b16a0)) + +## [10.0.0-28](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-27...v10.0.0-28) (2025-09-10) + + +### Bug Fixes + +* ensure task is reloaded ([5e94099](https://github.com/b0ink/doubtfire-deploy/commit/5e94099024dbf0fee11a412a0c6f06ace3c15559)) + +## [10.0.0-27](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-26...v10.0.0-27) (2025-09-09) + + +### Features + +* add task prerequisites ([5a0e673](https://github.com/b0ink/doubtfire-deploy/commit/5a0e6732f779380a7159ff03ee544aef752039f2)) +* ensure prerequisite tasks have been submitted ([013428c](https://github.com/b0ink/doubtfire-deploy/commit/013428c522edf228893f0743a2743170e0e05f0b)) + + +### Bug Fixes + +* check target date instead of deadline date ([165d1ab](https://github.com/b0ink/doubtfire-deploy/commit/165d1abb143329b5afdeae1afa7ad5b536b3f9fc)) + +## [10.0.0-26](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-25...v10.0.0-26) (2025-09-09) + +## [10.0.0-25](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-24...v10.0.0-25) (2025-09-08) + + +### Features + +* add tasks discussed stats ([70dc9c6](https://github.com/b0ink/doubtfire-deploy/commit/70dc9c61e92254c4322f16d90e207fb706cc3c91)) +* csv to download task assessment counts ([f40214e](https://github.com/b0ink/doubtfire-deploy/commit/f40214e338479e8d145ab0f72640daf1bf481707)) + +## [10.0.0-24](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-23...v10.0.0-24) (2025-09-04) + + +### Bug Fixes + +* ensure staff rows are initialised ([a919c27](https://github.com/b0ink/doubtfire-deploy/commit/a919c27b6de13b31aec73021dc07374888eeffb5)) +* get correct number of projects not enrolled in a tutorial per stream ([fbe7252](https://github.com/b0ink/doubtfire-deploy/commit/fbe7252b1f62f44047221504028774e299990d58)) +* get correct staff engagement count ([f813319](https://github.com/b0ink/doubtfire-deploy/commit/f8133193a9ba6140b180bbf6c646f64934a6ec05)) + +## [10.0.0-23](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-22...v10.0.0-23) (2025-08-27) + +## [10.0.0-22](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-21...v10.0.0-22) (2025-08-27) + + +### Features + +* add attendance marked task comment ([1505242](https://github.com/b0ink/doubtfire-deploy/commit/150524243d99f4821daa7489064d1b324673b9b7)) + +## [10.0.0-21](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-20...v10.0.0-21) (2025-08-26) + + +### Features + +* env var to set jplag minimum token match ([8c04f38](https://github.com/b0ink/doubtfire-deploy/commit/8c04f388e435451595191c9bc627fdec59cf8244)) +* use env var for max file size ([f5f4b29](https://github.com/b0ink/doubtfire-deploy/commit/f5f4b2955b7910c06b5a552f07549355ac913dd6)) + +## [10.0.0-20](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-19...v10.0.0-20) (2025-08-21) + +## [10.0.0-19](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-18...v10.0.0-19) (2025-08-14) + + +### Bug Fixes + +* correct header name ([3987a04](https://github.com/b0ink/doubtfire-deploy/commit/3987a0487eb8d56d5d452c30600ec8649b76ccee)) + +## [10.0.0-18](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-17...v10.0.0-18) (2025-08-14) + + +### Bug Fixes + +* fetch enrolled students only ([72b6af8](https://github.com/b0ink/doubtfire-deploy/commit/72b6af858ff30350ff67f10956806d4637e96520)) + +## [10.0.0-17](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-16...v10.0.0-17) (2025-08-14) + + +### Features + +* download tasks awaiting feedback stats ([43a2963](https://github.com/b0ink/doubtfire-deploy/commit/43a29633303954c055662e771928e023d44faea4)) + +## [10.0.0-16](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-15...v10.0.0-16) (2025-08-07) + + +### Features + +* populate feedback chips ([8380b6f](https://github.com/b0ink/doubtfire-deploy/commit/8380b6fa7dec763bca057a00baf3a536b667da9c)) + +## [10.0.0-15](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-14...v10.0.0-15) (2025-07-31) + +## [10.0.0-14](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-13...v10.0.0-14) (2025-07-31) + + +### Bug Fixes + +* move tmp csv into shared volume ([d8e77c3](https://github.com/b0ink/doubtfire-deploy/commit/d8e77c351dc38dece9becebbdcc0b2fc9f29e188)) + +## [10.0.0-13](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-12...v10.0.0-13) (2025-07-31) + +## [10.0.0-12](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-11...v10.0.0-12) (2025-07-29) + + +### Bug Fixes + +* ensure styling is applied to elements ([f17b806](https://github.com/b0ink/doubtfire-deploy/commit/f17b8066720705ccc0383f0da651b24e3149e7c7)) +* remove duplicate protocol [#460](https://github.com/b0ink/doubtfire-deploy/issues/460) ([198295b](https://github.com/b0ink/doubtfire-deploy/commit/198295b3ea724262919a502c2b231982cff57ff3)) +* return file path ([457ccd3](https://github.com/b0ink/doubtfire-deploy/commit/457ccd3665b8220a7ed95c2d140dfbcc1ae32b69)) +* validate unique project for same unit and user ([e5f3fa7](https://github.com/b0ink/doubtfire-deploy/commit/e5f3fa72de8279e6e04b1f35a12c9083ba37659d)) + +## [10.0.0-11](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-10...v10.0.0-11) (2025-07-28) + + +### Features + +* move portfolio zip downloading to sidekiq ([569540b](https://github.com/b0ink/doubtfire-deploy/commit/569540bd0be1bece7fd958e298ea16a59a78082f)) +* sidekiq job status tracking ([486dbdd](https://github.com/b0ink/doubtfire-deploy/commit/486dbdd3570fdedb07cc3fb789ac722aa651f6f9)) + + +### Bug Fixes + +* casing typo ([dd40479](https://github.com/b0ink/doubtfire-deploy/commit/dd40479abb832f0b3132e2fffc2e4de9daea9613)) +* ensure allow flexible dates is enabled ([c011b93](https://github.com/b0ink/doubtfire-deploy/commit/c011b93fc6bca90108c4f03b28b1c8bbe2532b35)) +* ensure correct sidekiq-unique options ([2320644](https://github.com/b0ink/doubtfire-deploy/commit/2320644de1745da19a775804b521eb91e38998c7)) +* filter out comments with plan content types ([86516e6](https://github.com/b0ink/doubtfire-deploy/commit/86516e626a1579fdac69484b7f8ec74d586cc378)) +* get portfolio zip filename ([4140473](https://github.com/b0ink/doubtfire-deploy/commit/4140473356ba5f7dedd885e04aa4a64aaa2fc752)) +* return portfolio zip name ([5f897e8](https://github.com/b0ink/doubtfire-deploy/commit/5f897e854247f6951a0cca4c596bcde3fc03397a)) + +## [10.0.0-10](https://github.com/b0ink/doubtfire-deploy/compare/v10.0.0-9...v10.0.0-10) (2025-07-22) + + +### Bug Fixes + +* mark task due date adjustment comment as read by tutor ([cfbee8c](https://github.com/b0ink/doubtfire-deploy/commit/cfbee8c305da5a04ba79c6ee49ae5fb8df0ad084)) + +## [10.0.0-9](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-8...v10.0.0-9) (2025-07-16) + + +### Bug Fixes + +* return correct row data ([3d26d96](https://github.com/macite/doubtfire-deploy/commit/3d26d9636f082d2f30b1da2b342b3783872962ed)) + +## [10.0.0-8](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-7...v10.0.0-8) (2025-07-14) + + +### Features + +* delete note api endpoint ([c692efa](https://github.com/macite/doubtfire-deploy/commit/c692efa4c2f074862007c44d80541448d4788ef0)) +* endpoint to update staff notes ([21c2b1b](https://github.com/macite/doubtfire-deploy/commit/21c2b1bb2337251d3185a4393ccaf4f9c13617e2)) +* fetch number of staff notes a project has ([32500e9](https://github.com/macite/doubtfire-deploy/commit/32500e97dceaa7493e89e03cfef46e9c377c5b1f)) +* init staff notes ([5f65978](https://github.com/macite/doubtfire-deploy/commit/5f65978aea6381fc3ae555cdd0d5969554bad229)) +* jplag unit test ([f0bf5a8](https://github.com/macite/doubtfire-deploy/commit/f0bf5a88aa5a748e475aaf56b1ce83ce94a3c485)) +* reply to staff notes ([06be778](https://github.com/macite/doubtfire-deploy/commit/06be778d79d24cc7ec2f9fce555e74572d09b112)) +* task discussed comment type ([d0857c3](https://github.com/macite/doubtfire-deploy/commit/d0857c3bb098f2c561bad00f98cdea8830217d44)) +* test staff note count ([531a7cf](https://github.com/macite/doubtfire-deploy/commit/531a7cf468bc072ba3350e0e354638b320b1fa58)) + + +### Bug Fixes + +* add student_id header ([9316def](https://github.com/macite/doubtfire-deploy/commit/9316deff248edc77b7a5d5d456ad9a763f4cfff4)) +* avoid unnecessary jplag work dir creation ([f4013a6](https://github.com/macite/doubtfire-deploy/commit/f4013a609c45ad535a6ad34fdde1835815ccebd5)) +* dont create duplicate note ([c0f750a](https://github.com/macite/doubtfire-deploy/commit/c0f750ac714aed50cc95cc52b811393e6ead8731)) +* ensure errors are logged early in accept submission ([ab5baa4](https://github.com/macite/doubtfire-deploy/commit/ab5baa4eebefcb1f0a3fde1ecff7e3d6bdef39a7)) +* ensure errors on missing streams in user inport is reported correctly ([46a919e](https://github.com/macite/doubtfire-deploy/commit/46a919e3b33366a831d8378b822f4b9dc9b2bd40)) +* fix report download ([a45eb6b](https://github.com/macite/doubtfire-deploy/commit/a45eb6baf721b8d2fb95763423425741fc17f91c)) +* make report downloading use new path ([c83919c](https://github.com/macite/doubtfire-deploy/commit/c83919c49074187cabffebba4a8e7e48dbf96319)) +* make report file generation make more sense and remove tmp files ([6fa26f0](https://github.com/macite/doubtfire-deploy/commit/6fa26f07ab5528b9610d185da90ed07f048e063b)) +* merge tutorials for duplicate student rows ([2dbe45c](https://github.com/macite/doubtfire-deploy/commit/2dbe45c25f8106eab680cfa0aaa5bc29e4261e5f)) +* remove need for iteractive terminal for latex run ([908e97d](https://github.com/macite/doubtfire-deploy/commit/908e97dde7c541cdbae03df4335535e8bdc0467f)) +* rename plagiarism link methods ([4efa907](https://github.com/macite/doubtfire-deploy/commit/4efa907fd0fe199e6a3d0ed821638ef2949c6d62)) +* simulate jplag task submissions ([3a45161](https://github.com/macite/doubtfire-deploy/commit/3a45161661800aff5aea791ed22606f264449e3d)) +* skip jplag for unchanged submissions ([1a3336f](https://github.com/macite/doubtfire-deploy/commit/1a3336f76b6122ac1632f0164a6e9949bb549ab2)) +* use interactive flag ([b20432c](https://github.com/macite/doubtfire-deploy/commit/b20432c209806cb8cf669ad5053727b54b702240)) +* use updated .jplag extension and get correct report path ([3247a9f](https://github.com/macite/doubtfire-deploy/commit/3247a9ffb59567f9fde97762a51359e9bcc46c72)) + +## [10.0.0-7](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-6...v10.0.0-7) (2025-06-13) + + +### Features + +* add spec con days and flexible unit dates ([fdd0ce1](https://github.com/macite/doubtfire-deploy/commit/fdd0ce13e9c92edc0c4d3a4b722409eedcdd4d06)) + + +### Bug Fixes + +* ensure SAML signout does not default to signin url ([d0d3bcd](https://github.com/macite/doubtfire-deploy/commit/d0d3bcdfd7634080894825707ae5820499c68ef2)) + +## [10.0.0-6](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-5...v10.0.0-6) (2025-05-30) + + +### Bug Fixes + +* adjust cookie function name to clarify purpose ([729efb6](https://github.com/macite/doubtfire-deploy/commit/729efb622b1257a14e28999a61e3a6343c4b2937)) + +## [10.0.0-5](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-4...v10.0.0-5) (2025-05-30) + + +### Features + +* add saml signout url ([1f079b9](https://github.com/macite/doubtfire-deploy/commit/1f079b990b5bcc099f945ed15abfad0dc05f9701)) +* allow database auth to simulate token signin ([4e4873c](https://github.com/macite/doubtfire-deploy/commit/4e4873c2a5eeffa0fa3f9546c38f4d4870dba7b8)) + +## [10.0.0-4](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-3...v10.0.0-4) (2025-04-11) + +## [10.0.0-3](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-2...v10.0.0-3) (2025-04-10) + + +### Bug Fixes + +* ensure stdin is attached to to latex exec ([d0a4002](https://github.com/macite/doubtfire-deploy/commit/d0a40020f8ac739ed7df36e41c5d61ea16d5daa6)) + +## [10.0.0-2](https://github.com/macite/doubtfire-deploy/compare/v10.0.0-1...v10.0.0-2) (2025-04-10) + +## [10.0.0-1](https://github.com/macite/doubtfire-deploy/compare/v8.0.42...v10.0.0-1) (2025-04-09) + + +### Features + +* add ability to set an institution logo to add to header ([267cf19](https://github.com/macite/doubtfire-deploy/commit/267cf19c15632a5b24d9f2915bbf1b59ff297fe4)) +* add refresh token auth handling ([e0129b9](https://github.com/macite/doubtfire-deploy/commit/e0129b9e393d9a6df1efd2e75a276f4cd38c36de)) +* added feedback chips, migrated learning outcome table changes, working on testing files for both ([182e06c](https://github.com/macite/doubtfire-deploy/commit/182e06ccd18a0abec3a30b8882458cce0c12ebd3)) +* added feedback chips, migrated learning outcome table changes, working on testing files for both ([1639205](https://github.com/macite/doubtfire-deploy/commit/16392058d7acab5a994c6b310fd55efdf08f1c33)) +* enable saml config per institution ([b1e17bf](https://github.com/macite/doubtfire-deploy/commit/b1e17bf1563c66de19f3f8bd6fa8072f2cd8fe5a)) +* made changes to feedback chips schema, removing redundant components, updated models and tests to reflect this ([84c3358](https://github.com/macite/doubtfire-deploy/commit/84c3358a755aabb7a1f02c98c725d3db7b2e02cd)) +* made changes to feedback chips schema, removing redundant components, updated models and tests to reflect this ([9eebc54](https://github.com/macite/doubtfire-deploy/commit/9eebc541b0c4a03017b7f0a5a2c2cd2e0ddb9969)) +* move to secure cookies and refresh tokens ([31975b7](https://github.com/macite/doubtfire-deploy/commit/31975b7b4475a854ba0adacf6bdb6e8ab45392ec)) +* show outcomes in portfolio and task pdfs ([7694726](https://github.com/macite/doubtfire-deploy/commit/76947269e83e577a124b6f0252d758951497f0a1)) +* started work on feedback chip api and modifications to learning outcome ([5b99b69](https://github.com/macite/doubtfire-deploy/commit/5b99b69e9297d55ab5a3cab0576d10ae47099ba5)) +* started work on feedback chip api and modifications to learning outcome ([1fc1569](https://github.com/macite/doubtfire-deploy/commit/1fc1569586fabe8be82b19e4543f7f3d36fb2498)) + + +### Bug Fixes + +* add include_tlo flag to feedback_chip csv download ([5bfafc3](https://github.com/macite/doubtfire-deploy/commit/5bfafc3cb87f39632df55daa2bd744e7c9b6325d)) +* add learning outcome abbreviation uniqueness ([655493f](https://github.com/macite/doubtfire-deploy/commit/655493f8e7f605a214c9d89856a4d811bca7c20c)) +* add task_status check for csv upload ([0147aff](https://github.com/macite/doubtfire-deploy/commit/0147aff9c22116ed03af4b7447d967b00e3fac98)) +* allow docker exec to work in gh actions ([807f567](https://github.com/macite/doubtfire-deploy/commit/807f567b636b2830458ed3368fef0861c27d956e)) +* correct routes for swagger support ([b0026ab](https://github.com/macite/doubtfire-deploy/commit/b0026ab5b571f7b82b647e4677b26217604bc1e3)) +* enhance auth for feedback chips including adding tests ([f038bdd](https://github.com/macite/doubtfire-deploy/commit/f038bdd4d4132d0464a40fa0fb02489ddd223260)) +* ensure log is still copied if pdf gen fails ([0370df4](https://github.com/macite/doubtfire-deploy/commit/0370df41ce119b8fba2a09ab108840f55b6007d6)) +* ensure new rollover units are not marked archived ([4dee4d7](https://github.com/macite/doubtfire-deploy/commit/4dee4d7c05a10056d47914d36b00ac7092bac3f0)) +* ensure task link migration works without model ([836e247](https://github.com/macite/doubtfire-deploy/commit/836e247137d5ccd6f92a5062a6084125838b4c2c)) +* fix global group chip creation ([eecb2ce](https://github.com/macite/doubtfire-deploy/commit/eecb2ce71c5479481f94ab4877eed83607b1ba1c)) +* global outcome chip upload ([57e1e6c](https://github.com/macite/doubtfire-deploy/commit/57e1e6ccb5f02b8d1449bbceef11c65ad98342af)) +* group chip not updating ([f02b22a](https://github.com/macite/doubtfire-deploy/commit/f02b22a01786635c47dd97b5082e49bb634f1949)) +* move group_id storage from summary_text to comment_text ([fa424d3](https://github.com/macite/doubtfire-deploy/commit/fa424d35193c65a5f6e64770cb961a909a86ee2c)) +* remove auth put and fix token cycling ([59aa38a](https://github.com/macite/doubtfire-deploy/commit/59aa38a5f2649163281ac136f795f9d74629c153)) +* remove missed api mount ([0b237a7](https://github.com/macite/doubtfire-deploy/commit/0b237a77a1517176317fa57157d9a057bc47c3e0)) +* reorder migrations to ensure they run together ([2d04b1d](https://github.com/macite/doubtfire-deploy/commit/2d04b1d8c5801323d49b0f198bb62d7c897b4fdc)) +* revert ruby version for development deploy branch ([c05f3cb](https://github.com/macite/doubtfire-deploy/commit/c05f3cbaad9cbf3e6c42cf4fff052616e221e192)) +* set correct exit status ([a63cee8](https://github.com/macite/doubtfire-deploy/commit/a63cee85efb0c1c4980743d18660af7da805d491)) +* set dockerfile context ([0f993a9](https://github.com/macite/doubtfire-deploy/commit/0f993a993dcb8636804fabcff23331d5a097b1ec)) +* task_status csv validation ([ddb166b](https://github.com/macite/doubtfire-deploy/commit/ddb166b43e6c9667732e727c25a0128c139a2f7a)) +* update create_from_csv to validate same abbreviation tlos ([242f38a](https://github.com/macite/doubtfire-deploy/commit/242f38a81dc6c8c94d0b77753563076acdc1ff1a)) + +## [8.1.0](https://github.com/macite/doubtfire-deploy/compare/v8.0.42...v8.1.0) (2025-04-09) + + +### Features + +* add ability to set an institution logo to add to header ([267cf19](https://github.com/macite/doubtfire-deploy/commit/267cf19c15632a5b24d9f2915bbf1b59ff297fe4)) +* add refresh token auth handling ([e0129b9](https://github.com/macite/doubtfire-deploy/commit/e0129b9e393d9a6df1efd2e75a276f4cd38c36de)) +* added feedback chips, migrated learning outcome table changes, working on testing files for both ([182e06c](https://github.com/macite/doubtfire-deploy/commit/182e06ccd18a0abec3a30b8882458cce0c12ebd3)) +* added feedback chips, migrated learning outcome table changes, working on testing files for both ([1639205](https://github.com/macite/doubtfire-deploy/commit/16392058d7acab5a994c6b310fd55efdf08f1c33)) +* enable saml config per institution ([b1e17bf](https://github.com/macite/doubtfire-deploy/commit/b1e17bf1563c66de19f3f8bd6fa8072f2cd8fe5a)) +* made changes to feedback chips schema, removing redundant components, updated models and tests to reflect this ([84c3358](https://github.com/macite/doubtfire-deploy/commit/84c3358a755aabb7a1f02c98c725d3db7b2e02cd)) +* made changes to feedback chips schema, removing redundant components, updated models and tests to reflect this ([9eebc54](https://github.com/macite/doubtfire-deploy/commit/9eebc541b0c4a03017b7f0a5a2c2cd2e0ddb9969)) +* move to secure cookies and refresh tokens ([31975b7](https://github.com/macite/doubtfire-deploy/commit/31975b7b4475a854ba0adacf6bdb6e8ab45392ec)) +* show outcomes in portfolio and task pdfs ([7694726](https://github.com/macite/doubtfire-deploy/commit/76947269e83e577a124b6f0252d758951497f0a1)) +* started work on feedback chip api and modifications to learning outcome ([5b99b69](https://github.com/macite/doubtfire-deploy/commit/5b99b69e9297d55ab5a3cab0576d10ae47099ba5)) +* started work on feedback chip api and modifications to learning outcome ([1fc1569](https://github.com/macite/doubtfire-deploy/commit/1fc1569586fabe8be82b19e4543f7f3d36fb2498)) + + +### Bug Fixes + +* add include_tlo flag to feedback_chip csv download ([5bfafc3](https://github.com/macite/doubtfire-deploy/commit/5bfafc3cb87f39632df55daa2bd744e7c9b6325d)) +* add learning outcome abbreviation uniqueness ([655493f](https://github.com/macite/doubtfire-deploy/commit/655493f8e7f605a214c9d89856a4d811bca7c20c)) +* add task_status check for csv upload ([0147aff](https://github.com/macite/doubtfire-deploy/commit/0147aff9c22116ed03af4b7447d967b00e3fac98)) +* allow docker exec to work in gh actions ([807f567](https://github.com/macite/doubtfire-deploy/commit/807f567b636b2830458ed3368fef0861c27d956e)) +* correct routes for swagger support ([b0026ab](https://github.com/macite/doubtfire-deploy/commit/b0026ab5b571f7b82b647e4677b26217604bc1e3)) +* enhance auth for feedback chips including adding tests ([f038bdd](https://github.com/macite/doubtfire-deploy/commit/f038bdd4d4132d0464a40fa0fb02489ddd223260)) +* ensure log is still copied if pdf gen fails ([0370df4](https://github.com/macite/doubtfire-deploy/commit/0370df41ce119b8fba2a09ab108840f55b6007d6)) +* ensure new rollover units are not marked archived ([4dee4d7](https://github.com/macite/doubtfire-deploy/commit/4dee4d7c05a10056d47914d36b00ac7092bac3f0)) +* ensure task link migration works without model ([836e247](https://github.com/macite/doubtfire-deploy/commit/836e247137d5ccd6f92a5062a6084125838b4c2c)) +* fix global group chip creation ([eecb2ce](https://github.com/macite/doubtfire-deploy/commit/eecb2ce71c5479481f94ab4877eed83607b1ba1c)) +* global outcome chip upload ([57e1e6c](https://github.com/macite/doubtfire-deploy/commit/57e1e6ccb5f02b8d1449bbceef11c65ad98342af)) +* group chip not updating ([f02b22a](https://github.com/macite/doubtfire-deploy/commit/f02b22a01786635c47dd97b5082e49bb634f1949)) +* move group_id storage from summary_text to comment_text ([fa424d3](https://github.com/macite/doubtfire-deploy/commit/fa424d35193c65a5f6e64770cb961a909a86ee2c)) +* remove auth put and fix token cycling ([59aa38a](https://github.com/macite/doubtfire-deploy/commit/59aa38a5f2649163281ac136f795f9d74629c153)) +* remove missed api mount ([0b237a7](https://github.com/macite/doubtfire-deploy/commit/0b237a77a1517176317fa57157d9a057bc47c3e0)) +* reorder migrations to ensure they run together ([2d04b1d](https://github.com/macite/doubtfire-deploy/commit/2d04b1d8c5801323d49b0f198bb62d7c897b4fdc)) +* revert ruby version for development deploy branch ([c05f3cb](https://github.com/macite/doubtfire-deploy/commit/c05f3cbaad9cbf3e6c42cf4fff052616e221e192)) +* set correct exit status ([a63cee8](https://github.com/macite/doubtfire-deploy/commit/a63cee85efb0c1c4980743d18660af7da805d491)) +* set dockerfile context ([0f993a9](https://github.com/macite/doubtfire-deploy/commit/0f993a993dcb8636804fabcff23331d5a097b1ec)) +* task_status csv validation ([ddb166b](https://github.com/macite/doubtfire-deploy/commit/ddb166b43e6c9667732e727c25a0128c139a2f7a)) +* update create_from_csv to validate same abbreviation tlos ([242f38a](https://github.com/macite/doubtfire-deploy/commit/242f38a81dc6c8c94d0b77753563076acdc1ff1a)) + +### [8.0.42](https://github.com/macite/doubtfire-deploy/compare/v8.0.41...v8.0.42) (2025-03-10) + + +### Bug Fixes + +* ensure submission history uses archive and moves ([e9161b3](https://github.com/macite/doubtfire-deploy/commit/e9161b380d0b32850df53baf2f7ab3f356fb64ba)) +* remove archive old unit from schedule ([f16fdee](https://github.com/macite/doubtfire-deploy/commit/f16fdeed9d0dcc9882a0615784800406fd021530)) + +### [8.0.41](https://github.com/macite/doubtfire-deploy/compare/v8.0.40...v8.0.41) (2025-03-07) + + +### Features + +* add ability to get individual overseer images ([9c32326](https://github.com/macite/doubtfire-deploy/commit/9c323269bcc1a41c2d1e37702a7bb90efea72773)) + +### [8.0.40](https://github.com/macite/doubtfire-deploy/compare/v8.0.39...v8.0.40) (2025-01-31) + + +### Features + +* add scheduled archive of units ([9b2cf03](https://github.com/macite/doubtfire-deploy/commit/9b2cf03986277a1d0016b5fd2d020cdf19c84271)) +* auto archive of units is optional ([23bd227](https://github.com/macite/doubtfire-deploy/commit/23bd2271fc2d8420ccbee142389b539c13c299c7)) +* email users on D2L grade transfer fail ([00cb85e](https://github.com/macite/doubtfire-deploy/commit/00cb85ecb84709f9c4500364a0da6f27c8097436)) +* remove stored pdf path and allow move to archive ([33944dd](https://github.com/macite/doubtfire-deploy/commit/33944dd05e1e6d197872a5c67fbec15206f04692)) + +### [8.0.39](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.38...v8.0.39) (2025-01-22) + + +### Features + +* add ability to post grades to d2l ([8e3a387](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8e3a387c2a9e45a36e15e3d45783c02c023194b8)) +* add redirect to success page on oauth success ([ba447ba](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ba447ba145674411e81db37af8829c87692ef9ed)) +* add start of new d2l integration feature ([dee040f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/dee040f44527e9126e375bca49df8234ef3b4f4e)) +* allow oauth login to d2l ([b300211](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b300211ecb84429c89b85722ded38a9c9e36aec5)) +* d2l mappings have crud api ([ccee700](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ccee7006422131f089372e464fe22331c1945537)) +* ensure only convenor or higher can use d2l details ([76cb354](https://github.com/doubtfire-lms/doubtfire-deploy/commit/76cb354b5f7075242c33bcbb68d59f90b640f18c)) +* ensure only one d2l mapping per unit ([178a040](https://github.com/doubtfire-lms/doubtfire-deploy/commit/178a0406ba03fc0710032c5bbf58df9f10513fc0)) +* give ability to trigger d2l result post and get results ([ca85b98](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ca85b984d0c25c5233afa09c596462c6b22ee89e)) +* handle multiple d2l grade transfer submissions ([1be09a6](https://github.com/doubtfire-lms/doubtfire-deploy/commit/1be09a6f288c8ec5662fc8d2c268623d6e78967c)) +* improve error reporting to send task log details ([9815fd2](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9815fd26802dc724290dbf76261488054f122d7a)) +* include grade in d2l copy report ([9b71f85](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9b71f85293ab24271ff2cd3a34afd8c3698ae630)) +* remove old portfolios in maintenance ([ae54a1c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ae54a1c884c91d3cc0c0abd0e74808f7bb2138d7)) +* simplified logging of d2l result transfer ([8ada69c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8ada69c6f8c71b3fccd21b344f3da5f6e5ab8b77)) +* use api to get login url for d2l ([e2d0fa7](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e2d0fa75fb47fed484eb72bc5bc75c2f881fc4d5)) + + +### Bug Fixes + +* allow none or no_auth for smtp authentication options ([c80ccf3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c80ccf36efbd5a099c4b5a6bcae63eaac5799378)) +* check for weighted grading in d2l integration ([97d8241](https://github.com/doubtfire-lms/doubtfire-deploy/commit/97d8241df9d29bff37ec98b9e20a4f43da0457e6)) +* correct issue creating unit folders on destroy ([8bec4b0](https://github.com/doubtfire-lms/doubtfire-deploy/commit/8bec4b0d3f324f334c7fb868d7524e37c0973bff)) +* correct output from d2l csv ([c1ea118](https://github.com/doubtfire-lms/doubtfire-deploy/commit/c1ea1180f2cac9852fa903b1f47e05a05c554f56)) +* ensure smtp can have no auth ([b2691bc](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b2691bcff0c04ebdbf50c58682c9ae7856870a5e)) +* ensure units update plagiarism stats for moss integration ([d59c6b8](https://github.com/doubtfire-lms/doubtfire-deploy/commit/d59c6b87cc636568244117e524824c742d2ea39b)) +* handle missing task or user details in accept submission job ([5fd31c3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5fd31c3b3a3748003331d9f79d1d48c5107310a0)) +* include grade object id in api ([a17e21a](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a17e21ac6fa0c206d55b6daff150d9d1e5836dfc)) +* redirect to new html5 site ([19daa53](https://github.com/doubtfire-lms/doubtfire-deploy/commit/19daa532777ac535833375c33f023812f9d58ab6)) + +### [8.0.38](https://github.com/macite/doubtfire-deploy/compare/v8.0.37...v8.0.38) (2024-11-06) + + +### Bug Fixes + +* ensure task definitions can be created without scorm details ([b45f0fa](https://github.com/macite/doubtfire-deploy/commit/b45f0fa15d790396f0ba3dbfebb1c059bffe622d)) + +### [8.0.37](https://github.com/macite/doubtfire-deploy/compare/v8.0.36...v8.0.37) (2024-10-25) + + +### Bug Fixes + +* enhance substitutions for ipynb ([c19e149](https://github.com/macite/doubtfire-deploy/commit/c19e14992fe81cef1a17ab1dceda4e81888a8979)) +* ensure broken aux file does not kill future pdf generation ([715ccaf](https://github.com/macite/doubtfire-deploy/commit/715ccaf101655e63e9d081ef9b364cfccae62d13)) +* improve ipynb processing ([3d24fb2](https://github.com/macite/doubtfire-deploy/commit/3d24fb25d613515ee86c625c840dcf9db2636ca9)) +* revert notebook replacements from file helper ([e41cad3](https://github.com/macite/doubtfire-deploy/commit/e41cad3d9b27733cb3c79dd61449f9922f6f07ab)) + +### [8.0.25](https://github.com/macite/doubtfire-deploy/compare/v8.0.24...v8.0.25) (2024-08-09) + + +### Bug Fixes + +* ensure schema has index for auth token type ([7d3e4d3](https://github.com/macite/doubtfire-deploy/commit/7d3e4d369e66815b422faf46f8924397600266f1)) +* ensure test attempt review exception is handled ([bb3590c](https://github.com/macite/doubtfire-deploy/commit/bb3590c14c5c66191833fa98ee6c6eeebc2a3d78)) +* remove default from cmi_datamodel in test attempt ([ccb20dc](https://github.com/macite/doubtfire-deploy/commit/ccb20dc5c1efea2e5d0331026bc17d39dda3db11)) + +### [8.0.24](https://github.com/macite/doubtfire-deploy/compare/v8.0.23...v8.0.24) (2024-08-09) + + +### Features + +* add attribute to allow file upload before scorm is passed ([fce7e75](https://github.com/macite/doubtfire-deploy/commit/fce7e7519bb9171726a030b409aee23de65f44fd)) +* add Numbas config options to task def backend ([d53610a](https://github.com/macite/doubtfire-deploy/commit/d53610a3f4b0c8077aea34cbfa2924e301914e1f)) +* add numbas task comment on test completion ([3f5aa2b](https://github.com/macite/doubtfire-deploy/commit/3f5aa2be6bd69441730375b689751fe881d7617a)) +* add test attempt auth ([7d31f7c](https://github.com/macite/doubtfire-deploy/commit/7d31f7caaae6dc1efa24f78842873e9f55796279)) +* change Numbas time delay config to enable incremental delays ([54c27ce](https://github.com/macite/doubtfire-deploy/commit/54c27cef2b8ff57fd8ac972728ec3d249e2862b8)) +* create unique token for scorm asset retrieval ([fc8134a](https://github.com/macite/doubtfire-deploy/commit/fc8134ab6b734b7daf064a67ad15f3cefba1d7d6)) +* enable reviewing, passing, and deleting test attempts ([8c9a68b](https://github.com/macite/doubtfire-deploy/commit/8c9a68ba6b3914da24ba33ee62f6a5a00e101c76)) +* enable students to request extra scorm attempt ([c5055b8](https://github.com/macite/doubtfire-deploy/commit/c5055b858c30ba693c535590e1ccff0e8e0b42da)) +* restrict test attempts by limit and comments to when test is completed ([26d75f5](https://github.com/macite/doubtfire-deploy/commit/26d75f51b7fcf11dac0834ddc5a46f40c07407de)) + + +### Bug Fixes + +* add allow review property to task def related files ([3539d95](https://github.com/macite/doubtfire-deploy/commit/3539d957022f0c6310a2939dd6eccad946cb6610)) +* add missing numbas config fields to fix unit tests ([89a6615](https://github.com/macite/doubtfire-deploy/commit/89a66157b4fde887a19912ca40261243b4961e2f)) +* add scorm bypass to excel file ([4139690](https://github.com/macite/doubtfire-deploy/commit/413969069969316f6ea9c515e4ec9da6b332be0a)) +* calculate attempt number and limit instead of using stored int ([28f3279](https://github.com/macite/doubtfire-deploy/commit/28f327964edb0c9326b487a674d19b7da7da8c89)) +* change scorm comment text ([69053ee](https://github.com/macite/doubtfire-deploy/commit/69053ee147503e7916e929aac5c834903c0087ba)) +* check for attempts before accessing properties ([4255347](https://github.com/macite/doubtfire-deploy/commit/42553479eb2a018a9273931e033084e26b3d18d5)) +* check if no old scorm tokens exist ([6108b52](https://github.com/macite/doubtfire-deploy/commit/6108b52bc04d7866548c8738b39d37c30d24f602)) +* consolidate numbas api endpoints ([27253bd](https://github.com/macite/doubtfire-deploy/commit/27253bd1b1d5640d00098f692160dd4b50675640)) +* enforce attempt limit ([d71ea14](https://github.com/macite/doubtfire-deploy/commit/d71ea14d319a59ba1e96bbd5bf34c85a21f0c0f6)) +* expose enable Numbas test config to all users ([20d5265](https://github.com/macite/doubtfire-deploy/commit/20d526533a2ecab592d7d22f3330d37cee7e0f45)) +* expose scorm configs to student ([910eecd](https://github.com/macite/doubtfire-deploy/commit/910eecdc218f52e572d39059e64a0b28acb44dce)) +* grant same number of extra attempts as scorm limit ([3d44ef2](https://github.com/macite/doubtfire-deploy/commit/3d44ef2ea57829131cc3c70d1655ccd996154ee2)) +* post scorm comment after test attempt termination ([0812e20](https://github.com/macite/doubtfire-deploy/commit/0812e206a9dadcfe7d575feec04e49b15b412556)) +* preload unit in test attempt and ensure limit flexibility in validation ([8059213](https://github.com/macite/doubtfire-deploy/commit/80592130bfb33bbb74322c5950e62e2663223af1)) +* prevent new attempt if last is incomplete or passed ([1240b3f](https://github.com/macite/doubtfire-deploy/commit/1240b3fa853d3a3f3fd1ad061f9cc6f6635c2c37)) +* prevent scorm extensions if no attempt limit ([1ae0347](https://github.com/macite/doubtfire-deploy/commit/1ae03478bb2c55b5e281a876bae37f730206ac3e)) +* refactor numbas config reset logic ([ff5ff62](https://github.com/macite/doubtfire-deploy/commit/ff5ff62061c05e509f15af3048fe047b0d69dc68)) +* rename entity file and add update fields in task spreadsheet ([b498924](https://github.com/macite/doubtfire-deploy/commit/b4989242e37ccd046651bfc8db32934ee94e190a)) +* reorder columns for csv export ([5db5f35](https://github.com/macite/doubtfire-deploy/commit/5db5f35dc6cc1874c50f5891ca7bbd752ea32b55)) +* reset Numbas configs if no zip file has been uploaded ([3f19ffa](https://github.com/macite/doubtfire-deploy/commit/3f19ffa6f4f465ed0691582b5012cf997ec62852)) +* temporarily disable auth and fix test attempt lookup ([b4d3f9d](https://github.com/macite/doubtfire-deploy/commit/b4d3f9dc1661b733eaf704c551ceb5836789db22)) +* update auth token to work with scorm and general ([e7a6eed](https://github.com/macite/doubtfire-deploy/commit/e7a6eed53d8e7049b6144e2b07b8018725be01fb)) +* use correct endpoint url and include exam result for numbas test attempts ([ee992f4](https://github.com/macite/doubtfire-deploy/commit/ee992f4218b8ca07c9259d6569c9c946af7701ef)) +* use correct Numbas data path in Numbas api ([5d80830](https://github.com/macite/doubtfire-deploy/commit/5d80830d3564bb7137db3c4adb3b1d906342e851)) +* use custom endpoint for Numbas ([0cc4915](https://github.com/macite/doubtfire-deploy/commit/0cc4915c85d7d55b48ca6832f6779e49362a7870)) +* use project and task def to fix issue where task is undefined on launching scorm test ([2a04a06](https://github.com/macite/doubtfire-deploy/commit/2a04a068282f69b11a6243a590bb25edcdd5c2c1)) +* use test attempt entity in file instead ([a7c4006](https://github.com/macite/doubtfire-deploy/commit/a7c400669bf199f30b627b54c4ed49157ff88222)) +* use unique perms for scorm test retrieval ([08a0090](https://github.com/macite/doubtfire-deploy/commit/08a00906019ce0c2706c34cf053a511b6e5ddca2)) +* validate attempt id ([c5240d8](https://github.com/macite/doubtfire-deploy/commit/c5240d8da378b84deb3ac64e1584808b07d5e671)) + +### [8.0.36](https://github.com/macite/doubtfire-deploy/compare/v8.0.35...v8.0.36) (2024-09-24) + + +### Bug Fixes + +* markdown in ipynb ([4fd4ed3](https://github.com/macite/doubtfire-deploy/commit/4fd4ed3c633e7aa4ee801e5d274c82aa840f31ac)) + +### [8.0.35](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.34...v8.0.35) (2024-09-23) + + +### Bug Fixes + +* ensure default lsr task def can be updated ([491691f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/491691f2b7b5d51c838d22299099f3725d2ee47e)) + +### [8.0.34](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.33...v8.0.34) (2024-09-21) + + +### Features + +* send latex log on convert failure ([166690f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/166690fffe8160146f734d6a7df42dda4174866b)) + + +### Bug Fixes + +* correct error reporting with AAF failure ([58b6391](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58b6391c5658b80ef4e803cd955bdde90c79d87c)) +* ensure unicode control characters work in pdf gen ([cd32b06](https://github.com/doubtfire-lms/doubtfire-deploy/commit/cd32b0672a4491bdc9b1d0ebef192abc4726ccf0)) +* migration to fix invalid task upload filenames ([ac80fec](https://github.com/doubtfire-lms/doubtfire-deploy/commit/ac80fec11a6748671821b8d82beae016464eec0b)) + +### [8.0.33](https://github.com/macite/doubtfire-deploy/compare/v8.0.32...v8.0.33) (2024-09-13) + + +### Bug Fixes + +* correct access to moss and devise secrets ([8fae9c6](https://github.com/macite/doubtfire-deploy/commit/8fae9c6f02a08e27daa141d448a4dae0ef62b241)) + +### [8.0.32](https://github.com/macite/doubtfire-deploy/compare/v8.0.31...v8.0.32) (2024-09-05) + + +### Features + +* add support for upload of vue components ([7c85aaf](https://github.com/macite/doubtfire-deploy/commit/7c85aaf1e1080554f4132bd7da18c1e67e2d2aea)) + +### [8.0.31](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.30...v8.0.31) (2024-08-29) + + +### Bug Fixes + +* ensure sidekiq logs latex errors to stdout ([78151b3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/78151b3c00f768ee83dd6838628eee2163bd6cde)) +* limit sidekiq concurrency to 1 ([0046562](https://github.com/doubtfire-lms/doubtfire-deploy/commit/004656216508f5469b234f3024c0d95a19d3b014)) +* revert delay in sidekiq pdf generation ([904ca34](https://github.com/doubtfire-lms/doubtfire-deploy/commit/904ca3432cf777f88121e1cd1cf59284c628e1cf)) + +### [8.0.30](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.29...v8.0.30) (2024-08-29) + + +### Bug Fixes + +* add short delay for accept submission job ([b3861ff](https://github.com/doubtfire-lms/doubtfire-deploy/commit/b3861ff2f44467e135a92427141844f9d33d6164)) + +### [8.0.29](https://github.com/macite/doubtfire-deploy/compare/v8.0.28...v8.0.29) (2024-08-28) + + +### Bug Fixes + +* correct email reporting of pdf errors in sidekiq ([ff2686a](https://github.com/macite/doubtfire-deploy/commit/ff2686ab0074c5f9442debdaddc9fce02dcdae54)) + +### [8.0.28](https://github.com/macite/doubtfire-deploy/compare/v8.0.27...v8.0.28) (2024-08-28) + + +### Bug Fixes + +* ensure that TII can log multiple similarity issues for each task ([55aa194](https://github.com/macite/doubtfire-deploy/commit/55aa1940b418d5bcb7d43663d5453e7cc6f8610a)) + +### [8.0.27](https://github.com/macite/doubtfire-deploy/compare/v8.0.26...v8.0.27) (2024-08-28) + + +### Bug Fixes + +* correct link to error log mailer and add test ([312f22e](https://github.com/macite/doubtfire-deploy/commit/312f22eacead8b8d666116df52a7c11e49ce1794)) + +### [8.0.26](https://github.com/macite/doubtfire-deploy/compare/v8.0.23...v8.0.26) (2024-08-26) + + +### Bug Fixes + +* logging of fail to send message in accept submission ([38abe9e](https://github.com/macite/doubtfire-deploy/commit/38abe9eeb7dedf8f7d26b7b1c659be94d9c42d4a)) +* use system timeout command with timeout helper ([b77147c](https://github.com/macite/doubtfire-deploy/commit/b77147c791396e202bbf2e01eb60385a1ae6cd7b)) + +### [8.0.23](https://github.com/macite/doubtfire-deploy/compare/v8.0.22...v8.0.23) (2024-08-05) + + +### Bug Fixes + +* ensure folders are removed when we move files with file helper ([cbec03d](https://github.com/macite/doubtfire-deploy/commit/cbec03d9e148e5d3fbead9b88621f6c06d368371)) +* remove global error and report failures to admin user for tii ([842f233](https://github.com/macite/doubtfire-deploy/commit/842f233d210345682d051e77cc3ddedb98baadc9)) + +### [8.0.22](https://github.com/macite/doubtfire-deploy/compare/v8.0.21...v8.0.22) (2024-08-01) + + +### Features + +* add email on accept submission error ([1e3acd4](https://github.com/macite/doubtfire-deploy/commit/1e3acd4e64af2f41227c03b8fa53ab71811dac20)) +* report high usage on database timeout ([8139f41](https://github.com/macite/doubtfire-deploy/commit/8139f41207a2a2b38f6560cc254d8e65bce40988)) + + +### Bug Fixes + +* add awaiting processing pdf ([3e0a1ba](https://github.com/macite/doubtfire-deploy/commit/3e0a1bac485322b6f7936fd3c244cdc5594ca9b1)) +* avoid attempts to read negative size in file stream helper ([758a51d](https://github.com/macite/doubtfire-deploy/commit/758a51dfb9f8c5bc2cf2471caf5b8c0875467971)) +* change zip of new upload to avoid loss ([218afb9](https://github.com/macite/doubtfire-deploy/commit/218afb9291b6864c3ede03b4f74bceaef81b339f)) +* ensure scoop files checks files are a hash ([33ee3ce](https://github.com/macite/doubtfire-deploy/commit/33ee3cecd6e8317cd54f51c4e1e4314725b5085c)) +* only try overseer assessment when overseer enabled ([e3d36c2](https://github.com/macite/doubtfire-deploy/commit/e3d36c27cffbeb8e56dc6c2b085665c5d91cd9ce)) + +### [8.0.21](https://github.com/macite/doubtfire-deploy/compare/v8.0.20...v8.0.21) (2024-07-30) + + +### Bug Fixes + +* delay pdf generation to ensure sufficient time for async task to run ([6753d80](https://github.com/macite/doubtfire-deploy/commit/6753d803a573c3ced9fb58ef202486cc69d3329c)) + +### [8.0.20](https://github.com/macite/doubtfire-deploy/compare/v8.0.19...v8.0.20) (2024-07-29) + + +### Bug Fixes + +* webhook registration key check ([70f095c](https://github.com/macite/doubtfire-deploy/commit/70f095c3bb762b73738344f6902cfd53e11daf0b)) + +### [8.0.19](https://github.com/macite/doubtfire-deploy/compare/v8.0.18...v8.0.19) (2024-07-26) + + +### Bug Fixes + +* ensure accept submission checks number of files ([cea12e5](https://github.com/macite/doubtfire-deploy/commit/cea12e5bee7ba7b954bdeff1c5257d2c9c9a841a)) +* remove newlines from signing key base64 encoding ([d84856b](https://github.com/macite/doubtfire-deploy/commit/d84856b8e90126e34cb34e4d405acc462af7e147)) + +### [8.0.18](https://github.com/macite/doubtfire-deploy/compare/v8.0.17...v8.0.18) (2024-07-25) + + +### Features + +* add ability to manually remove webhooks from rails console ([7e9adaa](https://github.com/macite/doubtfire-deploy/commit/7e9adaa50b8db70fb488ae3a489ad521dac5e28a)) + + +### Bug Fixes + +* ensure tii signing secret is sent as a base64 string ([efa6692](https://github.com/macite/doubtfire-deploy/commit/efa669273bc8aa56ecfced4d63ab0f9af4649273)) + +### [8.0.17](https://github.com/macite/doubtfire-deploy/compare/v8.0.16...v8.0.17) (2024-07-22) + +### [8.0.16](https://github.com/macite/doubtfire-deploy/compare/v8.0.15...v8.0.16) (2024-07-22) + + +### Bug Fixes + +* ensure comment added on task pdf convert fail ([232dcaa](https://github.com/macite/doubtfire-deploy/commit/232dcaa7c5ea11109d35bc3bd7cd9d3c737259cd)) + +### [8.0.15](https://github.com/macite/doubtfire-deploy/compare/v8.0.14...v8.0.15) (2024-07-22) + + +### Bug Fixes + +* correct turn it in hmac calculation ([a249662](https://github.com/macite/doubtfire-deploy/commit/a249662d6866a80cf03c5793bc4816a766ad2b97)) +* ensure pax header is not included in tex on 2nd pass ([1b2a43c](https://github.com/macite/doubtfire-deploy/commit/1b2a43c0bfe45019b69bbf1952373709c09b67c5)) + +### [8.0.14](https://github.com/macite/doubtfire-deploy/compare/v8.0.13...v8.0.14) (2024-07-18) + + +### Features + +* allow logging to stdout using env var ([7d47eda](https://github.com/macite/doubtfire-deploy/commit/7d47eda6affafb6056d391a101c39670e3a1b7f6)) + + +### Bug Fixes + +* add logging info to debug hmac issues ([de3ec39](https://github.com/macite/doubtfire-deploy/commit/de3ec392612470a1103f6a04c737775965e58ccf)) + +### [8.0.13](https://github.com/macite/doubtfire-deploy/compare/v8.0.12...v8.0.13) (2024-07-17) + + +### Features + +* add env var to configure log to stdout ([0bf29eb](https://github.com/macite/doubtfire-deploy/commit/0bf29eb79824cfab89a6f4ce5ce15d89f1a77ca5)) +* check that old tii submissions upload when eula accepted ([6b08013](https://github.com/macite/doubtfire-deploy/commit/6b08013b423ae990c34224fdd6c358b08026e9f0)) + + +### Bug Fixes + +* check need to register webhooks in tii action ([ebbacb9](https://github.com/macite/doubtfire-deploy/commit/ebbacb90cd1602b04489d2d41ee9723d13a75852)) +* ensure tii module looks for appropriate user ([4dae884](https://github.com/macite/doubtfire-deploy/commit/4dae884dd29bf443d64654e36134e09e570ce31e)) +* ensure webhook test will register hooks ([be21763](https://github.com/macite/doubtfire-deploy/commit/be21763e2b486df0181da1a87ffbddcfb7407388)) +* limit tii action log to 25 entries ([03e9214](https://github.com/macite/doubtfire-deploy/commit/03e9214182e07561100b051cbed6e82191cc8750)) +* merge student records for deakin students ([4f3979b](https://github.com/macite/doubtfire-deploy/commit/4f3979ba4c00a0040f4899e33e48cd950cb6e833)) +* tii action retry resets retries ([789fbad](https://github.com/macite/doubtfire-deploy/commit/789fbada30f8d91cfaff732a4392ecb12d346e3f)) + +### [8.0.12](https://github.com/macite/doubtfire-deploy/compare/v8.0.11...v8.0.12) (2024-07-15) + + +### Features + +* allow register webhooks to be controlled via config ([e01ed19](https://github.com/macite/doubtfire-deploy/commit/e01ed1940ecc7f91c66ea9d22ebbacae04ce7b70)) + +### [8.0.11](https://github.com/macite/doubtfire-deploy/compare/v8.0.10...v8.0.11) (2024-07-12) + + +### Features + +* ensure deakin sync retries failed connections ([d4808b0](https://github.com/macite/doubtfire-deploy/commit/d4808b0f9d2653a02e56f868a9f0d9bec6e53826)) + +### [8.0.10](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.9...v8.0.10) (2024-07-10) + + +### Bug Fixes + +* ensure failure to send email is handled ([32b1d9f](https://github.com/doubtfire-lms/doubtfire-deploy/commit/32b1d9f94c225e326ed7fbc111565fa75de3ec00)) +* ensure logger only logs to stdout in development ([e3fab0d](https://github.com/doubtfire-lms/doubtfire-deploy/commit/e3fab0d897bac82dcc14d3ff4b3948245a203b1c)) +* ensure sidekiq moves to Rails root before task pdf creation ([bb29f84](https://github.com/doubtfire-lms/doubtfire-deploy/commit/bb29f84c8c4808886cf84b89069a622308d7b859)) +* ensure task definitions render when upload requirements are nil ([6373eee](https://github.com/doubtfire-lms/doubtfire-deploy/commit/6373eee8ab38f5b1c79be5e88302c1880e36cc90)) +* ensure turn it in actions only occur when tii enabled ([5b8f5d3](https://github.com/doubtfire-lms/doubtfire-deploy/commit/5b8f5d35f520f7e59ddfe53d795200f45882c517)) +* guard access of pwd incase pwd is invalid ([58d8281](https://github.com/doubtfire-lms/doubtfire-deploy/commit/58d828193ee4448df15d4fcc391d2a1a22338efc)) +* turn it in enabled property ([a49fc8c](https://github.com/doubtfire-lms/doubtfire-deploy/commit/a49fc8c042d608f109706278f933071e0f058ed2)) + +### [8.0.9](https://github.com/macite/doubtfire-deploy/compare/v8.0.8...v8.0.9) (2024-07-03) + + +### Features + +* allow new unit code to be provided to rollover ([7f3b752](https://github.com/macite/doubtfire-deploy/commit/7f3b7529a9c8ee0a8800e28aa1504f221f80bc5d)) + + +### Bug Fixes + +* ensure main convenor validation on change only ([52450be](https://github.com/macite/doubtfire-deploy/commit/52450bec9039fda80f6f8a6d3a742adc8def8d77)) +* remove rollover teaching period ([eacbac1](https://github.com/macite/doubtfire-deploy/commit/eacbac1f659e09252ab24a4fc9e0d5a02d811a00)) +* streamline archiving units in maintenance task ([e740d82](https://github.com/macite/doubtfire-deploy/commit/e740d8218478b6ef27795fc15093082c07e0c69a)) + +### [8.0.8](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.7...v8.0.8) (2024-07-01) + + +### Features + +* provide task to archive pdfs ([9e85c21](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e85c2186880a374d5306a8aa4e6eccc108239ff)) + +## [8.1.0](https://github.com/doubtfire-lms/doubtfire-deploy/compare/v8.0.7...v8.1.0) (2024-07-01) + + +### Features + +* provide task to archive pdfs ([9e85c21](https://github.com/doubtfire-lms/doubtfire-deploy/commit/9e85c2186880a374d5306a8aa4e6eccc108239ff)) + +### [8.0.7](https://github.com/macite/doubtfire-deploy/compare/v8.0.6...v8.0.7) (2024-07-01) + + +### Bug Fixes + +* remove sync of online students at deakin ([2b64bce](https://github.com/macite/doubtfire-deploy/commit/2b64bcef3b74882403d2b04a9da80a7d0e8c68b6)) + +### [8.0.6](https://github.com/macite/doubtfire-deploy/compare/v8.0.5...v8.0.6) (2024-06-28) + + +### Bug Fixes + +* ensure upload requirements works in edit ([61f35ce](https://github.com/macite/doubtfire-deploy/commit/61f35cecf8b4af9ab520bfcc9bde72a0d11c7481)) + +### [8.0.5](https://github.com/macite/doubtfire-deploy/compare/v8.0.4...v8.0.5) (2024-06-27) + + +### Bug Fixes + +* ensure new units can have a different main convenor ([44b6566](https://github.com/macite/doubtfire-deploy/commit/44b656605e44a529078656ba9174b843056e0e31)) + +### [8.0.4](https://github.com/macite/doubtfire-deploy/compare/v8.0.3...v8.0.4) (2024-06-27) + + +### Bug Fixes + +* ensure unit recode results in file moves ([4d0c10f](https://github.com/macite/doubtfire-deploy/commit/4d0c10faff97db01c2f952f4afd66f02af283bb5)) + +### [8.0.3](https://github.com/macite/doubtfire-deploy/compare/v8.0.2...v8.0.3) (2024-06-25) + + +### Bug Fixes + +* export task definition to csv ([793b734](https://github.com/macite/doubtfire-deploy/commit/793b73466fa468f1cb51ed69a07d1c8701dff3a8)) +* limit exposure of nil for task def fields ([fc1bcfd](https://github.com/macite/doubtfire-deploy/commit/fc1bcfd88407f877d6ed7fadc6f70a8dad0e279f)) + +### [8.0.2](https://github.com/macite/doubtfire-deploy/compare/v8.0.1...v8.0.2) (2024-06-21) + + +### Bug Fixes + +* ensure file stream has a string path ([fa5ca52](https://github.com/macite/doubtfire-deploy/commit/fa5ca52b1e2470fbb2537e15259f30946b1e8a54)) + +### [8.0.1](https://github.com/macite/doubtfire-deploy/compare/v7.0.32...v8.0.1) (2024-06-21) + + +### Bug Fixes + +* correct handling of group submissions ([931c9dd](https://github.com/macite/doubtfire-deploy/commit/931c9dd4280e31e935f796bf1d349add1b431c63)) +* correct ipynb code ([9e2056d](https://github.com/macite/doubtfire-deploy/commit/9e2056d8d721325683d115db2356fbca8f7380c7)) +* correct issues with missing rsvg convert and identified test problems ([2024350](https://github.com/macite/doubtfire-deploy/commit/2024350f8080928597bad2b00f6aacd7a6a1be1f)) +* correct merge issues to ensure tests pass ([192bd41](https://github.com/macite/doubtfire-deploy/commit/192bd4175607f8ac2efa1acb6f029883a3bdcea1)) +* correct typos in unit role needed for teaching role ([f808ad4](https://github.com/macite/doubtfire-deploy/commit/f808ad437f424f40a4eb68d5218ddf4317ba44b6)) +* ensure error reported when viewer not available ([2aaacb6](https://github.com/macite/doubtfire-deploy/commit/2aaacb6e9c181b260e9c7f62f362dd1da2ab98ad)) +* ensure ipynb handles markdown, raw, and long output ([955ca0b](https://github.com/macite/doubtfire-deploy/commit/955ca0bf844ad673a445e04012e6950a07f748d8)) +* handle long, raw, and markdown ipynb ([609b49b](https://github.com/macite/doubtfire-deploy/commit/609b49bf1b73af9eeeb66e4788c7d6dffbca94fa)) +* limit to 3 group attachments in tii upload ([5252639](https://github.com/macite/doubtfire-deploy/commit/525263903a11af362d78b82c8065a665024a3a1f)) +* reinstate teaching staff ids ([167eb1a](https://github.com/macite/doubtfire-deploy/commit/167eb1a144ad667d00a8b7c9a469115943048fc4)) +* task file import ([#438](https://github.com/macite/doubtfire-deploy/issues/438)) ([8f37943](https://github.com/macite/doubtfire-deploy/commit/8f379430fd48b0449ef21f680165e4323cad1750)) +* truncate long lines in PDF conversion ([#439](https://github.com/macite/doubtfire-deploy/issues/439)) ([2425997](https://github.com/macite/doubtfire-deploy/commit/2425997305afb4f6a7964a7cd689a04418828ea1)) + +## [8.0.0-11](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-10...v8.0.0-11) (2024-05-13) + +## [8.0.0-10](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-9...v8.0.0-10) (2024-05-13) + + +### Bug Fixes + +* host url for turn it in integration ([3cd67d7](https://github.com/macite/doubtfire-deploy/commit/3cd67d7c58916cda429d3c0266942cfc2c0ef878)) + +## [8.0.0-9](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-8...v8.0.0-9) (2024-05-11) + + +### Bug Fixes + +* ensure default log in tii actions ([a9959fe](https://github.com/macite/doubtfire-deploy/commit/a9959fef2223ffca41338b15c973b888253225bf)) +* ensure tii launch handles errors so rails can progress ([d7c9c3c](https://github.com/macite/doubtfire-deploy/commit/d7c9c3c8c60b49721aa9cac1f8df6ec716b82422)) +* revert to default cache store ([c3a22bf](https://github.com/macite/doubtfire-deploy/commit/c3a22bfee6e9912fd8b4d331d6a6e6f350b72ffa)) + +## [8.0.0-8](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-7...v8.0.0-8) (2024-05-11) + + +### Bug Fixes + +* adjust log and params in tii_actions ([4bfdfb1](https://github.com/macite/doubtfire-deploy/commit/4bfdfb1faf5bbaf45ec7827ec89e4cd231f88dba)) +* display latex math properly in jupyter notebooks ([ba6d615](https://github.com/macite/doubtfire-deploy/commit/ba6d61506a5f699aed299658f9a664123fdaf57b)) +* update for dotenv 3 ([ef8611f](https://github.com/macite/doubtfire-deploy/commit/ef8611f917b198064a891f81c02408ff081e977b)) + +## [8.0.0-7](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-6...v8.0.0-7) (2024-05-02) + +## [8.0.0-6](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-5...v8.0.0-6) (2024-05-02) + + +### Bug Fixes + +* revert to doubtfire local image for unit tests ([73fcbe3](https://github.com/macite/doubtfire-deploy/commit/73fcbe3f5adb603253033e7b126502a5d3c006f1)) + +## [8.0.0-5](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-4...v8.0.0-5) (2024-05-02) + + +### Bug Fixes + +* correct updates in TII migration ([d1ab30b](https://github.com/macite/doubtfire-deploy/commit/d1ab30ba666898f556b69db53766124742b4f593)) + +## [8.0.0-4](https://github.com/macite/doubtfire-deploy/compare/v7.0.24...v8.0.0-4) (2024-05-01) + + +### Features + +* add the pdf-reader gem for validating pdf submissions ([71c845b](https://github.com/macite/doubtfire-deploy/commit/71c845bf28fccf28de17ed83e3da1cf243646e7b)) +* implement unit test for pdf validation on submit ([57db1dc](https://github.com/macite/doubtfire-deploy/commit/57db1dc57a75aaf211030ecf78b9252a5d8b583b)) +* improve pdf file validation and detect encrypted pdfs ([dd729cf](https://github.com/macite/doubtfire-deploy/commit/dd729cf31bec115bd0e7018f33a692bd35bb5519)) + + +### Bug Fixes + +* add missing moss language in task def post ([1fa7b0b](https://github.com/macite/doubtfire-deploy/commit/1fa7b0b10f855bc2e23aa27e78ed41ff3e5b4683)) +* add redis to the github actions workflow ([9935720](https://github.com/macite/doubtfire-deploy/commit/99357205d42d148f3a6165a96122691680409092)) +* correct tii migrationm defaults ([2beb6e8](https://github.com/macite/doubtfire-deploy/commit/2beb6e8599cf6b99992e34548615e7802e7ff141)) +* document two new env variables for redis ([749903f](https://github.com/macite/doubtfire-deploy/commit/749903f390a388fac2c2a8652975580611f1e072)) +* implement error reporting in database populator ([136b9f9](https://github.com/macite/doubtfire-deploy/commit/136b9f98151688d3d6a578db1f980b39b3e21514)) +* install ruby-lsp in the development environment ([c57290e](https://github.com/macite/doubtfire-deploy/commit/c57290e4b2f7ba1bab7d600965988489dc3dd5a4)) +* pick up redis url from env for sidekiq if present ([e9628eb](https://github.com/macite/doubtfire-deploy/commit/e9628eb31398719d78508a610a251a785f56a14f)) +* remove plagiarism checks field ([19107bf](https://github.com/macite/doubtfire-deploy/commit/19107bf87601115ea15036f25634b2ba30e23c7c)) +* remove serialisation of plagiarism checks ([1962cc9](https://github.com/macite/doubtfire-deploy/commit/1962cc96ff46134d756984473d1022792e9ada1a)) +* skip unit tests and linting for documentation updates ([2503fe6](https://github.com/macite/doubtfire-deploy/commit/2503fe61468f8ebe37e54ccb1d0cc2a11387949b)) + +## [8.0.0-3](https://github.com/macite/doubtfire-deploy/compare/v7.0.23...v8.0.0-3) (2024-03-22) + + +### Bug Fixes + +* ensure redis is in dockerfile ([c37f5ba](https://github.com/macite/doubtfire-deploy/commit/c37f5ba0e78fbbb6fb1f7423f8b1bbabb557f761)) +* revert new tii action field to text from json ([72a8f18](https://github.com/macite/doubtfire-deploy/commit/72a8f18c31a3b5476f4f7b414b54cf47e3db8087)) + +## [8.0.0-2](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-1...v8.0.0-2) (2024-03-22) + + +### Bug Fixes + +* remove switch to json db format ([1b789a2](https://github.com/macite/doubtfire-deploy/commit/1b789a2194b745a6f91f8989c12c9387932ca70c)) + +## [8.0.0-1](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-0...v8.0.0-1) (2024-03-21) + +## [8.0.0-0](https://github.com/macite/doubtfire-deploy/compare/v7.0.22...v8.0.0-0) (2024-03-21) + + +### Features + +* add ability to adjust similarity flag ([339acf8](https://github.com/macite/doubtfire-deploy/commit/339acf8d741ff8d53b9cf1bb7a00e88907d8a183)) +* add ability to fetch tii viewer url ([002bb07](https://github.com/macite/doubtfire-deploy/commit/002bb07972f9364eea74563af66efa8f783c2c2e)) +* add api to interact with tii group attachments ([f286302](https://github.com/macite/doubtfire-deploy/commit/f2863020a3619f097c182c98b1392e5c05e7c7c0)) +* add similarity report webhook ([07157f9](https://github.com/macite/doubtfire-deploy/commit/07157f9f0c6bbbc8d07b37807562b0ec00606c97)) +* add submission tii hook ([3f1c8ca](https://github.com/macite/doubtfire-deploy/commit/3f1c8ca1e9ef25b010c7a9061b986ce514775a71)) +* add tii submission to enable retry ([e38e884](https://github.com/macite/doubtfire-deploy/commit/e38e88423779a0d92cfe22eb96cc4e3d5ac07582)) +* add upload tii group attachment ([aa10e35](https://github.com/macite/doubtfire-deploy/commit/aa10e35c45920ef0504dd9073a1c38470cae39da)) +* allow score to 100 for tasks ([757d184](https://github.com/macite/doubtfire-deploy/commit/757d1845a48f5ec967b7094940b50e2bd4478ab4)) +* asynchronously process submissions ([5a1ab9c](https://github.com/macite/doubtfire-deploy/commit/5a1ab9c051e054f7a30f6a1958c78f14b226d365)) +* cache tii details in files ([ae2208c](https://github.com/macite/doubtfire-deploy/commit/ae2208c344c0959382e833557ccc69227e471da3)) +* can fetch and retry tii actions ([81ee714](https://github.com/macite/doubtfire-deploy/commit/81ee714c7c0043502100758accc3d526ee3d73e9)) +* check tii features ([21a0fcc](https://github.com/macite/doubtfire-deploy/commit/21a0fcc324f184e0de416fa7091f1e57ba33f329)) +* delay generation for a short period to allow sidekiq to handle ([a53a998](https://github.com/macite/doubtfire-deploy/commit/a53a9980c56556b3e65321ac8e0ca455ea9f2ce6)) +* ensure correct error when no token ([0aa8e71](https://github.com/macite/doubtfire-deploy/commit/0aa8e7130551a2f6e4c76302179a13aadb720344)) +* ensure eula loads from file where possible ([aa4d7e8](https://github.com/macite/doubtfire-deploy/commit/aa4d7e86649f60cce5ebbbcb5f403b64e31315f9)) +* ensure only high similarity for tii reported ([4c4e55a](https://github.com/macite/doubtfire-deploy/commit/4c4e55a9b8febc2d18954a4baf8de1d5993341de)) +* ensure turn it in viewer only available when report ready ([124558f](https://github.com/macite/doubtfire-deploy/commit/124558f6c2c28d14e8fd47fb7e5ab0fac09425d0)) +* move cache to redis to share across instances ([63ab5b2](https://github.com/macite/doubtfire-deploy/commit/63ab5b27a8b142209dd6bcf9aa148ada56a04a91)) +* pdf report web hook ([355b375](https://github.com/macite/doubtfire-deploy/commit/355b37582b2d430d66c2aafc1cc545c049fd5d78)) +* record max similarity percent and flag high tii submissions ([9f56be9](https://github.com/macite/doubtfire-deploy/commit/9f56be9a1fc562f0429ce73b7aab0ce4b7775c23)) +* record overall match percent in tii submission ([f0bd981](https://github.com/macite/doubtfire-deploy/commit/f0bd981ffe6aa967093ed7ce992b37a81aa7a4e3)) +* register turn it in webhooks ([b3fbc45](https://github.com/macite/doubtfire-deploy/commit/b3fbc45c759b34f7ab31bc251e1f441e3a0dbe36)) +* report tii presence via settings api ([5354584](https://github.com/macite/doubtfire-deploy/commit/5354584db125c4186cf23643ad672fadab367f9d)) +* report tii upload action status ([6dadc16](https://github.com/macite/doubtfire-deploy/commit/6dadc1630d5a88dbba69c3d05092a25d21653fd2)) +* trigger tii group attachment on change ([4adee6b](https://github.com/macite/doubtfire-deploy/commit/4adee6bafa71b9a2db91970932a84a517bf72c2c)) +* update group on due date change ([98187f1](https://github.com/macite/doubtfire-deploy/commit/98187f1b5c4a5751ddd2c051e93cfc9cdba6fbfd)) + + +### Bug Fixes + +* add description to tii actions ([039ca1a](https://github.com/macite/doubtfire-deploy/commit/039ca1a40f6ccfeb671de5f2400a58b71c210b5d)) +* change load of tii eula and feature to use file cache ([d17c5d6](https://github.com/macite/doubtfire-deploy/commit/d17c5d6fa69a7916a19807fe645bdcb3b8c1f428)) +* change tii batch upload to limit submission rate ([984524f](https://github.com/macite/doubtfire-deploy/commit/984524fbc3c04c04337137672554c25c8ea6c0de)) +* correct latex packages for texlive 2024 ([1e52ea5](https://github.com/macite/doubtfire-deploy/commit/1e52ea5a01e514f29eabae6ee6de655dd527ed79)) +* create missing portfolios ([259baa6](https://github.com/macite/doubtfire-deploy/commit/259baa6dd863122eccaec3d88d7fdfaaf1bb97e4)) +* ensure endpoint can accept eula ([f8a69a7](https://github.com/macite/doubtfire-deploy/commit/f8a69a72bf9a8a425d4f0d63fa1f36112390ac8a)) +* ensure file download returns something ([3439bab](https://github.com/macite/doubtfire-deploy/commit/3439babcd521155f1c78d76f71717c0645c23d95)) +* ensure similarities without files work in ui ([bbbedb7](https://github.com/macite/doubtfire-deploy/commit/bbbedb7e2dfecf96c7a31e628e6f3824bdbb033d)) +* ensure staff before tutorial data ([953068e](https://github.com/macite/doubtfire-deploy/commit/953068e219df6c3722a44389deca837d3cd47380)) +* ensure tests work and address tii check list items ([3dc5cb1](https://github.com/macite/doubtfire-deploy/commit/3dc5cb1f37ee1707da8b3f42f39b1bd43b2e5f09)) +* ensure tii initializer loads correctly ([0339ce5](https://github.com/macite/doubtfire-deploy/commit/0339ce5e1fa76e82bb82bf2b516f1ae990944e27)) +* ensure we can get the report url for moss reports ([582d13a](https://github.com/macite/doubtfire-deploy/commit/582d13a292f56e7d6294fb636b1aeb4228b98426)) +* ensure we do not ask to accept eula if not required ([3df2ade](https://github.com/macite/doubtfire-deploy/commit/3df2ade168970741b80413f396dbb6b312124cf0)) +* ensure we send indexing and eula details in viewer and submissions ([38d4059](https://github.com/macite/doubtfire-deploy/commit/38d4059bf2f301896c27fea83d635b496c218a0a)) +* eula link in upload action ([96e8bce](https://github.com/macite/doubtfire-deploy/commit/96e8bce8354026865a7792a62acc8898c7d18dee)) +* get tii user details for viewer url ([c7de571](https://github.com/macite/doubtfire-deploy/commit/c7de57158aa630fe62f430c247ad277baba49e5a)) +* no auth mirrors timeout ([b83f09c](https://github.com/macite/doubtfire-deploy/commit/b83f09c3b10f3d93216c34d1adb642d43c82476b)) +* only admin can retry tii actions ([7e019cc](https://github.com/macite/doubtfire-deploy/commit/7e019cc34005d8de6b69f6c4475a79e4e2ceff37)) +* remove debugging ([c6d067a](https://github.com/macite/doubtfire-deploy/commit/c6d067aed1dd6b283615a12706a7e9ed4c452052)) +* remove max pct similar ([87bc428](https://github.com/macite/doubtfire-deploy/commit/87bc42888d28fe6911fd25281845e54ab290bd8f)) +* rescue missing action in job ([ea84ac2](https://github.com/macite/doubtfire-deploy/commit/ea84ac21fbfbf72cb476458261f943e1a8ddbf4f)) +* simulate signoff adds similarities ([74a74e0](https://github.com/macite/doubtfire-deploy/commit/74a74e07dc7a61d56472e19ca49787d8ec2890cb)) +* update save status on actions ([096aee6](https://github.com/macite/doubtfire-deploy/commit/096aee685d2cf0652092b4f87a319de73e1dd1e9)) +* update schema to match migration dates ([5c1afe4](https://github.com/macite/doubtfire-deploy/commit/5c1afe421dd41b8f56284e776ce39996b3f28d71)) + ## [8.0.0](https://github.com/macite/doubtfire-deploy/compare/v8.0.0-11...v8.0.0) (2024-05-23) diff --git a/Dockerfile b/Dockerfile index f856303670..cb98ae2be8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ -FROM ruby:3.1-bullseye +FROM ruby:3.4-bookworm # DEBIAN_FRONTEND=noninteractive is required to install tzdata in non interactive way -ENV DEBIAN_FRONTEND noninteractive +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common \ - && curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \ - && add-apt-repository "deb [arch=amd64,arm64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list \ && curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg \ && echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/redis.list @@ -24,7 +26,6 @@ RUN apt-get update \ wget \ redis \ libc6-dev \ - librsvg2-bin \ docker-ce \ docker-ce-cli \ containerd.io \ @@ -34,10 +35,8 @@ RUN apt-get update \ WORKDIR /doubtfire COPY ./.ci-setup/ /doubtfire/.ci-setup/ -RUN ./.ci-setup/texlive-install.sh -ENV PATH /tmp/texlive/bin/x86_64-linux:/tmp/texlive/bin/aarch64-linux:$PATH -RUN gem install bundler -v '2.4.5' +RUN gem install bundler -v '2.6.6' COPY Gemfile /doubtfire/Gemfile COPY Gemfile.lock /doubtfire/Gemfile.lock @@ -55,5 +54,5 @@ COPY . . EXPOSE 3000 -ENV RAILS_ENV development -CMD rm -f tmp/pids/server.pid && bundle exec rake db:migrate && bundle exec rails s -b 0.0.0.0 +ENV RAILS_ENV=development +CMD rm -f tmp/pids/server.pid && bundle exec rake db:migrate && bundle exec rails s -b 0.0.0.0 diff --git a/Gemfile b/Gemfile index 51477714ab..11e0e2fb03 100644 --- a/Gemfile +++ b/Gemfile @@ -4,25 +4,27 @@ source 'https://rubygems.org' # Ruby versions for various enviornments ruby_versions = { - development: '~>3.1.0', - test: '~>3.1.0', - staging: '~>3.1.0', - production: '~>3.1.0' + development: '~>3.4.0', + test: '~>3.4.0', + staging: '~>3.4.0', + production: '~>3.4.0' } # Get the ruby version for the current enviornment ruby ruby_versions[(ENV['RAILS_ENV'] || 'development').to_sym] # The venerable, almighty Rails -gem 'rails', '~>7.0' +gem 'rails', '~> 8.0.0', '>= 8.0.5.1' group :development, :test do gem 'better_errors' gem 'byebug' - gem 'database_cleaner-active_record' gem 'listen' gem 'rails_best_practices' gem 'rubocop' + gem 'rubocop-factory_bot' gem 'rubocop-faker' + gem 'rubocop-minitest' + gem 'rubocop-performance' gem 'rubocop-rails' gem 'ruby-lsp' gem 'simplecov', require: false @@ -45,9 +47,10 @@ end gem 'mysql2' # Webserver - included in development and test and optionally in production -gem 'puma' +gem 'puma', '~> 7.2', '>= 7.2.1' gem 'bootsnap', require: false +gem 'csv' # Extend irb for better output gem 'hirb' @@ -56,7 +59,8 @@ gem 'hirb' gem 'devise' gem 'devise_ldap_authenticatable' gem 'json-jwt' -gem 'ruby-saml', '~> 1.13.0' +gem 'rack-attack', '~> 6.8' +gem 'ruby-saml' # Student submission gem 'coderay' @@ -65,10 +69,10 @@ gem 'ruby-filemagic' gem 'rubyzip' # Plagarism detection -gem 'moss_ruby', '>= 1.1.4' +gem 'moss_ruby' # Latex -gem 'rails-latex', '>2.3' +gem 'rails-latex' # API gem 'grape' @@ -84,7 +88,7 @@ gem 'rack-cors', require: 'rack/cors' gem 'require_all', '>=1.3.3' # Excel support -gem 'roo', '~> 2.7.0' +gem 'roo' gem 'roo-xls' # webcal generation @@ -95,11 +99,14 @@ gem 'rest-client' gem 'net-smtp', require: false # Turn it in -gem 'tca_client', '1.0.4' +gem 'tca_client' # Async jobs +gem 'ice_cube' gem 'sidekiq' gem 'sidekiq-cron' +gem 'sidekiq-status' +gem 'sidekiq-unique-jobs' # Redis for sidekiq, caching, and action cable (eventually) gem 'redis' @@ -109,3 +116,19 @@ gem 'shellwords' # PDF reader for validating PDF file submissions gem 'pdf-reader' + +# oauth gem for OAuth2 authentication - D2L +gem 'oauth2' + +gem "sys-filesystem" + +gem "sentry-rails" +gem "sentry-ruby" + +# Web push notifications. Signs and encrypts payloads for the browser push +# services (VAPID). See docs/notifications/push-setup.md. +# +# Pinned exactly so a future dependency update cannot unexpectedly move JWT to +# a new major version. web-push 3.0.1 still supports jwt ~> 2.0 and replaces the +# retired hkdf dependency with OpenSSL::KDF; JWT 3 is introduced by 3.0.2. +gem 'web-push', '3.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 58ca5d5924..c325baf15d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,123 +1,126 @@ GEM remote: https://rubygems.org/ specs: - Ascii85 (1.1.1) - actioncable (7.1.3.3) - actionpack (= 7.1.3.3) - activesupport (= 7.1.3.3) + Ascii85 (2.0.1) + actioncable (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.3) - actionpack (= 7.1.3.3) - activejob (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3.3) - actionpack (= 7.1.3.3) - actionview (= 7.1.3.3) - activejob (= 7.1.3.3) - activesupport (= 7.1.3.3) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) + mail (>= 2.8.0) + actionmailer (8.0.5.1) + actionpack (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activesupport (= 8.0.5.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3.3) - actionview (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionpack (8.0.5.1) + actionview (= 8.0.5.1) + activesupport (= 8.0.5.1) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.3) - actionpack (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + useragent (~> 0.16) + actiontext (8.0.5.1) + actionpack (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.3) - activesupport (= 7.1.3.3) + actionview (8.0.5.1) + activesupport (= 8.0.5.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3.3) - activesupport (= 7.1.3.3) + activejob (8.0.5.1) + activesupport (= 8.0.5.1) globalid (>= 0.3.6) - activemodel (7.1.3.3) - activesupport (= 7.1.3.3) - activerecord (7.1.3.3) - activemodel (= 7.1.3.3) - activesupport (= 7.1.3.3) + activemodel (8.0.5.1) + activesupport (= 8.0.5.1) + activerecord (8.0.5.1) + activemodel (= 8.0.5.1) + activesupport (= 8.0.5.1) timeout (>= 0.4.0) - activestorage (7.1.3.3) - actionpack (= 7.1.3.3) - activejob (= 7.1.3.3) - activerecord (= 7.1.3.3) - activesupport (= 7.1.3.3) + activestorage (8.0.5.1) + actionpack (= 8.0.5.1) + activejob (= 8.0.5.1) + activerecord (= 8.0.5.1) + activesupport (= 8.0.5.1) marcel (~> 1.0) - activesupport (7.1.3.3) + activesupport (8.0.5.1) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) afm (0.2.2) - amq-protocol (2.3.2) - ast (2.4.2) + amq-protocol (2.3.3) + anonymous_loader (0.1.3) + version_gem (~> 1.1, >= 1.1.14) + ast (2.4.3) + auth-sanitizer (0.2.3) + version_gem (~> 1.1, >= 1.1.14) backport (1.2.0) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.3.0) + benchmark (0.4.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.1.8) + bigdecimal (3.1.9) bindata (2.5.0) - bootsnap (1.18.3) + bootsnap (1.18.4) msgpack (~> 1.2) - builder (3.2.4) - bunny (2.22.0) - amq-protocol (~> 2.3, >= 2.3.1) + builder (3.3.0) + bunny (2.24.0) + amq-protocol (~> 2.3) sorted_set (~> 1, >= 1.0.2) bunny-pub-sub (0.5.2) bunny (~> 2.14) - byebug (11.1.3) + byebug (12.0.0) + chronic_duration (0.10.6) + numerizer (~> 0.1.1) ci_reporter (2.1.0) builder (>= 2.1.2) rexml code_analyzer (0.5.5) sexp_processor coderay (1.1.3) - concurrent-ruby (1.3.1) - connection_pool (2.4.1) + concurrent-ruby (1.3.8) + connection_pool (2.5.0) crack (1.0.0) bigdecimal rexml - crass (1.0.6) - database_cleaner-active_record (2.1.0) - activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) - database_cleaner-core (2.0.1) - date (3.3.4) + crass (1.0.7) + cronex (0.15.0) + tzinfo + unicode (>= 0.4.4.5) + csv (3.3.3) + date (3.5.1) devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -127,100 +130,111 @@ GEM devise_ldap_authenticatable (0.8.7) devise (>= 3.4.1) net-ldap (>= 0.16.0) - diff-lcs (1.5.1) - docile (1.4.0) + diff-lcs (1.6.1) + docile (1.4.1) domain_name (0.6.20240107) - dotenv (3.1.2) + dotenv (3.1.7) drb (2.2.1) - dry-core (1.0.1) + dry-core (1.1.0) concurrent-ruby (~> 1.0) + logger zeitwerk (~> 2.6) - dry-inflector (1.0.0) - dry-logic (1.5.0) + dry-inflector (1.2.0) + dry-logic (1.6.0) + bigdecimal concurrent-ruby (~> 1.0) - dry-core (~> 1.0, < 2) + dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-types (1.7.2) + dry-types (1.8.2) bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) - e2mmap (0.1.0) - erubi (1.12.0) + erubi (1.13.1) erubis (2.7.0) et-orbi (1.2.11) tzinfo ethon (0.16.0) ffi (>= 1.15.0) - factory_bot (6.4.6) - activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) + factory_bot (6.5.1) + activesupport (>= 6.1.0) + factory_bot_rails (6.4.4) + factory_bot (~> 6.5) railties (>= 5.0.0) - faker (3.4.1) + faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.9.0) - faraday-net_http (>= 2.0, < 3.2) + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) - faraday-net_http (3.1.0) - net-http - ffi (1.17.0-aarch64-linux-gnu) - fugit (1.11.0) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-x86_64-linux-gnu) + fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - grape (2.0.0) - activesupport (>= 5) - builder + grape (2.3.0) + activesupport (>= 6) dry-types (>= 1.1) - mustermann-grape (~> 1.0.0) - rack (>= 1.3.0) - rack-accept + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk grape-entity (1.0.1) activesupport (>= 3.0.0) multi_json (>= 1.3.2) - grape-swagger (2.1.0) + grape-swagger (2.1.2) grape (>= 1.7, < 3.0) rack-test (~> 2) - grape-swagger-rails (0.5.0) + grape-swagger-rails (0.6.0) + ostruct railties (>= 6.0.6.1) - hashdiff (1.1.0) + hashdiff (1.1.2) hashery (2.1.2) + hashie (5.0.0) hirb (0.7.3) http-accept (1.7.0) - http-cookie (1.0.6) + http-cookie (1.0.8) domain_name (~> 0.5) - i18n (1.14.5) + i18n (1.14.7) concurrent-ruby (~> 1.0) - icalendar (2.10.1) + icalendar (2.10.3) ice_cube (~> 0.16) - ice_cube (0.16.4) - io-console (0.7.2) - irb (1.13.1) + ostruct + ice_cube (0.17.0) + io-console (0.8.0) + irb (1.15.1) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jaro_winkler (1.5.6) - json (2.7.2) - json-jwt (1.16.6) + jaro_winkler (1.6.0) + json (2.10.2) + json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap base64 bindata faraday (~> 2.0) faraday-follow_redirects - kramdown (2.4.0) - rexml + jwt (2.10.3) + base64 + kramdown (2.5.1) + rexml (>= 3.3.9) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - loofah (2.22.0) + logger (1.7.0) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -229,29 +243,31 @@ GEM net-pop net-smtp marcel (1.0.4) - mime-types (3.5.2) + mime-types (3.6.2) + logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.0507) + mime-types-data (3.2025.0325) mini_mime (1.1.5) - minitest (5.23.1) + minitest (5.25.5) minitest-around (0.5.0) minitest (~> 5.0) - minitest-rails (7.1.0) + minitest-rails (8.0.0) minitest (~> 5.20) - railties (~> 7.1.0) + railties (>= 8.0.0, < 8.1.0) moss_ruby (1.1.4) tcp_timeout (~> 0.1.1) - msgpack (1.7.2) + msgpack (1.8.0) multi_json (1.15.0) - mustermann (3.0.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + mustermann (3.0.3) ruby2_keywords (~> 0.0.1) - mustermann-grape (1.0.2) + mustermann-grape (1.1.0) mustermann (>= 1.0.0) - mutex_m (0.2.0) mysql2 (0.5.6) - net-http (0.4.1) + net-http (0.6.0) uri - net-imap (0.4.12) + net-imap (0.6.6) date net-protocol net-ldap (0.19.0) @@ -259,68 +275,87 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol netrc (0.11.0) - nio4r (2.7.3) - nokogiri (1.16.5-aarch64-linux) + nio4r (2.7.4) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) + numerizer (0.1.1) + oauth2 (2.0.25) + anonymous_loader (~> 0.1, >= 0.1.3) + auth-sanitizer (~> 0.2, >= 0.2.3) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.7) + version_gem (~> 1.1, >= 1.1.14) observer (0.1.2) + openssl (3.3.3) orm_adapter (0.5.0) - parallel (1.24.0) - parser (3.3.2.0) + ostruct (0.6.1) + parallel (1.26.3) + parser (3.3.7.4) ast (~> 2.4.1) racc - pdf-reader (2.12.0) - Ascii85 (~> 1.0) + pdf-reader (2.14.1) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) afm (~> 0.2.1) hashery (~> 2.0) ruby-rc4 ttfunk - pkg-config (1.5.6) - prism (0.29.0) - psych (5.1.2) + pkg-config (1.6.0) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + psych (5.2.3) + date stringio - public_suffix (5.0.5) - puma (6.4.2) + public_suffix (6.0.1) + puma (7.2.1) nio4r (~> 2.0) raabro (1.4.0) - racc (1.8.0) - rack (3.0.11) - rack-accept (0.4.5) - rack (>= 0.4) + racc (1.8.1) + rack (3.1.22) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-cors (2.0.2) rack (>= 2.0.0) - rack-session (2.0.0) + rack-session (2.1.2) + base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) - rails (7.1.3.3) - actioncable (= 7.1.3.3) - actionmailbox (= 7.1.3.3) - actionmailer (= 7.1.3.3) - actionpack (= 7.1.3.3) - actiontext (= 7.1.3.3) - actionview (= 7.1.3.3) - activejob (= 7.1.3.3) - activemodel (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + rails (8.0.5.1) + actioncable (= 8.0.5.1) + actionmailbox (= 8.0.5.1) + actionmailer (= 8.0.5.1) + actionpack (= 8.0.5.1) + actiontext (= 8.0.5.1) + actionview (= 8.0.5.1) + activejob (= 8.0.5.1) + activemodel (= 8.0.5.1) + activerecord (= 8.0.5.1) + activestorage (= 8.0.5.1) + activesupport (= 8.0.5.1) bundler (>= 1.15.0) - railties (= 7.1.3.3) + railties (= 8.0.5.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - rails-latex (2.3.4) - rails (>= 3.0.0, < 8) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-latex (2.3.5) + rails (>= 3.0.0, < 9) rails_best_practices (1.23.2) activesupport code_analyzer (~> 0.5.5) @@ -329,29 +364,32 @@ GEM json require_all (~> 3.0) ruby-progressbar - railties (7.1.3.3) - actionpack (= 7.1.3.3) - activesupport (= 7.1.3.3) - irb + railties (8.0.5.1) + actionpack (= 8.0.5.1) + activesupport (= 8.0.5.1) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (2.8.4) + rbs (3.10.4) + logger + tsort rbtree (0.4.6) - rdoc (6.7.0) + rdoc (6.13.1) psych (>= 4.0.0) - redis (5.2.0) + redis (5.4.0) redis-client (>= 0.22.0) - redis-client (0.22.2) + redis-client (0.24.0) connection_pool - regexp_parser (2.9.2) - reline (0.5.8) + regexp_parser (2.10.0) + reline (0.6.0) io-console (~> 0.5) require_all (3.0.0) responders (3.1.1) @@ -362,134 +400,183 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - reverse_markdown (2.1.1) + reverse_markdown (3.0.0) nokogiri - rexml (3.2.8) - strscan (>= 3.0.9) - rmagick (6.0.1) + rexml (3.4.1) + rmagick (6.1.1) observer (~> 0.1) pkg-config (~> 1.4) - roo (2.7.1) + roo (2.10.1) nokogiri (~> 1) - rubyzip (~> 1.1, < 2.0.0) + rubyzip (>= 1.3.0, < 3.0.0) roo-xls (1.2.0) nokogiri roo (>= 2.0.0, < 3) spreadsheet (> 0.9.0) - rouge (4.2.1) - rubocop (1.64.1) + rouge (4.5.1) + rubocop (1.75.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.43.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) - parser (>= 3.3.1.0) - rubocop-faker (1.1.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.43.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-factory_bot (2.27.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-faker (1.3.0) faker (>= 2.12.0) - rubocop (>= 0.82.0) - rubocop-rails (2.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-minitest (0.37.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-rails (2.30.3) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-filemagic (0.7.3) - ruby-lsp (0.17.1) + ruby-lsp (0.26.9) language_server-protocol (~> 3.17.0) - prism (>= 0.29.0, < 0.30) - sorbet-runtime (>= 0.5.10782) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) ruby-ole (1.2.13.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) - ruby-saml (1.13.0) - nokogiri (>= 1.10.5) + ruby-saml (1.18.1) + nokogiri (>= 1.13.10) rexml ruby2_keywords (0.0.5) - rubyzip (1.3.0) - set (1.1.0) - sexp_processor (4.17.1) - shellwords (0.2.0) - sidekiq (7.2.4) - concurrent-ruby (< 2) + rubyzip (2.4.1) + securerandom (0.4.1) + sentry-rails (6.5.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.5.0) + sentry-ruby (6.5.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + logger + set (1.1.1) + sexp_processor (4.17.3) + shellwords (0.2.2) + sidekiq (7.3.9) + base64 connection_pool (>= 2.3.0) + logger rack (>= 2.2.4) - redis-client (>= 0.19.0) - sidekiq-cron (1.12.0) - fugit (~> 1.8) + redis-client (>= 0.22.2) + sidekiq-cron (2.2.0) + cronex (>= 0.13.0) + fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) - sidekiq (>= 6) + sidekiq (>= 6.5.0) + sidekiq-status (3.0.3) + chronic_duration + sidekiq (>= 6.0, < 8) + sidekiq-unique-jobs (8.0.10) + concurrent-ruby (~> 1.0, >= 1.0.5) + sidekiq (>= 7.0.0, < 8.0.0) + thor (>= 1.0, < 3.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) - solargraph (0.50.0) + snaky_hash (2.0.7) + hashie (>= 0.1.0, < 6) + version_gem (~> 1.1, >= 1.1.14) + solargraph (0.53.4) backport (~> 1.2) benchmark bundler (~> 2.0) diff-lcs (~> 1.4) - e2mmap - jaro_winkler (~> 1.5) + jaro_winkler (~> 1.6) kramdown (~> 2.3) kramdown-parser-gfm (~> 1.1) + logger (~> 1.6) + observer (~> 0.1) + ostruct (~> 0.6) parser (~> 3.0) - rbs (~> 2.0) - reverse_markdown (~> 2.0) + rbs (~> 3.3) + reverse_markdown (>= 2.0, < 4) rubocop (~> 1.38) thor (~> 1.0) tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) - sorbet-runtime (0.5.11409) + yard-solargraph (~> 0.1) sorted_set (1.0.3) rbtree set (~> 1.0) - spreadsheet (1.3.1) + spreadsheet (1.3.4) bigdecimal + logger ruby-ole sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - stringio (3.1.0) - strscan (3.1.0) + stringio (3.1.6) + sys-filesystem (1.5.5) + ffi (~> 1.1) tca_client (1.0.4) typhoeus (~> 1.0, >= 1.0.1) tcp_timeout (0.1.1) - thor (1.3.1) - tilt (2.3.0) - timeout (0.4.1) + thor (1.3.2) + tilt (2.6.0) + timeout (0.6.1) + tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) typhoeus (1.4.1) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) - uri (0.13.0) + unicode (0.4.4.5) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.4) + useragent (0.16.11) + version_gem (1.1.15) warden (1.2.9) rack (>= 2.0.9) - webmock (3.23.1) + web-push (3.0.1) + jwt (~> 2.0) + openssl (~> 3.0) + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.1) - websocket-driver (0.7.6) + websocket-driver (0.8.2) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - yard (0.9.36) - zeitwerk (2.6.15) + yard (0.9.45) + yard-solargraph (0.1.0) + yard (~> 0.9) + zeitwerk (2.7.2) PLATFORMS aarch64-linux + x86_64-linux DEPENDENCIES better_errors @@ -498,7 +585,7 @@ DEPENDENCIES byebug ci_reporter coderay - database_cleaner-active_record + csv devise devise_ldap_authenticatable dotenv @@ -511,44 +598,285 @@ DEPENDENCIES grape-swagger-rails hirb icalendar + ice_cube json-jwt listen minitest minitest-around minitest-rails - moss_ruby (>= 1.1.4) + moss_ruby mysql2 net-smtp + oauth2 pdf-reader - puma + puma (~> 7.2, >= 7.2.1) + rack-attack (~> 6.8) rack-cors - rails (~> 7.0) - rails-latex (> 2.3) + rails (~> 8.0.0, >= 8.0.5.1) + rails-latex rails_best_practices redis require_all (>= 1.3.3) rest-client rmagick - roo (~> 2.7.0) + roo roo-xls rubocop + rubocop-factory_bot rubocop-faker + rubocop-minitest + rubocop-performance rubocop-rails ruby-filemagic ruby-lsp - ruby-saml (~> 1.13.0) + ruby-saml rubyzip + sentry-rails + sentry-ruby shellwords sidekiq sidekiq-cron + sidekiq-status + sidekiq-unique-jobs simplecov solargraph sprockets-rails - tca_client (= 1.0.4) + sys-filesystem + tca_client + web-push (= 3.0.1) webmock +CHECKSUMS + Ascii85 (2.0.1) sha256=15cb5d941808543cbb9e7e6aea3c8ec3877f154c3461e8b3673e97f7ecedbe5a + actioncable (8.0.5.1) sha256=5adb700c605a7ef7628f87dc7a6da20cd5f0ceac782a59055c864ea51a77d7c7 + actionmailbox (8.0.5.1) sha256=f8b72eadf53b3e285df8f2d1f6533012abf5a0a001180abe436aea3139eeaed6 + actionmailer (8.0.5.1) sha256=c3d2b3f96e1989ea25f51699786a97fcb2536eb7abfc2a667cb8f2376ec08403 + actionpack (8.0.5.1) sha256=a5595c9d824d68884ddc4d3965ab78c897760d3752e190df7efe897371caa1eb + actiontext (8.0.5.1) sha256=370e90d35feb4313fc18ccef658776427d5bdd13126f266933b828a77e2125b2 + actionview (8.0.5.1) sha256=472a108b9cc2295c4ac3ff09b028045e619875801f48c556f0085210b9cb1440 + activejob (8.0.5.1) sha256=142407a21b6c3cbc6ddd92ca111ac18ea5c40298eb94d81845cd897a072a6880 + activemodel (8.0.5.1) sha256=559be32aa9c40db7a3ee0aef926d4508a9ebd22f96f7276c11326d21a7dff4a4 + activerecord (8.0.5.1) sha256=9252968fce404d75eb17092498a440d472167f2f8deee32b4658d6552b1eeea7 + activestorage (8.0.5.1) sha256=239742932b2fdcf0ead175e0889dbd385a36da2168fd7bde023aaad88ef745f2 + activesupport (8.0.5.1) sha256=329a4280c4fbcfcf338ae2cb9df28b0b14527929dba105e10b3604516d998710 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + aes_key_wrap (1.1.0) sha256=b935f4756b37375895db45669e79dfcdc0f7901e12d4e08974d5540c8e0776a5 + afm (0.2.2) sha256=c83e698e759ab0063331ff84ca39c4673b03318f4ddcbe8e90177dd01e4c721a + amq-protocol (2.3.3) sha256=85b42738290913a35dcc487a2ca0dd260a4150b40ed1954c9c1932df466abc1f + anonymous_loader (0.1.3) sha256=084a18e2439144d955447dc11dfc982f41fcd1583ad32d4d55151325dc44cb55 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + auth-sanitizer (0.2.3) sha256=db10aac92cfbe4c64ab637eebcbe1d67395d1694798041362173370f59933e3c + backport (1.2.0) sha256=912c7dfdd9ee4625d013ddfccb6205c3f92da69a8990f65c440e40f5b2fc7f75 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 + benchmark (0.4.0) sha256=0f12f8c495545e3710c3e4f0480f63f06b4c842cc94cec7f33a956f5180e874a + better_errors (2.10.1) sha256=f798f1bac93f3e775925b7fcb24cffbcf0bb62ee2210f5350f161a6b75fc0a73 + bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc + bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 + bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bunny (2.24.0) sha256=072fe4ae98eaa9c95a17e4d166204f710bba8a9a7070b73a8c3b023f439d1682 + bunny-pub-sub (0.5.2) sha256=cc8bef8007915a4b35f750955a13df128ce5332162f9755910172479edad01f0 + byebug (12.0.0) sha256=d4a150d291cca40b66ec9ca31f754e93fed8aa266a17335f71bb0afa7fca1a1e + chronic_duration (0.10.6) sha256=fac58d4147d3183a40811400380cafcef049f2bb02421d2fd1c6e685fbe8facc + ci_reporter (2.1.0) sha256=8ab6c378e3ea6af4f99790523ef52049405399156992fc5f51284b59b5728a61 + code_analyzer (0.5.5) sha256=c81533e9986259657acb9b3321d831efb1720ef59eed37e7e5dec56ac368e03e + coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (2.5.0) sha256=233b92f8d38e038c1349ccea65dd3772727d669d6d2e71f9897c8bf5cd53ebfc + crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49 + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + cronex (0.15.0) sha256=21c794e085fad2951c4f2e279f440340a35ba2297e0b738f22f263f69fbe2186 + csv (3.3.3) sha256=7e2966befb7bdaf7d5e9b36e1de73e6a5e7a72f584f180a1726aec88a1b0a900 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 + devise_ldap_authenticatable (0.8.7) sha256=8af6f839661e24ca9afc5a1508a7ec7e1327e93af4516f2baabacdf511ee5a2e + diff-lcs (1.6.1) sha256=12a5a83f3e37a8e2f4427268e305914d5f1879f22b4e73bb1a09f76a3dd86cd4 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (3.1.7) sha256=c670df478675d23889e657beaca6fb423228f75ce9f052a0690c0d0daa333cf3 + drb (2.2.1) sha256=e9d472bf785f558b96b25358bae115646da0dbfd45107ad858b0bc0d935cb340 + dry-core (1.1.0) sha256=0903821a9707649a7da545a2cd88e20f3a663ab1c5288abd7f914fa7751ab195 + dry-inflector (1.2.0) sha256=22f5d0b50fd57074ae57e2ca17e3b300e57564c218269dcf82ff3e42d3f38f2e + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-types (1.8.2) sha256=c84e9ada69419c727c3b12e191e0ed7d2c6d58d040d55e79ea16e0ebf8b3ec0f + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + erubis (2.7.0) sha256=63653f5174a7997f6f1d6f465fbe1494dcc4bdab1fb8e635f6216989fb1148ba + et-orbi (1.2.11) sha256=d26e868cc21db88280a9ec1a50aa3da5d267eb9b2037ba7b831d6c2731f5df64 + ethon (0.16.0) sha256=bba0da1cea8ac3e1f5cdd7cb1cb5fc78d7ac562c33736f18f0c3eb2b63053d9e + factory_bot (6.5.1) sha256=40581ea7bec0aee05514b8f4f99ed477274bdf1884c1372de5209e60322d6ca9 + factory_bot_rails (6.4.4) sha256=139e17caa2c50f098fddf5e5e1f29e8067352024e91ca1186d018b36589e5c88 + faker (3.5.1) sha256=1ad1fbea279d882f486059c23fe3ddb816ccd1d7052c05a45014b4450d859bfc + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 + faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 + faraday-net_http (3.4.0) sha256=a1f1e4cd6a2cf21599c8221595e27582d9936819977bbd4089a601f24c64e54a + ffi (1.17.1-aarch64-linux-gnu) sha256=c5d22cb545a3a691d46060f1343c461d1a8d38c3fd71b96b4cbbe6906bf1fd38 + ffi (1.17.1-x86_64-linux-gnu) sha256=8c0ade2a5d19f3672bccfe3b58e016ae5f159e3e2e741c856db87fcf07c903d0 + fugit (1.11.1) sha256=e89485e7be22226d8e9c6da411664d0660284b4b1c08cacb540f505907869868 + globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 + grape (2.3.0) sha256=99484ae2907b06a9e109edf2911c383809bf7f7c00d65554e4d01f0388728bda + grape-entity (1.0.1) sha256=e00f9e94e407aff77aa2945d741f544d07e48501927942988799913151d02634 + grape-swagger (2.1.2) sha256=8ad7bd53c8baee704575808875dba8c08d269c457db3cf8f1b8a2a1dbf827294 + grape-swagger-rails (0.6.0) sha256=4e518cf0dd2d5b2d0345fc615067c56ea9331e23d932d08d6ebec051de11ff06 + hashdiff (1.1.2) sha256=2c30eeded6ed3dce8401d2b5b99e6963fe5f14ed85e60dd9e33c545a44b71a77 + hashery (2.1.2) sha256=d239cc2310401903f6b79d458c2bbef5bf74c46f3f974ae9c1061fb74a404862 + hashie (5.0.0) sha256=9d6c4e51f2a36d4616cbc8a322d619a162d8f42815a792596039fc95595603da + hirb (0.7.3) sha256=5132733ca44b1f41f36c624693a3201284368a349dfe37f543ae6e2ad880ec57 + http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f + icalendar (2.10.3) sha256=0ebfc2672f9fa77b86b4d8c0e25e9b2319aad45a33319fed06d0be8ddd0cd485 + ice_cube (0.17.0) sha256=32deb45dda4b4acc53505c2f581f6d32b5afc04d29b9004769944a0df5a5fcbe + io-console (0.8.0) sha256=cd6a9facbc69871d69b2cb8b926fc6ea7ef06f06e505e81a64f14a470fddefa2 + irb (1.15.1) sha256=d9bca745ac4207a8b728a52b98b766ca909b86ff1a504bcde3d6f8c84faae890 + jaro_winkler (1.6.0) sha256=8b081ab4ba7da5d16b438e62c4be58b87724bfeeb1527e62603f05ab0a2cc424 + json (2.10.2) sha256=34e0eada93022b2a0a3345bb0b5efddb6e9ff5be7c48e409cfb54ff8a36a8b06 + json-jwt (1.16.7) sha256=ccabff4c6d1a14276b23178e8bebe513ef236399b72a0b886d7ed94800d172a5 + jwt (2.10.3) sha256=e4d9352fbc7309b1a7448c7dd713dfe4d8c47077af80759cdbed8f878ea0b484 + kramdown (2.5.1) sha256=87bbb6abd9d3cebe4fc1f33e367c392b4500e6f8fa19dd61c0972cf4afe7368c + kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729 + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.24.0) sha256=61e6a710883abb8210887f3dc868cf3ed66594c509d9ff6987621efa6651ee1e + mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad + marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 + mime-types (3.6.2) sha256=6109148e6a6e656607510b74571deff8ecd9a97ab0dcec9b7431bdd0b74460af + mime-types-data (3.2025.0325) sha256=8557e0e43b0b3216c2a518290039c1b65ffdbd6639db241142f7459eeba3c668 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (5.25.5) sha256=391b6c6cb43a4802bfb7c93af1ebe2ac66a210293f4a3fb7db36f2fc7dc2c756 + minitest-around (0.5.0) sha256=b959cea84f5eedb493ca2143e24a3c2547c62bd40efb2258a23285033ab6dc97 + minitest-rails (8.0.0) sha256=7788731b9793ef302721f925bf4349e0b943093e6f6b3d68cf8ac9134cd954bc + moss_ruby (1.1.4) sha256=3a0ea108a189647feba1c5ef34c12eb3f89be5ea1ded7e5d75a9806cf6ff0031 + msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + multi_json (1.15.0) sha256=1fd04138b6e4a90017e8d1b804c039031399866ff3fbabb7822aea367c78615d + multi_xml (0.7.1) sha256=4fce100c68af588ff91b8ba90a0bb3f0466f06c909f21a32f4962059140ba61b + mustermann (3.0.3) sha256=d1f8e9ba2ddaed47150ddf81f6a7ea046826b64c672fbc92d83bce6b70657e88 + mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5 + mysql2 (0.5.6) sha256=70f447d45d6b3cc16b00f7dd30366f708a81b4093a35d026ff7135d778d8da33 + net-http (0.6.0) sha256=9621b20c137898af9d890556848c93603716cab516dc2c89b01a38b894e259fb + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-ldap (0.19.0) sha256=be2a379ccbd28fc75fb70a94af74e3a9a6866b84574247fc243e0abdd2f82f3d + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f + nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + numerizer (0.1.1) sha256=10ec9efec62472b69a3a0e275a18a44baa595ce6e2e4cfc1678d5cb2974c336f + oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac + observer (0.1.2) sha256=d8a3107131ba661138d748e7be3dbafc0d82e732fffba9fccb3d7829880950ac + openssl (3.3.3) sha256=d46902138f2987c13122fab826030a11c2bb9b8a16394215cbfc5062c5e2d335 + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + ostruct (0.6.1) sha256=09a3fb7ecc1fa4039f25418cc05ae9c82bd520472c5c6a6f515f03e4988cb817 + parallel (1.26.3) sha256=d86babb7a2b814be9f4b81587bf0b6ce2da7d45969fab24d8ae4bf2bb4d4c7ef + parser (3.3.7.4) sha256=2b26282274280e13f891080dc4ef3f65ce658d62e13255b246b28ec6754e98ab + pdf-reader (2.14.1) sha256=b45a4521c249a394ad7ad9e691bfd46d4d00998cfc4f019e4525afb4963b411b + pkg-config (1.6.0) sha256=d6548afbcc6a63a1493cfdd743693415948c597cc85d7b2537bd3d1a3eb1b660 + pp (0.6.2) sha256=947ec3120c6f92195f8ee8aa25a7b2c5297bb106d83b41baa02983686577b6ff + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + psych (5.2.3) sha256=84a54bb952d14604fea22d99938348814678782f58b12648fcdfa4d2fce859ee + public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f + puma (7.2.1) sha256=d7bf0e9cabd532e0d401e142cd94e3ac531e993610e2d80e6fbf9c26961414b0 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.1.22) sha256=db116c1462fd32dec8b942a808ebedd4e1dbf1fcd0b24c481ae32ee99ca1ebe0 + rack-attack (6.8.0) sha256=f2499fdebf85bcc05573a22dff57d24305ac14ec2e4156cd3c28d47cafeeecf2 + rack-cors (2.0.2) sha256=415d4e1599891760c5dc9ef0349c7fecdf94f7c6a03e75b2e7c2b54b82adda1b + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.2.1) sha256=f737191fd5c5b348b7f0a4412a3b86383f88c43e13b8217b63d4c8d90b9e798d + rails (8.0.5.1) sha256=c91cefbf38881876ddbe67b5e0246eb985d27d60c6bb1cd7034b4261de0ba495 + rails-dom-testing (2.2.0) sha256=e515712e48df1f687a1d7c380fd7b07b8558faa26464474da64183a7426fa93b + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + rails-latex (2.3.5) sha256=8829129f833a8410666fa1f7b8c39ad2e90a1e5dbdece940d87d04b30ad0ab9f + rails_best_practices (1.23.2) sha256=b3f2e63766e99d087fa832a373b27f2a38e4a8aa2e406b166fa5d237ce3592ac + railties (8.0.5.1) sha256=da1958e1d9dab04691a2f8721b3ff7fab323715d37f103c19972dedfd644d5c7 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.2.1) sha256=46cb38dae65d7d74b6020a4ac9d48afed8eb8149c040eccf0523bec91907059d + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e + rbs (3.10.4) sha256=b17d7c4be4bb31a11a3b529830f0aa206a807ca42f2e7921a3027dfc6b7e5ce8 + rbtree (0.4.6) sha256=14eea4469b24fd2472542e5f3eb105d6344c8ccf36f0b56d55fdcfeb4e0f10fc + rdoc (6.13.1) sha256=62a0dac99493c94e8eb7a3fb44e55aefcb4cecb119f7991f25bddc5ed8d472f7 + redis (5.4.0) sha256=798900d869418a9fc3977f916578375b45c38247a556b61d58cba6bb02f7d06b + redis-client (0.24.0) sha256=ee65ee39cb2c38608b734566167fd912384f3c1241f59075e22858f23a085dbb + regexp_parser (2.10.0) sha256=cb6f0ddde88772cd64bff1dbbf68df66d376043fe2e66a9ef77fcb1b0c548c61 + reline (0.6.0) sha256=57620375dcbe56ec09bac7192bfb7460c716bbf0054dc94345ecaa5438e539d2 + require_all (3.0.0) sha256=937853faa2833388eab551107bf7bf87c6bba6b4800bac5ce469eda7b6a9fed0 + responders (3.1.1) sha256=92f2a87e09028347368639cfb468f5fefa745cb0dc2377ef060db1cdd79a341a + rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3 + reverse_markdown (3.0.0) sha256=ab228386765a0259835873cd07054b62939c40f620c77c247eafaaa3b23faca4 + rexml (3.4.1) sha256=c74527a9a0a04b4ec31dbe0dc4ed6004b960af943d8db42e539edde3a871abca + rmagick (6.1.1) sha256=df0171c0641956a172ed0bbf6bdcf2ea68ad7fa3ec09364705f32c2cdd3b8726 + roo (2.10.1) sha256=cbb43bc955f9c110e74b721c835fb9bd3515b63af88ec709ac87fbf30f8be70e + roo-xls (1.2.0) sha256=e340d7458d5f084e30f5eb4dc80925b047ecc7802a09115eaaba11bd4e8384cd + rouge (4.5.1) sha256=2ac81c6dee7019bbc6600d4c2d641d730d65c165941400ebd924259067e690dd + rubocop (1.75.1) sha256=c12900c55b0b52e6ed1384f7f7575beb92047019ce37ca14b9572d80239adc29 + rubocop-ast (1.43.0) sha256=92cd649e336ce10212cb2f2b29028f487777ecc477f108f437a1dce1ee3db79a + rubocop-factory_bot (2.27.1) sha256=9d744b5916778c1848e5fe6777cc69855bd96548853554ec239ba9961b8573fe + rubocop-faker (1.3.0) sha256=cb9ac132d44f9d2db6d5f9f8f5714700bf4d272cbaef5bce4052f4270fdc5c9b + rubocop-minitest (0.37.1) sha256=dcdcc2c835a859193e50bc67296daaf95ac99f6410838119374df31490460d36 + rubocop-performance (1.24.0) sha256=e5bd39ff3e368395b9af886927cc37f5892f43db4bd6c8526594352d5b4440b5 + rubocop-rails (2.30.3) sha256=fc5a6506daa916d15e282cc806943afa64a020bf592b93a94025d89a2a78a715 + ruby-filemagic (0.7.3) sha256=9dedfac69c737be29efb4542a280e345a70ba2b6ba905a518abd9998c8f3a7d9 + ruby-lsp (0.26.9) sha256=33a01c001c00a76b4e821efc04ed7572983430f31ca5d6f3e343d0b6ccab4129 + ruby-ole (1.2.13.1) sha256=578d10dd2a797a2b35a1286c6fb2c9525f67c24791346fc8015d39f0ffa3cb72 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby-rc4 (0.1.5) sha256=00cc40a39d20b53f5459e7ea006a92cf584e9bc275e2a6f7aa1515510e896c03 + ruby-saml (1.18.1) sha256=1b0e7a44aef150b4197955f5e015d593672e242cfdc5d06aa7554ec2350b9107 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sentry-rails (6.5.0) sha256=ebf9d4d82c740c3e0e4a0840f11f7bbd0cf648a30afd9c67e5b50bb07018a0e4 + sentry-ruby (6.5.0) sha256=3c57ae0d6a017aafcd9ac37114e38149a58534679dec5d4e9e8fc010b85f3a6b + set (1.1.1) sha256=6c7ac6c06d5907216395a4d5dae3ffe52ca5ee8a372befe6d4dea794383f98f0 + sexp_processor (4.17.3) sha256=5ef0d952565eeedb416519f678b6b41c6ab6700abba828f46986f2d85d295dae + shellwords (0.2.2) sha256=b8695a791de2f71472de5abdc3f4332f6535a4177f55d8f99e7e44266cd32f94 + sidekiq (7.3.9) sha256=1108712e1def89002b28e3545d5ae15d4a57ffd4d2c25d97bb1360988826b5a7 + sidekiq-cron (2.2.0) sha256=4de604412a733036130bd5f5fac12f31102f027c67aa21980b60c00eb2dfec41 + sidekiq-status (3.0.3) sha256=efd8d33417d79f3a86fdac094f8fb2c61afa72b792569797e95d83c4c8ad94dd + sidekiq-unique-jobs (8.0.10) sha256=d8abed98f863b2f830a75839e8325b892e72a2fda7cf335f10540382393c950c + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-html (0.13.1) sha256=5dab0b7ee612e60e9887ad57693832fdf4695b4c0c859eaea5f95c18791ef10b + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d + solargraph (0.53.4) sha256=a14c778bf96ed06e2e23438b35113acd5256233880e363cc11a75900a09a65d2 + sorted_set (1.0.3) sha256=4f2b8bee6e8c59cbd296228c0f1f81679357177a8b6859dcc2a99e86cce6372f + spreadsheet (1.3.4) sha256=0aefd6f3dfdc8b43528109f7fbd54db54f85ce5920429413d48305906bc59253 + sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97 + sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e + stringio (3.1.6) sha256=292c495d1657adfcdf0a32eecf12a60e6691317a500c3112ad3b2e31068274f5 + sys-filesystem (1.5.5) sha256=6f995890a734b9f0aa55df5e09d99adeb9fd1c288f2c4097269a1f8c95e15033 + tca_client (1.0.4) sha256=6d72702d0e4b02f4cec236a0f34f32b74fcc3072b29b3a42c486f646575b548f + tcp_timeout (0.1.1) sha256=9a289238e89acfc1bcbeaabae18b3f4e19ce30e9d38afc1320d8d0b9fa8c3239 + thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda + tilt (2.6.0) sha256=263d748466e0d83e510aa1a2e2281eff547937f0ef06be33d3632721e255f76b + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + ttfunk (1.8.0) sha256=a7cbc7e489cc46e979dde04d34b5b9e4f5c8f1ee5fc6b1a7be39b829919d20ca + typhoeus (1.4.1) sha256=1c17db8364bd45ab302dc61e460173c3e69835896be88a3df07c206d5c55ef7c + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode (0.4.4.5) sha256=42f294bfc8e186d29da89d1f766071505a20a22776168a31bb3408e03fa7a9d7 + unicode-display_width (3.1.4) sha256=8caf2af1c0f2f07ec89ef9e18c7d88c2790e217c482bfc78aaa65eadd5415ac1 + unicode-emoji (4.0.4) sha256=2c2c4ef7f353e5809497126285a50b23056cc6e61b64433764a35eff6c36532a + uri (1.0.4) sha256=34485d137c079f8753a0ca1d883841a7ba2e5fae556e3c30c2aab0dde616344b + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + version_gem (1.1.15) sha256=a73241587b29252e3567e0c818ded80730eb754d43b508f6ce4db22ca9ba27d0 + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 + web-push (3.0.1) sha256=5b4dd2f2bba3bd8951da6416492fe920a6f203d14d3080f943c5d01c0cc4b18d + webmock (3.25.1) sha256=ab9d5d9353bcbe6322c83e1c60a7103988efc7b67cd72ffb9012629c3d396323 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + yard (0.9.45) sha256=52e211493f7cb8a3ebf7e104a25a1e73937a3103092545d34cb88fafebb3dc51 + yard-solargraph (0.1.0) sha256=a19a4619c942181a618fb9458970a9d2534cf7fda69fc43949629a7948a5930e + zeitwerk (2.7.2) sha256=842e067cb11eb923d747249badfb5fcdc9652d6f20a1f06453317920fdcd4673 + RUBY VERSION - ruby 3.1.4p223 + ruby 3.4.10p104 BUNDLED WITH - 2.4.15 + 2.6.6 diff --git a/NOTIFICATIONS.md b/NOTIFICATIONS.md new file mode 100644 index 0000000000..b50b399571 --- /dev/null +++ b/NOTIFICATIONS.md @@ -0,0 +1,108 @@ +# Notifications + +This explains how notifications work in OnTrack after the unification. + +## The idea + +Before, each type of message did its own thing. Emails were sent from many +places. There was no single system. + +Now there is one system. You send a notification once. It goes out on all the +channels the user has turned on: in-app, email, and Web Push when the deployment +has VAPID keys and the user has subscribed a browser. + +## The flow + + something happens in the app + -> you call NotificationService.notify(...) + -> saves an in-app notification (the bell) + -> queues an ID-only email job + -> queues an ID-only push job + -> Sidekiq workers reload the notification and contact providers + +You only call one thing. The system handles the rest. + +## How to send one + +Call this from anywhere in the API code: + + NotificationService.notify( + user: project.student, + type: 'feedback', + event: 'task_comment_created', + message: "New feedback is ready for #{task_definition.name}.", + link: "/#/projects/#{project.id}" + ) + +- user: who gets it. +- type: the category. One of task, feedback, portfolio, extension, general. + This is what the user's on/off setting controls. +- event: the specific thing that happened, as a lower_snake_case string. + Required. Use one event name per ticket, and use the same name every time you + raise that notification, so a notification can always be traced back to the + code that sent it. +- message: the text the user sees. Keep it short, 500 characters at most. +- link: where clicking it should take them. Optional. + +type and event are different on purpose. type is the coarse category the user +switches off in their profile. event is the fine-grained reason, and there will +be many events inside one type. + +## Types and preferences + +Each user already has three on/off settings in their profile: + +- receive_task_notifications +- receive_feedback_notifications +- receive_portfolio_notifications + +The type you pass maps to one of these settings. + +- task uses receive_task_notifications +- feedback uses receive_feedback_notifications +- portfolio uses receive_portfolio_notifications +- extension and general are always sent + +If the matching setting is off, nothing is sent. Not the bell, not the email, +not the push. One switch controls all channels. This keeps it simple. We can add +per-channel switches later if we want. + +## The pieces + +- app/models/notification.rb: the notification record. Has the type, message, + link, and whether it has been read. +- app/services/notification_service.rb: the one entry point. Checks the setting, + saves the record, and queues the email and push channel jobs. +- app/sidekiq/notification_email_job.rb: reloads a notification by id and sends + its email on the `mailers` queue. +- app/sidekiq/push_notification_delivery_job.rb: reloads a notification by id + and hands it to the Web Push delivery channel on the `notifications` queue. +- app/services/push_notification_service.rb: the Web Push delivery channel. It + remains a safe no-op until both VAPID keys are configured. +- app/mailers/notifications_mailer.rb: the email. New method single_notification + with templates in app/views/notifications_mailer. +- app/api/notifications_api.rb: the endpoints the web app calls. +- app/api/entities/notification_entity.rb: the shape of the data sent back. + +## The endpoints + + GET /api/notifications list my notifications + GET /api/notifications/unread_count how many I have not read + PUT /api/notifications/:id/read mark one as read + PUT /api/notifications/read_all mark all as read + DELETE /api/notifications/:id delete one + + GET /api/push_subscriptions list my browser subscriptions + POST /api/push_subscriptions register or update a browser + DELETE /api/push_subscriptions remove the browser identified by endpoint + +Every endpoint only ever touches the current user's own notifications. + +## What is on now + +- In-app: working. The record is saved and the endpoints return it. +- Email: working. Best effort. If email fails, the in-app notification is still + saved. +- Push: implemented and deliberately configuration-gated. It sends only when + VAPID keys are set and that user has opted in from a supported browser. Keep + the production keys blank until browser/device acceptance testing is complete. diff --git a/NOTIFICATIONS_STATUS.md b/NOTIFICATIONS_STATUS.md new file mode 100644 index 0000000000..82b661c747 --- /dev/null +++ b/NOTIFICATIONS_STATUS.md @@ -0,0 +1,123 @@ +# Unified Notifications - Status + +> Historical implementation record. The unified in-app, email and Web Push +> paths described as future stages below are now implemented on the integration +> branch. Use `NOTIFICATIONS.md`, `docs/notifications/push-setup.md`, and the +> review evidence under `docs/notifications/reviews/` for current operation and +> release status. + +Feature: unified notifications (in-app, email, push) for OnTrack. +Base: `11.0.x`. Branch: `feature/notifications` (api and web), off `origin/11.0.x`. +Merge and demo target: `integration`. + +The lead runs all commits, merges, and pushes. This file records what is staged +in the working tree and the exact commands to run. + +## Architecture + +One hub, many channels: + + event happens -> NotificationService.notify(...) -> in-app record + -> email job -> mailer + -> push job -> Web Push (when configured) + +A single category toggle gates every channel. The three existing user +preference columns (`receive_task_notifications`, `receive_feedback_notifications`, +`receive_portfolio_notifications`) map onto the notification `type`. If a +category is off, the notification is suppressed on all channels, including +in-app. Per-channel granularity (a type x channel matrix) is deferred to v2. + +## Stage 1 (done, staged in api working tree) + +New files: +- `app/models/notification.rb` - hub model. Types task/feedback/portfolio/extension/general. `unread` and `recent_first` scopes, `mark_read!`. +- `db/migrate/20260722000001_create_notifications.rb` - notifications table (user_id, notification_type, message, link, read_at, timestamps). Ticket EN-F01 later added `event` and widened `message` to text. +- `app/services/notification_service.rb` - the fan-out entry point. Respects the category preference, creates the in-app record, and queues ID-only email and push jobs. +- `app/services/push_notification_service.rb` - push channel delivery. No-op until VAPID keys exist, so it is safe to run today. +- `app/api/notifications_api.rb` - REST endpoints (list, unread_count, mark read, mark all read, delete). All scoped to `current_user`, so no IDOR. +- `app/api/entities/notification_entity.rb` - response shape. +- `app/views/notifications_mailer/single_notification.{html,text}.erb` - email templates. + +Changed files: +- `app/api/api_root.rb` - mount `NotificationsApi` and add auth, both in a `# Notifications feature` block. +- `app/models/user.rb` - `has_many :notifications` in a `# Notifications feature` block. +- `app/mailers/notifications_mailer.rb` - new `single_notification` method. +- `app/api/users_api.rb` - real bug fix (see below). + +## Bug review result + +- `users_api.rb:69-71` copy-paste bug: REAL, fixed. The three lines all wrote the + portfolio key and read top-level params instead of the nested `:user` hash, so + the nil-default never applied. Replaced with a loop over the three keys on + `params[:user]`. +- "portfolio emails gated by the wrong flag": NOT a bug. `receive_portfolio_notifications` + is enforced at `lib/tasks/generate_pdfs.rake:151`, which gates the + `portfolio_ready` and `portfolio_failed` emails. Line 75 of + `portfolio_evidence.rb` gates a task email (`task_pdf_failed`) by the task flag, + which is correct. No change made here. + +## Endpoints + + GET /api/notifications?unread_only=false + GET /api/notifications/unread_count + PUT /api/notifications/:id/read + PUT /api/notifications/read_all + DELETE /api/notifications/:id + +## How to raise a notification (for teammates wiring events) + + NotificationService.notify( + user: project.student, + type: 'feedback', + event: 'task_comment_created', + message: "New feedback is ready for #{task_definition.name}.", + link: "/#/projects/#{project.id}" + ) + +`event:` is required. It is the specific thing that happened, in +lower_snake_case. See NOTIFICATIONS.md for how it differs from `type:`. + +## Verification (run in the container, host Ruby is 2.6) + +The compose files live in `doubtfire-deploy/development/`. Run all of these from +there. See doubtfire-deploy/RUNNING-LOCALLY.md. + + cd doubtfire-deploy/development + COMPOSE="docker compose -f docker-compose.yml -f docker-compose.local-paths.yml" + +1. Rebuild and start (11.0.x needs Ruby 3.4 and Node 22): + `$COMPOSE up -d --build` +2. Only if migrate fails with a stale-DB error (task_prerequisites doesn't exist), reset first: + `$COMPOSE run --rm --no-deps doubtfire-api bash -c "bundle exec rake db:drop db:create db:schema:load && bundle exec rails db:environment:set RAILS_ENV=development && bundle exec rake db:populate"` +3. Migrate (updates `db/schema.rb`, commit that change after migrating): + `$COMPOSE exec doubtfire-api bundle exec rails db:migrate` +4. Lint the new code: + `$COMPOSE exec doubtfire-api bundle exec rubocop app/models/notification.rb app/services app/api/notifications_api.rb app/api/entities/notification_entity.rb` +5. Smoke test: in a rails console, `NotificationService.notify(user: User.first, type: 'general', event: 'smoke_test', message: 'Hello')`, then `GET /api/notifications` as that user. A mail file should also appear in `doubtfire-deploy/data/tmp/mails/`. + +Still to add for Stage 1 completion (good first tasks, run in container): +- `test/factories/notifications_factory.rb` +- `test/models/notification_test.rb` and `test/api/notifications_api_test.rb` + +## Open decisions for the lead + +1. VAPID keys: where in `doubtfire-deploy` secrets, and who generates them. Suggest + env vars `DOUBTFIRE_VAPID_PUBLIC_KEY` and `DOUBTFIRE_VAPID_PRIVATE_KEY`. Blocks + the Stage 4 push send path. Push code is safe to run before this is set. +2. Notification email sender: set `institution[:email_sender]` in config, or accept + the `noreply@doubtfire.local` fallback for now. +3. `api_root.rb` mount ordering: agree with the cross-unit and peer-progress leads. +4. In-app suppression when a category is off is implemented as decided. Confirm. +5. v1 trigger events: which events create notifications. The mechanism is done; + this is a scoping task for the team. + +## Remaining stages + +- Stage 2 (web): re-home the salvaged #353 header bell to Angular 22 (standalone: false, + routerLink not uiSref, @if/@for), add a notifications API service, register in a + `// Notifications feature` block in `doubtfire-angular.module.ts`. +- Stage 3 (web): settings toggles for the three preference booleans (API already exists). +- Stage 4 (api + web + deploy): Web Push. push_subscriptions table and endpoint, + web-push gem, VAPID keys; SwPush subscribe and permission UI; service worker + push and notificationclick handlers. +- Stage 5: verification per stage. diff --git a/README.md b/README.md index faedf0645a..4ec7c44914 100644 --- a/README.md +++ b/README.md @@ -23,28 +23,67 @@ Doubtfire is a feedback-driven learning support system. See [Doubtfire Deploy](https://github.com/doubtfire-lms/doubtfire-deploy) for instructions on deploying, and contributing, to the Doubtfire project. +The legacy root `docker-compose.yml` defaults to local database authentication. +Optional AAF development must use a dedicated non-production registration +supplied through an ignored `.env` file copied from `.env.example`. Any AAF +secret ever committed to Git must be treated as compromised and rotated by its +identity owner. + +Image publication is coordinated from the exact API/web revisions pinned by +`doubtfire-deploy` and its `production/publish-release.sh` release gate. The +legacy API image workflow is intentionally build-only and cannot publish a +tagged image independently of the cross-repository handover checks. + ## Environment variables Doubtfire requires multiple environment variables that help define settings about the Doubtfire instance running. Whilst these will default to other values, you may want to override them in production. -| Key | Description | Default | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | -| `DF_AUTH_METHOD` | The authentication method you would like Doubtfire to use. Possible values are `database` for standard authentication with the database, `ldap` for [LDAP](https://www.freebsd.org/doc/en/articles/ldap-auth/), `aaf` for [AAF Rapid Connect](https://rapid.aaf.edu.au/), or `SAML2` for [SAML2.0 auth](https://en.wikipedia.org/wiki/SAML_2.0). | `database` | -| `DF_STUDENT_WORK_DIR` | The directory to store uploaded student work for processing. | `student_work` | -| `DF_INSTITUTION_NAME` | The name of your institution running Doubtfire. | _University of Foo_ | -| `DF_INSTITUTION_EMAIL_DOMAIN` | The email domain from which emails are sent to and from in your institution. | `doubtfire.com` | -| `DF_INSTITUTION_HOST` | The host running the Doubtfire instance. | `localhost:3000` | -| `DF_INSTITUTION_PRODUCT_NAME` | The name of the product (i.e. Doubtfire) at your institution. | _Doubtfire_ | -| `DF_SECRET_KEY_BASE` | The Rails secret key. | Default key provided. | -| `DF_SECRET_KEY_ATTR` | The secret key to encrypt certain database fields. | Default key provided. | -| `DF_SECRET_KEY_DEVISE` | The secret key provided to Devise. | Default key provided. | -| `DF_SECRET_KEY_MOSS` | The secret key provided to [Moss](http://theory.stanford.edu/~aiken/moss/) for plagiarism detection. This value will need to be set to run `rake submission:check_plagiarism` (otherwise you **won't** need it). You will need to register for a Moss account to use this. | No default. | -| `DF_INSTITUTION_PRIVACY` | A statement related to the need for students to submit their own work, and that this work may be uploaded to 3rd parties for the purpose of plagiarism detection. | Default statement provided | -| `DF_INSTITUTION_PLAGIARISM` | A statement clarifying the terms plagiarism and collusion. | Default statement provided | -| `DF_INSTITUTION_SETTINGS_RB` | The path of the institution specific settings rb code - used to map student imports from institutional exports to a format understood by Doubtfire. | No default | -| `DF_FFMPEG_PATH` | The path of to the ffmpeg binary for audio processing. | ffmpeg | -| `DF_REDIS_CACHE_URL` | The redis URL for rails used for development and production, ignored in the test env. | `redis://localhost:6379/0` | -| `DF_REDIS_SIDEKIQ_URL` | The redis URL for sidekiq. A working redis server is **mandatory** for sidekiq in all environments. | `redis://localhost:6379/1` | +| Key | Description | Default | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `DF_AUTH_METHOD` | The authentication method you would like Doubtfire to use. Possible values are `database` for standard authentication with the database, `ldap` | `database` | +| | for [LDAP](https://www.freebsd.org/doc/en/articles/ldap-auth/), `aaf` for [AAF Rapid Connect](https://rapid.aaf.edu.au/), or `SAML2` for [SAML2.0 auth](https://en.wikipedia.org/wiki/SAML_2.0). | | +| `DF_STUDENT_WORK_DIR` | The directory to store uploaded student work for processing. | `student_work` | +| `DF_ARCHIVE_DIR` | The directory to move archived unit files to, and access from. | `DF_STUDENT_WORK_DIR/archive` | +| `DF_INSTITUTION_NAME` | The name of your institution running Doubtfire. | _Doubtfire University_ | +| `DF_INSTITUTION_EMAIL_DOMAIN` | The email domain from which emails are sent to and from in your institution. | `doubtfire.com` | +| `DF_INSTITUTION_EMAIL_SENDER` | The SMTP-authorised From address used for event-notification email. It may include a display name. | `noreply@doubtfire.local` | +| `DF_INSTITUTION_HOST` | The host running the Doubtfire instance. | `localhost:3000` | +| `DF_COOKIE_DOMAIN` | The domain to be associated with secure cookies. | Attempts to read from host | +| `DF_INSTITUTION_PRODUCT_NAME` | The name of the product (i.e. Doubtfire) at your institution. | _Doubtfire_ | +| `DF_INSTITUTION_HAS_LOGO` | Set to true (or 1) if there is an associated institution logo to be included in the header. | false | +| `DF_INSTITUTION_LOGO_URL` | The url of the logo to include in the header if there is a logo. | /assets/images/institution-logo.png | +| `DF_INSTITUTION_LOGO_LINK_URL` | The url used for the hyperlink associated with clicking the logo. | / | +| `DF_SECRET_KEY_BASE` | The Rails secret key. | Default key provided. | +| `DF_SECRET_KEY_ATTR` | The secret key to encrypt certain database fields. | Default key provided. | +| `DF_SECRET_KEY_DEVISE` | The secret key provided to Devise. | Default key provided. | +| `DF_SECRET_KEY_MOSS` | The secret key provided to [Moss](http://theory.stanford.edu/~aiken/moss/) for plagiarism detection. This value will need to be set to run `rake submission:check_plagiarism` (otherwise you **won't** need it). You will need to register for a Moss account to use this. | No default. | +| `DF_INSTITUTION_PRIVACY` | A statement related to the need for students to submit their own work, and that this work may be uploaded to 3rd parties for the purpose of plagiarism detection. | Default statement provided | +| `DF_INSTITUTION_PLAGIARISM` | A statement clarifying the terms plagiarism and collusion. | Default statement provided | +| `DF_INSTITUTION_SETTINGS_RB` | The path of the institution specific settings rb code - used to map student imports from institutional exports to a format understood by Doubtfire. | No default | +| `DF_FFMPEG_PATH` | The path of to the ffmpeg binary for audio processing. | ffmpeg | +| `DF_REDIS_CACHE_URL` | The preferred shared Redis URL for Rails caching and authentication throttling. Production and staging must set this or `DF_REDIS_SIDEKIQ_URL`; it is ignored in the test environment. | No production default | +| `DF_REDIS_SIDEKIQ_URL` | The redis URL for sidekiq. A working redis server is **mandatory** for sidekiq in all environments. | `redis://localhost:6379/1` | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| **Turn It In Integration** | | | +| `TII_ENABLED` | Whether or not Turn It In integration is enabled. | 0 / false | +| `TII_INDEX_SUBMISSIONS` | Whether or not to index submissions in Turn It In. Should be set to 1 or true in production environments | 0 / false | +| `TII_REGISTER_WEBHOOK` | Whether or not to register a webhook with Turn It In. Should be set to 1 or true in production environments | 0 / false | +| `TCA_API_KEY` | The API key for Turn It In integration, acquire from the Turn It In administration interface. | No default | +| `TCA_HOST` | The host for the Turn It In integration, eg: https://institution.turnitin.com | No default | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| **D2L Integration** | | | +| `D2L_ENABLED` | Whether or not D2L integration is enabled. | 0 / false | +| `D2L_CLIENT_ID` | The client ID for D2L integration - from the oauth registration in D2L | No default | +| `D2L_CLIENT_SECRET` | The client secret for D2L integration - from the oauth registration in D2L | No default | +| `D2L_REDIRECT_URI` | The redirect URI for D2L integration. Must redirect to https://host/api/d2l/callback which must match the oauth registration in D2L | No default | +| `D2L_API_HOST` | The specific institutional URL for the D2L server, eg: https://d2l.institution.edu | No default | +| `D2L_OAUTH_SITE` | The location of the D2L authentication server. | `https://auth.brightspace.com` | +| `D2L_OAUTH_SITE_AUTHORIZE_URL` | The URL to authorize the D2L integration. | `/oauth2/auth` | +| `D2L_OAUTH_SITE_TOKEN_URL` | The URL to get the token for the D2L integration. | `/core/connect/token` | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| **Latex Configuration** | | | +| `LATEX_CONTAINER_NAME` | The name of the container housing the latex image. Used when pdfs are generated. | No default | +| `LATEX_BUILD_PATH` | The path to the latex build script within the container. | /texlive/shell/latex_build.sh | If you have chosen to use AAF Rapid Connect authentication, then you will also need to provide the following: @@ -58,6 +97,22 @@ If you have chosen to use AAF Rapid Connect authentication, then you will also n | `DF_AAF_AUTH_SIGNOUT_URL` | The URL to redirect to on sign out in order to log out of AAF Rapid Connect. | No default - required | | `DF_SECRET_KEY_AAF` | The secret used to register your application with AAF. | `secretsecret12345` | +If you are authenticating using SAML2, then you will also need to provide the following: + +| Key | Description | Default | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `DF_SAML_METADATA_URL` | The URL to getch the SAML metadata. Either the url or a file path to the meta data must be provided. Where the url is provided it will be used. | No default | +| `DF_SAML_METADATA_FILE_PATH` | The path to the metadata xml file. When the url is not set, this path will be used to get the metadata for the saml settings. | No default | +| `DF_SAML_CONSUMER_SERVICE_URL` | The URL of the SAML application. | No default - required | +| `DF_SAML_IDP_TARGET_URL` | The IDP SAML login URL, (e.g., "https://login.microsoftonline.com/xxxx/saml2") - OnTrack will redirect to this for login. | No default - required | +| `DF_SAML_IDP_SIGNOUT_URL` | The IDP SAML logout URL, (e.g., "https://login.microsoftonline.com/xxxx/saml2") - OnTrack will redirect to this for logout. | the SAML login url | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `DF_AAF_CALLBACK_URL` | The secure endpoint within your application that AAF Rapid Connect should POST responses to. It **must end with `/api/auth/jwt`** to access the Doubtfire JWT authentication endpoint. | No default - required | +| `DF_AAF_UNIQUE_URL` | The unique URL provided by AAF Rapid Connect used for redirection out of Doubtfire. | No default - required | +| `DF_AAF_IDENTITY_PROVIDER_URL` | The URL of the AAF-registered identity provider. | No default - required | +| `DF_AAF_AUTH_SIGNOUT_URL` | The URL to redirect to on sign out in order to log out of AAF Rapid Connect. | No default - required | +| `DF_SECRET_KEY_AAF` | The secret used to register your application with AAF. | `secretsecret12345` | + You may choose to keep your environment variables inside a `.env` file using key-value pairs: ``` @@ -99,6 +154,13 @@ To run unit tests, execute: $ rake test ``` +Code coverage is disabled during normal test runs to keep feedback fast. To +generate the SimpleCov report explicitly, run: + +```bash +$ COVERAGE=true rake test +``` + Unit tests are located in the `test` directory, where **model** tests are under the `model` subdirectory and **API** tests are under the `api` subdirectory. diff --git a/app/api/admin/overseer_admin_api.rb b/app/api/admin/overseer_admin_api.rb index d99fd3f3fd..25f3e86d8f 100644 --- a/app/api/admin/overseer_admin_api.rb +++ b/app/api/admin/overseer_admin_api.rb @@ -4,6 +4,7 @@ module Admin class OverseerAdminApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers SidekiqHelper before do authenticated? @@ -59,6 +60,11 @@ class OverseerAdminApi < Grape::API .permit(:name, :tag) + # Clear image status and text when updating + overseer_image_params[:pulled_image_status] = nil + overseer_image_params[:pulled_image_text] = nil + overseer_image_params[:last_pulled_date] = nil + overseer_image.update!(overseer_image_params) present overseer_image, with: Entities::OverseerImageEntity end @@ -89,6 +95,19 @@ class OverseerAdminApi < Grape::API end end + desc 'Get all overseer images' + get '/admin/overseer_images/:id' do + unless authorise? current_user, User, :use_overseer + error!({ error: 'Not authorised to get overseer images' }, 403) + end + + if Doubtfire::Application.config.overseer_enabled + present OverseerImage.find(params[:id]), with: Entities::OverseerImageEntity + else + present [], with: Grape::Presenters::Presenter + end + end + desc 'Get overseer image by id and pull image' put '/admin/overseer_images/:id/pull_image' do unless authorise? current_user, User, :admin_overseer @@ -99,9 +118,30 @@ class OverseerAdminApi < Grape::API end overseer_image = OverseerImage.find(params[:id]) - overseer_image.pull_from_docker - present overseer_image, with: Entities::OverseerImageEntity + job_id = PullDockerImageJob.perform_async(overseer_image.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Get available space' + get '/admin/disk_space' do + unless authorise? current_user, User, :use_overseer + error!({ error: 'Not authorised to get disk space' }, 403) + end + + unless Doubtfire::Application.config.disk_space_endpoint_enabled + error!({ error: 'Not authorised to get disk space' }, 403) + end + + stat = Sys::Filesystem.stat("/") + free_bytes = stat.block_size * stat.blocks_available + free_gb = free_bytes.to_f / 1024 / 1024 / 1024 + + present free_gb.round(2), with: Grape::Presenters::Presenter + rescue Sys::Filesystem::Error => e + error!({ error: "Filesystem stat failed", detail: e.message }, 500) end end end diff --git a/app/api/api_root.rb b/app/api/api_root.rb index bde5f02365..0bae8f1b6c 100644 --- a/app/api/api_root.rb +++ b/app/api/api_root.rb @@ -33,8 +33,13 @@ class ApiRoot < Grape::API when ActionController::ParameterMissing message = "Missing value for #{e.param}" status = 400 + when ActiveRecord::ConnectionTimeoutError + message = 'There is currently high load on the system. Please wait a moment and try again.' + status = 503 else + # rubocop:disable Rails/Output puts e.inspect unless Rails.env.production? + # rubocop:enable Rails/Output logger.error "Unhandled exception: #{e.class}" logger.error e.inspect @@ -42,6 +47,7 @@ class ApiRoot < Grape::API message = "Sorry... something went wrong with your request." status = 500 end + Sentry.capture_exception(e) Rack::Response.new({ error: message }.to_json, status, { 'Content-type' => 'text/error' }) end @@ -54,11 +60,14 @@ class ApiRoot < Grape::API mount AuthenticationApi mount BreaksApi mount DiscussionCommentApi + mount EngagementsApi mount ExtensionCommentsApi + mount ScormExtensionCommentsApi mount GroupSetsApi mount LearningOutcomesApi - mount LearningAlignmentApi mount ProjectsApi + mount SettingsPublicApi + mount PeerProgressApi mount SettingsApi mount StudentsApi mount Submission::PortfolioApi @@ -70,12 +79,19 @@ class ApiRoot < Grape::API mount Similarity::TaskSimilarityApi mount TeachingPeriodsPublicApi mount TeachingPeriodsAuthenticatedApi + mount StaffNotesApi + mount SidekiqApi + mount LtiApi if Doubtfire::Application.config.lti_enabled + mount TaskPrerequisitesApi + mount CommunicationRulesApi mount Tii::TurnItInApi mount Tii::TurnItInHooksApi mount Tii::TiiGroupAttachmentApi mount Tii::TiiActionApi + mount ScormApi + mount TestAttemptsApi mount CampusesPublicApi mount CampusesAuthenticatedApi mount TutorialsApi @@ -83,9 +99,24 @@ class ApiRoot < Grape::API mount TutorialEnrolmentsApi mount UnitRolesApi mount UnitsApi + mount TutorNotesApi + + mount D2lIntegrationApi::D2lApi + mount D2lIntegrationApi::OauthPublicApi + mount UsersApi mount WebcalApi mount WebcalPublicApi + mount MarkingSessionsApi + mount DiscussionPromptsApi + mount OverseerStepsApi + mount TaskPrioritizationApi + + mount Feedback::FeedbackChipApi + + # Notifications feature + mount NotificationsApi + mount PushSubscriptionsApi # # Add auth details to all end points @@ -95,11 +126,14 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to ActivityTypesAuthenticatedApi AuthenticationHelpers.add_auth_to BreaksApi AuthenticationHelpers.add_auth_to DiscussionCommentApi + AuthenticationHelpers.add_auth_to EngagementsApi AuthenticationHelpers.add_auth_to ExtensionCommentsApi + AuthenticationHelpers.add_auth_to ScormExtensionCommentsApi AuthenticationHelpers.add_auth_to GroupSetsApi AuthenticationHelpers.add_auth_to LearningOutcomesApi - AuthenticationHelpers.add_auth_to LearningAlignmentApi AuthenticationHelpers.add_auth_to ProjectsApi + AuthenticationHelpers.add_auth_to SettingsApi + AuthenticationHelpers.add_auth_to PeerProgressApi AuthenticationHelpers.add_auth_to StudentsApi AuthenticationHelpers.add_auth_to Submission::PortfolioApi AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi @@ -109,6 +143,11 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to TaskCommentsApi AuthenticationHelpers.add_auth_to TaskDefinitionsApi AuthenticationHelpers.add_auth_to TeachingPeriodsAuthenticatedApi + AuthenticationHelpers.add_auth_to StaffNotesApi + AuthenticationHelpers.add_auth_to SidekiqApi + AuthenticationHelpers.add_auth_to LtiApi if Doubtfire::Application.config.lti_enabled + AuthenticationHelpers.add_auth_to TaskPrerequisitesApi + AuthenticationHelpers.add_auth_to CommunicationRulesApi AuthenticationHelpers.add_auth_to Tii::TurnItInApi AuthenticationHelpers.add_auth_to Tii::TiiGroupAttachmentApi @@ -122,13 +161,27 @@ class ApiRoot < Grape::API AuthenticationHelpers.add_auth_to UnitRolesApi AuthenticationHelpers.add_auth_to UnitsApi AuthenticationHelpers.add_auth_to WebcalApi + AuthenticationHelpers.add_auth_to ScormApi + AuthenticationHelpers.add_auth_to TestAttemptsApi + + AuthenticationHelpers.add_auth_to D2lIntegrationApi::D2lApi + AuthenticationHelpers.add_auth_to Feedback::FeedbackChipApi + AuthenticationHelpers.add_auth_to MarkingSessionsApi + AuthenticationHelpers.add_auth_to DiscussionPromptsApi + AuthenticationHelpers.add_auth_to OverseerStepsApi + AuthenticationHelpers.add_auth_to TaskPrioritizationApi + AuthenticationHelpers.add_auth_to TutorNotesApi + + # Notifications feature + AuthenticationHelpers.add_auth_to NotificationsApi + AuthenticationHelpers.add_auth_to PushSubscriptionsApi add_swagger_documentation \ base_path: nil, - api_version: 'v1', + doc_version: 'v11.0.0', hide_documentation_path: true, info: { - title: 'Doubtfire API Documentaion', + title: 'Doubtfire API Documentation', description: 'Doubtfire is a modern, lightweight learning management system.', license: 'AGPL v3.0', license_url: 'https://github.com/doubtfire-lms/doubtfire-api/blob/master/LICENSE' diff --git a/app/api/authentication_api.rb b/app/api/authentication_api.rb index 3b43b10cf6..012b48c95c 100644 --- a/app/api/authentication_api.rb +++ b/app/api/authentication_api.rb @@ -11,29 +11,29 @@ class AuthenticationApi < Grape::API helpers LogHelper helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers LtiHelper # - # Sign in - only mounted if AAF auth is NOT used + # Sign in - only mounted if AAF and SAML auth is NOT used (database auth) # if !AuthenticationHelpers.aaf_auth? && !AuthenticationHelpers.saml_auth? desc 'Sign in' params do requires :username, type: String, desc: 'User username' - requires :password, type: String, desc: 'User\'s password' + optional :password, type: String, desc: 'User\'s password' + optional :auth_token, type: String, desc: 'User\'s auth token' optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false end post '/auth' do username = params[:username] password = params[:password] + auth_token = params[:auth_token] remember = params[:remember] logger.info "Authenticate #{username} from #{request.ip}" - # Truncate the 's' from sXXX for Swinburne auth - truncate_s_match = (username =~ /^[Ss]\d{6,10}([Xx]|\d)$/) - username[0] = '' if !truncate_s_match.nil? && truncate_s_match.zero? - # No provided credentials - if username.nil? || password.nil? + if username.nil? || (password.nil? && auth_token.nil?) error!({ error: 'The request must contain the user username and password.' }, 400) end @@ -49,10 +49,13 @@ class AuthenticationApi < Grape::API new_user.login_id = username end - # Try to authenticate - unless user.authenticate?(password) + # Try to authenticate with password + if password.present? && !user.authenticate?(password) error!({ error: 'Invalid email or password.' }, 401) return + elsif auth_token.present? && !authenticated?(:login) + error!({ error: 'Invalid user or auth token.' }, 401) + return end # Create user if they are a new record @@ -69,9 +72,17 @@ class AuthenticationApi < Grape::API logger.info "Login #{username} from #{request.ip}" + user = User.find_by(username: params[:username]) + token = user&.token_for_text?(params[:auth_token], :login) + + token&.destroy! + token = user.generate_authentication_token! + # Return user details present :user, user, with: Entities::UserEntity - present :auth_token, user.generate_authentication_token!(remember).authentication_token + present :auth_token, token.authentication_token + present :auth_token_expiry, token.auth_token_expiry + set_refresh_cookie_in_response(remember) end end @@ -91,46 +102,154 @@ class AuthenticationApi < Grape::API # We validate the SAML Response and check if the user already exists in the system return error!({ error: 'Invalid SAML response.' }, 401) unless response.is_valid? - attributes = response.attributes + # Get the user identification data from the SAML response + # This includes the login_id, email and username + user_id_data = Doubtfire::Application.config.institution_settings.map_saml_response_to_user_id(response) - login_id = response.name_id || response.nameid - email = login_id + logger.info "Authenticate #{user_id_data[:email]} from #{request.ip}" - logger.info "Authenticate #{email} from #{request.ip}" + # Lookup using login_id if it exists + # Lookup using email otherwise and set login_id + # Otherwise create new + user = User.find_by(login_id: user_id_data[:login_id]) || + User.find_by(username: user_id_data[:username]) || + User.find_by(email: user_id_data[:email]) || + User.create do |new_user| + # Update new user with details from the SAML response + Doubtfire::Application.config.institution_settings.update_user_from_saml_response( + new_user, + user_id_data, + response + ) + end + + # Set login id + username if not yet specified + if user.login_id.nil? || user.username.nil? + user.update( + login_id: user_id_data[:login_id], + username: user_id_data[:username] + ) + end + + # Try and save the user once authenticated if new + if user.new_record? + user.encrypted_password = BCrypt::Password.create(SecureRandom.hex(32)) + unless user.valid? + logger.error "User #{user.username} is invalid: #{user.errors.full_messages.join(', ')}" + error!(error: 'There was an error creating your account. ' \ + 'Please get in contact with your unit convenor or the ' \ + 'system administrators.') + end + user.save + end + + # Generate a temporary auth_token for future requests + onetime_token = user.generate_temporary_authentication_token! + + logger.info "Redirecting #{user.username} from #{request.ip}" + + # Must redirect to the front-end after sign in + host = Doubtfire::Application.config.institution[:host] + unless host.starts_with?('http') + protocol = Rails.env.development? ? 'http' : 'https' + host = "#{protocol}://#{host}" + end + redirect AuthenticationHelpers.frontend_sign_in_url( + host: host, + auth_token: onetime_token.authentication_token, + username: user.username + ) + end + + # Saml 2 logout callback + desc 'SAML2.0 logout callback' + params do + requires :SAMLResponse, type: String, desc: 'SAML logout response data.' + end + post '/auth/saml_logout' do + response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], allowed_clock_drift: 1.second, + settings: AuthenticationHelpers.saml_settings) + + # Check if the SAML response is valid - if not log an error + unless response.is_valid? + logger.error "Invalid SAML logout response: #{response.errors.join(', ')}" + end + + redirect "#{host}/sign_in" + end + end + + # + # LTI JWT callback - only mounted if LTI is used + # + if AuthenticationHelpers.lti_enabled? + desc 'LTI1.3 auth' + params do + requires :ltik, type: String, desc: 'JWT provided for further processing.' + # requires :member, type: Hash do + # requires :status, type: String + # requires :roles, type: Array[String] + # requires :user_id, type: String + # optional :lis_person_sourcedid, type: String + # requires :name, type: String + # requires :given_name, type: String + # requires :family_name, type: String + # requires :email, type: String + # requires :ext_user_username, type: String + # end + end + post '/auth/lti' do + token = decode_lti_token(params[:ltik]) + + member = token['member'] + if member.nil? + error!({ error: 'Invalid LTI token.' }, 400) + end + + valid_member, missing = valid_lti_member?(member) + unless valid_member + error!({ error: "Missing required fields: #{missing.join(', ')}" }, 400) + end + + user_id_data = { + login_id: member['ext_user_username'] || member['user_id'], + email: member['email'], + username: member['email']&.split('@')&.first + } + + logger.info "Authenticate #{user_id_data[:email]} from #{request.ip}" # Lookup using login_id if it exists # Lookup using email otherwise and set login_id # Otherwise create new - user = User.find_by(login_id: login_id) || - User.find_by_username(email[/(.*)@/, 1]) || - User.find_by(email: email) || - User.find_or_create_by(login_id: login_id) do |new_user| - role_response = attributes.fetch(/role/) || attributes.fetch(/userRole/) - role = role_response.include?('Staff') ? Role.tutor.id : Role.student.id - first_name = (attributes.fetch(/givenname/) || attributes.fetch(/cn/)).capitalize - last_name = attributes.fetch(/surname/).capitalize - username = email.split('@').first - # Some institutions may provide givenname and surname, others - # may only provide common name which we will use as first name - new_user.first_name = first_name - new_user.last_name = last_name - new_user.email = email - new_user.username = username - new_user.nickname = first_name - new_user.role_id = role + user = User.find_by(login_id: user_id_data[:login_id]) || + User.find_by(username: user_id_data[:username]) || + User.find_by(email: user_id_data[:email]) || + User.create do |new_user| + # Update new user with details from the LTI response + Doubtfire::Application.config.institution_settings.update_user_from_lti_response( + new_user, + user_id_data, + member + ) end # Set login id + username if not yet specified - user.login_id = login_id if user.login_id.nil? - user.username = username if user.username.nil? + if user.login_id.nil? || user.username.nil? + user.update( + login_id: user_id_data[:login_id], + username: user_id_data[:username] + ) + end # Try and save the user once authenticated if new if user.new_record? user.encrypted_password = BCrypt::Password.create(SecureRandom.hex(32)) unless user.valid? - error!(error: 'There was an error creating your account in Doubtfire. ' \ + logger.error "User #{user.username} is invalid: #{user.errors.full_messages.join(', ')}" + error!(error: 'There was an error linking your Lti account. ' \ 'Please get in contact with your unit convenor or the ' \ - 'Doubtfire administrators.') + 'system administrators.') end user.save end @@ -146,8 +265,14 @@ class AuthenticationApi < Grape::API protocol = Rails.env.development? ? 'http' : 'https' host = "#{protocol}://#{host}" end - redirect "#{host}/#/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + + logger.info "Login #{params[:username]} from #{request.ip}" + + # Respond user details with temporary auth token + present :username, user.username + present :auth_token, onetime_token.authentication_token end + end # @@ -177,7 +302,7 @@ class AuthenticationApi < Grape::API # Lookup using email otherwise and set login_id # Otherwise create new user = User.find_by(login_id: login_id) || - User.find_by_username(email[/(.*)@/, 1]) || + User.find_by(username: email[/(.*)@/, 1]) || User.find_by(email: email) || User.find_or_create_by(login_id: login_id) do |new_user| role = Role.aaf_affiliation_to_role_id(attrs[:edupersonscopedaffiliation]) @@ -223,7 +348,11 @@ class AuthenticationApi < Grape::API protocol = Rails.env.development? ? 'http' : 'https' host = "#{protocol}://#{host}" end - redirect "#{host}/#/sign_in?authToken=#{onetime_token.authentication_token}&username=#{user.username}" + redirect AuthenticationHelpers.frontend_sign_in_url( + host: host, + auth_token: onetime_token.authentication_token, + username: user.username + ) end end @@ -235,26 +364,29 @@ class AuthenticationApi < Grape::API params do requires :username, type: String, desc: 'The user\'s username' requires :auth_token, type: String, desc: 'The user\'s temporary auth token' + optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false end post '/auth' do - error!({ error: 'Invalid token.' }, 404) if params[:auth_token].nil? - logger.info "Get user via auth_token from #{request.ip}" + error!({ error: 'Invalid authentication details.' }, 404) if params[:auth_token].blank? || params[:username].blank? + logger.info "Get user via auth_token from #{request.ip} - #{params[:username]}" # Authenticate that the token is okay - if authenticated? - user = User.find_by_username(params[:username]) - token = user.token_for_text?(params[:auth_token]) unless user.nil? - error!({ error: 'Invalid token.' }, 404) if token.nil? + if authenticated?(:login) + user = User.find_by(username: params[:username]) + token = user.token_for_text?(params[:auth_token], :login) unless user.nil? + error!({ error: 'Invalid authentication details.' }, 404) if token.nil? # Invalidate the token and regenrate a new one token.destroy! - token = user.generate_authentication_token! true + token = user.generate_authentication_token! logger.info "Login #{params[:username]} from #{request.ip}" # Respond user details with new auth token present :user, user, with: Entities::UserEntity present :auth_token, token.authentication_token + present :auth_token_expiry, token.auth_token_expiry + set_refresh_cookie_in_response(params[:remember]) end end end @@ -293,9 +425,9 @@ class AuthenticationApi < Grape::API end # - # Update the expiry of an existing authentication token + # Sign out # - desc 'Allow tokens to be updated', + desc 'Sign out', { headers: { @@ -312,60 +444,77 @@ class AuthenticationApi < Grape::API } } params do - optional :remember, type: Boolean, desc: 'User has requested to remember login', default: false + requires :remember, type: Boolean, desc: 'Retain the refresh token?', default: false end - put '/auth' do - token_param = headers['auth-token'] || headers['Auth-Token'] || params['Auth-Token'] - user_param = headers['username'] || headers['Username'] || params['Username'] || params['username'] - - error!({ error: 'Invalid token/username.' }, 404) if token_param.nil? || user_param.nil? - - logger.info "Update token #{token_param} from #{request.ip} for #{user_param}" + delete '/auth' do + user = User.find_by(username: headers['username'] || headers['Username']) + token = user&.token_for_text?(headers['auth-token'] || headers['Auth-Token'], :general) - # Find user - user = User.find_by_username(user_param) - token = user.token_for_text?(token_param) unless user.nil? - remember = params[:remember] || false + if token.present? + logger.info "Sign out #{user.username} from #{request.ip}" + token.destroy! + end - # Token does not match user - if token.nil? || user.nil? || user.username != user_param - error!({ error: 'Invalid token.' }, 404) - else - token.extend_token remember if token.auth_token_expiry > Time.zone.now + if cookies['refresh_token'].present? && !params[:remember] + auth_param = cookies['refresh_token'] + user_param = cookies['username'] - # Return extended auth token - present :auth_token, token.authentication_token + user = User.find_by(username: user_param) + token = user&.token_for_text?(auth_param, :refresh_token) + if token.present? + logger.info "Destroy refresh token for #{user.username} from #{request.ip}" + token.destroy! + end end + + # Remove the refresh token cookie - if remember is false + set_refresh_cookie_in_response(false) unless params[:remember] + present nil end - # - # Sign out - # - desc 'Sign out', - { - headers: - { - "username" => - { - description: "User username", - required: true - }, - "auth_token" => - { - description: "The user's temporary auth token", - required: true - } - } - } - delete '/auth' do - user = User.find_by_username(headers['username'] || headers['Username']) - token = user.token_for_text?(headers['auth-token'] || headers['Auth-Token']) unless user.nil? + desc 'Get SCORM authentication token' + get '/auth/scorm' do + if authenticated?(:general) + unless authorise? current_user, User, :get_scorm_token + error!({ error: 'You cannot get SCORM tokens' }, 403) + end - if token.present? - logger.info "Sign out #{user.username} from #{request.ip}" - token.destroy! + token = current_user.auth_tokens.find_by(token_type: :scorm) + if token.nil? || token.auth_token_expiry <= Time.zone.now + token&.destroy + token = current_user.generate_scorm_authentication_token! + end + + present :scorm_auth_token, token.authentication_token end + end - present nil + desc 'Get access token from the refresh token cookie' + params do + optional :delete_auth_token, type: Boolean, desc: 'Delete the auth token if also provided', default: true + end + post '/auth/access-token' do + if authenticated_via_refresh_token? + # Check if we have a auth token as well + if params[:delete_auth_token] + user_param, auth_param = get_user_and_token_from(:header) + case user_auth_token_type(user_param, auth_param, :general) + when :valid + # Valid token and user + token = current_user.token_for_text?(auth_param, :general) + if token.present? + token.destroy! + logger.info "Destroying auth token for #{current_user.username} from #{request.ip}" + end + end + end + # Return user details + token = current_user.generate_authentication_token!(token_type: :general, force_new: false) + present :user, current_user, with: Entities::UserEntity + present :auth_token, token.authentication_token + present :auth_token_expiry, token.auth_token_expiry + else + present nil + end end end diff --git a/app/api/campuses_authenticated_api.rb b/app/api/campuses_authenticated_api.rb index 2bd697a635..e42e85b26e 100644 --- a/app/api/campuses_authenticated_api.rb +++ b/app/api/campuses_authenticated_api.rb @@ -14,6 +14,7 @@ class CampusesAuthenticatedApi < Grape::API requires :name, type: String, desc: 'The name of the campus' requires :mode, type: String, desc: 'This will determine the campus mode', values: ['timetable', 'automatic', 'manual'] requires :abbreviation, type: String, desc: 'The abbreviation for the campus' + optional :timezone, type: String, desc: 'The timezone for the campus' requires :active, type: Boolean, desc: 'Determines whether campus is active' end end @@ -26,6 +27,7 @@ class CampusesAuthenticatedApi < Grape::API .permit(:name, :mode, :abbreviation, + :timezone, :active) result = Campus.create!(campus_parameters) @@ -43,6 +45,7 @@ class CampusesAuthenticatedApi < Grape::API optional :name, type: String, desc: 'The name of the campus' optional :mode, type: String, desc: 'This will determine the campus mode', values: ['timetable', 'automatic', 'manual'] optional :abbreviation, type: String, desc: 'The abbreviation for the campus' + optional :timezone, type: String, desc: 'The timezone for the campus' optional :active, type: Boolean, desc: 'Determines whether campus is active' end end @@ -56,6 +59,7 @@ class CampusesAuthenticatedApi < Grape::API .permit(:name, :mode, :abbreviation, + :timezone, :active) campus.update!(campus_parameters) diff --git a/app/api/communication_rules_api.rb b/app/api/communication_rules_api.rb new file mode 100644 index 0000000000..af64417750 --- /dev/null +++ b/app/api/communication_rules_api.rb @@ -0,0 +1,780 @@ +require 'grape' +require 'entities/communication_set_entity' +require 'entities/communication_rule_entity' +require 'entities/communication_condition_entity' +require 'entities/communication_action_entity' +require 'entities/communication_set_schedule_entity' +require 'entities/sidekiq_job_entity' + +class CommunicationRulesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers SidekiqHelper + helpers do + def permitted_schedule_params(raw_schedule) + ActionController::Parameters.new(raw_schedule).permit( + :id, + :name, + :active, + :anchor_week, + :anchor_day, + :hour, + :minute, + :timezone, + :recurrence, + :interval, + :repeat_count, + :until_at + ) + end + + def schedule_params_from_request + communication_set_params = params[:communication_set] || params['communication_set'] || {} + communication_set_params[:schedules] || communication_set_params['schedules'] + end + + def sync_set_schedules!(communication_set, raw_schedules) + schedules = Array(raw_schedules).map { |schedule| permitted_schedule_params(schedule).to_h } + + keep_ids = schedules.filter_map { |schedule| schedule['id'] || schedule[:id] } + + communication_set.transaction do + communication_set.communication_set_schedules.where.not(id: keep_ids).destroy_all + + schedules.each do |schedule_attrs| + schedule_id = schedule_attrs.delete('id') || schedule_attrs.delete(:id) + + if schedule_id.present? + communication_set.communication_set_schedules.find(schedule_id).update!(schedule_attrs) + else + communication_set.communication_set_schedules.create!(schedule_attrs) + end + end + end + end + end + + before do + authenticated? + + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :mannage_communications + error!({ error: 'Not authorised to manage unit communications' }, 403) + end + end + + desc 'Get communication sets for a unit' + params do + requires :unit_id, type: Integer + end + get '/units/:unit_id/communication_sets' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present unit.communication_sets.includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]), + with: Entities::CommunicationSetEntity + end + + desc 'Get a communication set for a unit with preview data' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + communication_set = unit.communication_sets + .includes(:communication_set_schedules, communication_rules: [:communication_conditions, :communication_actions]) + .find(params[:id]) + + previews = communication_set.preview_allocations_by_rule + + present( + id: communication_set.id, + unit_id: communication_set.unit_id, + name: communication_set.name, + active: communication_set.active, + schedules: Entities::CommunicationSetScheduleEntity.represent(communication_set.communication_set_schedules), + rules: Entities::CommunicationRuleEntity.represent(communication_set.communication_rules), + previews: communication_set.communication_rules.map do |rule| + { + target_rule_id: rule.id, + allocations: previews.fetch(rule.id, []).map do |allocation| + { + rule_id: allocation[:rule].id, + rule_name: allocation[:rule].name, + position: allocation[:rule].position, + students: allocation[:projects].map do |project| + { + first_name: project.user&.first_name, + last_name: project.user&.last_name, + preferred_name: project.user&.nickname, + username: project.user&.username, + student_id: project.user&.student_id, + full_name: [project.user&.first_name, project.user&.last_name].compact.join(' '), + target_grade: project.target_grade, + spec_con_days: project.spec_con_days, + last_sign_in_at: project.user&.last_sign_in_at, + campus: project.campus&.name + } + end + } + end + } + end + ) + end + + desc 'Create a communication set for a unit' + params do + requires :unit_id, type: Integer + requires :communication_set, type: Hash do + requires :name, type: String + optional :active, type: Boolean + optional :schedules, type: Array do + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + end + post '/units/:unit_id/communication_sets' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + set_params = ActionController::Parameters.new(params) + .require(:communication_set) + .permit(:name, :active) + + communication_set = unit.communication_sets.create!(set_params) + sync_set_schedules!(communication_set, schedule_params_from_request) + communication_set.reload + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Update a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + requires :communication_set, type: Hash do + optional :name, type: String + optional :active, type: Boolean + optional :schedules, type: Array do + optional :id, type: Integer + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + end + put '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:id]) + set_params = ActionController::Parameters.new(params) + .require(:communication_set) + .permit(:name, :active) + + communication_set.update!(set_params) + sync_set_schedules!(communication_set, schedule_params_from_request) if schedule_params_from_request.present? || (params[:communication_set] || params['communication_set'] || {}).key?(:schedules) || (params[:communication_set] || params['communication_set'] || {}).key?('schedules') + communication_set.reload + present communication_set, with: Entities::CommunicationSetEntity + end + + desc 'Delete a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_sets/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + unit.communication_sets.find(params[:id]).destroy! + status 204 + end + + desc 'Execute a communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_sets/:id/execute' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to execute unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:id]) + job_id = ExecuteCommunicationSetJob.perform_async(communication_set.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Get schedules for a communication set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/schedules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + present communication_set.communication_set_schedules.order(:id), + with: Entities::CommunicationSetScheduleEntity + end + + desc 'Get a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule = communication_set.communication_set_schedules.find(params[:id]) + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Create a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :communication_set_schedule, type: Hash do + requires :name, type: String + optional :active, type: Boolean + requires :anchor_week, type: Integer + requires :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + post '/units/:unit_id/communication_sets/:communication_set_id/schedules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule_params = permitted_schedule_params(params[:communication_set_schedule]).to_h + schedule = communication_set.communication_set_schedules.create!(schedule_params) + schedule.reload + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Update a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + requires :communication_set_schedule, type: Hash do + optional :name, type: String + optional :active, type: Boolean + optional :anchor_week, type: Integer + optional :anchor_day, type: String + optional :hour, type: Integer + optional :minute, type: Integer + optional :timezone, type: String + optional :recurrence, type: String + optional :interval, type: Integer + optional :repeat_count, type: Integer + optional :until_at, type: DateTime + end + end + put '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + schedule = communication_set.communication_set_schedules.find(params[:id]) + schedule_params = permitted_schedule_params(params[:communication_set_schedule]).to_h + schedule.update!(schedule_params) + schedule.reload + present schedule, with: Entities::CommunicationSetScheduleEntity + end + + desc 'Delete a communication schedule' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_sets/:communication_set_id/schedules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update communication schedules' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + communication_set.communication_set_schedules.find(params[:id]).destroy! + status 204 + end + + desc 'Get communication rules for a unit' + params do + requires :unit_id, type: Integer + end + get '/units/:unit_id/communication_rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + present unit.communication_rules.includes(:communication_conditions, :communication_actions), + with: Entities::CommunicationRuleEntity + end + + desc 'Get communication rules for a set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + end + get '/units/:unit_id/communication_sets/:communication_set_id/rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + + present communication_set.communication_rules.includes(:communication_conditions, :communication_actions), + with: Entities::CommunicationRuleEntity + end + + desc 'Create a communication rule for a communication set' + params do + requires :unit_id, type: Integer + requires :communication_set_id, type: Integer + requires :communication_rule, type: Hash do + requires :name, type: String + requires :operator, type: String + optional :position, type: Integer + optional :active, type: Boolean + optional :send_log_to_convenors, type: Boolean + end + end + post '/units/:unit_id/communication_sets/:communication_set_id/rules' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + communication_set = unit.communication_sets.find(params[:communication_set_id]) + rule_params = ActionController::Parameters.new(params) + .require(:communication_rule) + .permit(:name, :operator, :position, :active, :send_log_to_convenors) + + rule_params[:position] = communication_set.communication_rules.count if rule_params[:position].nil? + rule = communication_set.communication_rules.create!(rule_params) + present rule, with: Entities::CommunicationRuleEntity + end + + desc 'Update a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + requires :communication_rule, type: Hash do + optional :name, type: String + optional :operator, type: String + optional :position, type: Integer + optional :active, type: Boolean + optional :send_log_to_convenors, type: Boolean + end + end + put '/units/:unit_id/communication_rules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + rule_params = ActionController::Parameters.new(params) + .require(:communication_rule) + .permit(:name, :operator, :position, :active, :send_log_to_convenors) + + rule.update!(rule_params) + present rule, with: Entities::CommunicationRuleEntity + end + + desc 'Execute a communication rule within its communication set' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_rules/:id/execute' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to execute unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + job_id = ExecuteCommunicationSetJob.perform_async(rule.communication_set_id, rule.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Preview projects matched by a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + post '/units/:unit_id/communication_rules/:id/preview' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to preview unit communications' }, 403) + end + + # rule = unit.communication_rules.find(params[:id]) + # job_id = CommunicationRuleJob.perform_async(rule.id) + # job = setup_job(job_id) + + # present job, with: Entities::SidekiqJobEntity + # rule = unit.communication_rules.find(params[:id]) + + rule = unit.communication_rules.find(params[:id]) + allocations = rule.communication_set.preview_allocations_for_rule(rule) + + present( + target_rule_id: rule.id, + allocations: allocations.map do |allocation| + { + rule_id: allocation[:rule].id, + rule_name: allocation[:rule].name, + position: allocation[:rule].position, + students: allocation[:projects].map do |project| + { + first_name: project.user&.first_name, + last_name: project.user&.last_name, + preferred_name: project.user&.nickname, + username: project.user&.username, + student_id: project.user&.student_id, + full_name: [project.user&.first_name, project.user&.last_name].compact.join(' '), + target_grade: project.target_grade, + last_sign_in_at: project.user&.last_sign_in_at, + campus: project.campus&.name + } + end + } + end + ) + end + + desc 'Get communication conditions for a rule' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + end + get '/units/:unit_id/communication_rules/:communication_rule_id/conditions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + + present rule.communication_conditions, with: Entities::CommunicationConditionEntity + end + + desc 'Delete a communication rule' + params do + requires :unit_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:id]) + rule.destroy! + status 204 + end + + desc 'Create a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :communication_condition, type: Hash do + requires :type, type: String + requires :operator, type: String + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + optional :task_statuses, type: Array[String] + optional :task_status_count, type: Integer + optional :task_target_grade, type: Integer + optional :last_sign_in_at, type: DateTime + optional :spec_con_days, type: Integer + optional :tutorial_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :campus_id, type: Integer + end + end + post '/units/:unit_id/communication_rules/:communication_rule_id/conditions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + raw_condition_params = params[:communication_condition] + condition_params = { + type: raw_condition_params[:type], + operator: raw_condition_params[:operator], + target_grade: raw_condition_params[:target_grade], + task_definition_id: raw_condition_params[:task_definition_id], + task_status_count: raw_condition_params[:task_status_count], + task_target_grade: raw_condition_params[:task_target_grade], + last_sign_in_at: raw_condition_params[:last_sign_in_at], + spec_con_days: raw_condition_params[:spec_con_days], + tutorial_id: raw_condition_params[:tutorial_id], + tutorial_stream_id: raw_condition_params[:tutorial_stream_id], + campus_id: raw_condition_params[:campus_id] + }.compact + + task_statuses = raw_condition_params[:task_statuses] + condition_params[:task_statuses] = Array(task_statuses) unless task_statuses.nil? + + condition = rule.communication_conditions.create!(condition_params) + present condition, with: Entities::CommunicationConditionEntity + end + + desc 'Update a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + requires :communication_condition, type: Hash do + optional :type, type: String + optional :operator, type: String + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + optional :task_statuses, type: Array[String] + optional :task_status_count, type: Integer + optional :task_target_grade, type: Integer + optional :last_sign_in_at, type: DateTime + optional :spec_con_days, type: Integer + optional :tutorial_id, type: Integer + optional :tutorial_stream_id, type: Integer + optional :campus_id, type: Integer + end + end + put '/units/:unit_id/communication_rules/:communication_rule_id/conditions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + condition = rule.communication_conditions.find(params[:id]) + raw_condition_params = params[:communication_condition] + condition_params = { + type: raw_condition_params[:type], + operator: raw_condition_params[:operator], + target_grade: raw_condition_params[:target_grade], + task_definition_id: raw_condition_params[:task_definition_id], + task_status_count: raw_condition_params[:task_status_count], + task_target_grade: raw_condition_params[:task_target_grade], + last_sign_in_at: raw_condition_params[:last_sign_in_at], + spec_con_days: raw_condition_params[:spec_con_days], + tutorial_id: raw_condition_params[:tutorial_id], + tutorial_stream_id: raw_condition_params[:tutorial_stream_id], + campus_id: raw_condition_params[:campus_id] + }.compact + + task_statuses = raw_condition_params[:task_statuses] + condition_params[:task_statuses] = Array(task_statuses) unless task_statuses.nil? + + condition.update!(condition_params) + present condition, with: Entities::CommunicationConditionEntity + end + + desc 'Get communication actions for a rule' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + end + get '/units/:unit_id/communication_rules/:communication_rule_id/actions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + + present rule.communication_actions, with: Entities::CommunicationActionEntity + end + + desc 'Delete a communication condition' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:communication_rule_id/conditions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + rule.communication_conditions.find(params[:id]).destroy! + status 204 + end + + desc 'Create a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :communication_action, type: Hash do + requires :type, type: String + optional :subject, type: String + optional :body, type: String + optional :email_tutors, type: Boolean + optional :email_convenors, type: Boolean + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + end + end + post '/units/:unit_id/communication_rules/:communication_rule_id/actions' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + action_params = ActionController::Parameters.new(params) + .require(:communication_action) + .permit( + :type, + :subject, + :body, + :email_tutors, + :email_convenors, + :target_grade, + :task_definition_id + ) + + action = rule.communication_actions.create!(action_params) + present action, with: Entities::CommunicationActionEntity + end + + desc 'Update a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + requires :communication_action, type: Hash do + optional :type, type: String + optional :subject, type: String + optional :body, type: String + optional :email_tutors, type: Boolean + optional :email_convenors, type: Boolean + optional :target_grade, type: Integer + optional :task_definition_id, type: Integer + end + end + put '/units/:unit_id/communication_rules/:communication_rule_id/actions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + action = rule.communication_actions.find(params[:id]) + action_params = ActionController::Parameters.new(params) + .require(:communication_action) + .permit(:type, :subject, :body, :email_tutors, :email_convenors, :target_grade, :task_definition_id) + + action.update!(action_params) + present action, with: Entities::CommunicationActionEntity + end + + desc 'Delete a communication action' + params do + requires :unit_id, type: Integer + requires :communication_rule_id, type: Integer + requires :id, type: Integer + end + delete '/units/:unit_id/communication_rules/:communication_rule_id/actions/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :update + error!({ error: 'Not authorised to update unit communications' }, 403) + end + + rule = unit.communication_rules.find(params[:communication_rule_id]) + rule.communication_actions.find(params[:id]).destroy! + status 204 + end +end diff --git a/app/api/d2l_integration_api/d2l_api.rb b/app/api/d2l_integration_api/d2l_api.rb new file mode 100644 index 0000000000..66f7ec635b --- /dev/null +++ b/app/api/d2l_integration_api/d2l_api.rb @@ -0,0 +1,184 @@ +require 'grape' + +module D2lIntegrationApi + # The D2l API provides the frontend with the ability to register + # integration details to connect units with D2L. This will allow + # grade book items to be copied from portfolio results to D2L. + class D2lApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers FileStreamHelper + include LogHelper + + before do + authenticated? + end + + desc 'Get the D2L assessment mapping for a unit' + get '/units/:unit_id/d2l' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view D2L details' }, 403) + end + + present unit.d2l_assessment_mapping, with: D2lIntegrationApi::Entities::D2lEntity + end + + desc 'Create a D2L assessment mapping for a unit' + params do + requires :org_unit_id, type: String, desc: 'The org unit id for the D2L unit' + optional :grade_object_id, type: Numeric, desc: 'The grade object id for the D2L unit' + end + post '/units/:unit_id/d2l' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to add D2L details' }, 403) + end + + accepted_params = ActionController::Parameters.new(params).permit(:unit_id, :org_unit_id, :grade_object_id) + + d2l = D2lAssessmentMapping.create!(accepted_params) + present d2l, with: D2lIntegrationApi::Entities::D2lEntity + end + + desc 'Delete a D2L assessment mapping for a unit' + delete '/units/:unit_id/d2l/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to delete D2L details' }, 403) + end + + d2l = unit.d2l_assessment_mapping + + if d2l.id != params[:id].to_i + error!({ error: 'D2L details not found' }, 404) + end + + d2l.destroy if d2l.present? + status 204 + end + + desc 'Update a D2L assessment mapping for a unit' + params do + optional :org_unit_id, type: String, desc: 'The org unit id for the D2L unit' + optional :grade_object_id, type: Numeric, desc: 'The grade object id for the D2L unit' + end + put '/units/:unit_id/d2l/:id' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to update D2L details' }, 403) + end + + d2l = unit.d2l_assessment_mapping + + if d2l.id != params[:id].to_i + error!({ error: 'D2L details not found' }, 404) + end + + accepted_params = ActionController::Parameters.new(params).permit(:org_unit_id, :grade_object_id) + + d2l.update!(accepted_params) + present d2l, with: D2lIntegrationApi::Entities::D2lEntity + end + + desc 'Initiate a login to D2L as a convenor or admin' + post '/d2l/login_url' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Not authorised to login to D2L' }, 403) + end + + begin + response = D2lIntegration.login_url(current_user) + rescue StandardError => e + error!({ error: e.message }, 500) + end + + present response, with: Grape::Presenters::Presenter + end + + desc 'Trigger the posting of grades to D2L' + post '/units/:unit_id/d2l/grades' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to post grades to D2L' }, 403) + end + + if unit.d2l_assessment_mapping.blank? + error!({ error: 'Configure D2L details for unit before starting transfer' }, 403) + end + + token = current_user.user_oauth_tokens.where(provider: :d2l).last + if token.blank? || token.expires_at < 10.minutes.from_now + error!({ error: 'Login to D2L before transferring results' }, 403) + end + + D2lPostGradesJob.perform_async(unit.id, current_user.id) + + status 202 + end + + desc 'Get the result of a grade transfer to D2L' + get '/units/:unit_id/d2l/grades' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view grade transfer results' }, 403) + end + + file_path = D2lIntegration.result_file_path(unit) + unless File.exist?(file_path) + error!({ error: 'No grade transfer result found' }, 404) + end + + content_type 'text/csv' + + stream_file(file_path) + end + + desc 'Determing if grade results are available for a unit' + get '/units/:unit_id/d2l/grades/available' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view grade transfer results' }, 403) + end + + file_path = D2lIntegration.result_file_path(unit) + response = { + available: File.exist?(file_path), + running: D2lIntegration.d2l_grade_job_present?(unit) + } + + present response, with: Grape::Presenters::Presenter + end + + desc 'Determing if unit is weighted' + get '/units/:unit_id/d2l/grades/weighted' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :update) + error!({ error: 'Not authorised to view unit details' }, 403) + end + + d2l = unit.d2l_assessment_mapping + + return false unless d2l.present? && d2l.org_unit_id.present? + + present D2lIntegration.grade_weighted?(d2l, current_user), with: Grape::Presenters::Presenter + end + + desc 'Get D2L api endpoint' + get '/d2l/endpoint' do + unless authorise? current_user, User, :convene_units + error!({ error: 'Not authorised to view D2L endpoint' }, 403) + end + + present D2lIntegration.d2l_api_host, with: Grape::Presenters::Presenter + end + end +end diff --git a/app/api/d2l_integration_api/entities/d2l_entity.rb b/app/api/d2l_integration_api/entities/d2l_entity.rb new file mode 100644 index 0000000000..95454802b1 --- /dev/null +++ b/app/api/d2l_integration_api/entities/d2l_entity.rb @@ -0,0 +1,9 @@ +module D2lIntegrationApi + module Entities + class D2lEntity < Grape::Entity + expose :id + expose :org_unit_id + expose :grade_object_id + end + end +end diff --git a/app/api/d2l_integration_api/oauth_public_api.rb b/app/api/d2l_integration_api/oauth_public_api.rb new file mode 100644 index 0000000000..04e050cff7 --- /dev/null +++ b/app/api/d2l_integration_api/oauth_public_api.rb @@ -0,0 +1,22 @@ +require 'grape' + +module D2lIntegrationApi + # Public api for oauth callback + class OauthPublicApi < Grape::API + include LogHelper + + desc 'Callback for oauth login' + params do + requires :code, type: String, desc: 'The code returned from the OAuth login' + requires :state, type: String, desc: 'The state returned from the OAuth login' + end + get '/d2l/callback' do + D2lIntegration.process_callback(params[:code], params[:state]) + + host = Doubtfire::Application.config.institution[:host] + redirect "#{host}/success-close" + rescue StandardError => e + error!({ error: "Error processing oauth callback: #{e.message}" }, 500) + end + end +end diff --git a/app/api/discussion_comment_api.rb b/app/api/discussion_comment_api.rb index 51bf67ecce..ffd70117fc 100644 --- a/app/api/discussion_comment_api.rb +++ b/app/api/discussion_comment_api.rb @@ -5,6 +5,7 @@ class DiscussionCommentApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers FileStreamHelper before do authenticated? @@ -29,7 +30,7 @@ class DiscussionCommentApi < Grape::API for attached_file in attached_files do if attached_file.present? - error!(error: 'Attachment is empty.') unless File.size?(attached_file["tempfile"].path).present? + error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank? error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 end end @@ -38,7 +39,7 @@ class DiscussionCommentApi < Grape::API logger.info("#{current_user.username} - added discussion comment for task #{task.id} (#{task_definition.abbreviation})") - if attached_files.nil? || attached_files.empty? + if attached_files.blank? error!({ error: 'Audio prompts are empty, unable to add new discussion comment' }, 403) end @@ -78,37 +79,9 @@ class DiscussionCommentApi < Grape::API # mark as attachment if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{prompt_path}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end - # Work out what part to return - file_size = File.size(prompt_path) - begin_point = 0 - end_point = file_size - 1 - - # Was it asked for just a part of the file? - if request.headers['Range'] - # indicate partial content - status 206 - - # extract part desired from the content - if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ - begin_point = Regexp.last_match(1).to_i - end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? - end - - end_point = file_size - 1 unless end_point < file_size - 1 - end - - # Return the requested content - content_length = end_point - begin_point + 1 - header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" - header['Content-Length'] = content_length.to_s - header['Accept-Ranges'] = 'bytes' - - # Read the binary data and return - result = File.binread(prompt_path, content_length, begin_point) - result + stream_file prompt_path end end @@ -140,38 +113,10 @@ class DiscussionCommentApi < Grape::API # mark as attachment if params[:as_attachment] - header['Content-Disposition'] = "attachment; filename=#{response_path}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' + header['Content-Disposition'] = "attachment; filename=response.ogg" end - # Work out what part to return - file_size = File.size(response_path) - begin_point = 0 - end_point = file_size - 1 - - # Was it asked for just a part of the file? - if request.headers['Range'] - # indicate partial content - status 206 - - # extract part desired from the content - if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ - begin_point = Regexp.last_match(1).to_i - end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? - end - - end_point = file_size - 1 unless end_point < file_size - 1 - end - - # Return the requested content - content_length = end_point - begin_point + 1 - header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" - header['Content-Length'] = content_length.to_s - header['Accept-Ranges'] = 'bytes' - - # Read the binary data and return - result = File.binread(response_path, content_length, begin_point) - result + stream_file response_path end end @@ -191,13 +136,13 @@ class DiscussionCommentApi < Grape::API attached_file = params[:attachment] if attached_file.present? - error!(error: 'Attachment is empty.') unless File.size?(attached_file["tempfile"].path).present? + error!(error: 'Attachment is empty.') if File.size?(attached_file["tempfile"].path).blank? error!(error: 'Attachment exceeds the maximum attachment size of 30MB.') unless File.size?(attached_file["tempfile"].path) < 30_000_000 end logger.info("#{current_user.username} - added a reply to the discussion comment #{params[:task_comment_id]} for task #{task.id} (#{task_definition.abbreviation})") - if attached_file.nil? || attached_file.empty? + if attached_file.blank? error!({ error: 'Discussion reply is empty, unable to add new reply to discussion comment' }, 403) end diff --git a/app/api/discussion_prompts_api.rb b/app/api/discussion_prompts_api.rb new file mode 100644 index 0000000000..34b11641be --- /dev/null +++ b/app/api/discussion_prompts_api.rb @@ -0,0 +1,122 @@ +require 'grape' + +class DiscussionPromptsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc "Get all the discussion prompts for a task definition" + params do + requires :task_definition_id, type: Integer, desc: 'Task definition to fetch discussion prompts for' + end + get '/task_definitions/:task_definition_id/discussion_prompts' do + task_definition = TaskDefinition.find(params[:task_definition_id]) + + unless authorise? current_user, task_definition, :get_discussion_prompt + error!({ error: 'You do not have permission to access this project' }, 403) + end + + result = task_definition.discussion_prompts + .order(priority: :desc) + + present result, with: Entities::DiscussionPromptEntity, user: current_user + end + + desc "Create a new discussion prompt for a task definition" + params do + requires :task_definition_id, type: Integer, desc: 'Task definition to fetch discussion prompts for' + requires :content, type: String, desc: 'Discussion prompt content' + requires :priority, type: Integer, desc: 'The priority of the disucssion prompt' + end + post '/task_definitions/:task_definition_id/discussion_prompts' do + task_definition = TaskDefinition.find(params[:task_definition_id]) + + unless authorise? current_user, task_definition, :create_discussion_prompt + error!({ error: 'You do not have permission to access this project' }, 403) + end + + unit = task_definition.unit + + content = params[:content].to_s + priority = params[:priority].to_i + + discussion_prompt = DiscussionPrompt.create!({ + task_definition: task_definition, + content: content, + priority: priority + }) + + present discussion_prompt, with: Entities::DiscussionPromptEntity + end + + desc "Update a discussion prompt for a task definition" + params do + requires :task_definition_id, type: Integer, desc: 'Task definition to fetch discussion prompts for' + requires :content, type: String, desc: 'Discussion prompt content' + requires :priority, type: Integer, desc: 'The priority of the disucssion prompt' + requires :id, type: Integer, desc: 'The ID of the discussion prompt' + end + put '/task_definitions/:task_definition_id/discussion_prompts/:id' do + task_definition = TaskDefinition.find(params[:task_definition_id]) + + unless authorise? current_user, task_definition, :create_discussion_prompt + error!({ error: 'You do not have permission to access this project' }, 403) + end + + discussion_prompt = task_definition.discussion_prompts.find(params[:id]) + + content = params[:content].to_s + priority = params[:priority].to_i + + discussion_prompt.update({ + task_definition: task_definition, + content: content, + priority: priority + }) + end + + desc "Delete a discussion prompt for a task definition" + params do + requires :task_definition_id, type: Integer, desc: 'Task definition to fetch discussion prompts for' + requires :id, type: Integer, desc: 'The ID of the discussion prompt' + end + delete '/task_definitions/:task_definition_id/discussion_prompts/:id' do + task_definition = TaskDefinition.find(params[:task_definition_id]) + + unless authorise? current_user, task_definition, :create_discussion_prompt + error!({ error: 'You do not have permission to access this project' }, 403) + end + + discussion_prompt = task_definition.discussion_prompts.find(params[:id]) + + discussion_prompt.destroy! + present discussion_prompt.destroyed?, with: Grape::Presenters::Presenter + end + + desc "Get all the discussion prompts for a project" + params do + requires :project_id, type: Integer, desc: 'Project to fetch discussion prompts for' + end + get 'projects/:project_id/discussion_prompts' do + project = Project.find(params[:project_id]) + + # TODO: should convenor permissions exist on the project ? + unless authorise? current_user, project, :get_discussion_prompt + error!({ error: 'You do not have permission to access this project' }, 403) + end + + tasks_to_discuss = project.tasks.where(task_status: [TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.attention_required, TaskStatus.demonstrate]) + task_definition_ids = tasks_to_discuss.pluck(:task_definition_id) + + result = DiscussionPrompt + .joins(:task_definition) + .where(task_definition_id: task_definition_ids) + .order('task_definitions.abbreviation ASC, discussion_prompts.priority DESC') + + present result, with: Entities::DiscussionPromptEntity, user: current_user + end + +end diff --git a/app/api/engagements_api.rb b/app/api/engagements_api.rb new file mode 100644 index 0000000000..b26d687ead --- /dev/null +++ b/app/api/engagements_api.rb @@ -0,0 +1,223 @@ +require 'grape' + +class EngagementsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers FileStreamHelper + + before do + authenticated? + end + + helpers do + def engagement_for(project) + project.engagements.find(params[:id]) + end + + def validate_evidence!(attachment, evidence_url, remove_evidence: false) + if remove_evidence && (attachment.present? || evidence_url.present?) + error!({ error: 'Cannot remove evidence and provide replacement evidence together.' }, 400) + end + + if attachment.present? && evidence_url.present? + error!({ error: 'Provide either an attachment or an evidence URL, not both.' }, 400) + end + + return nil if attachment.blank? + + error!({ error: 'Attachment is empty.' }, 400) if File.size?(attachment['tempfile'].path).blank? + if File.size?(attachment['tempfile'].path) >= 30_000_000 + error!({ error: 'Attachment exceeds the maximum attachment size of 30MB.' }, 400) + end + + image_result = FileHelper.accept_file(attachment, 'engagement evidence image', 'image') + return 'image' if image_result[:accepted] + + pdf_result = FileHelper.accept_file(attachment, 'engagement evidence PDF', 'document') + return 'pdf' if pdf_result[:accepted] + + error!({ error: "File is not an acceptable image or PDF: #{pdf_result[:msg]}" }, 400) + end + end + + desc 'Get engagements for a project' + get '/projects/:project_id/engagements' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to view these engagements.' }, 403) unless authorise?(current_user, project, :get_engagements) + + engagements = project.engagements + .includes(:user, :engagement_comments) + .order(:occurred_at, :created_at) + present engagements, with: Entities::EngagementEntity + end + + desc 'Get an engagement for a project' + get '/projects/:project_id/engagements/:id' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to view this engagement.' }, 403) unless authorise?(current_user, project, :get_engagements) + + engagement = project.engagements + .includes(:user, engagement_comments: :user) + .find(params[:id]) + present engagement, with: Entities::EngagementDetailEntity + end + + desc 'Create an engagement for a project' + params do + requires :engagement_type, type: String + requires :note, type: String + requires :occurred_at, type: DateTime + optional :evidence_url, type: String + optional :attachment, type: File + end + post '/projects/:project_id/engagements' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to create an engagement.' }, 403) unless authorise?(current_user, project, :create_engagement) + + attachment = params[:attachment] + attachment_type = validate_evidence!(attachment, params[:evidence_url]) + + engagement = project.engagements.create!( + user: current_user, + engagement_type: params[:engagement_type], + note: params[:note], + occurred_at: params[:occurred_at], + evidence_url: attachment.present? ? nil : params[:evidence_url] + ) + + engagement.replace_attachment(attachment, attachment_type) if attachment.present? + present engagement, with: Entities::EngagementDetailEntity + rescue StandardError + engagement&.destroy + raise + end + + desc 'Update an engagement for a project' + params do + optional :engagement_type, type: String + optional :note, type: String + optional :occurred_at, type: DateTime + optional :evidence_url, type: String + optional :attachment, type: File + optional :remove_evidence, type: Boolean, default: false + end + put '/projects/:project_id/engagements/:id' do + project = Project.find(params[:project_id]) + engagement = engagement_for(project) + + can_edit = authorise?(current_user, project, :edit_engagement) && engagement.user_id == current_user.id + error!({ error: 'You do not have permission to edit this engagement.' }, 403) unless can_edit + + attachment = params[:attachment] + evidence_url = params.key?(:evidence_url) ? params[:evidence_url] : nil + attachment_type = validate_evidence!( + attachment, + evidence_url, + remove_evidence: params[:remove_evidence] + ) + + attributes = {} + attributes[:engagement_type] = params[:engagement_type] if params.key?(:engagement_type) + attributes[:note] = params[:note] if params.key?(:note) + attributes[:occurred_at] = params[:occurred_at] if params.key?(:occurred_at) + + if params[:remove_evidence] + engagement.remove_attachment + engagement.evidence_url = nil + elsif attachment.present? + engagement.assign_attributes(attributes) + engagement.replace_attachment(attachment, attachment_type) + attributes = {} + elsif params.key?(:evidence_url) + engagement.remove_attachment + engagement.evidence_url = evidence_url + end + + engagement.update!(attributes) + present engagement.reload, with: Entities::EngagementDetailEntity + end + + desc 'Delete an engagement for a project' + delete '/projects/:project_id/engagements/:id' do + project = Project.find(params[:project_id]) + engagement = engagement_for(project) + error!({ error: 'You do not have permission to delete this engagement.' }, 403) unless authorise?(current_user, project.unit, :delete_engagement) + + engagement.destroy! + present engagement.destroyed?, with: Grape::Presenters::Presenter + end + + desc 'Get evidence attached to an engagement' + params do + optional :as_attachment, type: Boolean, default: false + end + get '/projects/:project_id/engagements/:id/attachment' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to view this evidence.' }, 403) unless authorise?(current_user, project, :get_engagements) + + engagement = engagement_for(project) + error!({ error: 'No attachment for this engagement.' }, 404) unless engagement.attachment? + error!({ error: 'File missing.' }, 404) unless File.exist?(engagement.attachment_path) + + content_type engagement.attachment_mime_type + env['api.format'] = :binary + if params[:as_attachment] + header['Content-Disposition'] = "attachment; filename=#{engagement.attachment_file_name}" + end + + stream_file engagement.attachment_path + end + + desc 'Add a comment to an engagement' + params do + requires :comment, type: String + optional :reply_to_id, type: Integer + end + post '/projects/:project_id/engagements/:id/comments' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to comment on this engagement.' }, 403) unless authorise?(current_user, project, :comment_engagement) + + engagement = engagement_for(project) + reply_to = engagement.engagement_comments.find(params[:reply_to_id]) if params[:reply_to_id].present? + comment = engagement.engagement_comments.create!( + user: current_user, + comment: params[:comment], + reply_to: reply_to + ) + present comment, with: Entities::EngagementCommentEntity + end + + desc 'Update an engagement comment' + params do + requires :comment, type: String + end + put '/projects/:project_id/engagements/:id/comments/:comment_id' do + project = Project.find(params[:project_id]) + error!({ error: 'You do not have permission to comment on this engagement.' }, 403) unless authorise?(current_user, project, :comment_engagement) + + engagement = engagement_for(project) + comment = engagement.engagement_comments.find(params[:comment_id]) + error!({ error: 'You can only edit your own comments.' }, 403) unless comment.user_id == current_user.id + if comment.created_at < 10.minutes.ago + error!({ error: 'Comments can only be edited within 10 minutes of being created.' }, 403) + end + + comment.update!(comment: params[:comment]) + present comment, with: Entities::EngagementCommentEntity + end + + desc 'Delete an engagement comment' + delete '/projects/:project_id/engagements/:id/comments/:comment_id' do + project = Project.find(params[:project_id]) + engagement = engagement_for(project) + comment = engagement.engagement_comments.find(params[:comment_id]) + + can_delete_own = comment.user_id == current_user.id && + authorise?(current_user, project, :comment_engagement) + can_delete_any = authorise?(current_user, project.unit, :delete_engagement) + error!({ error: 'You do not have permission to delete this comment.' }, 403) unless can_delete_own || can_delete_any + + comment.destroy! + present comment.destroyed?, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/entities/campus_entity.rb b/app/api/entities/campus_entity.rb index ccb7d1e356..1699f72a80 100644 --- a/app/api/entities/campus_entity.rb +++ b/app/api/entities/campus_entity.rb @@ -4,6 +4,7 @@ class CampusEntity < Grape::Entity expose :name expose :mode expose :abbreviation + expose :timezone expose :active end end diff --git a/app/api/entities/communication_action_entity.rb b/app/api/entities/communication_action_entity.rb new file mode 100644 index 0000000000..f44ecc2cc9 --- /dev/null +++ b/app/api/entities/communication_action_entity.rb @@ -0,0 +1,13 @@ +module Entities + class CommunicationActionEntity < Grape::Entity + expose :id + expose :type + expose :communication_rule_id + expose :subject + expose :body + expose :email_tutors + expose :email_convenors + expose :target_grade + expose :task_definition_id + end +end diff --git a/app/api/entities/communication_condition_entity.rb b/app/api/entities/communication_condition_entity.rb new file mode 100644 index 0000000000..045b58d96b --- /dev/null +++ b/app/api/entities/communication_condition_entity.rb @@ -0,0 +1,18 @@ +module Entities + class CommunicationConditionEntity < Grape::Entity + expose :id + expose :type + expose :communication_id, as: :communication_rule_id + expose :operator + expose :target_grade + expose :task_definition_id + expose :task_statuses + expose :task_status_count + expose :task_target_grade + expose :last_sign_in_at + expose :spec_con_days + expose :tutorial_id + expose :tutorial_stream_id + expose :campus_id + end +end diff --git a/app/api/entities/communication_rule_entity.rb b/app/api/entities/communication_rule_entity.rb new file mode 100644 index 0000000000..97f56da5a6 --- /dev/null +++ b/app/api/entities/communication_rule_entity.rb @@ -0,0 +1,17 @@ +module Entities + class CommunicationRuleEntity < Grape::Entity + expose :id + expose :communication_set_id + expose :name + expose :operator + expose :position + expose :active + expose :send_log_to_convenors + expose :communication_conditions, + as: :conditions, + using: Entities::CommunicationConditionEntity + expose :communication_actions, + as: :actions, + using: Entities::CommunicationActionEntity + end +end diff --git a/app/api/entities/communication_set_entity.rb b/app/api/entities/communication_set_entity.rb new file mode 100644 index 0000000000..a9b6029b9d --- /dev/null +++ b/app/api/entities/communication_set_entity.rb @@ -0,0 +1,17 @@ +require 'entities/communication_rule_entity' +require 'entities/communication_set_schedule_entity' + +module Entities + class CommunicationSetEntity < Grape::Entity + expose :id + expose :unit_id + expose :name + expose :active + expose :communication_set_schedules, + as: :schedules, + using: Entities::CommunicationSetScheduleEntity + expose :communication_rules, + as: :rules, + using: Entities::CommunicationRuleEntity + end +end diff --git a/app/api/entities/communication_set_schedule_entity.rb b/app/api/entities/communication_set_schedule_entity.rb new file mode 100644 index 0000000000..1c1573b024 --- /dev/null +++ b/app/api/entities/communication_set_schedule_entity.rb @@ -0,0 +1,21 @@ +module Entities + class CommunicationSetScheduleEntity < Grape::Entity + expose :id + expose :communication_set_id + expose :name + expose :active + expose :anchor_week + expose :anchor_day + expose :hour + expose :minute + expose :timezone + expose :recurrence + expose :interval + expose :repeat_count + expose :until_at + expose :ice_cube_schedule + expose :next_run_at + expose :last_run_at + expose :last_enqueued_at + end +end diff --git a/app/api/entities/discussion_prompt_entity.rb b/app/api/entities/discussion_prompt_entity.rb new file mode 100644 index 0000000000..1ffc160f35 --- /dev/null +++ b/app/api/entities/discussion_prompt_entity.rb @@ -0,0 +1,8 @@ +module Entities + class DiscussionPromptEntity < Grape::Entity + expose :id + expose :task_definition_id + expose :content + expose :priority + end +end diff --git a/app/api/entities/engagement_comment_entity.rb b/app/api/entities/engagement_comment_entity.rb new file mode 100644 index 0000000000..4abf5deb8a --- /dev/null +++ b/app/api/entities/engagement_comment_entity.rb @@ -0,0 +1,10 @@ +module Entities + class EngagementCommentEntity < Grape::Entity + expose :id + expose :comment + expose :reply_to_id + expose :user, using: Entities::Minimal::MinimalUserEntity + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/engagement_detail_entity.rb b/app/api/entities/engagement_detail_entity.rb new file mode 100644 index 0000000000..d0bf9fc591 --- /dev/null +++ b/app/api/entities/engagement_detail_entity.rb @@ -0,0 +1,7 @@ +module Entities + class EngagementDetailEntity < EngagementEntity + expose :engagement_comments, + as: :comments, + using: Entities::EngagementCommentEntity + end +end diff --git a/app/api/entities/engagement_entity.rb b/app/api/entities/engagement_entity.rb new file mode 100644 index 0000000000..984fb74015 --- /dev/null +++ b/app/api/entities/engagement_entity.rb @@ -0,0 +1,21 @@ +module Entities + class EngagementEntity < Grape::Entity + expose :id + expose :project_id + expose :engagement_type + expose :note + expose :occurred_at + expose :evidence_url + expose :content_type + expose :has_attachment do |engagement, _options| + engagement.attachment? + end + expose :attachment_file_name, if: ->(engagement, _) { engagement.attachment? } + expose :user, using: Entities::Minimal::MinimalUserEntity + expose :comment_count do |engagement| + engagement.engagement_comments.size + end + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/learning_outcome_entity.rb b/app/api/entities/learning_outcome_entity.rb index 2467bd7426..95d03406d7 100644 --- a/app/api/entities/learning_outcome_entity.rb +++ b/app/api/entities/learning_outcome_entity.rb @@ -1,9 +1,13 @@ module Entities class LearningOutcomeEntity < Grape::Entity expose :id - expose :ilo_number + expose :context_type + expose :context_id expose :abbreviation - expose :name - expose :description + expose :short_description + expose :full_outcome_description + expose :linked_outcome_ids do |learning_outcome, _options| + learning_outcome.linked_outcomes.pluck(:id) + end end end diff --git a/app/api/entities/marking_session_entity.rb b/app/api/entities/marking_session_entity.rb new file mode 100644 index 0000000000..7382451c7a --- /dev/null +++ b/app/api/entities/marking_session_entity.rb @@ -0,0 +1,24 @@ +module Entities + class MarkingSessionEntity < Grape::Entity + expose :id + expose :user_id + expose :unit_id + expose :start_time + expose :end_time + expose :during_tutorial + + expose :duration_minutes + + expose :comments_added do |session, _options| + session.session_activities.count { |act| act.action == 'add-comment' } + end + + expose :assessments do |session, _options| + session.session_activities.count { |act| act.action == 'assessing' } + end + + expose :submissions_opened do |session, _options| + session.session_activities.count { |act| act.action == 'get-submission-details' } + end + end +end diff --git a/app/api/entities/minimal/minimal_unit_entity.rb b/app/api/entities/minimal/minimal_unit_entity.rb index bbead2cd81..44cc958c92 100644 --- a/app/api/entities/minimal/minimal_unit_entity.rb +++ b/app/api/entities/minimal/minimal_unit_entity.rb @@ -20,6 +20,13 @@ class MinimalUnitEntity < Grape::Entity end expose :active + expose :allow_flexible_dates + expose :ordered_task_definitions, + as: :task_definitions, + using: Entities::TaskDefinitionEntity, + if: :include_task_definitions + expose :grade_values + expose :grade_definitions end end end diff --git a/app/api/entities/notification_entity.rb b/app/api/entities/notification_entity.rb new file mode 100644 index 0000000000..a036d4c689 --- /dev/null +++ b/app/api/entities/notification_entity.rb @@ -0,0 +1,10 @@ +module Entities + class NotificationEntity < Grape::Entity + expose :id + expose :notification_type + expose :message + expose :link + expose :read_at + expose :created_at + end +end diff --git a/app/api/entities/overseer_assessment_entity.rb b/app/api/entities/overseer_assessment_entity.rb index b8402dcf59..4e893a5d0b 100644 --- a/app/api/entities/overseer_assessment_entity.rb +++ b/app/api/entities/overseer_assessment_entity.rb @@ -2,10 +2,16 @@ module Entities class OverseerAssessmentEntity < Grape::Entity expose :id expose :task_id + expose :submission_history_id expose :submission_timestamp expose :result_task_status expose :status expose :created_at expose :updated_at + + expose :total_steps + expose :passed_steps + + expose :has_submission_files?, as: :has_submission_files end end diff --git a/app/api/entities/overseer_step_entity.rb b/app/api/entities/overseer_step_entity.rb new file mode 100644 index 0000000000..b230b577cd --- /dev/null +++ b/app/api/entities/overseer_step_entity.rb @@ -0,0 +1,47 @@ +module Entities + class OverseerStepEntity < Grape::Entity + expose :id + expose :task_definition_id + + def staff?(my_role) + Role.teaching_staff_ids.include?(my_role.id) unless my_role.nil? + end + + expose :name, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :description, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :display_name + expose :display_description + + expose :run_command, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :timeout, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :sort_order, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :step_type + expose :partial_output_diff, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :stdin_input_file, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :expected_output_file, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :feedback_message, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :status_on_success, + if: ->(_obj, options) { staff?(options[:my_role]) } do |overseer_step| + TaskStatus.find_by(id: overseer_step.status_on_success_id)&.status_key || 'no_change' + end + + expose :status_on_failure, + if: ->(_obj, options) { staff?(options[:my_role]) } do |overseer_step| + TaskStatus.find_by(id: overseer_step.status_on_failure_id)&.status_key || 'no_change' + end + + expose :halt_on_success, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :halt_on_failure, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :show_expected_output, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :show_stdin, if: ->(_unit, options) { staff?(options[:my_role]) } + expose :show_stdout, if: ->(_unit, options) { staff?(options[:my_role]) } + + expose :enabled + end +end diff --git a/app/api/entities/overseer_step_result_entity.rb b/app/api/entities/overseer_step_result_entity.rb new file mode 100644 index 0000000000..eeebd0caa4 --- /dev/null +++ b/app/api/entities/overseer_step_result_entity.rb @@ -0,0 +1,30 @@ +module Entities + class OverseerStepResultEntity < Grape::Entity + + def staff?(my_role) + Role.teaching_staff_ids.include?(my_role.id) unless my_role.nil? + end + + expose :id + expose :overseer_step_id + expose :exit_status + expose :pass + expose :feedback_message + + expose :stdout, if: lambda { |result, options| + staff?(options[:my_role]) || result.overseer_step&.show_stdout + } + + expose :stdin, if: lambda { |result, options| + staff?(options[:my_role]) || result.overseer_step&.show_stdin + } + + expose :expected_output, if: lambda { |result, options| + staff?(options[:my_role]) || result.overseer_step&.show_expected_output + } + + expose :stdout_sha256 + expose :stdin_sha256 + expose :expected_output_sha256 + end +end diff --git a/app/api/entities/project_entity.rb b/app/api/entities/project_entity.rb index 0f3672f66d..ac4a26c955 100644 --- a/app/api/entities/project_entity.rb +++ b/app/api/entities/project_entity.rb @@ -10,23 +10,27 @@ class ProjectEntity < Grape::Entity expose :enrolled, unless: :for_student expose :target_grade + expose :spec_con_days + expose :submitted_grade, unless: :summary_only expose :portfolio_files, unless: :summary_only expose :compile_portfolio, unless: :summary_only expose :portfolio_available + expose :portfolio_submission_date, if: :for_staff expose :uses_draft_learning_summary, unless: :summary_only expose :task_stats, as: :stats, unless: :for_student - expose :tasks, using: TaskEntity, unless: :summary_only do |project, options| + expose :tasks, using: TaskEntity, if: ->(project, options) { !options[:summary_only] || options[:include_task_definitions] } do |project, options| project.task_details_for_shallow_serializer(options[:user]) end expose :tutorial_enrolments, using: TutorialEnrolmentEntity, unless: :summary_only expose :groups, using: GroupEntity, unless: :summary_only - expose :task_outcome_alignments, using: TaskOutcomeAlignmentEntity, unless: :summary_only expose :grade, if: :for_staff expose :grade_rationale, if: :for_staff + + expose :escalation_attempts_remaining end end diff --git a/app/api/entities/push_subscription_entity.rb b/app/api/entities/push_subscription_entity.rb new file mode 100644 index 0000000000..32a865022c --- /dev/null +++ b/app/api/entities/push_subscription_entity.rb @@ -0,0 +1,12 @@ +module Entities + class PushSubscriptionEntity < Grape::Entity + expose :id + expose :endpoint + expose :created_at + expose :updated_at + + # p256dh and auth are deliberately not exposed. They are the browser's own + # encryption material, the client already holds them, and nothing in the UI + # needs them read back. + end +end diff --git a/app/api/entities/session_activity_entity.rb b/app/api/entities/session_activity_entity.rb new file mode 100644 index 0000000000..e9902d2cae --- /dev/null +++ b/app/api/entities/session_activity_entity.rb @@ -0,0 +1,12 @@ +module Entities + class SessionActivityEntity < Grape::Entity + expose :id + expose :marking_session_id + expose :action + expose :project_id + expose :task_id + expose :task_definition_id + expose :created_at + expose :updated_at + end +end diff --git a/app/api/entities/sidekiq_job_entity.rb b/app/api/entities/sidekiq_job_entity.rb new file mode 100644 index 0000000000..31627799c9 --- /dev/null +++ b/app/api/entities/sidekiq_job_entity.rb @@ -0,0 +1,17 @@ +module Entities + class SidekiqJobEntity < Grape::Entity + expose :jid, as: :id + expose :status + expose :pct_complete + expose :message + + expose :at, as: :processed_count + expose :total, as: :total_count + expose :working_at, as: :created_at + expose :update_time, as: :updated_at + + expose :worker, as: :job_class + + expose :result + end +end diff --git a/app/api/entities/staff_note_entity.rb b/app/api/entities/staff_note_entity.rb new file mode 100644 index 0000000000..468a298a55 --- /dev/null +++ b/app/api/entities/staff_note_entity.rb @@ -0,0 +1,14 @@ +module Entities + class StaffNoteEntity < Grape::Entity + expose :id + + expose :note + expose :user_id + + expose :created_at + expose :updated_at + + expose :reply_to_id + + end +end diff --git a/app/api/entities/submission_history_entity.rb b/app/api/entities/submission_history_entity.rb new file mode 100644 index 0000000000..d33eee7193 --- /dev/null +++ b/app/api/entities/submission_history_entity.rb @@ -0,0 +1,13 @@ +module Entities + class SubmissionHistoryEntity < Grape::Entity + expose :id + expose :task_id + expose :submission_timestamp + expose :created_at + expose :has_submission_files?, as: :has_submission_files + + expose :overseer_assessment_id do |history| + history.overseer_assessment&.id + end + end +end diff --git a/app/api/entities/task_definition_entity.rb b/app/api/entities/task_definition_entity.rb index 94ba180d48..6027226c3d 100644 --- a/app/api/entities/task_definition_entity.rb +++ b/app/api/entities/task_definition_entity.rb @@ -21,12 +21,14 @@ def staff?(my_role) expose :start_date end - expose :upload_requirements do |task_definition, options| + expose :grade_due_date_overrides, as: :grade_due_dates, expose_nil: false + + expose :upload_requirements, expose_nil: false do |task_definition, options| if staff?(options[:my_role]) task_definition.upload_requirements else # Filter out turn it in details - task_definition.upload_requirements.map { |r| r.except('tii_check', 'tii_pct') } + task_definition.upload_requirements.map { |r| r.except('tii_check', 'tii_pct') } unless task_definition.upload_requirements.nil? end end @@ -35,14 +37,40 @@ def staff?(my_role) end expose :plagiarism_warn_pct, if: ->(unit, options) { staff?(options[:my_role]) } expose :restrict_status_updates, if: ->(unit, options) { staff?(options[:my_role]) } - expose :group_set_id + expose :group_set_id, expose_nil: false expose :has_task_sheet?, as: :has_task_sheet expose :has_task_resources?, as: :has_task_resources expose :has_task_assessment_resources?, as: :has_task_assessment_resources, if: ->(unit, options) { staff?(options[:my_role]) } + expose :has_task_assessment_script?, as: :has_task_assessment_script, if: ->(unit, options) { staff?(options[:my_role]) } + expose :has_scorm_data?, as: :has_scorm_data + expose :scorm_enabled + expose :scorm_allow_review + expose :scorm_bypass_test + expose :scorm_time_delay_enabled + expose :scorm_attempt_limit + expose :has_jplag_report?, as: :has_jplag_report, if: ->(unit, options) { staff?(options[:my_role]) } expose :is_graded expose :max_quality_pts - expose :overseer_image_id, if: ->(unit, options) { staff?(options[:my_role]) } - expose :assessment_enabled, if: ->(unit, options) { staff?(options[:my_role]) } - expose :moss_language, if: ->(unit, options) { staff?(options[:my_role]) } + expose :overseer_image_id, if: ->(unit, options) { staff?(options[:my_role]) }, expose_nil: false + # expose :assessment_enabled, if: ->(unit, options) { staff?(options[:my_role]) } + expose :assessment_enabled + expose :similarity_language, if: ->(unit, options) { staff?(options[:my_role]) }, expose_nil: false + expose :assess_in_portfolio_only + expose :requires_discussion + expose :use_resources_for_jplag_base_code, if: ->(unit, options) { staff?(options[:my_role]) } + expose :lock_assessments_to_tutorial_stream, if: ->(unit, options) { staff?(options[:my_role]) } + + expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos + + expose :discussion_prompts_count do |task_def| + task_def.discussion_prompts.size + end + + # expose :overseer_steps, using: OverseerStepEntity, if: ->(unit, options) { staff?(options[:my_role]) } + expose :overseer_steps, using: OverseerStepEntity do |task_def, options| + task_def.overseer_steps # options[:my_role] is still available inside the entity + end + expose :overseer_resource_files, if: ->(task_def, options) { staff?(options[:my_role]) } + end end diff --git a/app/api/entities/task_entity.rb b/app/api/entities/task_entity.rb index cd88b53eb4..1de8fb6b39 100644 --- a/app/api/entities/task_entity.rb +++ b/app/api/entities/task_entity.rb @@ -14,9 +14,12 @@ class TaskEntity < Grape::Entity expose :due_date expose :submission_date, expose_nil: false expose :completion_date, expose_nil: false + expose :target_due_date, expose_nil: false + expose :target_start_date, expose_nil: false end expose :extensions + expose :scorm_extensions expose :times_assessed expose :grade, expose_nil: false diff --git a/app/api/entities/task_outcome_alignment_entity.rb b/app/api/entities/task_outcome_alignment_entity.rb deleted file mode 100644 index 4215db7418..0000000000 --- a/app/api/entities/task_outcome_alignment_entity.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Entities - class TaskOutcomeAlignmentEntity < Grape::Entity - expose :id - expose :description - expose :rating - expose :learning_outcome_id - expose :task_definition_id - expose :task_id, expose_nil: false - end -end diff --git a/app/api/entities/task_prerequisite_entity.rb b/app/api/entities/task_prerequisite_entity.rb new file mode 100644 index 0000000000..8b3a3fd819 --- /dev/null +++ b/app/api/entities/task_prerequisite_entity.rb @@ -0,0 +1,10 @@ +module Entities + class TaskPrerequisiteEntity < Grape::Entity + expose :id + expose :task_definition_id + expose :prerequisite_id + expose :task_status do |task_prerequisite| + TaskStatus.find(task_prerequisite.task_status_id).status_key + end + end +end diff --git a/app/api/entities/teaching_period_entity.rb b/app/api/entities/teaching_period_entity.rb index 67a9b570e6..772e2b3e89 100644 --- a/app/api/entities/teaching_period_entity.rb +++ b/app/api/entities/teaching_period_entity.rb @@ -9,7 +9,7 @@ class TeachingPeriodEntity < Grape::Entity expose :active do |teaching_period, options| object.active_until > DateTime.now end - expose :breaks, if: :full_details, using: Entities::BreakEntity + expose :breaks, if: :include_breaks, using: Entities::BreakEntity expose :units, if: :full_details do |teaching_period, options| Entities::UnitEntity.represent teaching_period.units, summary_only: true, user: options[:user] end diff --git a/app/api/entities/test_attempt_entity.rb b/app/api/entities/test_attempt_entity.rb new file mode 100644 index 0000000000..d0d5ebc072 --- /dev/null +++ b/app/api/entities/test_attempt_entity.rb @@ -0,0 +1,12 @@ +module Entities + class TestAttemptEntity < Grape::Entity + expose :id + expose :task_id + expose :attempted_time + expose :terminated + expose :success_status + expose :score_scaled + expose :completion_status + expose :cmi_datamodel + end +end diff --git a/app/api/entities/tutor_note_entity.rb b/app/api/entities/tutor_note_entity.rb new file mode 100644 index 0000000000..1184b01d2a --- /dev/null +++ b/app/api/entities/tutor_note_entity.rb @@ -0,0 +1,21 @@ +module Entities + class TutorNoteEntity < Grape::Entity + expose :id + + expose :note + expose :unit_role_id + expose :user_id + + expose :created_at + expose :updated_at + + expose :reply_to_id + + expose :task_id + expose :task_definition_id + expose :project_id + + expose :read_by_unit_role + + end +end diff --git a/app/api/entities/unit_entity.rb b/app/api/entities/unit_entity.rb index 4ac79cb157..d23ee49f60 100644 --- a/app/api/entities/unit_entity.rb +++ b/app/api/entities/unit_entity.rb @@ -34,7 +34,13 @@ def can_read_unit_config?(my_role) expose :portfolio_auto_generation_date, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) }, expose_nil: false end + expose :current_unit_week do |unit| + unit.week_number(Time.current) + end + expose :active + expose :grade_values + expose :grade_definitions expose :overseer_image_id, unless: :summary_only, if: lambda { |unit, options| can_read_unit_config?(options[:my_role]) } expose :assessment_enabled, unless: :summary_only @@ -47,6 +53,13 @@ def can_read_unit_config?(my_role) expose :allow_student_extension_requests, unless: :summary_only expose :extension_weeks_on_resubmit_request, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } expose :allow_student_change_tutorial, unless: :summary_only + expose :allow_flexible_dates, unless: :summary_only + expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only + expose :peer_progress_enabled, + unless: :summary_only, + if: lambda { |_unit, options| + can_read_unit_config?(options[:my_role]) + } expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only @@ -56,12 +69,16 @@ def can_read_unit_config?(my_role) expose :tutorials, using: TutorialEntity, unless: :summary_only # expose :tutorial_enrolments, using: TutorialEnrolmentEntity, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } - expose :task_definitions, using: TaskDefinitionEntity, unless: :summary_only - expose :task_outcome_alignments, using: TaskOutcomeAlignmentEntity, unless: :summary_only + expose :ordered_task_definitions, as: :task_definitions, using: TaskDefinitionEntity, unless: :summary_only expose :group_sets, using: GroupSetEntity, unless: :summary_only expose :groups, using: GroupEntity, unless: :summary_only # expose :group_memberships, using: GroupMembershipEntity, unless: :summary_only do |unit, options| # unit.group_memberships.where(active: true) # end + + expose :feedback_warning_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } + expose :feedback_overflow_threshold_days, unless: :summary_only, if: lambda { |unit, options| is_staff?(options[:my_role]) } + + expose :enforce_feedback_before_discussed_in_class, if: lambda { |unit, options| is_staff?(options[:my_role]) } end end diff --git a/app/api/entities/unit_role_entity.rb b/app/api/entities/unit_role_entity.rb index 08159ea44e..34539fa82b 100644 --- a/app/api/entities/unit_role_entity.rb +++ b/app/api/entities/unit_role_entity.rb @@ -1,8 +1,24 @@ module Entities class UnitRoleEntity < Grape::Entity + + def staff?(my_role) + [Role.tutor_id, Role.convenor_id, Role.admin_id, Role.auditor_id].include?(my_role.id) unless my_role.nil? + end + expose :id expose :role do |unit_role, options| unit_role.role.name end expose :user, using: Entities::Minimal::MinimalUserEntity expose :unit, using: Entities::Minimal::MinimalUnitEntity, unless: :in_unit + expose :observer_only + + expose :mentor_id, if: ->(unit_role, options) { staff?(options[:my_role]) } + expose :tutor_note_count, if: ->(unit_role, options) { unit_role.unit.unit_role_for(options[:user])&.id == unit_role.id } do |unit_role, options| + unit_role.tutor_notes + .where(read_by_unit_role: false) + .where.not(user_id: options[:user].id) + .count + end + + expose :can_mark_overflow_tasks, if: ->(unit_role, options) { staff?(options[:my_role]) } end end diff --git a/app/api/entities/user_entity.rb b/app/api/entities/user_entity.rb index 9ede2faf8b..f7a4c53f69 100644 --- a/app/api/entities/user_entity.rb +++ b/app/api/entities/user_entity.rb @@ -10,10 +10,11 @@ class UserEntity < Grape::Entity expose :receive_task_notifications, unless: :minimal expose :receive_portfolio_notifications, unless: :minimal expose :receive_feedback_notifications, unless: :minimal + expose :display_peer_progress, unless: :minimal expose :opt_in_to_research, unless: :minimal expose :has_run_first_time_setup, unless: :minimal - expose :accepted_tii_eula, unless: :minimal, if: ->(user, options) { Doubtfire::Application.config.tii_enabled } do |user, options| + expose :accepted_tii_eula, unless: :minimal, if: ->(user, options) { TurnItIn.enabled? } do |user, options| if TiiActionFetchFeaturesEnabled.eula_required? TurnItIn.eula_version == user.tii_eula_version else diff --git a/app/api/extension_comments_api.rb b/app/api/extension_comments_api.rb index 3bde9171de..1d510f0799 100644 --- a/app/api/extension_comments_api.rb +++ b/app/api/extension_comments_api.rb @@ -19,6 +19,10 @@ class ExtensionCommentsApi < Grape::API error!({ error: 'Not authorised to request an extension for this task' }, 403) end + if project.unit.allow_flexible_dates + error!({ error: 'Extensions are disabled for this unit.' }, 403) + end + error!({ error: 'Extension weeks can not be 0.' }, 403) if params[:weeks_requested] == 0 max_duration = task.weeks_can_extend diff --git a/app/api/feedback/entities/feedback_chip_entity.rb b/app/api/feedback/entities/feedback_chip_entity.rb new file mode 100644 index 0000000000..0c3a2768df --- /dev/null +++ b/app/api/feedback/entities/feedback_chip_entity.rb @@ -0,0 +1,24 @@ +module Feedback + module Entities + class FeedbackChipEntity < Grape::Entity + expose :id + expose :type do |chip| + case chip.type + when 'Feedback::FeedbackTemplateChip' + 'template' + when 'Feedback::FeedbackGroupChip' + 'group' + else + 'unknown' + end + end + expose :chip_text + expose :description + expose :task_status + expose :parent_chip_id + expose :learning_outcome_id + expose :summary_text + expose :comment_text + end + end +end diff --git a/app/api/feedback/entities/feedback_group_chip_entity.rb b/app/api/feedback/entities/feedback_group_chip_entity.rb new file mode 100644 index 0000000000..2af0dac425 --- /dev/null +++ b/app/api/feedback/entities/feedback_group_chip_entity.rb @@ -0,0 +1,21 @@ +module Feedback + module Entities + class FeedbackGroupChipEntity < Grape::Entity + expose :id + expose :type do |chip| + case chip.type + when 'Feedback::FeedbackTemplateChip' + 'template' + when 'Feedback::FeedbackGroupChip' + 'group' + else + 'unknown' + end + end + expose :chip_text + expose :description + expose :parent_chip_id + expose :learning_outcome_id + end + end +end diff --git a/app/api/feedback/entities/feedback_template_chip_entity.rb b/app/api/feedback/entities/feedback_template_chip_entity.rb new file mode 100644 index 0000000000..26eb97cc22 --- /dev/null +++ b/app/api/feedback/entities/feedback_template_chip_entity.rb @@ -0,0 +1,25 @@ +module Feedback + module Entities + class FeedbackTemplateChipEntity < Grape::Entity + expose :id + expose :type do |chip| + case chip.type + when 'Feedback::FeedbackTemplateChip' + 'template' + when 'Feedback::FeedbackGroupChip' + 'group' + else + 'unknown' + end + end + expose :chip_text + expose :description + expose :task_status + expose :parent_chip_id + expose :learning_outcome_id + + expose :comment_text + expose :summary_text + end + end +end diff --git a/app/api/feedback/feedback_chip_api.rb b/app/api/feedback/feedback_chip_api.rb new file mode 100644 index 0000000000..6b364ad47a --- /dev/null +++ b/app/api/feedback/feedback_chip_api.rb @@ -0,0 +1,287 @@ +require 'grape' +module Feedback + class FeedbackChipApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers MimeCheckHelpers + helpers CsvHelper + helpers FileHelper + + before do + authenticated? + end + + desc "Get all feedback chips for a context" + params do + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + requires :context_id, type: Integer, desc: 'The ID of the context' + end + get '/:context_type_plural/:context_id/feedback_chips' do + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + + unless authorise? current_user, context_model, :get_feedback_chips + error!({ error: 'You are not authorised to view feedback chips in this context.' }, 403) + end + + learning_outcomes = context_model.learning_outcomes + feedback_chips = FeedbackChip.where(learning_outcome_id: learning_outcomes.pluck(:id)) + present feedback_chips, with: Feedback::Entities::FeedbackChipEntity + end + + desc "Get all feedback chips for a global context" + get '/global/feedback_chips' do + unless authorise? current_user, User, :get_feedback_chips + error!({ error: 'You are not authorised to view feedback chips globally.' }, 403) + end + + present Feedback::FeedbackChip.global_chips, with: Feedback::Entities::FeedbackChipEntity + end + + desc 'Add a feedback chip to a learning outcome' + params do + requires :chip_text, type: String, desc: 'The title of the feedback chip' + optional :parent_chip_id, type: Integer, desc: 'The parent chip ID of the feedback chip' + requires :learning_outcome_id, type: Integer, desc: 'The learning outcome of the feedback chip' + requires :description, type: String, desc: 'The description of the feedback chip' + optional :task_status, type: String, desc: 'The task status of the feedback template chip' + optional :comment_text, type: String, desc: 'The comment text of the feedback template chip' + optional :summary_text, type: String, desc: 'The summary text of the feedback template chip' + requires :type, type: String, desc: 'The type of the feedback chip (template or group)' + end + post '/feedback_chips' do + learning_outcome = LearningOutcome.find(params[:learning_outcome_id]) + + unless authorise? current_user, learning_outcome, :create_feedback_chips + error!({ error: 'You are not authorised to create feedback chips.' }, 403) + end + + chip_class = params[:type] == 'template' ? FeedbackTemplateChip : FeedbackGroupChip + chip = chip_class.create(declared(params, include_missing: false).except(:type)) + + if params[:type] == 'group' + + if learning_outcome.context_type == 'TaskDefinition' + task_definition = TaskDefinition.find(learning_outcome.context_id) + unit = task_definition.unit + group_id = "#{unit.id}-#{task_definition.id}-#{learning_outcome.abbreviation}-#{chip.chip_text}" + elsif learning_outcome.context_type == 'Unit' + unit = Unit.find(learning_outcome.context_id) + group_id = "#{unit.id}-#{learning_outcome.abbreviation}-#{chip.chip_text}" + elsif learning_outcome.context_type.nil? && learning_outcome.context_id.nil? + group_id = "global-#{learning_outcome.abbreviation}-#{chip.chip_text}" + else + error!({ error: 'Invalid context type' }, 400) + end + chip.update(summary_text: group_id) + end + entity = params[:type] == 'template' ? Entities::FeedbackTemplateChipEntity : Entities::FeedbackGroupChipEntity + present chip, with: entity + end + + desc 'Update a feedback chip' + params do + requires :id, type: Integer, desc: 'The ID of the feedback chip' + optional :chip_text, type: String, desc: 'The title of the feedback chip' + optional :parent_chip_id, type: Integer, desc: 'The parent chip ID of the feedback chip' + # optional :learning_outcome_id, type: Integer, desc: 'The learning outcome of the feedback chip' + optional :description, type: String, desc: 'The description of the feedback chip' + optional :task_status, type: String, desc: 'The task status of the feedback template chip' + optional :comment_text, type: String, desc: 'The comment text of the feedback template chip' + optional :summary_text, type: String, desc: 'The summary text of the feedback template chip' + # optional :type, type: String, desc: 'The type of the feedback chip (template or group)' + end + put '/feedback_chips/:id' do + chip = FeedbackChip.find(params[:id]) + + unless authorise? current_user, chip, :update_chip + error!({ error: 'You are not authorised to update feedback chips.' }, 403) + end + + chip.update(declared(params, include_missing: false).except(:type)) + + # Ensure we have the right group ID for group chips + if chip.type == 'Feedback::FeedbackGroupChip' && params.key?(:chip_text) + learning_outcome = chip.learning_outcome + if learning_outcome.context_type == 'TaskDefinition' + task_definition = learning_outcome.context + unit = task_definition.unit + group_chip_id = "#{unit.id}-#{task_definition.id}-#{learning_outcome.abbreviation}-#{chip.chip_text}" + elsif learning_outcome.context_type == 'Unit' + unit = Unit.find(learning_outcome.context_id) + group_chip_id = "#{unit.id}-#{learning_outcome.abbreviation}-#{chip.chip_text}" + elsif learning_outcome.context_type.nil? && learning_outcome.context_id.nil? + group_chip_id = "global-#{learning_outcome.abbreviation}-#{chip.chip_text}" + else + error!({ error: 'Invalid context type' }, 400) + end + chip.update(comment_text: group_chip_id) + end + + entity = chip.type == 'Feedback::FeedbackTemplateChip' ? Entities::FeedbackTemplateChipEntity : Entities::FeedbackGroupChipEntity + present chip, with: entity + end + + desc 'Delete a feedback chip' + params do + requires :id, type: Integer, desc: 'The ID of the feedback chip' + end + delete '/feedback_chips/:id' do + chip = FeedbackChip.find(params[:id]) + + unless authorise? current_user, chip, :delete_feedback_chips + error!({ error: 'You are not authorised to delete feedback chips.' }, 403) + end + + chip.destroy + nil + end + + desc 'Track usage of a feedback template chip by a tutor' + params do + requires :id, type: Integer, desc: 'The ID of the feedback template chip' + requires :tutor_id, type: Integer, desc: 'The ID of the tutor' + end + post '/feedback_template_chip/:id/track_usage' do + chip = FeedbackTemplateChip.find(params[:id]) + tutor = Tutor.find(params[:tutor_id]) + + chip.track_usage_by_tutor(tutor) + nil + end + + desc 'Download the feedback chips for a specific outcome' # change to specific context rather than outcome + params do + requires :id, type: Integer, desc: 'The ID of the context' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + end + get '/:context_type_plural/:context_id/outcomes/:id/feedback_chips/csv' do + # find context model dynamically + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + learning_outcome = LearningOutcome.find(params[:id]) + + unless authorise? current_user, context_model, :create_feedback_chips + error!({ error: 'You are not authorised to download feedback chips in this context.' }, 403) + end + + title = learning_outcome.abbreviation + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{title}-FeedbackChips.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + learning_outcome.export_feedback_chips_to_csv + end + + desc 'Download the feedback chips for a specific context' + params do + requires :context_id, type: Integer, desc: 'The ID of the context' + optional :includes_tlos, type: Boolean, desc: 'Include TLOs in the export' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + end + get '/:context_type_plural/:context_id/feedback_chips/csv' do + include_tlos = params[:includes_tlos] || false + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + + unless authorise? current_user, context_model, :create_feedback_chips + error!({ error: 'You are not authorised to download feedback chips in this context.' }, 403) + end + + title = context_model.name + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{title}-FeedbackChips.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + + context_model.export_feedback_chips_to_csv(include_tlos: include_tlos) + end + + desc 'Upload the feedback chips for a specified outcome from a csv' # change to specific context rather than outcome + params do + requires :file, type: File, desc: 'CSV upload file.' + requires :id, type: Integer, desc: 'The id of the learning outcome' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + requires :context_id, type: Integer, desc: 'The ID of the context' + end + post '/:context_type_plural/:context_id/outcomes/:id/feedback_chips/csv' do + # check mime is correct before uploading + ensure_csv!(params[:file][:tempfile]) + + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + + # find context model dynamically + learning_outcome = context_model.learning_outcomes.find(params[:id]) + + unless authorise? current_user, learning_outcome, :upload_csv + error!({ error: 'Not authorised to upload CSV of feedback chips for this outcome' }, 403) + end + + # Actually import... + Feedback::FeedbackChip.import_feedback_chips_from_csv(params[:file][:tempfile], 'LearningOutcome', learning_outcome) + end + + desc 'Upload the feedback chips for a specified context from a csv' + params do + requires :file, type: File, desc: 'CSV upload file.' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + requires :context_id, type: Integer, desc: 'The ID of the context' + end + post '/:context_type_plural/:context_id/feedback_chips/csv' do + # check mime is correct before uploading + ensure_csv!(params[:file][:tempfile]) + + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + + unless authorise? current_user, context_model, :create_feedback_chips + error!({ error: "Not authorised to upload CSV of feedback chips for #{context_type}" }, 403) + end + + # Actually import... + Feedback::FeedbackChip.import_feedback_chips_from_csv(params[:file][:tempfile], context_type, context_model) + end + + desc 'Download the feedback chips for a global context' + get '/global/feedback_chips/csv' do + unless authorise? current_user, User, :create_feedback_chips + error!({ error: 'You are not authorised to download feedback chips globally.' }, 403) + end + + title = 'Global_Learning_Outcomes' + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{title}-FeedbackChips.csv" + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + + glos = LearningOutcome.where(context_id: nil, context_type: nil) + CSV.generate do |row| + row << Feedback::FeedbackChip.csv_header + glos.each do |lo| + lo.feedback_chips.each do |chip| + chip.add_csv_row row + end + end + end + end + + desc 'Upload the feedback chips for a global context from a csv' + params do + requires :file, type: File, desc: 'CSV upload file.' + end + post '/global/feedback_chips/csv' do + # check mime is correct before uploading + ensure_csv!(params[:file][:tempfile]) + + unless authorise? current_user, User, :create_feedback_chips + error!({ error: 'Not authorised to upload CSV of feedback' }, 403) + end + + Feedback::FeedbackChip.import_feedback_chips_from_csv(params[:file][:tempfile], nil, nil) + end + end +end diff --git a/app/api/group_sets_api.rb b/app/api/group_sets_api.rb index ef231f2d3c..eb78a85d4e 100644 --- a/app/api/group_sets_api.rb +++ b/app/api/group_sets_api.rb @@ -220,7 +220,7 @@ class GroupSetsApi < Grape::API end num = group_set.groups.count + 1 - while group_params[:name].nil? || group_params[:name].empty? || group_set.groups.where(name: group_params[:name]).count > 0 + while group_params[:name].blank? || group_set.groups.where(name: group_params[:name]).count > 0 group_params[:name] = "Group #{num}" num += 1 end diff --git a/app/api/learning_alignment_api.rb b/app/api/learning_alignment_api.rb deleted file mode 100644 index 0a8900bb97..0000000000 --- a/app/api/learning_alignment_api.rb +++ /dev/null @@ -1,238 +0,0 @@ -require 'grape' - -class LearningAlignmentApi < Grape::API - helpers AuthenticationHelpers - helpers AuthorisationHelpers - helpers MimeCheckHelpers - - before do - authenticated? - end - - desc 'Get the task/outcome alignment details for a unit or a project' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - optional :project_id, type: Integer, desc: 'The id of the student project to get the alignment from' - end - get '/units/:unit_id/learning_alignments' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access this unit.' }, 403) - end - - if params[:project_id].nil? - present unit.task_outcome_alignments, with: Entities::TaskOutcomeAlignmentEntity - else - proj = unit.projects.find(params[:project_id]) - unless authorise?(current_user, proj, :get) - error!({ error: 'You are not authorised to access this project.' }, 403) - end - present proj.task_outcome_alignments, with: Entities::TaskOutcomeAlignmentEntity - end - end - - desc 'Download CSV of task alignments in this unit' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - optional :project_id, type: Integer, desc: 'The id of the student project to get the alignment from' - end - get '/units/:unit_id/learning_alignments/csv' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access this unit.' }, 403) - end - - if params[:project_id].nil? - unless authorise? current_user, unit, :download_unit_csv - error!({ error: "Not authorised to download CSV of task alignment in #{unit.code}" }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-Alignment.csv" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' - env['api.format'] = :binary - unit.export_task_alignment_to_csv - else - proj = unit.projects.find(params[:project_id]) - unless authorise?(current_user, proj, :get) - error!({ error: 'You are not authorised to access this project.' }, 403) - end - - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{proj.student.name}-Task-Alignment.csv" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' - env['api.format'] = :binary - - proj.export_task_alignment_to_csv - end - end - - desc 'Upload CSV of task to outcome alignments' - params do - requires :file, type: File, desc: 'CSV upload file.' - optional :project_id, type: Integer, desc: 'The id of the student project to upload the alignment to' - end - post '/units/:unit_id/learning_alignments/csv' do - ensure_csv!(params[:file][:tempfile]) - - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access this unit.' }, 403) - end - - if params[:project_id].nil? - unless authorise? current_user, unit, :upload_csv - error!({ error: "Not authorised to upload CSV of task alignment to #{unit.code}" }, 403) - end - - # Actually import... - unit.import_task_alignment_from_csv(params[:file][:tempfile], nil) - else - proj = unit.projects.find(params[:project_id]) - unless authorise?(current_user, proj, :make_submission) - error!({ error: 'You are not authorised to access this project.' }, 403) - end - - unit.import_task_alignment_from_csv(params[:file][:tempfile], proj) - end - end - - desc "Add an outcome to a unit's task definition" - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - requires :learning_outcome_id, type: Integer, desc: 'The id of the learning outcome' - requires :task_definition_id, type: Integer, desc: 'The id of the task definition' - optional :project_id, type: Integer, desc: 'The id of the project if this is a self reflection' - requires :description, type: String, desc: 'The ILO''s description' - requires :rating, type: Integer, desc: 'The rating for this link, indicating the strength of this alignment' - end - post '/units/:unit_id/learning_alignments' do - unit = Unit.find(params[:unit_id]) - - # if there is no project -- then this is a unit LO link - # so need to check the user is authorised to update the unit... - if params[:project_id].nil? && !authorise?(current_user, unit, :update) - error!({ error: 'You are not authorised to create task alignments in this unit.' }, 403) - end - - unit.learning_outcomes.find(params[:learning_outcome_id]) - - task_def = unit.task_definitions.find(params[:task_definition_id]) - - link_parameters = ActionController::Parameters.new(params) - .permit( - :task_definition_id, - :learning_outcome_id, - :description, - :rating - ) - - unless params[:project_id].nil? - project = unit.projects.find(params[:project_id]) - task = project.task_for_task_definition(task_def) - - unless authorise?(current_user, task, :make_submission) - error!({ error: 'You are not authorised to create outcome alignments for this task.' }, 403) - end - - link_parameters[:task_id] = task.id - end - - result = LearningOutcomeTaskLink.create! link_parameters - present result, with: Entities::TaskOutcomeAlignmentEntity - end - - desc 'Update the alignment between a task and unit outcome' - params do - requires :id, type: Integer, desc: 'The id of the task alignment' - requires :unit_id, type: Integer, desc: 'The id of the unit' - optional :description, type: String, desc: 'The description of the alignment' - optional :rating, type: Integer, desc: 'The rating for this link, indicating the strength of this alignment' - end - put '/units/:unit_id/learning_alignments/:id' do - unit = Unit.find(params[:unit_id]) - - align = unit.learning_outcome_task_links.find(params[:id]) - - link_parameters = ActionController::Parameters.new(params) - .permit( - :description, - :rating - ) - - # is this a project task alignment update? - if !align.task_id.nil? - task = align.task - - unless authorise?(current_user, task, :make_submission) - error!({ error: 'You are not authorised to update outcome alignments for this task.' }, 403) - end - - # else, this is a unit alignment update! - elsif !authorise?(current_user, unit, :update) - error!({ error: 'You are not authorised to update the task alignments in this unit.' }, 403) - end - - align.update(link_parameters) - align.save! - present align, with: Entities::TaskOutcomeAlignmentEntity - end - - desc 'Delete the alignment between a task and unit outcome' - params do - requires :id, type: Integer, desc: 'The id of the task alignment' - requires :unit_id, type: Integer, desc: 'The id of the unit' - end - delete '/units/:unit_id/learning_alignments/:id' do - unit = Unit.find(params[:unit_id]) - - align = unit.learning_outcome_task_links.find(params[:id]) - - # is this a project task alignment update? - if !align.task_id.nil? - task = align.task - - unless authorise?(current_user, task, :make_submission) - error!({ error: 'You are not authorised to update outcome alignments for this task.' }, 403) - end - - # else, this is a unit alignment update! - elsif !authorise?(current_user, unit, :update) - error!({ error: 'You are not authorised to update the task alignments in this unit.' }, 403) - end - - align.destroy! - nil - end - - desc 'Return unit learning alignment median values' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - end - get '/units/:unit_id/learning_alignments/class_stats' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_unit) - error!({ error: 'You are not authorised to access these task alignments.' }, 403) - end - - present unit.ilo_progress_class_stats - end - - desc 'Return unit learning alignment values with median stats for each tutorial' - params do - requires :unit_id, type: Integer, desc: 'The id of the unit' - end - get '/units/:unit_id/learning_alignments/class_details' do - unit = Unit.find(params[:unit_id]) - - unless authorise?(current_user, unit, :get_students) - error!({ error: 'You are not authorised to access these task alignments.' }, 403) - end - - present unit.ilo_progress_class_details - end -end diff --git a/app/api/learning_outcomes_api.rb b/app/api/learning_outcomes_api.rb index 2ee8f524f0..d7752777e2 100644 --- a/app/api/learning_outcomes_api.rb +++ b/app/api/learning_outcomes_api.rb @@ -4,111 +4,207 @@ class LearningOutcomesApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers helpers MimeCheckHelpers + helpers CsvHelper before do authenticated? end - desc 'Add an outcome to a unit' + desc 'Get all global outcomes' + get '/global/outcomes' do + # can be read by any logged in user + present LearningOutcome.global_outcomes, with: Entities::LearningOutcomeEntity + end + + desc 'Add an outcome to a specified context (unit, task_definition, etc.)' params do - requires :unit_id, type: Integer, desc: 'The unit ID for which the ILO belongs to' - requires :name, type: String, desc: 'The ILO''s name' - requires :description, type: String, desc: 'The ILO''s description' - optional :abbreviation, type: String, desc: 'The ILO''s new abbreviation' + requires :abbreviation, type: String, desc: 'The ILO''s abbreviation' + requires :short_description, type: String, desc: 'The ILO''s short_description' + optional :full_outcome_description, type: String, desc: 'The ILO''s full_outcome_description' + optional :linked_outcome_ids, type: Array[Integer], desc: 'The ids of the linked outcome ids' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] end - post '/units/:unit_id/outcomes' do - unit = Unit.find(params[:unit_id]) + post '/:context_type_plural/:context_id/outcomes' do + # find context model dynamically + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to create outcomes in this unit.' }, 403) + unless authorise? current_user, context_model, :update + error!({ error: 'You are not authorised to create outcomes in this context.' }, 403) end - ilo = unit.add_ilo(params[:name], params[:description], params[:abbreviation]) - present ilo, with: Entities::LearningOutcomeEntity + outcome_params = declared(params, include_missing: false).except(:linked_outcome_ids, :context_type_plural) + outcome_params[:context_type] = context_type + + learning_outcome = context_model.learning_outcomes.create!(outcome_params) + learning_outcome.update_linked_outcomes(params[:linked_outcome_ids]) + + present learning_outcome, with: Entities::LearningOutcomeEntity end - desc 'Update ILO' + desc "Add a global outcome" params do - requires :unit_id, type: Integer, desc: 'The unit ID for which the ILO belongs to' - optional :name, type: String, desc: 'The ILO''s new name' - optional :description, type: String, desc: 'The ILO''s new description' - optional :abbreviation, type: String, desc: 'The ILO''s new abbreviation' - optional :ilo_number, type: Integer, desc: 'The ILO''s new sequence number' - end - put '/units/:unit_id/outcomes/:id' do - unit = Unit.find(params[:unit_id]) - error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? - - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to update outcomes in this unit.' }, 403) + requires :abbreviation, type: String, desc: 'The ILO''s abbreviation' + requires :short_description, type: String, desc: 'The ILO''s short_description' + optional :full_outcome_description, type: String, desc: 'The ILO''s full_outcome_description' + optional :linked_outcome_ids, type: Array[Integer], desc: 'The ids of the linked outcome ids' + end + post '/global/outcomes' do + # find learning outcomes with a null context_type and context_id + + unless authorise? current_user, User, :update_glos + error!({ error: 'You are not authorised to create global outcomes.' }, 403) end - ilo = unit.learning_outcomes.find(params[:id]) - error!({ error: 'Unable to locate outcome requested.' }, 405) if ilo.nil? + outcome_params = declared(params, include_missing: false).except(:linked_outcome_ids) + outcome_params[:context_type] = nil + outcome_params[:context_id] = nil + glo = LearningOutcome.create!(outcome_params) + glo.update_linked_outcomes(params[:linked_outcome_ids]) + + present glo, with: Entities::LearningOutcomeEntity + end + + desc 'Update an outcome in a specified context (unit, course, task_definition, ect.)' + params do + requires :context_id, type: Integer, desc: 'The id of the context' + optional :abbreviation, type: String, desc: 'The ILO''s abbreviation' + optional :short_description, type: String, desc: 'The ILO''s short_description' + optional :full_outcome_description, type: String, desc: 'The ILO''s full_outcome_description' + optional :linked_outcome_ids, type: Array[Integer], desc: 'The ids of the linked outcome ids' + # optional :ilo_number, type: Integer, desc: 'The ILO''s new sequence number' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + end + put '/:context_type_plural/:context_id/outcomes/:id' do + # find context model dynamically + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + + unless authorise? current_user, context_model, :update + error!({ error: 'You are not authorised to update outcomes in this context.' }, 403) + end + + learning_outcome = context_model.learning_outcomes.find(params[:id]) + error!({ error: 'Unable to locate outcome requested.' }, 405) if learning_outcome.nil? + + learning_outcome_parameters = declared(params, include_missing: false).except(:linked_outcome_ids, :context_id, :context_type_plural, :context_type) + + learning_outcome.update!(learning_outcome_parameters) + + if params[:linked_outcome_ids] + learning_outcome.update_linked_outcomes(params[:linked_outcome_ids]) + end + + present learning_outcome, with: Entities::LearningOutcomeEntity + end + + desc 'Update a global outcome' + params do + optional :abbreviation, type: String, desc: 'The ILO''s abbreviation' + optional :short_description, type: String, desc: 'The ILO''s short_description' + optional :full_outcome_description, type: String, desc: 'The ILO''s full_outcome_description' + optional :linked_outcome_ids, type: Array[Integer], desc: 'The ids of the linked outcome ids' + end + put '/global/outcomes/:id' do + # find learning outcomes with a null context_type and context_id + glo = LearningOutcome.find(params[:id]) + + unless authorise? current_user, glo, :update_glos + error!({ error: 'You are not authorised to update global outcomes.' }, 403) + end + + ilo_parameters = declared(params, include_missing: false).except(:linked_outcome_ids) + glo.update!(ilo_parameters) - ilo_parameters = ActionController::Parameters.new(params) - .permit( - :name, - :description, - :abbreviation - ) - unit.move_ilo(ilo, params[:ilo_number]) if params[:ilo_number] - ilo.update!(ilo_parameters) - present ilo, with: Entities::LearningOutcomeEntity + if params[:linked_outcome_ids] + learning_outcome.update_linked_outcomes(params[:linked_outcome_ids]) + end + present glo, with: Entities::LearningOutcomeEntity end - desc 'Delete an outcome from a unit' + desc 'Delete an outcome from a specified context (unit, course, task_definition, ect.)' params do - requires :unit_id, type: Integer, desc: 'The id for the unit' + requires :context_id, type: Integer, desc: 'The id of the context' requires :id, type: Integer, desc: 'The id for the outcome you wish to delete' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] end - delete '/units/:unit_id/outcomes/:id' do - unit = Unit.find(params[:unit_id]) - error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? + delete '/:context_type_plural/:context_id/outcomes/:id' do + # find context model dynamically + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to delete outcomes in this unit.' }, 403) + unless authorise? current_user, context_model, :update + error!({ error: 'You are not authorised to delete outcomes in this context.' }, 403) end - ilo = unit.learning_outcomes.find(params[:id]) + ilo = context_model.learning_outcomes.find(params[:id]) error!({ error: 'Unable to locate outcome requested.' }, 405) if ilo.nil? ilo.destroy nil end - desc 'Download the outcomes for a unit to a csv' - get '/units/:unit_id/outcomes/csv' do - unit = Unit.find(params[:unit_id]) - error!({ error: 'Unable to locate requested unit.' }, 405) if unit.nil? + desc 'Delete a global outcome' + params do + requires :id, type: Integer, desc: 'The id for the outcome you wish to delete' + end + delete '/global/outcomes/:id' do + # find learning outcomes with a null context_type and context_id + glo = LearningOutcome.find(params[:id]) + + unless authorise? current_user, glo, :update_glos + error!({ error: 'You are not authorised to delete global outcomes.' }, 403) + end + + glo.destroy + nil + end - unless authorise? current_user, unit, :update - error!({ error: 'You are not authorised to download outcomes for this unit.' }, 403) + desc 'Download the outcomes for a specified context (unit, course, task_definition, ect.) to a csv' # add a way to get nested outcomes aswell + params do + requires :context_id, type: Integer, desc: 'The id of the context' + optional :includes_tlos, type: Boolean, desc: 'Include nested task learning outcomes in the export' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] + end + get '/:context_type_plural/:context_id/outcomes/csv' do + # find context model dynamically + include_tlos = params[:includes_tlos] || false + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) + + unless authorise? current_user, context_model, :update + error!({ error: 'You are not authorised to download outcomes for this context.' }, 403) end + title = context_model.respond_to?(:export_title) ? context_model.export_title : 'LearningOutcomes' + content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-LearningOutcomes.csv" + header['Content-Disposition'] = "attachment; filename=#{title}-LearningOutcomes.csv" header['Access-Control-Expose-Headers'] = 'Content-Disposition' env['api.format'] = :binary - unit.export_learning_outcome_to_csv + + context_model.export_learning_outcome_to_csv(include_tlos: include_tlos) end - desc 'Upload the outcomes for a unit from a csv' + desc 'Upload the outcomes for a specified context (unit, course, task_definition, ect.) from a csv' # make it a generic upload for any context params do requires :file, type: File, desc: 'CSV upload file.' - requires :unit_id, type: Integer, desc: 'The unit to upload tasks to' + requires :context_id, type: Integer, desc: 'The id of the context' + requires :context_type_plural, type: String, desc: 'The context - a unit or task_definition', values: %w[units task_definitions] end - post '/units/:unit_id/outcomes/csv' do + post '/:context_type_plural/:context_id/outcomes/csv' do # check mime is correct before uploading ensure_csv!(params[:file][:tempfile]) - unit = Unit.find(params[:unit_id]) + # find context model dynamically + context_type = params[:context_type_plural].singularize.camelize + context_model = context_type.classify.constantize.find(params[:context_id]) - unless authorise? current_user, unit, :upload_csv + unless authorise? current_user, context_model, :upload_csv error!({ error: 'Not authorised to upload CSV of outcomes' }, 403) end # Actually import... - unit.import_outcomes_from_csv(params[:file][:tempfile]) + context_model.import_outcomes_from_csv(params[:file][:tempfile]) end end diff --git a/app/api/lti_api.rb b/app/api/lti_api.rb new file mode 100644 index 0000000000..7e9bbd4e23 --- /dev/null +++ b/app/api/lti_api.rb @@ -0,0 +1,184 @@ +require 'grape' + +class LtiApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers LtiHelper + helpers SidekiqHelper + include LogHelper + + # before do + # authenticated? + # end + + desc 'Returns success if current user is allowed to link requested unit' + params do + requires :ltik, type: String, desc: 'LtiKey provided with user info and deeplink resources' + end + post '/lti/link' do + authenticated? + + unless authorise? current_user, User, :convene_units + error!({ error: "Not authorised to link this unit." }, 403) + end + + token = decode_lti_token(params[:ltik]) + + unit_id = token["unit_id"] + if unit_id.nil? + error!({ error: 'Invalid LTI token.' }, 400) + end + + unit = Unit.find_by(id: unit_id) + + if unit.nil? + error!({ error: 'Unit does not exist.' }, 404) + end + + unless authorise? current_user, unit, :enrol_student + error!({ error: "Not authorised to link this unit." }, 403) + end + + status 200 + end + + desc 'Enrol a student into a linked Lti unit' + params do + requires :ltik, type: String, desc: 'LtiKey provided with user info and deeplink resources' + end + post '/lti/enrol' do + authenticated? + + token = decode_lti_token(params[:ltik]) + + unit_id = token["unit_id"] + if unit_id.nil? + error!({ error: 'Invalid LTI token.' }, 400) + end + + unit = Unit.find_by(id: unit_id) + if unit.nil? + error!({ error: 'Unit does not exist.' }, 404) + end + + member = token['member'] + if member.nil? + error!({ error: 'Invalid LTI token.' }, 400) + end + + valid_member, missing = valid_lti_member?(member) + unless valid_member + error!({ error: "Missing required fields: #{missing.join(', ')}" }, 400) + end + + # if current_user.role_id != Role.student_id + # return status 204 + # end + + # role = unit.role_for(current_user) + # if !role.nil? && role != Role.student + # # error!({ error: 'Failed to enrol, user is already staff.' }, 400) + # return status 204 + # end + + unit_role = Doubtfire::Application.config.institution_settings.should_employ_lti_member(member) + unless unit_role.nil? + unit.employ_staff(current_user, unit_role) + end + + unless Doubtfire::Application.config.institution_settings.should_enrol_lti_member(member) + # error!({ error: 'User can not be enrolled into this unit.' }, 404) + return status 204 + end + + # TODO: which campus? + project = unit.enrol_student(current_user, nil) + + present project, with: Entities::ProjectEntity, user: current_user, for_student: true, in_project: true + end + + desc 'Enrol a list of students into a linked Lti unit' + params do + requires :ltik, type: String, desc: 'LtiKey provided with unit id and list of members' + # requires :members, type: Array, + # requires :email, type: String + # requires :family_name, type: String + # requires :given_name, type: String + # requires :name, type: String + # requires :user_id, type: String + # requires :roles, type: Array[String] + # end + end + post '/lti/enrol/bulk' do + authenticated? + + token = decode_lti_token(params[:ltik]) + + unit_id = token["unit_id"] + if unit_id.nil? + error!({ error: 'Invalid LTI token.' }, 400) + end + + unit = Unit.find_by(id: unit_id) + if unit.nil? + error!({ error: 'Unit does not exist.' }, 404) + end + + unless authorise? current_user, unit, :enrol_student + error!({ error: "Not authorised to link this unit." }, 403) + end + + job_id = ImportStudentsLtiJob.perform_async(unit.id, token['members']) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Get grades for a list of students' + params do + requires :ltik, type: String, desc: 'LtiKey provided with user info and deeplink resources' + end + post '/lti/grades' do + authenticated? + + token = decode_lti_token(params[:ltik]) + + unless authorise? current_user, User, :convene_units + error!({ error: "Not authorised to sync grades." }, 403) + end + + unit_id = token["unit_id"] + if unit_id.nil? + error!({ error: 'Invalid LTI token.' }, 400) + end + + unit = Unit.find_by(id: unit_id) + if unit.nil? + error!({ error: 'Unit does not exist.' }, 404) + end + + student_emails = token["student_emails"] + + if student_emails.nil? + error!({ error: 'Student emails field does not exist.' }, 400) + end + + unless student_emails.is_a?(Array) + error!({ error: 'Student emails must be an array.' }, 400) + end + + projects = unit.projects.joins(:user).where(users: { email: student_emails }) + + projects_hash = {} + + projects.each do |project| + projects_hash[project.user.email] = if authorise?(current_user, project, :assess) + project.grade + else + # Let the lti API know that user doesnt have permission to this project + -1 + end + end + + projects_hash + end +end diff --git a/app/api/marking_sessions_api.rb b/app/api/marking_sessions_api.rb new file mode 100644 index 0000000000..36d16af2f5 --- /dev/null +++ b/app/api/marking_sessions_api.rb @@ -0,0 +1,58 @@ +require 'grape' + +class MarkingSessionsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + format :json + prefix :api + + before do + authenticated? + end + + # Raw marking session data endpoints + desc "Retrieve all marking session records for a unit" + params do + requires :unit_id, type: Integer, desc: "Unit ID" + optional :start_date, type: Date, desc: "Start date for analytics" + optional :end_date, type: Date, desc: "End date for analytics" + optional :timezone, type: String, desc: "Requested timezone to search sessions for" + end + get '/units/:unit_id/marking_sessions' do + unit = Unit.find(params[:unit_id]) + + unless authorise?(current_user, unit, :get_tutor_times) + error!({ error: "You are not authorised to view marking sessions for this unit" }, 403) + end + + unit_role = unit.unit_role_for(current_user) + unless unit_role || current_user.role == Role.admin + error!({ error: "You are not authorised to view marking sessions for this unit" }, 403) + end + + tz = Time.zone + tz = ActiveSupport::TimeZone[params[:timezone]] if params[:timezone] + + end_date = if params[:end_date].present? + tz.parse(params[:end_date].to_s).end_of_day + else + tz.today.end_of_day + end + + start_date = if params[:start_date].present? + tz.parse(params[:start_date].to_s).beginning_of_day + else + (end_date - 7.days).beginning_of_day + end + + sessions = MarkingSession + .includes(:session_activities) + .where(unit: unit) + .where(start_time: start_date..end_date) + + sessions = sessions.where(user_id: current_user.id) if unit_role && unit_role&.role != Role.convenor + + present sessions, with: Entities::MarkingSessionEntity + end +end diff --git a/app/api/notifications_api.rb b/app/api/notifications_api.rb new file mode 100644 index 0000000000..b40315e768 --- /dev/null +++ b/app/api/notifications_api.rb @@ -0,0 +1,59 @@ +require 'grape' + +class NotificationsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get the current user notifications' + params do + optional :unread_only, type: Boolean, default: false, desc: 'Only return unread notifications' + end + get '/notifications' do + notifications = current_user.notifications.recent_first + notifications = notifications.unread if params[:unread_only] + + present notifications, with: Entities::NotificationEntity + end + + desc 'Get the current user unread notification count' + get '/notifications/unread_count' do + { count: current_user.notifications.unread.count } + end + + desc 'Mark a notification as read' + params do + requires :id, type: Integer, desc: 'The notification id' + end + put '/notifications/:id/read' do + notification = current_user.notifications.find(params[:id]) + notification.mark_read! + + present notification, with: Entities::NotificationEntity + end + + desc 'Mark all of the current user notifications as read' + put '/notifications/read_all' do + # rubocop:disable Rails/SkipsModelValidations + current_user.notifications.unread.update_all(read_at: Time.zone.now) + # rubocop:enable Rails/SkipsModelValidations + + status 200 + { success: true } + end + + desc 'Delete a notification' + params do + requires :id, type: Integer, desc: 'The notification id' + end + delete '/notifications/:id' do + notification = current_user.notifications.find(params[:id]) + notification.destroy! + + status 200 + { success: true } + end +end diff --git a/app/api/overseer_steps_api.rb b/app/api/overseer_steps_api.rb new file mode 100644 index 0000000000..018f3cc547 --- /dev/null +++ b/app/api/overseer_steps_api.rb @@ -0,0 +1,209 @@ +require 'grape' + +class OverseerStepsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers SidekiqHelper + + before do + authenticated? + end + + desc 'Add an overseer step' + params do + requires :overseer_step, type: Hash do + requires :name, type: String + optional :description, type: String + optional :display_name, type: String + optional :display_description, type: String + optional :run_command, type: String + optional :timeout, type: Integer + # TODO: rename to execution_order || exec_order? + optional :sort_order, type: Integer + optional :partial_output_diff, type: Boolean + requires :step_type, type: String + optional :stdin_input_file, type: String + optional :expected_output_file, type: String + optional :feedback_message, type: String + optional :status_on_success, type: String + optional :status_on_failure, type: String + optional :halt_on_success, type: Boolean + optional :halt_on_failure, type: Boolean + optional :show_expected_output, type: Boolean + optional :show_stdin, type: Boolean + optional :show_stdout, type: Boolean + optional :enabled, type: Boolean + end + requires :task_def_id, type: Integer + end + post '/units/:unit_id/task_definitions/:task_def_id/overseer_steps' do + unless Doubtfire::Application.config.overseer_enabled + error!({ error: 'Overseer is not enabled. Enable Overseer before updating settings.' }, 403) + end + + task_definition = TaskDefinition.find(params[:task_def_id]) + + unless authorise? current_user, task_definition, :manage_overseer_steps + error!({ error: 'Not authorised to manage overseer for this task definition' }, 403) + end + + status_on_success_param = params[:overseer_step][:status_on_success] + status_on_failure_param = params[:overseer_step][:status_on_failure] + + status_on_success_id = status_on_success_param.present? && status_on_success_param != 'no_change' ? TaskStatus.status_for_name(status_on_success_param)&.id : nil + status_on_failure_id = status_on_failure_param.present? && status_on_failure_param != 'no_change' ? TaskStatus.status_for_name(status_on_failure_param)&.id : nil + + overseer_step_params = ActionController::Parameters.new(params) + .require(:overseer_step) + .permit( + :name, + :description, + :display_name, + :display_description, + :run_command, + :timeout, + :sort_order, + :step_type, + :partial_output_diff, + :stdin_input_file, + :expected_output_file, + :feedback_message, + :status_on_success_id, + :status_on_failure_id, + :halt_on_success, + :halt_on_failure, + :show_expected_output, + :show_stdin, + :show_stdout, + :enabled + ) + .merge(task_definition_id: task_definition.id, + status_on_success_id: status_on_success_id, + status_on_failure_id: status_on_failure_id) + + result = OverseerStep.create!(overseer_step_params) + + if result.nil? + error!({ error: 'No overseer step added' }, 403) + else + present result, with: Entities::OverseerStepEntity + end + end + + desc 'Update an overseer step' + params do + requires :overseer_step, type: Hash do + optional :name, type: String + optional :description, type: String + optional :display_name, type: String + optional :display_description, type: String + optional :run_command, type: String + optional :timeout, type: Integer + optional :sort_order, type: Integer + optional :step_type, type: String + optional :partial_output_diff, type: Boolean + optional :stdin_input_file, type: String + optional :expected_output_file, type: String + optional :feedback_message, type: String + optional :status_on_success, type: String + optional :status_on_failure, type: String + optional :halt_on_success, type: Boolean + optional :halt_on_failure, type: Boolean + optional :show_expected_output, type: Boolean + optional :show_stdin, type: Boolean + optional :show_stdout, type: Boolean + optional :enabled, type: Boolean + end + requires :task_def_id, type: Integer + end + put '/units/:unit_id/task_definitions/:task_def_id/overseer_steps/:id' do + unless Doubtfire::Application.config.overseer_enabled + error!({ error: 'Overseer is not enabled. Enable Overseer before updating settings.' }, 403) + end + + unit = Unit.find(params[:unit_id]) + task_definition = unit.task_definitions.find(params[:task_def_id]) + overseer_step = task_definition.overseer_steps.find(params[:id]) + + unless authorise? current_user, overseer_step.task_definition, :manage_overseer_steps + error!({ error: 'Not authorised to manage overseer for this task definition' }, 403) + end + + status_on_success_param = params[:overseer_step][:status_on_success] + status_on_failure_param = params[:overseer_step][:status_on_failure] + + merge_hash = {} + + if status_on_success_param == 'no_change' + merge_hash[:status_on_success_id] = nil + elsif status_on_success_param.present? + merge_hash[:status_on_success_id] = TaskStatus.status_for_name(status_on_success_param)&.id + end + + if status_on_failure_param == 'no_change' + merge_hash[:status_on_failure_id] = nil + elsif status_on_failure_param.present? + merge_hash[:status_on_failure_id] = TaskStatus.status_for_name(status_on_failure_param)&.id + end + + overseer_step_params = ActionController::Parameters.new(params) + .require(:overseer_step) + .permit( + :name, + :description, + :display_name, + :display_description, + :run_command, + :timeout, + :sort_order, + :step_type, + :partial_output_diff, + :stdin_input_file, + :expected_output_file, + :feedback_message, + :status_on_success_id, + :status_on_failure_id, + :halt_on_success, + :halt_on_failure, + :show_expected_output, + :show_stdin, + :show_stdout, + :enabled + ) + + overseer_step_params = overseer_step_params.merge(merge_hash) + + overseer_step.update!(overseer_step_params) + + present overseer_step, with: Entities::OverseerStepEntity + end + + desc 'Delete an overseer step' + delete '/overseer_steps/:id' do + overseer_step = OverseerStep.find(params[:id]) + + unless authorise? current_user, overseer_step.task_definition, :manage_overseer_steps + error!({ error: 'Not authorised to manage overseer for this task definition' }, 403) + end + + overseer_step.destroy! + + error!({ error: overseer_step.errors.full_messages.last }, 403) unless overseer_step.destroyed? + + present overseer_step.destroyed?, with: Grape::Presenters::Presenter + end + + desc 'Get test results for an overseer assessment' + get '/projects/:project_id/task_definitions/:task_def_id/overseer_assessments_results/:id' do + project = Project.find(params[:project_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: 'Not authorised to view this project' }, 403) + end + + unit = project.unit + + overseer_assessment = OverseerAssessment.find(params[:id]) + present overseer_assessment.overseer_step_results, with: Entities::OverseerStepResultEntity, my_role: unit.role_for(current_user) + end +end diff --git a/app/api/peer_progress_api.rb b/app/api/peer_progress_api.rb new file mode 100644 index 0000000000..c3787e7423 --- /dev/null +++ b/app/api/peer_progress_api.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require 'grape' + +class PeerProgressApi < Grape::API + helpers AuthenticationHelpers + + UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.' + NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.' + CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.' + # These two constants are a pair and must not be changed independently. + # + # The zero and hundred edge buckets only hide the underlying submitted count + # while half a bucket is wider than one student's share of the peer-only + # cohort. At 20 remaining peers, one peer is exactly five percentage points + # and zero becomes a singleton bucket. A floor of 21 remaining peers makes + # one peer's share smaller than the boundary, so every returned bucket + # represents at least two possible peer counts. + # + # 21 and 10.0 leave no cohort size at or above the floor from which the count + # can be recovered. peer_progress_api_test.rb asserts the relationship holds. + MINIMUM_SAFE_COHORT_SIZE = 21 + PERCENTAGE_BUCKET_SIZE = + PeerProgressDistributionPolicy::PERCENTAGE_BUCKET_SIZE + + before do + header 'Cache-Control', 'private, no-store' + authenticated? + end + + helpers do + def peer_progress_not_found! + error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404) + end + + def effective_task(project:, task_definition:) + project.tasks.find_by( + task_definition_id: task_definition.id + ) || Task.new( + project: project, + task_definition: task_definition, + task_status: TaskStatus.not_started, + extensions: 0 + ) + end + + def released_for_project?(project:, task_definition:) + start_date = effective_task( + project: project, + task_definition: task_definition + ).local_start_date + + start_date.present? && start_date <= Time.zone.now + end + + def safe_target_grade(project) + target_grade = project.target_grade + + target_grade if target_grade.present? && + project.unit.grade_value?(target_grade) + end + + def positive_integer_env!(name) + value = Integer(ENV.fetch(name), 10) + raise ArgumentError unless value.positive? + + value + rescue KeyError, ArgumentError + error!({ error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, 503) + end + + def minimum_cohort_size! + value = positive_integer_env!( + 'DF_PPI_MINIMUM_COHORT_SIZE' + ) + + return value if value >= PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE + + error!( + { error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, + 503 + ) + end + + def peer_progress_payload(project:, task_definition:, **overrides) + state = { + snapshot: nil, + submitted_percentage: nil, + completed_percentage: nil, + status_distribution: nil, + distribution_unavailable_reason: nil, + is_suppressed: false, + is_stale: false, + is_feature_enabled: true, + is_user_enabled: current_user.display_peer_progress?, + unavailable_reason: nil, + unavailable_message: '' + } + overrides.assert_valid_keys(*state.keys) + state.merge!(overrides) + + distribution_available = state[:status_distribution].present? + if !distribution_available && + state[:distribution_unavailable_reason].nil? + state[:distribution_unavailable_reason] = state[:unavailable_reason] + end + + { + task_definition_id: task_definition.id, + unit_id: project.unit_id, + target_grade: safe_target_grade(project), + submitted_percentage: state[:submitted_percentage], + completed_percentage: state[:completed_percentage], + status_distribution: state[:status_distribution], + distribution_available: distribution_available, + distribution_unavailable_reason: + state[:distribution_unavailable_reason], + is_suppressed: state[:is_suppressed], + is_stale: state[:is_stale], + is_feature_enabled: state[:is_feature_enabled], + is_user_enabled: state[:is_user_enabled], + last_updated_at: state[:snapshot]&.calculated_at&.utc&.iso8601, + unavailable_reason: state[:unavailable_reason], + unavailable_message: state[:unavailable_message] + } + end + + def peer_progress_result(project:, task_definition:) + unit = project.unit + + unless current_user.display_peer_progress? + return peer_progress_payload( + project: project, + task_definition: task_definition, + is_user_enabled: false, + unavailable_reason: 'user_disabled', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + unless unit.peer_progress_enabled? + return peer_progress_payload( + project: project, + task_definition: task_definition, + is_feature_enabled: false, + unavailable_reason: 'feature_disabled', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + target_grade = safe_target_grade(project) + unless target_grade + return peer_progress_payload( + project: project, + task_definition: task_definition, + unavailable_reason: 'target_grade_unavailable', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + snapshot = unit.peer_progress_snapshots.find_by( + task_definition_id: task_definition.id, + target_grade: target_grade + ) + + if snapshot.nil? || + (project.target_grade_changed_at.present? && + snapshot.calculated_at < project.target_grade_changed_at) + return peer_progress_payload( + project: project, + task_definition: task_definition, + unavailable_reason: 'snapshot_unavailable', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + viewer_task = effective_task( + project: project, + task_definition: task_definition + ) + unless PeerProgressViewerPolicy.viewer_context_current?( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + unavailable_reason: 'snapshot_unavailable', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + minimum_cohort_size = minimum_cohort_size! + stale_after_hours = positive_integer_env!( + 'DF_PPI_STALE_AFTER_HOURS' + ) + + is_stale = snapshot.calculated_at < stale_after_hours.hours.ago + + # Treat an empty cohort exactly like every other cohort below the + # privacy threshold. This prevents the response from revealing + # whether a target-grade group is empty or merely small. + peer_cohort_size = [snapshot.cohort_size - 1, 0].max + if peer_cohort_size < minimum_cohort_size + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_suppressed: true, + is_stale: is_stale, + unavailable_reason: 'insufficient_cohort', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + peer_progress = PeerProgressViewerPolicy.build( + snapshot: snapshot, + viewer_project: project, + viewer_task: viewer_task + ) + if peer_progress.nil? + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_stale: is_stale, + unavailable_reason: 'aggregation_incomplete', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + if is_stale + return peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + is_stale: true, + unavailable_reason: 'stale', + unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE + ) + end + + peer_progress_payload( + project: project, + task_definition: task_definition, + snapshot: snapshot, + **PeerProgressViewerPolicy.public_metrics(peer_progress) + ) + end + end + + desc 'Get anonymous task-level peer progress for the authenticated student', + tags: ['peer_progress'], + summary: 'Get anonymous task-level peer progress' + params do + requires :id, + type: Integer, + desc: 'The authenticated student project ID' + requires :task_definition_id, + type: Integer, + desc: 'The task definition ID' + end + get '/projects/:id/task_def_id/:task_definition_id/peer_progress' do + peer_progress_not_found! if current_user.role.id != Role.student_id + + project = Project.for_user(current_user, false) + .includes(:unit) + .find_by(id: params[:id]) + peer_progress_not_found! if project.nil? + + unit = project.unit + task_definition = unit.task_definitions.find_by( + id: params[:task_definition_id] + ) + peer_progress_not_found! if task_definition.nil? + + peer_progress_not_found! unless released_for_project?( + project: project, + task_definition: task_definition + ) + + target_grade = project.target_grade + if target_grade.present? && unit.grade_value?(target_grade) && + task_definition.target_grade > target_grade + peer_progress_not_found! + end + + present peer_progress_result( + project: project, + task_definition: task_definition + ), with: Grape::Presenters::Presenter + end +end diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb index dc3cbafc62..4fdb08dbd6 100644 --- a/app/api/projects_api.rb +++ b/app/api/projects_api.rb @@ -1,10 +1,39 @@ require 'grape' class ProjectsApi < Grape::API + TASK_DEFINITION_PRELOADS = [ + :discussion_prompts, + :grade_due_dates, + { learning_outcomes: :linked_outcomes }, + :overseer_steps, + :tutorial_stream + ].freeze + helpers AuthenticationHelpers helpers AuthorisationHelpers helpers DbHelpers + helpers do + def notify_portfolio_received(project) + timezone = project.campus&.timezone.presence || Time.zone.name + received_at = project.portfolio_submission_date.in_time_zone(timezone) + submitted_at = received_at.strftime('%-d %B %Y at %-I:%M %p %Z (UTC%:z)') + product_name = Doubtfire::Application.config.institution[:product_name] + + NotificationService.notify( + user: project.student, + type: 'portfolio', + event: 'portfolio_received', + message: "#{product_name} received your portfolio submission at #{submitted_at}.", + link: "/projects/#{project.id}/dashboard" + ) + rescue StandardError => e + Rails.logger.error( + "Failed to raise portfolio_received notification for project #{project.id}: #{e.message}" + ) + end + end + before do authenticated? end @@ -12,12 +41,17 @@ class ProjectsApi < Grape::API desc "Fetches all of the current user's projects" params do optional :include_inactive, type: Boolean, desc: 'Include projects for units that are no longer active?' + optional :include_task_definitions, type: Boolean, desc: 'Include all task definitions with tasks for each project?' end get '/projects' do include_inactive = params[:include_inactive] || false + include_task_definitions = params[:include_task_definitions] || false projects = Project.eager_load(:unit, :user).for_user current_user, include_inactive - present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, user: current_user + if include_task_definitions + projects = projects.preload(unit: { task_definitions: TASK_DEFINITION_PRELOADS }) + end + present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, include_task_definitions: include_task_definitions, user: current_user end desc 'Get project' @@ -34,6 +68,23 @@ class ProjectsApi < Grape::API end end + desc 'Adjust special consideration days' + params do + requires :id, type: Integer, desc: 'The id of the project to adjust' + requires :spec_con_days, type: Integer, desc: 'The number of special consideration days to add or remove' + end + put '/projects/:id/spec_con' do + project = Project.find(params[:id]) + # Can access project and grant spec con + unless authorise?(current_user, project, :get) && authorise?(current_user, project.unit, :grant_spec_con) + error!({ error: 'You are not authorised to perform this action' }, 403) + end + + project.update!(spec_con_days: params['spec_con_days']) + + present project, with: Entities::ProjectEntity, for_staff: true, summary_only: true, user: current_user, only: [:spec_con_days] + end + desc 'Update a project' params do optional :trigger, type: String, desc: 'The update trigger' @@ -81,6 +132,10 @@ class ProjectsApi < Grape::API error!({ error: "You do not have permissions to change this student" }, 403) end + unless project.unit.grade_value?(params[:target_grade]) + error!({ error: 'Target grade is not enabled for this unit' }, 422) + end + project.target_grade = params[:target_grade] project.save elsif !params[:submitted_grade].nil? @@ -90,6 +145,9 @@ class ProjectsApi < Grape::API if project.portfolio_exists? error!({ error: "You cannot change your submitted grade after portfolio submission" }, 403) end + unless project.unit.grade_value?(params[:submitted_grade]) + error!({ error: 'Submitted grade is not enabled for this unit' }, 422) + end project.submitted_grade = params[:submitted_grade] project.save @@ -109,11 +167,11 @@ class ProjectsApi < Grape::API if params[:old_grade] != project.grade error!({ error: 'Existing project grade does not match current grade. Refresh project and try again.' }, 403) end - for_student = false project.grade = params[:grade] project.grade_rationale = params[:grade_rationale] + project.assessor = current_user project.save! present project, Entities::ProjectEntity, for_staff: true @@ -123,10 +181,26 @@ class ProjectsApi < Grape::API error!({ error: "You do not have permissions to change this student" }, 403) end - # if someone changes this setting manually, clear the autogenerated status - project.portfolio_auto_generated = false - project.compile_portfolio = params[:compile_portfolio] - project.save + new_portfolio_submission = false + submission_saved = false + + # Lock the project while deciding whether this is new so concurrent + # retries cannot both observe the old state and send two receipts. + project.with_lock do + # A true request starts a new manual submission when no manual portfolio + # is already queued. Converting a queued auto-generated portfolio into + # a manual submission is also new; retrying the same request is not. + new_portfolio_submission = params[:compile_portfolio] && + (!project.compile_portfolio? || project.portfolio_auto_generated?) + + # if someone changes this setting manually, clear the autogenerated status + project.portfolio_auto_generated = false + project.compile_portfolio = params[:compile_portfolio] + project.portfolio_submission_date = Time.zone.now if new_portfolio_submission + submission_saved = project.save + end + + notify_portfolio_received(project) if submission_saved && new_portfolio_submission end Entities::ProjectEntity.represent(project, only: [:campus_id, :enrolled, :target_grade, :submitted_grade, :compile_portfolio, :portfolio_available, :uses_draft_learning_summary, :stats], for_student: for_student) @@ -182,4 +256,31 @@ class ProjectsApi < Grape::API error!({ error: "Couldn't find Unit with id=#{params[:unit_id]}" }, 403) end end + + desc 'Get task IDs that will be included in portfolio' + get '/projects/:id/portfolio_tasks' do + project = Project.find(params[:id]) + + unless authorise? current_user, project, :get + error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) + end + + portfolio_tasks = project.portfolio_tasks + + present portfolio_tasks.map(&:id) + end + + desc 'Get IDs of tasks that are still processing a PDF ' + get '/projects/:id/tasks_processing' do + project = Project.find(params[:id]) + + unless authorise? current_user, project, :get + error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) + end + + portfolio_tasks = project.tasks_processing_pdf + + present portfolio_tasks.map(&:id) + end + end diff --git a/app/api/push_subscriptions_api.rb b/app/api/push_subscriptions_api.rb new file mode 100644 index 0000000000..b2c67158cc --- /dev/null +++ b/app/api/push_subscriptions_api.rb @@ -0,0 +1,64 @@ +require 'grape' + +class PushSubscriptionsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get the push subscriptions belonging to the current user' + get '/push_subscriptions' do + present current_user.push_subscriptions.order(:id), with: Entities::PushSubscriptionEntity + end + + # The endpoint posted here is later used as the target of an outbound request + # by PushNotificationService, so it is not accepted as free text. + # PushSubscription validates it against PUSH_SERVICE_HOSTS, and a URL that is + # not an https push service URL fails with a 400 from the handler in + # api_root.rb. PushNotificationService checks again before it sends. + desc 'Register this browser to receive push notifications' + params do + requires :endpoint, type: String, desc: 'The push service URL, from PushSubscription.endpoint' + requires :p256dh, type: String, desc: 'The browser public key, from PushSubscription.getKey("p256dh")' + requires :auth, type: String, desc: 'The browser auth secret, from PushSubscription.getKey("auth")' + end + post '/push_subscriptions' do + subscription = current_user.push_subscriptions.find_by(endpoint: params[:endpoint]) + + # Not ours, or not stored yet. An endpoint identifies a browser rather than + # a person, so an endpoint held by another user means someone has signed in + # on a machine that account used. Move the registration across instead of + # failing on the unique index. + # + # This is the only lookup in this file that is not scoped to current_user, + # and it is safe. The push service delivers to that browser no matter which + # row owns it, and the payload is encrypted to the keys posted here. Taking + # over someone else's endpoint cannot read their notifications, it can only + # stop them arriving, and you need the endpoint URL to try it at all. + subscription ||= PushSubscription.find_by(endpoint: params[:endpoint]) || PushSubscription.new + + subscription.assign_attributes( + user: current_user, + endpoint: params[:endpoint], + p256dh: params[:p256dh], + auth: params[:auth] + ) + subscription.save! + + present subscription, with: Entities::PushSubscriptionEntity + end + + desc 'Stop this browser receiving push notifications' + params do + requires :endpoint, type: String, desc: 'The push service URL to remove' + end + delete '/push_subscriptions' do + subscription = current_user.push_subscriptions.find_by!(endpoint: params[:endpoint]) + subscription.destroy! + + status 200 + { success: true } + end +end diff --git a/app/api/scorm_api.rb b/app/api/scorm_api.rb new file mode 100644 index 0000000000..56b258d1aa --- /dev/null +++ b/app/api/scorm_api.rb @@ -0,0 +1,73 @@ +require 'grape' +require 'zip' +require 'mime/types' +class ScormApi < Grape::API + # Include the AuthenticationHelpers for authentication functionality + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? :scorm + end + + helpers do + # Method to stream a file from a zip archive at the specified path + # @param zip_path [String] the path to the zip archive + # @param file_path [String] the path of the file within the zip archive + def stream_file_from_zip(zip_path, file_path) + file_stream = nil + + logger.debug "Streaming zip file at #{zip_path}" + # Get an input stream for the requested file within the ZIP archive + Zip::File.open(zip_path) do |zip_file| + zip_file.each do |entry| + next unless entry.name == file_path + logger.debug "Found file #{file_path} from SCORM container" + file_stream = entry.get_input_stream + break + end + end + + # If the file was not found in the ZIP archive, return a 404 response + unless file_stream + error!({ error: 'File not found' }, 404) + end + + # Set the content type based on the file extension + content_type = MIME::Types.type_for(file_path).first.content_type + logger.debug "Content type: #{content_type}" + + # Set the content type header + header 'Content-Type', content_type + + # Set cache control header to prevent caching + header 'Cache-Control', 'no-cache, no-store, must-revalidate' + + # Set the body to the contents of the file_stream and return the response + body file_stream.read + end + end + + desc 'Serve SCORM content' + params do + requires :task_def_id, type: Integer, desc: 'Task Definition ID to get SCORM test data for' + end + + # NOTE: allow '.' in username; without this Rails/Grape treats '.' as format. + get '/scorm/:task_def_id/:username/:auth_token/*file_path', requirements: { username: %r{[^/]+} } do + task_def = TaskDefinition.find(params[:task_def_id]) + + unless authorise? current_user, task_def.unit, :get_unit + error!({ error: 'You cannot access SCORM tests of unit' }, 403) + end + + env['api.format'] = :txt + if task_def.has_scorm_data? + zip_path = task_def.task_scorm_data + content_type 'application/octet-stream' + stream_file_from_zip(zip_path, params[:file_path]) + else + error!({ error: 'SCORM data does not exist.' }, 404) + end + end +end diff --git a/app/api/scorm_extension_comments_api.rb b/app/api/scorm_extension_comments_api.rb new file mode 100644 index 0000000000..7a8626dc58 --- /dev/null +++ b/app/api/scorm_extension_comments_api.rb @@ -0,0 +1,54 @@ +require 'grape' + +class ScormExtensionCommentsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + desc 'Request a scorm extension for a task' + params do + requires :comment, type: String, desc: 'The details of the request' + end + post '/projects/:project_id/task_def_id/:task_definition_id/request_scorm_extension' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + # check permissions using specific permission has with addition of request extension if allowed in unit + unless authorise? current_user, task, :request_scorm_extension + error!({ error: 'Not authorised to request a scorm extension for this task' }, 403) + end + + if task_definition.scorm_attempt_limit == 0 + error!({ message: 'This task allows unlimited attempts to complete the test' }, 400) + return + end + + result = task.apply_for_scorm_extension(current_user, params[:comment]) + present result.serialize(current_user), Grape::Presenters::Presenter + end + + desc 'Assess a scorm extension for a task' + params do + requires :granted, type: Boolean, desc: 'Assess a scorm extension' + end + put '/projects/:project_id/task_def_id/:task_definition_id/assess_scorm_extension/:task_comment_id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + unless authorise? current_user, task, :assess_scorm_extension + error!({ error: 'Not authorised to assess a scorm extension for this task' }, 403) + end + + task_comment = task.all_comments.find(params[:task_comment_id]).becomes(ScormExtensionComment) + + unless task_comment.assess_scorm_extension(current_user, params[:granted]) + if task_comment.errors.count >= 1 + error!({ error: task_comment.errors.full_messages.first }, 403) + else + error!({ error: 'Error saving scorm extension' }, 403) + end + end + present task_comment.serialize(current_user), Grape::Presenters::Presenter + end +end diff --git a/app/api/settings_api.rb b/app/api/settings_api.rb index 39be2d292a..f5e2a07632 100644 --- a/app/api/settings_api.rb +++ b/app/api/settings_api.rb @@ -1,25 +1,28 @@ require 'grape' class SettingsApi < Grape::API - # - # Returns the current auth method - # - desc 'Return configurable details for the Doubtfire front end' - get '/settings' do - response = { - externalName: Doubtfire::Application.config.institution[:product_name], - overseerEnabled: Doubtfire::Application.config.overseer_enabled, - tiiEnabled: Doubtfire::Application.config.tii_enabled - } + helpers AuthenticationHelpers - present response, with: Grape::Presenters::Presenter + before do + authenticated? end - desc 'Return privacy policy details' - get '/settings/privacy' do + desc 'Return authenticated feature configuration for the Doubtfire front end' + get '/settings' do response = { - privacy: Doubtfire::Application.config.institution[:privacy], - plagiarism: Doubtfire::Application.config.institution[:plagiarism] + overseerEnabled: Doubtfire::Application.config.overseer_enabled, + tiiEnabled: TurnItIn.enabled?, + d2lEnabled: D2lIntegration.enabled?, + + # Web push. The VAPID *public* key is not a secret — the browser has to + # send it to the push service to subscribe at all. Serving it here means it + # is configured in one place instead of being copied into the front end and + # going stale the first time the keys are rotated. + # + # Blank when push is not configured, which is how the client knows not to + # offer the opt-in. + pushEnabled: PushNotificationService.configured?, + vapidPublicKey: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY', nil).presence } present response, with: Grape::Presenters::Presenter diff --git a/app/api/settings_public_api.rb b/app/api/settings_public_api.rb new file mode 100644 index 0000000000..c3a5c34dac --- /dev/null +++ b/app/api/settings_public_api.rb @@ -0,0 +1,27 @@ +require 'grape' + +class SettingsPublicApi < Grape::API + # This endpoint is required before sign-in. + # Keep this response explicitly allowlisted. + desc 'Return public branding details for the Doubtfire front end' + get '/settings/public' do + response = { + externalName: Doubtfire::Application.config.institution[:product_name], + hasLogo: Doubtfire::Application.config.institution[:has_logo], + logoUrl: Doubtfire::Application.config.institution[:logo_url], + logoLinkUrl: Doubtfire::Application.config.institution[:logo_link_url] + } + + present response, with: Grape::Presenters::Presenter + end + + desc 'Return public privacy policy details' + get '/settings/privacy' do + response = { + privacy: Doubtfire::Application.config.institution[:privacy], + plagiarism: Doubtfire::Application.config.institution[:plagiarism] + } + + present response, with: Grape::Presenters::Presenter + end +end diff --git a/app/api/sidekiq_api.rb b/app/api/sidekiq_api.rb new file mode 100644 index 0000000000..47c92a4c74 --- /dev/null +++ b/app/api/sidekiq_api.rb @@ -0,0 +1,26 @@ +require 'grape' + +class SidekiqApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Returns job information for a Sidekiq job' + params do + requires :id, type: String, desc: 'Id of the sidekiq job' + end + get '/sidekiq/:id' do + job_id = params[:id] + job_data = Sidekiq::Status.get_all(job_id) + + initiator = Sidekiq::Status.get(job_id, :initiator) + if current_user.id != initiator.to_i + error!({ error: 'You do not have permission to access this job' }, 403) + end + + present job_data.with_indifferent_access, with: Entities::SidekiqJobEntity + end +end diff --git a/app/api/similarity/entities/task_similarity_entity.rb b/app/api/similarity/entities/task_similarity_entity.rb index 001f075941..0b69107bf6 100644 --- a/app/api/similarity/entities/task_similarity_entity.rb +++ b/app/api/similarity/entities/task_similarity_entity.rb @@ -13,30 +13,51 @@ def staff?(my_role) similarity.ready_for_viewer? end + expose :other_task, safe: true + expose :other_student, safe: true + expose :parts do |similarity, options| path = similarity.file_path has_resource = path.present? && File.exist?(path) - result = [ - { - idx: 0, - format: if has_resource - similarity.type == 'MossTaskSimilarity' ? 'html' : 'pdf' - end, - description: "#{similarity.student.name} (#{similarity.student.username}) - #{similarity.pct}%" - } - ] + result = [] + case similarity.type + when 'JplagTaskSimilarity' + # We display just the "Other student" for JPlag similarities + # JPlag report viewer will show both students side by side + result << + { + idx: 0, + format: has_resource ? 'jplag' : nil, + description: "#{similarity.other_student&.name} (#{similarity.other_student&.username}) - #{similarity.pct}% similarity" + } + when 'MossTaskSimilarity' + result << + { + idx: 0, + format: has_resource ? 'html' : nil, + description: "#{similarity.student.name} (#{similarity.student.username}) - #{similarity.pct}% similarity" + } - # For moss similarity, show staff other student details - if similarity.type == 'MossTaskSimilarity' && staff?(options[:my_role]) - other_path = similarity.other_similarity&.file_path - has_other_resource = other_path.present? && File.exist?(other_path) + # For moss similarity, show staff other student details + if staff?(options[:my_role]) + other_path = similarity.other_similarity&.file_path + has_other_resource = other_path.present? && File.exist?(other_path) + result << + { + idx: 1, + format: has_other_resource ? 'html' : nil, + description: "#{similarity.other_student.name} (#{similarity.other_student.username}) - #{similarity.pct}% similarity" + } + end - result << { - idx: 1, - format: has_other_resource ? 'html' : nil, - description: "Match: #{similarity.other_student&.name} (#{similarity.other_student&.username}) - #{similarity.other_similarity&.pct}" - } + when 'TiiTaskSimilarity' + result << + { + idx: 0, + format: has_resource ? 'pdf' : nil, + description: "#{similarity.pct}% similarity" + } end result diff --git a/app/api/similarity/task_similarity_api.rb b/app/api/similarity/task_similarity_api.rb index 23e4c84c23..79df1ff797 100644 --- a/app/api/similarity/task_similarity_api.rb +++ b/app/api/similarity/task_similarity_api.rb @@ -115,6 +115,7 @@ class TaskSimilarityApi < Grape::API if similarity.present? && similarity.type == 'TiiTaskSimilarity' if similarity.ready_for_viewer? result = similarity.create_viewer_url(current_user) + error!({ error: 'Report viewer not currently available, please try again later' }, 503) if result.blank? present result, with: Grape::Presenters::Presenter else error!({ error: "Similarity report is not yet ready to be viewed for this submission" }, 404) diff --git a/app/api/staff_notes_api.rb b/app/api/staff_notes_api.rb new file mode 100644 index 0000000000..b202f39645 --- /dev/null +++ b/app/api/staff_notes_api.rb @@ -0,0 +1,94 @@ +require 'grape' + +class StaffNotesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc "Get all the staff notes for a project" + params do + requires :project_id, type: Integer, desc: 'Project to fetch staff notes for' + end + get '/projects/:project_id/staff_notes' do + project = Project.find(params[:project_id]) + + unless authorise? current_user, project, :get_staff_note + error!({ error: 'You do not have permission to access this project' }, 403) + end + + result = project.staff_notes + + present result, with: Entities::StaffNoteEntity, user: current_user + end + + desc "Create a new staff note for a project" + params do + requires :project_id, type: Integer, desc: 'Project to add the staff note for' + requires :note, type: String, desc: 'The text to add to the staff note' + optional :reply_to_id, type: Integer, desc: 'ID of the staff note this is being replied to' + end + post '/projects/:project_id/staff_notes' do + project = Project.find(params[:project_id]) + + unless authorise? current_user, project, :create_staff_note + error!({ error: 'You do not have permission to access this project' }, 403) + end + + text_note = params[:note] + + reply_to_id = params[:reply_to_id] + if reply_to_id.present? + original_staff_note = StaffNote.find(reply_to_id) + error!(error: 'You do not have permission to read the replied staff note') unless authorise?(current_user, original_staff_note.project, :get) + error!(error: 'Original staff note is not in this project.') if project.staff_notes.find(reply_to_id).blank? + end + + result = project.add_staff_note(current_user, text_note, reply_to_id) + + if result.nil? + error!({ error: 'Duplicate note.' }, 403) + else + present result, with: Entities::StaffNoteEntity, user: current_user + end + end + + desc "Delete a staff note for a project" + delete '/projects/:project_id/staff_notes/:id' do + project = Project.find(params[:project_id]) + staff_note = StaffNote.find(params[:id]) + + unless authorise?(current_user, project, :delete_staff_note) || staff_note.user.id == current_user.id + error!({ error: 'You do not have permission to delete this note.' }, 403) + end + + error!({ error: 'Note does not belong to this project' }, 404) if staff_note.project_id != project.id + + staff_note.destroy + error!({ error: staff_note.errors.full_messages.last }, 403) unless staff_note.destroyed? + + present staff_note.destroyed?, with: Grape::Presenters::Presenter + end + + desc "Update a staff note for a project" + params do + requires :id, type: Integer, desc: 'The staff note id to update' + requires :note, type: String, desc: 'The text to update the staff note with' + end + put '/projects/:project_id/staff_notes/:id' do + project = Project.find(params[:project_id]) + staff_note = StaffNote.find(params[:id]) + + unless authorise?(current_user, project, :create_staff_note) && staff_note.user.id == current_user.id + error!({ error: 'You do not have permission to edit this note.' }, 403) + end + + error!({ error: 'Note does not belong to this project' }, 404) if staff_note.project_id != project.id + + staff_note.update!(note: params[:note]) + present staff_note, with: Entities::StaffNoteEntity, user: current_user + end + +end diff --git a/app/api/submission/batch_task_api.rb b/app/api/submission/batch_task_api.rb index e1f6642ea1..1ab664dabd 100644 --- a/app/api/submission/batch_task_api.rb +++ b/app/api/submission/batch_task_api.rb @@ -5,62 +5,95 @@ class BatchTaskApi < Grape::API helpers GenerateHelpers helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers SidekiqHelper before do authenticated? end - desc "Retrieve all submission documents ready to mark for the provided user's tutorials for the given unit id" - params do - requires :unit_id, type: Integer, desc: 'Unit ID to retrieve submissions for.' - optional :user_id, type: Integer, desc: 'User ID to retrieve submissions for (optional; will use current_user otherwise).' - end - get '/submission/assess/' do - user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) - unit = Unit.find(params[:unit_id]) + # desc "Retrieve all submission documents ready to mark for the provided user's tutorials for the given unit id" + # params do + # requires :unit_id, type: Integer, desc: 'Unit ID to retrieve submissions for.' + # optional :user_id, type: Integer, desc: 'User ID to retrieve submissions for (optional; will use current_user otherwise).' + # end + # get '/submission/assess/' do + # user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + # unit = Unit.find(params[:unit_id]) - unless authorise? user, unit, :provide_feedback - error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) - end + # unless authorise? user, unit, :provide_feedback + # error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) + # end - unless authorise? current_user, unit, :provide_feedback - error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) - end + # unless authorise? current_user, unit, :provide_feedback + # error!({ error: 'Not authorised to batch download ready to mark submissions' }, 401) + # end + + # # Array of tasks that need marking for the given unit id + # tasks_to_download = UnitRole.tasks_to_review(user) + + # output_zip = unit.generate_batch_task_zip(current_user, tasks_to_download) - # Array of tasks that need marking for the given unit id - tasks_to_download = UnitRole.tasks_to_review(user) + # error!({ error: 'No files to download' }, 401) if output_zip.nil? - output_zip = unit.generate_batch_task_zip(current_user, tasks_to_download) + # # Set download headers... + # content_type 'application/octet-stream' + # download_id = "#{Time.zone.now.strftime('%Y-%m-%d')}-#{unit.code}-#{current_user.username}" + # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" + # env['api.format'] = :binary - error!({ error: 'No files to download' }, 401) if output_zip.nil? + # stream_file output_zip + # ensure + # File.unlink(output_zip) unless output_zip.blank? + # end # get - # Set download headers... - content_type 'application/octet-stream' - download_id = "#{Time.new.strftime('%Y-%m-%d')}-#{unit.code}-#{current_user.username}" - header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' - env['api.format'] = :binary + # desc 'Upload submission documents for the given unit and user id' + # params do + # requires :file, type: File, desc: 'batch file upload' + # requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' + # optional :user_id, type: Integer, desc: 'User ID to upload marked submissions to (optional; will use current_user otherwise).' + # end + # post '/submission/assess/' do + # user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + # unit = Unit.find(params[:unit_id]) - out = File.read(output_zip) - File.unlink(output_zip) - out - end # get + # unless authorise? user, unit, :provide_feedback + # error!({ error: 'Not authorised to batch upload marks' }, 401) + # end - desc 'Upload submission documents for the given unit and user id' + # present unit.upload_batch_task_zip_or_csv(current_user, params[:file]), with: Grape::Presenters::Presenter + # end # post + + desc 'Upload a batch feedback CSV or zip package for a selected task definition.' params do - requires :file, type: File, desc: 'batch file upload' + requires :file, type: File, desc: 'Batch feedback csv or zip upload' requires :unit_id, type: Integer, desc: 'Unit ID to upload marked submissions to.' - optional :user_id, type: Integer, desc: 'User ID to upload marked submissions to (optional; will use current_user otherwise).' + requires :task_definition_id, type: Integer, desc: 'Task definition ID the uploaded CSV relates to.' end - post '/submission/assess/' do - user = params[:user_id].nil? ? current_user : User.find(params[:user_id]) + post '/submission/batch_feedback_csv/' do unit = Unit.find(params[:unit_id]) + unit.task_definitions.find(params[:task_definition_id]) - unless authorise? user, unit, :provide_feedback - error!({ error: 'Not authorised to batch upload marks' }, 401) + unless authorise? current_user, unit, :provide_bulk_feedback + error!({ error: 'Not authorised to batch upload feedback csv' }, 401) end - present unit.upload_batch_task_zip_or_csv(current_user, params[:file]), with: Grape::Presenters::Presenter - end # post + error!({ error: "No file uploaded" }, 403) if params[:file].blank? + + import_dir = Rails.root.join(FileHelper.tmp_file_dir, 'batch-feedback') + FileUtils.mkdir_p(import_dir) + + extension = File.extname(params[:file][:filename].to_s) + extension = '.upload' if extension.blank? + file_name = File.join( + import_dir, + "batch-feedback-#{unit.id}-#{params[:task_definition_id]}-#{Process.pid}-#{Thread.current.object_id}-#{current_user.id}#{extension}" + ) + + FileUtils.cp(params[:file][:tempfile].path, file_name) + + job_id = ImportBatchFeedbackJob.perform_async(unit.id, current_user.id, params[:task_definition_id], file_name) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end end end diff --git a/app/api/submission/generate_helpers.rb b/app/api/submission/generate_helpers.rb index 073881339f..c78564e4a2 100644 --- a/app/api/submission/generate_helpers.rb +++ b/app/api/submission/generate_helpers.rb @@ -14,11 +14,11 @@ def scoop_files(params, upload_reqs) # upload_reqs.each do |detail| key = detail['key'] - next unless files.key? key + next unless files.key?(key) && files[key].is_a?(Hash) files[key][:id] = files[key]['name'] files[key][:name] = detail['name'] - files[key][:type] = detail['type'] + files[key][:type] = detail['type'] == 'archive' ? 'zip' : detail['type'] end # File didn't get assigned an id above, then reject it since there was a mismatch diff --git a/app/api/submission/portfolio_api.rb b/app/api/submission/portfolio_api.rb index 3b0182ec5a..aec64c65e1 100644 --- a/app/api/submission/portfolio_api.rb +++ b/app/api/submission/portfolio_api.rb @@ -55,6 +55,10 @@ class PortfolioApi < Grape::API # Remove file or portfolio? if params[:idx].nil? && params[:name].nil? && params[:kind].nil? + project.update!({ + portfolio_submission_date: nil, + portfolio_production_date: nil + }) project.remove_portfolio # returns details of file elsif !(params[:idx].nil? || params[:name].nil? || params[:kind].nil?) idx = params[:idx] @@ -81,15 +85,14 @@ class PortfolioApi < Grape::API evidence_loc = project.portfolio_path if evidence_loc.nil? || File.exist?(evidence_loc) == false - evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename = "FileNotFound.pdf" + evidence_loc = Rails.root.join('public/resources/FileNotFound.pdf') + filename = 'FileNotFound.pdf' else filename = "#{project.unit.code}-#{project.student.username}-portfolio.pdf" end if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end # Set download headers... diff --git a/app/api/submission/portfolio_evidence_api.rb b/app/api/submission/portfolio_evidence_api.rb index ff7176821e..8a6d36fe84 100644 --- a/app/api/submission/portfolio_evidence_api.rb +++ b/app/api/submission/portfolio_evidence_api.rb @@ -6,6 +6,8 @@ class PortfolioEvidenceApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers helpers FileStreamHelper + helpers Base64Helper + include LogHelper def self.logger @@ -16,6 +18,16 @@ def self.logger authenticated? end + TASK_STATES = { + ready_for_feedback: 1, + assess_in_portfolio: 1, + discuss: 2, + rediscuss: 2, + attention_required: 0, + demonstrate: 2, + complete: 3 + }.freeze + desc 'Upload and generate doubtfire-task-specific submission document' params do optional :file0, type: File, desc: 'file 0.' @@ -24,6 +36,7 @@ def self.logger optional :alignment_data, type: JSON, desc: "Data for task alignment, eg: [ { ilo_id: 1, rating: 5, rationale: 'Hello' }, ... ]" optional :trigger, type: String, desc: 'Can be need_help to indicate upload is not a ready to mark submission' optional :accepted_tii_eula, type: Boolean, desc: 'Whether or not the user has accepted the TII EULA as part of the submission.' + optional :comment, type: String, desc: 'Comment to be added together with the submission' end post '/projects/:id/task_def_id/:task_definition_id/submission' do project = Project.find(params[:id]) @@ -40,35 +53,57 @@ def self.logger error!({ error: "This task requires a group submission. Ensure you are in a group for the unit's #{task_definition.group_set.name}" }, 403) end + # Check that prerequisite tasks are in the required minimum submitted state + prerequisites = task_definition.task_prerequisites + prerequisites.each do |prerequisite| + prereq_td = prerequisite.prerequisite + minimum_status = TaskStatus.find(prerequisite.task_status_id) + + + prereq_task = project.task_for_task_definition(prereq_td) + + verb = + case minimum_status + when TaskStatus.complete + 'completed' + when TaskStatus.discuss + 'discussed' + when TaskStatus.rediscuss + 'rediscussed' + when TaskStatus.demonstrate + 'demonstrated' + when TaskStatus.ready_for_feedback + 'submitted' + end + + if !prereq_task.ready_or_complete? || TASK_STATES[prereq_task.status] < TASK_STATES[minimum_status.status_key] + error!({ error: "Cannot submit this task until prerequisite '#{prereq_td.abbreviation}' has been #{verb}" }, 409) + end + end + trigger = if params[:trigger] && params[:trigger].tr('"\'', '') == 'need_help' 'need_help' + elsif params[:trigger] && params[:trigger].tr('"\'', '') == 'assess_in_portfolio' + 'assess_in_portfolio' else 'ready_for_feedback' end - alignments = params[:alignment_data] - upload_reqs = task.upload_requirements - student = task.project.student - # Copy files to be PDFed - task.accept_submission(current_user, scoop_files(params, upload_reqs), student, self, params[:contributions], trigger, alignments, accepted_tii_eula: params[:accepted_tii_eula]) - - overseer_assessment = OverseerAssessment.create_for(task) - if overseer_assessment.present? - logger.info "Launching Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id}" + comment = params[:comment] + comment = comment.strip unless comment.nil? + if task_definition.assess_in_portfolio_only && comment.blank? && trigger == 'ready_for_feedback' + error!({ error: 'Please provide a comment requesting specific feedback on your submission.' }, 422) + end - response = overseer_assessment.send_to_overseer + task.add_text_comment(current_user, comment) if comment.present? - if response[:error].present? - error!({ error: response[:error] }, 403) - end + alignments = params[:alignment_data] + upload_reqs = task.upload_requirements - present :updated_task, task, with: Entities::TaskEntity, update_only: true - present :comment, response[:comment].serialize(current_user), with: Grape::Presenters::Presenter - return - end + # Copy files to be PDFed + task.accept_submission(current_user, scoop_files(params, upload_reqs), self, params[:contributions], trigger, alignments, accepted_tii_eula: params[:accepted_tii_eula]) - logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" present task, with: Entities::TaskEntity, update_only: true end @@ -79,8 +114,8 @@ def self.logger optional :as_attachment, type: Boolean, desc: 'Whether or not to download file as attachment. Default is false.' end get '/projects/:id/task_def_id/:task_definition_id/submission' do - project = Project.find(params[:id]) - task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + project = Project.eager_load(:unit).find(params[:id]) + task_definition = project.unit.task_definitions.select(:id, :name, :abbreviation).find(params[:task_definition_id]) # check the user can put this task unless authorise? current_user, project, :get_submission @@ -89,15 +124,13 @@ def self.logger task = project.task_for_task_definition(task_definition) - evidence_loc = task.portfolio_evidence_path - student = task.project.student - unit = task.project.unit + evidence_loc = task.final_pdf_path if task.processing_pdf? - evidence_loc = Rails.root.join('public', 'resources', 'AwaitingProcessing.pdf') + evidence_loc = Rails.root.join('public/resources/AwaitingProcessing.pdf') filename = 'AwaitingProcessing.pdf' - elsif evidence_loc.nil? - evidence_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + elsif evidence_loc.nil? || !File.exist?(evidence_loc) + evidence_loc = Rails.root.join('public/resources/FileNotFound.pdf') filename = 'FileNotFound.pdf' else filename = "#{task.task_definition.abbreviation}.pdf" @@ -105,7 +138,6 @@ def self.logger if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end # Set download headers... @@ -119,7 +151,7 @@ def self.logger project = Project.find(params[:id]) task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - unless authorise? current_user, project, :get_submission + unless authorise? current_user, project, :reprocess_submission error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) end @@ -153,12 +185,54 @@ def self.logger present result, with: Entities::OverseerAssessmentEntity end + desc 'Get all retained submission histories for a task' + get '/projects/:id/task_def_id/:task_definition_id/submission_histories' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorised to get submission history for task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + unless task + error!({ error: 'A submission for this task definition has never been created' }, 404) + end + + present task.submission_histories.order(submission_timestamp: :desc), + with: Entities::SubmissionHistoryEntity + end + + desc 'Download a retained submission history archive' + get '/projects/:id/task_def_id/:task_definition_id/submission_histories/:history_id/files' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project.unit, :provide_feedback + error!({ error: "Not authorised to get submission history for task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + history = task&.submission_histories&.find_by(id: params[:history_id]) + error!({ error: 'Submission history was not found' }, 404) unless history + error!({ error: 'Submission history files are not available' }, 404) unless history.has_submission_files? + + filename = "#{project.student.username}-#{task_definition.abbreviation}-#{history.submission_timestamp}.zip" + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{filename}" + submission_zip_data = history.submission_zip_data + header['Content-Length'] = submission_zip_data.bytesize.to_s + env['api.format'] = :binary + body submission_zip_data + end + desc 'Trigger an overseer assessment to run again' put '/projects/:id/task_def_id/:task_definition_id/overseer_assessment/:oa_id/trigger' do project = Project.find(params[:id]) task_definition = project.unit.task_definitions.find(params[:task_definition_id]) - unless authorise? current_user, project, :get_submission + unless authorise? current_user, project, :reprocess_submission error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) end @@ -201,12 +275,23 @@ def self.logger error!({ error: "No submissions found for project: '#{params[:id]}' task: '#{params[:task_def_id]}' and timestamp: '#{timestamp}'" }, 401) end - unless File.exist? "#{path}/output.txt" + unless File.exist? "#{path}/output.yaml" error!({ error: "There is no output for this assessment. Either the output wasn't generated, or processing failed. Please review your submission, and discuss with the teaching team if issues persist." }, 401) end result = [] - result << { label: 'output', result: File.read("#{path}/output.txt") } + begin + yaml_data = YAML.load_file("#{path}/output.yaml") # returns a hash + rescue Psych::SyntaxError => e + error!({ error: "Failed to parse overseer output: #{e.message}" }, 401) + end + + yaml_data.each do |key, value| + if base64?(value) + value = Base64.decode64(value) + end + result << { label: key, result: value } + end if project.role_for(current_user) == :student return result @@ -223,6 +308,43 @@ def self.logger present result, with: Grape::Presenters::Presenter end + desc 'Get the submission files zip for the overseer assessment made at the given timestamp' + params do + requires :timestamp, type: Integer, desc: 'The submission timestamp for the overseer assessment' + end + get '/projects/:id/task_def_id/:task_definition_id/submissions/timestamps/:timestamp/files' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project.unit, :provide_feedback + error!({ error: "Not authorised to get task '#{task_definition.name}'" }, 401) + end + + task = project.task_for_task_definition(task_definition) + unless task + error!({ error: 'A submission for this task definition have never been created' }, 401) + end + + history = task.submission_histories.find_by(submission_timestamp: params[:timestamp]) + unless history + error!({ error: "No submission history found for timestamp '#{params[:timestamp]}'" }, 404) + end + + unless history.has_submission_files? + error!({ error: "No submission files are available for timestamp '#{params[:timestamp]}'" }, 404) + end + + filename = "#{project.student.username}-#{task.task_definition.abbreviation}-#{params[:timestamp]}.zip" + + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{filename}" + + submission_zip_data = history.submission_zip_data + header['Content-Length'] = submission_zip_data.bytesize.to_s + env['api.format'] = :binary + body submission_zip_data + end + desc 'Get the result of the submission of a task made last' get '/projects/:id/task_def_id/:task_definition_id/submissions/latest' do project = Project.find(params[:id]) @@ -245,25 +367,36 @@ def self.logger path = "#{path}/#{FileHelper.latest_submission_timestamp_entry_in_dir(path)}" - unless File.exist? "#{path}/output.txt" + unless File.exist? "#{path}/output.yaml" error!({ error: "There is no output for this assessment. Either the output wasn't generated, or processing failed. Please review your submission, and discuss with the teaching team if issues persist." }, 401) end result = [] - result << { label: 'output', result: File.read("#{path}/output.txt") } + begin + yaml_data = YAML.load_file("#{path}/output.yaml") # returns a hash + rescue Psych::SyntaxError => e + error!({ error: "Failed to parse overseer output: #{e.message}" }, 401) + end + + yaml_data.each do |key, value| + if base64?(value) + value = Base64.decode64(value) + end + result << { label: key, result: value } + end if project.role_for(current_user) == :student present result, with: Grape::Presenters::Presenter return end - if File.exist? "#{path}/build-diff.txt" - result << { label: 'build-diff', result: File.read("#{path}/build-diff.txt") } - end + # if File.exist? "#{path}/build-diff.txt" + # result << { label: 'build-diff', result: File.read("#{path}/build-diff.txt") } + # end - if File.exist? "#{path}/run-diff.txt" - result << { label: 'run-diff', result: File.read("#{path}/run-diff.txt") } - end + # if File.exist? "#{path}/run-diff.txt" + # result << { label: 'run-diff', result: File.read("#{path}/run-diff.txt") } + # end present result, with: Grape::Presenters::Presenter end diff --git a/app/api/task_comments_api.rb b/app/api/task_comments_api.rb index f5b25a5f19..86065e040e 100644 --- a/app/api/task_comments_api.rb +++ b/app/api/task_comments_api.rb @@ -3,6 +3,7 @@ class TaskCommentsApi < Grape::API helpers AuthenticationHelpers helpers AuthorisationHelpers + helpers FileStreamHelper before do authenticated? @@ -26,24 +27,31 @@ class TaskCommentsApi < Grape::API attached_file = params[:attachment] reply_to_id = params[:reply_to_id] + task = project.task_for_task_definition(task_definition) + if task.active_overflow_task_claim + unit_role = project.unit.unit_role_for(current_user) + if unit_role && unit_role.id != task.overflow_task_claim.claimed_by_unit_role_id + error!({ error: 'This task is currently being reviewed by another tutor. Please try again later.' }, 403) + end + end + if attached_file.present? - error!({ error: "Attachment is empty." }) unless File.size?(attached_file["tempfile"].path).present? + error!({ error: "Attachment is empty." }) if File.size?(attached_file["tempfile"].path).blank? error!({ error: "Attachment exceeds the maximum attachment size of 30MB." }) unless File.size?(attached_file["tempfile"].path) < 30_000_000 end - task = project.task_for_task_definition(task_definition) type_string = content_type.to_s if reply_to_id.present? originalTaskComment = TaskComment.find(reply_to_id) error!(error: 'You do not have permission to read the replied comment') unless authorise?(current_user, originalTaskComment.project, :get) || (task.group_task? && task.group.role_for(current_user) != nil) - error!(error: 'Original comment is not in this task.') unless task.all_comments.find(reply_to_id).present? + error!(error: 'Original comment is not in this task.') if task.all_comments.find(reply_to_id).blank? end - logger.info("#{current_user.username} - added comment for task #{task.id} (#{task_definition.abbreviation})") + logger.info("user_id=#{current_user.id} added comment for task #{task.id} (#{task_definition.abbreviation})") - if attached_file.nil? || attached_file.empty? - error!({ error: 'Comment text is empty, unable to add new comment' }, 403) unless text_comment.present? + if attached_file.blank? + error!({ error: 'Comment text is empty, unable to add new comment' }, 403) if text_comment.blank? result = task.add_text_comment(current_user, text_comment, reply_to_id) else file_result = FileHelper.accept_file(attached_file, 'comment attachment - TaskComment', 'comment_attachment') @@ -57,6 +65,15 @@ class TaskCommentsApi < Grape::API if result.nil? error!({ error: 'No comment added. Comment duplicates last comment, so ignored.' }, 403) else + + SessionTracker.record_assessment_activity( + action: 'add-comment', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + present result.serialize(current_user), with: Grape::Presenters::Presenter end end @@ -90,36 +107,17 @@ class TaskCommentsApi < Grape::API # mark as attachment if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{comment.attachment_file_name}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' - end - - # Work out what part to return - file_size = File.size(comment.attachment_path) - begin_point = 0 - end_point = file_size - 1 - - # Was it asked for just a part of the file? - if request.headers['Range'] - # indicate partial content - status 206 - - # extract part desired from the content - if request.headers['Range'] =~ /bytes\=(\d+)\-(\d*)/ - begin_point = Regexp.last_match(1).to_i - end_point = Regexp.last_match(2).to_i if Regexp.last_match(2).present? - end - - end_point = file_size - 1 unless end_point < file_size - 1 end - # Return the requested content - content_length = end_point - begin_point + 1 - header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" - header['Content-Length'] = content_length.to_s - header['Accept-Ranges'] = 'bytes' + SessionTracker.record_assessment_activity( + action: 'get-comment-attachment', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) - # Read the binary data and return - File.binread(comment.attachment_path, content_length, begin_point) + stream_file comment.attachment_path end end @@ -146,6 +144,15 @@ class TaskCommentsApi < Grape::API else result = [] end + + SessionTracker.record_assessment_activity( + action: 'get-comments', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + present result, with: Grape::Presenters::Presenter end @@ -171,11 +178,82 @@ class TaskCommentsApi < Grape::API error!({ error: 'Not authorised to delete this comment' }, 403) end + # Comments that don't reveal a delete button + protected_comment_types = [ + AssessmentComment, + ExtensionComment, + ScormComment, + TaskCheckedInComment, + TaskDiscussedComment, + TaskFeedbackReviewRequestComment, + TaskStatusComment + ] + + if protected_comment_types.any? { |comment_type| task_comment.is_a?(comment_type) } + error!({ error: 'This comment type cannot be deleted' }, 403) + end + task_comment.destroy + SessionTracker.record_assessment_activity( + action: 'delete-comment', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + present false end + desc 'Edit a comment' + params do + requires :comment, type: String, desc: 'The updated comment text' + end + put '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :make_submission + error!({ error: 'Not authorised to edit this comment' }, 403) + end + + task = project.task_for_task_definition(task_definition) + task_comment = task.all_comments.find(params[:id]) + + unless task_comment.user == current_user + error!({ error: 'You can only edit your own comments' }, 403) + end + + unless task_comment.content_type.blank? || task_comment.content_type == 'text' + error!({ error: 'Only text comments can be edited' }, 403) + end + + if task_comment.created_at < 10.minutes.ago + error!({ error: 'Comments can only be edited within 10 minutes of being created' }, 403) + end + + if params[:comment].blank? + error!({ error: 'Comment text is empty, unable to update comment' }, 403) + end + + task_comment.comment = params[:comment] + + unless task_comment.save + error!({ error: task_comment.errors.full_messages.to_sentence.presence || 'Unable to update comment' }, 403) + end + + SessionTracker.record_assessment_activity( + action: 'edit-comment', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + + present task_comment.serialize(current_user), with: Grape::Presenters::Presenter + end + desc 'Mark a comment as unread' post '/projects/:project_id/task_def_id/:task_definition_id/comments/:id' do project = Project.find(params[:project_id]) @@ -190,6 +268,14 @@ class TaskCommentsApi < Grape::API task_comment = task.comments.find(params[:id]) task_comment.mark_as_unread(current_user) + SessionTracker.record_assessment_activity( + action: 'mark-comment-unread', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + present task_comment.serialize(current_user), with: Grape::Presenters::Presenter end end diff --git a/app/api/task_definitions_api.rb b/app/api/task_definitions_api.rb index 03536c9efd..8ee18d4cb9 100644 --- a/app/api/task_definitions_api.rb +++ b/app/api/task_definitions_api.rb @@ -7,6 +7,7 @@ class TaskDefinitionsApi < Grape::API helpers MimeCheckHelpers helpers Submission::GenerateHelpers helpers FileStreamHelper + helpers SidekiqHelper before do authenticated? @@ -32,7 +33,16 @@ class TaskDefinitionsApi < Grape::API requires :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' optional :assessment_enabled, type: Boolean, desc: 'Enable or disable assessment' optional :overseer_image_id, type: Integer, desc: 'The id of the Docker image for overseer' - optional :moss_language, type: String, desc: 'The language to use for code similarity checks' + optional :similarity_language, type: String, desc: 'The language to use for code similarity checks' + optional :scorm_enabled, type: Boolean, desc: 'Whether SCORM assessment is enabled for this task' + optional :scorm_allow_review, type: Boolean, desc: 'Whether a student is allowed to review their completed test attempts' + optional :scorm_bypass_test, type: Boolean, desc: 'Whether a student is allowed to upload files before passing SCORM test' + optional :scorm_time_delay_enabled, type: Boolean, desc: 'Whether there is an incremental time delay between SCORM test attempts' + optional :scorm_attempt_limit, type: Integer, desc: 'The number of times a SCORM test can be attempted' + optional :assess_in_portfolio_only, type: Boolean, desc: 'Whether a task can only be signed off during portfolio assessment' + optional :requires_discussion, type: Boolean, desc: 'Whether task must be discussed in class before it can be signed off as complete' + optional :use_resources_for_jplag_base_code, type: Boolean, desc: 'Include the common base code from task resources for JPlag comparisons' + optional :lock_assessments_to_tutorial_stream, type: Boolean, desc: 'Only allow tutors in this tutorial stream to assess this task' end end post '/units/:unit_id/task_definitions/' do @@ -42,8 +52,6 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to create a task definition of this unit' }, 403) end - params[:task_def][:upload_requirements] = [] if params[:task_def][:upload_requirements].nil? - task_params = ActionController::Parameters.new(params) .require(:task_def) .permit( @@ -57,15 +65,30 @@ class TaskDefinitionsApi < Grape::API :abbreviation, :restrict_status_updates, :plagiarism_warn_pct, + :scorm_enabled, + :scorm_allow_review, + :scorm_bypass_test, + :scorm_time_delay_enabled, + :scorm_attempt_limit, :is_graded, :max_quality_pts, :assessment_enabled, :overseer_image_id, - :moss_language + :similarity_language, + :assess_in_portfolio_only, + :requires_discussion, + :upload_requirements, + :unit_id, + :use_resources_for_jplag_base_code, + :lock_assessments_to_tutorial_stream ) task_params[:unit_id] = unit.id - task_params[:upload_requirements] = JSON.parse(params[:task_def][:upload_requirements]) unless params[:task_def][:upload_requirements].nil? + task_params[:upload_requirements] = params[:task_def][:upload_requirements].present? ? JSON.parse(params[:task_def][:upload_requirements]) : [] + + unless unit.grade_value?(task_params[:target_grade]) + error!({ error: 'Target grade is not enabled for this unit' }, 422) + end task_def = TaskDefinition.new(task_params) @@ -85,8 +108,11 @@ class TaskDefinitionsApi < Grape::API end task_def.save! + NewTaskAvailableNotificationJob.track_and_enqueue(task_def) - present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) + present task_def, + with: Entities::TaskDefinitionEntity, + my_role: unit.role_for(current_user) end desc 'Edits the given task definition' @@ -106,11 +132,25 @@ class TaskDefinitionsApi < Grape::API optional :restrict_status_updates, type: Boolean, desc: 'Restrict updating of the status to staff' optional :upload_requirements, type: String, desc: 'Task file upload requirements' optional :plagiarism_warn_pct, type: Integer, desc: 'The percent at which to record and warn about plagiarism' + optional :scorm_enabled, type: Boolean, desc: 'Whether or not SCORM test assessment is enabled for this task' + optional :scorm_allow_review, type: Boolean, desc: 'Whether a student is allowed to review their completed test attempts' + optional :scorm_bypass_test, type: Boolean, desc: 'Whether a student is allowed to upload files before passing SCORM test' + optional :scorm_time_delay_enabled, type: Boolean, desc: 'Whether or not there is an incremental time delay between SCORM test attempts' + optional :scorm_attempt_limit, type: Integer, desc: 'The number of times a SCORM test can be attempted' optional :is_graded, type: Boolean, desc: 'Whether or not this task definition is a graded task' optional :max_quality_pts, type: Integer, desc: 'A range for quality points when quality is assessed' optional :assessment_enabled, type: Boolean, desc: 'Enable or disable assessment' optional :overseer_image_id, type: Integer, desc: 'The id of the Docker image name for overseer' - optional :moss_language, type: String, desc: 'The language to use for code similarity checks' + optional :similarity_language, type: String, desc: 'The language to use for code similarity checks' + optional :assess_in_portfolio_only, type: Boolean, desc: 'Whether a task can only be signed off during portfolio assessment' + optional :requires_discussion, type: Boolean, desc: 'Whether task must be discussed in class before it can be signed off as complete' + optional :use_resources_for_jplag_base_code, type: Boolean, desc: 'Include the common base code from task resources for JPlag comparisons' + optional :lock_assessments_to_tutorial_stream, type: Boolean, desc: 'Only allow tutors in this tutorial stream to assess this task' + optional :grade_due_dates, type: Array do + requires :target_grade, type: Integer + optional :target_due_date, type: Date + optional :start_date, type: Date + end end end put '/units/:unit_id/task_definitions/:id' do @@ -121,6 +161,9 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to create a task definition of this unit' }, 403) end + # strip these out so TaskDefinition#update! never sees them + grade_due_date_rows = params[:task_def].delete('grade_due_dates') + task_params = ActionController::Parameters.new(params) .require(:task_def) .permit( @@ -134,26 +177,46 @@ class TaskDefinitionsApi < Grape::API :abbreviation, :restrict_status_updates, :plagiarism_warn_pct, + :scorm_enabled, + :scorm_allow_review, + :scorm_bypass_test, + :scorm_time_delay_enabled, + :scorm_attempt_limit, :is_graded, :max_quality_pts, :assessment_enabled, :overseer_image_id, - :moss_language + :similarity_language, + :assess_in_portfolio_only, + :requires_discussion, + :upload_requirements, + :use_resources_for_jplag_base_code, + :lock_assessments_to_tutorial_stream ) - task_params[:upload_requirements] = JSON.parse(params[:task_def][:upload_requirements]) unless params[:task_def][:upload_requirements].nil? + if params[:task_def][:upload_requirements].present? + upload_reqs = JSON.parse(params[:task_def][:upload_requirements]) + task_params[:upload_requirements] = upload_reqs - # Ensure changes to a TD defined as a "draft task definition" are validated - if unit.draft_task_definition_id == params[:id] - if params[:task_def][:upload_requirements] - requirements = params[:task_def][:upload_requirements] - if requirements.length != 1 || requirements[0]["type"] != "document" - error!({ error: 'Task is marked as the draft learning summary task definition. A draft learning summary task can only contain a single document upload.' }, 403) - end + # Ensure we permit all of the passed in upload requirements + if task_params[:upload_requirements].is_a? Array + # Force permit - the model validates the details + task_params[:upload_requirements].each(&:permit!) + end + + # Ensure changes to a TD defined as a 'draft task definition' are validated + if unit.draft_task_definition_id == params[:id] && (upload_reqs.length != 1 || upload_reqs[0]['type'] != 'document') + error!({ error: 'Task is marked as the draft learning summary. A draft learning summary task can only contain a single document upload.' }, 403) end end + if task_params.key?(:target_grade) && !unit.grade_value?(task_params[:target_grade]) + error!({ error: 'Target grade is not enabled for this unit' }, 422) + end + + # Bulk update task definition with permitted parameters task_def.update!(task_params) + due_date_change = task_def.saved_change_to_due_date # Set the tutorial stream tutorial_stream_abbr = params[:task_def][:tutorial_stream_abbr] @@ -179,7 +242,53 @@ class TaskDefinitionsApi < Grape::API end end - puts task_def.upload_requirements + if grade_due_date_rows.present? + unless unit.allow_flexible_dates + error!({ error: 'This unit must have Allow Flexible Dates enabled to modify target dates per grade' }, 403) + end + + grade_due_date_rows.each do |row_params| + target_grade = row_params[:target_grade] || row_params['target_grade'] + next if target_grade.to_i.zero? + + unless unit.grade_value?(target_grade) + error!({ error: "Target grade #{target_grade} is not enabled for this unit" }, 422) + end + + target_due_date = row_params[:target_due_date] || row_params['target_due_date'] + start_date = row_params[:start_date] || row_params['start_date'] + row = TaskDefinitionGradeDueDate.find_or_initialize_by( + task_definition: task_def, + target_grade: target_grade + ) + + if target_due_date.blank? && start_date.blank? + row.destroy if row.persisted? + else + row.update!(target_due_date: target_due_date, start_date: start_date) + end + end + end + + if due_date_change + previous_due_date, new_due_date = due_date_change.map do |value| + value&.to_date&.iso8601 + end + + begin + TaskDueDateChangedNotificationJob.perform_async( + task_def.id, + previous_due_date, + new_due_date + ) + rescue StandardError => e + Rails.logger.error( + "Failed to enqueue due-date notification for TaskDefinition " \ + "#{task_def.id}: #{e.class} - #{e.message}" + ) + end + end + present task_def, with: Entities::TaskDefinitionEntity, my_role: unit.role_for(current_user) end @@ -195,8 +304,8 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to upload CSV of tasks' }, 403) end - unless params[:file].present? - error!({ error: "No file uploaded" }, 403) + if params[:file].blank? + error!({ error: 'No file uploaded' }, 403) end path = params[:file][:tempfile].path @@ -274,7 +383,7 @@ class TaskDefinitionsApi < Grape::API # This API accepts more than 2 files, file0 and file1 are just examples. end post '/units/:unit_id/task_definitions/:task_def_id/test_overseer_assessment' do - logger.info "********* - Starting overseer test" + logger.info '********* - Starting overseer test' return 'Overseer is not enabled' if !Doubtfire::Application.config.overseer_enabled unit = Unit.find(params[:unit_id]) @@ -297,21 +406,17 @@ class TaskDefinitionsApi < Grape::API upload_reqs = task.upload_requirements # Copy files to be PDFed - task.accept_submission(current_user, scoop_files(params, upload_reqs), current_user, self, nil, 'ready_for_feedback', nil, accepted_tii_eula: false) + task.accept_submission(current_user, scoop_files(params, upload_reqs), self, nil, 'ready_for_feedback', nil, accepted_tii_eula: false, test_submission: true) - logger.info "********* - about to perform overseer submission" - overseer_assessment = OverseerAssessment.create_for(task) - if overseer_assessment.present? - response = overseer_assessment.send_to_overseer + # logger.info '********* - about to perform overseer submission' + # overseer_assessment = OverseerAssessment.create_for(task) + # if overseer_assessment.present? + # overseer_assessment.send_to_overseer - if response[:error].present? - error!({ error: response[:error] }, 403) - end - - logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was performed" - else - logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" - end + # logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was performed" + # else + # logger.info "Overseer assessment for task_def_id: #{task_definition.id} task_id: #{task.id} was not performed" + # end # todo: Do we need to return additional details here? e.g. the comment, and project? present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true @@ -351,8 +456,8 @@ class TaskDefinitionsApi < Grape::API task_def = unit.task_definitions.find(params[:task_def_id]) - unless params[:file].present? - error!({ error: "No file uploaded" }, 403) + if params[:file].blank? + error!({ error: 'No file uploaded' }, 403) end file_path = params[:file][:tempfile].path @@ -404,7 +509,7 @@ class TaskDefinitionsApi < Grape::API # Actually import... task_def.add_task_assessment_resources(file_path) - true + task_def.overseer_resource_files end desc 'Remove the task assessment resources for a given task' @@ -438,8 +543,8 @@ class TaskDefinitionsApi < Grape::API error!({ error: 'Not authorised to upload tasks of unit' }, 403) end - unless params[:file].present? - error!({ error: "No file uploaded" }, 403) + if params[:file].blank? + error!({ error: 'No file uploaded' }, 403) end file = params[:file][:tempfile].path @@ -479,6 +584,7 @@ class TaskDefinitionsApi < Grape::API .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") .joins("LEFT OUTER JOIN (#{subquery}) as sq ON sq.project_id = projects.id") .joins('LEFT OUTER JOIN task_similarities ON tasks.id = task_similarities.task_id') + .joins("LEFT JOIN task_pins ON task_pins.task_id = tasks.id AND task_pins.user_id = #{current_user.id}") .select( 'sq.tutorial_stream_id as tutorial_stream_id', 'sq.tutorial_id as tutorial_id', @@ -492,7 +598,8 @@ class TaskDefinitionsApi < Grape::API 'grade', 'quality_pts', "SUM(case when task_comments.date_extension_assessed IS NULL AND task_comments.type = 'ExtensionComment' AND NOT task_comments.id IS NULL THEN 1 ELSE 0 END) > 0 as has_extensions", - 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count' + 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count', + 'COUNT(distinct task_pins.task_id) != 0 as pinned' ) .where('task_definition_id = :id', id: params[:task_def_id]) .group( @@ -523,7 +630,8 @@ class TaskDefinitionsApi < Grape::API similarity_flag: t.similar_to_count > 0, grade: t.grade, quality_pts: t.quality_pts, - has_extensions: t.has_extensions + has_extensions: t.has_extensions, + pinned: t.pinned } end @@ -548,13 +656,12 @@ class TaskDefinitionsApi < Grape::API path = task_def.task_sheet filename = "#{task_def.unit.code}-#{task_def.abbreviation}.pdf" else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') - filename = "FileNotFound.pdf" + path = Rails.root.join('public/resources/FileNotFound.pdf') + filename = 'FileNotFound.pdf' end if params[:as_attachment] header['Content-Disposition'] = "attachment; filename=#{filename}" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' end content_type 'application/pdf' @@ -579,11 +686,10 @@ class TaskDefinitionsApi < Grape::API content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-resources.zip" else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + path = Rails.root.join('public/resources/FileNotFound.pdf') content_type 'application/pdf' header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' end - header['Access-Control-Expose-Headers'] = 'Content-Disposition' stream_file path end @@ -606,12 +712,287 @@ class TaskDefinitionsApi < Grape::API content_type 'application/octet-stream' header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-assessment-resources.zip" else - path = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + path = Rails.root.join('public/resources/FileNotFound.pdf') + content_type 'application/pdf' + header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' + end + + stream_file path + end + + desc 'Upload the SCORM container (zip file) for a task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + requires :file, type: File, desc: 'The SCORM data container' + end + post '/units/:unit_id/task_definitions/:task_def_id/scorm_data' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to upload SCORM data for the unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + if params[:file].blank? + error!({ error: "No file uploaded" }, 403) + end + + file_path = params[:file][:tempfile].path + + check_mime_against_list! file_path, 'zip', ['application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream'] + + # Actually import... + task_def.add_scorm_data(file_path) + true + end + + desc 'Download the SCORM test data' + params do + requires :unit_id, type: Integer, desc: 'The unit to modify tasks for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the SCORM test data of' + end + get '/units/:unit_id/task_definitions/:task_def_id/scorm_data' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to download task details of unit' }, 403) + end + + if task_def.has_scorm_data? + path = task_def.task_scorm_data + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-scorm.zip" + else + path = Rails.root.join('public/resources/FileNotFound.pdf') content_type 'application/pdf' header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' end header['Access-Control-Expose-Headers'] = 'Content-Disposition' + env['api.format'] = :binary + File.read(path) + end + + desc 'Remove the SCORM test data for a given task' + params do + requires :unit_id, type: Integer, desc: 'The related unit' + requires :task_def_id, type: Integer, desc: 'The related task definition' + end + delete '/units/:unit_id/task_definitions/:task_def_id/scorm_data' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :add_task_def + error!({ error: 'Not authorised to remove task SCORM data of unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + # Actually remove... + task_def.remove_scorm_data + true + end + + desc 'Download the JPLAG report for a given task' + params do + requires :unit_id, type: Integer, desc: 'The unit to download JPLAG report for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the JPLAG report of' + end + get '/units/:unit_id/task_definitions/:task_def_id/jplag_report' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + unless authorise? current_user, unit, :download_jplag_report + error!({ error: 'Not authorised to download JPLAG reports of unit' }, 403) + end + logger.debug "This is the has_jplag_report? #{task_def.has_jplag_report?}" + if task_def.has_jplag_report? + path = FileHelper.task_jplag_report_path(unit, task_def) + header['Content-Disposition'] = "attachment; filename=#{task_def.abbreviation}-jplag-report.jplag" + else + path = Rails.root.join("public/resources/FileNotFound.pdf") + content_type 'application/pdf' + header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' + end + header['Access-Control-Expose-Headers'] = 'Content-Disposition' + content_type 'application/octet-stream' stream_file path end + + desc 'Get hasJplagReport boolean for a given task' + params do + requires :unit_id, type: Integer, desc: 'The unit to get JPLAG report for' + requires :task_def_id, type: Integer, desc: 'The task definition to get the JPLAG report of' + end + get '/units/:unit_id/task_definitions/:task_def_id/has_jplag_report' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, unit, :download_jplag_report + error!({ error: 'Not authorised to download JPLAG reports of unit' }, 403) + end + + task_def.has_jplag_report? + end + + # Create task definition prerequsite + desc 'Create new task definition prerequisite' + params do + requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + requires :task_def_id, type: Integer, desc: 'The task definition to add the prerequisite to' + requires :prerequisite_id, type: Integer, desc: 'The prerequisite task definition' + end + post '/units/:unit_id/task_definitions/:task_def_id/prerequisites' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + task_prerequisite = unit.task_definitions.find(params[:prerequisite_id]) + + unless authorise? current_user, task_def, :create_task_prerequisite + error!({ error: 'Not authorised to add task prerequisite' }, 403) + end + + prereq = TaskPrerequisite.create!( + task_definition: task_def, + prerequisite: task_prerequisite, + task_status_id: TaskStatus.complete.id + ) + + present prereq, with: Entities::TaskPrerequisiteEntity + end + + # Create task definition prerequsite + desc 'Update a task prerequisite' + params do + requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + requires :task_def_id, type: Integer, desc: 'The task definition to add the prerequisite to' + requires :prerequisite_id, type: Integer, desc: 'The prerequisite task definition' + requires :task_status_required, type: String, desc: "ID of the task status required to mark the prerequisite as complete" + end + put '/units/:unit_id/task_definitions/:task_def_id/prerequisites/:prerequisite_id' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + + unless authorise? current_user, task_def, :create_task_prerequisite + error!({ error: 'Not authorised to add task prerequisite' }, 403) + end + + prereq = TaskPrerequisite.find_by(id: params[:prerequisite_id], task_definition: task_def) + + unless authorise? current_user, prereq.prerequisite, :create_task_prerequisite + error!({ error: 'Not authorised to add task prerequisite' }, 403) + end + + status = TaskStatus.status_for_name(params[:task_status_required]) + prereq.update!(task_status_id: status.id) + + true + end + + desc 'Remove task definition prerequisite' + params do + requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + requires :task_def_id, type: Integer, desc: 'The task definition to remove the prerequisite from' + requires :prerequisite_id, type: Integer, desc: 'The prerequisite task definition to remove' + end + delete '/units/:unit_id/task_definitions/:task_def_id/prerequisites/:prerequisite_id' do + unit = Unit.find(params[:unit_id]) + task_def = unit.task_definitions.find(params[:task_def_id]) + prereq_task = unit.task_definitions.find(params[:prerequisite_id]) + + unless authorise? current_user, task_def, :create_task_prerequisite + error!({ error: 'Not authorised to remove task prerequisite' }, 403) + end + + prereq_record = TaskPrerequisite.find_by(task_definition: task_def, prerequisite: prereq_task) + if prereq_record.nil? + error!({ error: 'Prerequisite not found' }, 404) + end + + prereq_record.destroy + + true + end + + desc 'Compress all submission files for a task definition into zip file' + params do + requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + requires :task_def_id, type: Integer, desc: 'The task definition to download submissions for' + end + get '/submission/units/:unit_id/task_definitions/:task_def_id/download_submissions/zip' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :get_students + error!({ error: "Not authorised to download submission files" }, 403) + end + + td = unit.task_definitions.find(params[:task_def_id]) + + # Queue submission files download to sidekiq + job_id = DownloadSubmissionFilesJob.perform_async(current_user.id, unit.id, td.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Compress all submission pdfs for a task definition into zip file' + params do + requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + requires :task_def_id, type: Integer, desc: 'The task definition to download submissions for' + end + get '/submission/units/:unit_id/task_definitions/:task_def_id/student_pdfs/zip' do + unit = Unit.find(params[:unit_id]) + unless authorise? current_user, unit, :get_students + error!({ error: "Not authorised to download submission pdfs" }, 403) + end + + td = unit.task_definitions.find(params[:task_def_id]) + + # Queue submission pdfs download to sidekiq + job_id = DownloadSubmissionPdfsJob.perform_async(current_user.id, unit.id, td.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + # desc 'Retrieve the contents of the overseer execution script' + # params do + # requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + # requires :task_def_id, type: Integer, desc: 'The task definition to download submissions for' + # end + # get '/units/:unit_id/task_definitions/:task_def_id/overseer_script' do + # unit = Unit.find(params[:unit_id]) + # unless authorise? current_user, unit, :add_task_def + # error!({ error: 'Not authorised to edit task details of unit' }, 403) + # end + + # td = unit.task_definitions.find(params[:task_def_id]) + + # script_path = td.task_assessment_script + + # content = File.read(script_path) + # content + # end + + # desc 'Update the contents of the overseer execution script' + # params do + # requires :unit_id, type: Integer, desc: 'The unit that has the task definition' + # requires :task_def_id, type: Integer, desc: 'The task definition to download submissions for' + # requires :script_content, type: String, desc: 'Content of the overseer execution script' + # end + # put '/units/:unit_id/task_definitions/:task_def_id/overseer_script' do + # unit = Unit.find(params[:unit_id]) + # unless authorise? current_user, unit, :add_task_def + # error!({ error: 'Not authorised to edit task details of unit' }, 403) + # end + + # td = unit.task_definitions.find(params[:task_def_id]) + + # script_path = td.task_assessment_script + + # decoded = Base64.urlsafe_decode64(params[:script_content]) + + # File.write(script_path, decoded) + # status 200 + # end + end diff --git a/app/api/task_prerequisites_api.rb b/app/api/task_prerequisites_api.rb new file mode 100644 index 0000000000..3cca5d2ffb --- /dev/null +++ b/app/api/task_prerequisites_api.rb @@ -0,0 +1,44 @@ +require 'grape' + +class TaskPrerequisitesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get task prerequisites for a task definition' + params do + requires :unit_id, type: Integer, desc: 'The unit to get the task definition from' + requires :task_def_id, type: Integer, desc: 'The task definition to get the prerequisites for' + end + get '/units/:unit_id/task_definitions/:task_def_id/prerequisites' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit' }, 403) + end + + task_def = unit.task_definitions.find(params[:task_def_id]) + + prerequisites = task_def.task_prerequisites + present prerequisites, with: Entities::TaskPrerequisiteEntity + end + + desc 'Get task prerequisites for a unit' + params do + requires :unit_id, type: Integer, desc: 'The unit to get the task definition from' + end + get '/units/:unit_id/task_prerequisites' do + unit = Unit.find(params[:unit_id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'Not authorised to get unit' }, 403) + end + + prerequisites = unit.task_definitions.flat_map(&:task_prerequisites) + + present prerequisites, with: Entities::TaskPrerequisiteEntity + end +end diff --git a/app/api/task_prioritization_api.rb b/app/api/task_prioritization_api.rb new file mode 100644 index 0000000000..cd1f8b036c --- /dev/null +++ b/app/api/task_prioritization_api.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'grape' + +class TaskPrioritizationApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + helpers DbHelpers + + DEFAULT_PER_PAGE = 50 + MAX_PER_PAGE = 50 + + before do + authenticated? + end + + desc 'Get prioritized task recommendations for a student', + detail: 'Returns the authenticated student\'s actionable tasks ranked by effective deadline, relative task size, and deadline workload.' + + params do + optional :page, type: Integer, default: 1, values: ->(value) { value.positive? } + optional :per_page, type: Integer, default: DEFAULT_PER_PAGE, values: 1..MAX_PER_PAGE + end + + get '/tasks/recommended' do + recommendations = TaskPrioritizationService.new(current_user).call + offset = (params[:page] - 1) * params[:per_page] + + { + data: recommendations.slice(offset, params[:per_page]) || [], + meta: { + page: params[:page], + per_page: params[:per_page], + total_count: recommendations.length, + total_pages: (recommendations.length / params[:per_page].to_f).ceil + } + } + end +end diff --git a/app/api/tasks_api.rb b/app/api/tasks_api.rb index 10e45917e4..afd9851fe3 100644 --- a/app/api/tasks_api.rb +++ b/app/api/tasks_api.rb @@ -72,7 +72,8 @@ class TasksApi < Grape::API task_definition_id: task.task_definition_id, status: TaskStatus.id_to_key(task.task_status_id), due_date: task.due_date, - extensions: task.extensions + extensions: task.extensions, + scorm_extensions: task.scorm_extensions } end @@ -108,6 +109,46 @@ class TasksApi < Grape::API present true, Grape::Presenters::Presenter end + desc 'Update the planned date for a task - when date flexibility is allowed' + params do + requires :id, type: Integer, desc: 'The project id to locate' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to update in this project' + requires :extensions, type: Integer, desc: 'The number of weeks to adjust the original planned date by' + end + put '/projects/:id/task_def_id/:task_definition_id/plan' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + # check the user can put this task + if authorise? current_user, project, :make_submission + # Check unit allows planned date changes + unless project.unit.allow_flexible_dates + error!({ error: 'This unit does not allow you to adjust due dates.' }, 403) + end + + task = project.task_for_task_definition(task_definition) + + # update the planned date + task.extensions = params[:extensions] + task.save! + + comment = TaskComment.create( + task: task, + user: current_user, + comment: "Planned date adjusted to #{task.due_date.strftime('%d %b')}.", + content_type: :plan, + recipient: project.student, + extension_weeks: params[:extensions] + ) + + comment.mark_as_read(project.tutor_for(task_definition)) + + present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true + else + error!({ error: "You are not permitted to adjust the plan." }, 403) + end + end + desc 'Update a task using its related project and task definition' params do # requires :id, type: Integer, desc: 'The project id to locate' @@ -116,6 +157,8 @@ class TasksApi < Grape::API optional :include_in_portfolio, type: Boolean, desc: 'Indicate if this task should be in the portfolio' optional :grade, type: Integer, desc: 'Grade value if task is a graded task (required if task definition is a graded task)' optional :quality_pts, type: Integer, desc: 'Quality points value if task has quality assessment' + optional :discussed, type: Boolean, desc: 'Mark task as discussed' + optional :trigger_recursive_fix, desc: 'If marking fix and resubmit, recursively update preqreuisite submissions to fix' end put '/projects/:id/task_def_id/:task_definition_id' do project = Project.find(params[:id]) @@ -127,6 +170,10 @@ class TasksApi < Grape::API if authorise? current_user, project, :make_submission task = project.task_for_task_definition(task_definition) + if !params[:discussed].nil? && authorise?(current_user, project, :assess) + task.add_discussed_comment(current_user) + end + # if trigger supplied... unless params[:trigger].nil? # Check if they should be using portfolio_evidence api @@ -134,15 +181,49 @@ class TasksApi < Grape::API error!({ error: 'Cannot set this task status to ready to mark without uploading documents.' }, 403) end + if params[:trigger] == 'assess_in_portfolio' && !authorise?(current_user, project, :assess) + # Prevent students from upading status if task definition doesn't enable it + if !task_definition.assess_in_portfolio_only + error!({ error: 'Cannot set this task status to assess in portfolio if task definition doesnt allow it.' }, 403) + elsif needs_upload_docs + # Prevent students from updating status without uploading files + error!({ error: 'Cannot set this task status to assess in portfolio without uploading documents.' }, 403) + end + end + if task.group_task? && !task.group error!({ error: "This task requires a group. Ensure you are in a group for the unit's #{task.task_definition.group_set.name}" }, 403) end + if task.task_definition.assess_in_portfolio_only && params[:trigger] == 'complete' + error!({ error: 'This task can only be assessed in portfolio.' }, 403) + end + + if task.task_definition.requires_discussion && params[:trigger] == 'complete' && !task.has_discussed_in_class_comment? + error!({ error: 'This task must be discussed in class before it can be marked complete.' }, 403) + end + logger.info "#{current_user.username} assessing task #{task.id} to #{params[:trigger]}" - result = task.trigger_transition(trigger: params[:trigger], by_user: current_user, quality: params[:quality_pts]) + result = task.trigger_transition( + trigger: params[:trigger], + by_user: current_user, + quality: params[:quality_pts], + recursive_fix: params[:trigger_recursive_fix], + check_feedback: true + ) + if result.nil? && task.errors.any? + error!({ error: task.errors.full_messages.to_sentence }, 403) + end if result.nil? && task.task_definition.restrict_status_updates error!({ error: 'This task can only be updated by your tutor.' }, 403) end + SessionTracker.record_assessment_activity( + action: "assessing", + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) end # if grade was supplied @@ -163,6 +244,20 @@ class TasksApi < Grape::API end end + desc 'Check in a student for a specific task.' + post '/projects/:id/task_def_id/:task_definition_id/check_in' do + project = Project.find(params[:id]) + + unless authorise?(current_user, project, :assess) + error!({ error: 'You do not have permission to assess this task.' }, 403) + end + + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + task.add_checked_in_comment(current_user) + end + desc 'Get the submission details of a task, indicating if it has a pdf to view' params do requires :id, type: Integer, desc: 'The project id to locate' @@ -179,23 +274,42 @@ class TasksApi < Grape::API # ensure there can be a pdf... needs_upload_docs = !task_definition.upload_requirements.empty? + task = nil + unit_role = project.unit.unit_role_for(current_user) + # check if we actually have this task... if not must be false. - if needs_upload_docs && project.has_task_for_task_definition?(task_definition) + if project.has_task_for_task_definition?(task_definition) task = project.task_for_task_definition(task_definition) + end - # return the details as json - result = { - has_pdf: task.has_pdf, - submission_date: task.submission_date, - processing_pdf: task.processing_pdf? - } - else - result = { - has_pdf: false, - processing_pdf: false - } + result = if needs_upload_docs && task + # return the details as json + { + has_pdf: task.has_pdf, + submission_date: task.submission_date, + processing_pdf: task.processing_pdf?, + task_status: task.task_status.status_key + } + else + { + has_pdf: false, + processing_pdf: false + } + end + + # Expose task claim to staff only + if unit_role + result[:claimed_by_unit_role_id] = task&.active_overflow_task_claim&.claimed_by_unit_role_id end + SessionTracker.record_assessment_activity( + action: 'get-submission-details', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) + present result, with: Grape::Presenters::Presenter end @@ -214,17 +328,23 @@ class TasksApi < Grape::API # Get the actual task... task = project.task_for_task_definition(task_definition) + SessionTracker.record_assessment_activity( + action: 'get-submission-files', + user: current_user, + project: project, + ip_address: request.ip, + task: task + ) # Find the file file_loc = FileHelper.zip_file_path_for_done_task(task) if file_loc.nil? || !File.exist?(file_loc) - file_loc = Rails.root.join('public', 'resources', 'FileNotFound.pdf') + file_loc = Rails.root.join('public/resources/FileNotFound.pdf') header['Content-Disposition'] = 'attachment; filename=FileNotFound.pdf' else header['Content-Disposition'] = "attachment; filename=#{project.student.username}-#{task.task_definition.abbreviation}.zip" end - header['Access-Control-Expose-Headers'] = 'Content-Disposition' # Set download headers... content_type 'application/octet-stream' @@ -232,4 +352,196 @@ class TasksApi < Grape::API # Return the file data stream_file file_loc end + + desc 'Update the target dates for a task - when date flexibility is allowed' + params do + requires :id, type: Integer, desc: 'The project id to locate' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition of the task to update in this project' + requires :target_start_date, type: Date, desc: 'Target date to start the task' + requires :target_due_date, type: Date, desc: 'Target date to submit the task' + end + put '/projects/:id/task_def_id/:task_definition_id/target_dates' do + project = Project.find(params[:id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + # check the user can put this task + if authorise? current_user, project, :make_submission + # Check unit allows planned date changes + unless project.unit.allow_flexible_dates + error!({ error: 'This unit does not allow you to adjust due dates.' }, 403) + end + + task = project.task_for_task_definition(task_definition) + + if task.target_start_date == params[:target_start_date] && task.target_due_date == params[:target_due_date] + present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true + return + end + + task.update!( + target_start_date: params[:target_start_date], + target_due_date: params[:target_due_date] + ) + + comment_text = if params[:target_start_date].present? && params[:target_due_date].present? + "Planned date adjusted: #{task.target_start_date.strftime('%d %b')} - #{task.target_due_date.strftime('%d %b')}." + else + "Planned date reset: #{task_definition.start_date.strftime('%d %b')} - #{task_definition.target_date.strftime('%d %b')}." + end + + comment = TaskComment.create( + task: task, + user: current_user, + comment: comment_text, + content_type: :plan, + recipient: project.student + ) + + comment.mark_as_read(project.tutor_for(task_definition)) + + present task, with: Entities::TaskEntity, include_other_projects: true, update_only: true + else + error!({ error: "You are not permitted to adjust the plan." }, 403) + end + end + + desc 'Update the target dates for a task - when date flexibility is allowed' + params do + requires :id, type: Integer, desc: 'The project id to locate' + end + put '/projects/:id/reset_target_dates' do + project = Project.find(params[:id]) + + # check the user can put this task + if authorise? current_user, project, :make_submission + # Check unit allows planned date changes + unless project.unit.allow_flexible_dates + error!({ error: 'This unit does not allow you to adjust due dates.' }, 403) + end + + project.tasks.each do |task| + next if task.target_start_date.nil? && task.target_due_date.nil? + + task.update!( + target_start_date: nil, + target_due_date: nil + ) + + comment_text = "Planned date reset: #{task.task_definition.start_date.strftime('%d %b')} - #{task.task_definition.target_date.strftime('%d %b')}." + comment = TaskComment.create( + task: task, + user: current_user, + comment: comment_text, + content_type: :plan, + recipient: project.student + ) + + comment.mark_as_read(project.tutor_for(task.task_definition)) + end + + present project, with: Entities::ProjectEntity, user: current_user, for_student: true, in_project: true + + else + error!({ error: "You are not permitted to adjust the plan." }, 403) + end + end + + desc 'Request a feedback review (creates an escalation ModeratedTask)' + params do + requires :id, type: Integer, desc: 'The project id' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition for the task to review' + end + post '/projects/:id/task_def_id/:task_definition_id/feedback_review' do + project = Project.find(params[:id]) + + unless authorise?(current_user, project, :make_submission) + error!({ error: 'You do not have permission to request a feedback review for this project.' }, 403) + end + + if project.escalation_attempts_remaining <= 0 + error!({ error: 'You can not escalate any more tasks.' }, 403) + end + + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + existing = ModeratedTask.find_by(task: task) + + if existing&.moderation_type == "escalation" + error!({ error: "A feedback review has already been requested for this task." }, 409) + end + + existing&.destroy! + + moderated_task = ModeratedTask.create!( + task: task, + task_definition: task_definition, + moderation_type: :escalation, + state: :open + ) + + unless moderated_task.valid? + error!({ error: "Failed to request a feedback review for this task" }, 400) + end + + task.add_feedback_review_request_comment(current_user) + + true + end + + desc 'Claim a task from the overflow queue' + params do + requires :id, type: Integer, desc: 'The project id' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition for the task to review' + end + post '/projects/:id/task_def_id/:task_definition_id/claim_overflow_task' do + project = Project.find(params[:id]) + + unit = project.unit + + my_unit_role = unit.unit_role_for(current_user) + unless my_unit_role + error!({ error: "Not a part of this unit" }, 400) + end + + unless my_unit_role.can_mark_overflow_tasks? + error!({ error: "Not allowed to claim task" }, 400) + end + + task_definition = unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + task_claim = task.active_overflow_task_claim + if task_claim + error!({ error: "This task has already been claimed by another tutor" }, 409) + end + + claimed_at = Time.zone.now + original_tutor = task.tutor + + ActiveRecord::Base.transaction do + task.overflow_task_claim&.destroy! + + OverflowTaskClaim.create!( + task: task, + claimed_by_unit_role: my_unit_role + ) + + OverflowTaskClaimLog.create!( + unit: unit, + task: task, + claimed_by_unit_role: my_unit_role, + claimed_by_user: current_user, + original_tutor_user: original_tutor, + student_user: project.student, + days_awaiting_feedback: task.days_awaiting_feedback(claimed_at), + claimed_at: claimed_at + ) + end + + logger.info "Overflow task claim: {\"user_id\": #{current_user.id},\"task_id\": #{task.id}, \"timestamp\": \"#{claimed_at}\", \"original_tutor_user_id\": #{original_tutor ? original_tutor.id : -1}}" + + true + end + end diff --git a/app/api/teaching_periods_authenticated_api.rb b/app/api/teaching_periods_authenticated_api.rb index d39260cfaf..4670cf9984 100644 --- a/app/api/teaching_periods_authenticated_api.rb +++ b/app/api/teaching_periods_authenticated_api.rb @@ -77,21 +77,4 @@ class TeachingPeriodsAuthenticatedApi < Grape::API TeachingPeriod.find(teaching_period_id).destroy end - desc 'Rollover a Teaching Period' - params do - requires :new_teaching_period_id, type: Integer, desc: 'The id of the rolled over teaching period' - optional :rollover_inactive, type: Boolean, default: false, desc: 'Are in active units included in the roll over' - optional :search_forward, type: Boolean, default: true, desc: 'When rolling over units, ensure that latest version is rolled over to new teaching period' - end - post '/teaching_periods/:existing_teaching_period_id/rollover' do - unless authorise? current_user, User, :rollover - error!({ error: 'Not authorised to rollover a teaching period' }, 403) - end - - new_teaching_period_id = params[:new_teaching_period_id] - new_teaching_period = TeachingPeriod.find(new_teaching_period_id) - - existing_teaching_period = TeachingPeriod.find(params[:existing_teaching_period_id]) - error!({ error: existing_teaching_period.errors.full_messages.first }, 403) unless existing_teaching_period.rollover(new_teaching_period, params[:search_forward], params[:rollover_inactive]) - end end diff --git a/app/api/teaching_periods_public_api.rb b/app/api/teaching_periods_public_api.rb index 9f17860541..704527fd96 100644 --- a/app/api/teaching_periods_public_api.rb +++ b/app/api/teaching_periods_public_api.rb @@ -4,12 +4,12 @@ class TeachingPeriodsPublicApi < Grape::API desc "Get a teaching period's details" get '/teaching_periods/:id' do teaching_period = TeachingPeriod.find(params[:id]) - present teaching_period, with: Entities::TeachingPeriodEntity, full_details: true, user: current_user + present teaching_period, with: Entities::TeachingPeriodEntity, full_details: true, include_breaks: true, user: current_user end desc 'Get all the Teaching Periods' get '/teaching_periods' do teaching_periods = TeachingPeriod.all - present teaching_periods, with: Entities::TeachingPeriodEntity + present teaching_periods, with: Entities::TeachingPeriodEntity, include_breaks: true end end diff --git a/app/api/test_attempts_api.rb b/app/api/test_attempts_api.rb new file mode 100644 index 0000000000..2ac6007bbd --- /dev/null +++ b/app/api/test_attempts_api.rb @@ -0,0 +1,192 @@ +require 'grape' + +class TestAttemptsApi < Grape::API + format :json + + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + desc 'Get all test results for a task' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition related to the task' + end + get '/projects/:project_id/task_def_id/:task_definition_id/test_attempts' do + project = Project.preload(:unit).find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorized to get scorm attempts for task" }, 403) + end + + task = project.task_for_task_definition(task_definition) + + attempts = TestAttempt.where(task_id: task.id) + tests = attempts.order(id: :desc) + present tests, with: Entities::TestAttemptEntity + end + + desc 'Get the latest test result' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition related to the task' + optional :completed, type: Boolean, desc: 'Get the latest completed test?' + end + get '/projects/:project_id/task_def_id/:task_definition_id/test_attempts/latest' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + + unless authorise? current_user, project, :get_submission + error!({ error: "Not authorized to get latest scorm attempt for task" }, 403) + end + + task = project.task_for_task_definition(task_definition) + + attempts = TestAttempt.where("task_id = ?", task.id) + + test = if params[:completed] + attempts.where(completion_status: true).order(id: :desc).first + else + attempts.order(id: :desc).first + end + + if test.nil? + error!({ message: 'No tests found for this task' }, 404) + else + present test, with: Entities::TestAttemptEntity + end + end + + desc 'Review a completed attempt' + params do + requires :id, type: Integer, desc: 'Test attempt ID to review' + end + get 'test_attempts/:id/review' do + test = TestAttempt.find(params[:id]) + + key = if current_user == test.student + :review_own_attempt + else + :review_other_attempt + end + + unless authorise? current_user, test, key, ->(role, perm_hash, other) { test.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to review this scorm attempt' }, 403) + end + + if test.nil? + error!({ message: 'Test attempt ID is invalid' }, 404) + else + logger.debug "Request to review test attempt #{params[:id]}" + begin + test.review + rescue StandardError => e + error!({ message: e.message }, 403) + end + end + present test, with: Entities::TestAttemptEntity + end + + desc 'Initiate a new test attempt' + params do + requires :project_id, type: Integer, desc: 'The id of the project with the task' + requires :task_definition_id, type: Integer, desc: 'The id of the task definition related to the task' + end + post '/projects/:project_id/task_def_id/:task_definition_id/test_attempts' do + project = Project.find(params[:project_id]) + task_definition = project.unit.task_definitions.find(params[:task_definition_id]) + task = project.task_for_task_definition(task_definition) + + # check permissions using specific permission has with addition of make scorm attempt if scorm is enabled in task def + unless authorise? current_user, task, :make_scorm_attempt, ->(role, perm_hash, other) { task.specific_permission_hash(role, perm_hash, other) } + error!({ error: 'Not authorised to make a scorm attempt for this task' }, 403) + end + + attempts = TestAttempt.where("task_id = ?", task.id) + test_count = attempts.count + + # check if last attempt is complete + last_attempt = attempts.order(id: :desc).first + if test_count > 0 && last_attempt.terminated == false + error!({ message: 'An attempt is still ongoing. Cannot initiate new attempt.' }, 400) + return + end + + # check if last attempt is a pass + if test_count > 0 && last_attempt.success_status == true + error!({ message: 'User has passed the SCORM test. Cannot initiate more attempts.' }, 400) + return + end + + # check attempt limit + limit = task.task_definition.scorm_attempt_limit + task.scorm_extensions + if limit != 0 && test_count >= limit + error!({ message: 'Attempt limit has been reached' }, 400) + return + end + + test = TestAttempt.create!({ task_id: task.id }) + present test, with: Entities::TestAttemptEntity + end + + desc 'Update an existing attempt' + params do + requires :id, type: String, desc: 'ID of the test attempt' + optional :cmi_datamodel, type: String, desc: 'JSON CMI datamodel to update' + optional :terminated, type: Boolean, desc: 'Terminate the current attempt' + optional :success_status, type: Boolean, desc: 'Override the success status of the current attempt' + end + patch 'test_attempts/:id' do + test = TestAttempt.find(params[:id]) + + if test.nil? + error!({ message: 'Test attempt ID is invalid' }, 404) + end + + if params[:success_status].present? + unless authorise? current_user, test, :override_success_status + error!({ error: 'Not authorised to override the success status of this scorm attempt' }, 403) + end + + test.override_success_status(params[:success_status]) + else + unless authorise? current_user, test, :update_attempt + error!({ error: 'Not authorised to update this scorm attempt' }, 403) + end + + attempt_data = ActionController::Parameters.new(params).permit(:cmi_datamodel, :terminated) + + unless test.terminated + test.update!(attempt_data) + test.save! + if params[:terminated] + test.add_scorm_comment + end + end + end + + present test, with: Entities::TestAttemptEntity + end + + desc 'Delete a test attempt' + params do + requires :id, type: String, desc: 'ID of the test attempt' + end + delete 'test_attempts/:id' do + test = TestAttempt.find(params[:id]) + + unless authorise? current_user, test, :delete_attempt + error!({ error: 'Not authorised to delete this scorm attempt' }, 403) + end + + if test.nil? + error!({ message: 'Test attempt ID is invalid' }, 404) + else + test.destroy! + end + end +end diff --git a/app/api/tii/tii_action_api.rb b/app/api/tii/tii_action_api.rb index 979eae9ef9..472460856e 100644 --- a/app/api/tii/tii_action_api.rb +++ b/app/api/tii/tii_action_api.rb @@ -12,7 +12,7 @@ class TiiActionApi < Grape::API desc 'Get the outstanding turn it in actions' params do - optional :unit_id, type: Integer, desc: 'The id of the unit to filter by', default: nil + optional :unit_id, type: Integer, desc: 'The id of the unit to filter by' optional :limit, type: Integer, desc: 'The maximum number of actions to return', default: 50 optional :offset, type: Integer, desc: 'The offset to start from', default: 0 optional :show_complete, type: Boolean, desc: 'Include complete actions?', default: false @@ -38,10 +38,10 @@ class TiiActionApi < Grape::API desc 'Trigger an action on the given group attachment' params do requires :action, type: String, desc: 'The action to perform: retry' - optional :unit_id, type: Integer, desc: 'The id of the unit to filter by', default: nil + # optional :unit_id, type: Integer, desc: 'The id of the unit to filter by' end put '/tii_actions/:id' do - unit = Unit.find(params[:unit_id]) if params[:unit_id].present? + # unit = Unit.find(params[:unit_id]) if params[:unit_id].present? unless authorise?(current_user, User, :admin_units) error!({ error: 'Not authorised to retry tasks' }, 403) @@ -52,8 +52,7 @@ class TiiActionApi < Grape::API case params[:action] when 'retry' error!({ error: 'Retry in progress. Please wait.' }, 403) if action.retry - action.update(retry: true) - action.perform_async + action.perform_retry else error!({ error: 'Invalid action' }, 400) end diff --git a/app/api/tii/turn_it_in_hooks_api.rb b/app/api/tii/turn_it_in_hooks_api.rb index 694099c262..8a039bf4db 100644 --- a/app/api/tii/turn_it_in_hooks_api.rb +++ b/app/api/tii/turn_it_in_hooks_api.rb @@ -17,14 +17,15 @@ class TurnItInHooksApi < Grape::API } } post 'tii_hook' do - data = JSON.parse(env['api.request.input']) + raw_data = env['api.request.input'] + data = JSON.parse(raw_data) digest = OpenSSL::Digest.new('sha256') - # puts data - hmac = OpenSSL::HMAC.hexdigest(digest, ENV.fetch('TCA_SIGNING_KEY', nil), data.to_json) + logger.debug("TII_HOOK_DEBUG:#{raw_data}") + hmac = OpenSSL::HMAC.hexdigest(digest, ENV.fetch('TCA_SIGNING_KEY', nil), raw_data) - # puts hmac - # puts headers['x-turnitin-signature'] + logger.debug("TII_HOOK_DEBUG:#{hmac}") + logger.debug("TII_HOOK_DEBUG:#{headers['x-turnitin-signature']}") if hmac != headers["x-turnitin-signature"] logger.error("TII: HMAC does not match") diff --git a/app/api/tutor_notes_api.rb b/app/api/tutor_notes_api.rb new file mode 100644 index 0000000000..83c99945ce --- /dev/null +++ b/app/api/tutor_notes_api.rb @@ -0,0 +1,199 @@ +require 'grape' + +class TutorNotesApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + helpers do + def can_access_tutor_notes?(unit, current_user, unit_role) + current_user_role = unit.unit_role_for(current_user) + + current_user_role.role == Role.convenor || + unit_role.mentor_id == current_user_role.id || + unit_role == current_user_role + end + end + + desc "Get all the tutor notes for a unit role" + params do + requires :unit_role_id, type: Integer, desc: 'Unit role to fetch the notes for' + end + get '/unit_roles/:unit_role_id/tutor_notes' do + unit_role = UnitRole.find(params[:unit_role_id]) + unit = unit_role.unit + unless authorise? current_user, unit, :get_unit + error!({ error: 'You do not have permission to access this unit' }, 403) + end + + unless authorise? current_user, unit_role, :create_tutor_note + error!({ error: 'You do not have permission to access this.' }, 403) + end + + unless can_access_tutor_notes?(unit, current_user, unit_role) + error!({ error: 'You do not have permission to access this.' }, 403) + end + + result = unit_role.tutor_notes + + present result, with: Entities::TutorNoteEntity, user: current_user + end + + desc "Mark a tutor note as read" + params do + requires :unit_role_id, type: Integer, desc: 'Unit role to fetch the notes for' + end + put '/unit_roles/:unit_role_id/tutor_notes/:id/mark_as_read' do + unit_role = UnitRole.find(params[:unit_role_id]) + + unit = unit_role.unit + unless authorise? current_user, unit, :get_unit + error!({ error: 'You do not have permission to access this unit' }, 403) + end + + unless can_access_tutor_notes?(unit, current_user, unit_role) + error!({ error: 'You do not have permission to access this.' }, 403) + end + + tutor_note = unit_role.tutor_notes.find(params[:id]) + + current_unit_role = unit.unit_role_for(current_user) + + unless current_unit_role == unit_role && unit_role == tutor_note.unit_role + error!({ error: 'You do not have permission to update this note.' }, 403) + end + + tutor_note.update!(read_by_unit_role: true) + + true + end + + desc "Create a new note for a tutor" + params do + requires :note, type: String, desc: 'The text to add to the tutor note' + optional :reply_to_id, type: Integer, desc: 'ID of the tutor note this is being replied to' + optional :task_id, type: Integer, desc: 'ID of the task this note is related to' + end + post '/unit_roles/:unit_role_id/tutor_notes' do + unit_role = UnitRole.find(params[:unit_role_id]) + unit = unit_role.unit + unless authorise? current_user, unit, :get_unit + error!({ error: 'You do not have permission to access this unit.' }, 403) + end + + unless authorise? current_user, unit_role, :create_tutor_note + error!({ error: 'You do not have permission to create note.' }, 403) + end + + unless can_access_tutor_notes?(unit, current_user, unit_role) + error!({ error: 'You do not have permission to create note.' }, 403) + end + + text_note = params[:note] + + reply_to_id = params[:reply_to_id] + if reply_to_id.present? + original_staff_note = TutorNote.find(reply_to_id) + error!(error: 'You do not have permission to read the replied tutor note') unless authorise?(current_user, original_staff_note.unit_role, :get) + error!(error: 'Original tutor note is not in this project.') if unit_role.tutor_notes.find(reply_to_id).blank? + end + + task_id = params[:task_id] + if task_id.present? + task = Task.find(task_id) + error!(error: 'You do not have permission to add a note related to this task') unless authorise?(unit_role.user, task.project, :assess) + end + + current_unit_role = unit.unit_role_for(current_user) + + result = unit_role.add_tutor_note(current_user, text_note, task_id, reply_to_id) + + reply_target = original_staff_note && unit.unit_role_for(original_staff_note.user) + + notify_unit_role = + if reply_target && original_staff_note.user != current_user + reply_target # tutor is responding to a reply -> notify original user that tutor is replying to + elsif current_unit_role == unit_role + unit_role.mentor # tutor is writing on their own notes -> notify the mentor + else + unit_role # anyone else wrote about this tutor, whether its their mentor or another convenor -> notify tutor + end + + if result.present? && notify_unit_role.present? && notify_unit_role.user_id != current_user.id + begin + NotifyTutorNotesJob.perform_async(result.id, notify_unit_role.user.id) + rescue StandardError => e + Rails.logger.error("Failed to send tutor note email for TutorNote #{result.id}: #{e.class} - #{e.message}") + end + end + + if result.nil? + error!({ error: 'Duplicate note.' }, 403) + else + present result, with: Entities::TutorNoteEntity, user: current_user + end + end + + desc "Delete a tutor note for a unit role" + delete '/unit_roles/:unit_role_id/tutor_notes/:id' do + unit_role = UnitRole.find(params[:unit_role_id]) + unit = unit_role.unit + unless authorise? current_user, unit, :get_unit + error!({ error: 'You do not have permission to access this unit' }, 403) + end + + unless can_access_tutor_notes?(unit, current_user, unit_role) + error!({ error: 'You do not have permission to access create note.' }, 403) + end + + tutor_note = unit_role.tutor_notes.find(params[:id]) + + error!({ error: 'Note does not belong to this tutor' }, 404) if tutor_note.unit_role != unit_role + + unless authorise?(current_user, unit_role, :delete_tutor_note) || tutor_note.user.id == current_user.id + error!({ error: 'You do not have permission to delete this note.' }, 403) + end + + tutor_note.destroy + error!({ error: tutor_note.errors.full_messages.last }, 403) unless tutor_note.destroyed? + + present tutor_note.destroyed?, with: Grape::Presenters::Presenter + end + + desc "Update a tutor note for a project" + params do + requires :unit_role_id, type: Integer, desc: 'The ID of the unit role' + requires :id, type: Integer, desc: 'The tutor note id to update' + requires :note, type: String, desc: 'The text to update the tutor note with' + end + put '/unit_roles/:unit_role_id/tutor_notes/:id' do + unit_role = UnitRole.find(params[:unit_role_id]) + unit = unit_role.unit + unless authorise? current_user, unit, :get_unit + error!({ error: 'You do not have permission to access this unit' }, 403) + end + + unless authorise? current_user, unit_role, :create_tutor_note + error!({ error: 'You do not have permission to access this.' }, 403) + end + + tutor_note = unit_role.tutor_notes.find(params[:id]) + + unless can_access_tutor_notes?(unit, current_user, unit_role) + error!({ error: 'You do not have permission to access create note.' }, 403) + end + + error!({ error: 'Note does not belong to this tutor' }, 404) if tutor_note.unit_role != unit_role + + unless tutor_note.user.id == current_user.id + error!({ error: 'You do not have permission to delete this note.' }, 403) + end + + tutor_note.update!(note: params[:note]) + present tutor_note, with: Entities::TutorNoteEntity, user: current_user + end + +end diff --git a/app/api/tutorial_enrolments_api.rb b/app/api/tutorial_enrolments_api.rb index cd86ad9f32..b3e83f05ff 100644 --- a/app/api/tutorial_enrolments_api.rb +++ b/app/api/tutorial_enrolments_api.rb @@ -17,7 +17,7 @@ class TutorialEnrolmentsApi < Grape::API end tutorial = unit.tutorials.find_by(abbreviation: params[:tutorial_abbr]) - error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) unless tutorial.present? + error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) if tutorial.blank? # If the tutorial has a capacity, and we are at that capacity, and the user does not have permissions to exceed capacity... if tutorial.capacity > 0 && tutorial.tutorial_enrolments.count >= tutorial.capacity && !authorise?(current_user, unit, :exceed_capacity) @@ -44,10 +44,10 @@ class TutorialEnrolmentsApi < Grape::API end tutorial = unit.tutorials.find_by(abbreviation: params[:tutorial_abbr]) - error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) unless tutorial.present? + error!({ error: "No tutorial with abbreviation #{params[:tutorial_abbr]} exists for the unit" }, 403) if tutorial.blank? tutorial_enrolment = tutorial.tutorial_enrolments.find_by(project_id: params[:project_id]) - error!({ error: "Project not enrolled in the selected tutorial" }, 403) unless tutorial_enrolment.present? + error!({ error: "Project not enrolled in the selected tutorial" }, 403) if tutorial_enrolment.blank? tutorial_enrolment.destroy # present :enrolments, project.tutorial_enrolments, with: Entities::TutorialEnrolmentEntity diff --git a/app/api/tutorials_api.rb b/app/api/tutorials_api.rb index b768f6792c..57b554c883 100644 --- a/app/api/tutorials_api.rb +++ b/app/api/tutorials_api.rb @@ -65,7 +65,7 @@ class TutorialsApi < Grape::API requires :meeting_location, type: String, desc: 'The tutorials location', allow_blank: false requires :meeting_day, type: String, desc: 'Day of the tutorial', allow_blank: false requires :meeting_time, type: String, desc: 'Time of the tutorial', allow_blank: false - optional :tutorial_stream_abbr, type: String, desc: 'Abbreviation of the associated tutorial stream', allow_blank: false + requires :tutorial_stream_abbr, type: String, desc: 'Abbreviation of the associated tutorial stream', allow_blank: false end end post '/tutorials' do @@ -83,6 +83,10 @@ class TutorialsApi < Grape::API tutorial_stream_abbr = tut_params[:tutorial_stream_abbr] tutorial_stream = unit.tutorial_streams.find_by!(abbreviation: tutorial_stream_abbr) unless tutorial_stream_abbr.nil? + unless tutorial_stream + error!({ error: 'Tutorial must belong to a tutorial stream' }, 403) + end + tutorial = unit.add_tutorial(tut_params[:meeting_day], tut_params[:meeting_time], tut_params[:meeting_location], tutor, campus, tut_params[:capacity], tut_params[:abbreviation], tutorial_stream) present tutorial, with: Entities::TutorialEntity diff --git a/app/api/unit_roles_api.rb b/app/api/unit_roles_api.rb index b59a69f96c..9b03699c05 100644 --- a/app/api/unit_roles_api.rb +++ b/app/api/unit_roles_api.rb @@ -25,6 +25,10 @@ class UnitRolesApi < Grape::API end desc 'Delete a unit role' + params do + requires :id, type: Integer, desc: 'The id of the unit role to delete' + optional :reassign_to_unit_role_id, type: Integer, desc: 'The unit role to reassign tutorials to before deletion' + end delete '/unit_roles/:id' do unit_role = UnitRole.find(params[:id]) @@ -32,7 +36,28 @@ class UnitRolesApi < Grape::API error!({ error: "You do not have permission to perform this action" }, 403) end - unit_role.destroy! + tutorials = unit_role.unit.tutorials.where(unit_role_id: unit_role.id) + + if tutorials.exists? + if params[:reassign_to_unit_role_id].blank? + error!({ error: 'Unable to delete this unit role while tutorials are assigned without providing a reassignment target' }, 400) + end + + reassignment_role = unit_role.unit.staff.find_by(id: params[:reassign_to_unit_role_id]) + + if reassignment_role.nil? || reassignment_role.id == unit_role.id + error!({ error: 'Unable to delete this unit role with the provided reassignment target' }, 400) + end + + ActiveRecord::Base.transaction do + tutorials.find_each do |tutorial| + tutorial.update!(unit_role: reassignment_role) + end + unit_role.destroy! + end + else + unit_role.destroy! + end end desc 'Employ a user as a teaching role in a unit' @@ -70,19 +95,40 @@ class UnitRolesApi < Grape::API params do requires :unit_role, type: Hash do requires :role_id, type: Integer, desc: 'The role to create with' + optional :observer_only, type: Boolean, desc: 'If the staff has read-only permissions' + optional :mentor_id, type: Integer, desc: 'Assign a mentor to this unit role' + optional :can_mark_overflow_tasks, type: Boolean, desc: 'If the staff can access overflow marking' end end put '/unit_roles/:id' do unit_role = UnitRole.find_by(id: params[:id]) unless (authorise? current_user, unit_role.unit, :employ_staff) || (authorise? current_user, User, :admin_units) - error!({ error: "Couldn't find Unit with id=#{params[:id]}" }, 403) + error!({ error: "Not authorised to update unit role with id=#{params[:id]}" }, 403) + end + + # Prevent staff from setting themselves as read-only + if params[:unit_role][:observer_only] && unit_role.user.id == current_user.id + error!({ error: "You cannot make yourself an observer" }, 403) + end + + # Once they're an observer, they'll no longer have access to this route to remove the observer status from themselves + # But let's double check just in case this route gets whitelisted... + + unit = unit_role.unit + current_unit_role = unit.unit_role_for(current_user) + + if current_unit_role.observer_only + error!({ error: "You are not authorised to update this staff member." }, 403) end unit_role_parameters = ActionController::Parameters.new(params) .require(:unit_role) .permit( - :role_id + :role_id, + :observer_only, + :mentor_id, + :can_mark_overflow_tasks ) if unit_role_parameters[:role_id] == Role.tutor.id && unit_role.role == Role.convenor && unit_role.unit.convenors.count == 1 @@ -92,4 +138,136 @@ class UnitRolesApi < Grape::API unit_role.update!(unit_role_parameters) present unit_role, with: Entities::UnitRoleEntity, in_unit: true end + + desc 'Moderate tutor feedback' + params do + requires :id, type: Integer, desc: 'The id of the unit role to moderate' + requires :task_id, type: Integer, desc: 'The id of the task' + requires :action, type: String, desc: 'Action to apply to this moderated task' + optional :apply_to_all, type: Boolean, desc: 'Should this action be applied to all moderated tasks for this tutor in this task definition' + end + post '/unit_roles/:id/moderation/:task_id' do + unit_role = UnitRole.find(params[:id]) + unit = unit_role.unit + + task = Task.find(params[:task_id]) + tutor_user = task.project.tutor_for(task.task_definition) + tutor = unit.unit_role_for(tutor_user) + unless tutor.id == unit_role.id + error!("Invalid unit role", 400) + end + + current_unit_role = unit.unit_role_for(current_user) + unless tutor.mentor == current_unit_role || current_unit_role.role == Role.convenor + error!({ error: 'You do not have permission to moderate this feedback' }, 400) + end + + action = params[:action].downcase + unless %w[show_more show_less dismiss_ok upheld overturn snooze].include?(action) + error!({ error: 'Invalid moderation action' }, 400) + end + + moderated_task = ModeratedTask.find_by(task: task) + + if moderated_task.escalation? + unless %w[upheld overturn].include?(action) + error!({ error: 'This task is under Feedback Review. Only review actions (upheld or overturn) are allowed. Please refresh the moderation queue.' }, 400) + end + else + unless %w[show_more show_less dismiss_ok snooze].include?(action) + error!({ error: 'Invalid action for this moderated task. Please refresh moderation queue.' }, 400) + end + end + + recent_threshold = 15.minutes.ago + if moderated_task.last_moderated_date && moderated_task.last_moderated_date > recent_threshold + error!({ error: 'Feedback is too new to moderate' }, 400) + end + + apply_to_all = params[:apply_to_all] + + if apply_to_all && !%w[show_less dismiss_ok].include?(action) + error!({ error: 'Bulk moderation can only be used when dismissing a task or seeing less from a tutor' }, 400) + end + + if moderated_task.resolved? + error!({ error: 'This moderated task has already been resolved. Please refresh moderation queue.' }, 400) + end + + state = nil + outcome = nil + + case action + when 'show_more' + delta = -1 + state = :waiting_for_new_feedback + when 'snooze' + delta = 0 + state = :waiting_for_new_feedback + when 'show_less' + delta = 1 + state = :resolved + outcome = :dismissed_good + when 'dismiss_ok' + delta = 0 + state = :resolved + outcome = :dismissed_ok + when 'overturn' + delta = -1 + state = :resolved + outcome = :overturned + when 'upheld' + delta = 0 + state = :resolved + outcome = :upheld + end + + factor = Doubtfire::Application.config.moderation_score_factor + + td_score = TutorFeedbackScore.find_by(unit_role: unit_role, task_definition: task.task_definition) + if td_score.nil? + td_score = TutorFeedbackScore.create!({ + unit_role: unit_role, + task_definition: task.task_definition, + score: 50 + }) + end + + attrs = { + last_moderated_date: Time.zone.now, + resolved_by_user_id: current_user.id, + state: state, + outcome: outcome + } + + ActiveRecord::Base.transaction do + count = 1 + + if apply_to_all + count = 0 + task_ids = unit.tasks.where(task_definition: task.task_definition) + .select { |t| t.tutor == tutor.user } + .map(&:id) + + moderated_tasks = + ModeratedTask.where(task_id: task_ids) + .where(state: %i[open waiting_for_new_feedback]) # Only update active moderation tasks + .where(moderation_type: %i[first_feedback random_sample]) # Don't updated escalated tasks + + moderated_tasks.find_each do |mt| + next if mt.resolved? + count += 1 + mt.update!(attrs) + end + else + moderated_task.update!(attrs) + end + + td_score.update!( + score: (td_score.score + (delta * factor * count)).clamp(0, 99) + ) + end + + true + end end diff --git a/app/api/units_api.rb b/app/api/units_api.rb index bbdc93fca7..9df7ae00c9 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -7,6 +7,8 @@ class UnitsApi < Grape::API helpers AuthorisationHelpers helpers MimeCheckHelpers helpers CsvHelper + helpers SidekiqHelper + helpers FileHelper before do authenticated? @@ -47,7 +49,6 @@ class UnitsApi < Grape::API { tutorial_streams: :activity_type }, { tutorials: [:tutor, :tutorial_stream] }, :tutorial_enrolments, - { staff: [:role, :user] }, :group_sets, :groups, :group_memberships @@ -61,7 +62,7 @@ class UnitsApi < Grape::API # Unit uses user from thread to limit exposure # my_role = unit.role_for(current_user) - present unit, with: Entities::UnitEntity, my_role: my_role, in_unit: true + present unit, with: Entities::UnitEntity, user: current_user, my_role: my_role, in_unit: true end desc 'Update unit' @@ -72,21 +73,33 @@ class UnitsApi < Grape::API optional :code, type: String optional :description, type: String optional :active, type: Boolean + optional :peer_progress_enabled, type: Boolean, desc: 'Enable anonymous peer progress for students in this unit' optional :teaching_period_id, type: Integer optional :start_date, type: Date optional :end_date, type: Date optional :main_convenor_id, type: Integer optional :auto_apply_extension_before_deadline, type: Boolean, desc: 'Indicates if extensions before the deadline should be automatically applied' + optional :mark_late_submissions_as_assess_in_portfolio, type: Boolean, desc: 'Indicates if late submissions should be set Time Exceeded or Assess in Portfolio' optional :send_notifications, type: Boolean, desc: 'Indicates if emails should be sent on updates each week' optional :enable_sync_timetable, type: Boolean, desc: 'Sync to timetable automatically if supported by deployment' optional :enable_sync_enrolments, type: Boolean, desc: 'Sync student enrolments automatically if supported by deployment' optional :draft_task_definition_id, type: Integer, desc: 'Indicates the ID of the task definition used as the "draft learning summary task"' optional :portfolio_auto_generation_date, type: Date, desc: 'Indicates a date where student portfolio will automatically compile' + optional :allow_flexible_dates, type: Boolean, desc: 'Can turn on/off flexible dates for tasks in this unit' optional :allow_student_extension_requests, type: Boolean, desc: 'Can turn on/off student extension requests' optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials' optional :extension_weeks_on_resubmit_request, type: Integer, desc: 'Determines the number of weeks extension on a resubmit request' optional :overseer_image_id, type: Integer, desc: 'The id of the docker image used with ' optional :assessment_enabled, type: Boolean + optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox' + optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking' + optional :enforce_feedback_before_discussed_in_class, type: Boolean, desc: 'Require feedback to be completed before tasks can be marked discussed in class' + optional :grade_definitions, type: Array do + requires :id, type: String + requires :value, type: Integer + requires :label, type: String + requires :abbreviation, type: String + end mutually_exclusive :teaching_period_id, :start_date mutually_exclusive :teaching_period_id, :end_date @@ -104,20 +117,28 @@ class UnitsApi < Grape::API :description, :start_date, :end_date, + :peer_progress_enabled, :teaching_period_id, :active, :main_convenor_id, :auto_apply_extension_before_deadline, + :mark_late_submissions_as_assess_in_portfolio, :send_notifications, :enable_sync_timetable, :enable_sync_enrolments, :draft_task_definition_id, :portfolio_auto_generation_date, + :allow_flexible_dates, :allow_student_extension_requests, :extension_weeks_on_resubmit_request, :allow_student_change_tutorial, :overseer_image_id, - :assessment_enabled) + :assessment_enabled, + :feedback_warning_threshold_days, + :feedback_overflow_threshold_days, + :enforce_feedback_before_discussed_in_class, + grade_definitions: [:id, :value, :label, :abbreviation] + ) if unit.teaching_period_id.present? && (unit_parameters.key?(:start_date) || unit_parameters['teaching_period_id'] == -1) unit.teaching_period = nil @@ -153,13 +174,24 @@ class UnitsApi < Grape::API optional :end_date, type: Date optional :main_convenor_user_id, type: Integer optional :auto_apply_extension_before_deadline, type: Boolean, desc: 'Indicates if extensions before the deadline should be automatically applied', default: true + optional :mark_late_submissions_as_assess_in_portfolio, type: Boolean, desc: 'Indicates if late submissions should be set to Time Exceeded or Assess in Portfolio', default: true optional :send_notifications, type: Boolean, desc: 'Indicates if emails should be sent on updates each week', default: true optional :enable_sync_timetable, type: Boolean, desc: 'Sync to timetable automatically if supported by deployment', default: true optional :enable_sync_enrolments, type: Boolean, desc: 'Sync student enrolments automatically if supported by deployment', default: true + optional :allow_flexible_dates, type: Boolean, desc: 'Can turn on/off flexible dates for tasks in this unit', default: true optional :allow_student_extension_requests, type: Boolean, desc: 'Can turn on/off student extension requests', default: true optional :extension_weeks_on_resubmit_request, type: Integer, desc: 'Determines the number of weeks extension on a resubmit request', default: 1 optional :portfolio_auto_generation_date, type: Date, desc: 'Indicates a date where student portfolio will automatically compile' optional :allow_student_change_tutorial, type: Boolean, desc: 'Can turn on/off student ability to change tutorials', default: true + optional :feedback_warning_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its highlighted in the tutors inbox' + optional :feedback_overflow_threshold_days, type: Integer, desc: 'Number of days since a submission without feedback before its added to overflow marking' + optional :enforce_feedback_before_discussed_in_class, type: Boolean, desc: 'Require feedback to be completed before tasks can be marked discussed in class', default: false + optional :grade_definitions, type: Array do + requires :id, type: String + requires :value, type: Integer + requires :label, type: String + requires :abbreviation, type: String + end mutually_exclusive :teaching_period_id, :start_date mutually_exclusive :teaching_period_id, :end_date @@ -181,19 +213,30 @@ class UnitsApi < Grape::API :start_date, :end_date, :auto_apply_extension_before_deadline, + :mark_late_submissions_as_assess_in_portfolio, :send_notifications, :enable_sync_timetable, :enable_sync_enrolments, + :allow_flexible_dates, :allow_student_extension_requests, :extension_weeks_on_resubmit_request, :portfolio_auto_generation_date, :allow_student_change_tutorial, + :feedback_warning_threshold_days, + :feedback_overflow_threshold_days, + :enforce_feedback_before_discussed_in_class, + grade_definitions: [:id, :value, :label, :abbreviation] ) + # Ensure the user is authorised to convene units + unless authorise? current_user, User, :convene_units + error!({ error: 'You are not authorised to manage units' }, 403) + end + # Identify main convenor - ensure they have the correct role - main_convenor_user = unit_parameters[:main_convenor_user_id].present? ? User.find(unit_parameters[:main_convenor_user_id]) : current_user + main_convenor_user = params[:unit][:main_convenor_user_id].present? ? User.find(params[:unit][:main_convenor_user_id]) : current_user - unless main_convenor_user.present? + if main_convenor_user.blank? error!({ error: 'Main convenor user not found' }, 403) end @@ -209,7 +252,7 @@ class UnitsApi < Grape::API if teaching_period_id.blank? if unit_parameters[:start_date].nil? start_date = Date.parse('Monday') - delta = start_date > Date.today ? 0 : 7 + delta = start_date > Time.zone.today ? 0 : 7 unit_parameters[:start_date] = start_date + delta end @@ -231,9 +274,10 @@ class UnitsApi < Grape::API desc 'Rollover unit' params do - optional :teaching_period_id - optional :start_date - optional :end_date + optional :teaching_period_id, type: Integer, desc: 'The teaching period to rollover to' + optional :start_date, type: Date, desc: 'The start date of the new unit' + optional :end_date, type: Date, desc: 'The end date of the new unit' + optional :new_unit_code, type: String, desc: 'The unit code for the new unit' exactly_one_of :teaching_period_id, :start_date all_or_none_of :start_date, :end_date @@ -249,9 +293,9 @@ class UnitsApi < Grape::API if teaching_period_id.present? tp = TeachingPeriod.find(teaching_period_id) - result = unit.rollover(tp, nil, nil) + result = unit.rollover(tp, nil, nil, params[:new_unit_code]) else - result = unit.rollover(nil, params[:start_date], params[:end_date]) + result = unit.rollover(nil, params[:start_date], params[:end_date], params[:new_unit_code]) end my_role = result.role_for(current_user) @@ -272,6 +316,9 @@ class UnitsApi < Grape::API end desc 'Download the tasks that should be listed under the task inbox' + params do + optional :my_students_only, type: Boolean, desc: 'Show tasks from all tutorials or just the ones you teach' + end get '/units/:id/tasks/inbox' do unit = Unit.find(params[:id]) @@ -279,7 +326,66 @@ class UnitsApi < Grape::API error!({ error: 'Not authorised to provide feedback for this unit' }, 403) end - tasks = unit.tasks_for_task_inbox(current_user) + my_students_only = params[:my_students_only] || false + + tasks = unit.tasks_for_task_inbox(current_user, my_students_only) + present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter + end + + desc 'Get tasks ready for moderation' + get '/units/:id/tasks/moderation' do + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to provide feedback for this unit' }, 403) + end + + unless authorise? current_user, unit, :provide_feedback + error!({ error: 'Not authorised to provide feedback for this unit' }, 403) + end + + tasks = unit.tasks_for_moderation(current_user) + data = tasks.map do |t| + { + id: t.task_id, + project_id: t.project_id, + task_definition_id: t.task_definition_id, + # tutorial_id: t.tutorial_id, + status: TaskStatus.id_to_key(t.status_id), + completion_date: t.completion_date, + submission_date: t.submission_date, + # times_assessed: t.times_assessed, + # grade: t.grade, + # quality_pts: t.quality_pts, + # num_new_comments: t.number_unread, + # similarity_flag: t.similar_to_count > 0, + # pinned: t.pinned, + # has_extensions: t.has_extensions, + moderation_type: t.moderated_task&.moderation_type + } + end + # present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter + present data, with: Grape::Presenters::Presenter + end + + desc 'Get tasks ready for overflow marking' + get '/units/:id/tasks/overflow' do + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :get_students + error!({ error: 'Not authorised to provide feedback for this unit' }, 403) + end + + unless authorise? current_user, unit, :provide_feedback + error!({ error: 'Not authorised to provide feedback for this unit' }, 403) + end + + unit_role = unit.unit_role_for(current_user) + unless unit_role&.can_mark_overflow_tasks? + error!({ error: 'Not authorised to access overflow queue' }, 403) + end + + tasks = unit.tasks_for_overflow_marking(current_user) present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter end @@ -291,7 +397,7 @@ class UnitsApi < Grape::API end content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-Students.csv" + header['Content-Disposition'] = "attachment; filename=#{unit.code}-Grades.csv" header['Access-Control-Expose-Headers'] = 'Content-Disposition' env['api.format'] = :binary @@ -308,14 +414,26 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) end - unless params[:file].present? + if params[:file].blank? error!({ error: "No file uploaded" }, 403) end ensure_csv!(params[:file][:tempfile]) - # Actually import... - unit.import_users_from_csv(params[:file][:tempfile]) + import_csv_dir = Rails.root.join(FileHelper.tmp_file_dir, 'csv') + + file_name = File.join(import_csv_dir, "import-student-csv-#{unit.id}-#{Process.pid}-#{Thread.current.object_id}.csv") + FileUtils.mkdir_p(import_csv_dir) + + csv = CSV.read(params[:file][:tempfile], headers: true) + CSV.open(file_name, "w", write_headers: true, headers: csv.headers) do |out| + csv.each { |row| out << row } + end + + # Queue student import onto sidekiq + job_id = ImportStudentsCsvJob.perform_async(unit.id, file_name) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity end desc 'Upload CSV with the students to un-enrol from the unit' @@ -328,7 +446,7 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to upload CSV of students to #{unit.code}" }, 403) end - unless params[:file].present? + if params[:file].blank? error!({ error: "No file uploaded" }, 403) end @@ -355,6 +473,97 @@ class UnitsApi < Grape::API unit.export_users_to_csv end + desc 'Download CSV of tutor times summary' + params do + optional :start_date, type: Date, desc: 'Filter sessions starting from this date' + optional :end_date, type: Date, desc: 'Filter sessions up to this date' + optional :timezone, type: String, desc: 'Requested timezone to search sessions for' + optional :ignore_sessions_during_tutorials, type: Boolean, desc: 'Filter out sessions that occured during tutorials' + end + get '/csv/units/:id/tutor_times_summary' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :get_tutor_times_summary + error!({ error: "Not authorised to download CSV of marking session summary in #{unit.code}" }, 403) + end + + ignore_sessions_during_tutorials = params[:ignore_sessions_during_tutorials] || false + + job_id = DownloadUnitTutorTimesSummaryJob.perform_async( + unit.id, + params[:start_date]&.to_s, + params[:end_date]&.to_s, + params[:timezone]&.to_s, + ignore_sessions_during_tutorials + ) + + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Download CSV of marking sessions for current unit role' + params do + optional :start_date, type: Date, desc: 'Filter sessions starting from this date' + optional :end_date, type: Date, desc: 'Filter sessions up to this date' + optional :timezone, type: String, desc: 'Requested timezone to search sessions for' + end + get '/csv/units/:id/my_marking_sessions' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :get_marking_sessions + error!({ error: "Not authorised to download CSV of marking sessions for #{unit.code}" }, 403) + end + + unit_role = unit.unit_role_for(current_user) + unless unit_role + error!({ error: "Not authorised to download CSV of marking sessions for #{unit.code}" }, 403) + end + + job_id = DownloadMarkingSessionsJob.perform_async( + unit_role.id, + params[:start_date]&.to_s, + params[:end_date]&.to_s, + params[:timezone]&.to_s, + ) + + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Download CSV of how many times each task changed status' + get '/csv/units/:id/task_assessment_counts' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_unit_csv + error!({ error: "Not authorised to download CSV of student tasks in #{unit.code}" }, 403) + end + + job_id = DownloadTaskAssessmentCountsCsvJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Download CSV of all student tasks awaiting feedback in this unit' + get '/csv/units/:id/tasks_awaiting_feedback' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_unit_csv + error!({ error: "Not authorised to download CSV of student tasks in #{unit.code}" }, 403) + end + + job_id = DownloadTasksAwaitingFeedbackCsvJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Download CSV of overflow task claims in this unit' + get '/csv/units/:id/overflow_task_claims' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_overflow_stats + error!({ error: "Not authorised to download overflow task claim stats for #{unit.code}" }, 403) + end + + job_id = DownloadOverflowTaskClaimsCsvJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + desc 'Download CSV of all student tasks in this unit' get '/csv/units/:id/task_completion' do unit = Unit.find(params[:id]) @@ -362,11 +571,9 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to download CSV of student tasks in #{unit.code}" }, 403) end - content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-TaskCompletion.csv" - header['Access-Control-Expose-Headers'] = 'Content-Disposition' - env['api.format'] = :binary - unit.task_completion_csv + job_id = DownloadTaskCompletionCsvJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity end desc 'Download the stats related to the number of students aiming for each grade' @@ -399,6 +606,61 @@ class UnitsApi < Grape::API present unit.student_task_completion_stats, with: Grape::Presenters::Presenter end + desc 'Get historical task completion snapshots' + params do + optional :start_date, type: Date, desc: 'Include snapshots captured on or after this date' + optional :end_date, type: Date, desc: 'Include snapshots captured on or before this date' + optional :limit, type: Integer, desc: 'Maximum number of snapshots to return', default: 365 + end + get '/units/:id/stats/task_completion_snapshots' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) + end + + snapshots = unit.task_completion_snapshots.order(snapshot_timestamp: :desc) + if params[:start_date].present? + start_timestamp = params[:start_date].in_time_zone.beginning_of_day.to_i + snapshots = snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) >= ?', start_timestamp) + end + if params[:end_date].present? + end_timestamp = params[:end_date].in_time_zone.end_of_day.to_i + snapshots = snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) <= ?', end_timestamp) + end + snapshots = snapshots.limit([params[:limit].to_i, 365].min) + + present snapshots.map { |snapshot| + stats = snapshot.load_stats + + { + snapshot_date: snapshot.snapshot_date, + snapshot_timestamp: snapshot.snapshot_timestamp, + stats: stats + } + }, with: Grape::Presenters::Presenter + end + + desc 'Capture task completion snapshot immediately for this unit' + post '/units/:id/stats/task_completion_snapshots/capture' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :capture_task_completion_snapshot + error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403) + end + + # Check if a snapshot was captured within the past 30 minutes + recent_snapshot = unit.task_completion_snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) > ?', 30.minutes.ago.to_i).order(snapshot_timestamp: :desc).first + if recent_snapshot.present? + recent_snapshot_time = recent_snapshot.snapshot_time + remaining_seconds = [(recent_snapshot_time + 30.minutes - Time.zone.now).ceil, 0].max + remaining_minutes = [(remaining_seconds / 60.0).ceil, 1].max + error!({ error: "A snapshot was captured at #{recent_snapshot_time.strftime('%H:%M')}. Please wait #{remaining_minutes} more minute(s) before capturing another snapshot." }, 429) + end + + job_id = AggregateTaskCompletionStatsJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + desc 'Download stats related to the number of tasks assessed by each tutor' get '/csv/units/:id/tutor_assessments' do unit = Unit.find(params[:id]) @@ -406,11 +668,69 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to download stats of statistics for #{unit.code}" }, 403) end + job_id = DownloadTutorAssessmentStatsJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Compress portfolios into zip file' + get '/submission/units/:id/portfolio/zip' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :get_students + error!({ error: "Not authorised to download portfolios for unit '#{unit.code}'" }, 403) + end + + # Queue portfolio downloads to sidekiq + job_id = DownloadPortfoliosJob.perform_async(current_user.id, unit.id) + job = setup_job(job_id) + + present job, with: Entities::SidekiqJobEntity + end + + desc 'Upload CSV of all the projects grades in the unit' + params do + requires :file, type: File, desc: 'CSV upload file.' + end + post '/units/:id/grades/csv' do + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :upload_grades_csv + error!({ error: "Not authorised to upload CSV of grades to #{unit.code}" }, 403) + end + + if params[:file].blank? + error!({ error: "No file uploaded" }, 403) + end + + ensure_csv!(params[:file][:tempfile]) + + import_csv_dir = Rails.root.join(FileHelper.tmp_file_dir, 'csv') + + file_name = File.join(import_csv_dir, "import-grades-csv-#{unit.id}-#{Process.pid}-#{Thread.current.object_id}-#{current_user.id}.csv") + FileUtils.mkdir_p(import_csv_dir) + + csv = CSV.read(params[:file][:tempfile], headers: true) + CSV.open(file_name, "w", write_headers: true, headers: csv.headers) do |out| + csv.each { |row| out << row } + end + + job_id = ImportGradesCsvJob.perform_async(unit.id, current_user.id, file_name) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity + end + + desc 'Download CSV of staff notes' + get '/csv/units/:id/staff_notes' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :get_staff_notes + error!({ error: "Not authorised to download CSV of marking session summary in #{unit.code}" }, 403) + end + content_type 'application/octet-stream' - header['Content-Disposition'] = "attachment; filename=#{unit.code}-TutorAssessments.csv" + header['Content-Disposition'] = "attachment; filename=#{unit.code}-#{unit.id}-StaffNotes.csv" header['Access-Control-Expose-Headers'] = 'Content-Disposition' env['api.format'] = :binary - unit.tutor_assessment_csv + unit.staff_notes_csv end end diff --git a/app/api/users_api.rb b/app/api/users_api.rb index ffcf6a42fd..9a24eedc4f 100644 --- a/app/api/users_api.rb +++ b/app/api/users_api.rb @@ -59,6 +59,7 @@ class UsersApi < Grape::API optional :receive_task_notifications, type: Boolean, desc: 'Allow user to be sent task notifications' optional :receive_portfolio_notifications, type: Boolean, desc: 'Allow user to be sent portfolio notifications' optional :receive_feedback_notifications, type: Boolean, desc: 'Allow user to be sent feedback notifications' + optional :display_peer_progress, type: Boolean, desc: 'Display anonymous peer progress information' optional :opt_in_to_research, type: Boolean, desc: 'Allow user to opt in to research conducted by Doubtfire' optional :has_run_first_time_setup, type: Boolean, desc: 'Whether or not user has run first-time setup' end @@ -66,9 +67,16 @@ class UsersApi < Grape::API put '/users/:id' do change_self = (params[:id] == current_user.id) - params[:receive_portfolio_notifications] = true if params.key?(:receive_portfolio_notifications) && params[:receive_portfolio_notifications].nil? - params[:receive_portfolio_notifications] = true if params.key?(:receive_feedback_notifications) && params[:receive_feedback_notifications].nil? - params[:receive_portfolio_notifications] = true if params.key?(:receive_task_notifications) && params[:receive_task_notifications].nil? + # Default notification preferences to true when explicitly sent as null. + # (Previously this wrote the portfolio key three times and read the + # top-level params instead of the nested :user hash, so it never applied.) + %i[receive_task_notifications receive_portfolio_notifications receive_feedback_notifications].each do |pref| + params[:user][pref] = true if params[:user].key?(pref) && params[:user][pref].nil? + end + if params[:user].key?(:display_peer_progress) && + params[:user][:display_peer_progress].nil? + params[:user][:display_peer_progress] = true + end # can only modify if current_user.id is same as :id provided # (i.e., user wants to update their own data) or if update_user token @@ -87,6 +95,7 @@ class UsersApi < Grape::API :receive_task_notifications, :receive_portfolio_notifications, :receive_feedback_notifications, + :display_peer_progress, :opt_in_to_research, :has_run_first_time_setup ) @@ -206,7 +215,7 @@ class UsersApi < Grape::API error!({ error: 'Not authorised to upload CSV of users' }, 403) end - unless params[:file].present? + if params[:file].blank? error!({ error: "No file uploaded" }, 403) end diff --git a/app/api/webcal_public_api.rb b/app/api/webcal_public_api.rb index a2bc091e9f..4c18aebc1f 100644 --- a/app/api/webcal_public_api.rb +++ b/app/api/webcal_public_api.rb @@ -14,9 +14,10 @@ class WebcalPublicApi < Grape::API webcal = Webcal.find_by!(guid: params[:guid]) # Serve the iCalendar with the correct MIME type. + env['api.format'] = :txt content_type 'text/calendar' - # Seve ical. - present webcal.to_ical.to_ical + # Serve ical. + webcal.to_ical.to_ical end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 227579c774..a3e2ff1aba 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,7 +7,5 @@ class ApplicationController < ActionController::Base # redirect_to root_url, alert: exception.message # end - def headers - request.headers - end + delegate :headers, to: :request end diff --git a/app/controllers/lecture_resource_downloads_controller.rb b/app/controllers/lecture_resource_downloads_controller.rb index bb4c8bc604..f91e3eddbb 100644 --- a/app/controllers/lecture_resource_downloads_controller.rb +++ b/app/controllers/lecture_resource_downloads_controller.rb @@ -32,7 +32,7 @@ def index error!({ error: 'No files to download' }, 403) if output_zip.nil? - download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-resources-#{unit.code}" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-resources-#{unit.code}" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) diff --git a/app/controllers/portfolio_downloads_controller.rb b/app/controllers/portfolio_downloads_controller.rb index a11fdb65e9..4f8206c5e6 100644 --- a/app/controllers/portfolio_downloads_controller.rb +++ b/app/controllers/portfolio_downloads_controller.rb @@ -29,13 +29,12 @@ def index error!({ error: "Not authorised to download portfolios for unit '#{params[:id]}'" }, 401) end - output_zip = unit.get_portfolio_zip(current_user) - - error!({ error: 'No files to download' }, 403) if output_zip.nil? + output_zip = unit.get_portfolio_zip_filename(current_user) + error!({ error: 'No files to download' }, 403) unless File.exist?(output_zip) # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-portfolios-#{unit.code}-#{current_user.username}" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-portfolios-#{unit.code}-#{current_user.username}" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" diff --git a/app/controllers/readiness_controller.rb b/app/controllers/readiness_controller.rb new file mode 100644 index 0000000000..d23bce182e --- /dev/null +++ b/app/controllers/readiness_controller.rb @@ -0,0 +1,5 @@ +class ReadinessController < ActionController::API + def show + head(ReadinessCheck.new.ready? ? :ok : :service_unavailable) + end +end diff --git a/app/controllers/task_downloads_controller.rb b/app/controllers/task_downloads_controller.rb index d30120955c..2a0c8d0763 100644 --- a/app/controllers/task_downloads_controller.rb +++ b/app/controllers/task_downloads_controller.rb @@ -37,7 +37,7 @@ def index # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-files" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-files" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" diff --git a/app/controllers/task_submission_pdfs_controller.rb b/app/controllers/task_submission_pdfs_controller.rb index 5a183d9a31..fec8fc20e9 100644 --- a/app/controllers/task_submission_pdfs_controller.rb +++ b/app/controllers/task_submission_pdfs_controller.rb @@ -37,7 +37,7 @@ def index # Set download headers... # content_type "application/octet-stream" - download_id = "#{Time.new.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-pdfs" + download_id = "#{Time.zone.now.strftime('%Y-%m-%d %H:%m:%S')}-#{unit.code}-#{td.abbreviation}-#{current_user.username}-pdfs" download_id.gsub! /[\\\/]/, '-' download_id = FileHelper.sanitized_filename(download_id) # header['Content-Disposition'] = "attachment; filename=#{download_id}.zip" diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index fc6d56c590..6b150f8156 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,6 +11,8 @@ def application_reference_date # Escape text for inclusion in Latex documents def lesc(text) # Convert to latex text, then use gsub to remove any characters that are not printable + # rubocop:disable Rails/OutputSafety raw(LatexToPdf.escape_latex(text).gsub(/[^[:print:]]/, '')) + # rubocop:enable Rails/OutputSafety end end diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb index 96128ae0af..c1275d6294 100644 --- a/app/helpers/authentication_helpers.rb +++ b/app/helpers/authentication_helpers.rb @@ -1,4 +1,5 @@ require 'onelogin/ruby-saml' +require 'uri' # # The AuthenticationHelpers include functions to check if the user @@ -7,39 +8,122 @@ # This is used by the grape api. # module AuthenticationHelpers - module_function + # private functions + # Check that the user and token are valid + # @param user_param [String] The username + # @param auth_param [String] The authentication token + # @param token_type [Symbol] The type of token to accept # - # Checks if the requested user is authenticated. - # Reads details from the params fetched from the caller context. - # - def authenticated? - auth_param = headers['auth-token'] || headers['Auth-Token'] || params['authToken'] || headers['Auth_Token'] || headers['auth_token'] || params['auth_token'] || params['Auth_Token'] - user_param = headers['username'] || headers['Username'] || params['username'] - + # @returns [Symbol] + # :valid if the user and token are valid + # :token_expired if the token is expired + def user_auth_token_type(user_param, auth_param, token_type) # Check for valid auth token and username in request header user = current_user # Authenticate from header or params if auth_param.present? && user_param.present? && user.present? # Get the list of tokens for a user - token = user.token_for_text?(auth_param) + token = user.token_for_text?(auth_param, token_type) end - # Check user by token + # Check user and token if user.present? && token.present? + # has the tolken not expired? if token.auth_token_expiry > Time.zone.now - logger.info("Authenticated #{user.username} from #{request.ip}") - return true + logger.info("Authenticated user_id=#{user.id} from #{request.ip}") + :valid + else + # Token is timed out - destroy it and return error + token.destroy! + logger.info("Timing out token for #{user.username} from #{request.ip}") + :token_expired end + elsif token.present? + # Never echo the presented credential. This branch is reached for invalid + # and expired tokens, which are exactly the values an attacker may try to + # force into application logs. + logger.info("Error logging in with an invalid one-time token from #{request.ip}") + :error + else + :missing_details + end + end + + # + # Public functions + # + module_function + + # Keep one-time sign-in credentials out of the query string. Query strings + # are routinely captured by reverse-proxy access logs, browser history, and + # telemetry. A URI fragment is not sent in the HTTP request; the web client + # consumes and removes it before initialising telemetry. + def frontend_sign_in_url(host:, auth_token:, username:) + callback_fragment = URI.encode_www_form( + authToken: auth_token, + username: username + ) + "#{host.to_s.delete_suffix('/')}/sign_in##{callback_fragment}" + end + + def authenticated_via_refresh_token? + auth_param = cookies['refresh_token'] + user_param = cookies['username'] + + case user_auth_token_type(user_param, auth_param, :refresh_token) + when :valid + # Valid token and user + true + when :token_expired, :error, :missing_details + # Token expired - remove cookies + set_refresh_cookie_in_response(false) + false + end + end + + # Get the user and token from the request + # @param source [Symbol] The source of the request + # :header - from the request header + # :cookie - from the request cookie + # @return [String, String] The username and token + def get_user_and_token_from(source) + if source == :header + user_param = headers['username'] || headers['Username'] || params['username'] + auth_param = headers['auth-token'] || headers['Auth-Token'] || params['authToken'] || headers['Auth_Token'] || headers['auth_token'] || params['auth_token'] || params['Auth_Token'] + elsif source == :cookie + user_param = cookies['username'] + auth_param = cookies['refresh_token'] + else + # Default to nil + user_param = nil + auth_param = nil + end + + [user_param, auth_param] + end + + # + # Checks if the requested user is authenticated. + # Reads details from the params fetched from the caller context. + # + def authenticated?(token_type = :general) + if token_type == :refresh_token + # Access credentials from cookie + user_param, auth_param = get_user_and_token_from(:cookie) + else + user_param, auth_param = get_user_and_token_from(:header) + end + + case user_auth_token_type(user_param, auth_param, token_type) + when :valid + true + when :token_expired # Token is timed out - destroy it and throw error - logger.info("Timing out token for #{user.username} from #{request.ip}") - token.destroy! error!({ error: 'Authentication token expired.' }, 419) - elsif token.present? - logger.info("Error logging in for #{user_param} / #{auth_param} from #{request.ip}") - + when :error # Add random delay then fail sleep(rand(200..399) / 1000.0) error!({ error: 'Could not authenticate with token. Username or Token invalid.' }, 419) @@ -52,8 +136,8 @@ def authenticated? # Get the current user either from warden or from the header # def current_user - username = headers['username'] || headers['Username'] || params['username'] - User.eager_load(:role, :auth_tokens).find_by_username(username) + username = headers['username'] || headers['Username'] || params['username'] || cookies['username'] + User.eager_load(:role, :auth_tokens).find_by(username: username) end # @@ -87,19 +171,25 @@ def saml_settings return unless saml_auth? metadata_url = Doubtfire::Application.config.saml[:SAML_metadata_url] || nil + metadata_file = Doubtfire::Application.config.saml[:SAML_metadata_file_path] || nil + + idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new if metadata_url - idp_metadata_parser = OneLogin::RubySaml::IdpMetadataParser.new settings = idp_metadata_parser.parse_remote(metadata_url) + elsif metadata_file && File.exist?(metadata_file) + metadata = File.read(metadata_file) + settings = idp_metadata_parser.parse(metadata) else settings = OneLogin::RubySaml::Settings.new settings.idp_cert = Doubtfire::Application.config.saml[:idp_sso_cert] settings.name_identifier_format = Doubtfire::Application.config.saml[:idp_name_identifier_format] end + settings.assertion_consumer_service_url = Doubtfire::Application.config.saml[:assertion_consumer_service_url] settings.sp_entity_id = Doubtfire::Application.config.saml[:entity_id] settings.idp_sso_target_url = Doubtfire::Application.config.saml[:idp_sso_target_url] - settings.idp_slo_target_url = Doubtfire::Application.config.saml[:idp_sso_target_url] + settings.idp_slo_target_url = Doubtfire::Application.config.saml[:idp_sso_signout_url] settings end @@ -111,6 +201,13 @@ def saml_auth? Doubtfire::Application.config.auth_method == :saml end + # + # Returns true if using SAML2.0 auth strategy + # + def lti_enabled? + Doubtfire::Application.config.lti_enabled == true + end + # # Returns true if using AAF devise auth strategy # @@ -131,4 +228,44 @@ def ldap_auth? def db_auth? Doubtfire::Application.config.auth_method == :database end + + # rubocop:disable Naming/AccessorMethodName + def set_refresh_cookie_in_response(remember) + # rubocop:enable Naming/AccessorMethodName + + if remember + token = current_user.auth_tokens.where(token_type: :refresh_token).last + + # Generate a new token when the old one is absent or getting close to expiring + if token.nil? || token.auth_token_expiry <= Time.zone.now - 12.hours + token = current_user.generate_authentication_token!(token_type: :refresh_token) + end + + domain = Doubtfire::Application.config.institution[:cookie_domain] + + cookies[:username] = { + value: current_user.username, + expires: token.auth_token_expiry, + domain: domain, + path: '/api/auth', + secure: Rails.env.production?, + same_site: true, + httponly: true + } + + cookies[:refresh_token] = { + value: token.authentication_token, + expires: token.auth_token_expiry, + domain: domain, + path: '/api/auth', + secure: Rails.env.production?, + same_site: true, + httponly: true + } + else + # Remove the cookies + cookies.delete(:username, domain: domain, path: '/api/auth', secure: Rails.env.production?, httponly: true, same_site: true) + cookies.delete(:refresh_token, domain: domain, path: '/api/auth', secure: Rails.env.production?, httponly: true, same_site: true) + end + end end diff --git a/app/helpers/authorisation_helpers.rb b/app/helpers/authorisation_helpers.rb index d09cbf75ab..fe9d7a8727 100644 --- a/app/helpers/authorisation_helpers.rb +++ b/app/helpers/authorisation_helpers.rb @@ -3,6 +3,32 @@ def get_permission_hash(role, perm_hash, _other) perm_hash[role] unless perm_hash.nil? end + OBSERVER_ONLY_PERMISSIONS = [ + :get, + :get_unit_roles, + :download_unit_csv, + :get_teaching_periods, + :get_scorm_token, + :get_feedback_chips, + :get_all_units, + :list_users, + :get_staff_list, + :get_user, + :download_system_csv, + :get_unit, + :get_students, + :download_stats, + :download_grades, + :download_jplag_report, + :get_submission, + :view_plagiarism, + :get_discussion, + :get_staff_note, + :get_members, + :get_groups, + :get_discussion_prompt + ].freeze + # # Authorises if the user can perform an action on the object # @@ -19,6 +45,19 @@ def authorise?(user, object, action, perm_get_fn = method(:get_permission_hash), return false if role_obj.nil? + # Observer status cannot change an allowlisted permission, so avoid a unit + # role lookup for those hot-path reads (including plagiarism visibility). + unless OBSERVER_ONLY_PERMISSIONS.include?(action) + unit_role = object&.unit_role_for(user) if object.respond_to?(:unit_role_for) + + # Attempt to get the unit role if object has a unit reference + if unit_role.nil? && object.respond_to?(:unit) + unit_role = object.unit.unit_role_for(user) + end + + return false if !unit_role.nil? && unit_role.observer_only + end + role = role_obj.to_sym perm_hash = obj_class.permissions perms = perm_get_fn.call(role, perm_hash, other) diff --git a/app/helpers/base64_helper.rb b/app/helpers/base64_helper.rb new file mode 100644 index 0000000000..8222ce6f5f --- /dev/null +++ b/app/helpers/base64_helper.rb @@ -0,0 +1,7 @@ +module Base64Helper + def base64?(value) + value.is_a?(String) && Base64.strict_encode64(Base64.decode64(value)) == value + rescue ArgumentError + false + end +end diff --git a/app/helpers/csv_helper.rb b/app/helpers/csv_helper.rb index 1dc44b9575..e56af31a94 100644 --- a/app/helpers/csv_helper.rb +++ b/app/helpers/csv_helper.rb @@ -1,6 +1,6 @@ module CsvHelper def csv_date_to_date(date) - return if date.nil? || date.empty? + return if date.blank? date = date.strip diff --git a/app/helpers/d2l_integration.rb b/app/helpers/d2l_integration.rb new file mode 100644 index 0000000000..05f3808ae3 --- /dev/null +++ b/app/helpers/d2l_integration.rb @@ -0,0 +1,367 @@ +# frozen_string_literal: true + +require 'oauth2' + +# Class to load d2l integration features +# +class D2lIntegration + def self.enabled? + Doubtfire::Application.config.d2l_enabled && + Doubtfire::Application.config.d2l_client_id.present? && + Doubtfire::Application.config.d2l_client_secret.present? && + Doubtfire::Application.config.d2l_redirect_uri.present? + end + + def self.d2l_client_id + Doubtfire::Application.config.d2l_client_id + end + + def self.d2l_client_secret + Doubtfire::Application.config.d2l_client_secret + end + + def self.d2l_redirect_uri + Doubtfire::Application.config.d2l_redirect_uri + end + + def self.d2l_oauth_site + Doubtfire::Application.config.d2l_oauth_site + end + + def self.d2l_oauth_authorize_url + Doubtfire::Application.config.d2l_oauth_authorize_url + end + + def self.d2l_oauth_token_url + Doubtfire::Application.config.d2l_oauth_token_url + end + + def self.d2l_api_version + Doubtfire::Application.config.d2l_api_version + end + + def self.d2l_api_host + Doubtfire::Application.config.d2l_api_host + end + + def self.load_config(config) + config.d2l_enabled = ENV['D2L_ENABLED'].present? && (ENV['D2L_ENABLED'].to_s.downcase == 'true' || ENV['D2L_ENABLED'].to_i == 1) + + if config.d2l_enabled + config.d2l_client_id = ENV.fetch('D2L_CLIENT_ID', nil) + config.d2l_client_secret = ENV.fetch('D2L_CLIENT_SECRET', nil) + config.d2l_redirect_uri = ENV.fetch('D2L_REDIRECT_URI', nil) + config.d2l_oauth_site = ENV.fetch('D2L_OAUTH_SITE', 'https://auth.brightspace.com') + config.d2l_oauth_authorize_url = ENV.fetch('D2L_OAUTH_SITE_AUTHORIZE_URL', '/oauth2/auth') + config.d2l_oauth_token_url = ENV.fetch('D2L_OAUTH_SITE_TOKEN_URL', '/core/connect/token') + config.d2l_api_host = ENV.fetch('D2L_API_HOST', nil) + config.d2l_api_version = ENV.fetch('D2L_API_VERSION', '1.7') + end + end + + def self.oauth_client + return nil unless self.enabled? + + OAuth2::Client.new( + self.d2l_client_id, + self.d2l_client_secret, + site: self.d2l_oauth_site, + authorize_url: self.d2l_oauth_authorize_url, + token_url: self.d2l_oauth_token_url + ) + end + + def self.login_url(user) + return nil unless self.enabled? + + # Create oauth client to initiate login + client = self.oauth_client + + # Generate a random state, unique within the user_oauth_states table + state = SecureRandom.hex(16) + + # Ensure state is unique + i = 0 + state = SecureRandom.hex(16) until UserOauthState.create(state: state, user: user) || ++i > 5 + + if UserOauthState.find_by(state: state, user: user).nil? + raise 'Could not create unique state' + end + + # Generate login url + client.auth_code.authorize_url(redirect_uri: self.d2l_redirect_uri, 'scope' => 'core:*:* enrollment:orgunit:read grades:*:*', 'state' => state) + end + + def self.process_callback(code, state) + client = self.oauth_client + + begin + # Get the access token + access_token = client.auth_code.get_token( + code, + redirect_uri: self.d2l_redirect_uri + ) + rescue OAuth2::Error => e + Rails.logger.error("Error getting oauth access token: #{e.message}") + raise(StandardError, 'Error getting access token') + end + + # Extract the token needed to be stored + token = access_token.token + + # Find the state in the user_oauth_states table + user_oauth_state = UserOauthState.find_by(state: state) + + raise(StandardError, 'Invalid state') if user_oauth_state.nil? + + Rails.logger.info("User #{user_oauth_state.user.id} logged in with D2L") + + # Create a user oauth token + UserOauthToken.create( + user: user_oauth_state.user, + provider: :d2l, + token: token, + expires_at: Time.zone.now + 30.minutes + ) + + user_oauth_state.destroy + end + + def self.test_has_details_for!(unit, user) + raise(StandardError, 'D2L not enabled') unless self.enabled? + + # Find the D2L assessment mapping + d2l_mapping = unit.d2l_assessment_mapping + raise(StandardError, 'Add the org unit id in unit administration before posting grades') if d2l_mapping.nil? + + # Get the user's oauth token + token = user.user_oauth_tokens.find_by(provider: :d2l) + + raise(StandardError, `No D2L token found for user #{user.username} when accessing unit #{unit.code}`) if token.nil? + end + + def self.grades_url(d2l_mapping) + "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/#{d2l_mapping.grade_object_id}" + end + + def self.grade_item_exist?(d2l_mapping, access_token) + return false if d2l_mapping.grade_object_id.nil? + + url = self.grades_url(d2l_mapping) + + # Call D2L API to check if the grade item exists + begin + # Try to get the grade item, and if this succeeds, the grade item exists + response = access_token.get(url) + return false unless response.present? && response.status == 200 + response.parsed.id == d2l_mapping.grade_object_id + rescue OAuth2::Error => e + Rails.logger.error("Error checking grade item: #{e.message}") + d2l_mapping.grade_object_id = nil + d2l_mapping.save + false + end + end + + def self.create_grade_item(d2l_mapping, access_token) + return if self.grade_item_exist?(d2l_mapping, access_token) + + app_name = Doubtfire::Application.config.institution[:product_name] + + # Create a grade item in D2L + url = self.grades_url(d2l_mapping) + begin + response = access_token.post( + url, + body: { + 'MaxPoints' => 100, + 'CanExceedMaxPoints' => false, + 'IsBonus' => false, + 'ExcludeFromFinalGradeCalculation' => false, + 'GradeSchemeId' => nil, + 'Name' => "#{app_name} Result", + 'ShortName' => 'Result', + 'GradeType' => 'Numeric', + 'CategoryId' => nil, + 'Description' => { + 'Content' => "Result from #{app_name}", + 'Type' => 'Text' + }, + 'AssociatedTool' => nil, + 'IsHidden' => true + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + d2l_mapping.grade_object_id = response.parsed.id + d2l_mapping.save + rescue OAuth2::Error => e + Rails.logger.error("Error creating grade item: #{e.response.status} #{e.response.body}") + raise(StandardError, 'Error creating grade item') + end + end + + def self.get_grade_weight(d2l_mapping, access_token) + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/categories/" + + begin + response = access_token.get(url) + response.parsed + rescue OAuth2::Error => e + Rails.logger.error("Error getting grade weight: #{e.message}") + raise(StandardError, 'Error getting grade weight') + end + end + + def self.get_class_list(d2l_mapping, access_token) + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/classlist/" + + begin + response = access_token.get(url) + response.parsed + rescue OAuth2::Error => e + Rails.logger.error("Error getting class list: #{e.message}") + raise(StandardError, "Error getting class list") + end + end + + def self.find_project_for_d2l_user(unit, d2l_user) + # Find using the user's org defined id + unit.projects.joins(:user).find_by(users: { student_id: d2l_user['OrgDefinedId'] }) || + # Find using the user's username + unit.projects.joins(:user).find_by(users: { username: d2l_user['UserName'] }) || + # Find using the user's email + unit.projects.joins(:user).find_by(users: { email: d2l_user['Email'] }) + end + + def self.access_token_for_user(user) + oauth_token = user.user_oauth_tokens.where(provider: :d2l).last + if oauth_token.present? + oauth_token.access_token + else + oauth_token # Return nil + end + end + + def self.access_token_for_user!(user) + token = D2lIntegration.access_token_for_user(user) + if token.nil? + raise(StandardError, 'No D2L token found for user') + end + + token + end + + def self.post_grades(unit, user) + test_has_details_for!(unit, user) + + app_name = Doubtfire::Application.config.institution[:product_name] + + # Get the D2L assessment mapping + d2l_mapping = unit.d2l_assessment_mapping + + token = D2lIntegration.access_token_for_user!(user) + + # Check if we need to create the grade item + unless self.grade_item_exist?(d2l_mapping, token) + create_grade_item(d2l_mapping, token) + end + + # Get the class list + list = self.get_class_list(d2l_mapping, token) + + result = [] + done = [] + + list.each do |d2l_student| + if d2l_student['ClasslistRoleDisplayName'] != 'Student' + result << "Ignored,#{d2l_student['OrgDefinedId']},,#{d2l_student['DisplayName']&.remove(',')} is not a student" + next + end + + project = self.find_project_for_d2l_user(unit, d2l_student) + if project.nil? + result << "Error,#{d2l_student['OrgDefinedId']},,No #{app_name} result for #{d2l_student['DisplayName']&.remove(',')}" + next + end + + done << project.id + + # Get the grade for the project + if project.grade.nil? || project.grade <= 0 + result << "Skipped,#{d2l_student['OrgDefinedId']},,No grade for #{project.student.username} in #{app_name}" + next + end + + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/#{d2l_mapping.grade_object_id}/values/#{d2l_student['Identifier']}" + + # Post the grade to D2L + begin + response = token.put( + url, + body: { + "GradeObjectType" => 1, + "PointsNumerator" => project.grade + }.to_json + ) + + # Check if we need to sleep for rate limiting + if response.headers['X-Rate-Limit-Remaining'].present? && response.headers['X-Request-Cost'].present? && response.headers['X-Rate-Limit-Remaining'].to_i < ((response.headers['X-Request-Cost'].to_i * 3) || 10) + sleep(response.headers['X-Rate-Limit-Reset'].to_i) + end + + result << "Success,#{d2l_student['OrgDefinedId']},#{project.grade},Posted grade for #{project.student.username}" + rescue OAuth2::Error => e + Rails.logger.error("Error posting grade for #{unit.code} #{project.student.username}: #{e.response.status} #{e.response.body}") + result << "Error,#{d2l_student['OrgDefinedId']},#{project.grade},Faile to post grade for #{d2l_student['DisplayName']&.remove(',')}" + end + end + + # Report students not found in the class list + unit.active_projects.each do |project| + next if done.include?(project.id) + result << if project.grade.present? && (project.grade > 0) + "Error,#{project.user.username},#{project.grade},Not found in D2L" + else + "Skipped,#{project.user.username},#{project.grade},Result missing or 0 and not found in D2L" + end + end + + result + end + + def self.result_file_path(unit) + "#{FileHelper.unit_dir(unit)}/d2l_post_grades_job_result.csv" + end + + def self.d2l_grade_job_present?(unit) + queue = Sidekiq::Queue.new("default") + queue.each do |job| + return true if job.klass == 'D2lPostGradesJob' && job.args[0] == unit.id + end + + Sidekiq::Workers.new.map do |_process_id, _thread_id, work| + payload = JSON.parse(work['payload']) + + return true if payload['class'] == 'D2lPostGradesJob' && payload['args'][0] == unit.id + end + + false + end + + def self.grade_weighted?(d2l_mapping, user) + url = "#{D2lIntegration.d2l_api_host}/d2l/api/le/#{D2lIntegration.d2l_api_version}/#{d2l_mapping.org_unit_id}/grades/setup/" + + access_token = D2lIntegration.access_token_for_user(user) + + return false if access_token.nil? + + begin + response = access_token.get(url) + 'Weighted'.casecmp?(response.parsed['GradingSystem']) + rescue OAuth2::Error => e + Rails.logger.error("Error getting class list: #{e.message}") + false + end + end +end diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 2ae3597b2f..8526c4b333 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -1,20 +1,28 @@ +# frozen_string_literal: true + require 'English' require 'zip' require 'tmpdir' require 'open3' require 'shellwords' require 'pdf-reader' +require 'zlib' +require 'rubygems/package' module FileHelper extend LogHelper extend TimeoutHelper extend MimeCheckHelpers + ZIP_NESTED_ARCHIVE_EXTENSIONS = %w[ + .7z .bz2 .ear .gz .jar .rar .tar .tar.bz2 .tar.gz .tar.xz .tbz .tbz2 .tgz .txz .war .xz .zip + ].freeze + def known_extension?(extn) - allow_extensions = %w(pdf ps csv xls xlsx pas cpp c cs csv h hpp java py js html coffee scss yaml yml xml json ts r rb rmd rnw rhtml rpres tex vb sql txt md jack hack asm hdl tst out cmp vm sh bat dat ipynb css png bmp tiff tif jpeg jpg gif zip gz tar wav ogg mp3 mp4 webm aac pcm aiff flac wma alac pml) + allow_extensions = %w(pdf ps csv xls xlsx pas cpp c cs csv h hpp java py js html coffee scss yaml yml xml json ts r rb rmd rnw rhtml rpres tex vb sql txt md jack hack asm hdl tst out cmp vm sh bat dat ipynb css png bmp tiff tif jpeg jpg gif zip gz tgz tar wav ogg mp3 mp4 webm aac pcm aiff flac wma alac pml vue) # Allow empty or nil extensions for blobs otherwise check that it matches the allowed list - extn.nil? || extn.empty? || allow_extensions.include?(extn) + extn.blank? || allow_extensions.include?(extn) end # @@ -32,6 +40,16 @@ def accept_file(file, name, kind) 'application/tst', 'text/x-cmp', 'text/x-vm', 'application/x-sh', 'application/x-bat', 'application/dat', 'application/x-wine-extension-ini'] when 'document' mime_allow_list = [ 'application/pdf' ] + when 'zip', 'archive' + mime_allow_list = [ + 'application/zip', + 'application/x-zip', + 'application/x-zip-compressed', + 'multipart/x-zip', + 'application/x-tar', + 'application/gzip', + 'application/x-gzip' + ] when 'audio' mime_allow_list = ['audio/', 'video/webm', 'application/ogg', 'application/octet-stream'] when 'comment_attachment' @@ -42,20 +60,20 @@ def accept_file(file, name, kind) logger.error "Unknown type '#{kind}' provided for '#{name}'" end - extension_check = FileHelper.known_extension?(File.extname(file["tempfile"]).downcase[1..]) + extension_check = FileHelper.known_extension?(File.extname(file['tempfile']).downcase[1..]) unless extension_check - msg = "invalid file extension." - logger.debug "File extension check failed" + msg = 'invalid file extension.' + logger.debug 'File extension check failed' return { accepted: false, msg: msg } end - mime_check = mime_in_list?(file["tempfile"].path, mime_allow_list) + mime_check = mime_in_list?(file['tempfile'].path, mime_allow_list) unless mime_check - msg = "invalid file MIME type, file is likely corrupted." - logger.debug "File MIME check failed" + msg = 'invalid file MIME type, file is likely corrupted.' + logger.debug 'File MIME check failed' return { accepted: false, msg: msg @@ -63,12 +81,12 @@ def accept_file(file, name, kind) end # Extra checks for PDF documents - if kind == "document" - pdf_validation_result = validate_pdf(file["tempfile"].path) + if kind == 'document' + pdf_validation_result = validate_pdf(file['tempfile'].path) if pdf_validation_result[:encrypted] - msg = "PDF file is encrypted, encrypted files are not supported." - logger.debug "PDF file is encrypted" + msg = 'PDF file is encrypted, encrypted files are not supported.' + logger.debug 'PDF file is encrypted' return { accepted: false, msg: msg @@ -76,8 +94,8 @@ def accept_file(file, name, kind) end unless pdf_validation_result[:valid] - msg = "PDF file is corrupted." - logger.debug "PDF file is corrupted" + msg = 'PDF file is corrupted.' + logger.debug 'PDF file is corrupted' return { accepted: false, msg: msg @@ -85,12 +103,24 @@ def accept_file(file, name, kind) end end - logger.debug "Uploaded file is accepted" + if %w[zip archive].include?(kind) + zip_validation_result = validate_zip_upload(file['tempfile'].path, File.basename(file[:filename].to_s)) + + unless zip_validation_result[:valid] + logger.debug "Zip file is invalid: #{zip_validation_result[:msg]}" + return { + accepted: false, + msg: zip_validation_result[:msg] + } + end + end + + logger.debug 'Uploaded file is accepted' # All checks are done { accepted: true, - msg: "success" + msg: 'success' } end @@ -125,9 +155,7 @@ def sanitized_filename(filename) end def task_file_dir_for_unit(unit, create = true) - file_server = Doubtfire::Application.config.student_work_dir - dst = "#{file_server}/" # trust the server config and passed in type for paths - dst << sanitized_path("#{unit.code}-#{unit.id}", 'TaskFiles') << '/' + dst = unit_work_root(unit) << 'TaskFiles/' FileUtils.mkdir_p dst if create && (!Dir.exist? dst) @@ -176,6 +204,30 @@ def student_work_root Doubtfire::Application.config.student_work_dir end + def archive_root + Doubtfire::Application.config.archive_dir + end + + # Get the path to the unit root - will take into consideration if archived + # + # @param [Unit] unit - the unit to get the root path for + # @param [Boolean] archived - whether to use the archived property (true/false) + # or force it to be the archived path (:force) + def unit_work_root(unit, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + "#{archive_root}/" + else + "#{student_work_root}/" + end + + dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' + end + + def project_work_root(project, archived: true, username: nil) + username = project.student.username.to_s if username.nil? + unit_work_root(project.unit, archived: archived) << sanitized_path(username) << '/' + end + # # Generates a path for storing student work # type = [:new, :in_process, :done, :pdf, :plagarism] @@ -187,18 +239,17 @@ def student_work_dir(type = nil, task = nil, create = true) file_server = Doubtfire::Application.config.student_work_dir dst = "#{file_server}/" # trust the server config and passed in type for paths - if !(type.nil? || task.nil?) + if !(type.nil? || task.nil?) # we have task and type if [:discussion, :pdf, :comment].include? type - dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s) << '/' + dst = project_work_root(task.project) << sanitized_path(type.to_s) << '/' elsif [:done, :plagarism].include? type - dst << sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s) << '/' + dst = project_work_root(task.project) << sanitized_path(type.to_s, task.id.to_s) << '/' else # new and in_process -- just have task id # Add task id to dst if we want task dst << "#{type}/#{task.id}/" end - elsif !type.nil? + elsif !type.nil? # have type but not task if [:in_process, :new].include? type - # Add task id to dst if we want task dst << "#{type}/" else raise 'Error in request to student work directory' @@ -211,19 +262,40 @@ def student_work_dir(type = nil, task = nil, create = true) dst end - def unit_dir(unit, create = true) - file_server = Doubtfire::Application.config.student_work_dir - dst = "#{file_server}/" # trust the server config and passed in type for paths - dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' + def dir_for_unit_code_and_id(unit_code, unit_id, create: true, archived: false) + dst = if archived + "#{archive_root}/" + else + "#{student_work_root}/" + end - FileUtils.mkdir_p dst if create && (!Dir.exist? dst) + dst << sanitized_path("#{unit_code}-#{unit_id}") + + FileUtils.mkdir_p dst if create && !Dir.exist?(dst) dst end - def unit_portfolio_dir(unit, create = true) - file_server = Doubtfire::Application.config.student_work_dir - dst = "#{file_server}/portfolio/" # trust the server config and passed in type for paths + def unit_dir(unit, create: true, archived: true) + dir_for_unit_code_and_id(unit.code, unit.id, create: create, archived: archived == :force || (archived && unit.archived)) + end + + def root_portfolio_dir(archived: false) + file_server = if archived + archive_root + else + student_work_root + end + + "#{file_server}/portfolio/" # trust the server config and passed in type for paths + end + + def unit_portfolio_dir(unit, create: true, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + "#{archive_root}/portfolio/" + else + "#{student_work_root}/portfolio/" + end dst << sanitized_path("#{unit.code}-#{unit.id}") << '/' @@ -232,11 +304,30 @@ def unit_portfolio_dir(unit, create = true) dst end + def unit_analytics_dir(unit, create: true, archived: true) + dst = unit_work_root(unit, archived: archived) + dst << 'analytics/' + + FileUtils.mkdir_p(dst) if create + dst + end + + def unit_task_status_snapshot_path(unit, create: true, archived: true) + analytics_dir = unit_analytics_dir(unit, create: create, archived: archived) + FileUtils.mkdir_p(analytics_dir) if create + File.join(analytics_dir, 'task-status-snapshots.zip') + end + + def snapshot_csv_filename(snapshot_timestamp) + return nil if snapshot_timestamp.blank? + "#{sanitized_filename(snapshot_timestamp.to_s)}.csv" + end + # # Generates a path for storing student portfolios # - def student_portfolio_dir(unit, username, create = true) - dst = unit_portfolio_dir(unit, create) + def student_portfolio_dir(unit, username, create: true, archived: true) + dst = unit_portfolio_dir(unit, create: create, archived: archived) dst << sanitized_path(username.to_s) @@ -245,14 +336,49 @@ def student_portfolio_dir(unit, username, create = true) dst end - def student_portfolio_path(unit, username, create = true) - File.join(student_portfolio_dir(unit, username, create), FileHelper.sanitized_filename("#{username}-portfolio.pdf")) + def student_portfolio_path(unit, username, create: true, archived: true) + File.join(student_portfolio_dir(unit, username, create: create, archived: archived), FileHelper.sanitized_filename("#{username}-portfolio.pdf")) + end + + def root_jplag_report_dir(archived: false) + file_server = if archived + archive_root + else + student_work_root + end + + "#{file_server}/jplag/results/" + end + + def unit_jplag_report_dir(unit, create: false, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + File.join(root_jplag_report_dir(archived: true), sanitized_path("#{unit.code}-#{unit.id}")) + else + File.join(root_jplag_report_dir(archived: false), sanitized_path("#{unit.code}-#{unit.id}")) + end + + FileUtils.mkdir_p dst if create + "#{dst}/" + end + + def task_jplag_report_dir(unit, create: false, archived: true) + unit_jplag_report_dir(unit, create: create, archived: archived) + end + + def task_jplag_report_path(unit, task) + File.join(unit_jplag_report_dir(unit), FileHelper.sanitized_filename("#{task.abbreviation}-result.jplag")) end def comment_attachment_path(task_comment, attachment_extension) "#{File.join(student_work_dir(:comment, task_comment.task), "#{task_comment.id.to_s}#{attachment_extension}")}" end + def engagement_attachment_path(engagement, attachment_extension) + dir = File.join(project_work_root(engagement.project), 'engagement') + FileUtils.mkdir_p(dir) + File.join(dir, "#{engagement.id}#{attachment_extension}") + end + def comment_prompt_path(task_comment, attachment_extension, count) "#{File.join(student_work_dir(:discussion, task_comment.task), "#{task_comment.id.to_s}_#{count.to_s}#{attachment_extension}")}" end @@ -264,10 +390,10 @@ def comment_reply_prompt_path(discussion_comment, attachment_extension) def compress_image_to_dest(source, dest, delete_frames = false) exec = "convert -quiet \ \"#{source}\" \ - #{delete_frames ? '-delete 1--1' : ''} -strip -density 72 -quality 85% -resize 2048x2048\\> -resize 48x48\\< \ + #{delete_frames ? '-delete 1--1' : ''} -auto-orient -strip -density 72 -quality 85% -resize 2048x2048\\> -resize 48x48\\< \ \"#{dest}\" >>/dev/null 2>>/dev/null" - did_compress = system_try_within 40, 'compressing image using convert', exec + system_try_within 40, 'compressing image using convert', exec end def compress_pdf(path, max_size: 2_500_000, timeout_seconds: 30) @@ -330,11 +456,198 @@ def compress_pdf(path, max_size: 2_500_000, timeout_seconds: 30) FileUtils.rm_f tmp_file end + def zip_path_safe?(path) + return false if path.blank? + + clean_path = path.tr('\\', '/') + return false if clean_path.start_with?('/') || clean_path.include?("\0") + + clean_path.sub!(%r{\A\./+}, '') + clean_path.split('/').none? { |part| part.blank? || part == '.' || part == '..' } + end + + def zip_entry_limit + limit = Doubtfire::Application.config.zip_entry_limit.to_i + limit.positive? ? limit : 1_000 + end + + def zip_compression_ratio_limit + limit = Doubtfire::Application.config.zip_compression_ratio_limit.to_i + limit.positive? ? limit : 100 + end + + def zip_uncompressed_size_multiplier + multiplier = Doubtfire::Application.config.zip_uncompressed_size_multiplier.to_i + multiplier.positive? ? multiplier : 10 + end + + def zip_nested_archive?(path) + clean_path = path.to_s.downcase + ZIP_NESTED_ARCHIVE_EXTENSIONS.any? { |extension| clean_path.end_with?(extension) } + end + + def validate_zip_upload_entry!(name, size, zip_stats, _max_file_size, max_uncompressed_size) + raise 'Zip contains a file with an unsafe path.' unless zip_path_safe?(name) + raise 'Zip contains another archive file. Nested archives are not allowed.' if zip_nested_archive?(name) + + zip_stats[:entries] += 1 + zip_stats[:total_uncompressed_size] += size.to_i + + raise "Zip contains too many files. Limit is #{zip_entry_limit} files." if zip_stats[:entries] > zip_entry_limit + # raise "Zip contains a file larger than the #{max_file_size / 1_000_000}MB file limit." if size.to_i > max_file_size + if zip_stats[:total_uncompressed_size] > max_uncompressed_size + raise "Zip expands beyond the #{max_uncompressed_size / 1_000_000}MB uncompressed size limit." + end + end + + def validate_zip_file(path, max_file_size, max_uncompressed_size) + stats = { entries: 0, total_uncompressed_size: 0 } + + Zip::File.open(path) do |zip_file| + zip_file.each do |entry| + raise 'Encrypted zip entries are not supported.' if entry.respond_to?(:encrypted?) && entry.encrypted? + raise 'Zip contains an unsupported link entry.' if entry.respond_to?(:ftype) && entry.ftype == :symlink + next if entry.directory? + + validate_zip_upload_entry!(entry.name, entry.size, stats, max_file_size, max_uncompressed_size) + end + end + + stats + end + + def validate_tar_file(io, max_file_size, max_uncompressed_size) + stats = { entries: 0, total_uncompressed_size: 0 } + + Gem::Package::TarReader.new(io) do |tar| + tar.each do |entry| + next if entry.directory? + raise 'Zip contains an unsupported non-file entry.' unless entry.file? + + validate_zip_upload_entry!(entry.full_name, entry.header.size, stats, max_file_size, max_uncompressed_size) + end + end + + stats + end + + def validate_zip_upload(path, filename) + max_file_size = Doubtfire::Application.config.max_file_size.to_i + max_file_size = 10_000_000 if max_file_size <= 0 + max_uncompressed_size = max_file_size * zip_uncompressed_size_multiplier + return { valid: false, msg: "Zip exceeds the #{max_file_size / 1_000_000}MB file limit." } if File.size(path) > max_file_size + + begin + stats = + if filename.downcase.end_with?('.zip') + validate_zip_file(path, max_file_size, max_uncompressed_size) + elsif filename.downcase.end_with?('.tar') + File.open(path, 'rb') { |file| validate_tar_file(file, max_file_size, max_uncompressed_size) } + elsif filename.downcase.end_with?('.tar.gz', '.tgz') + Zlib::GzipReader.open(path) { |gzip| validate_tar_file(gzip, max_file_size, max_uncompressed_size) } + else + return { valid: false, msg: 'Unsupported zip format. Use .zip, .tar, .tar.gz, or .tgz.' } + end + + return { valid: false, msg: 'Zip must contain at least one file.' } if stats[:entries].zero? + + compressed_size = [File.size(path), 1].max + if stats[:total_uncompressed_size] / compressed_size > zip_compression_ratio_limit + return { valid: false, msg: "Zip compression ratio is too high. Limit is #{zip_compression_ratio_limit}:1." } + end + + { valid: true, msg: 'success' } + rescue Zip::Error, Zlib::Error, Gem::Package::TarInvalidError, EOFError + { valid: false, msg: 'Zip file is corrupted or not a supported zip.' } + rescue StandardError => e + { valid: false, msg: e.message } + end + end + + def zip_tree_add_path(tree, path) + clean_path = path.to_s.tr('\\', '/').sub(%r{\A\./+}, '').sub(%r{/+\z}, '') + return if clean_path.blank? + + parts = clean_path.split('/').reject(&:blank?) + node = tree + + parts.each_with_index do |part, index| + key = index == parts.length - 1 ? part : "#{part}/" + node[key] ||= {} + node = node[key] + end + end + + def zip_tree_walk(node, prefix = '', lines = []) + sorted_entries = node.keys.sort_by { |key| [key.end_with?('/') ? 0 : 1, key.downcase] } + + sorted_entries.each_with_index do |name, index| + last = index == sorted_entries.length - 1 + connector = '↳ ' + lines << "#{prefix}#{connector}#{name}" + zip_tree_walk(node[name], "#{prefix} ", lines) if node[name].any? + end + + lines + end + + def zip_file_tree(path, filename, display_limit: 200) + tree = {} + entries = 0 + + read_entry = lambda do |entry_name| + return unless zip_path_safe?(entry_name) + + entries += 1 + zip_tree_add_path(tree, entry_name) + end + + if filename.downcase.end_with?('.zip') + Zip::File.open(path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + + read_entry.call(entry.name) + end + end + elsif filename.downcase.end_with?('.tar') + File.open(path, 'rb') do |file| + Gem::Package::TarReader.new(file) do |tar| + tar.each do |entry| + next if entry.directory? + + read_entry.call(entry.full_name) + end + end + end + elsif filename.downcase.end_with?('.tar.gz', '.tgz', '.gz') + Zlib::GzipReader.open(path) do |gzip| + Gem::Package::TarReader.new(gzip) do |tar| + tar.each do |entry| + next if entry.directory? + + read_entry.call(entry.full_name) + end + end + end + end + + all_lines = zip_tree_walk(tree) + lines = all_lines.first(display_limit) + { lines: lines, entries: entries, tree_lines: all_lines.length, truncated: all_lines.length > display_limit } + rescue Zip::Error, Zlib::Error, Gem::Package::TarInvalidError, EOFError => e + logger.debug "Could not read zip file tree for #{filename}: #{e.message}" + { lines: [], entries: 0, truncated: false, error: true } + rescue StandardError => e + logger.debug "Could not read zip file tree for #{filename}: #{e.message}" + { lines: [], entries: 0, truncated: false, error: true } + end + def pages_in_pdf(path) exec = "qpdf --show-npages #{path}" - out_text, error_text, exit_status = Open3.capture3(exec) - result = out_text.to_i # will default to 0 if not a number + out_text, _error_text, _exit_status = Open3.capture3(exec) + out_text.to_i # will default to 0 if not a number rescue => e logger.error "Failed to run QPDF on #{path}. Rescued with error:\n\t#{e.message}" 0 @@ -357,7 +670,13 @@ def qpdf(path) # - only_before = date for files to move (only if retain from is true) def move_files(from_path, to_path, retain_from = false, only_before = nil) # move into the new dir - and mv files to the in_process_dir - pwd = FileUtils.pwd + begin + pwd = FileUtils.pwd + rescue + # if no pwd, reset to the root + pwd = Rails.root + end + begin FileUtils.mkdir_p(to_path) Dir.chdir(from_path) @@ -366,7 +685,7 @@ def move_files(from_path, to_path, retain_from = false, only_before = nil) begin # remove from_path as files are now "in process" # these can be retained when the old folder wants to be kept - FileUtils.rm_r(from_path) unless retain_from + FileUtils.rm_rf(from_path) unless retain_from rescue logger.warn "failed to rm #{from_path}" end @@ -537,12 +856,50 @@ def move_compressed_task_to_new(task) task.extract_file_from_done student_work_dir(:new), '*', ->(_task, to_path, name) { "#{to_path}#{name}" } end + REPLACEMENTS_PERL_COMMAND = [ + ['[\\\\]u0000','[NUL]'], + ['[\\\\]u0001','[SOH]'], + ['[\\\\]u0002','[STX]'], + ['[\\\\]u0003','[ETX]'], + ['[\\\\]u0004','[EOT]'], + ['[\\\\]u0005','[ENQ]'], + ['[\\\\]u0006','[ACK]'], + ['[\\\\]u0007','[BEL]'], + ['[\\\\]u0008','[BS]'], + ['(? "#{tmp_filename}"` end + # Remove utf8 control character sequences + `perl -i -pe '#{FileHelper::REPLACEMENTS_PERL_COMMAND}' "#{tmp_filename}"` + # Move into place FileUtils.mv(tmp_filename, output_filename) end @@ -558,7 +918,30 @@ def ensure_utf8_code(output_filename, force_ascii) def process_audio(input_path, output_path) logger.info("Trying to process audio in FileHelper") path = Doubtfire::Application.config.institution[:ffmpeg] - TimeoutHelper.system_try_within 20, "Failed to process audio submission - timeout", "#{path} -loglevel quiet -y -i #{input_path} -ac 1 -ar 16000 -sample_fmt s16 #{output_path}" + out_text, error_text, status = Open3.capture3( + 'timeout', '-k', '2', '20', + 'nice', '-n', '10', + path, + '-loglevel', 'quiet', + '-y', + '-i', input_path.to_s, + '-ac', '1', + '-ar', '16000', + '-sample_fmt', 's16', + output_path.to_s + ) + + return true if status.success? + + logger.error( + "Failed to process audio submission from #{input_path} to #{output_path}. " \ + "Exit status: #{status.exitstatus}. STDERR: #{error_text.presence || '(none)'}. " \ + "STDOUT: #{out_text.presence || '(none)'}" + ) + false + rescue => e + logger.error "Failed to process audio submission from #{input_path} to #{output_path}. Rescued with error:\n\t#{e.message}" + false end def sorted_timestamp_entries_in_dir(path) @@ -569,13 +952,39 @@ def latest_submission_timestamp_entry_in_dir(path) sorted_timestamp_entries_in_dir(path)[0] end + def root_submission_history_dir(archived: false) + file_server = if archived + archive_root + else + student_work_root + end + + "#{file_server}/submission_history/" # trust the server config and passed in type for paths + end + + def unit_submission_history_dir(unit, archived: true) + dst = if (unit.archived && archived) || (archived == :force) + "#{archive_root}/" + else + "#{student_work_root}/" + end + + dst << sanitized_path('submission_history', "#{unit.code}-#{unit.id}") + end + + def project_submission_history_dir(project, username: nil, archived: true) + username = project.student.username.to_s if username.nil? + dst = unit_submission_history_dir(project.unit, archived: archived) + + File.join(dst, sanitized_path(username)) + end + def task_submission_identifier_path(type, task) - file_server = Doubtfire::Application.config.student_work_dir - "#{file_server}/submission_history/#{sanitized_path("#{task.project.unit.code}-#{task.project.unit.id}", task.project.student.username.to_s, type.to_s, task.id.to_s)}" + "#{project_submission_history_dir(task.project)}/#{sanitized_path(type.to_s, task.id.to_s)}" end def task_submission_identifier_path_with_timestamp(type, task, timestamp) - "#{task_submission_identifier_path(type, task)}/#{timestamp.to_s}" + "#{task_submission_identifier_path(type, task)}/#{sanitized_path(timestamp.to_s)}" end # Apply line wrapping to a given file, returns true when line wrapping is necessary. @@ -624,11 +1033,20 @@ def line_wrap(path, width: 160) module_function :student_group_work_dir module_function :student_work_dir module_function :student_work_root + module_function :archive_root + module_function :dir_for_unit_code_and_id module_function :unit_dir + module_function :root_portfolio_dir module_function :unit_portfolio_dir + module_function :unit_analytics_dir + module_function :unit_task_status_snapshot_path + module_function :snapshot_csv_filename + module_function :unit_work_root + module_function :project_work_root module_function :student_portfolio_dir module_function :student_portfolio_path module_function :comment_attachment_path + module_function :engagement_attachment_path module_function :comment_prompt_path module_function :comment_reply_prompt_path module_function :compress_image_to_dest @@ -636,6 +1054,18 @@ def line_wrap(path, width: 160) module_function :qpdf module_function :move_files module_function :validate_pdf + module_function :zip_path_safe? + module_function :zip_entry_limit + module_function :zip_compression_ratio_limit + module_function :zip_uncompressed_size_multiplier + module_function :zip_nested_archive? + module_function :validate_zip_upload_entry! + module_function :validate_zip_file + module_function :validate_tar_file + module_function :validate_zip_upload + module_function :zip_tree_add_path + module_function :zip_tree_walk + module_function :zip_file_tree module_function :copy_pdf module_function :read_file_to_str module_function :path_to_plagarism_html @@ -653,9 +1083,16 @@ def line_wrap(path, width: 160) module_function :process_audio module_function :sorted_timestamp_entries_in_dir module_function :latest_submission_timestamp_entry_in_dir + module_function :root_submission_history_dir + module_function :unit_submission_history_dir + module_function :project_submission_history_dir module_function :task_submission_identifier_path module_function :task_submission_identifier_path_with_timestamp module_function :known_extension? module_function :pages_in_pdf module_function :line_wrap + module_function :root_jplag_report_dir + module_function :unit_jplag_report_dir + module_function :task_jplag_report_dir + module_function :task_jplag_report_path end diff --git a/app/helpers/file_stream_helper.rb b/app/helpers/file_stream_helper.rb index c0d9d78981..1644b3fe4b 100644 --- a/app/helpers/file_stream_helper.rb +++ b/app/helpers/file_stream_helper.rb @@ -3,6 +3,8 @@ module FileStreamHelper # file_path is the path to the file to be streamed # this will set the headers and return the content def stream_file(file_path) + # Ensure we have a file path string + file_path = file_path.to_s # Work out what part to return file_size = File.size(file_path) begin_point = 0 @@ -31,13 +33,15 @@ def stream_file(file_path) begin_point = 0 end_point = 10_485_760 else + header['Access-Control-Expose-Headers'] = 'Content-Disposition' if header.key?('Content-Disposition') sendfile file_path return end # Return the requested content - content_length = end_point - begin_point + 1 + content_length = [end_point - begin_point + 1, 0].max # Ensure we don't attempt to read a negative length + header['Access-Control-Expose-Headers'] = header.key?('Content-Disposition') ? 'Content-Disposition,Content-Range,Accept-Ranges' : 'Content-Range,Accept-Ranges' header['Content-Range'] = "bytes #{begin_point}-#{end_point}/#{file_size}" header['Content-Length'] = content_length.to_s header['Accept-Ranges'] = 'bytes' diff --git a/app/helpers/latex_helper.rb b/app/helpers/latex_helper.rb new file mode 100644 index 0000000000..d4c6f0c122 --- /dev/null +++ b/app/helpers/latex_helper.rb @@ -0,0 +1,7 @@ +module LatexHelper + def generate_pdf(template:) + raise 'LATEX_CONTAINER_NAME is not set' if ENV['LATEX_CONTAINER_NAME'].nil? + raise 'LATEX_BUILD_PATH is not set' if ENV['LATEX_BUILD_PATH'].nil? + render_to_string(template: template, layout: true) + end +end diff --git a/app/helpers/lti_helper.rb b/app/helpers/lti_helper.rb new file mode 100644 index 0000000000..335b33d805 --- /dev/null +++ b/app/helpers/lti_helper.rb @@ -0,0 +1,27 @@ +module LtiHelper + def decode_lti_token(token) + begin + secret_key = Doubtfire::Application.config.lti_api_secret + response = JWT.decode(token, secret_key, true, algorithm: 'HS256').first + + jti = response['jti'] + exp = response['exp'] + + raise "Missing jti" if jti.nil? + raise "Missing exp" if exp.nil? + rescue JWT::DecodeError => e + logger.debug "Failed to validate Lti Token: #{e}" + return error!({ error: 'Invalid LTI token.' }, 403) + rescue StandardError => e + logger.debug "Missing token properties: #{e}" + return error!({ error: 'Invalid LTI token.' }, 403) + end + response + end + + def valid_lti_member?(member) + required_fields = %w[user_id email roles given_name family_name name] + missing = required_fields.select { |f| member[f].nil? || member[f].to_s.strip.empty? } + [missing.empty?, missing] + end +end diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb new file mode 100644 index 0000000000..63fbcc3ec8 --- /dev/null +++ b/app/helpers/sidekiq_helper.rb @@ -0,0 +1,9 @@ +module SidekiqHelper + def setup_job(job_id) + error!({ error: "Failed to create job. Job may already be in progress." }, 409) if job_id.nil? + job = Sidekiq::Status.get_all(job_id) + # TODO: create a table to track which user id owns the sidekiq job, instead of bypassing a protected method to store the id + Sidekiq::Status.send(:store_for_id, job_id, { initiator: current_user.id }, nil) + job.with_indifferent_access + end +end diff --git a/app/helpers/timeout_helper.rb b/app/helpers/timeout_helper.rb index e620f30026..2131014e3e 100644 --- a/app/helpers/timeout_helper.rb +++ b/app/helpers/timeout_helper.rb @@ -25,12 +25,10 @@ def try_within(sec, timeout_message = 'operation') # def system_try_within(sec, timeout_message, command) # shell script to kill command after timeout - timeout_exec = Rails.root.join('lib', 'shell', 'timeout.sh') - result = false - try_within sec, timeout_message do - result = system "#{timeout_exec} -t #{sec} nice -n 10 #{command}" - end - result + system "timeout -k 2 #{sec} nice -n 10 #{command}" + rescue + logger.error "Timeout when #{timeout_message} after #{sec}s" + false end # Export functions as module functions diff --git a/app/helpers/turn_it_in.rb b/app/helpers/turn_it_in.rb index b77bcb53f0..c2c0e765ef 100644 --- a/app/helpers/turn_it_in.rb +++ b/app/helpers/turn_it_in.rb @@ -3,29 +3,35 @@ # Class to interact with the Turn It In similarity api # class TurnItIn - @instance = TurnItIn.new - # rubocop:disable Style/ClassVars @@x_turnitin_integration_name = 'formatif-tii' @@x_turnitin_integration_version = '1.0' - @@global_error = nil @@delay_call_until = nil cattr_reader :x_turnitin_integration_name, :x_turnitin_integration_version + def self.enabled? + Doubtfire::Application.config.tii_enabled + end + + def self.register_webhooks? + Doubtfire::Application.config.tii_register_webhook + end + def self.load_config(config) config.tii_enabled = ENV['TII_ENABLED'].present? && (ENV['TII_ENABLED'].to_s.downcase == "true" || ENV['TII_ENABLED'].to_i == 1) - config.tii_add_submissions_to_index = ENV['TII_INDEX_SUBMISSIONS'].present? && (ENV['TII_INDEX_SUBMISSIONS'].to_s.downcase == "true" || ENV['TII_INDEX_SUBMISSIONS'].to_i == 1) - if config.tii_enabled + config.tii_add_submissions_to_index = ENV['TII_INDEX_SUBMISSIONS'].present? && (ENV['TII_INDEX_SUBMISSIONS'].to_s.downcase == "true" || ENV['TII_INDEX_SUBMISSIONS'].to_i == 1) + config.tii_register_webhook = ENV['TII_REGISTER_WEBHOOK'].present? && (ENV['TII_REGISTER_WEBHOOK'].to_s.downcase == "true" || ENV['TII_REGISTER_WEBHOOK'].to_i == 1) + # Turn-it-in TII configuration require 'tca_client' # Setup authorization TCAClient.configure do |tii_config| # Configure API key authorization: api_key - tii_config.api_key['api_key'] = ENV.fetch('TCA_API_KEY', nil) + tii_config.api_key['api_key'] = Doubtfire::Application.fetch_credential_or_env(:tii, :api_key, env_key: 'TCA_API_KEY') # Uncomment the following line to set a prefix for the API key, e.g. 'Bearer' (defaults to nil) tii_config.api_key_prefix['api_key'] = 'Bearer' tii_config.host = ENV.fetch('TCA_HOST', nil) @@ -38,7 +44,7 @@ def self.load_config(config) # Launch the tii background jobs def self.launch_tii(with_webhooks: true) - TiiRegisterWebHookJob.perform_async if with_webhooks + TiiRegisterWebHookJob.perform_async if with_webhooks && TurnItIn.register_webhooks? load_tii_features load_tii_eula rescue StandardError => e @@ -57,41 +63,6 @@ def self.load_tii_features feature_job.fetch_features_enabled end - # A global error indicates that tii is not configured correctly or a change in the - # environment requires that the configuration is updated - def self.global_error - return nil unless Doubtfire::Application.config.tii_enabled - - Rails.cache.fetch("tii.global_error") do - @@global_error - end - end - - # Update the global error, when present this will block calls to tii until resolved - def self.global_error=(value) - return unless Doubtfire::Application.config.tii_enabled - - @@global_error = value - - if value.present? - Rails.cache.write("tii.global_error", value) - else - Rails.cache.delete("tii.global_error") - end - end - - # Indicates if there is a global error that indicates that things should not call tii until resolved - def self.global_error? - return false unless Doubtfire::Application.config.tii_enabled - - Rails.cache.exist?("tii.global_error") || @@global_error.present? - end - - # Indicates that tii can be called, that it is configured and there are no global errors - def self.functional? - Doubtfire::Application.config.tii_enabled && !TurnItIn.global_error? - end - # Indicates that the service is rate limited def self.rate_limited? @@delay_call_until.present? && DateTime.now < @@delay_call_until @@ -111,8 +82,13 @@ def self.handle_tii_error(action, error) case error.code when 429 # rate limit @@delay_call_until = DateTime.now + 1.minute - when 403 # forbidden, issue with authentication... do not attempt more tii requests - TurnItIn.global_error = [403, error.message] + when 403 # forbidden, issue with authentication... notify admin + begin + Sentry.capture_exception(error) if defined?(Sentry) + ErrorLogMailer.error_message('TII Credentials', "TII Error: #{error.message}", error).deliver + rescue StandardError => e + Rails.logger.error "Failed to send error email: #{e}" + end end end @@ -120,7 +96,7 @@ def self.handle_tii_error(action, error) # Get the current eula - value is refreshed every 24 hours def self.eula_version - return nil unless Doubtfire::Application.config.tii_enabled + return nil unless TurnItIn.enabled? action = TiiActionFetchEula.last || TiiActionFetchEula.create action.fetch_eula_version unless action.eula? @@ -132,7 +108,7 @@ def self.eula_version # Return the html for the eula def self.eula_html - return nil unless Doubtfire::Application.config.tii_enabled + return nil unless TurnItIn.enabled? Rails.cache.fetch("tii.eula_html.#{TurnItIn.eula_version}") end @@ -160,7 +136,7 @@ def self.webhook_url # @param unit [Unit] the unit to create or get the group context for # @return [TCAClient::GroupContext] the group context for the unit def self.create_or_get_group_context(unit) - unless unit.tii_group_context_id.present? + if unit.tii_group_context_id.blank? unit.tii_group_context_id = SecureRandom.uuid unit.save end @@ -185,6 +161,15 @@ def self.tii_user_for(user) ) end + def self.tii_user_for_group(grp) + TCAClient::Users.new( + id: "group-#{grp.id}", + family_name: 'Submission', + given_name: 'Group', + email: user.email + ) + end + def self.tii_role_for(task, user) user_role = task.role_for(user) if [:tutor].include?(user_role) || (user_role.nil? && user.role_id == Role.admin_id) @@ -194,6 +179,16 @@ def self.tii_role_for(task, user) end end + # Check and retry any failed tii submissions, where it was due to no accepted EULA + def self.check_and_retry_submissions_with_updated_eula + TiiActionUploadSubmission + .where( + complete: false, + custom_error_message: TiiActionUploadSubmission::NO_USER_ACCEPTED_EULA_ERROR + ) + .find_each(&:attempt_retry_on_no_eula) + end + private def logger diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000000..ead50cd963 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,2 @@ +class ApplicationMailer < ActionMailer::Base +end diff --git a/app/mailers/communications_mailer.rb b/app/mailers/communications_mailer.rb new file mode 100644 index 0000000000..95034342d9 --- /dev/null +++ b/app/mailers/communications_mailer.rb @@ -0,0 +1,37 @@ +class CommunicationsMailer < ApplicationMailer + def communication_email(to:, from:, subject:, body:, recipient:, sender:, unit:, rule:) + @recipient = recipient + @sender = sender + @unit = unit + @rule = rule + @body = body.to_s + @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?) + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + + mail(to: to, from: from, subject: subject) + end + + def action_log_email(payload) + @recipient = payload[:recipient] + @sender = payload[:sender] + @unit = payload[:unit] + @rule = payload[:rule] + @body = payload[:body].to_s + @body_paragraphs = @body.split(/\r?\n/).map(&:strip).reject(&:blank?) + @affected_students_count = payload[:affected_students_count] + + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + + attachments[payload[:csv_filename]] = { + mime_type: 'text/csv', + content: payload[:csv_content] + } + + mail(to: payload[:to], from: payload[:from], subject: payload[:subject]) + end +end diff --git a/app/mailers/convenor_contact_mailer.rb b/app/mailers/convenor_contact_mailer.rb index bb57b8cc0f..5859399bda 100644 --- a/app/mailers/convenor_contact_mailer.rb +++ b/app/mailers/convenor_contact_mailer.rb @@ -1,4 +1,4 @@ -class ConvenorContactMailer < ActionMailer::Base +class ConvenorContactMailer < ApplicationMailer def request_project_membership(user, _convenor, unit, _first_name, _last_name) @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] diff --git a/app/mailers/d2l_result_mailer.rb b/app/mailers/d2l_result_mailer.rb new file mode 100644 index 0000000000..b50ff6bdb6 --- /dev/null +++ b/app/mailers/d2l_result_mailer.rb @@ -0,0 +1,20 @@ +class D2lResultMailer < ApplicationMailer + def result_message(unit, user, result_message: 'completed', success: true) + email = user.email + return nil if email.blank? + + path = D2lIntegration.result_file_path(unit) + + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @user = user + @unit = unit + @result_message = result_message + @has_file = success && File.exist?(path) + + if @has_file + attachments['result.csv'] = File.read(path) + end + + mail(to: email, from: email, subject: "#{@doubtfire_product_name} #{unit.code} - D2L Grade Transfer Result") + end +end diff --git a/app/mailers/error_log_mailer.rb b/app/mailers/error_log_mailer.rb new file mode 100644 index 0000000000..578a6f6fc3 --- /dev/null +++ b/app/mailers/error_log_mailer.rb @@ -0,0 +1,16 @@ +class ErrorLogMailer < ApplicationMailer + def error_message(subject, message, exception) + email = Doubtfire::Application.config.email_errors_to + return nil if email.blank? + + if exception.instance_of?(Task::LatexError) || exception.instance_of?(Project::LatexError) + attachments['log.txt'] = exception.log_message + end + + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + backtrace = exception.backtrace&.join("\n") || 'No backtrace available' + @error_log = "#{message}\n\n#{exception.message}\n\n#{backtrace}" + + mail(to: email, from: email, subject: "#{@doubtfire_product_name} Error Log - #{subject}") + end +end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 741fa18b8e..f57ea06226 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -1,8 +1,47 @@ -class NotificationsMailer < ActionMailer::Base +class NotificationsMailer < ApplicationMailer def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] - @unsubscribe_url = "#{@doubtfire_host}/#/home?notifications" + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + end + + # Sends a single in-system notification as an email. Called by + # NotificationEmailJob, which lets delivery failures reach Sidekiq so they can + # be retried without blocking the request that created the notification. + def single_notification(notification) + add_general + + @notification = notification + @user = notification.user + + # Use the deployment's SMTP-authorised sender, with a development-safe + # fallback for older installations that have not configured one yet. + from_address = Doubtfire::Application.config.institution[:email_sender].presence || 'noreply@doubtfire.local' + + email_with_name = %("#{@user.name}" <#{@user.email}>) + subject = "#{@doubtfire_product_name}: New notification" + + # An event may ship its own pair of templates named after it, for example + # task_comment_created.html.erb and task_comment_created.text.erb. Events + # without them fall back to the generic single_notification pair. + # + # This is why a new event ticket only ever adds files and never edits this + # method: eight event tickets can run in parallel without touching each + # other's work. + mail( + to: email_with_name, + from: from_address, + subject: subject, + template_name: event_template_name(notification.event) + ) + end + + # The event's own template if it exists, otherwise the generic one. + def event_template_name(event) + return 'single_notification' if event.blank? + return 'single_notification' unless lookup_context.exists?(event, [self.class.mailer_name], false) + + event end def weekly_staff_summary(unit_role, summary_stats) @@ -13,7 +52,27 @@ def weekly_staff_summary(unit_role, summary_stats) @staff = unit_role.user @unit_role = unit_role @unit = summary_stats[:unit] - @data = summary_stats[:staff][unit_role] + + @received_comments = @unit.comments + .where("task_comments.recipient_id = :uid AND task_comments.created_at > :start", uid: @staff.id, start: 7.days.ago) + .where(content_type: [:text, :assessment, :audio, :image, :pdf, :discussion, :extension]) + .count + + @sent_comments = @unit.comments + .where("task_comments.user_id = :uid AND task_comments.created_at > :start", uid: @staff.id, start: 7.days.ago) + .where(content_type: [:text, :assessment, :audio, :image, :pdf, :discussion, :extension]) + .count + + @data = { + sent_comments: @sent_comments, # Sent by tutor + received_comments: @received_comments, # Received by tutor + tasks_awaiting_feedback_count: summary_stats[:staff][unit_role.user][:tasks_awaiting_feedback_count], # For the tutor + weekly_engagements_count: summary_stats[:staff][unit_role.user][:weekly_engagements_count], # Engagements from the student? + staff_engagements: summary_stats[:staff][unit_role.user][:staff_engagements], # Engagements by the tutor + oldest_task_days: summary_stats[:staff][unit_role.user][:oldest_task_days], + weekly_total_tasks_discussed: summary_stats[:staff][unit_role.user][:weekly_total_tasks_discussed] # Total for the tutor for the week for the tutor + } + @convenor = @unit.main_convenor_user @summary_stats = summary_stats @@ -41,12 +100,12 @@ def weekly_student_summary(project, summary_stats, did_revert_to_pass) @student_engagements = @engagements.select { |e| [TaskStatus.not_started.name, TaskStatus.need_help.name, TaskStatus.working_on_it.name, TaskStatus.ready_for_feedback.name].include? e.engagement }.count - @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count + @staff_engagements = @engagements.select { |e| [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.rediscuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name].include? e.engagement }.count @task_states = project.tasks.joins(:task_status).select("count(tasks.id) as number, task_statuses.name as status").group("task_statuses.name") - @received_comments = project.comments.where("recipient_id = :student_id AND task_comments.created_at > :start", student_id: @student.id, start: Time.zone.today - 7.days).count - @sent_comments = project.comments.where("user_id = :student_id AND task_comments.created_at > :start", student_id: @student.id, start: Time.zone.today - 7.days).count + @received_comments = project.comments.where("recipient_id = :student_id AND task_comments.created_at > :start", student_id: @student.id, start: Time.zone.now - 7.days).count + @sent_comments = project.comments.where("user_id = :student_id AND task_comments.created_at > :start", student_id: @student.id, start: Time.zone.now - 7.days).count @top_tasks = project.top_tasks @overdue_top = @top_tasks.select { |tt| tt[:reason] == :overdue } diff --git a/app/mailers/portfolio_evidence_mailer.rb b/app/mailers/portfolio_evidence_mailer.rb index 61b729eb65..743503c25b 100644 --- a/app/mailers/portfolio_evidence_mailer.rb +++ b/app/mailers/portfolio_evidence_mailer.rb @@ -1,8 +1,8 @@ -class PortfolioEvidenceMailer < ActionMailer::Base +class PortfolioEvidenceMailer < ApplicationMailer def add_general @doubtfire_host = Doubtfire::Application.config.institution[:host] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] - @unsubscribe_url = "#{@doubtfire_host}/#/home?notifications" + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" end def task_pdf_failed(project, tasks) @@ -17,7 +17,7 @@ def task_pdf_failed(project, tasks) email_with_name = %("#{@student.name}" <#{@student.email}>) tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) - subject = "#{project.unit.name}: Task PDFs ready to view" + subject = "#{project.unit.code} #{project.unit.name}: Task submission processing failed" mail(to: email_with_name, from: tutor_email, subject: subject) end @@ -54,6 +54,22 @@ def task_feedback_ready(project, tasks) mail(to: email_with_name, from: tutor_email, subject: subject) end + def overseer_assessment_failed(project, tasks) + return nil if project.nil? || tasks.nil? || tasks.empty? + + add_general + @student = project.student + @project = project + @tasks = tasks.sort_by { |t| t.task_definition.abbreviation } + @tutor = project.main_convenor_user + return nil if @tutor.nil? || @student.nil? + + email_with_name = %("#{@student.name}" <#{@student.email}>) + tutor_email = %("#{@tutor.name}" <#{@tutor.email}>) + subject = "#{project.unit.code} #{project.unit.name}: Automated feedback needs your attention" + mail(to: email_with_name, from: tutor_email, subject: subject) + end + def portfolio_ready(project) return nil if project.nil? diff --git a/app/mailers/tutor_note_mailer.rb b/app/mailers/tutor_note_mailer.rb new file mode 100644 index 0000000000..094644aa79 --- /dev/null +++ b/app/mailers/tutor_note_mailer.rb @@ -0,0 +1,27 @@ +class TutorNoteMailer < ApplicationMailer + def add_general + @doubtfire_host = Doubtfire::Application.config.institution[:host] + @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] + @unsubscribe_url = "#{@doubtfire_host}/edit_profile" + end + + def notify_tutor_note(tutor_note, recipient) + add_general + + @note = tutor_note + @recipient = recipient + + @from = tutor_note.user + + @unit = tutor_note.unit_role.unit + @mentor = @unit.unit_role_for(@from) + + @task = tutor_note.task + + recipient_email_with_name = %("#{recipient.name}" <#{recipient.email}>) + tutor_email = %("#{@from.name}" <#{@from.email}>) + subject = "#{@unit.name}: New tutor note from #{@from.name}" + mail(to: recipient_email_with_name, from: tutor_email, subject: subject) + end + +end diff --git a/app/middleware/sentry_tunnel_middleware.rb b/app/middleware/sentry_tunnel_middleware.rb new file mode 100644 index 0000000000..aac126171f --- /dev/null +++ b/app/middleware/sentry_tunnel_middleware.rb @@ -0,0 +1,84 @@ +require 'rest-client' +require 'uri' + +class SentryTunnelMiddleware + PATH = '/api/client-reports'.freeze + MAX_ENVELOPE_BYTES = 256 * 1024 + + def initialize(app) + @app = app + end + + def call(env) + return @app.call(env) unless env['REQUEST_METHOD'] == 'POST' && env['PATH_INFO'] == PATH + + return payload_too_large_response if declared_body_too_large?(env) + + body = read_envelope(env) + return payload_too_large_response if body.bytesize > MAX_ENVELOPE_BYTES + + forward_envelope(env, body) + [204, {}, []] + end + + private + + def declared_body_too_large?(env) + length = Integer(env['CONTENT_LENGTH'], exception: false) + length && length > MAX_ENVELOPE_BYTES + end + + def read_envelope(env) + input = env.fetch('rack.input') + input.read(MAX_ENVELOPE_BYTES + 1).to_s + ensure + input.rewind if input.respond_to?(:rewind) + end + + def payload_too_large_response + [413, { 'content-length' => '0' }, []] + end + + def forward_envelope(env, body) + envelope_url = sentry_envelope_url + return if envelope_url.blank? + + return if body.blank? + + RestClient::Request.execute( + method: :post, + url: envelope_url, + payload: body, + headers: sentry_headers(env), + timeout: 5, + open_timeout: 2 + ) + rescue RestClient::ExceptionWithResponse => e + Rails.logger.warn "Unable to forward Sentry envelope: #{e.class} #{e.response&.code}" + rescue RestClient::Exception, SocketError, Timeout::Error => e + Rails.logger.warn "Unable to forward Sentry envelope: #{e.class}" + end + + def sentry_headers(env) + headers = { + content_type: env['CONTENT_TYPE'].presence || 'application/x-sentry-envelope' + } + + headers[:content_encoding] = env['HTTP_CONTENT_ENCODING'] if env['HTTP_CONTENT_ENCODING'].present? + headers + end + + def sentry_envelope_url + dsn = ENV.fetch('SENTRY_DSN', nil) + return nil if dsn.blank? + + uri = URI.parse(dsn) + project_id = uri.path.delete_prefix('/') + public_key = uri.user + return nil if project_id.blank? || public_key.blank? + + "#{uri.scheme}://#{uri.host}/api/#{project_id}/envelope/?sentry_key=#{URI.encode_www_form_component(public_key)}" + rescue URI::InvalidURIError + nil + end +end diff --git a/app/models/activity_type.rb b/app/models/activity_type.rb index 87e92c966a..ffdbb6b5a4 100644 --- a/app/models/activity_type.rb +++ b/app/models/activity_type.rb @@ -1,5 +1,5 @@ class ActivityType < ApplicationRecord - has_many :tutorial_streams + has_many :tutorial_streams, dependent: :restrict_with_exception # Callbacks - methods called are private before_destroy :can_destroy? @@ -32,10 +32,6 @@ def self.find_by(*args) end end - def self.find_by_abbr_or_name(data) - ActivityType.find_by(abbreviation: data) || ActivityType.find_by(name: data) - end - private def invalidate_cache diff --git a/app/models/auth_token.rb b/app/models/auth_token.rb index 5ce9a48a72..e494567de3 100644 --- a/app/models/auth_token.rb +++ b/app/models/auth_token.rb @@ -6,16 +6,24 @@ class AuthToken < ApplicationRecord validates :authentication_token, presence: true validate :ensure_token_unique_for_user, on: :create - def self.generate(user, remember, expiry_time = Time.zone.now + 2.hours) + enum :token_type, { + general: 0, + login: 1, + scorm: 2, + refresh_token: 3 + } + + def self.generate(user, remember, expiry_time = Time.zone.now + 2.hours, token_type = :general) # Loop until new unique auth token is found token = loop do token = Devise.friendly_token - break token unless user.token_for_text?(token) + break token unless user.token_for_text?(token, token_type) end # Create a new AuthToken with this value result = AuthToken.new(user_id: user.id) result.authentication_token = token + result.token_type = token_type result.extend_token(remember, expiry_time, false) result.save! result @@ -53,7 +61,7 @@ def extend_token(remember, expiry_time = Time.zone.now + 2.hours, save = true) end def ensure_token_unique_for_user - if user.token_for_text?(authentication_token) + if user.token_for_text?(authentication_token, nil) errors.add(:authentication_token, 'already exists for the selected user') end end diff --git a/app/models/break.rb b/app/models/break.rb index f857fd12ba..ed1c76b1ad 100644 --- a/app/models/break.rb +++ b/app/models/break.rb @@ -1,6 +1,9 @@ class Break < ApplicationRecord belongs_to :teaching_period, optional: false + after_save :refresh_teaching_period_communication_schedule_caches + after_destroy :refresh_teaching_period_communication_schedule_caches + validates :start_date, presence: true validates :number_of_weeks, presence: true validates :teaching_period_id, presence: true @@ -46,4 +49,10 @@ def monday_after_break def end_date start_date + duration end + + private + + def refresh_teaching_period_communication_schedule_caches + teaching_period.refresh_communication_schedule_caches + end end diff --git a/app/models/campus.rb b/app/models/campus.rb index 21c373dda0..08044cb39a 100644 --- a/app/models/campus.rb +++ b/app/models/campus.rb @@ -1,7 +1,7 @@ class Campus < ApplicationRecord # Relationships - has_many :tutorials - has_many :projects + has_many :tutorials, dependent: :restrict_with_exception + has_many :projects, dependent: :restrict_with_exception # Callbacks - methods called are private before_destroy :can_destroy? @@ -12,12 +12,14 @@ class Campus < ApplicationRecord validates :mode, presence: true validates :abbreviation, presence: true, uniqueness: true - validates_inclusion_of :active, :in => [true, false] + validates :active, inclusion: { :in => [true, false] } + + validate :valid_timezone after_destroy :invalidate_cache after_save :invalidate_cache - enum mode: { timetable: 0, automatic: 1, manual: 2 } + enum :mode, { timetable: 0, automatic: 1, manual: 2 } def self.find(id) Rails.cache.fetch("campuses/#{id}", expires_in: 12.hours) do @@ -39,8 +41,8 @@ def self.find_by(*args) end end - def self.find_by_abbr_or_name(data) - Campus.find_by(abbreviation: data) || Campus.find_by(name: data) + def timezone + super || Time.zone.name end private @@ -57,4 +59,13 @@ def can_destroy? errors.add :base, "Cannot delete campus with projects and tutorials" throw :abort end + + def valid_timezone + return if timezone.nil? + + tz = timezone.strip + if tz.empty? || !ActiveSupport::TimeZone[tz] + errors.add(:timezone, "'#{timezone}' is not a valid timezone") + end + end end diff --git a/app/models/comments/assessment_comment.rb b/app/models/comments/assessment_comment.rb index 4944bf5ae9..1b614b41f5 100644 --- a/app/models/comments/assessment_comment.rb +++ b/app/models/comments/assessment_comment.rb @@ -1,13 +1,14 @@ class AssessmentComment < TaskComment - belongs_to :overseer_assessment, optional: false - before_create do self.content_type = :assessment end def serialize(user) json = super(user) - json[:overseer_assessment_id] = self.overseer_assessment_id + json[:overseer_assessment_id] = self.commentable_id + json[:overseer_total_steps] = self.commentable.total_steps + json[:overseer_passed_steps] = self.commentable.passed_steps + json[:overseer_status] = self.commentable.status json end end diff --git a/app/models/comments/extension_comment.rb b/app/models/comments/extension_comment.rb index abd9d1030c..9084fbc135 100644 --- a/app/models/comments/extension_comment.rb +++ b/app/models/comments/extension_comment.rb @@ -29,24 +29,34 @@ def mark_as_read(user, unit = self.unit) def assess_extension(user, granted, automatic = false) if self.assessed? - self.errors[:extension] << 'has already been assessed' + errors.add(:extension, 'could not be applied') + return false + end + + can_apply = self.task.can_apply_for_extension? + should_grant = granted && can_apply + + if should_grant && !self.task.grant_extension(user, extension_weeks) + errors.add(:extension, 'could not be applied') return false end self.assessor = user self.date_extension_assessed = Time.zone.now - self.extension_granted = granted && self.task.can_apply_for_extension? + self.extension_granted = should_grant + + should_notify = true if self.extension_granted - self.task.grant_extension(user, extension_weeks) if automatic self.extension_response = "Time extended to #{self.task.due_date.strftime('%a %b %e')}" else self.extension_response = "Extension granted to #{self.task.due_date.strftime('%a %b %e')}" end - elsif !self.task.can_apply_for_extension? && granted + elsif !can_apply && granted self.extension_response = "Extension cannot be granted as deadline has been reached" - errors[:extension] << 'cannot be granted as deadline has been reached' + errors.add(:extension, 'cannot be granted as deadline has been reached') + should_notify = false else self.extension_response = "Extension rejected" end @@ -54,5 +64,21 @@ def assess_extension(user, granted, automatic = false) # Now make sure to read it by the main tutor - even if assessed by someone else super_mark_as_read(project.tutor_for(task.task_definition)) save! + + if should_notify + begin + NotificationService.notify( + user: project.student, + type: 'extension', + event: 'extension_assessed', + message: extension_response, + link: "/projects/#{project.id}/dashboard/#{task.task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error "Failed to notify student about extension assessment: #{e.message}" + end + end + + true end end diff --git a/app/models/comments/scorm_comment.rb b/app/models/comments/scorm_comment.rb new file mode 100644 index 0000000000..df7bcc9f56 --- /dev/null +++ b/app/models/comments/scorm_comment.rb @@ -0,0 +1,14 @@ +class ScormComment < TaskComment + before_create do + self.content_type = :scorm + end + + def serialize(user) + json = super(user) + json[:test_attempt] = { + id: self.commentable_id, + success_status: self.commentable.success_status + } + json + end +end diff --git a/app/models/comments/scorm_extension_comment.rb b/app/models/comments/scorm_extension_comment.rb new file mode 100644 index 0000000000..74bc9d0c8c --- /dev/null +++ b/app/models/comments/scorm_extension_comment.rb @@ -0,0 +1,45 @@ +class ScormExtensionComment < TaskComment + belongs_to :assessor, class_name: 'User', optional: true + + def serialize(user) + json = super(user) + json[:granted] = extension_granted + json[:assessed] = date_extension_assessed.present? + json[:date_assessed] = date_extension_assessed + json + end + + def assessed? + self.date_extension_assessed.present? + end + + # Make sure we can access super's version of mark_as_read for assess extension + alias super_mark_as_read mark_as_read + + # Allow individual staff and the student to read this... but stop + # the main tutor reading without assessing. As only the main tutor + # propagates reads, this will work as required - other staff cant + # make it read for the main tutor. + def mark_as_read(user, unit = self.unit) + super if assessed? || user == project.student || user != recipient + end + + def assess_scorm_extension(user, granted) + if self.assessed? + self.errors[:scorm_extension] << 'has already been assessed' + return false + end + + self.assessor = user + self.date_extension_assessed = Time.zone.now + self.extension_granted = granted + + if self.extension_granted + self.task.grant_scorm_extension(user) + end + + # Now make sure to read it by the main tutor - even if assessed by someone else + super_mark_as_read(project.tutor_for(task.task_definition)) + save! + end +end diff --git a/app/models/comments/task_checked_in_comment.rb b/app/models/comments/task_checked_in_comment.rb new file mode 100644 index 0000000000..36dfd4b135 --- /dev/null +++ b/app/models/comments/task_checked_in_comment.rb @@ -0,0 +1,16 @@ +class TaskCheckedInComment < TaskComment + before_create do + self.content_type = :checked_in + end + + after_create do + mark_as_read(self.recipient) + end + + def serialize(user) + json = super(user) + json[:recipient_read_time] = nil + json[:date] = self.created_at + json + end +end diff --git a/app/models/comments/task_comment.rb b/app/models/comments/task_comment.rb index 4dd3aa8111..c74883d014 100644 --- a/app/models/comments/task_comment.rb +++ b/app/models/comments/task_comment.rb @@ -20,6 +20,9 @@ class TaskComment < ApplicationRecord # Can optionally be a reply to a comment belongs_to :task_comment, optional: true + # Can be a comment for different types of entities e.g. Test Attempt, Overseer Assessment + belongs_to :commentable, polymorphic: true, optional: true + validates :task, presence: true validates :user, presence: true validates :recipient, presence: true @@ -38,8 +41,8 @@ def valid_reply_to? if reply_to_id.present? originalTaskComment = TaskComment.find(reply_to_id) replyProject = originalTaskComment.project - errors.add(:task_comment, "Not a reply to a valid task comment") unless originalTaskComment.present? - errors.add(:task_comment, "Original comment is not in this task") unless task.all_comments.find(reply_to_id).present? + errors.add(:task_comment, "Not a reply to a valid task comment") if originalTaskComment.blank? + errors.add(:task_comment, "Original comment is not in this task") if task.all_comments.find(reply_to_id).blank? errors.add(:task_comment, "Not authorised to reply to comment") unless authorise?(user, originalTaskComment.project, :get) || (task.group_task? && task.group.role_for(user) != nil) end end diff --git a/app/models/comments/task_discussed_comment.rb b/app/models/comments/task_discussed_comment.rb new file mode 100644 index 0000000000..5c4ee3a71a --- /dev/null +++ b/app/models/comments/task_discussed_comment.rb @@ -0,0 +1,16 @@ +class TaskDiscussedComment < TaskComment + before_create do + self.content_type = :discussed_in_class + end + + after_create do + mark_as_read(self.recipient) + end + + def serialize(user) + json = super(user) + json[:recipient_read_time] = nil + json[:date] = self.created_at + json + end +end diff --git a/app/models/comments/task_feedback_review_request_comment.rb b/app/models/comments/task_feedback_review_request_comment.rb new file mode 100644 index 0000000000..3d3f6182a3 --- /dev/null +++ b/app/models/comments/task_feedback_review_request_comment.rb @@ -0,0 +1,16 @@ +class TaskFeedbackReviewRequestComment < TaskComment + before_create do + self.content_type = :feedback_review_request + end + + after_create do + mark_as_read(self.recipient) + end + + def serialize(user) + json = super(user) + json[:recipient_read_time] = nil + json[:date] = self.created_at + json + end +end diff --git a/app/models/communication/campus_condition.rb b/app/models/communication/campus_condition.rb new file mode 100644 index 0000000000..07e2bc0096 --- /dev/null +++ b/app/models/communication/campus_condition.rb @@ -0,0 +1,4 @@ +class CampusCondition < CommunicationCondition + validates :campus, presence: true + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/change_target_grade_action.rb b/app/models/communication/change_target_grade_action.rb new file mode 100644 index 0000000000..efcfc2cb8e --- /dev/null +++ b/app/models/communication/change_target_grade_action.rb @@ -0,0 +1,12 @@ +class ChangeTargetGradeAction < CommunicationAction + validates :target_grade, presence: true + validate :target_grade_enabled_for_unit + + private + + def target_grade_enabled_for_unit + return if target_grade.nil? || communication_rule&.unit&.grade_value?(target_grade) + + errors.add(:target_grade, 'is not enabled for this unit') + end +end diff --git a/app/models/communication/communication_action.rb b/app/models/communication/communication_action.rb new file mode 100644 index 0000000000..af287b6fa2 --- /dev/null +++ b/app/models/communication/communication_action.rb @@ -0,0 +1,13 @@ +class CommunicationAction < ApplicationRecord + VALID_TYPES = %w[ + EmailStudentAction + EmailStaffAction + ChangeTargetGradeAction + TaskCommentAction + ].freeze + + belongs_to :communication_rule, class_name: 'CommunicationRule' + belongs_to :task_definition, optional: true + + validates :type, presence: true, inclusion: { in: VALID_TYPES } +end diff --git a/app/models/communication/communication_condition.rb b/app/models/communication/communication_condition.rb new file mode 100644 index 0000000000..6623b0f4c5 --- /dev/null +++ b/app/models/communication/communication_condition.rb @@ -0,0 +1,94 @@ +class CommunicationCondition < ApplicationRecord + VALID_TYPES = %w[ + TargetGradeCondition + TaskDefinitionStatusCondition + TaskStatusCountCondition + LoginStatusCondition + SpecConCondition + TutorialEnrolmentCondition + TutorialStreamEnrolmentCondition + CampusCondition + ].freeze + + GRADE_OPERATORS = %w[ + greater_than + greater_than_or_equal_to + less_than + less_than_or_equal_to + equal_to + not_equal_to + ].freeze + + EQUALITY_OPERATORS = %w[equal_to not_equal_to].freeze + DATE_OPERATORS = %w[before after].freeze + ENROLMENT_OPERATORS = %w[enrolled_in not_enrolled_in].freeze + TASK_STATUS_KEYS = %w[ + not_started + complete + need_help + working_on_it + fix_and_resubmit + feedback_exceeded + redo + discuss + ready_for_feedback + demonstrate + fail + time_exceeded + assess_in_portfolio + attention_required + rediscuss + ].freeze + + belongs_to :communication, + class_name: 'CommunicationRule', + inverse_of: :communication_conditions + + belongs_to :task_definition, optional: true + belongs_to :tutorial, optional: true + belongs_to :tutorial_stream, optional: true + belongs_to :campus, optional: true + + attribute :task_statuses, :json, default: -> { [] } + + validates :type, presence: true, inclusion: { in: VALID_TYPES } + validates :operator, presence: true + before_validation :normalize_task_statuses + + def task_statuses_must_be_present + unless task_statuses.is_a?(Array) && task_statuses.any?(&:present?) + errors.add(:task_statuses, 'must include at least one task status') + return + end + + invalid_statuses = task_statuses.reject { |status| TASK_STATUS_KEYS.include?(status) } + return if invalid_statuses.empty? + + errors.add(:task_statuses, "contains invalid task statuses: #{invalid_statuses.join(', ')}") + end + + private + + def normalize_task_statuses + parsed_statuses = + case task_statuses + when nil + nil + when String + begin + JSON.parse(task_statuses) + rescue JSON::ParserError + [task_statuses] + end + when Array + task_statuses + else + Array(task_statuses) + end + + self.task_statuses = + parsed_statuses&.filter_map do |status| + status.is_a?(String) ? status.strip.presence : status.presence + end + end +end diff --git a/app/models/communication/communication_rule.rb b/app/models/communication/communication_rule.rb new file mode 100644 index 0000000000..205192560e --- /dev/null +++ b/app/models/communication/communication_rule.rb @@ -0,0 +1,134 @@ +class CommunicationRule < ApplicationRecord + LOGICAL_OPERATORS = %w[and or].freeze + + belongs_to :communication_set, class_name: 'CommunicationSet' + delegate :unit, to: :communication_set + + has_many :communication_conditions, + class_name: 'CommunicationCondition', + foreign_key: :communication_id, + inverse_of: :communication, + dependent: :destroy + has_many :communication_actions, class_name: 'CommunicationAction', dependent: :destroy + + validates :name, presence: true + validates :operator, presence: true, inclusion: { in: LOGICAL_OPERATORS } + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + def matching_projects(projects = nil) + projects ||= communication_set.eligible_projects + return projects if communication_conditions.empty? + + projects.select do |project| + matches = communication_conditions.map { |condition| condition_match?(project, condition) } + + operator == 'or' ? matches.any? : matches.all? + end + end + + private + + def condition_match?(project, condition) + case condition.type + when 'TargetGradeCondition' + target_grade_condition_match?(project, condition) + when 'TaskDefinitionStatusCondition' + task_definition_status_condition_match?(project, condition) + when 'TaskStatusCountCondition' + task_status_count_condition_match?(project, condition) + when 'LoginStatusCondition' + login_status_condition_match?(project, condition) + when 'SpecConCondition' + spec_con_condition_match?(project, condition) + when 'TutorialEnrolmentCondition' + tutorial_enrolment_condition_match?(project, condition) + when 'TutorialStreamEnrolmentCondition' + tutorial_stream_enrolment_condition_match?(project, condition) + when 'CampusCondition' + campus_condition_match?(project, condition) + else + false + end + end + + def target_grade_condition_match?(project, condition) + project_target_grade = project.target_grade + return false if project_target_grade.nil? + + compare_value(project_target_grade, condition.target_grade, condition.operator) + end + + def task_definition_status_condition_match?(project, condition) + task = project.tasks.find { |t| t.task_definition_id == condition.task_definition_id } + status = task&.task_status&.status_key&.to_s || 'not_started' + statuses = condition.task_statuses || [] + + case condition.operator + when 'equal_to' then statuses.include?(status) + when 'not_equal_to' then !statuses.include?(status) + else false + end + end + + def task_status_count_condition_match?(project, condition) + statuses = condition.task_statuses || [] + relevant_task_definitions = project.unit.task_definitions.select do |task_definition| + task_definition.target_grade == condition.task_target_grade + end + + count = relevant_task_definitions.count do |task_definition| + task = project.tasks.find { |project_task| project_task.task_definition_id == task_definition.id } + task_status = task&.task_status&.status_key&.to_s || 'not_started' + + statuses.include?(task_status) + end + + compare_value(count, condition.task_status_count, condition.operator) + end + + def login_status_condition_match?(project, condition) + last_sign_in_at = project.user&.last_sign_in_at + + case condition.operator + when 'before' then last_sign_in_at.present? && last_sign_in_at < condition.last_sign_in_at + when 'after' then last_sign_in_at.present? && last_sign_in_at > condition.last_sign_in_at + else false + end + end + + def spec_con_condition_match?(project, condition) + compare_value(project.spec_con_days, condition.spec_con_days, condition.operator) + end + + def tutorial_enrolment_condition_match?(project, condition) + enrolled = project.tutorial_enrolments.any? { |enrolment| enrolment.tutorial_id == condition.tutorial_id } + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def tutorial_stream_enrolment_condition_match?(project, condition) + enrolled = project.tutorial_enrolments.any? do |enrolment| + enrolment.tutorial&.tutorial_stream_id == condition.tutorial_stream_id + end + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def campus_condition_match?(project, condition) + enrolled = project.campus_id == condition.campus_id + + condition.operator == 'not_enrolled_in' ? !enrolled : enrolled + end + + def compare_value(left, right, operator) + case operator + when 'greater_than' then left > right + when 'greater_than_or_equal_to' then left >= right + when 'less_than' then left < right + when 'less_than_or_equal_to' then left <= right + when 'equal_to' then left == right + when 'not_equal_to' then left != right + else false + end + end +end diff --git a/app/models/communication/communication_set.rb b/app/models/communication/communication_set.rb new file mode 100644 index 0000000000..9d9a818996 --- /dev/null +++ b/app/models/communication/communication_set.rb @@ -0,0 +1,113 @@ +class CommunicationSet < ApplicationRecord + belongs_to :unit + + has_many :communication_set_schedules, + class_name: 'CommunicationSetSchedule', + inverse_of: :communication_set, + dependent: :destroy + + has_many :communication_rules, + -> { order(:position) }, + class_name: 'CommunicationRule', + inverse_of: :communication_set, + dependent: :destroy + + validates :name, presence: true + + def eligible_projects + unit.projects + .where(enrolled: true) + .includes(:user, :campus, { tasks: [:task_status, :task_definition] }, { tutorial_enrolments: :tutorial }) + .to_a + end + + def preview_projects_for_rule(target_rule) + preview_allocations_for_rule(target_rule) + .find { |allocation| allocation[:rule].id == target_rule.id } + &.fetch(:projects, []) || [] + end + + def preview_allocations_by_rule + communication_rules.each_with_object({}) do |rule, allocations_by_rule| + allocations_by_rule[rule.id] = preview_allocations_for_rule(rule) + end + end + + def preview_allocations_for_rule(target_rule) + remaining_projects = eligible_projects + allocations = [] + + communication_rules.each do |rule| + matched_projects = rule.matching_projects(remaining_projects) + allocations << { rule: rule, projects: matched_projects } + return allocations if rule.id == target_rule.id + + remaining_projects -= matched_projects + end + + allocations + end + + def copy_to(other_unit) + new_set = dup + new_set.unit = other_unit + new_set.save! + + communication_set_schedules.each do |schedule| + new_schedule = schedule.dup + new_schedule.communication_set = new_set + new_schedule.ice_cube_schedule = nil + new_schedule.next_run_at = nil + new_schedule.last_run_at = nil + new_schedule.last_enqueued_at = nil + new_schedule.save! + end + + communication_rules.each do |rule| + new_rule = rule.dup + new_rule.communication_set = new_set + new_rule.save! + + rule.communication_conditions.each do |condition| + new_condition = condition.dup + new_condition.communication = new_rule + new_condition.task_definition = matching_task_definition(other_unit, condition) + new_condition.tutorial_stream = matching_tutorial_stream(other_unit, condition) + new_condition.tutorial = matching_tutorial(other_unit, condition) + new_condition.save! + end + + rule.communication_actions.each do |action| + new_action = action.dup + new_action.communication_rule = new_rule + new_action.task_definition = matching_task_definition(other_unit, action) + new_action.save! + end + end + + new_set + end + + private + + def matching_task_definition(unit, condition) + return nil if condition.task_definition.blank? + + unit.task_definitions.find_by(abbreviation: condition.task_definition.abbreviation) + end + + def matching_tutorial_stream(unit, condition) + return nil if condition.tutorial_stream.blank? + + unit.tutorial_streams.find_by(abbreviation: condition.tutorial_stream.abbreviation) + end + + def matching_tutorial(unit, condition) + return nil if condition.tutorial.blank? + + unit.tutorials.find_by( + abbreviation: condition.tutorial.abbreviation, + campus_id: condition.tutorial.campus_id + ) + end +end diff --git a/app/models/communication/communication_set_schedule.rb b/app/models/communication/communication_set_schedule.rb new file mode 100644 index 0000000000..26f0a41628 --- /dev/null +++ b/app/models/communication/communication_set_schedule.rb @@ -0,0 +1,175 @@ +class CommunicationSetSchedule < ApplicationRecord + RECURRENCES = %w[none daily weekly monthly].freeze + VALID_DAYS = Date::DAYNAMES.freeze + DAY_ABBREVIATIONS = Date::ABBR_DAYNAMES.freeze + + belongs_to :communication_set, class_name: 'CommunicationSet', inverse_of: :communication_set_schedules + delegate :unit, to: :communication_set + + validates :name, presence: true + validates :anchor_week, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :anchor_day, presence: true + validates :hour, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 24 } + validates :minute, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 60 } + validates :interval, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :recurrence, presence: true, inclusion: { in: RECURRENCES } + validates :repeat_count, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :active, inclusion: { in: [true, false] } + validate :anchor_day_supported + validate :timezone_supported + validate :anchor_date_resolves + + before_validation :normalize_anchor_day + before_validation :default_timezone + before_validation :sync_schedule_cache + + scope :active, -> { where(active: true) } + scope :due, ->(time = Time.zone.now) { where('next_run_at IS NOT NULL AND next_run_at <= ?', time) } + scope :with_active_unit, -> { joins(communication_set: :unit).where(units: { active: true }) } + + def resolved_anchor_date + return nil if unit.blank? || anchor_week.blank? || anchor_day.blank? + + unit.date_for_week_and_day(anchor_week, anchor_day_abbreviation) + end + + def resolved_start_at + date = resolved_anchor_date + return nil if date.nil? + + timezone_object.local(date.year, date.month, date.day, hour, minute) + end + + def build_ice_cube_schedule + start_at = resolved_start_at + return nil if start_at.nil? + + schedule = IceCube::Schedule.new(start_at) + rule = recurrence_rule + schedule.add_recurrence_rule(rule) if rule + schedule + end + + def next_occurrence_after(time = Time.zone.now) + schedule = build_ice_cube_schedule + return nil if schedule.nil? + + time_for_schedule = time.in_time_zone(timezone_object) + occurrence = if recurrence == 'none' + occurrence = schedule.start_time + occurrence >= time_for_schedule ? occurrence : nil + else + schedule.next_occurrence(time_for_schedule) + end + + return nil if occurrence.blank? + + occurrence_within_unit_dates?(occurrence) ? occurrence : nil + end + + def due?(time = Time.zone.now) + active_for_scheduling? && next_run_at.present? && next_run_at <= time + end + + def refresh_next_run_at!(from_time = Time.zone.now) + update!(next_run_at: active_for_scheduling? ? next_occurrence_after(from_time) : nil) + end + + def anchor_day_abbreviation + return nil if anchor_day.blank? + + day = anchor_day.to_s.strip + return day if DAY_ABBREVIATIONS.include?(day) + + index = VALID_DAYS.index(day.titlecase) + index.nil? ? nil : DAY_ABBREVIATIONS[index] + end + + private + + def recurrence_rule + case recurrence + when 'daily' + IceCube::Rule.daily(interval) + when 'weekly' + IceCube::Rule.weekly(interval) + when 'monthly' + IceCube::Rule.monthly(interval) + end&.tap do |rule| + rule.count(repeat_count) if repeat_count.present? + rule.until(until_at.in_time_zone(timezone_object)) if until_at.present? + end + end + + def timezone_object + ActiveSupport::TimeZone[timezone.presence || Time.zone.name] || Time.zone + end + + def normalize_anchor_day + return if anchor_day.blank? + + full_day = + if VALID_DAYS.include?(anchor_day.to_s.titlecase) + anchor_day.to_s.titlecase + else + day_index = DAY_ABBREVIATIONS.index(anchor_day.to_s.titlecase) + day_index.nil? ? anchor_day : VALID_DAYS[day_index] + end + + self.anchor_day = full_day + end + + def default_timezone + self.timezone = timezone.presence || Time.zone.name + end + + def sync_schedule_cache + schedule = build_ice_cube_schedule + self.ice_cube_schedule = schedule.present? ? JSON.generate(schedule.to_hash) : nil + self.next_run_at = next_occurrence_after(Time.zone.now) if active_for_scheduling? && should_refresh_next_run_at? + self.next_run_at = nil unless active_for_scheduling? + end + + def active_for_scheduling? + active? && unit&.active? + end + + def occurrence_within_unit_dates?(occurrence) + return true if unit&.end_date.blank? + + occurrence.to_date <= unit.end_date + end + + def should_refresh_next_run_at? + will_save_change_to_anchor_week? || + will_save_change_to_anchor_day? || + will_save_change_to_hour? || + will_save_change_to_minute? || + will_save_change_to_timezone? || + will_save_change_to_recurrence? || + will_save_change_to_interval? || + will_save_change_to_repeat_count? || + will_save_change_to_until_at? || + will_save_change_to_active? || + next_run_at.blank? + end + + def anchor_day_supported + return if anchor_day.blank? || VALID_DAYS.include?(anchor_day.to_s.titlecase) + + errors.add(:anchor_day, 'must be a valid day name') + end + + def timezone_supported + return if timezone.blank? || ActiveSupport::TimeZone[timezone].present? + + errors.add(:timezone, 'must be a valid timezone') + end + + def anchor_date_resolves + return if unit.blank? || anchor_week.blank? || anchor_day.blank? + return if resolved_anchor_date.present? + + errors.add(:base, 'schedule anchor could not be resolved for this unit') + end +end diff --git a/app/models/communication/email_staff_action.rb b/app/models/communication/email_staff_action.rb new file mode 100644 index 0000000000..fc1c2d36f3 --- /dev/null +++ b/app/models/communication/email_staff_action.rb @@ -0,0 +1,15 @@ +class EmailStaffAction < CommunicationAction + validates :subject, presence: true + validates :body, presence: true + validates :email_tutors, inclusion: { in: [true, false] } + validates :email_convenors, inclusion: { in: [true, false] } + validate :staff_recipient? + + private + + def staff_recipient? + return if email_tutors || email_convenors + + errors.add(:base, 'must email tutors or convenors') + end +end diff --git a/app/models/communication/email_student_action.rb b/app/models/communication/email_student_action.rb new file mode 100644 index 0000000000..fdd0ecb17d --- /dev/null +++ b/app/models/communication/email_student_action.rb @@ -0,0 +1,4 @@ +class EmailStudentAction < CommunicationAction + validates :subject, presence: true + validates :body, presence: true +end diff --git a/app/models/communication/login_status_condition.rb b/app/models/communication/login_status_condition.rb new file mode 100644 index 0000000000..c972c76fdc --- /dev/null +++ b/app/models/communication/login_status_condition.rb @@ -0,0 +1,4 @@ +class LoginStatusCondition < CommunicationCondition + validates :last_sign_in_at, presence: true + validates :operator, inclusion: { in: DATE_OPERATORS } +end diff --git a/app/models/communication/spec_con_condition.rb b/app/models/communication/spec_con_condition.rb new file mode 100644 index 0000000000..cebeb8565f --- /dev/null +++ b/app/models/communication/spec_con_condition.rb @@ -0,0 +1,4 @@ +class SpecConCondition < CommunicationCondition + validates :spec_con_days, presence: true, numericality: { only_integer: true } + validates :operator, inclusion: { in: GRADE_OPERATORS } +end diff --git a/app/models/communication/target_grade_condition.rb b/app/models/communication/target_grade_condition.rb new file mode 100644 index 0000000000..77f195a473 --- /dev/null +++ b/app/models/communication/target_grade_condition.rb @@ -0,0 +1,13 @@ +class TargetGradeCondition < CommunicationCondition + validates :target_grade, presence: true + validates :operator, inclusion: { in: GRADE_OPERATORS } + validate :target_grade_enabled_for_unit + + private + + def target_grade_enabled_for_unit + return if target_grade.nil? || communication&.unit&.grade_value?(target_grade) + + errors.add(:target_grade, 'is not enabled for this unit') + end +end diff --git a/app/models/communication/task_comment_action.rb b/app/models/communication/task_comment_action.rb new file mode 100644 index 0000000000..d42165b51e --- /dev/null +++ b/app/models/communication/task_comment_action.rb @@ -0,0 +1,4 @@ +class TaskCommentAction < CommunicationAction + validates :task_definition, presence: true + validates :body, presence: true +end diff --git a/app/models/communication/task_definition_status_condition.rb b/app/models/communication/task_definition_status_condition.rb new file mode 100644 index 0000000000..83ab40bdec --- /dev/null +++ b/app/models/communication/task_definition_status_condition.rb @@ -0,0 +1,5 @@ +class TaskDefinitionStatusCondition < CommunicationCondition + validates :task_definition, presence: true + validates :operator, inclusion: { in: EQUALITY_OPERATORS } + validate :task_statuses_must_be_present +end diff --git a/app/models/communication/task_status_count_condition.rb b/app/models/communication/task_status_count_condition.rb new file mode 100644 index 0000000000..42659a930e --- /dev/null +++ b/app/models/communication/task_status_count_condition.rb @@ -0,0 +1,15 @@ +class TaskStatusCountCondition < CommunicationCondition + validates :task_status_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :task_target_grade, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :operator, inclusion: { in: GRADE_OPERATORS } + validate :task_statuses_must_be_present + validate :task_target_grade_enabled_for_unit + + private + + def task_target_grade_enabled_for_unit + return if task_target_grade.nil? || communication&.unit&.grade_value?(task_target_grade) + + errors.add(:task_target_grade, 'is not enabled for this unit') + end +end diff --git a/app/models/communication/tutorial_enrolment_condition.rb b/app/models/communication/tutorial_enrolment_condition.rb new file mode 100644 index 0000000000..024ed79ce1 --- /dev/null +++ b/app/models/communication/tutorial_enrolment_condition.rb @@ -0,0 +1,4 @@ +class TutorialEnrolmentCondition < CommunicationCondition + validates :tutorial, presence: true + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/communication/tutorial_stream_enrolment_condition.rb b/app/models/communication/tutorial_stream_enrolment_condition.rb new file mode 100644 index 0000000000..e2153037fa --- /dev/null +++ b/app/models/communication/tutorial_stream_enrolment_condition.rb @@ -0,0 +1,4 @@ +class TutorialStreamEnrolmentCondition < CommunicationCondition + validates :tutorial_stream, presence: true + validates :operator, inclusion: { in: ENROLMENT_OPERATORS } +end diff --git a/app/models/d2l/d2l_assessment_mapping.rb b/app/models/d2l/d2l_assessment_mapping.rb new file mode 100644 index 0000000000..fffe6d6b15 --- /dev/null +++ b/app/models/d2l/d2l_assessment_mapping.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class D2lAssessmentMapping < ApplicationRecord + belongs_to :unit + + # Ensure only one D2L mapping per unit + validates :unit_id, uniqueness: true + + # Ensure org_unit_id is present + validates :org_unit_id, presence: true + +end diff --git a/app/models/d2l/user_oauth_state.rb b/app/models/d2l/user_oauth_state.rb new file mode 100644 index 0000000000..e4b2c3ce3d --- /dev/null +++ b/app/models/d2l/user_oauth_state.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class UserOauthState < ApplicationRecord + belongs_to :user + + # Ensure unique states + validates :state, uniqueness: true + + def self.destroy_old_states + UserOauthState.where('created_at < ?', Time.zone.now - 15.minutes).delete_all + end +end diff --git a/app/models/d2l/user_oauth_token.rb b/app/models/d2l/user_oauth_token.rb new file mode 100644 index 0000000000..5754e41a4d --- /dev/null +++ b/app/models/d2l/user_oauth_token.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# This is an oauth access token to connect to an external service +class UserOauthToken < ApplicationRecord + belongs_to :user + + encrypts :token + + validates :token, presence: true + + # Ensure a known provider - what the token gives access to + enum :provider, { + d2l: 0 + } + + # Get the provider as a symbol + def provider_sym + provider.to_sym + end + + # Get access token - used to make http requests + def access_token + case provider_sym + when :d2l + client = D2lIntegration.oauth_client + else + raise "Unknown provider" + end + + OAuth2::AccessToken.new(client, token) + end + + def self.destroy_old_tokens + UserOauthToken.where('expires_at < ?', Time.zone.now).delete_all + end +end diff --git a/app/models/discussion_prompt.rb b/app/models/discussion_prompt.rb new file mode 100644 index 0000000000..7bbd14c80c --- /dev/null +++ b/app/models/discussion_prompt.rb @@ -0,0 +1,7 @@ +class DiscussionPrompt < ApplicationRecord + + belongs_to :task_definition, optional: false + belongs_to :project, optional: true + belongs_to :created_by, class_name: 'User', optional: true + +end diff --git a/app/models/engagement.rb b/app/models/engagement.rb new file mode 100644 index 0000000000..5d7cc78fe1 --- /dev/null +++ b/app/models/engagement.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'uri' + +class Engagement < ApplicationRecord + include FileHelper + include MimeCheckHelpers + + belongs_to :project, optional: false, inverse_of: :engagements + belongs_to :user, optional: false, inverse_of: :engagements + + has_many :engagement_comments, + -> { order(:created_at) }, + dependent: :destroy, + inverse_of: :engagement + + validates :engagement_type, presence: true, length: { maximum: 255 } + validates :note, presence: true, length: { maximum: 4095 } + validates :occurred_at, presence: true + validates :evidence_url, length: { maximum: 2048, allow_blank: true } + validates :content_type, inclusion: { in: %w[image pdf], allow_nil: true } + validate :valid_evidence_url + validate :single_evidence_source + validate :consistent_attachment_metadata + + before_validation :normalise_text + before_destroy :delete_attachment + + def attachment? + content_type.present? && attachment_extension.present? + end + + def attachment_path + FileHelper.engagement_attachment_path(self, attachment_extension) + end + + def attachment_file_name + "engagement-#{id}#{attachment_extension}" + end + + def attachment_mime_type + mime_type(attachment_path) + end + + def replace_attachment(file_upload, attachment_type) + delete_attachment + self.evidence_url = nil + self.content_type = attachment_type + + if attachment_type == 'image' + self.attachment_extension = + if mime_type(file_upload['tempfile'].path).starts_with?('image/gif') + '.gif' + else + '.jpg' + end + save! + image_saved = FileHelper.compress_image_to_dest(file_upload['tempfile'].path, attachment_path) + raise 'Failed to save engagement image attachment' unless image_saved && File.exist?(attachment_path) + else + self.attachment_extension = '.pdf' + save! + FileHelper.compress_pdf(file_upload['tempfile'].path) + FileUtils.mv(file_upload['tempfile'].path, attachment_path) + raise 'Failed to save engagement PDF attachment' unless File.exist?(attachment_path) + end + + file_upload['tempfile'].unlink if File.exist?(file_upload['tempfile'].path) + true + end + + def remove_attachment + delete_attachment + self.content_type = nil + self.attachment_extension = nil + end + + private + + def normalise_text + self.engagement_type = engagement_type&.strip + self.note = note&.strip + self.evidence_url = evidence_url&.strip.presence + end + + def valid_evidence_url + return if evidence_url.blank? + + uri = URI.parse(evidence_url) + return if uri.is_a?(URI::HTTP) && uri.host.present? + + errors.add(:evidence_url, 'must be a valid HTTP or HTTPS URL') + rescue URI::InvalidURIError + errors.add(:evidence_url, 'must be a valid HTTP or HTTPS URL') + end + + def single_evidence_source + return unless evidence_url.present? && (content_type.present? || attachment_extension.present?) + + errors.add(:base, 'An engagement can have either an evidence URL or an attachment, not both') + end + + def consistent_attachment_metadata + return if content_type.present? == attachment_extension.present? + + errors.add(:base, 'Attachment content type and extension must both be present') + end + + def delete_attachment + FileUtils.rm_f(attachment_path) if attachment_extension.present? + end +end diff --git a/app/models/engagement_comment.rb b/app/models/engagement_comment.rb new file mode 100644 index 0000000000..d02b4fca89 --- /dev/null +++ b/app/models/engagement_comment.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class EngagementComment < ApplicationRecord + belongs_to :engagement, optional: false, inverse_of: :engagement_comments + belongs_to :user, optional: false, inverse_of: :engagement_comments + belongs_to :reply_to, class_name: 'EngagementComment', optional: true + has_many :replies, + class_name: 'EngagementComment', + foreign_key: :reply_to_id, + dependent: :nullify, + inverse_of: :reply_to + + validates :comment, presence: true, length: { maximum: 4095 } + validate :reply_belongs_to_engagement + + before_validation do + self.comment = comment&.strip + end + + private + + def reply_belongs_to_engagement + return if reply_to.nil? || reply_to.engagement_id == engagement_id + + errors.add(:reply_to, 'must belong to the same engagement') + end +end diff --git a/app/models/feedback/chip_usage.rb b/app/models/feedback/chip_usage.rb new file mode 100644 index 0000000000..53a3602d8b --- /dev/null +++ b/app/models/feedback/chip_usage.rb @@ -0,0 +1,19 @@ +# == Schema Information +# +# Table name: chip_usages +# +# id :bigint not null, primary key +# feedback_chip_id :bigint not null +# tutor_id :bigint not null +# usage_count :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# +module Feedback + class ChipUsage < ApplicationRecord + belongs_to :feedback_chip, class_name: 'FeedbackChip' + belongs_to :tutor, class_name: 'User' + + validates :usage_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + end +end diff --git a/app/models/feedback/feedback_chip.rb b/app/models/feedback/feedback_chip.rb new file mode 100644 index 0000000000..deabbeccff --- /dev/null +++ b/app/models/feedback/feedback_chip.rb @@ -0,0 +1,348 @@ +# == Schema Information +# +# Table name: feedback_chips +# +# id :bigint not null, primary key +# type :string(255) +# chip_text :text(65535) +# description :text(65535) +# comment_text :text(65535) +# summary_text :text(65535) +# learning_outcome_id :bigint not null +# parent_chip_id :bigint +# created_at :datetime not null +# updated_at :datetime not null +# task_status :string(255) +# +module Feedback + class FeedbackChip < ApplicationRecord + self.inheritance_column = :type + + validates :chip_text, presence: true, length: { maximum: 20 } + validates :description, presence: true, length: { maximum: 116 } + + belongs_to :parent_chip, class_name: 'FeedbackChip', optional: true + belongs_to :learning_outcome, class_name: 'LearningOutcome', optional: true + + has_many :child_chips, class_name: 'FeedbackChip', foreign_key: 'parent_chip_id', dependent: :nullify, inverse_of: :parent_chip + has_many :chip_usages, class_name: 'ChipUsage', dependent: :destroy + + validate :parent_chip_cannot_create_loop, if: :parent_chip_id_changed? + validate :parent_is_group_chip_in_same_context, if: :parent_chip_id_changed? + + def self.permissions + convenor_role_permissions = [ + :update_chip, + :delete_feedback_chips + ] + + admin_role_permissions = [ + :update_chip, + :delete_feedback_chips + ] + + nil_role_permissions = [] + + { + convenor: convenor_role_permissions, + admin: admin_role_permissions, + tutor: nil_role_permissions, + student: nil_role_permissions, + auditor: nil_role_permissions, + nil: nil_role_permissions + } + end + + delegate :role_for, to: :learning_outcome + + def track_usage_by(tutor) + analytics = chip_usage_analytics.find_or_initialize_by(tutor: tutor) + analytics.usage_count += 1 + analytics.save + end + + def self.global_chips + Feedback::FeedbackChip.joins(:learning_outcome).where(learning_outcome: { context_type: nil, context_id: nil }) + end + + def children + FeedbackChip.where(parent_chip_id: self.id, learning_outcome_id: self.learning_outcome_id) + end + + def feedback_type + if is_a?(Feedback::FeedbackGroupChip) + 'group' + elsif is_a?(Feedback::FeedbackTemplateChip) + 'template' + else + 'something went wrong' + end + end + + def parent_is_group_chip_in_same_context + if parent_chip + if parent_chip.type != 'Feedback::FeedbackGroupChip' + errors.add(:parent_chip, 'must be a group chip') + end + if parent_chip.learning_outcome_id != learning_outcome_id + errors.add(:parent_chip, 'must be in the same learning outcome') + end + end + end + + def parent_chip_cannot_create_loop + if parent_chip_id.present? && descendant_of?(parent_chip_id) + errors.add(:parent_chip_id, 'cannot create a loop') + end + end + + def descendant_of?(parent_chip_id) + current_chip_id = parent_chip_id + while current_chip_id + return true if current_chip_id == self.id + current_chip_id = FeedbackChip.where(id: current_chip_id).pick(:parent_chip_id) + end + end + + TYPE_MAPPING = { + 'template' => 'Feedback::FeedbackTemplateChip', + 'group' => 'Feedback::FeedbackGroupChip' + }.freeze + + def self.to_csv_type(db_type) + TYPE_MAPPING.key(db_type) || db_type + end + + def self.from_csv_type(csv_type) + TYPE_MAPPING[csv_type] || csv_type + end + + def self.csv_header + %w[unit_code task_abbreviation learning_outcome_abbreviation type group_id parent_group_id chip_text description task_status summary_text comment_text] + end + + def add_csv_row(row) + csv_type = self.class.to_csv_type(type) + + learning_outcome = LearningOutcome.find(learning_outcome_id) + + context_type = learning_outcome.context_type + context_id = learning_outcome.context_id + + unit_code = nil + if context_type == 'Unit' + unit_code = Unit.find(context_id).code + elsif context_type == 'TaskDefinition' + unit_code = TaskDefinition.find(context_id).unit.code + end + + learning_outcome_abbreviation = learning_outcome.abbreviation + task_abbreviation = learning_outcome.context_type == 'TaskDefinition' ? TaskDefinition.find(learning_outcome.context_id).abbreviation : nil + + group_id = nil + parent_group_id = nil + summary_text = self.summary_text + if csv_type == 'group' + group_id = summary_text + summary_text = nil + end + + if parent_chip_id.present? + parent_group_id = FeedbackChip.find(parent_chip_id).summary_text + if parent_group_id.nil? + parent_group_id = FeedbackChip.find(parent_chip_id).chip_text + end + end + + row << [unit_code, task_abbreviation, learning_outcome_abbreviation, csv_type, group_id, parent_group_id, chip_text, description, task_status, summary_text, comment_text] + end + + def self.import_feedback_chips_from_csv(file, context_type, context) + result = { + success: [], + errors: [], + ignored: [] + } + + data = FileHelper.read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr&.strip }], + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| + # Make sure we're not looking at the header or an empty line + next if row[0] =~ /unit_code/ + + begin + Feedback::FeedbackChip.create_from_csv(row, context_type, context, result) + rescue StandardError => e + result[:errors] << { row: row, message: e.message.to_s } + end + end + + result + end + + # Get the context for the CSV row + # Returns: + # - { + # success: true if the context is valid + # message: error message if success is false + # unit:, + # task_definition: + # learning_outcome: + # } + def self.context_for_csv(row, context_type, context) + # Find unit - which contains task definition if present + unit_code = row['unit_code'] + if unit_code.present? + unit = Unit.find_by(code: unit_code) + if unit.nil? + return { success: false, message: "Unit #{unit_code} not found" } + elsif (context_type == 'Unit' && context.id != unit.id) || (context_type == 'TaskDefinition' && context.unit_id != unit.id) + return { success: false, message: "Is not for unit #{context.code}" } + end + elsif %w[Unit TaskDefinition].include? context_type + # the unit code is not present, but the context is a unit or task definition + return { success: false, message: "Is not for unit #{context.code}" } + end + + task_abbreviation = row['task_abbreviation'] + if task_abbreviation.present? + if unit.nil? && task_abbreviation.present? + return { success: false, message: 'Task abbreviation is missing unit code' } + end + task_definition = unit.task_definitions.where(abbreviation: task_abbreviation).select(:id).first + if task_definition.nil? + return { success: false, message: "Task #{task_abbreviation} not found" } + end + if context_type == 'TaskDefinition' && context.id != task_definition.id + # Supposed to be in the task definition, but it's in another td + return { success: false, message: "Is not for task #{context.abbreviation}" } + end + elsif context_type == 'TaskDefinition' + # the task abbreviation is not present, but the context is a task definition + return { success: false, message: "Is not for task #{context.abbreviation}" } + end + + learning_outcome_abbreviation = row['learning_outcome_abbreviation'] + if learning_outcome_abbreviation.nil? + return { success: false, message: 'Missing learning outcome abbreviation' } + end + + search_context = if context_type == 'LearningOutcome' + context.context + else + context + end + + learning_outcome = LearningOutcome.find_by(abbreviation: learning_outcome_abbreviation, context: search_context) + if learning_outcome.nil? && task_definition.present? && context_type == 'Unit' + learning_outcome = LearningOutcome.find_by(abbreviation: learning_outcome_abbreviation, context: task_definition) + end + + if learning_outcome.nil? + return { success: false, message: "Learning outcome #{learning_outcome_abbreviation} not found" } + end + if context_type == 'LearningOutcome' && context.id != learning_outcome.id + return { success: false, message: "Is not for learning outcome #{context.abbreviation}" } + end + + { + success: true, + unit: unit, + task_definition: task_definition, + learning_outcome: learning_outcome + } + end + + def self.create_from_csv(row, context_type, context, result) + context_result = context_for_csv(row, context_type, context) + unless context_result[:success] + result[:errors] << { row: row, message: context_result[:message] } + return + end + + # Get the context for easy access + unit = context_result[:unit] + task_definition = context_result[:task_definition] + learning_outcome = context_result[:learning_outcome] + + required_fields = { + 'type' => row['type'], + 'chip_text' => row['chip_text'], + 'description' => row['description'] + } + + required_fields.each do |field, value| + if value.nil? + result[:errors] << { row: row, message: "Missing #{field}" } + end + end + + type = row['type'] + group_id = row['group_id'] + if group_id.nil? && type == 'group' + result[:errors] << { row: row, message: 'Missing group_id' } + return + end + + parent_group_id = row['parent_group_id'] + parent_chip_id = nil + if parent_group_id.present? + parent_chip_id = FeedbackChip.where(summary_text: parent_group_id, learning_outcome_id: learning_outcome.id).pick(:id) + if parent_chip_id.nil? + result[:errors] << { row: row, message: "Parent group_id #{parent_group_id} not found" } + return + end + end + + chip_text = row['chip_text'] + description = row['description'] + + task_status = row['task_status'] + if task_status.present? + task_status_check = TaskStatus.status_for_name(task_status) + if task_status_check.nil? + result[:errors] << { row: row, message: "Task status #{task_status} not found" } + return + end + end + + summary_text = row['type'] == 'group' ? group_id : row['summary_text'] # store group_id in summary_text for group chips + comment_text = row['comment_text'] + + csv_type = if type == 'group' + 'Feedback::FeedbackGroupChip' + elsif type == 'template' + 'Feedback::FeedbackTemplateChip' + end + if csv_type.nil? + result[:errors] << { row: row, message: "Invalid type #{type}" } + return + end + + # Type and learning outcome cannot change - find or create by chip_text + chip = FeedbackChip.find_or_create_by( + learning_outcome_id: learning_outcome.id, + type: csv_type, + chip_text: chip_text + ) + + # Update the other details + chip.description = description + chip.task_status = task_status + chip.parent_chip_id = parent_chip_id + chip.summary_text = summary_text + chip.comment_text = comment_text + + chip.save! + + if chip.persisted? + result[:success] << { row: row, message: "#{chip.new_record? ? 'Created' : 'Updated'} chip #{row['chip_text']}" } + else + result[:errors] << { row: row, message: "Chip #{chip_text} not created - #{chip.errors.full_messages.join('. ')}" } + end + end + end +end diff --git a/app/models/feedback/feedback_group_chip.rb b/app/models/feedback/feedback_group_chip.rb new file mode 100644 index 0000000000..48fdb208db --- /dev/null +++ b/app/models/feedback/feedback_group_chip.rb @@ -0,0 +1,20 @@ +# == Schema Information +# +# Table name: feedback_chips +# +# id :bigint not null, primary key +# type :string(255) +# chip_text :text(65535) +# description :text(65535) +# comment_text :text(65535) +# summary_text :text(65535) +# learning_outcome_id :bigint not null +# parent_chip_id :bigint +# created_at :datetime not null +# updated_at :datetime not null +# task_status :string(255) +# +module Feedback + class FeedbackGroupChip < FeedbackChip + end +end diff --git a/app/models/feedback/feedback_template_chip.rb b/app/models/feedback/feedback_template_chip.rb new file mode 100644 index 0000000000..a2a71f1698 --- /dev/null +++ b/app/models/feedback/feedback_template_chip.rb @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: feedback_chips +# +# id :bigint not null, primary key +# type :string(255) +# chip_text :text(65535) +# description :text(65535) +# comment_text :text(65535) +# summary_text :text(65535) +# learning_outcome_id :bigint not null +# parent_chip_id :bigint +# created_at :datetime not null +# updated_at :datetime not null +# task_status :string(255) +# +module Feedback + class FeedbackTemplateChip < FeedbackChip + validates :comment_text, presence: true + validates :summary_text, presence: true + end +end diff --git a/app/models/group.rb b/app/models/group.rb index e075c04b48..a992525cc8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,9 +3,11 @@ class Group < ApplicationRecord belongs_to :tutorial, optional: false has_many :group_memberships, dependent: :destroy - has_many :group_submissions + has_many :group_submissions, dependent: :destroy + has_many :projects, -> { where('group_memberships.active = :value and projects.enrolled = true', value: true) }, through: :group_memberships has_many :past_projects, -> { where('group_memberships.active = :value', value: false) }, through: :group_memberships, source: 'project' + has_one :unit, through: :group_set has_one :tutor, through: :tutorial @@ -133,24 +135,27 @@ def switch_to_tutorial tutorial if group_set.keep_groups_in_same_class && has_active_group_members? projects.each do |proj| - # We need to remove members to break the circular dependency and switch tutorial - remove_member(proj) + # These membership changes are temporary while moving tutorial, + # so they must not generate leave/join notifications. + remove_member(proj, notify: false) te = proj.enrol_in tutorial unless te.valid? raise "Unable to move group as #{proj.student.name} could not switch tutorial." end - add_member(proj) + add_member(proj, notify: false) end end self.save! end end - def add_member(project) + def add_member(project, notify: true) gm = project.group_membership_for_groupset(group_set) + membership_changed = gm.nil? || !gm.active? || gm.group_id != id + if gm.nil? gm = GroupMembership.create(group: self, project: project) group_memberships << gm @@ -162,16 +167,41 @@ def add_member(project) gm.active = true gm.save! + notify_group_membership_change(project, 'added to') if notify && membership_changed + gm end - def remove_member(project) + def remove_member(project, notify: true) gm = group_memberships.where(project: project).first + was_active = gm.active? + gm.active = false - gm.save + saved = gm.save + + notify_group_membership_change(project, 'removed from') if notify && saved && was_active + self end + def notify_group_membership_change(project, change) + student = project.student + return if student.blank? + + NotificationService.notify( + user: student, + type: 'general', + event: 'group_membership_changed', + message: "You have been #{change} group #{name} in #{unit.code}.", + link: "/projects/#{project.id}/groups" + ) + rescue StandardError => e + logger.error( + "Failed to raise group_membership_changed notification for project #{project.id}: #{e.message}" + ) + end + + private :notify_group_membership_change # # check if the project is the same as the current submission # diff --git a/app/models/group_set.rb b/app/models/group_set.rb index b4731579cf..3fd43ee598 100644 --- a/app/models/group_set.rb +++ b/app/models/group_set.rb @@ -1,6 +1,6 @@ class GroupSet < ApplicationRecord belongs_to :unit, optional: false - has_many :task_definitions + has_many :task_definitions, dependent: :nullify has_many :groups, dependent: :destroy validates :name, uniqueness: { diff --git a/app/models/group_submission.rb b/app/models/group_submission.rb index ba5d23bd45..5016aab970 100644 --- a/app/models/group_submission.rb +++ b/app/models/group_submission.rb @@ -4,7 +4,7 @@ class GroupSubmission < ApplicationRecord belongs_to :group, optional: false belongs_to :task_definition, optional: false - belongs_to :submitted_by_project, class_name: 'Project', foreign_key: 'submitted_by_project_id', optional: false + belongs_to :submitted_by_project, class_name: 'Project', optional: false has_many :tasks, dependent: :nullify has_many :projects, through: :tasks @@ -18,10 +18,9 @@ class GroupSubmission < ApplicationRecord FileHelper.delete_group_submission(group_submission) # also remove evidence from group members - tasks.each do |t| - t.portfolio_evidence_path = nil - t.save - end + # rubocop:disable Rails/SkipsModelValidations + tasks.where('portfolio_evidence IS NOT NULL').update_all(portfolio_evidence: nil) + # rubocop:enable Rails/SkipsModelValidations rescue => e logger.error "Failed to delete group submission #{group_submission.id}. Error: #{e.message}" end diff --git a/app/models/learning_outcome.rb b/app/models/learning_outcome.rb index 6532fd56c4..db074a1a50 100644 --- a/app/models/learning_outcome.rb +++ b/app/models/learning_outcome.rb @@ -1,62 +1,265 @@ +# == Schema Information +# +# Table name: learning_outcomes +# +# id :bigint not null, primary key +# short_description :string(255) +# full_outcome_description :string(4096) +# abbreviation :string(255) +# context_id :bigint +# context_type :string(255) +# class LearningOutcome < ApplicationRecord include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper - belongs_to :unit, optional: false + def self.permissions + convenor_role_permissions = [ + :update, + :get_los, + :update_glos, + :upload_csv, + :create_feedback_chips, + :get_feedback_chips + ] - has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes - has_many :related_task_definitions, -> { where('learning_outcome_task_links.task_id is NULL') }, through: :learning_outcome_task_links, source: :task_definition # only link staff relations + admin_role_permissions = [ + :update, + :get_los, + :update_glos, + :upload_csv, + :create_feedback_chips, + :get_feedback_chips + ] - validates :abbreviation, uniqueness: { scope: :unit_id } # outcome names within a unit must be unique - validates :description, length: { maximum: 4095, allow_blank: true } + tutor_role_permissions = [ + :update, + :get_los, + :update_glos + ] + + auditor_role_permissions = [ + :get_los + ] + + nil_role_permissions = [] + + { + convenor: convenor_role_permissions, + admin: admin_role_permissions, + tutor: tutor_role_permissions, + auditor: auditor_role_permissions, + nil: nil_role_permissions + } + end + + def role_for(user) + if context.nil? + # Global outcomes - only admins can edit. + # - No convenor level as that gived edit + if user.has_admin_capability? + Role.admin + elsif user.has_tutor_capability? + Role.tutor + # else will be nil + end + else + context.role_for(user) + end + end + + scope :global_outcomes, -> { where(context_id: nil, context_type: nil) } + + # Where the outcome is defined - TaskDefinition, Unit, nil + belongs_to :context, polymorphic: true, optional: true + + # Which higher level outcomes does this outcome support + has_many :outgoing_links, class_name: 'LearningOutcomeLink', foreign_key: 'source_id', dependent: :destroy, inverse_of: :source + has_many :linked_outcomes, through: :outgoing_links, source: :target + + # Which lower level outcomes are used to demonstrate this outcome? + has_many :demonstrated_through_outcome_links, class_name: 'LearningOutcomeLink', foreign_key: 'target_id', dependent: :destroy, inverse_of: :target + has_many :demonstrated_through_outcomes, through: :demonstrated_through_outcome_links, source: :source + + has_many :feedback_group_chips, class_name: 'Feedback::FeedbackGroupChip', dependent: :destroy + has_many :feedback_template_chips, class_name: 'Feedback::FeedbackTemplateChip', dependent: :destroy + has_many :feedback_chips, class_name: 'Feedback::FeedbackChip', dependent: :destroy + + validates :short_description, length: { maximum: 100, allow_blank: true } + validates :full_outcome_description, length: { maximum: 4095, allow_blank: true } + validates :abbreviation, uniqueness: { scope: %i[context_id context_type] }, length: { maximum: 5, allow_blank: false } def self.csv_header - %w(unit_code ilo_number abbreviation name description) + %w(unit_code task_abbreviation learning_outcome_abbreviation short_description full_outcome_description linked_outcomes) end def add_csv_row(row) - row << [unit.code, ilo_number, abbreviation, name, description] + unit_code = if context_type == 'Unit' + Unit.find(context_id).code + elsif context_type == 'TaskDefinition' + TaskDefinition.find(context_id).unit.code + elsif context_type.nil? + '' + end + task_abbreviation = context_type == 'TaskDefinition' ? TaskDefinition.find(context_id).abbreviation : '' + linked_learning_outcomes = linked_outcomes.pluck(:abbreviation).join(',') + + row << [unit_code, task_abbreviation, abbreviation, short_description, full_outcome_description, linked_learning_outcomes] end - def self.create_from_csv(unit, row, result) + def self.create_from_csv(unit, task_definition, row, result) unit_code = row['unit_code'] + task_abbreviation = row['task_abbreviation'] + + # In a unit context, but no unit code + if unit.present? + if unit_code.nil? + result[:errors] << { row: row, message: 'Missing unit_code' } + return + elsif unit_code != unit.code + result[:errors] << { row: row, message: "Unit #{unit_code} does not match unit #{unit.code}" } + return + end + end + + # In a task context, but no task abbreviation + if task_definition.present? + if task_abbreviation.nil? + result[:errors] << { row: row, message: 'Missing task_abbreviation' } + return + elsif task_abbreviation != task_definition.abbreviation + result[:errors] << { row: row, message: "Task #{task_abbreviation} does not match task #{task_definition.abbreviation}" } + return + end + end - if unit_code != unit.code - result[:ignored] << { row: row, message: "Invalid unit code. #{unit_code} does not match #{unit.code}" } + if unit_code.present? && unit.nil? + result[:errors] << { row: row, message: 'Unit outcomes must be uploaded to a unit' } return end - ilo_number = row['ilo_number'].to_i + if task_abbreviation.present? && unit.nil? + result[:errors] << { row: row, message: 'Task outcomes must be uploaded to a unit' } + return + end - abbr = row['abbreviation'] - if abbr.nil? - result[:errors] << { row: row, message: 'Missing abbreviation' } + # Get the context for the outcome + if task_abbreviation.present? + task_definition = unit.task_definitions.find_by(abbreviation: task_abbreviation) + unless task_definition + result[:errors] << { row: row, message: "Task #{task_abbreviation} not found" } + return + end + context_type = 'TaskDefinition' + context_id = task_definition.id + elsif unit_code.present? + context_type = 'Unit' + context_id = unit.id + else + context_type = nil + context_id = nil + end + + abbreviation = row['learning_outcome_abbreviation'] + if abbreviation.nil? + result[:errors] << { row: row, message: 'Missing learning_outcome_abbreviation' } + return + end + if abbreviation.length > 5 + result[:errors] << { row: row, message: 'learning_outcome_abbreviation must be less than 5 characters' } return end - name = row['name'] - if name.nil? - result[:errors] << { row: row, message: 'Missing name' } + short_description = row['short_description'] + if short_description.nil? + result[:errors] << { row: row, message: 'Missing short_description' } return end - description = row['description'] - if description.nil? - result[:errors] << { row: row, message: 'Missing description' } + full_outcome_description = row['full_outcome_description'] + if full_outcome_description.nil? + result[:errors] << { row: row, message: 'Missing full_outcome_description' } return end - outcome = LearningOutcome.find_or_create_by(unit_id: unit.id, abbreviation: abbr) do |outcome| - outcome.name = name - outcome.description = description - outcome.ilo_number = ilo_number + linked_outcomes = row['linked_outcomes'].to_s.split(',').map(&:strip) + + linked_outcome_ids = [] + + linked_outcomes.each do |linked_outcome| + # Search in the unit if present + found_outcome = if unit.present? + unit.learning_outcomes.find_by(abbreviation: linked_outcome) || LearningOutcome.global_outcomes.find_by(abbreviation: linked_outcome) + else + # Search in the global outcomes + LearningOutcome.global_outcomes.find_by(abbreviation: linked_outcome) + end + + if found_outcome.present? + linked_outcome_ids << found_outcome.id + else + result[:errors] << { row: row, message: "Linked outcome #{linked_outcome} not found" } + return nil + end + + found_outcome.id + end + + outcome = LearningOutcome.find_or_create_by(context_id: context_id, context_type: context_type, abbreviation: abbreviation) + outcome.short_description = short_description + outcome.full_outcome_description = full_outcome_description + + linked_outcome_ids.each do |linked_outcome_id| + LearningOutcomeLink.find_or_create_by(source_id: outcome.id, target_id: linked_outcome_id) end - outcome.save! + outcome.save result[:success] << if outcome.new_record? - { row: row, message: "Outcome #{abbr} created for unit" } + { row: row, message: "Outcome #{abbreviation} created" } else - { row: row, message: "Outcome #{abbr} updated for unit" } + { row: row, message: "Outcome #{abbreviation} updated" } end end + + def export_feedback_chips_to_csv + CSV.generate do |csv| + csv << Feedback::FeedbackChip.csv_header + feedback_chips.each do |chip| + chip.add_csv_row csv + end + end + end + + def update_linked_outcomes(data) + existing_link_ids = outgoing_links.pluck(:target_id) + + if existing_link_ids.present? + links_to_delete = if data.present? + existing_link_ids - data + else + existing_link_ids + end + end + + if links_to_delete.present? + outgoing_links.where(target_id: links_to_delete).destroy_all + end + + data&.each do |linked_outcome_id| + LearningOutcomeLink.find_or_create_by!(source_id: id, target_id: linked_outcome_id) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn "Failed to link learning outcome #{id} to learning outcome #{linked_outcome_id}: #{e.message}" + end + end + + def link_to(linked_outcome) + return if linked_outcome.nil? + LearningOutcomeLink.find_or_create_by( + source_id: id, + target_id: linked_outcome.id + ) + end end diff --git a/app/models/learning_outcome_link.rb b/app/models/learning_outcome_link.rb new file mode 100644 index 0000000000..3d97a6b0b0 --- /dev/null +++ b/app/models/learning_outcome_link.rb @@ -0,0 +1,42 @@ +# == Schema Information +# +# Table name: learning_outcome_links +# +# id :bigint not null, primary key +# source_id :bigint not null +# target_id :bigint not null +# link_type :string(255) +# created_at :datetime not null +# updated_at :datetime not null +# +class LearningOutcomeLink < ApplicationRecord + belongs_to :source, class_name: 'LearningOutcome' + belongs_to :target, class_name: 'LearningOutcome' + + validates :source, presence: true + validates :target, presence: true + validates :source_id, uniqueness: { scope: :target_id, message: 'Link already exists' } + + validate :validate_link_type + validate :validate_context + + def validate_link_type + return if source.nil? || target.nil? + + if source.context_type.nil? + errors.add(:link_type, 'Cannot link global learning outcomes') + elsif source.context_type == 'Unit' && target.context_type == 'TaskDefinition' + errors.add(:link_type, 'Unit learning outcomes may not link to task learning outcomes') + elsif source.context_type == target.context_type + errors.add(:link_type, 'Learning outcomes must only link to higher level outcomes') + end + end + + def validate_context + return if source.nil? || target.nil? + + if source.context_type == 'TaskDefinition' && target.context_type == 'Unit' && source.context.unit != target.context + errors.add(:link_type, 'Task learning outcomes must be linked to the same unit') + end + end +end diff --git a/app/models/learning_outcome_task_link.rb b/app/models/learning_outcome_task_link.rb deleted file mode 100644 index 4747c732ae..0000000000 --- a/app/models/learning_outcome_task_link.rb +++ /dev/null @@ -1,55 +0,0 @@ -class LearningOutcomeTaskLink < ApplicationRecord - default_scope { all } - - belongs_to :task_definition, optional: false - belongs_to :task, optional: true - belongs_to :learning_outcome, optional: false - - validates :task_definition, presence: true - validates :learning_outcome, presence: true - validate :ensure_relations_unique - - validates :rating, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 5 } - - def ensure_relations_unique - return if learning_outcome.nil? || task_definition.nil? - - if id.nil? - related_links = LearningOutcomeTaskLink.where('task_definition_id = :task_definition_id AND learning_outcome_id = :learning_outcome_id', task_definition_id: task_definition.id, learning_outcome_id: learning_outcome.id) - else - related_links = LearningOutcomeTaskLink.where('id != :my_id AND task_definition_id = :task_definition_id AND learning_outcome_id = :learning_outcome_id', my_id: id, task_definition_id: task_definition.id, learning_outcome_id: learning_outcome.id) - end - - if task.nil? - errors.add(:task_definition, 'already linked to this learning outcome') if related_links.where('task_id is NULL').count > 0 - else - errors.add(:task, 'already linked to this learning outcome') if related_links.where('task_id = :task_id', task_id: task.id).count > 0 - end - end - - def duplicate_to(new_unit) - result = self.dup - - throw "Unable to duplicate project learning outcome task links in unit #{new_unit.code}" if task.present? - - ilo = new_unit.learning_outcomes.find_by(abbreviation: self.learning_outcome.abbreviation) - throw "Unable to find Learning Outcome #{self.learning_outcome.abbreviation} in unit #{new_unit.code}" if ilo.nil? - - task_def = new_unit.task_definitions.find_by(abbreviation: self.task_definition.abbreviation) - throw "Unable to find Task Definition #{self.task_definition.abbreviation} in unit #{new_unit.code}" if task_def.nil? - - result.learning_outcome = ilo - result.task_definition = task_def - result.task = nil - result.save - end - - def self.export_task_alignment_to_csv(unit, source) - CSV.generate do |row| - row << %w(unit_code learning_outcome task_abbr rating description) - source.task_outcome_alignments.each do |align| - row << [unit.code, align.learning_outcome.abbreviation, align.task_definition.abbreviation, align.rating, align.description] - end - end - end -end diff --git a/app/models/marking_session.rb b/app/models/marking_session.rb new file mode 100644 index 0000000000..9ba79540d0 --- /dev/null +++ b/app/models/marking_session.rb @@ -0,0 +1,20 @@ +class MarkingSession < ApplicationRecord + belongs_to :user + belongs_to :unit + has_many :session_activities, dependent: :destroy + + validates :user, presence: true + validates :unit, presence: true + validates :ip_address, presence: true + validates :start_time, presence: true + + def duration_minutes + return 0 unless start_time && end_time + ((end_time - start_time) / 60).to_i + end + + def update_session_details + now = DateTime.now + update(end_time: now) + end +end diff --git a/app/models/moderated_task.rb b/app/models/moderated_task.rb new file mode 100644 index 0000000000..adcc4be8b9 --- /dev/null +++ b/app/models/moderated_task.rb @@ -0,0 +1,17 @@ +class ModeratedTask < ApplicationRecord + belongs_to :task + belongs_to :task_definition + belongs_to :user, optional: true + + enum :state, { + open: 'open', + waiting_for_new_feedback: 'waiting_for_new_feedback', + resolved: 'resolved' + } + + enum :moderation_type, { + first_feedback: 'first_feedback', + random_sample: 'random_sample', + escalation: 'escalation' + } +end diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 0000000000..db780e7e43 --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,51 @@ +class Notification < ApplicationRecord + belongs_to :user + + # Notification categories. The first three map onto the existing user + # preference columns (receive_task/feedback/portfolio_notifications) so that a + # single category toggle gates every delivery channel (in-app, email, push). + TYPES = %w[task feedback portfolio extension general].freeze + + # `notification_type` is the category the user's preferences switch on. + # `event` is the specific thing that happened within that category, e.g. + # 'task_comment_created'. It is free text so a new event ticket does not have + # to edit this model, but it is required so every notification can be traced + # back to the code that raised it. + + # Maps a notification type to the user preference column that gates it. + # Types without an entry here are always delivered. + PREFERENCE_FOR_TYPE = { + 'task' => :receive_task_notifications, + 'feedback' => :receive_feedback_notifications, + 'portfolio' => :receive_portfolio_notifications + }.freeze + + validates :notification_type, presence: true, inclusion: { in: TYPES } + validates :event, presence: true, length: { maximum: 255 } + validates :message, presence: true, length: { maximum: 500 } + validates :dedupe_key, length: { maximum: 191 }, allow_nil: true + + # Queue the email only once the transaction that created the notification has + # committed. Several callers raise notifications from inside a transaction, + # for example a tutorial enrolment being destroyed removes the student from + # their group, and a worker that picked the job up before the commit could not + # see the row yet. + after_commit :queue_email_delivery, on: :create + + scope :unread, -> { where(read_at: nil) } + scope :recent_first, -> { order(created_at: :desc) } + + def read? + read_at.present? + end + + def mark_read! + update!(read_at: Time.zone.now) unless read? + end + + private + + def queue_email_delivery + NotificationService.queue_email(self) + end +end diff --git a/app/models/overflow_task_claim.rb b/app/models/overflow_task_claim.rb new file mode 100644 index 0000000000..986d055b6d --- /dev/null +++ b/app/models/overflow_task_claim.rb @@ -0,0 +1,5 @@ +class OverflowTaskClaim < ApplicationRecord + belongs_to :task + belongs_to :claimed_by_unit_role, class_name: 'UnitRole' + +end diff --git a/app/models/overflow_task_claim_log.rb b/app/models/overflow_task_claim_log.rb new file mode 100644 index 0000000000..9b4b606115 --- /dev/null +++ b/app/models/overflow_task_claim_log.rb @@ -0,0 +1,8 @@ +class OverflowTaskClaimLog < ApplicationRecord + belongs_to :unit + belongs_to :task, optional: true + belongs_to :claimed_by_unit_role, class_name: 'UnitRole', optional: true + belongs_to :claimed_by_user, class_name: 'User', optional: true + belongs_to :original_tutor_user, class_name: 'User', optional: true + belongs_to :student_user, class_name: 'User', optional: true +end diff --git a/app/models/overseer_assessment.rb b/app/models/overseer_assessment.rb index cd843235df..d01a3210ed 100644 --- a/app/models/overseer_assessment.rb +++ b/app/models/overseer_assessment.rb @@ -1,89 +1,123 @@ +# rubocop:disable Rails/Output class OverseerAssessment < ApplicationRecord belongs_to :task, optional: false + belongs_to :submission_history, optional: false has_one :project, through: :task - has_many :assessment_comments, dependent: :destroy + has_many :assessment_comments, as: :commentable, dependent: :destroy + has_many :overseer_step_results, dependent: :destroy validates :status, presence: true validates :task_id, presence: true validates :submission_timestamp, presence: true - validates_uniqueness_of :submission_timestamp, scope: :task_id + validates :submission_timestamp, uniqueness: { scope: :task_id } + validates :submission_history_id, uniqueness: true + validate :submission_history_matches_task - enum status: { pre_queued: 0, queued: 1, queue_failed: 2, done: 3 } + enum :status, { pre_queued: 0, passed: 1, failed: 2 } - after_destroy :delete_associated_files + def submission_history_matches_task + return if submission_history.nil? || task.nil? || submission_history.task_id == task_id + + errors.add(:submission_history, 'must belong to the same task') + end + + def self.student_notification_grace_period + Doubtfire::Application.config.overseer_student_notification_grace_period + end + + scope :awaiting_student_failure_notification, lambda { |grace_period: student_notification_grace_period| + notification_cutoff = grace_period.ago + + joins(task: { project: :user }) + .joins(<<~SQL.squish) + INNER JOIN task_comments assessment_comments + ON assessment_comments.commentable_type = 'OverseerAssessment' + AND assessment_comments.commentable_id = overseer_assessments.id + AND assessment_comments.type = 'AssessmentComment' + SQL + .joins(<<~SQL.squish) + LEFT JOIN comments_read_receipts student_read_receipts + ON student_read_receipts.task_comment_id = assessment_comments.id + AND student_read_receipts.user_id = projects.user_id + SQL + .where(status: statuses[:failed], student_notified_at: nil) + .where(users: { receive_task_notifications: true }) + .where('overseer_assessments.updated_at <= ?', notification_cutoff) + .where('student_read_receipts.id IS NULL') + .where(<<~SQL.squish) + assessment_comments.id = ( + SELECT latest_comment.id + FROM task_comments latest_comment + WHERE latest_comment.commentable_type = 'OverseerAssessment' + AND latest_comment.commentable_id = overseer_assessments.id + AND latest_comment.type = 'AssessmentComment' + ORDER BY latest_comment.created_at DESC, latest_comment.id DESC + LIMIT 1 + ) + SQL + .where(<<~SQL.squish) + NOT EXISTS ( + SELECT 1 + FROM overseer_assessments newer_assessments + WHERE newer_assessments.task_id = overseer_assessments.task_id + AND ( + newer_assessments.created_at > overseer_assessments.created_at OR + ( + newer_assessments.created_at = overseer_assessments.created_at AND + newer_assessments.id > overseer_assessments.id + ) + ) + ) + SQL + } + + # TODO: track how many tests ran, and how many tests total at the time + # TODO: we might not have an overseerStepResult because a new test was added later # Creates an OverseerAssessment object for a new submission - def self.create_for(task) + def self.create_for(submission_history, test_submission) # Create only if: # unit's assessment is enabled && # task's assessment is enabled && # task definition has an assessment resources zip file && # task has a student submission + task = submission_history.task task_definition = task.task_definition unit = task_definition.unit - return nil unless unit.assessment_enabled - return nil unless task_definition.assessment_enabled - return nil unless task_definition.has_task_assessment_resources? - return nil unless task.has_new_files? || task.has_done_file? + return nil unless task.overseer_enabled? || test_submission + + active_overseer_steps = task.task_definition.overseer_steps.select(&:enabled) + return nil if active_overseer_steps.empty? docker_image_name_tag = task_definition.docker_image_name_tag || unit.docker_image_name_tag - assessment_resources_path = task_definition.task_assessment_resources + # assessment_resources_path = task_definition.task_assessment_resources return nil if docker_image_name_tag.nil? || docker_image_name_tag.strip.empty? - result = OverseerAssessment.create!( + OverseerAssessment.create!( task: task, + submission_history: submission_history, status: :pre_queued, - submission_timestamp: Time.now.utc.to_i + submission_timestamp: submission_history.submission_timestamp ) - - # Create the submission folder and give access - FileUtils.mkdir_p result.output_path - result.grant_access_to_submission - - result.copy_latest_files_to_submission - - result - end - - def has_submission_files? - File.exist? submission_zip_file_name - end - - def submission_zip_file_name - "#{output_path}/submission.zip" end - def grant_access_to_submission - # TODO: Use FACL instead in future. - `chmod o+w #{output_path}` - end - - def copy_latest_files_to_submission - zip_file_path = submission_zip_file_name - - if task.has_new_files? - puts "Copying new files to submission at: #{zip_file_path}" - # Generate a zip file for this particular submission with timestamp value and put it here - task.compress_new_to_done zip_file_path: zip_file_path, rm_task_dir: false, rename_files: true - else - puts "Copying done file to submission at: #{zip_file_path}" - task.copy_done_to zip_file_path - end - end + delegate :has_submission_files?, + :submission_zip_file_name, + :output_path, + to: :submission_history - # Path to where the submission and output are stored - includes the submission when it is to be processed - def output_path - FileHelper.task_submission_identifier_path_with_timestamp(:done, task, submission_timestamp) + def latest_assessment_comment + assessment_comments.order(created_at: :desc, id: :desc).first end def add_assessment_comment(text = 'Automated Assessment Started') text.strip! - return nil if text.nil? || text.empty? + return nil if text.blank? tutor = project.tutor_for(task.task_definition) @@ -95,7 +129,7 @@ def add_assessment_comment(text = 'Automated Assessment Started') comment.user = tutor comment.comment = text comment.recipient = project.student - comment.overseer_assessment = self + comment.commentable = self comment.save! comment @@ -103,7 +137,7 @@ def add_assessment_comment(text = 'Automated Assessment Started') def update_assessment_comment(text) text.strip! - return nil if text.nil? || text.empty? + return nil if text.blank? assessment_comment = assessment_comments.last @@ -120,18 +154,12 @@ def update_assessment_comment(text) add_assessment_comment text end - def send_to_overseer() + def send_to_overseer(test_submission: false) return { error: "Your task is already queued for processing. Pleasse wait until you receive a response before queueing your task again." } if self.status == :queued # TODO: Check status and do not queue if already queued puts "********* Sending #{self.id} to overseer" - sm_instance = Doubtfire::Application.config.sm_instance - if sm_instance.nil? - puts "ERROR: Unable to get service manager to send message to overseer. Unable to send - OverseerAssessment #{id}" - return { error: "Automated feedback is not configured correctly. Please raise an issue with your administrator. ERR:O1" } - end - unless has_submission_files? puts "ERROR: Attempting to send submission to Overseer without associated submission files - OverseerAssessment #{id}" return { error: "Your submission does not include any files to be processed." } @@ -148,16 +176,16 @@ def send_to_overseer() assessment_resources_path = task_definition.task_assessment_resources - unless unit.assessment_enabled && - task_definition.assessment_enabled && - task_definition.has_task_assessment_resources? && - (task.has_new_files? || task.has_done_file?) + unless unit.assessment_enabled && + (task_definition.assessment_enabled || test_submission) && + # task_definition.has_task_assessment_script? && + (task.has_new_files? || task.has_done_file?) puts "ERROR: Assessment is no longer configured for overseer assessment. Unable to send - OverseerAssessment #{id}" return { error: "This assessment is no longer setup for automated feedback. Automated feedback is turned off at either the unit or task level, or the task does not have the scripts needed to automate assessment." } end - unless File.exist? submission_zip_file_name + unless has_submission_files? puts "ERROR: Student submission history zip file doesn't exist #{submission_zip_file_name}. Unable to send - OverseerAssessment #{id}" return { error: "We no longer have the files associated with this submission. Please test a later submission, or upload your work again." } end @@ -183,57 +211,56 @@ def send_to_overseer() puts message.inspect - begin - sm_instance.clients[:ontrack].publisher.connect_publisher - puts("Sending message to rabbitmq for Overseer Assessment #{id}") - sm_instance.clients[:ontrack].publisher.publish_message(message) - puts("Sent to rabbitmq for Overseer Assessment #{id}") - self.status = :queued - rescue RuntimeError => e - puts "ERROR: OverseerAssessment #{id} failed to send: #{e.inspect}" - self.status = :queue_failed - return { error: "We are unable to send your submission to the automated feedback service. Please try again later." } - ensure - puts "saving... #{self.status}" - save! - sm_instance.clients[:ontrack].publisher.disconnect_publisher - end - - puts "********* - end perform assessment" - if assessment_comments.count == 0 - result = add_assessment_comment() - else - result = assessment_comments.last - result.update created_at: Time.zone.now - result - end - - { - comment: result, - error: nil - } + AcceptOverseerJob.perform_async( + task.id, + output_path, + docker_image_name_tag, + submission_zip_file_name, + assessment_resources_path, + submission_timestamp, + self.id + ) end - def update_from_output() + def update_from_output(work_dir_path) # Update the overseer assessment status self.status = :done - yaml_path = "#{output_path}/output.yaml" + yaml_path = "#{work_dir_path}/output.yaml" + if File.exist? yaml_path yaml_file = YAML.load_file(yaml_path).with_indifferent_access comment_txt = '' if !yaml_file['build_message'].nil? && !yaml_file['build_message'].strip.empty? - comment_txt += yaml_file['build_message'] + comment_txt += "Build output:\n" + comment_txt += if base64?(yaml_file['run_message']) + Base64.urlsafe_decode64(yaml_file['build_message']) + else + yaml_file['run_message'] + end + comment_txt += "\n" end if !yaml_file['run_message'].nil? && !yaml_file['run_message'].strip.empty? - comment_txt += "\n\n" unless comment_txt.empty? - comment_txt += yaml_file['run_message'] + comment_txt += "\n" unless comment_txt.empty? + comment_txt += "Execution output:\n" + comment_txt += if base64?(yaml_file['run_message']) + Base64.urlsafe_decode64(yaml_file['run_message']) + else + yaml_file['run_message'] + end + comment_txt += "\n" + end + + if !yaml_file['message'].nil? && !yaml_file['message'].strip.empty? + comment_txt += "\n" unless comment_txt.empty? + comment_txt += "Message:\n" + comment_txt += yaml_file['message'] end if comment_txt.present? - update_assessment_comment(comment_txt) + update_assessment_comment(comment_txt[0, 4000]) # Truncate to 4000 characters else puts 'YAML file doesn\'t contain field `build_message` or `run_message`' end @@ -248,6 +275,7 @@ def update_from_output() end if task.ready_for_feedback? && new_status.present? + task.add_status_comment(task.task_definition.unit.main_convenor.user, new_status) task.update task_status: new_status end else @@ -260,7 +288,13 @@ def update_from_output() self.save! end - def delete_associated_files - FileUtils.rm_rf output_path + def base64?(value) + value.is_a?(String) && Base64.strict_encode64(Base64.decode64(value)) == value + end + + + def passed_steps + overseer_step_results.select(&:pass).size end end +# rubocop:enable Rails/Output diff --git a/app/models/overseer_image.rb b/app/models/overseer_image.rb index 11321fe6da..c530c2e960 100644 --- a/app/models/overseer_image.rb +++ b/app/models/overseer_image.rb @@ -4,15 +4,15 @@ class OverseerImage < ApplicationRecord # Callbacks - methods called are private before_destroy :can_destroy? - has_many :units - has_many :task_definitions + has_many :units, dependent: :nullify + has_many :task_definitions, dependent: :nullify # Always add a unique index with uniqueness constraint # This is to prevent new records from passing the validations when checked at the same time before being written validates :name, presence: true, uniqueness: true validates :tag, presence: true, uniqueness: true, format: { with: %r{\A([\w.\-_]+((?::\d+|)(?=/[a-z0-9._-]+/[a-z0-9._-]+))|)(?:/|)([a-z0-9.\-_]+(?:/[a-z0-9.\-_]+|))(:([\w.\-_]{1,127})|)\z} } - enum pulled_image_status: { failed: 0, success: 1 } + enum :pulled_image_status, { failed: 0, loading: 1, success: 2 } # Pulls overseer image def pull_from_docker @@ -26,10 +26,12 @@ def pull_from_docker registry = "" if registry.nil? self.last_pulled_date = Time.zone.now + self.pulled_image_status = :loading + cmd = "docker pull #{registry}#{tag}" out_text, error_text, exit_status = Open3.capture3(cmd) - self.pulled_image_text = "#{cmd}\n#{out_text}\n#{error_text}" + self.pulled_image_text = "#{cmd}\n#{out_text}\n#{error_text}" self.pulled_image_status = if exit_status == 0 :success else diff --git a/app/models/overseer_step.rb b/app/models/overseer_step.rb new file mode 100644 index 0000000000..6273f0db06 --- /dev/null +++ b/app/models/overseer_step.rb @@ -0,0 +1,6 @@ +class OverseerStep < ApplicationRecord + belongs_to :task_definition, optional: false + + validates :timeout, presence: true, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 300, message: 'must be between 1 and 300' } + validates :sort_order, presence: true, numericality: { greater_than_or_equal_to: 0 } +end diff --git a/app/models/overseer_step_result.rb b/app/models/overseer_step_result.rb new file mode 100644 index 0000000000..21e4821581 --- /dev/null +++ b/app/models/overseer_step_result.rb @@ -0,0 +1,5 @@ +class OverseerStepResult < ApplicationRecord + belongs_to :overseer_assessment, optional: false + belongs_to :overseer_step, optional: false + +end diff --git a/app/models/pdf_generation/project_compile_portfolio_module.rb b/app/models/pdf_generation/project_compile_portfolio_module.rb index 33e72bca42..eab5a5292b 100644 --- a/app/models/pdf_generation/project_compile_portfolio_module.rb +++ b/app/models/pdf_generation/project_compile_portfolio_module.rb @@ -2,9 +2,9 @@ module PdfGeneration module ProjectCompilePortfolioModule def projects_awaiting_auto_generation Project.joins(:unit) - .where(units: { active: true, end_date: Date.today..Float::INFINITY }) + .where(units: { active: true, end_date: Time.zone.today..Float::INFINITY }) .where(projects: { enrolled: true, portfolio_production_date: nil }) - .where("units.portfolio_auto_generation_date < ?", Date.today) + .where("units.portfolio_auto_generation_date < ?", Time.zone.today) .where(compile_portfolio: false) .reject(&:portfolio_available) end @@ -35,11 +35,16 @@ def compress_portfolio # This class scaffolds the creation of the portfolio - mapping the required data into the erb template class ProjectAppController < ApplicationController + include LatexHelper + attr_accessor :student, :project, :base_path, :image_path, :learning_summary_report, + :submission_date, + :formatted_submission_date, + :formatted_submission_time, :ordered_tasks, :portfolio_tasks, :task_defs, @@ -53,28 +58,64 @@ def init(project, is_retry) @student = project.student @project = project @learning_summary_report = project.learning_summary_report_path + @submission_date = project.portfolio_submission_date + @formatted_submission_date, @formatted_submission_time = format_submission_date(project) @files = project.portfolio_files(ensure_valid: true, force_ascii: is_retry) @base_path = project.portfolio_temp_path - @image_path = Rails.root.join('public', 'assets', 'images') + @image_path = Rails.root.join('public/assets/images') @ordered_tasks = project.tasks.joins(:task_definition).order('task_definitions.start_date, task_definitions.abbreviation').where("task_definitions.target_grade <= #{project.target_grade}") @portfolio_tasks = project.portfolio_tasks @task_defs = project.unit.task_definitions.order(:start_date) - @outcomes = project.unit.learning_outcomes.order(:ilo_number) + @outcomes = project.unit.learning_outcomes # .order(:ilo_number) @institution_name = Doubtfire::Application.config.institution[:name] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] @is_retry = is_retry + @include_pax = !is_retry + @work_id = FileHelper.sanitized_path( + "portfolio-#{Time.current.strftime('%Y%m%d-%H%M')}-#{project.student.username}-#{project.id}-#{Process.pid}#{'-retry' if is_retry}" + ) end def make_pdf - render_to_string(template: '/portfolio/portfolio_pdf', layout: true) + logger.debug 'Running make_pdf: (portfolio)' + generate_pdf(template: '/portfolio/portfolio_pdf') + end + + private + + def format_submission_date(project) + return [nil, nil] if @submission_date.blank? + + campus_timezone = project.campus&.timezone.presence + + submission_time = + if campus_timezone.present? + @submission_date.in_time_zone(campus_timezone) + else + @submission_date.to_time.getlocal + end + + timezone_label = campus_timezone || ENV['TZ'].presence || submission_time.zone || Time.zone.name + [ + submission_time.strftime('%d %b %Y'), + "#{submission_time.strftime('%I:%M %p')} #{timezone_label}" + ] + end + end + + # A custom error to capture the log message from the latex error + class LatexError < StandardError + attr_reader :log_message + + def initialize(log_message) + super + @log_message = log_message end end # Create the portfolio for this project def create_portfolio - self.compile_portfolio = false - save! - + logger.info "Creating portfolio for #{user.username} in #{unit.code}" begin pac = ProjectAppController.new pac.init(self, false) @@ -101,21 +142,34 @@ def create_portfolio logger.info "Created portfolio at #{portfolio_path} - #{log_details}" self.portfolio_production_date = Time.zone.now - save + self.compile_portfolio = false + save! + true rescue StandardError => e + self.compile_portfolio = false + save! + logger.error "Failed to convert portfolio to PDF - #{log_details} -\nError: #{e.message}" log_file = e.message.scan(%r{/.*\.log}).first if log_file && File.exist?(log_file) + begin + log_message = File.read(log_file) + + # rubocop:disable Rails/Output puts "--- Latex Log ---\n" - puts File.read(log_file) + puts log_message puts "--- End ---\n\n" + # rubocop:enable Rails/Output rescue StandardError + # rubocop:disable Rails/Output puts "Failed to read log file: #{log_file}" + # rubocop:enable Rails/Output end end - false + + raise LatexError.new(log_message), "Failed to convert portfolio to PDF - #{log_details}" end end @@ -139,10 +193,29 @@ def save_as_learning_summary_report(path) # Return the tasks to include in the student's portfolio def portfolio_tasks # Get assigned tasks that are included in the portfolio - tasks = self.tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation').where('tasks.include_in_portfolio = TRUE') + tasks = self.tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation') + + # Select tasks that have a PDF, or unless the task has no upload requirements, and is in a submitted state + tasks.select do |task| + next if task.task_status_id == TaskStatus.not_started.id + task.has_pdf || ( + task.task_definition.upload_requirements.blank? && + ![TaskStatus.need_help.id, TaskStatus.working_on_it.id].include?(task.task_status_id) + ) + end + end - # Now select the tasks that and have a PDF... cant include the others... - tasks.select(&:has_pdf) + # Return the tasks that are currently being processed + def tasks_processing_pdf + # Get assigned tasks that should be included in the portfolio + tasks = self.tasks.joins(:task_definition).order('task_definitions.target_date, task_definitions.abbreviation') + + # Select tasks that should have a PDF submission, but is currently being processed + tasks.select do |task| + !task.has_pdf && + task.processing_pdf? && + task.task_definition.upload_requirements.present? + end end # @@ -173,8 +246,8 @@ def learning_summary_report_exists? # Portfolio production code # def portfolio_temp_path - portfolio_dir = FileHelper.student_portfolio_dir(self.unit, self.student.username, false) - portfolio_tmp_dir = File.join(portfolio_dir, 'tmp') + portfolio_dir = FileHelper.student_portfolio_dir(self.unit, self.student.username, create: false) + File.join(portfolio_dir, 'tmp') end def portfolio_tmp_file_name(dict) @@ -231,7 +304,7 @@ def portfolio_files(ensure_valid: false, force_ascii: false) result = [] Dir.chdir(portfolio_tmp_dir) - files = Dir.glob('*').select { |f| (f =~ /^\d{3}-(cover|document|code|image)/) == 0 } + files = Dir.glob('*').select { |f| (f =~ /^\d{3}-(cover|document|code|image|zip|archive)/) == 0 } files.each do |file| parts = file.split('-') idx = parts[0].to_i @@ -266,7 +339,7 @@ def remove_portfolio_file(idx, kind, name) end def portfolio_path - FileHelper.student_portfolio_path(self.unit, self.student.username, true) + FileHelper.student_portfolio_path(self.unit, self.student.username, create: true) end def portfolio_exists? diff --git a/app/models/peer_progress_snapshot.rb b/app/models/peer_progress_snapshot.rb new file mode 100644 index 0000000000..fe5dab6aef --- /dev/null +++ b/app/models/peer_progress_snapshot.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +class PeerProgressSnapshot < ApplicationRecord + # MariaDB exposes its JSON-compatible LONGTEXT column as text to the mysql2 + # adapter. Declaring the logical type explicitly keeps Hash casting identical + # on MariaDB and native-JSON MySQL deployments. + attribute :status_counts, :json + + belongs_to :unit, + inverse_of: :peer_progress_snapshots + + belongs_to :task_definition, + inverse_of: :peer_progress_snapshots + + validates :target_grade, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + }, + uniqueness: { + scope: %i[unit_id task_definition_id] + } + + validates :submitted_percentage, + numericality: { + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 + }, + allow_nil: true + + validates :submitted_count, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + }, + allow_nil: true + + validates :cohort_size, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 0 + } + + validates :calculated_at, + presence: true + + validate :task_definition_belongs_to_unit + validate :target_grade_enabled_for_unit + validate :target_grade_covers_task + validate :percentage_requires_non_empty_cohort + validate :submitted_count_fits_cohort + validate :status_counts_cover_the_cohort + + private + + def task_definition_belongs_to_unit + return if unit.blank? || task_definition.blank? + return if task_definition.unit_id == unit_id + + errors.add( + :task_definition, + 'must belong to the same unit' + ) + end + + def target_grade_enabled_for_unit + return if unit.blank? || target_grade.nil? + return if unit.grade_value?(target_grade) + + errors.add( + :target_grade, + 'must be enabled for the unit' + ) + end + + def target_grade_covers_task + return if task_definition.blank? || target_grade.nil? + return if target_grade >= task_definition.target_grade + + errors.add( + :target_grade, + 'must be at least the task definition target grade' + ) + end + + def percentage_requires_non_empty_cohort + return if submitted_percentage.nil? + return if cohort_size.nil? + return if cohort_size.positive? + + errors.add( + :submitted_percentage, + 'must be blank when cohort size is zero' + ) + end + + def submitted_count_fits_cohort + return if submitted_count.nil? || cohort_size.nil? + return if submitted_count <= cohort_size + + errors.add( + :submitted_count, + 'must not exceed the cohort size' + ) + end + + def status_counts_cover_the_cohort + return if status_counts.nil? + + keys_valid = status_counts.is_a?(Hash) && + status_counts.keys.map(&:to_s).sort == + PeerProgressDistributionPolicy::STATUS_KEYS.sort + values_valid = status_counts.is_a?(Hash) && + status_counts.values.all? do |value| + value.is_a?(Integer) && value >= 0 + end + + unless keys_valid && values_valid + errors.add( + :status_counts, + 'must contain every supported task status with non-negative integer counts' + ) + return + end + + return if PeerProgressDistributionPolicy.valid_status_counts?( + status_counts, + cohort_size: cohort_size + ) + + errors.add( + :status_counts, + 'must sum to the cohort size' + ) + end +end diff --git a/app/models/portfolio_evidence.rb b/app/models/portfolio_evidence.rb index 5e0790593c..65d1d481b6 100644 --- a/app/models/portfolio_evidence.rb +++ b/app/models/portfolio_evidence.rb @@ -22,7 +22,7 @@ def self.move_to_pid_folder pid_folder = File.join(student_work_dir(:in_process), "pid_#{Process.pid}") # Move everything in "new" to "pid" folder but retain the old "new" folder - FileHelper.move_files(student_work_dir(:new), pid_folder, true, DateTime.now - 1.minute) + FileHelper.move_files(student_work_dir(:new), pid_folder, true, DateTime.now - 30.minutes) pid_folder end @@ -50,9 +50,6 @@ def self.process_new_to_pdf(my_source) logger.error "Failed to process folder_id = #{folder_id}. #{message}" if task - task.add_text_comment task.project.tutor_for(task.task_definition), "**Automated Comment**: Something went wrong with your submission. Check the files and resubmit this task. #{message}" - task.trigger_transition trigger: 'fix', by_user: task.project.tutor_for(task.task_definition) - errors[task.project] = [] if errors[task.project].nil? errors[task.project] << task end @@ -60,7 +57,7 @@ def self.process_new_to_pdf(my_source) begin logger.info "creating pdf for task #{task.id}" - success = task.convert_submission_to_pdf(my_source) + success = task.convert_submission_to_pdf(source_folder: my_source, log_to_stdout: true) if success done[task.project] = [] if done[task.project].nil? @@ -73,20 +70,15 @@ def self.process_new_to_pdf(my_source) end end - # Remove email of task notification success - only email on fail - # done.each do |project, tasks| - # logger.info "checking email for project #{project.id}" - # if project.student.receive_task_notifications - # logger.info "emailing task notification to #{project.student.name}" - # PortfolioEvidenceMailer.task_pdf_ready_message(project, tasks).deliver - # end - # end - errors.each do |project, tasks| - logger.info "checking email for project #{project.id}" - if project.student.receive_task_notifications - logger.info "emailing task notification to #{project.student.name}" + logger.debug "checking email for project #{project.id}" + next unless project.student.receive_task_notifications + + logger.info "emailing task notification to #{project.student.name}" + begin PortfolioEvidenceMailer.task_pdf_failed(project, tasks).deliver + rescue StandardError => e + logger.error "Failed to send task pdf failed email for project #{project.id}!\n#{e.message}" end end end diff --git a/app/models/project.rb b/app/models/project.rb index c0770cf875..aa4cb8995b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,27 +19,40 @@ class Project < ApplicationRecord belongs_to :unit, optional: false belongs_to :user, optional: false belongs_to :campus, optional: true + belongs_to :assessor, class_name: 'User', optional: true # has_one :user, through: :student has_many :tasks, dependent: :destroy # Destroying a project will also nuke all of its tasks - has_many :group_memberships, dependent: :destroy + has_many :tutorial_enrolments, dependent: :destroy + has_many :groups, -> { where('group_memberships.active = :value', value: true) }, through: :group_memberships has_many :task_engagements, through: :tasks has_many :comments, through: :tasks has_many :tutorial_enrolments, dependent: :destroy + has_many :session_activities, dependent: :destroy - has_many :learning_outcome_task_links, through: :tasks + has_many :staff_notes, dependent: :destroy + has_many :engagements, dependent: :destroy, inverse_of: :project + + before_create :record_target_grade_change + before_update :record_target_grade_change, + if: :will_save_change_to_target_grade? # Callbacks - methods called are private before_destroy :can_destroy? validates :grade_rationale, length: { maximum: 4095, allow_blank: true } + validates :spec_con_days, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :spec_con_days_changed? validate :tutorial_enrolment_same_campus, if: :will_save_change_to_enrolled? after_update :check_withdraw_from_groups, if: :saved_change_to_enrolled? after_update :update_task_stats, if: :saved_change_to_target_grade? # TODO: consider making this an async task! + after_update :revert_overdue_tasks, if: :saved_change_to_spec_con_days? + + # Don't create project if one already exists for user_id in this unit_id + validates :user_id, uniqueness: { scope: :unit_id } # # Permissions around project data @@ -50,7 +63,10 @@ def self.permissions :get, :make_submission, :get_submission, - :change + :change, + :reprocess_submission, + :get_engagements, + :comment_engagement ] # What can tutors do with projects? tutor_role_permissions = [ @@ -61,18 +77,36 @@ def self.permissions :get_submission, :change, :assess, - :change_campus + :change_campus, + :get_staff_note, + :create_staff_note, + :reprocess_submission, + :get_discussion_prompt, + :get_engagements, + :create_engagement, + :edit_engagement, + :comment_engagement ] + # What can admins do with projects? admin_role_permissions = [ :get, - :get_submission + :get_submission, + :reprocess_submission, + :get_discussion_prompt, + :get_engagements ] + # What can auditors do with projects? auditor_role_permissions = [ :get, - :get_submission + :get_submission, + :get_staff_note, + :reprocess_submission, + :get_discussion_prompt, + :get_engagements ] + # What can nil users do with projects? nil_role_permissions = [] @@ -144,10 +178,30 @@ def enrol_in(tutorial) else # there is an existing enrolment... tutorial_enrolment.tutorial = tutorial tutorial_enrolment.update!(tutorial_id: tutorial.id) + notify_tutorial_changed(tutorial) end tutorial_enrolment end + def notify_tutorial_changed(tutorial) + student = self.student + return if student.blank? + + NotificationService.notify( + user: student, + type: 'general', + event: 'tutorial_changed', + message: "You have been moved to tutorial #{tutorial.abbreviation} in #{unit.code}. It meets on #{tutorial.meeting_day} at #{tutorial.meeting_time}.", + link: "/projects/#{id}/dashboard" + ) + rescue StandardError => e + logger.error( + "Failed to raise tutorial_changed notification for project #{id}: #{e.message}" + ) + end + + private :notify_tutorial_changed + def enrolled_in?(tutorial) tutorial_enrolments.select { |e| e.tutorial_id == tutorial.id }.count > 0 || tutorial_enrolments.where(tutorial_id: tutorial.id).count > 0 end @@ -173,10 +227,6 @@ def log_details "#{id} - #{student.name} (#{student.username}) #{unit.code}" end - def task_outcome_alignments - learning_outcome_task_links - end - # # All "discuss" and "demonstrate" become complete # @@ -225,9 +275,7 @@ def tutor_for(task_definition) (tutorial.present? and tutorial.tutor.present?) ? tutorial.tutor : main_convenor_user end - def main_convenor_user - unit.main_convenor_user - end + delegate :main_convenor_user, to: :unit def user_role(user) if user == student then :student @@ -246,16 +294,7 @@ def active? # Get a string representation of the Target Grade # def target_grade_desc - case target_grade - when 1 - 'Credit' - when 2 - 'Distinction' - when 3 - 'High Distinction' - else - 'Pass' - end + unit.grade_label(target_grade) end def reference_date @@ -263,7 +302,7 @@ def reference_date end def task_details_for_shallow_serializer(user) - tasks + task_rows = tasks .joins(:task_status) .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')") .joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}") @@ -279,24 +318,38 @@ def task_details_for_shallow_serializer(user) 'completion_date', 'times_assessed', 'submission_date', 'grade', 'quality_pts', 'include_in_portfolio', 'grade' ) - .map do |r| - t = Task.find(r.id) - { - id: r.id, - status: TaskStatus.id_to_key(r.status_id), - task_definition_id: r.task_definition_id, - include_in_portfolio: r.include_in_portfolio, - times_assessed: r.times_assessed, - grade: r.grade, - quality_pts: r.quality_pts, - num_new_comments: r.number_unread, - similarity_flag: AuthorisationHelpers.authorise?(user, t, :view_plagiarism) ? r.similar_to_count > 0 : false, - extensions: t.extensions, - due_date: t.due_date, - submission_date: t.submission_date, - completion_date: t.completion_date - } - end + .to_a + + # The aggregate rows intentionally select only the fields used directly in + # the response. Reload their complete Task records in one batch so due-date + # and authorisation helpers can use preloaded associations instead of doing + # a Task.find (plus project/unit/task-definition lookups) for every task. + tasks_by_id = Task + .where(id: task_rows.map(&:id)) + .preload(:task_definition, project: %i[unit user]) + .index_by(&:id) + + task_rows.map do |r| + t = tasks_by_id.fetch(r.id) + { + id: r.id, + status: TaskStatus.id_to_key(r.status_id), + task_definition_id: r.task_definition_id, + include_in_portfolio: r.include_in_portfolio, + times_assessed: r.times_assessed, + grade: r.grade, + quality_pts: r.quality_pts, + num_new_comments: r.number_unread, + similarity_flag: AuthorisationHelpers.authorise?(user, t, :view_plagiarism) ? r.similar_to_count > 0 : false, + extensions: t.extensions, + scorm_extensions: t.scorm_extensions, + due_date: t.due_date, + submission_date: t.submission_date, + completion_date: t.completion_date, + target_start_date: t.target_start_date, + target_due_date: t.target_due_date + } + end end def assigned_tasks @@ -343,9 +396,7 @@ def top_tasks # overdue_tasks = task_states.select { |ts| to_target.call(ts) < Time.zone.today } - grades = ["Pass", "Credit", "Distinction", "High Distinction"] - - for i in GradeHelper::RANGE + for i in unit.grade_values graded_tasks = overdue_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -361,7 +412,7 @@ def top_tasks # soon_tasks = task_states.select { |ts| to_target.call(ts) >= Time.zone.today && to_target.call(ts) < Time.zone.today + 7.days } - for i in GradeHelper::RANGE + for i in unit.grade_values graded_tasks = soon_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -376,7 +427,7 @@ def top_tasks # ahead_tasks = task_states.select { |ts| to_target.call(ts) >= Time.zone.today + 7.days } - for i in GradeHelper::RANGE + for i in unit.grade_values graded_tasks = ahead_tasks.select { |ts| ts[:task_definition].target_grade == i } graded_tasks.each do |ts| @@ -490,7 +541,7 @@ def self.create_task_stats_from(total_task_counts, project_task_counts, target_g red_pct = ((project_task_counts.fail_count + project_task_counts.feedback_exceeded_count + project_task_counts.time_exceeded_count) / total_task_counts[target_grade]).signif(2) orange_pct = ((project_task_counts.redo_count + project_task_counts.need_help_count + project_task_counts.fix_and_resubmit_count) / total_task_counts[target_grade]).signif(2) - green_pct = ((project_task_counts.discuss_count + project_task_counts.demonstrate_count + project_task_counts.complete_count) / total_task_counts[target_grade]).signif(2) + green_pct = ((project_task_counts.discuss_count + project_task_counts.rediscuss_count + project_task_counts.demonstrate_count + project_task_counts.complete_count) / total_task_counts[target_grade]).signif(2) blue_pct = (project_task_counts.ready_for_feedback_count / total_task_counts[target_grade]).signif(2) grey_pct = (1 - red_pct - orange_pct - green_pct - blue_pct).signif(2) @@ -514,23 +565,29 @@ def self.create_task_stats_from(total_task_counts, project_task_counts, target_g } end + def revert_overdue_tasks + tasks.each do |task| + next if task.submission_date.blank? + + if task.submitted_before_due? && (task.task_status == TaskStatus.assess_in_portfolio || task.task_status == TaskStatus.time_exceeded) + task.update!(task_status: TaskStatus.ready_for_feedback) + task.add_status_comment(unit.main_convenor.user, TaskStatus.ready_for_feedback) + end + end + end + # Recalculate the task stats for the project, and store in the # task_stats field def update_task_stats # generate SQL for columns that count the number of tasks per grade - count_by_grade = (GradeHelper::RANGE).map { |grade_id| "SUM(CASE WHEN target_grade <= #{grade_id} THEN 1 ELSE 0 END) AS count_#{grade_id}" } + count_by_grade = unit.grade_values.map { |grade_id| "SUM(CASE WHEN target_grade <= #{grade_id} THEN 1 ELSE 0 END) AS count_#{grade_id}" } # Get the count of the total number of tasks less than each target grade task_count = unit .task_definitions .select(*count_by_grade) # create columns for each grade .map do |r| # map to array - [ - r['count_0'].to_f || 0.0, - r['count_1'].to_f || 0.0, - r['count_2'].to_f || 0.0, - r['count_3'].to_f || 0.0 - ] + unit.grade_values.index_with { |grade_id| r["count_#{grade_id}"].to_f || 0.0 } end .first # there is only one row returned... @@ -614,7 +671,7 @@ def status_for_task_definition(td) # task if the task does not exist for this project. # def task_for_task_definition(td) - logger.debug "Finding task #{td.abbreviation} for project #{log_details}" + logger.debug "Finding task #{td.abbreviation} for project_id=#{id}" result = tasks.where(task_definition: td).first if result.nil? begin @@ -641,29 +698,65 @@ def group_membership_for_groupset(gs) group_memberships.joins(:group).where('groups.group_set_id = :id', id: gs).first end - def export_task_alignment_to_csv - LearningOutcomeTaskLink.export_task_alignment_to_csv(unit, self) - end - def send_weekly_status_email(summary_stats, middle_of_unit) did_revert_to_pass = false - if middle_of_unit && should_revert_to_pass && !portfolio_exists? - self.target_grade = 0 - save - did_revert_to_pass = true + # TODO: refactor automatic target grade reset + # if middle_of_unit && should_revert_to_pass && !portfolio_exists? + # self.target_grade = 0 + # save + # did_revert_to_pass = true - summary_stats[:revert_count] = summary_stats[:revert_count] + 1 - summary_stats[:revert][main_convenor_user] << self - end + # summary_stats[:revert_count] = summary_stats[:revert_count] + 1 + # summary_stats[:revert][main_convenor_user] << self + # end return unless student.receive_feedback_notifications return if portfolio_exists? && !middle_of_unit - NotificationsMailer.weekly_student_summary(self, summary_stats, did_revert_to_pass).deliver_now + begin + NotificationsMailer.weekly_student_summary(self, summary_stats, did_revert_to_pass).deliver_now + rescue StandardError => e + logger.error "Failed to send weekly status email for project #{id}!\n#{e.message}" + end + end + + def archive_submissions(out) + out.puts " - Archiving submissions for project #{id}" + tasks.each(&:archive_submission) + + FileUtils.rm_f(portfolio_path) if portfolio_available + end + + def add_staff_note(user, text, reply_to_id = nil) + text = text.strip + return nil if user.nil? || text.nil? || text.empty? + + ln = staff_notes.last + + # don't add if duplicate note + return if ln && ln.user == user && ln.note == text + + note = StaffNote.create + note.note = text + note.user = user + note.project = self + note.reply_to_id = reply_to_id + note.save! + note + end + + # TODO: env var for escalation attempts -- per unit setting? max_feedback_escalation_attempts + + def escalation_attempts_remaining + 3 - ModeratedTask.where(task: tasks, moderation_type: :escalation, outcome: [nil, 'upheld']).count end private + def record_target_grade_change + self.target_grade_changed_at = Time.current + end + def can_destroy? return true if tutorial_enrolments.count == 0 @@ -679,7 +772,7 @@ def check_withdraw_from_groups group_memberships.each do |gm| next unless gm.active - if !gm.valid? || gm.group.beyond_capacity? + if gm.invalid? || gm.group.beyond_capacity? gm.update(active: false) end end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..f235c1a613 --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,91 @@ +# One browser registered to receive web push notifications. +# +# The endpoint is the URL the push service gave that browser. It identifies the +# browser, not the person, so it is unique across the whole table: if the same +# browser signs in as a different user the registration moves across instead of +# being duplicated. PushSubscriptionsApi does that move. +# +# The endpoint arrives from the client and the api later makes an outbound POST +# to it, so it is not free text. It has to be an https URL belonging to a push +# service we recognise, or a signed in user could point the api at an internal +# host and use it to make requests on their behalf. See PUSH_SERVICE_HOSTS. +class PushSubscription < ApplicationRecord + # Exact hosts. One per push service. + # + # fcm.googleapis.com Chrome, Opera, Brave + # android.googleapis.com older Chrome on Android + # updates.push.services.mozilla.com Firefox + # + # A verified Edge 151 subscription on macOS used WNS even though Edge is + # Chromium. Endpoint selection can vary by platform or release, so these + # labels are observations rather than a browser-detection contract. + PUSH_SERVICE_HOSTS = %w[ + fcm.googleapis.com + android.googleapis.com + updates.push.services.mozilla.com + ].freeze + + # Suffixes, for the services that shard across per-region subdomains. Matched + # with a leading dot so "evil-notify.windows.com" cannot pass as a subdomain + # of "notify.windows.com". + # + # *.notify.windows.com WNS, current Edge observed + # *.push.services.microsoft.com WNS, current + # *.push.apple.com Safari, iOS 16.4+ + # + # Not legacy. Edge 151 on macOS subscribed through + # wns2-bl2p.notify.windows.com when this was checked on 27 Aug 2026. Do not + # infer a browser only from an endpoint host; the testing guide records the + # scoped observation and the allow-list accepts the supported services. + PUSH_SERVICE_HOST_SUFFIXES = %w[ + .notify.windows.com + .push.services.microsoft.com + .push.apple.com + ].freeze + + belongs_to :user + + validates :endpoint, presence: true, uniqueness: true, length: { maximum: 500 } + validates :p256dh, presence: true, length: { maximum: 255 } + validates :auth, presence: true, length: { maximum: 255 } + + validate :endpoint_is_a_known_push_service + + # True when this endpoint is one we are willing to send to. + # + # Also called at delivery time, because rows written before this validation + # existed were never checked. Keep it a class method for that reason. + def self.push_service_endpoint?(endpoint) + return false if endpoint.blank? + + uri = URI.parse(endpoint.to_s) + + # https only. http would send the encrypted payload in the clear and is not + # something any real push service offers. + return false unless uri.is_a?(URI::HTTPS) + + # user:password@host is a redirect trick, and a non standard port is a sign + # somebody is aiming this somewhere it should not go. No push service uses + # either. + return false if uri.userinfo.present? + return false unless uri.port == 443 + + host = uri.host.to_s.downcase + return false if host.blank? + + PUSH_SERVICE_HOSTS.include?(host) || + PUSH_SERVICE_HOST_SUFFIXES.any? { |suffix| host.end_with?(suffix) } + rescue URI::InvalidURIError + false + end + + private + + def endpoint_is_a_known_push_service + return if endpoint.blank? # presence validation already covers this + + return if self.class.push_service_endpoint?(endpoint) + + errors.add(:endpoint, 'must be an https URL belonging to a recognised push service') + end +end diff --git a/app/models/session_activity.rb b/app/models/session_activity.rb new file mode 100644 index 0000000000..18e6a55a65 --- /dev/null +++ b/app/models/session_activity.rb @@ -0,0 +1,9 @@ +class SessionActivity < ApplicationRecord + belongs_to :marking_session + belongs_to :project, optional: true + belongs_to :task, optional: true + belongs_to :task_definition, optional: true + + VALID_ACTIONS = %w[inbox GET PUT assessing add-comment edit-comment get-comments delete-comment get-comment-attachment mark-comment-unread get-submission-details get-submission-files].freeze + validates :action, presence: true, inclusion: { in: VALID_ACTIONS } +end diff --git a/app/models/similarity/jplag_task_similarity.rb b/app/models/similarity/jplag_task_similarity.rb new file mode 100644 index 0000000000..ec39b7fe2a --- /dev/null +++ b/app/models/similarity/jplag_task_similarity.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class JplagTaskSimilarity < TaskSimilarity + belongs_to :other_task, class_name: 'Task' + + def file_path + self.task.task_definition.jplag_report + end + + # + # Ensure file is also deleted + # + before_destroy do |similarity| + if similarity.task.group_task? + other_tasks = similarity.task.group_submission.tasks.reject { |t| t.id == similarity.task.id } + + other_tasks_using_file = other_tasks.select { |t| t.task_similarities.where(other_task_id: similarity.other_task_id).count > 0 } + FileHelper.delete_plagarism_html(similarity) unless other_tasks_using_file.count > 0 + else # individual... so can delete file + FileHelper.delete_plagarism_html(similarity) + end + rescue StandardError => e + logger.error "Error deleting match link for task #{similarity.task.id}. Error: #{e.message}" + end + + after_destroy do |similarity| + similarity.other_similarity&.destroy + end + + def other_similarity + JplagTaskSimilarity.where(task_id: other_task.id, other_task_id: task.id).first unless other_task.nil? + end + + def other_student + other_task&.student + end + + def other_tutor + other_task&.project&.tutor_for(other_task.task_definition) + end + + def other_tutorial + tute = other_task.project.tutorial_for(other_task.task_definition) unless other_task.nil? + tute.nil? ? 'None' : tute.abbreviation + end + + def ready_for_viewer? + self.task.task_definition.has_jplag_report? + end +end diff --git a/app/models/similarity/task_definition_similarity_module.rb b/app/models/similarity/task_definition_similarity_module.rb index 5eb5ed8021..d2088cf2c9 100644 --- a/app/models/similarity/task_definition_similarity_module.rb +++ b/app/models/similarity/task_definition_similarity_module.rb @@ -6,6 +6,10 @@ def moss_similarities? MossTaskSimilarity.joins(:task).where('tasks.task_definition_id' => id).count > 0 end + def jplag_similarities? + JplagTaskSimilarity.joins(:task).where('tasks.task_definition_id' => id).count > 0 + end + def clear_related_plagiarism # delete old plagiarism links logger.info "Deleting old links for task definition #{id} - #{abbreviation}" @@ -13,5 +17,10 @@ def clear_related_plagiarism pair = MossTaskSimilarity.find_by(id: plnk.id) pair.destroy! if pair.present? end + + JplagTaskSimilarity.joins(:task).where('tasks.task_definition_id' => id).find_each do |plnk| + pair = JplagTaskSimilarity.find_by(id: plnk.id) + pair.destroy! if pair.present? + end end end diff --git a/app/models/similarity/unit_similarity_module.rb b/app/models/similarity/unit_similarity_module.rb index c69897f78f..819dc288c6 100644 --- a/app/models/similarity/unit_similarity_module.rb +++ b/app/models/similarity/unit_similarity_module.rb @@ -25,9 +25,9 @@ def check_moss_similarity(force: false) logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" task_definitions.each do |td| - next if td.moss_language.nil? || td.upload_requirements.nil? || td.upload_requirements.select { |upreq| upreq['type'] == 'code' && upreq['tii_check'] }.empty? + next if td.similarity_language.nil? || td.upload_requirements.nil? || td.upload_requirements.select { |upreq| upreq['type'] == 'code' && upreq['tii_check'] }.empty? - type_data = td.moss_language.split + type_data = td.similarity_language.split next if type_data.nil? || (type_data.length != 2) || (type_data[0] != 'moss') # Is there anything to check? @@ -51,7 +51,7 @@ def check_moss_similarity(force: false) logger.debug 'Contacting MOSS for new checks' # Create the MossRuby object - moss_key = Doubtfire::Application.secrets.secret_key_moss + moss_key = Doubtfire::Application.credentials.secret_key_moss raise "No moss key set. Check ENV['DF_SECRET_KEY_MOSS'] first." if moss_key.nil? moss = MossRuby.new(moss_key) @@ -98,8 +98,76 @@ def check_moss_similarity(force: false) self end - def update_plagiarism_stats - moss_key = Doubtfire::Application.secrets.secret_key_moss + # Pass tasks on to plagarism detection software and setup links between students + def check_jplag_similarity(force: false) + # Get each task... + return unless active + + # need pwd to restore after cding into submission folder (so the files do not have full path) + pwd = FileUtils.pwd + completed_all_checks = true + + # making temp directory for unit - jplag + root_work_dir = Rails.root.join("tmp", "jplag", "#{code}-#{id}") + + begin + logger.info "Checking plagiarsm for unit #{code} - #{name} (id=#{id})" + + task_definitions.each do |td| + next if td.similarity_language.nil? || td.upload_requirements.nil? || td.upload_requirements.select { |upreq| upreq['type'] == 'code' && upreq['tii_check'] }.empty? + + # Is there anything to check? + logger.debug "Checking plagiarism for #{td.name} (id=#{td.id})" + tasks = tasks_for_definition(td) + tasks_with_files = tasks.select(&:has_pdf) + + # Skip if no files changed + next unless tasks_with_files.count > 1 && + ( + # NOTE: `last_plagarism_scan` is currently tracked for each Unit, not each Task Definition + tasks.where('tasks.file_uploaded_at > ?', last_plagarism_scan).select(&:has_pdf).count > 0 || + td.updated_at > last_plagarism_scan || + force + ) + + # Ensure work directory for the unit is created + FileUtils.mkdir_p(root_work_dir) + + # Init work directory for each task definition + tasks_dir = root_work_dir.join(td.id.to_s) + FileUtils.mkdir_p(tasks_dir) + + # There are new tasks, check these with JPLAG + report_path = FileHelper.task_jplag_report_path(self, td) + begin + run_jplag_on_done_files(td, tasks_dir, tasks_with_files, report_path) + warn_pct = td.plagiarism_warn_pct || 50 + logger.debug "Warn PCT: #{warn_pct}" + + # Remove any existing plagiarism links that are below the threshold, in case it has been updated since the last analysis + JplagTaskSimilarity.joins(:task) + .where("pct < ? AND tasks.task_definition_id = ?", warn_pct, td.id) + .delete_all + + process_jplag_plagiarism_report(report_path, warn_pct, td.group_set) + rescue StandardError => e + completed_all_checks = false + logger.error "Failed to check JPlag similarity for task #{td.name} (id=#{td.id}). Error: #{e.message}" + end + end + if completed_all_checks + self.last_plagarism_scan = Time.zone.now + save! + end + ensure + FileUtils.chdir(pwd) if FileUtils.pwd != pwd + end + + self + end + + def update_moss_plagiarism_stats + moss_key = Doubtfire::Application.credentials.secret_key_moss raise "No moss key set. Check ENV['DF_SECRET_KEY_MOSS'] first." if moss_key.nil? moss = MossRuby.new(moss_key) @@ -135,12 +203,12 @@ def update_plagiarism_stats g1_tasks.each do |gt1| g2_tasks.each do |gt2| - create_plagiarism_link(gt1, gt2, match, warn_pct) + create_moss_plagiarism_link(gt1, gt2, match, warn_pct) end end else # just link the individuals... - create_plagiarism_link(t1, t2, match, warn_pct) + create_moss_plagiarism_link(t1, t2, match, warn_pct) end end end @@ -153,7 +221,217 @@ def update_plagiarism_stats private - def create_plagiarism_link(task1, task2, match, warn_pct) + # Extract all done files related to a task definition matching a pattern into a given directory. + # Returns an array of files + # def add_done_files_for_plagiarism_check_of(task_definition, tmp_path, tasks_with_files) + # # get each code file for each task + # task_definition.upload_requirements.each_with_index do |upreq, idx| + # # only check code files marked for similarity checks + # next unless upreq['type'] == 'code' && upreq['tii_check'] + # pattern = task_definition.glob_for_upload_requirement(idx) + # tasks_with_files.each do |t| + # t.extract_file_from_done(tmp_path, pattern, ->(_task, to_path, name) { File.join(to_path.to_s, t.student.username.to_s, name.to_s) }) + # end + # end + # self + # end + + # JPLAG Function - extracts "done" files for each task and packages them into a directory for JPLAG to run on + def run_jplag_on_done_files(task_definition, tasks_dir, tasks_with_files, report_path) + similarity_pct = task_definition.plagiarism_warn_pct + return if similarity_pct.nil? + + # Check if the directory exists and create it if it doesn't + results_dir = File.dirname(report_path) + system("docker exec -i jplag sh -c 'if [ ! -d \"#{results_dir}\" ]; then mkdir -p \"#{results_dir}\"; fi'") || raise('Failed to create JPlag results directory') + + # Remove existing result file if it exists + system("docker exec -i jplag sh -c 'if [ -f \"#{report_path}\" ]; then rm \"#{report_path}\"; fi'") || raise('Failed to remove previous JPlag report') + + # Extract task resources for base code + use_base_code = false + if task_definition.has_task_resources? && task_definition.use_resources_for_jplag_base_code + use_base_code = true + path = task_definition.task_resources + + Zip::File.open(path) do |zip_file| + zip_file.each do |entry| + dest = File.join(tasks_dir, 'base', entry.name) + FileUtils.mkdir_p(File.dirname(dest)) + entry.extract(dest) { true } + end + end + end + + # get each code file for each task + task_definition.upload_requirements.each_with_index do |upreq, idx| + # only check code files marked for similarity checks + next unless upreq['type'] == 'code' && upreq['tii_check'] + + pattern = task_definition.glob_for_upload_requirement(idx) + + # Name to save submission file (used in JPlag report) + file_name = task_definition.upload_requirements[idx]['name'].to_s + file_name = file_name.squish.tr(" ", "_").tr("-", "_").camelize(:upper) + + tasks_with_files.each do |t| + # "name" is {taskId}/{filename}, so it will create a subdir with the task id, but we use this later when processing the report + t.extract_file_from_done(tasks_dir, pattern, lambda { |task, to_path, name| + names = name.split("/") + if names.count >= 2 + # "name" will include the upload req name, eg. "401/000-code.cpp" + file_extension = File.extname(names[1]) + + # Strip out file_extension from the upload requirement name since we'll append it manually + file_name = file_name.gsub(file_extension, "") + + File.join(to_path.to_s, 'submissions', t.student.username.to_s, task.id.to_s, "#{idx}-#{file_name}#{file_extension}") + else + # "name" is simply the directory of the task, eg. "401/" + File.join(to_path.to_s, 'submissions', t.student.username.to_s, name.to_s) + end + }) + end + end + + logger.info "Starting JPLAG container to run on #{tasks_dir}" + root_dir = Rails.root.to_s + tasks_dir_split = tasks_dir.to_s.split(root_dir)[1] + file_lang = task_definition.similarity_language.to_s + + # Convert pct to decimal + similarity_threshold = similarity_pct.to_f / 100 + + min_tokens = Doubtfire::Application.config.jplag_min_tokens.to_i + # If empty, let JPlag set the default per-language + min_token_string = min_tokens <= 0 ? "" : "--min-tokens=#{min_tokens}" + + base_code_string = use_base_code ? "--base-code=#{tasks_dir_split}/base" : "" + + skip_cluster_check = Doubtfire::Application.config.jplag_skip_cluster_check + skip_cluster_string = skip_cluster_check ? '--cluster-skip' : '' + + max_shown_comparisons = Doubtfire::Application.config.jplag_max_shown_comparisons + max_shown_comparisons = 2500 if max_shown_comparisons.nil? + + # Run JPLAG on the extracted files. JPlag container should already be in the /jplag/ workdir. + docker_command = [ + "docker exec -i jplag", + "java -jar jplag-jar-with-dependencies.jar", + "--skip-version-check", + "#{tasks_dir_split}/submissions", + base_code_string, + "-l #{file_lang}", + "--similarity-threshold=#{similarity_threshold}", + "--shown-comparisons=#{max_shown_comparisons}", + min_token_string, + skip_cluster_string, + "-M RUN", + "-r #{report_path.delete_suffix('.jplag')}", + "--overwrite" + ].join(" ") + + logger.debug "Executing command: #{docker_command}" + system(docker_command) + + # Delete the extracted code files from tmp + tmp_dir = Rails.root.join("tmp/jplag") + logger.info "Deleting files in: #{tmp_dir}" + logger.info "Files to delete: #{Dir.glob("#{tmp_dir}/*")}" + FileUtils.rm_rf(Dir.glob("#{tmp_dir}/*")) + self + end + + def process_jplag_plagiarism_report(path, warn_pct, is_group) + # Extract top comparisons json from report zip + # Note: overview.json has been replaced by topComparisons.json since JPlag v6.2.0 + Zip::File.open(path) do |zip_file| + top_comparisons_file = zip_file.find_entry('topComparisons.json') + raise "topComparisons.json not found in jplag report" if top_comparisons_file.nil? + + # Read the contents of topComparisons.json + content = top_comparisons_file.get_input_stream.read + # Parse the JSON into a Ruby hash + data = JSON.parse(content) + + # Iterate over the top comparisons array and collect the required fields + top_comparisons = data.map do |comparison| + { + first_submission: comparison['firstSubmission'], + second_submission: comparison['secondSubmission'], + max_similarity: comparison['similarities']['MAX'] * 100 + } + end + + # Save the results to the database + top_comparisons.each do |comparison| + task1_id = nil + task2_id = nil + zip_file.each do |entry| + if entry.name =~ %r{\Afiles/#{comparison[:first_submission]}/} + task1_id = entry.name.split('/')[2].to_i + elsif entry.name =~ %r{\Afiles/#{comparison[:second_submission]}/} + task2_id = entry.name.split('/')[2].to_i + end + end + first_submission = Task.find(task1_id) if task1_id + second_submission = Task.find(task2_id) if task2_id + + if first_submission.nil? || second_submission.nil? + logger.error "Could not find tasks #{comparison[:first_submission]} or #{comparison[:second_submission]} for plagiarism stats check!" + next + end + + if is_group # its a group task + g1_tasks = first_submission.group_submission.tasks + g2_tasks = second_submission.group_submission.tasks + g1_tasks.each do |gt1| + g2_tasks.each do |gt2| + next if gt1.student == gt2.student + create_jplag_plagiarism_link(gt1, gt2, warn_pct, comparison[:max_similarity]) + end + end + else # just link the individuals... + create_jplag_plagiarism_link(first_submission, second_submission, warn_pct, comparison[:max_similarity]) + end + end + + self + end + end + + def create_jplag_plagiarism_link(task1, task2, warn_pct, max_similarity) + # Create a new plagiarism link between the two tasks + plk1 = JplagTaskSimilarity.where(task_id: task1.id, other_task_id: task2.id).first + plk2 = JplagTaskSimilarity.where(task_id: task2.id, other_task_id: task1.id).first + if plk1.nil? || plk2.nil? + # Delete old links between tasks + plk1&.destroy ## will delete its pair + plk2&.destroy + plk1 = JplagTaskSimilarity.create do |plm| + plm.task = task1 + plm.other_task = task2 + plm.pct = max_similarity + plm.flagged = plm.pct >= warn_pct + end + plk2 = JplagTaskSimilarity.create do |plm| + plm.task = task2 + plm.other_task = task1 + plm.pct = max_similarity + plm.flagged = plm.pct >= warn_pct + end + else + # Flag is larger than warn pct and larger than previous pct + plk1.flagged = max_similarity >= warn_pct && max_similarity >= plk1.pct + plk2.flagged = max_similarity >= warn_pct && max_similarity >= plk2.pct + plk1.pct = max_similarity + plk2.pct = max_similarity + end + plk1.save! + plk2.save! + end + + def create_moss_plagiarism_link(task1, task2, match, warn_pct) plk1 = MossTaskSimilarity.where(task_id: task1.id, other_task_id: task2.id).first plk2 = MossTaskSimilarity.where(task_id: task2.id, other_task_id: task1.id).first @@ -201,7 +479,7 @@ def create_plagiarism_link(task1, task2, match, warn_pct) # Returns an array of files # def add_done_files_for_plagiarism_check_of(task_definition, tmp_path, to_check, tasks_with_files) - type_data = task_definition.moss_language.split + type_data = task_definition.similarity_language.split return if type_data.nil? || (type_data.length != 2) || (type_data[0] != 'moss') # get each code file for each task diff --git a/app/models/staff_note.rb b/app/models/staff_note.rb new file mode 100644 index 0000000000..b0a84e86a9 --- /dev/null +++ b/app/models/staff_note.rb @@ -0,0 +1,7 @@ +class StaffNote < ApplicationRecord + belongs_to :project + belongs_to :user + + # belongs_to :reply_to, class_name: 'StaffNote', optional: true, inverse_of: :replies + # has_many :replies, class_name: 'StaffNote', dependent: :restrict_with_exception, inverse_of: :reply_to +end diff --git a/app/models/submission_history.rb b/app/models/submission_history.rb new file mode 100644 index 0000000000..745925ac99 --- /dev/null +++ b/app/models/submission_history.rb @@ -0,0 +1,172 @@ +require 'zip' +require 'stringio' + +class SubmissionHistory < ApplicationRecord + belongs_to :task, optional: false + has_one :overseer_assessment, dependent: :destroy + + validates :submission_timestamp, presence: true, uniqueness: { scope: :task_id } + + after_destroy :delete_associated_files + + def self.enabled_requirements(task) + task.upload_requirements.each_index.select do |index| + task.upload_requirements[index]['submission_history'] == true + end + end + + def self.create_archive!(task, submission_timestamp) + if exists?(task: task, submission_timestamp: submission_timestamp.to_s) + raise ActiveRecord::RecordNotUnique, 'Submission history already exists for this task and timestamp' + end + + enabled_indexes = enabled_requirements(task) + raise 'No upload requirements are enabled for submission history' if enabled_indexes.empty? + + source_path = FileHelper.zip_file_path_for_done_task(task) + raise "Submission file not found: #{source_path}" unless File.exist?(source_path) + + history = new(task: task, submission_timestamp: submission_timestamp.to_s) + FileUtils.mkdir_p(history.output_path) + + temporary_history = "#{history.archive_file_name}.tmp-#{SecureRandom.hex(8)}" + copied_files = 0 + archive_updated = false + begin + Zip::File.open(temporary_history, create: true) do |destination| + copy_archive_entries(history.archive_file_name, destination) if File.exist?(history.archive_file_name) + + Zip::File.open(source_path) do |source| + source.each do |entry| + next if entry.name_is_directory? + + file_name = entry.name.split('/').last + next unless file_name&.match?(/^\d{3}-(?:document|code|image|zip|archive)/) + next unless enabled_indexes.include?(file_name.to_i) + + destination.get_output_stream(File.join(history.entry_prefix, entry.name)) do |output| + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + copied_files += 1 + end + end + end + + raise 'No selected submission files were found in the completed submission' if copied_files.zero? + + FileUtils.mv(temporary_history, history.archive_file_name) + archive_updated = true + system('chmod', 'o+w', history.output_path) + history.save! + history + ensure + FileUtils.rm_f(temporary_history) + history.delete_associated_files if archive_updated && !history.persisted? + if !history.persisted? && Dir.exist?(history.output_path) && Dir.empty?(history.output_path) + FileUtils.rm_rf(history.output_path) + end + end + end + + def output_path + FileHelper.task_submission_identifier_path(:done, task) + end + + def archive_file_name + File.join(output_path, 'history.zip') + end + + def entry_prefix + "#{FileHelper.sanitized_path(submission_timestamp.to_s)}/" + end + + def submission_entry_prefix + File.join(entry_prefix, task.id.to_s, '/') + end + + # Kept for Overseer compatibility; submissions are entries within this archive. + def submission_zip_file_name + archive_file_name + end + + def submission_zip_data + buffer = Zip::OutputStream.write_buffer do |output| + Zip::File.open(archive_file_name) do |archive| + submission_entries(archive).each do |entry| + output.put_next_entry(entry.name.delete_prefix(entry_prefix)) + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + end + + buffer.string + end + + def has_submission_files? # rubocop:disable Naming/PredicateName + return false unless File.exist?(archive_file_name) + + Zip::File.open(archive_file_name) { |archive| submission_entries(archive).any? } + rescue Zip::Error + false + end + + def delete_associated_files + return unless File.exist?(archive_file_name) + + temporary_history = "#{archive_file_name}.tmp-#{SecureRandom.hex(8)}" + + Zip::File.open(temporary_history, create: true) do |destination| + self.class.copy_archive_entries(archive_file_name, destination, excluding_prefix: entry_prefix) + end + + if Zip::File.open(temporary_history) { |archive| archive.entries.empty? } + FileUtils.rm_f(archive_file_name) + FileUtils.rm_f(temporary_history) + FileUtils.rm_rf(output_path) if Dir.empty?(output_path) + else + FileUtils.mv(temporary_history, archive_file_name) + end + ensure + FileUtils.rm_f(temporary_history) if temporary_history + end + + def submission_entries(archive) + archive.entries.reject(&:name_is_directory?).select do |entry| + entry.name.start_with?(submission_entry_prefix) + end + end + + def self.copy_archive_entries(source_path, destination, excluding_prefix: nil) + Zip::File.open(source_path) do |source| + source.each do |entry| + next if excluding_prefix && entry.name.start_with?(excluding_prefix) + + if entry.name_is_directory? + destination.mkdir(entry.name) unless destination.find_entry(entry.name) + else + destination.get_output_stream(entry.name) do |output| + entry.get_input_stream { |input| IO.copy_stream(input, output) } + end + end + end + end + end + + def self.pending_marker_path(task) + File.join(FileHelper.task_submission_identifier_path(:pending, task), 'submission-history') + end + + def self.mark_pending(task) + marker_path = pending_marker_path(task) + FileUtils.mkdir_p(File.dirname(marker_path)) + FileUtils.touch(marker_path) + end + + def self.clear_pending(task) + FileUtils.rm_f(pending_marker_path(task)) + end + + def self.pending?(task) + File.exist?(pending_marker_path(task)) + end +end diff --git a/app/models/task.rb b/app/models/task.rb index e75815f90b..578be03a76 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'date' class Task < ApplicationRecord @@ -18,7 +20,9 @@ def self.permissions :start_discussion, :get_discussion, :make_discussion_reply, + :request_scorm_extension, # :request_extension -- depends on settings in unit. See specific_permission_hash method + # :make_scorm_attempt -- depends on task def settings. See specific_permission_hash method ] # What can tutors do with tasks? tutor_role_permissions = [ @@ -34,7 +38,9 @@ def self.permissions :delete_discussion, :get_discussion, :assess_extension, - :request_extension + :assess_scorm_extension, + :request_extension, + :request_scorm_extension ] # What can convenors do with tasks? convenor_role_permissions = [ @@ -47,13 +53,16 @@ def self.permissions :delete_plagiarism, :get_discussion, :assess_extension, - :request_extension + :assess_scorm_extension, + :request_extension, + :request_scorm_extension ] # What can admins do with tasks? admin_role_permissions = [ :get, :get_submission, :view_plagiarism, + :delete_plagiarism, :get_discussion ] # What can auditors do with tasks? @@ -94,12 +103,15 @@ def role_for(user) end # Used to adjust the request extension permission in units that do not - # allow students to request extensions + # allow students to request extensions and the make scorm attempt permission def specific_permission_hash(role, perm_hash, _other) result = perm_hash[role] unless perm_hash.nil? if result && role == :student && unit.allow_student_extension_requests result << :request_extension end + if result && role == :student && task_definition.scorm_enabled + result << :make_scorm_attempt + end result end @@ -113,16 +125,20 @@ def specific_permission_hash(role, perm_hash, _other) belongs_to :group_submission, optional: true has_one :unit, through: :project + has_one :moderated_task, dependent: :destroy + has_one :overflow_task_claim, dependent: :destroy has_many :comments, class_name: 'TaskComment', dependent: :destroy, inverse_of: :task has_many :task_similarities, class_name: 'TaskSimilarity', dependent: :destroy, inverse_of: :task - has_many :reverse_task_similarities, class_name: 'MossTaskSimilarity', dependent: :destroy, inverse_of: :other_task, foreign_key: 'other_task_id' - has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes - has_many :learning_outcomes, through: :learning_outcome_task_links + has_many :reverse_jplag_similarities, class_name: 'JplagTaskSimilarity', dependent: :destroy, inverse_of: :other_task, foreign_key: 'other_task_id' + has_many :reverse_moss_similarities, class_name: 'MossTaskSimilarity', dependent: :destroy, inverse_of: :other_task, foreign_key: 'other_task_id' has_many :task_engagements, dependent: :destroy has_many :task_submissions, dependent: :destroy + has_many :submission_histories, dependent: :destroy has_many :overseer_assessments, dependent: :destroy has_many :tii_submissions, dependent: :destroy + has_many :test_attempts, dependent: :destroy + has_many :session_activities, dependent: :destroy delegate :unit, to: :project delegate :student, to: :project @@ -138,16 +154,45 @@ def specific_permission_hash(role, perm_hash, _other) validate :must_have_quality_pts, if: :for_definition_with_quality? - validate :extensions_must_end_with_due_date, if: :has_requested_extension? + validate :extensions_must_end_with_due_date, if: -> { has_requested_extension? && !unit.allow_flexible_dates } + + validate :prevent_complete_if_assess_in_portfolio_only + validate :prevent_complete_if_requires_discussion + validate :prevent_feedback_exceeed_if_assess_in_portfolio_enabled + validate :prevent_time_exceeed_if_assess_in_portfolio_enabled include TaskTiiModule + def prevent_complete_if_assess_in_portfolio_only + if task_definition&.assess_in_portfolio_only && task_status == TaskStatus.complete + errors.add(:task_status, "cannot be 'complete' if task is to be assessed in portfolio only") + end + end + + def prevent_complete_if_requires_discussion + if task_definition&.requires_discussion && task_status == TaskStatus.complete && !has_discussed_in_class_comment? + errors.add(:task_status, "cannot be 'complete' until task has been discussed in class") + end + end + + def prevent_feedback_exceeed_if_assess_in_portfolio_enabled + if (unit.mark_late_submissions_as_assess_in_portfolio || task_definition.assess_in_portfolio_only) && task_status == TaskStatus.feedback_exceeded + errors.add(:task_status, "cannot be 'feedback_exceeded' if unit 'has tasks assessed in portolio' enabled") + end + end + + def prevent_time_exceeed_if_assess_in_portfolio_enabled + if (unit.mark_late_submissions_as_assess_in_portfolio || task_definition.assess_in_portfolio_only) && task_status == TaskStatus.time_exceeded + errors.add(:task_status, "cannot be 'time_exceeded' if unit 'has tasks assessed in portolio' enabled") + end + end + def for_definition_with_quality? task_definition.has_stars? end def has_requested_extension? - extensions > 0 && will_save_change_to_extensions? && extensions > extensions_in_database + extensions != 0 && will_save_change_to_extensions? && extensions != extensions_in_database end def must_have_quality_pts @@ -159,7 +204,7 @@ def must_have_quality_pts # Ensure that extensions do not exceed the defined due date def extensions_must_end_with_due_date # First check the raw extension date - but allow it to be up to a week later in case due date and target date are on different days - if raw_extension_date.to_date - 7.days >= task_definition.due_date.to_date + if raw_extension_date - 7.days >= max_date_with_spec_con_days errors.add(:extensions, "have exceeded deadline for task. Work must be submitted within current timeframe. Work submitted after current due date will be assessed in the portfolio") end end @@ -227,7 +272,7 @@ def self.for_user(user) Task.joins(:project).where('projects.user_id = ?', user.id) end - def processing_pdf? + def folder_exists_in_new? if group_task? && group_submission File.exist? File.join(FileHelper.student_work_dir(:new), group_submission.submitter_task.id.to_s) else @@ -235,15 +280,32 @@ def processing_pdf? end end + def folder_exists_in_process? + if group_task? && group_submission + File.exist? File.join(FileHelper.student_work_dir(:in_process), group_submission.submitter_task.id.to_s) + else + File.exist? File.join(FileHelper.student_work_dir(:in_process), id.to_s) + end + end + + def processing_pdf? + folder_exists_in_new? || folder_exists_in_process? + end + # Get the raw extension date - with extensions representing weeks def raw_extension_date - target_date + extensions.weeks + target_date.to_date + extensions.weeks + end + + def max_date_with_spec_con_days + task_definition.due_date.to_date + project.spec_con_days.days end # Get the adjusted extension date, which ensures it is never past the due date def extension_date result = raw_extension_date - return task_definition.due_date if result > task_definition.due_date + max_date = max_date_with_spec_con_days + return max_date if result > max_date && !unit.allow_flexible_dates return result end @@ -251,7 +313,7 @@ def extension_date # The student can apply for an extension if the current extension date is # before the task's due date def can_apply_for_extension? - raw_extension_date.to_date < task_definition.due_date.to_date + raw_extension_date < max_date_with_spec_con_days end def tutor @@ -286,8 +348,8 @@ def apply_for_extension(user, text, weeks) end def weeks_can_extend - deadline = task_definition.due_date.to_date - current_due = raw_extension_date.to_date + deadline = max_date_with_spec_con_days + current_due = raw_extension_date diff = deadline - current_due (diff.to_f / 7).ceil @@ -295,6 +357,9 @@ def weeks_can_extend # Add an extension to the task def grant_extension(by_user, weeks) + # Only used when extensions are allowed - not if the student manages the dates + return if unit.allow_flexible_dates + weeks_to_extend = [weeks, weeks_can_extend].min return false unless weeks_to_extend > 0 @@ -311,22 +376,93 @@ def grant_extension(by_user, weeks) end end + # Applying for a scorm extension will create a scorm extension comment + def apply_for_scorm_extension(user, text) + extension = ScormExtensionComment.create + extension.task = self + extension.user = user + extension.content_type = :scorm_extension + extension.comment = text + extension.recipient = unit.main_convenor_user + extension.save! + + # Check and apply those requested by staff + if role_for(user) == :tutor + extension.assess_scorm_extension user, true + end + + extension + end + + # Add a scorm extension to the task + def grant_scorm_extension(by_user) + if update(scorm_extensions: self.scorm_extensions + task_definition.scorm_attempt_limit) + return true + else + return false + end + end + def due_date return target_date if extensions == 0 return extension_date end + def local_due_date + if unit.allow_flexible_dates + return target_due_date if target_due_date.present? + + grade_target_date = task_definition.grade_target_date(project.target_grade) + return grade_target_date if grade_target_date.present? + end + + due_date + end + + def local_start_date + if unit.allow_flexible_dates + return target_start_date if target_start_date.present? + + grade_start_date = task_definition.grade_start_date(project.target_grade) + return grade_start_date if grade_start_date.present? + end + + return task_definition.start_date + extensions.weeks if extensions.negative? + + task_definition.start_date + end + + def days_awaiting_feedback(now_time = Time.zone.now) + return 0 if submission_date.blank? + + submission_time = submission_date.to_f + current_time = now_time.to_f + return 0 if current_time <= submission_time + + teaching_breaks = unit&.teaching_period&.breaks || [] + paused_seconds = break_overlap_seconds(submission_time, current_time, teaching_breaks) + + ([0, current_time - submission_time - paused_seconds].max / 1.day).floor + end + + # Excludes any breaks that would otherwise "pause" feedback + def calendar_days_awaiting_feedback(now_time = Time.zone.now) + return 0 if submission_date.blank? + + [0, (now_time.to_date - submission_date.to_date).to_i].max + end + def complete? status == :complete end def discuss_or_demonstrate? - status == :discuss || status == :demonstrate + [:discuss, :rediscuss, :demonstrate].include?(status) end def discuss? - status == :discuss + [:discuss, :rediscuss].include?(status) end def demonstrate? @@ -346,11 +482,11 @@ def ready_for_feedback? end def ready_or_complete? - [:complete, :discuss, :demonstrate, :ready_for_feedback].include? status + [:complete, :discuss, :rediscuss, :demonstrate, :ready_for_feedback, :assess_in_portfolio].include? status end def submitted_status? - ![:working_on_it, :not_started, :fix_and_resubmit, :redo, :need_help].include? status + [:working_on_it, :not_started, :fix_and_resubmit, :redo, :need_help].exclude? status end def fix_and_resubmit? @@ -382,7 +518,7 @@ def status end def has_pdf - !portfolio_evidence_path.nil? && File.exist?(portfolio_evidence_path) && !processing_pdf? + !final_pdf_path.nil? && File.exist?(final_pdf_path) && !processing_pdf? end def log_details @@ -393,6 +529,29 @@ def group_task? !group_submission.nil? || !task_definition.group_set.nil? end + def active_overflow_task_claim + claim = overflow_task_claim + return nil unless claim + + threshold = 30.minutes.ago + unit = project.unit + + # Find latest comment made by the claiming unit role (on this task) + latest_by_claimer = + comments + .where('task_comments.created_at > ?', claim.created_at) + .includes(:user) + .select { |c| unit.unit_role_for(c.user)&.id == claim.claimed_by_unit_role_id } + .max_by(&:created_at) + + # If they've commented, use that as the activity timer; otherwise fall back to claim time + last_activity_at = latest_by_claimer&.created_at || claim.created_at + + return nil if last_activity_at < threshold + + claim + end + def group return nil unless group_task? @@ -408,7 +567,8 @@ def ensured_group_submission group.create_submission self, '', group.projects.map { |proj| { project: proj, pct: 100 / group.projects.count } } end - def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: false, quality: 1) + def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: false, quality: 1, recursive_fix: false, + check_feedback: false) # # Ensure that assessor is allowed to update the task in the indicated way # @@ -427,10 +587,28 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: # Protect closed states from student changes return nil if [:student, :group_member].include?(role) && task_submission_closed? + if task_definition.lock_assessments_to_tutorial_stream + unit_role = unit.unit_role_for(by_user) + tutorial_stream = task_definition.tutorial_stream + tutorials = tutorial_stream.tutorials + return nil unless tutorials.any? { |t| t.unit_role == unit_role } + end + + # Check to see if another tutor has claimed this task from overflow + if active_overflow_task_claim + unit_role = unit.unit_role_for(by_user) + if unit_role && unit_role.id != active_overflow_task_claim.claimed_by_unit_role_id + return nil + end + end # # State transitions based upon the trigger # + # Remember the status before the transition so we can tell, at the end, + # whether it actually changed. An unchanged status must not notify (EN-E02). + status_id_before_transition = task_status_id + status = TaskStatus.status_for_name(trigger) case status @@ -438,22 +616,56 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: return nil when TaskStatus.ready_for_feedback submit by_user - when TaskStatus.not_started, TaskStatus.need_help, TaskStatus.working_on_it + when TaskStatus.not_started, TaskStatus.need_help, TaskStatus.working_on_it, TaskStatus.assess_in_portfolio add_status_comment(by_user, status) engage status else # Only tutors can perform these actions if role == :tutor - if task_definition.max_quality_pts > 0 - case status - when TaskStatus.complete, TaskStatus.discuss, TaskStatus.demonstrate - update(quality_pts: quality) + if status == TaskStatus.complete && task_definition.requires_discussion && !has_discussed_in_class_comment? + return nil + end + + if status == TaskStatus.rediscuss && task_status != TaskStatus.discuss + return nil + end + + if check_feedback + if status == TaskStatus.complete && !has_manual_feedback_since_first_ready_for_feedback? + errors.add(:task_status, "cannot be moved to '#{status.name}' until feedback has been given") + return nil + end + + if [TaskStatus.fix_and_resubmit, TaskStatus.redo].include?(status) && + !has_recent_manual_feedback_from_tutor?(by_user) + errors.add(:task_status, "cannot be moved to '#{status.name}' until feedback has been given") + return nil end end - assess status, by_user - # Add a status comment for new assessments - only recorded on submitter's task in groups - add_status_comment(by_user, status) + if task_definition.assess_in_portfolio_only + # Block assess_in_portfolio_only tasks from being signed off as complete + if status == TaskStatus.complete + return nil + end + else + # Can only be graded if task_def is not assess_in_portfolio_only + if task_definition.max_quality_pts > 0 + case status + when TaskStatus.complete, TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.demonstrate, TaskStatus.attention_required + update(quality_pts: quality) + end + end + end + + lc = comments.last + # Prevent duplicate status comments during feedback + unless lc && lc.user == by_user && lc.comment == status.name && (lc.content_type != 'status' || lc.task_status == status) + assess status, by_user, Time.zone.now, recursive_fix + + # Add a status comment for new assessments - only recorded on submitter's task in groups + add_status_comment(by_user, status) + end else # Attempt to move to tutor state by non-tutor return nil @@ -468,9 +680,108 @@ def trigger_transition(trigger: '', by_user: nil, bulk: false, group_transition: end end + # EN-V06: tell the responsible tutor when a student submits for marking. + notify_tutor_of_task_submission(by_user, role, status_id_before_transition, group_transition) + + # EN-E02: tell the student when a staff member changed their task's status. + notify_student_of_status_change(by_user, role, status_id_before_transition) + true end + # Tell the responsible tutor when a student's task genuinely moves into the + # ready-for-feedback state. EN-E02 shares this transition seam, but its + # tutor-only role guard is deliberately disjoint from this student-only one, + # so one transition cannot raise both events. + # + # A group submission fans the same transition out to every member task. Only + # the original action notifies; internal group transitions are suppressed so + # one logical submission cannot amplify into duplicate tutor emails. + def notify_tutor_of_task_submission(by_user, role, previous_status_id, group_transition) + return unless [:student, :group_member].include?(role) + return if group_transition + return unless task_status == TaskStatus.ready_for_feedback + return if task_status_id == previous_status_id + + recipient = project&.tutor_for(task_definition) + student = project&.student + return if recipient.blank? || student.blank? || recipient == by_user + + product_name = Doubtfire::Application.config.institution[:product_name] + + NotificationService.notify( + user: recipient, + type: 'task', + event: 'task_submitted', + message: "#{student.name} submitted #{task_definition.name} for marking in #{product_name}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_submitted notification for task #{id}: #{e.message}" + end + + # Tell the student that a staff member changed the status of their task. + # + # Only a tutor's action notifies (role == :tutor); a student changing their + # own task must never email themselves. And only a real change notifies: an + # unchanged status is a no-op. + # + # The new status value is deliberately kept out of the notification, the same + # way the comment text is in notify_comment_recipient. The email is a prompt to + # come back to OnTrack, not a copy of the result. + # + # Raising a notification must never roll back the transition, so failures are + # logged and swallowed. NotificationService already rescues mail errors; this + # catches the record write and anything else unexpected. + def notify_student_of_status_change(by_user, role, previous_status_id) + return unless role == :tutor + return if task_status_id == previous_status_id + + recipient = project&.student + # recipient == by_user is belt and braces: once role == :tutor the actor + # cannot be the student, since user_role checks user == student first. Kept + # so a future change to user_role cannot start emailing someone themselves. + return if recipient.blank? || recipient == by_user + + NotificationService.notify( + user: recipient, + type: 'task', + event: 'task_status_changed', + message: "#{by_user.name} updated the status of #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_status_changed notification for task #{id}: #{e.message}" + end + + def has_discussed_in_class_comment? + comments.where(content_type: 'discussed_in_class').exists? + end + + def has_manual_feedback_since_first_ready_for_feedback? + first_ready_for_feedback_at = comments + .where(content_type: 'status', task_status_id: TaskStatus.ready_for_feedback.id) + .order(:created_at) + .pick(:created_at) + + feedback_comments = comments + .where(content_type: %w[text audio image pdf discussion]) + .where(user_id: unit.staff.select(:user_id)) + + feedback_comments = feedback_comments.where('created_at >= ?', first_ready_for_feedback_at) if first_ready_for_feedback_at + + feedback_comments.where.not("COALESCE(comment, '') LIKE ?", '**Automated Message:%').exists? + end + + def has_recent_manual_feedback_from_tutor?(tutor) + comments + .where(content_type: %w[text audio image pdf discussion]) + .where(user: tutor) + .where('created_at >= ?', 10.minutes.ago) + .where.not("COALESCE(comment, '') LIKE ?", '**Automated Message:%') + .exists? + end + def grade_desc grade_for(grade) end @@ -484,13 +795,9 @@ def grade_task(new_grade, ui = nil, grading_group = false) raise message end - grade_map = { - 'f' => -1, - 'p' => 0, - 'c' => 1, - 'd' => 2, - 'hd' => 3 - } + grade_map = unit.grade_definitions.to_h do |definition| + [definition['abbreviation'].downcase, definition['value']] + end if task_definition.is_graded if new_grade.nil? raise_error.call("No grade was supplied for a graded task (task id #{id})") @@ -502,13 +809,16 @@ def grade_task(new_grade, ui = nil, grading_group = false) if new_grade.is_a?(String) if grade_map.keys.include?(new_grade.downcase) # convert string representation to integer representation - new_grade = grade_map[new_grade] + new_grade = grade_map[new_grade.downcase] else - raise_error.call("New grade supplied to task is not a valid string - expects one of {f|p|c|d|hd} (task id #{id})") + raise_error.call("New grade supplied to task is not a valid abbreviation (task id #{id})") end end - unless new_grade.is_a?(Integer) && grade_map.values.include?(new_grade.to_i) - raise_error.call("New grade supplied to task is not a valid integer - expects one of {-1|0|1|2|3} (task id #{id})") + unless new_grade.is_a?(Integer) + raise_error.call("New grade supplied to task is not a valid integer (task id #{id})") + end + unless unit.assessment_grade_value?(new_grade) + raise_error.call("Grade is not enabled for this unit (task id #{id})") end # propagate new grade to all OTHER group members if group_task? && !grading_group @@ -525,7 +835,7 @@ def grade_task(new_grade, ui = nil, grading_group = false) end end - def assess(task_status, assessor, assess_date = Time.zone.now) + def assess(task_status, assessor, assess_date = Time.zone.now, recursive_fix = false) # Set the task's status to the assessment outcome status # and flag it as no longer awaiting signoff self.task_status = task_status @@ -548,7 +858,7 @@ def assess(task_status, assessor, assess_date = Time.zone.now) # Grant an extension on fix if due date is within 1 week case task_status - when TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.demonstrate + when TaskStatus.fix_and_resubmit, TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.demonstrate if to_same_day_anywhere_on_earth(due_date) < Time.zone.now + 7.days && can_apply_for_extension? && unit.extension_weeks_on_resubmit_request > 0 grant_extension(assessor, unit.extension_weeks_on_resubmit_request) end @@ -557,6 +867,44 @@ def assess(task_status, assessor, assess_date = Time.zone.now) # Save the task if save! + if assessor == tutor && task_status != TaskStatus.time_exceeded && task_status != TaskStatus.assess_in_portfolio + moderated_task = ModeratedTask.find_by(task: self) + if moderated_task + if moderated_task.assessor_id != tutor.id + moderated_task.update!(assessor_id: tutor.id) + end + else + sample_count = ModeratedTask.where( + moderation_type: :first_feedback, + assessor_id: tutor.id, + task_definition: task_definition + ).count + + if sample_count < 3 + mark_as_moderated(moderation_type: :first_feedback) + end + end + end + + if task_status == TaskStatus.fix_and_resubmit && recursive_fix + # Look for other submitted tasks from this student that has this task as a prerequisite + # If they are ready for feedback, automatically assess them to fix and resubmit + dependents = TaskPrerequisite.where(prerequisite_id: task_definition.id) + dependents.each do |prereq| + td = prereq.task_definition + task = project.task_for_task_definition(td) + + # Avoid infinite loop + next if task.id == id + + next unless task.task_status == TaskStatus.ready_for_feedback + # Since we are calling this assess method again, we recursively check for more dependent tasks that need to be updated + task.assess(TaskStatus.fix_and_resubmit, assessor, assess_date, recursive_fix) + task.add_status_comment(assessor, TaskStatus.fix_and_resubmit) + task.add_text_comment(assessor, "**Automated comment**: A prerequisite task was updated to Fix and Resubmit, so this task was updated as well. You may need to review and update the prerequisite before resubmitting.") + end + end + TaskEngagement.create!(task: self, engagement_time: Time.zone.now, engagement: task_status.name) # Grab the submission for the task if the user made one @@ -592,9 +940,11 @@ def engage(engagement_status) end def submitted_before_due? - return true unless due_date.present? + return true if due_date.blank? - to_same_day_anywhere_on_earth(due_date) >= self.submission_date + # When using flexible dates, we need to check against the deadline + check_date = unit.allow_flexible_dates ? max_date_with_spec_con_days : due_date + to_same_day_anywhere_on_earth(check_date) >= self.submission_date end # @@ -602,15 +952,21 @@ def submitted_before_due? # Default submission time to current time. # def submit(by_user, submit_date = Time.zone.now) - self.submission_date = submit_date + if self.task_status != TaskStatus.ready_for_feedback || self.submission_date.nil? + self.submission_date = submit_date + end add_status_comment(by_user, TaskStatus.ready_for_feedback) - # If it is submitted before the due date... - if submitted_before_due? + # If it is submitted before the due date, or student has already made a submission before the due date + if submitted_before_due? || self.task_status == TaskStatus.ready_for_feedback self.task_status = TaskStatus.ready_for_feedback else - assess TaskStatus.time_exceeded, by_user + if unit.mark_late_submissions_as_assess_in_portfolio || task_definition.assess_in_portfolio_only + assess TaskStatus.assess_in_portfolio, by_user + else + assess TaskStatus.time_exceeded, by_user + end add_status_comment(project.tutor_for(task_definition), self.task_status) grade_task(-1) if task_definition.is_graded? && self.grade.nil? end @@ -646,7 +1002,7 @@ def weight end def add_text_comment(user, text, reply_to_id = nil) - text.strip! + text = text.strip return nil if user.nil? || text.nil? || text.empty? lc = comments.last @@ -665,12 +1021,42 @@ def add_text_comment(user, text, reply_to_id = nil) comment.reply_to_id = reply_to_id comment.save! + notify_comment_recipient(comment) + comment end + # Tell the other party that a comment arrived. + # + # comment.recipient is already worked out above: the tutor when a student + # commented, the student when a tutor commented. Do not recalculate it. + # + # A project with no tutor for this task definition has no recipient, so the + # guard is required and not defensive padding. + # + # The comment text is deliberately not put in the notification. The email is a + # prompt to come back to OnTrack, not a copy of the conversation. + # + # Raising a notification must never stop a comment being posted, so failures + # are logged and swallowed. NotificationService already rescues mail errors; + # this catches the record write and anything else unexpected. + def notify_comment_recipient(comment) + return if comment.recipient.blank? + + NotificationService.notify( + user: comment.recipient, + type: 'feedback', + event: 'task_comment_created', + message: "#{comment.user.name} commented on #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise task_comment_created notification for task #{id}: #{e.message}" + end + def individual_task_or_submitter_of_group_task? return true if !group_task? # its individual - return true unless group.present? # no group yet... so individual + return true if group.blank? # no group yet... so individual ensured_group_submission.submitted_by? self.project # return true if submitted by this project end @@ -689,6 +1075,33 @@ def add_status_comment(current_user, status) comment end + def add_discussed_comment(current_user) + comment = 'Discussed in class' + + lc = comments.last + + # don't add if duplicate comment + return if lc && lc.user == current_user && lc.content_type == 'discussed_in_class' && lc.comment == comment + + discussed = TaskDiscussedComment.create + discussed.task = self + discussed.user = current_user + discussed.comment = comment + discussed.recipient = current_user == project.student ? project.tutor_for(task_definition) : project.student + discussed.save! + discussed + end + + def add_checked_in_comment(current_user) + discussed = TaskCheckedInComment.create + discussed.task = self + discussed.user = current_user + discussed.comment = "Checked In" + discussed.recipient = current_user == project.student ? project.tutor_for(task_definition) : project.student + discussed.save! + discussed + end + def add_discussion_comment(user, prompts) # don't allow if group task. discussion = DiscussionComment.create @@ -706,11 +1119,34 @@ def add_discussion_comment(user, prompts) end discussion.mark_as_read(user, unit) + notify_discussion_request_recipient(discussion) logger.info(discussion) return discussion end + # EN-V08 was originally described as a discussion booking notification, but + # OnTrack has no booking or appointment record to hook. A discussion comment + # is the point where a tutor actually raises an audio prompt for a student, + # so notify the student once that prompt and its attachments are ready. + # + # Prompt content is deliberately left out of the notification and email. A + # notification failure must not stop the discussion comment being created. + def notify_discussion_request_recipient(discussion) + return if discussion.recipient.blank? + + NotificationService.notify( + user: discussion.recipient, + type: 'feedback', + event: 'discussion_request_created', + message: 'A discussion prompt is ready for you.', + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + ) + rescue StandardError => e + logger.error "Failed to raise discussion_request_created notification for task #{id}: #{e.message}" + end + private :notify_discussion_request_recipient + # TODO: Refactor to attachment comment (with inheritance on model) def add_comment_with_attachment(user, tempfile, reply_to_id = nil) ensured_group_submission if group_task? && group @@ -736,6 +1172,23 @@ def add_comment_with_attachment(user, tempfile, reply_to_id = nil) comment end + def add_feedback_review_request_comment(current_user) + comment = 'Feedback Review Requested' + + lc = comments.last + + # don't add if duplicate comment + return if lc && lc.user == current_user && lc.content_type == 'feedback_review_request' && lc.comment == comment + + request = TaskFeedbackReviewRequestComment.create + request.task = self + request.user = current_user + request.comment = comment + request.recipient = current_user == project.student ? project.tutor_for(task_definition) : project.student + request.save! + request + end + def last_comment all_comments.last end @@ -832,12 +1285,10 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: zip_file = zip_file_path || zip_file_path_for_done_task return false if zip_file.nil? || (!Dir.exist? task_dir) - FileUtils.rm_f(zip_file) - - # compress image files + # compress image files - convert to jpg image_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(image)/) == 0 } image_files.each do |img| - # Ensure all images in submissions are not jpg + # Ensure all images in submissions are jpg dest_file = "#{task_dir}#{File.basename(img, ".*")}.jpg" raise 'Failed to compress an image. Ensure all images are valid.' unless FileHelper.compress_image_to_dest("#{task_dir}#{img}", dest_file, true) @@ -845,9 +1296,20 @@ def compress_new_to_done(task_dir: student_work_dir(:new, false), zip_file_path: FileUtils.rm("#{task_dir}#{img}") unless dest_file == "#{task_dir}#{img}" end - # copy all files into zip - input_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(cover|document|code|image)/) == 0 } + input_files = Dir.entries(task_dir).select { |f| (f =~ /^\d{3}.(cover|document|code|image|zip|archive)/) == 0 } + if input_files.length != task_definition.number_of_uploaded_files + logger.error "Error processing task #{log_details} - missing files expected #{task_definition.number_of_uploaded_files} got #{input_files.length}" + logger.error "Files found: #{input_files}" + return false + end + + logger.info "Creating new zip file for task #{id} in #{zip_file}" + + # We have what looks like a good submission, remove old zip + FileUtils.rm_f(zip_file) + + # copy all files into zip zip_dir = File.dirname(zip_file) FileUtils.mkdir_p zip_dir @@ -878,9 +1340,12 @@ def copy_done_to(path) def clear_in_process in_process_dir = student_work_dir(:in_process, false) if Dir.exist? in_process_dir - Dir.chdir(FileUtils.student_work_dir) if FileUtils.pwd == in_process_dir + Dir.chdir(FileHelper.student_work_root) if FileUtils.pwd == in_process_dir FileUtils.rm_rf in_process_dir end + + rescue StandardError => e + logger.error "Error clearing in process directory for task #{log_details} - #{e.message}" end # @@ -923,7 +1388,10 @@ def move_files_to_in_process(source_folder = FileHelper.student_work_dir(:new)) from_dir = File.join(source_folder, id.to_s) + "/" if Dir.exist?(from_dir) # save new files in done folder - return false unless compress_new_to_done(task_dir: from_dir) + unless compress_new_to_done(task_dir: from_dir) + logger.error "Error processing task #{log_details} - failed to compress new files" + return false + end end # Get the zip file path... @@ -939,6 +1407,17 @@ def move_files_to_in_process(source_folder = FileHelper.student_work_dir(:new)) end end + def move_files_on_abbreviation_change(old_abbreviation) + # Move files from old abbreviation to new abbreviation + old_path = final_pdf_path(abbr: old_abbreviation) + new_path = final_pdf_path(ignore_portfolio_evidence: true) + + return if old_path == new_path || !File.exist?(old_path) + + FileUtils.mv(old_path, new_path) + update(portfolio_evidence: nil) unless portfolio_evidence.nil? + end + def __output_filename__(in_dir, idx, type) pwd = FileUtils.pwd Dir.chdir(in_dir) @@ -998,20 +1477,30 @@ def in_process_files_for_task(is_retry) end class TaskAppController < ApplicationController + include LatexHelper + attr_accessor :task attr_accessor :files attr_accessor :base_path attr_accessor :image_path attr_accessor :include_pax + attr_accessor :submitted_files_url def init(task, is_retry) @task = task @files = task.in_process_files_for_task(is_retry) @base_path = task.student_work_dir(:in_process, false) - @image_path = Rails.root.join('public', 'assets', 'images') + @image_path = Rails.root.join('public/assets/images') @institution_name = Doubtfire::Application.config.institution[:name] @doubtfire_product_name = Doubtfire::Application.config.institution[:product_name] @include_pax = !is_retry + @work_id = FileHelper.sanitized_path( + "task-#{Time.current.strftime('%Y%m%d-%H%M')}-#{task.project.student.username}-#{task.task_definition.abbreviation}-#{task.id}-#{Process.pid}#{'-retry' if is_retry}" + ) + host = Doubtfire::Application.config.institution[:host].to_s + host = "http://#{host}" unless host.match?(%r{\Ahttps?://}) + host = host.sub(%r{/*\z}, '') + @submitted_files_url = "#{host}/projects/#{task.project.id}/task_def_id/#{task.task_definition.id}/submission_files/download" end def make_pdf @@ -1022,8 +1511,8 @@ def make_pdf FileHelper.qpdf(f[:path]) end end - logger.debug "Preprocessing complete, rendering file." - render_to_string(template: '/task/task_pdf', layout: true) + logger.debug 'Preprocessing complete, rendering file.' + generate_pdf(template: '/task/task_pdf') end end @@ -1035,7 +1524,7 @@ def self.pygments_lang(extn) elsif ['cpp', 'hpp', 'c++', 'h++', 'cc', 'cxx', 'cp'].include?(extn) then 'cpp' elsif ['java'].include?(extn) then 'java' elsif %w(js json ts).include?(extn) then 'js' - elsif ['html', 'rhtml'].include?(extn) then 'html' + elsif ['html', 'rhtml', 'vue'].include?(extn) then 'html' elsif %w(css scss).include?(extn) then 'css' elsif ['rb'].include?(extn) then 'ruby' elsif ['coffee'].include?(extn) then 'coffeescript' @@ -1053,33 +1542,76 @@ def self.pygments_lang(extn) end end + def move_to_final_pdf_path + if portfolio_evidence.present? + # Move the portfolio evidence to the final pdf path + if File.exist?(portfolio_evidence_path) + new_path = final_pdf_path(ignore_portfolio_evidence: true) + FileUtils.mv(portfolio_evidence_path, new_path) + end + update(portfolio_evidence: nil) + end + end + def portfolio_evidence_path # Add the student work dir to the start of the portfolio evidence - File.join(FileHelper.student_work_dir, self.portfolio_evidence) if self.portfolio_evidence.present? + if unit.archived + base = FileHelper.archive_root + else + base = FileHelper.student_work_dir + end + File.join(base, self.portfolio_evidence) if self.portfolio_evidence.present? end - def portfolio_evidence_path=(value) - # Strip the student work directory to store in database as relative path - self.portfolio_evidence = value.present? ? value.sub(FileHelper.student_work_dir, '') : nil + # The path to the PDF for this task's submission + def final_pdf_path(abbr: nil, ignore_portfolio_evidence: false) + result = if group_task? + return nil if group_submission.nil? || group_submission.task_definition.nil? + + abbr = group_submission.task_definition.abbreviation if abbr.nil? + + File.join( + FileHelper.student_group_work_dir(:pdf, group_submission, task = nil, create = true), + FileHelper.sanitized_filename(FileHelper.sanitized_path("#{abbr}-#{group_submission.id}") + '.pdf') + ) + else + abbr = task_definition.abbreviation if abbr.nil? + File.join(student_work_dir(:pdf), FileHelper.sanitized_filename(FileHelper.sanitized_path("#{abbr}-#{id}") + '.pdf')) + end + + # see if we need to use the portfolio evidence + if portfolio_evidence.present? && !ignore_portfolio_evidence + evidence_loc = portfolio_evidence_path + + # Remove portfolio evidence if possible + if evidence_loc == result || !File.exist?(evidence_loc) + update(portfolio_evidence: nil) + else + result = evidence_loc + end + end + + result end - # The path to the PDF for this task's submission - def final_pdf_path - if group_task? - return nil if group_submission.nil? || group_submission.task_definition.nil? + # A custom error to capture the log message from the latex error + class LatexError < StandardError + attr_reader :log_message - File.join( - FileHelper.student_group_work_dir(:pdf, group_submission, task = nil, create = true), - FileHelper.sanitized_filename(FileHelper.sanitized_path("#{group_submission.task_definition.abbreviation}-#{group_submission.id}") + '.pdf') - ) - else - File.join(student_work_dir(:pdf), FileHelper.sanitized_filename(FileHelper.sanitized_path("#{task_definition.abbreviation}-#{id}") + '.pdf')) + def initialize(log_message) + super + @log_message = log_message end end # Convert a submission to pdf - the source folder is the root folder in which the submission folder will be found (not the submission folder itself) - def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) - return false unless move_files_to_in_process(source_folder) + def convert_submission_to_pdf(source_folder: FileHelper.student_work_dir(:new), log_to_stdout: true) + logger.info "Converting task #{self.id} to pdf" + + unless move_files_to_in_process(source_folder) + logger.error("Failed to move files for #{log_details} to in process") + return false + end begin tac = TaskAppController.new @@ -1088,8 +1620,11 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) begin pdf_text = tac.make_pdf rescue => e - # Try again... with convert to ascic - # + # Try again... + # Without newpax + # Ensure latex aux file is removed + # Dir.glob(Rails.root.join('tmp/rails-latex/**/input.aux')).each { |f| File.delete(f) } + tac2 = TaskAppController.new tac2.init(self, true) @@ -1099,56 +1634,55 @@ def convert_submission_to_pdf(source_folder = FileHelper.student_work_dir(:new)) logger.error "Failed to create PDF for task #{log_details}. Error: #{e.message}" log_file = e.message.scan(/\/.*\.log/).first - # puts "log file is ... #{log_file}" if log_file && File.exist?(log_file) - # puts "exists" - begin - puts "--- Latex Log ---\n" - puts File.read(log_file) - puts "--- End ---\n\n" - rescue + log_message = File.read(log_file) + + # puts "log file is ... #{log_file}" + if log_to_stdout + # puts "exists" + begin + # rubocop:disable Rails/Output + puts "--- Latex Log ---\n" + puts log_message + puts "--- End ---\n\n" + # rubocop:enable Rails/Output + rescue + end end end - raise 'Failed to convert your submission to PDF. Check code files submitted for invalid characters, that documents are valid pdfs, and that images are valid.' + raise LatexError.new(log_message), 'Failed to convert your submission to PDF. Check code files submitted for invalid characters, that documents are valid pdfs, images are valid, and zip files are valid.' end end - # save the final pdf path to portfolio evidence - relative to student work folder - if group_task? - group_submission.tasks.each do |t| - t.portfolio_evidence_path = final_pdf_path - t.save - end - reload - else - self.portfolio_evidence_path = final_pdf_path - end - # Save the file... now using the full path! - File.open(portfolio_evidence_path, 'w') do |fout| + File.open(final_pdf_path, 'w') do |fout| fout.puts pdf_text end - FileHelper.compress_pdf(portfolio_evidence_path) + FileHelper.compress_pdf(final_pdf_path) + + logger.info("PDF created for task #{self.id}") # if the task is the draft learning summary task if task_definition_id == unit.draft_task_definition_id # if there is a learning summary, execute, if there isn't and a learning summary exists, don't execute if project.uses_draft_learning_summary || !project.learning_summary_report_exists? - project.save_as_learning_summary_report portfolio_evidence_path + project.save_as_learning_summary_report final_pdf_path end end save - - clear_in_process return true rescue => e - clear_in_process - trigger_transition trigger: 'fix', by_user: project.tutor_for(task_definition) + add_text_comment project.tutor_for(task_definition), "**Automated Comment**: Something went wrong with your submission. Check the files and resubmit this task. #{e.message}" raise e + ensure + # Ensure latex aux file is removed - if broken will cause issues for next submission in sidekiq + # Dir.glob(Rails.root.join('tmp/rails-latex/**/input.aux')).each { |f| File.delete(f) } + + clear_in_process end end @@ -1167,7 +1701,9 @@ def create_submission_and_trigger_state_change(user, propagate = true, contribut reload else self.file_uploaded_at = Time.zone.now - self.submission_date = Time.zone.now + if self.task_status != TaskStatus.ready_for_feedback || self.submission_date.nil? + self.submission_date = Time.zone.now + end # This task is now ready to submit - trigger a transition if not in final state unless discuss_or_demonstrate? || complete? || feedback_exceeded? || fail? @@ -1176,30 +1712,13 @@ def create_submission_and_trigger_state_change(user, propagate = true, contribut # Destroy the links to ensure we test new files task_similarities.each(&:destroy) - reverse_task_similarities(&:destroy) + reverse_jplag_similarities(&:destroy) + reverse_moss_similarities(&:destroy) save end end - # - # Create alignments on submission - # - def create_alignments_from_submission(alignments) - # Remove existing alignments no longer applicable - LearningOutcomeTaskLink.where(task_id: id).delete_all() - alignments.each do |alignment| - link = LearningOutcomeTaskLink.find_or_create_by( - task_definition_id: task_definition.id, - learning_outcome_id: alignment[:ilo_id], - task_id: id - ) - link.rating = alignment[:rating] - link.description = alignment[:rationale] - link.save! - end - end - # # Moves submission into place # - from -- tmp upload files @@ -1207,7 +1726,27 @@ def create_alignments_from_submission(alignments) # # Checks to make sure that the files match what we expect # - def accept_submission(current_user, files, _student, ui, contributions, trigger, alignments, accepted_tii_eula: false) + def accept_submission(current_user, files, ui, contributions, trigger, alignments, accepted_tii_eula: false, test_submission: false) + submission_lock_target.with_lock do + # Ensure there is not a submission already in process + if processing_pdf? + ui.error!({ 'error' => 'A submission is already being processed. Please wait for the current submission process to complete.' }, 403) + end + + if SubmissionHistory.pending?(self) + ui.error!({ 'error' => 'Submission history is still being created. Please wait before submitting again.' }, 403) + end + + if !test_submission && (overseer_enabled? || task_definition.assessment_enabled) && + overseer_assessments.where(status: OverseerAssessment.statuses[:pre_queued]).exists? + ui.error!({ 'error' => 'A submission is already waiting for automated feedback. Please wait for the current Overseer job to complete before submitting again.' }, 403) + end + + # Ensure all of the files are present + if files.nil? || files.length != task_definition.number_of_uploaded_files + ui.error!({ 'error' => 'Some files are missing from the submission upload' }, 403) + end + # # Ensure that each file in files has the following attributes: # id, name, filename, type, tempfile @@ -1240,23 +1779,18 @@ def accept_submission(current_user, files, _student, ui, contributions, trigger, ui.error!({ 'error' => "'#{file[:name]}' is invalid: #{file_result[:msg]}" }, 403) end - if File.size(file["tempfile"].path) > 10_000_000 - ui.error!({ 'error' => "'#{file[:name]}' exceeds the 10MB file limit. Try compressing or reformat and submit again." }, 403) + max_file_size = Doubtfire::Application.config.max_file_size.to_i + max_file_size = 10_000_000 if max_file_size <= 0 + size_in_mb = max_file_size / 1_000_000 + + if File.size(file["tempfile"].path) > max_file_size + ui.error!({ 'error' => "'#{file[:name]}' exceeds the #{size_in_mb}MB file limit. Try compressing or reformat and submit again." }, 403) end end # Ready to accept... so create the submission and update the task status create_submission_and_trigger_state_change(current_user, true, contributions, trigger, self) - # Update the alignments - across groups if needed - unless alignments.nil? - if group_task? - ensured_group_submission.propogate_alignments_from_submission(alignments) - else - create_alignments_from_submission(alignments) - end - end - # # Create student submission folder (/doubtfire/new/) # @@ -1269,7 +1803,10 @@ def accept_submission(current_user, files, _student, ui, contributions, trigger, # # Set portfolio_evidence_path to nil while it gets processed # - self.portfolio_evidence_path = nil + if portfolio_evidence.present? + FileUtils.rm_f(portfolio_evidence_path) + update(portfolio_evidence: nil) + end files.each_with_index.map do |file, idx| output_filename = File.join(tmp_dir, "#{idx.to_s.rjust(3, '0')}-#{file[:type]}#{File.extname(file[:filename]).downcase}") @@ -1298,7 +1835,12 @@ def accept_submission(current_user, files, _student, ui, contributions, trigger, logger.info "Submission accepted! Status for task #{id} is now #{trigger}" # Trigger processing of new submission - async - AcceptSubmissionJob.perform_async(id, current_user.id, accepted_tii_eula) + AcceptSubmissionJob.perform_async(id, current_user.id, accepted_tii_eula, test_submission) + end + end + + def submission_lock_target + group_task? ? group : self end # The name that should be used for the uploaded file (based on index of upload requirements) @@ -1362,8 +1904,48 @@ def read_file_from_done(idx) nil end + def archive_submission + FileUtils.rm_f(final_pdf_path) if has_pdf + end + + def overseer_enabled? + return unit.assessment_enabled && + task_definition.assessment_enabled && + # task_definition.has_task_assessment_script? && + (has_new_files? || has_done_file?) + end + + def mark_as_moderated(moderation_type: :random_sample) + moderated_task = ModeratedTask.find_by(task_id: id) + if moderated_task.nil? + ModeratedTask.create!({ + task: self, + task_definition: task_definition, + assessor_id: tutor.id, + state: :open, + moderation_type: moderation_type, + last_moderated_date: Time.zone.now + }) + end + end + private + def break_overlap_seconds(start_time, end_time, teaching_breaks) + teaching_breaks.sum do |teaching_break| + break_start = teaching_break.start_date.to_f + break_duration = teaching_break.number_of_weeks.to_i.weeks + break_end = break_start + break_duration + + next 0 unless break_start.finite? && break_duration.positive? + + overlap_start = [start_time, break_start].max + overlap_end = [end_time, break_end].min + + [0, overlap_end - overlap_start].max + end + end + def delete_associated_files if group_submission && group_submission.tasks.count <= 1 group_submission.destroy @@ -1371,8 +1953,8 @@ def delete_associated_files zip_file = zip_file_path_for_done_task FileUtils.rm(zip_file) if zip_file && File.exist?(zip_file) - - FileUtils.rm(portfolio_evidence_path) if portfolio_evidence_path.present? && File.exist?(portfolio_evidence_path) + path = final_pdf_path + FileUtils.rm(path) if path.present? && File.exist?(path) new_path = FileHelper.student_work_dir(:new, self, false) FileUtils.rm_rf(new_path) if new_path.present? && File.directory?(new_path) diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb new file mode 100644 index 0000000000..ee6ae30a00 --- /dev/null +++ b/app/models/task_completion_snapshot.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'csv' +require 'zip' + +class TaskCompletionSnapshot < ApplicationRecord + include FileHelper + + belongs_to :unit + + validates :snapshot_timestamp, presence: true + validates :snapshot_timestamp, uniqueness: { scope: :unit_id } + + after_destroy :delete_snapshot_file + + def snapshot_file_path + return nil if unit.blank? + FileHelper.unit_task_status_snapshot_path(unit, create: true) + end + + def snapshot_contents + file_path = snapshot_file_path + return nil if file_path.blank? + if File.exist?(file_path) + return read_csv_from_zip(file_path, snapshot_timestamp) + end + nil + rescue Zip::Error + nil + end + + def snapshot_date + return nil if snapshot_timestamp.blank? + + snapshot_time.to_date + end + + def snapshot_time + return nil if snapshot_timestamp.blank? + + Time.zone.at(snapshot_timestamp.to_i) + end + + def load_stats + snapshot_contents = self.snapshot_contents + + return {} if snapshot_contents.blank? + + parse_csv_stats(snapshot_contents) + rescue CSV::MalformedCSVError + {} + end + + def store_stats!(payload) + file_path = snapshot_file_path + raise 'Cannot store stats without a unit' if file_path.blank? + + FileUtils.mkdir_p(File.dirname(file_path)) + + csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp) + raise 'Cannot store stats without a valid snapshot timestamp' if csv_filename.blank? + + tmp_path = "#{file_path}.tmp" + + # Read existing zip entries (if file exists) + existing_entries = {} + if File.exist?(file_path) + Zip::File.open(file_path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + existing_entries[entry.name] = entry.get_input_stream.read + end + end + end + + # Update or add the current snapshot entry + existing_entries[csv_filename] = payload.to_s + + # Write the zip file with all entries + Zip::OutputStream.open(tmp_path) do |zip| + existing_entries.each do |filename, content| + zip.put_next_entry(filename) + zip.write(content) + end + end + + FileUtils.mv(tmp_path, file_path) + ensure + FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path + end + + private + + def parse_csv_stats(csv_text) + csv = CSV.parse(csv_text, headers: true) + return {} if csv.empty? + + stream_headers = unit.tutorial_streams.pluck(:abbreviation) + stream_headers = ['Tutorial'] if stream_headers.empty? + task_definitions = unit.task_definitions_by_grade + + stats = Hash.new { |hash, key| hash[key] = Hash.new { |tutorial_hash, tutorial_key| tutorial_hash[tutorial_key] = Hash.new { |task_hash, task_key| task_hash[task_key] = Hash.new(0) } } } + + csv.each do |row| + campus_abbreviation = row['Campus'].to_s.strip + next if campus_abbreviation.blank? + + campus_name = Campus.find_by(abbreviation: campus_abbreviation)&.name || campus_abbreviation + + stream_headers.each do |stream_header| + tutorial_name = row[stream_header].to_s.strip + next if tutorial_name.blank? + + task_definitions.each do |task_definition| + status_value = row[task_definition.abbreviation].to_s.strip + status_key = TaskStatus.id_to_key(status_value.to_i) || :not_started + stats[campus_name][tutorial_name][task_definition.abbreviation][status_key.to_s] += 1 + end + end + end + + stats + end + + def read_csv_from_zip(zip_path, snapshot_timestamp) + csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp) + Zip::File.open(zip_path) do |zip_file| + entry = zip_file.find_entry(csv_filename) + return nil if entry.nil? + + entry.get_input_stream.read + end + end + + def delete_snapshot_file + return if snapshot_timestamp.blank? + + file_path = snapshot_file_path + return if file_path.blank? || !File.exist?(file_path) + + csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp) + return if csv_filename.blank? + + tmp_path = "#{file_path}.tmp" + + begin + # Read existing zip entries excluding the one we want to delete + remaining_entries = {} + Zip::File.open(file_path) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + next if entry.name == csv_filename + remaining_entries[entry.name] = entry.get_input_stream.read + end + end + + if remaining_entries.empty? + # If no entries left, just delete the zip file + FileUtils.rm_f(file_path) + else + # Write the zip file with remaining entries + Zip::OutputStream.open(tmp_path) do |zip| + remaining_entries.each do |filename, content| + zip.put_next_entry(filename) + zip.write(content) + end + end + FileUtils.mv(tmp_path, file_path) + end + rescue StandardError => e + # If anything goes wrong with zip operations, just clean up and log + logger.error("Error managing snapshot zip file: #{e.message}") + FileUtils.rm_f(tmp_path) + end + end +end diff --git a/app/models/task_definition.rb b/app/models/task_definition.rb index 7e78bd2a71..d6d5c8318f 100644 --- a/app/models/task_definition.rb +++ b/app/models/task_definition.rb @@ -1,12 +1,73 @@ require 'json' class TaskDefinition < ApplicationRecord + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + def self.permissions + convenor_role_permissions = [ + :create_feedback_chips, + :get_feedback_chips, + :update, + :upload_csv, + :get_los, + :create_task_prerequisite, + :get_discussion_prompt, + :create_discussion_prompt, + :manage_overseer_steps + ] + + admin_role_permissions = [ + :create_feedback_chips, + :get_feedback_chips, + :update, + :upload_csv, + :get_los, + :create_task_prerequisite, + :get_discussion_prompt, + :create_discussion_prompt, + :manage_overseer_steps + ] + + tutor_role_permissions = [ + :get_feedback_chips, + :get_los, + :get_discussion_prompt, + :create_discussion_prompt + ] + + auditor_role_permissions = [ + :get_feedback_chips + ] + + nil_role_permissions = [] + + { + convenor: convenor_role_permissions, + admin: admin_role_permissions, + tutor: tutor_role_permissions, + auditor: auditor_role_permissions, + nil: nil_role_permissions + } + end + + delegate :role_for, to: :unit + before_destroy :delete_associated_files + # The database default protects rows written by an older application process + # during a rolling deployment. Current code explicitly keeps new definitions + # untracked until a supported creation workflow has fully configured them. + before_create :defer_new_task_notifications + after_update :move_files_on_abbreviation_change, if: :saved_change_to_abbreviation? after_update :remove_old_group_submissions, if: :has_removed_group? after_update :check_and_update_tii_status, if: :saved_change_to_upload_requirements? after_update :update_tii_group, if: :saved_change_to_due_date? + after_update :update_overdue_tasks_aip, if: :saved_change_to_assess_in_portfolio_only? + after_update :reset_overdue_tasks, if: :saved_change_to_due_date? # Model associations belongs_to :unit, optional: false # Foreign key @@ -14,24 +75,36 @@ class TaskDefinition < ApplicationRecord belongs_to :tutorial_stream, optional: true belongs_to :overseer_image, optional: true - has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances + has_many :tasks, dependent: :destroy # Destroying a task definition will also nuke any instances + has_many :peer_progress_snapshots, dependent: :destroy, inverse_of: :task_definition has_many :group_submissions, dependent: :destroy # Destroying a task definition will also nuke any group submissions - has_many :learning_outcome_task_links, dependent: :destroy # links to learning outcomes - has_many :learning_outcomes, -> { where('learning_outcome_task_links.task_id is NULL') }, through: :learning_outcome_task_links # only link staff relations + has_many :learning_outcomes, as: :context, dependent: :destroy + has_many :overseer_steps, -> { order(:sort_order) }, inverse_of: :task_definition, dependent: :destroy - has_many :tii_group_attachments, dependent: :destroy + has_many :tii_group_attachments, dependent: :destroy # destroy uploaded files to tii - after the tasks has_many :tii_actions, as: :entity, dependent: :destroy + has_many :task_prerequisites, dependent: :destroy + has_many :prerequisites, through: :task_prerequisites, source: :prerequisite + + has_many :grade_due_dates, + class_name: "TaskDefinitionGradeDueDate", + dependent: :destroy + + has_many :discussion_prompts, dependent: :destroy + serialize :upload_requirements, coder: JSON # Model validations/constraints validates :name, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique validates :abbreviation, uniqueness: { scope: :unit_id } # task definition names within a unit must be unique - validates :target_grade, inclusion: { in: GradeHelper::RANGE, message: '%{value} is not a valid target grade' } + validates :target_grade, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validate :target_grade_enabled_for_unit validates :max_quality_pts, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, message: 'must be between 0 and 100' } validate :upload_requirements, :check_upload_requirements_format + validate :submission_history_required_for_overseer validates :description, length: { maximum: 4095, allow_blank: true } @@ -41,9 +114,49 @@ class TaskDefinition < ApplicationRecord validates :weighting, presence: true + validate :check_existing_prerequisites + + validate :cant_disable_aip_only_if_aip_tasks_exist + include TaskDefinitionTiiModule include TaskDefinitionSimilarityModule + def grade_due_date_overrides + grade_due_dates.map do |override| + { + target_grade: override.target_grade, + target_due_date: override.target_due_date, + start_date: override.start_date + } + end + end + + def grade_target_date(target_grade) + grade_due_dates.find { |g| g.target_grade == target_grade.to_i }&.target_due_date + end + + def grade_start_date(target_grade) + grade_due_dates.find { |g| g.target_grade == target_grade.to_i }&.start_date + end + + # Opt this fully configured definition into immediate and scheduled + # availability notifications. Existing definitions are backfilled from the + # rollout time by the migration; new supported workflows use the unit start + # so already-available copied/imported tasks can still be announced. + def enable_new_task_notifications! + notification_start = [unit.start_date, start_date].compact.min || Time.current + # Legacy definitions may fail unrelated validations; this internal marker + # must still be repairable by a retrying notification job. + # rubocop:disable Rails/SkipsModelValidations + update_column(:new_task_notifications_from, notification_start) + # rubocop:enable Rails/SkipsModelValidations + end + + def defer_new_task_notifications + self.new_task_notifications_from = nil + new_task_notifications_from_will_change! + end + def unit_must_be_same if unit.present? and tutorial_stream.present? and not unit.eql? tutorial_stream.unit errors.add(:unit, "should be same as the unit in the associated tutorial stream") @@ -56,10 +169,36 @@ def tutorial_stream_present? end end + def cant_disable_aip_only_if_aip_tasks_exist + return unless will_save_change_to_assess_in_portfolio_only? + return if assess_in_portfolio_only? # only care about disabling + + if tasks.where(task_status_id: TaskStatus.assess_in_portfolio.id).exists? + errors.add(:assess_in_portfolio_only, "cannot be disabled while tasks are in the Assess in Portfolio state") + end + end + + def check_existing_prerequisites + prereqs = TaskPrerequisite.where(task_definition_id: id) + prereqs.each do |dp| + if target_grade < dp.prerequisite.target_grade + errors.add(:target_grade, "cannot be lower than prerequisite #{dp.prerequisite.abbreviation}'s target grade") + end + end + + dependents = TaskPrerequisite.where(prerequisite_id: id) + dependents.each do |pr| + if target_grade > pr.task_definition.target_grade + errors.add(:target_grade, "cannot exceed the target grade #{pr.task_definition.abbreviation} because this is a prerequisite") + end + end + end + # In the rollover process, copy this definition into another unit # Copy this task into the other unit def copy_to(other_unit) new_td = self.dup + new_td.new_task_notifications_from = nil # change the unit... new_td.unit_id = other_unit.id # for database @@ -95,7 +234,16 @@ def copy_to(other_unit) new_td.add_task_resources(task_resources, copy: true) end + if has_scorm_data? + new_td.add_scorm_data(task_scorm_data, copy: true) + end + new_td.save! + overseer_steps.find_each do |step| + new_td.overseer_steps.create!( + step.attributes.except('id', 'task_definition_id', 'created_at', 'updated_at') + ) + end new_td end @@ -120,19 +268,53 @@ def detailed_name "#{abbreviation} #{name}" end + def update_overdue_tasks_aip + return unless saved_change_to_assess_in_portfolio_only? && assess_in_portfolio_only? + + overdue_statuses = [TaskStatus.time_exceeded.id] + + tasks.where(task_status_id: overdue_statuses).find_each do |task| + task.add_status_comment(unit.main_convenor.user, TaskStatus.assess_in_portfolio) + task.update(task_status_id: TaskStatus.assess_in_portfolio.id) + end + end + + def reset_overdue_tasks + original_due_date = saved_change_to_due_date&.first + return unless original_due_date + return if assess_in_portfolio_only + + late_submissions = tasks + .where('submission_date > ?', original_due_date) + .where(task_status: [TaskStatus.time_exceeded, TaskStatus.assess_in_portfolio]) + + late_submissions.each do |task| + task.add_status_comment(unit.main_convenor.user, TaskStatus.ready_for_feedback) + task.update(task_status_id: TaskStatus.ready_for_feedback.id) + end + end + def move_files_on_abbreviation_change old_abbr = saved_change_to_abbreviation[0] # 0 is original abbreviation - if File.exist? task_sheet_with_abbreviation(old_abbr) + if File.exist? task_sheet_with_abbreviation(old_abbr, false) FileUtils.mv(task_sheet_with_abbreviation(old_abbr), task_sheet()) end - if File.exist? task_resources_with_abbreviation(old_abbr) + if File.exist? task_resources_with_abbreviation(old_abbr, false) FileUtils.mv(task_resources_with_abbreviation(old_abbr), task_resources()) end - if File.exist? task_assessment_resources_with_abbreviation(old_abbr) + if File.exist? task_assessment_resources_with_abbreviation(old_abbr, false) FileUtils.mv(task_assessment_resources_with_abbreviation(old_abbr), task_assessment_resources()) end + + if File.exist? task_scorm_data_with_abbreviation(old_abbr, false) + FileUtils.mv(task_scorm_data_with_abbreviation(old_abbr), task_scorm_data()) + end + + tasks.find_each do |task| + task.move_files_on_abbreviation_change(old_abbr) + end end def docker_image_name_tag @@ -171,15 +353,48 @@ def check_upload_requirements_format return end - # Check keys only contain key, type, name, tii_check, and tii_pct - unless req.keys.excluding('key', 'type', 'name', 'tii_check', 'tii_pct').empty? + req['type'] = 'zip' if req['type'] == 'archive' + + # Check keys only contain supported upload requirement settings + unless req.keys.excluding('key', 'type', 'name', 'tii_check', 'tii_pct', 'submission_history').empty? errors.add(:upload_requirements, "has additional values for item #{i + 1} --> #{req.keys.join(' ')}.") end + # Check the name matches a valid filename format + unless req['name'].match?(/^[a-zA-Z0-9_\- .]+$/) + errors.add(:upload_requirements, "the name for item #{i + 1} does not seem to be a valid filename --> #{req['name']}.") + end + + # Check the type is either document, image, code, or zip + unless %w(document image code zip).include? req['type'] + errors.add(:upload_requirements, "the type for item #{i + 1} is not valid --> #{req['type']}.") + end + + # Check that tii check is a boolean + unless req['tii_check'].blank? || [true, false].include?(req['tii_check']) + errors.add(:upload_requirements, "the tii_check for item #{i + 1} is not a boolean --> #{req['tii_check']}.") + end + + # Check that tii_pct is a non-negative number + unless req['tii_pct'].blank? || (req['tii_pct'].is_a?(Numeric) && req['tii_pct'] >= 0) + errors.add(:upload_requirements, "the tii_pct for item #{i + 1} is not a non-negative number --> #{req['tii_pct']}.") + end + + unless req['submission_history'].blank? || [true, false].include?(req['submission_history']) + errors.add(:upload_requirements, "the submission_history for item #{i + 1} is not a boolean --> #{req['submission_history']}.") + end + i += 1 end end + def submission_history_required_for_overseer + return unless assessment_enabled? + return if upload_requirements&.any? { |requirement| requirement['submission_history'] == true } + + errors.add(:upload_requirements, 'must include at least one file in submission history when Overseer is enabled') + end + def number_of_uploaded_files upload_requirements.length end @@ -210,6 +425,58 @@ def self.to_csv(task_definitions) end end + # Export the learning outcomes for this task definition to a CSV file + # @param _include_tlos [Boolean] ignored as at the task definition level already + def export_learning_outcome_to_csv(*) + CSV.generate do |row| + row << LearningOutcome.csv_header + learning_outcomes.each do |outcome| + outcome.add_csv_row row + end + end + end + + def export_feedback_chips_to_csv(*) + CSV.generate do |row| + row << Feedback::FeedbackChip.csv_header + learning_outcomes.each do |outcome| + outcome.feedback_chips.each do |chip| + chip.add_csv_row row + end + end + end + end + + def export_title + abbreviation + end + + def import_outcomes_from_csv(file) + result = { + success: [], + errors: [], + ignored: [] + } + + data = read_file_to_str(file) + + CSV.parse(data, + headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr&.strip }], + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| + # Make sure we're not looking at the header or an empty line + next if row[0] =~ /abbreviation/ + + begin + LearningOutcome.create_from_csv(unit, self, row, result) + rescue StandardError => e + result[:errors] << { row: row, message: e.message.to_s } + end + end + + result + end + def start_week unit.week_number(start_date) end @@ -274,25 +541,70 @@ def propogate_date_changes date_diff def to_csv_row TaskDefinition.csv_columns - .reject { |col| [:start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :upload_requirements, :group_set, :tutorial_stream].include? col } + .reject { |col| [:start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :upload_requirements, :group_set, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts, :overseer_steps].include? col} .map { |column| attributes[column.to_s] } + [ group_set.nil? ? "" : group_set.name, - upload_requirements.to_s, + upload_requirements.to_json, start_week, start_day, target_week, target_day, due_week, due_day, - tutorial_stream.present? ? tutorial_stream.abbreviation : nil + tutorial_stream.present? ? tutorial_stream.abbreviation : nil, + assess_in_portfolio_only, + task_prerequisites.map do |tp| + prereq = TaskDefinition.find(tp.prerequisite_id) + { + abbreviation: prereq.abbreviation, + task_status_id: tp.task_status_id + } + end.to_json, + discussion_prompts.map do |prompt| + { + content: prompt.content, + priority: prompt.priority + } + end.to_json, + overseer_steps.map do |step| + { + name: step.name, + description: step.description, + display_name: step.display_name, + display_description: step.display_description, + run_command: step.run_command, + timeout: step.timeout, + sort_order: step.sort_order, + step_type: step.step_type, + partial_output_diff: step.partial_output_diff, + stdin_input_file: step.stdin_input_file, + expected_output_file: step.expected_output_file, + feedback_message: step.feedback_message, + status_on_success: TaskStatus.find_by(id: step.status_on_success_id)&.status_key, + status_on_failure: TaskStatus.find_by(id: step.status_on_failure_id)&.status_key, + halt_on_success: step.halt_on_success, + halt_on_failure: step.halt_on_failure, + show_expected_output: step.show_expected_output, + show_stdin: step.show_stdin, + show_stdout: step.show_stdout, + enabled: step.enabled + } + end.to_json ] # [target_date.strftime('%d-%m-%Y')] + # [ self['due_date'].nil? ? '' : due_date.strftime('%d-%m-%Y')] end def self.csv_columns - [:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts, :is_graded, :plagiarism_warn_pct, :group_set, :upload_requirements, :start_week, :start_day, :target_week, :target_day, :due_week, :due_day, :tutorial_stream] + [:name, :abbreviation, :description, :weighting, :target_grade, :restrict_status_updates, :max_quality_pts, + :is_graded, :plagiarism_warn_pct, :scorm_enabled, :scorm_allow_review, :scorm_bypass_test, :scorm_time_delay_enabled, + :scorm_attempt_limit, :group_set, :upload_requirements, :start_week, :start_day, :target_week, :target_day, + :due_week, :due_day, :tutorial_stream, :assess_in_portfolio_only, :task_prerequisites, :discussion_prompts, :overseer_steps] + end + + def self.required_csv_columns + csv_columns - [:overseer_steps] end def self.task_def_for_csv_row(unit, row) @@ -301,7 +613,7 @@ def self.task_def_for_csv_row(unit, row) new_task = false abbreviation = row[:abbreviation].strip name = row[:name].strip - tutorial_stream = unit.tutorial_streams.find_by_abbr_or_name("#{row[:tutorial_stream]}".strip) + tutorial_stream = unit.tutorial_streams.find_by('abbreviation = :name OR name = :name', name: "#{row[:tutorial_stream]}".strip) target_date = unit.date_for_week_and_day row[:target_week].to_i, "#{row[:target_day]}".strip return [nil, false, "Unable to determine target date for #{abbreviation} -- need week number, and day short text eg. 'Wed'"] if target_date.nil? @@ -335,9 +647,18 @@ def self.task_def_for_csv_row(unit, row) result.is_graded = %w(Yes y Y yes true TRUE 1).include? "#{row[:is_graded]}".strip result.start_date = start_date result.target_date = target_date - result.upload_requirements = JSON.parse(row[:upload_requirements]) unless row[:upload_requirements].nil? + unless row[:upload_requirements].nil? + upload_requirements = JSON.parse(row[:upload_requirements]) + result.upload_requirements = normalize_upload_requirement_keys(upload_requirements) + end result.due_date = due_date + result.scorm_enabled = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_enabled]}".strip + result.scorm_allow_review = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_allow_review]}".strip + result.scorm_bypass_test = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_bypass_test]}".strip + result.scorm_time_delay_enabled = %w(Yes y Y yes true TRUE 1).include? "#{row[:scorm_time_delay_enabled]}".strip + result.scorm_attempt_limit = row[:scorm_attempt_limit].to_i + result.plagiarism_warn_pct = row[:plagiarism_warn_pct].to_i if row[:group_set].present? @@ -348,6 +669,11 @@ def self.task_def_for_csv_row(unit, row) result.tutorial_stream = unit.tutorial_streams.where(abbreviation: row[:tutorial_stream]).first end + import_discussion_prompts_from_csv_row(result, row) + import_overseer_steps_from_csv_row(result, row) + + result.assess_in_portfolio_only = %w(Yes y Y yes true TRUE 1).include? "#{row[:assess_in_portfolio_only]}".strip + if result.valid? && (row[:group_set].blank? || result.group_set.present?) begin result.save @@ -368,20 +694,113 @@ def self.task_def_for_csv_row(unit, row) [result, new_task, new_task ? "Added new task definition #{result.abbreviation}." : "Updated existing task #{result.abbreviation}"] end + def self.normalize_upload_requirement_keys(upload_requirements) + return upload_requirements unless upload_requirements.is_a?(Array) + + upload_requirements.map.with_index do |requirement, idx| + next requirement unless requirement.is_a?(Hash) + + requirement.merge('key' => "file#{idx}") + end + end + + def self.import_discussion_prompts_from_csv_row(task_definition, row) + task_definition.discussion_prompts.destroy_all + return if row[:discussion_prompts].blank? + + prompts = JSON.parse(row[:discussion_prompts]) + prompts.each do |prompt| + DiscussionPrompt.create!({ + task_definition: task_definition, + content: prompt['content'], + priority: prompt['priority'] + }) + end + end + + def self.import_overseer_steps_from_csv_row(task_definition, row) + task_definition.overseer_steps.destroy_all + return if row[:overseer_steps].blank? + + JSON.parse(row[:overseer_steps]).each do |step| + OverseerStep.create!( + task_definition: task_definition, + name: step['name'], + description: step['description'], + display_name: step['display_name'], + display_description: step['display_description'], + run_command: step['run_command'], + timeout: step['timeout'], + sort_order: step['sort_order'], + step_type: step['step_type'], + partial_output_diff: step['partial_output_diff'], + stdin_input_file: step['stdin_input_file'], + expected_output_file: step['expected_output_file'], + feedback_message: step['feedback_message'], + status_on_success_id: status_id_from_csv(step['status_on_success']), + status_on_failure_id: status_id_from_csv(step['status_on_failure']), + halt_on_success: step['halt_on_success'], + halt_on_failure: step['halt_on_failure'], + show_expected_output: step['show_expected_output'], + show_stdin: step['show_stdin'], + show_stdout: step['show_stdout'], + enabled: step.key?('enabled') ? step['enabled'] : true + ) + end + end + + def self.status_id_from_csv(value) + return nil if value.blank? + + TaskStatus.status_for_name(value)&.id || TaskStatus.find_by(id: value.to_i)&.id + end + def is_group_task? !group_set.nil? end def has_task_resources? - File.exist? task_resources + File.exist? task_resources(false) end def has_task_assessment_resources? - File.exist? task_assessment_resources + File.exist? task_assessment_resources(false) + end + + def has_task_assessment_script? + File.exist? task_assessment_script(false) end def has_task_sheet? - File.exist? task_sheet + File.exist? task_sheet(false) + end + + def has_scorm_data? + File.exist? task_scorm_data + end + + def scorm_enabled? + scorm_enabled + end + + def scorm_allow_review? + scorm_allow_review + end + + def scorm_bypass_test? + scorm_bypass_test + end + + def scorm_time_delay_enabled? + scorm_time_delay_enabled + end + + def scorm_attempt_limit? + scorm_attempt_limit + end + + def has_jplag_report? + File.exist? jplag_report end def is_graded? @@ -436,17 +855,63 @@ def remove_task_assessment_resources() end end + def add_scorm_data(file, copy: false) + if copy + FileUtils.cp file, task_scorm_data + else + FileUtils.mv file, task_scorm_data + end + end + + def remove_scorm_data() + if has_scorm_data? + FileUtils.rm task_scorm_data + end + + reset_scorm_config() + end + # Get the path to the task sheet - using the current abbreviation - def task_sheet - task_sheet_with_abbreviation(abbreviation) + def task_sheet(create = true) + task_sheet_with_abbreviation(abbreviation, create) end - def task_resources - task_resources_with_abbreviation(abbreviation) + def task_resources(create = true) + task_resources_with_abbreviation(abbreviation, create) end - def task_assessment_resources - task_assessment_resources_with_abbreviation(abbreviation) + def task_assessment_resources(create = true) + task_assessment_resources_with_abbreviation(abbreviation, create) + end + + def overseer_resource_files + return [] unless File.exist?(task_assessment_resources) + + files = [] + Zip::File.open(task_assessment_resources) do |zip_file| + zip_file.each do |entry| + next if entry.directory? + # skip macOS metadata files and hidden files + next if File.basename(entry.name).start_with?('._', '.') + + # remove top-level folder + parts = entry.name.split('/', 2) + files << "/#{parts.last}" unless parts.empty? + end + end + files + end + + def task_assessment_script(create = true) + task_assessment_script_with_abbreviation(abbreviation, create) + end + + def task_scorm_data(create = true) + task_scorm_data_with_abbreviation(abbreviation, create) + end + + def jplag_report + task_jplag_report_with_abbreviation(abbreviation) end def related_tasks_with_files(consolidate_groups = true) @@ -460,7 +925,7 @@ def related_tasks_with_files(consolidate_groups = true) if t.group.nil? result = false else - result = !seen_groups.include?(t.group) + result = seen_groups.exclude?(t.group) seen_groups << t.group if result end result @@ -487,17 +952,24 @@ def read_file_from_resources(filename) private + def target_grade_enabled_for_unit + return if unit.nil? || target_grade.nil? || unit.grade_value?(target_grade) + + errors.add(:target_grade, 'is not enabled for this unit') + end + def delete_associated_files() remove_task_sheet() remove_task_resources() remove_task_assessment_resources() + remove_scorm_data() end # Calculate the path to the task sheet using the provided abbreviation # This allows the path to be calculated on abbreviation change to allow files to # be moved - def task_sheet_with_abbreviation(abbr) - task_path = FileHelper.task_file_dir_for_unit unit, create = true + def task_sheet_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.pdf" result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.pdf" @@ -512,8 +984,8 @@ def task_sheet_with_abbreviation(abbr) # Calculate the path to the task sheet using the provided abbreviation # This allows the path to be calculated on abbreviation change to allow files to # be moved - def task_resources_with_abbreviation(abbr) - task_path = FileHelper.task_file_dir_for_unit unit, create = true + def task_resources_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.zip" result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.zip" @@ -525,8 +997,8 @@ def task_resources_with_abbreviation(abbr) end end - def task_assessment_resources_with_abbreviation(abbr) - task_path = FileHelper.task_file_dir_for_unit unit, create = true + def task_assessment_resources_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}-assessment.zip" result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}-assessment.zip" @@ -537,4 +1009,60 @@ def task_assessment_resources_with_abbreviation(abbr) result_with_sanitised_file end end + + def task_assessment_script_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}-assessment-script.txt" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}-assessment-script.txt" + + # TODO: currently its saving 1_P instead of 1.1P + if !File.exist?(result_with_sanitised_path) && create + FileUtils.mkdir_p(File.dirname(result_with_sanitised_path)) + File.write(result_with_sanitised_path, '') + end + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end + + # Calculate the path to the SCORM containzer zip file using the provided abbreviation + # This allows the path to be calculated on abbreviation change to allow files to + # be moved + def task_scorm_data_with_abbreviation(abbr, create = true) + task_path = FileHelper.task_file_dir_for_unit unit, create + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}.scorm.zip" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}.scorm.zip" + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end + + def task_jplag_report_with_abbreviation(abbr) + task_path = FileHelper.task_jplag_report_dir unit + + result_with_sanitised_path = "#{task_path}#{FileHelper.sanitized_path(abbr)}-result.jplag" + result_with_sanitised_file = "#{task_path}#{FileHelper.sanitized_filename(abbr)}-result.jplag" + + if File.exist? result_with_sanitised_path + result_with_sanitised_path + else + result_with_sanitised_file + end + end + + def reset_scorm_config() + self.scorm_enabled = false + self.scorm_allow_review = false + self.scorm_bypass_test = false + self.scorm_time_delay_enabled = false + self.scorm_attempt_limit = 0 + end end diff --git a/app/models/task_definition_grade_due_date.rb b/app/models/task_definition_grade_due_date.rb new file mode 100644 index 0000000000..9b5c15b8b4 --- /dev/null +++ b/app/models/task_definition_grade_due_date.rb @@ -0,0 +1,3 @@ +class TaskDefinitionGradeDueDate < ApplicationRecord + belongs_to :task_definition +end diff --git a/app/models/task_prerequisite.rb b/app/models/task_prerequisite.rb new file mode 100644 index 0000000000..bb517dc3d9 --- /dev/null +++ b/app/models/task_prerequisite.rb @@ -0,0 +1,46 @@ +class TaskPrerequisite < ApplicationRecord + belongs_to :task_definition + belongs_to :prerequisite, class_name: "TaskDefinition" + + # validate :prerequisite_due_date_not_after_task + validate :cannot_be_its_own_prerequisite + validate :no_reverse_prerequisite + validates :prerequisite_id, uniqueness: { scope: :task_definition_id, message: "already exists for this task" } + validate :same_unit + validate :prerequisite_grade_not_higher + + def prerequisite_due_date_not_after_task + return unless task_definition && prerequisite + if prerequisite.target_date > task_definition.target_date + errors.add(:prerequisite, "due date cannot be later than the task definition's due date") + end + end + + def no_reverse_prerequisite + if TaskPrerequisite.exists?(task_definition: prerequisite, prerequisite: task_definition) + errors.add(:base, "reverse prerequisite already exists") + end + end + + def cannot_be_its_own_prerequisite + if task_definition_id == prerequisite_id + errors.add(:prerequisite, "cannot be the same as the task definition") + end + end + + def same_unit + if task_definition.unit_id != prerequisite.unit_id + errors.add(:base, "task definition and prerequisite must belong to the same unit") + end + end + + # Prevent HD tasks being set as a prerequisite for a Pass task + def prerequisite_grade_not_higher + return if prerequisite.nil? || task_definition.nil? + + if prerequisite.target_grade.present? && task_definition.target_grade.present? && + prerequisite.target_grade > task_definition.target_grade + errors.add(:base, "Prerequisite can not have a higher target grade") + end + end +end diff --git a/app/models/task_status.rb b/app/models/task_status.rb index 825fc5c25e..bc6eec9b99 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -5,7 +5,7 @@ class TaskStatus < ApplicationRecord # TODO: Consider refactoring this class. Is there any point to having this in the database? Could this become an enum? # Model associations - has_many :tasks + has_many :tasks, dependent: :restrict_with_exception # # Override find to ensure that task status objects are cached - these do not change @@ -64,6 +64,18 @@ def self.time_exceeded TaskStatus.find(12) end + def self.assess_in_portfolio + TaskStatus.find(13) + end + + def self.attention_required + TaskStatus.find(14) + end + + def self.rediscuss + TaskStatus.find(15) + end + class << self # Provide access to the count from the database via a new db_count method alias_method :db_count, :count @@ -76,7 +88,7 @@ class << self # Keep this hard coded! Saves cache load time. # Important: count must equal the largest id in the database def self.count - 12 + 15 end def self.status_for_name(name) @@ -95,6 +107,8 @@ def self.status_for_name(name) TaskStatus.working_on_it when 'discuss', 'd' TaskStatus.discuss + when 'rediscuss', 're-discuss', 're discuss' + TaskStatus.rediscuss when 'demonstrate', 'demo' TaskStatus.demonstrate when 'ready for feedback', 'ready_for_feedback', 'ready to mark', 'ready_to_mark', 'rtm', 'rff' @@ -105,6 +119,10 @@ def self.status_for_name(name) TaskStatus.not_started when 'time exceeded', 'time_exceeded' TaskStatus.time_exceeded + when 'assess in portfolio', 'assess_in_portfolio', 'aip' + TaskStatus.assess_in_portfolio + when 'attention required', 'attention_required', 'ar' + TaskStatus.attention_required else nil end @@ -128,6 +146,9 @@ def self.id_to_key(id) when 10 then :demonstrate when 11 then :fail when 12 then :time_exceeded + when 13 then :assess_in_portfolio + when 14 then :attention_required + when 15 then :rediscuss else :not_started end end @@ -145,6 +166,9 @@ def status_key return :fail if self == TaskStatus.fail return :feedback_exceeded if self == TaskStatus.feedback_exceeded return :time_exceeded if self == TaskStatus.time_exceeded + return :assess_in_portfolio if self == TaskStatus.assess_in_portfolio + return :attention_required if self == TaskStatus.attention_required + return :rediscuss if self == TaskStatus.rediscuss return :not_started end diff --git a/app/models/task_submission.rb b/app/models/task_submission.rb index 178f565c4f..7b07f7ce34 100644 --- a/app/models/task_submission.rb +++ b/app/models/task_submission.rb @@ -1,4 +1,4 @@ class TaskSubmission < ApplicationRecord belongs_to :task, optional: false - belongs_to :assessor, class_name: 'User', foreign_key: 'assessor_id', optional: true + belongs_to :assessor, class_name: 'User', optional: true end diff --git a/app/models/teaching_period.rb b/app/models/teaching_period.rb index 09020ae0ef..6d55ea0e48 100644 --- a/app/models/teaching_period.rb +++ b/app/models/teaching_period.rb @@ -1,6 +1,6 @@ class TeachingPeriod < ApplicationRecord # Relationships - has_many :units + has_many :units, dependent: :restrict_with_exception has_many :breaks, dependent: :delete_all # Callbacks - methods called are private @@ -18,6 +18,7 @@ class TeachingPeriod < ApplicationRecord validate :validate_end_date_after_start_date, :validate_active_until_after_end_date after_update :propogate_date_changes + after_update :refresh_communication_schedule_caches, if: :saved_change_to_teaching_dates? # Public methods @@ -115,7 +116,10 @@ def date_for_week_and_day(week, day) start_day_num = start_date.wday - result = week_start + (day_num - start_day_num).days + day_offset = day_num - start_day_num + day_offset += 7 if day_offset.negative? + + result = week_start + day_offset.days for a_break in breaks do if result >= a_break.start_date && result < a_break.end_date @@ -132,35 +136,11 @@ def future_teaching_periods TeachingPeriod.where("start_date > :end_date", end_date: end_date) end - def rollover(rollover_to, search_forward = true, rollover_inactive = false) - if rollover_to.start_date < Time.zone.now || rollover_to.start_date <= start_date - self.errors.add(:base, "Units can only be rolled over to future teaching periods") - - false - else - units_to_rollover = units - - unless rollover_inactive - units_to_rollover = units_to_rollover.where(active: true) - end - - if search_forward - ftp = future_teaching_periods.where("start_date < :date", date: rollover_to.start_date).order(start_date: "desc") - - units_to_rollover = units_to_rollover.map do |u| - ftp.map { |tp| tp.units.where(code: u.code).first }.select { |u| u.present? }.first || u - end - end - - for unit in units_to_rollover do - # skip if the unit already exists in the teaching period - next if rollover_to.units.where(code: unit.code).count > 0 - - unit.rollover(rollover_to, nil, nil) - end - - true - end + def refresh_communication_schedule_caches + CommunicationSetSchedule + .joins(communication_set: :unit) + .where(units: { teaching_period_id: id }) + .find_each(&:refresh_next_run_at!) end private @@ -191,4 +171,8 @@ def propogate_date_changes u.update(start_date: self.start_date, end_date: self.end_date) end end + + def saved_change_to_teaching_dates? + saved_change_to_start_date? || saved_change_to_end_date? + end end diff --git a/app/models/test_attempt.rb b/app/models/test_attempt.rb new file mode 100644 index 0000000000..9918414254 --- /dev/null +++ b/app/models/test_attempt.rb @@ -0,0 +1,160 @@ +require 'json' +require 'time' + +class TestAttempt < ApplicationRecord + belongs_to :task, optional: false + + has_one :task_definition, through: :task + + has_one :scorm_comment, as: :commentable, dependent: :destroy + + delegate :role_for, to: :task + delegate :student, to: :task + + validates :task_id, presence: true + + def self.permissions + student_role_permissions = [ + :update_attempt + # :review_own_attempt -- depends on task def settings. See specific_permission_hash method + ] + + tutor_role_permissions = [ + :review_other_attempt, + :override_success_status, + :delete_attempt + ] + + convenor_role_permissions = [ + :review_other_attempt, + :override_success_status, + :delete_attempt + ] + + nil_role_permissions = [] + + { + student: student_role_permissions, + tutor: tutor_role_permissions, + convenor: convenor_role_permissions, + nil: nil_role_permissions + } + end + + # Used to adjust the review own attempt permission based on task def setting + def specific_permission_hash(role, perm_hash, _other) + result = perm_hash[role] unless perm_hash.nil? + if result && role == :student && task_definition.scorm_allow_review + result << :review_own_attempt + end + result + end + + # task + # t.references :task + + # extra non-cmi metadata + # t.datetime :attempted_time, null:false + # t.boolean :terminated, default: false + + # fields that must be synced from cmi data whenever it's updated + # t.boolean :completion_status, default: false + # t.boolean :success_status, default: false + # t.float :score_scaled, default: 0 + + # scorm datamodel + # t.text :cmi_datamodel + + after_initialize if: :new_record? do + self.attempted_time = Time.zone.now + task = Task.find(self.task_id) + learner_name = task.project.student.name + learner_id = task.project.student.student_id + + init_state = { + "cmi.completion_status": 'not attempted', + "cmi.entry": 'ab-initio', # init state + "cmi.objectives._count": '0', # this counter will be managed on the frontend + "cmi.interactions._count": '0', # this counter will be managed on the frontend + "cmi.mode": 'normal', + "cmi.learner_name": learner_name, + "cmi.learner_id": learner_id + } + self.cmi_datamodel = init_state.to_json + end + + def cmi_datamodel=(data) + new_data = JSON.parse(data) + + if self.terminated == true + raise "Terminated entries should not be updated" + end + + # set cmi.entry to resume if the attempt is in progress + if new_data['cmi.completion_status'] == 'incomplete' + new_data['cmi.entry'] = 'resume' + end + + # IMPORTANT: always sync any model attributes with cmi values here to ensure consistency! + # attributes derived from cmi keys: completion_status, success_status, score_scaled + self.completion_status = new_data['cmi.completion_status'] == 'completed' + self.success_status = new_data['cmi.success_status'] == 'passed' + self.score_scaled = new_data['cmi.score.scaled'] + + write_attribute(:cmi_datamodel, new_data.to_json) + end + + def review + dm = JSON.parse(self.cmi_datamodel) + if dm['cmi.completion_status'] != 'completed' + raise StandardError, 'Cannot review incomplete attempts!' + end + + # when review is requested change the mode to review + dm['cmi.mode'] = 'review' + self[:cmi_datamodel] = dm.to_json + end + + def override_success_status(new_success_status) + dm = JSON.parse(self.cmi_datamodel) + dm['cmi.success_status'] = (new_success_status ? 'passed' : 'failed') + self[:cmi_datamodel] = dm.to_json + self.success_status = dm['cmi.success_status'] == 'passed' + self.save! + self.update_scorm_comment + end + + def add_scorm_comment + comment = ScormComment.create + comment.task = task + comment.user = task.tutor + comment.comment = success_status_description + comment.recipient = task.student + comment.commentable = self + comment.save! + + comment + end + + def update_scorm_comment + if self.scorm_comment.present? + self.scorm_comment.comment = success_status_description + self.scorm_comment.save! + + return self.scorm_comment + end + + logger.warn "WARN: Unexpected need to create scorm comment for test attempt: #{self.id}" + add_scorm_comment + end + + def success_status_description + if self.success_status && self.score_scaled == 1 + "Passed without mistakes" + elsif self.success_status && self.score_scaled < 1 + "Passed" + else + "Unsuccessful" + end + end +end diff --git a/app/models/turn_it_in/task_definition_tii_module.rb b/app/models/turn_it_in/task_definition_tii_module.rb index 79ea6fda1e..8e9d9e3097 100644 --- a/app/models/turn_it_in/task_definition_tii_module.rb +++ b/app/models/turn_it_in/task_definition_tii_module.rb @@ -19,13 +19,13 @@ def tii_match_pct(idx) # # @return [Boolean] true if there are any Turnitin checks def tii_checks? - Doubtfire::Application.config.tii_enabled && + TurnItIn.enabled? && !upload_requirements.empty? && ((0..upload_requirements.length - 1).map { |i| use_tii?(i) }.inject(:|) || false) end def had_tii_checks_before_last_save? - Doubtfire::Application.config.tii_enabled && + TurnItIn.enabled? && upload_requirements_before_last_save.present? && !upload_requirements_before_last_save.empty? && ((0..upload_requirements_before_last_save.length - 1).map { |i| use_tii?(i, upload_requirements_before_last_save) }.inject(:|) || false) @@ -34,9 +34,11 @@ def had_tii_checks_before_last_save? # Send all doc and docx files from the task resources to turn it in # as group attachments. def send_group_attachments_to_tii - return unless tii_group_id.present? + return if tii_group_id.blank? return unless has_task_resources? + count = 0 + # loop through files in the task resources zip file Zip::File.open(task_resources) do |zip_file| zip_file.each do |entry| @@ -45,6 +47,11 @@ def send_group_attachments_to_tii next if entry.name.include?('__MACOSX') next if entry.size < 50 + # TODO: This is a hack as TII limits the number of attachments to 3 + # We need to merge documents into a single file... + count += 1 + break if count > 3 + TiiGroupAttachment.find_or_create_from_task_definition(self, entry.name) end end @@ -56,7 +63,7 @@ def send_group_attachments_to_tii # @return [TCAClient::Group] the group for the task definition def create_or_get_tii_group # if there is no group id, create one (but not register with tii) - unless self.tii_group_id.present? + if self.tii_group_id.blank? self.tii_group_id = SecureRandom.uuid self.save end @@ -77,7 +84,7 @@ def check_and_update_tii_status TurnItIn.create_or_get_group_context(unit) if tii_group_id.present? - # We already have the group - so just create the attachments + # We already have the group - so just create/send the attachments send_group_attachments_to_tii else # Trigger the update - which creates action if needed @@ -88,9 +95,10 @@ def check_and_update_tii_status end def update_tii_group - return unless tii_group_id.present? + return if tii_group_id.blank? action = TiiActionUpdateTiiGroup.find_or_create_by(entity: self) + action.params = { update_due_date: true } action.perform end end diff --git a/app/models/turn_it_in/task_tii_module.rb b/app/models/turn_it_in/task_tii_module.rb index b88e318d89..3589d9a99a 100644 --- a/app/models/turn_it_in/task_tii_module.rb +++ b/app/models/turn_it_in/task_tii_module.rb @@ -21,7 +21,7 @@ def send_documents_to_tii(submitter, accepted_tii_eula: false) filename: filename_for_upload(idx), submitted_at: Time.zone.now, status: :created, - submitted_by_user: submitter + submitted_by: submitter ) # and start its processing diff --git a/app/models/turn_it_in/tii_action.rb b/app/models/turn_it_in/tii_action.rb index d20d5c0d66..d32b1ae460 100644 --- a/app/models/turn_it_in/tii_action.rb +++ b/app/models/turn_it_in/tii_action.rb @@ -4,7 +4,7 @@ # all actions to be retried if they fail, and manages this process. class TiiAction < ApplicationRecord - enum error_code: { + enum :error_code, { task_def_create_group: 0, no_user_with_accepted_eula: 1, excessive_retries: 2, @@ -50,7 +50,7 @@ def perform self.error_code = nil if self.retry && error? self.custom_error_message = nil - self.log = [] if self.log.nil? || self.log.empty? || self.complete # reset log if complete... and performing again + self.log = [] if self.log.blank? || self.complete # reset log if complete... and performing again self.log << { date: Time.zone.now, message: "Started #{type}" } self.last_run = Time.zone.now @@ -60,6 +60,12 @@ def perform result = run self.log << { date: Time.zone.now, message: "#{type} Ended" } + + # Ensure log does not get too long! + if self.log.size > 25 + self.log = self.log.last(25) + end + save result @@ -67,7 +73,7 @@ def perform save_and_log_custom_error e&.to_s if Rails.env.development? || Rails.env.test? - puts e.inspect + Rails.logger.debug e.inspect end nil @@ -122,8 +128,14 @@ def error? error_code.present? end + def perform_retry + save_and_reschedule + perform_async + end + def save_and_reschedule(reset_retry: true) self.retries = 0 if reset_retry + self.error_code = nil # reset error code self.retry = true save end @@ -227,8 +239,8 @@ def log_error # @param description [String] the description of the action that is being performed # @param block [Proc] the block that will be called to perform the call def exec_tca_call(description, codes = [], &block) - unless TurnItIn.functional? - raise TCAClient::ApiError, code: 0, message: "Turn It In not functiona: #{description}" + unless TurnItIn.enabled? + raise TCAClient::ApiError, code: 0, message: "Turn It In not enabled: #{description}" end if TurnItIn.rate_limited? raise TCAClient::ApiError, code: 429, message: "Turn It In rate limited: #{description}" diff --git a/app/models/turn_it_in/tii_action_delete_submission.rb b/app/models/turn_it_in/tii_action_delete_submission.rb index 53c17ebc85..acdadda6fd 100644 --- a/app/models/turn_it_in/tii_action_delete_submission.rb +++ b/app/models/turn_it_in/tii_action_delete_submission.rb @@ -9,7 +9,7 @@ def description def run submission_id = params["submission_id"] - unless submission_id.present? + if submission_id.blank? save_and_log_custom_error "Group Attachment id or Group id does not exist - cannot delete group attachment" return end diff --git a/app/models/turn_it_in/tii_action_fetch_eula.rb b/app/models/turn_it_in/tii_action_fetch_eula.rb index 8aa63c4892..09942018b6 100644 --- a/app/models/turn_it_in/tii_action_fetch_eula.rb +++ b/app/models/turn_it_in/tii_action_fetch_eula.rb @@ -31,9 +31,7 @@ def self.eula_yaml_path "#{FileHelper.student_work_root}/tii_eula.yml" end - def eula_yaml_path - self.class.eula_yaml_path - end + delegate :eula_yaml_path, to: :class def load_eula_yaml require 'yaml' # Built in, no gem required diff --git a/app/models/turn_it_in/tii_action_register_webhook.rb b/app/models/turn_it_in/tii_action_register_webhook.rb index b004125f66..e897eaabb7 100644 --- a/app/models/turn_it_in/tii_action_register_webhook.rb +++ b/app/models/turn_it_in/tii_action_register_webhook.rb @@ -6,10 +6,24 @@ def description "Register webhooks" end - private + def remove_webhooks + # Get all webhooks + webhooks = list_all_webhooks + + # Delete each of the webhooks + webhooks.each do |webhook| + exec_tca_call 'delete webhook' do + TCAClient::WebhookApi.new.delete_webhook( + TurnItIn.x_turnitin_integration_name, + TurnItIn.x_turnitin_integration_version, + webhook.id + ) + end + end + end def run - register_webhook if need_to_register_webhook? + register_webhook if TurnItIn.register_webhooks? && need_to_register_webhook? self.complete = true end @@ -27,8 +41,11 @@ def need_to_register_webhook? end def register_webhook + key = ENV.fetch('TCA_SIGNING_KEY', nil) + raise "TCA_SIGNING_KEY is not set" if key.nil? + data = TCAClient::WebhookWithSecret.new( - signing_secret: ENV.fetch('TCA_SIGNING_KEY', nil), + signing_secret: Base64.encode64(key).tr("\n", ''), url: TurnItIn.webhook_url, event_types: %w[ SIMILARITY_COMPLETE @@ -39,8 +56,6 @@ def register_webhook ] ) # WebhookWithSecret | - raise "TCA_SIGNING_KEY is not set" if data.signing_secret.nil? - exec_tca_call 'register webhook' do TCAClient::WebhookApi.new.webhooks_post( TurnItIn.x_turnitin_integration_name, diff --git a/app/models/turn_it_in/tii_action_update_tii_group.rb b/app/models/turn_it_in/tii_action_update_tii_group.rb index 87caf41bd9..ad7f2b96cb 100644 --- a/app/models/turn_it_in/tii_action_update_tii_group.rb +++ b/app/models/turn_it_in/tii_action_update_tii_group.rb @@ -8,7 +8,7 @@ def description def run # Generate id but do not save until put is complete - entity.tii_group_id = SecureRandom.uuid unless entity.tii_group_id.present? + entity.tii_group_id = SecureRandom.uuid if entity.tii_group_id.blank? data = TCAClient::AggregateGroup.new( id: entity.tii_group_id, @@ -24,6 +24,7 @@ def run ] exec_tca_call "create or update group #{entity.tii_group_id} for task definition #{entity.id}", error_code do + # Update the due date TCAClient::GroupsApi.new.groups_group_id_put( TurnItIn.x_turnitin_integration_name, TurnItIn.x_turnitin_integration_version, diff --git a/app/models/turn_it_in/tii_action_upload_submission.rb b/app/models/turn_it_in/tii_action_upload_submission.rb index 8c707170d7..8558c32c94 100644 --- a/app/models/turn_it_in/tii_action_upload_submission.rb +++ b/app/models/turn_it_in/tii_action_upload_submission.rb @@ -4,6 +4,8 @@ class TiiActionUploadSubmission < TiiAction delegate :status_sym, :status, :submission_id, :submitted_by_user, :task, :idx, :similarity_pdf_id, :similarity_pdf_path, :filename, to: :entity + NO_USER_ACCEPTED_EULA_ERROR = 'None of the student, tutor, or unit lead have accepted the EULA for Turnitin'.freeze + def description "Upload #{self.filename} for #{self.task.student.username} from #{self.task.task_definition.abbreviation} (#{self.status} - #{self.next_step})" end @@ -15,8 +17,8 @@ def update_from_pdf_report_status(response) case response when 'FAILED' # The report failed to be generated error_message = 'similarity PDF failed to be created' - when 'SUCCESS' # Similarity report is complete - entity.status = :similarity_pdf_requested + when 'SUCCESS' # Similarity report is complete - pdf is available + entity.status = :similarity_pdf_available entity.save save_progress download_similarity_report_pdf(skip_check: true) @@ -62,17 +64,15 @@ def update_from_similarity_status(response) # when 'PROCESSING' # Similarity report is being generated # return when 'COMPLETE' # Similarity report is complete - entity.overall_match_percentage = response.overall_match_percentage - - flag = response.overall_match_percentage.present? && response.overall_match_percentage.to_i > task.tii_match_pct(idx) - # Update the status of the entity - entity.update(status: flag ? :similarity_report_complete : :complete_low_similarity) + entity.overall_match_percentage = response.overall_match_percentage.present? ? response.overall_match_percentage.to_i : -1 + flag = entity.should_flag? + entity.status = flag ? :similarity_report_complete : :complete_low_similarity + entity.save - # Create the similarity record - TiiTaskSimilarity.find_or_initialize_by task: entity.task do |similarity| - similarity.pct = response.overall_match_percentage - similarity.tii_submission = entity + # Create the similarity record - for task and this turn it in submission + TiiTaskSimilarity.find_or_initialize_by task: entity.task, tii_submission: entity do |similarity| + similarity.pct = entity.overall_match_percentage # record percentage similarity.flagged = flag similarity.save end @@ -106,7 +106,7 @@ def next_step "awaiting similarity report" when :similarity_pdf_available "downloading similarity report" - when "similarity_pdf_downloaded" + when :similarity_pdf_downloaded "complete - report available" when :to_delete "awaiting deletion" @@ -163,7 +163,7 @@ def fetch_tii_submission_id data = tii_submission_data # If we don't have data, then we can't create a submission - fail as no one accepted EULA - return false unless data.present? + return false if data.blank? exec_tca_call "TiiSubmission #{entity.id} - fetching id" do # Check to ensure it is a new upload @@ -199,8 +199,9 @@ def tii_submission_data # Setup the task owners if task.group_task? - result.owner = task.group_submission.submitter_task.student.username - result.metadata.owners = task.group_submission.tasks.map { |t| @instance.tii_user_for(t.student) } + grp = Task.group + result.owner = "group-#{grp.id}" + result.metadata.owners = [TurnItIn.tii_user_for_group(task.group_submission.submitter_task.student.email)] else result.owner = task.student.username result.metadata.owners = [TurnItIn.tii_user_for(task.student)] @@ -213,7 +214,7 @@ def tii_submission_data result.submitter = submitted_by_user.username unless submitted_by_user.accepted_tii_eula? || (params.key?("accepted_tii_eula") && params["accepted_tii_eula"]) - save_and_log_custom_error "None of the student, tutor, or unit lead have accepted the EULA for Turnitin" + save_and_log_custom_error NO_USER_ACCEPTED_EULA_ERROR return nil end @@ -240,7 +241,7 @@ def tii_submission_data # Upload all of the document files to the turn it in submission for a task. def upload_file_to_tii # Ensure we have a submission id and have not uploaded already - return unless submission_id.present? && (status_sym == :has_id || status_sym == :created) + return unless submission_id.present? && [:has_id, :created].include?(status_sym) return if error_message.present? error_codes = [ @@ -334,7 +335,7 @@ def request_similarity_report # # @return [TCAClient::SimilarityMetadata] the similarity report status def fetch_tii_similarity_status - return nil unless submission_id.present? + return nil if submission_id.blank? exec_tca_call "TiiSubmission #{entity.id} - fetching similarity report status" do # Get Similarity Report Status @@ -381,7 +382,7 @@ def request_similarity_report_pdf # # @param [Boolean] skip_check - skip the check to see if the report is ready def download_similarity_report_pdf(skip_check: false) - return false unless similarity_pdf_id.present? + return false if similarity_pdf_id.blank? return false unless skip_check || fetch_tii_similarity_pdf_status == 'SUCCESS' error_codes = [ @@ -442,4 +443,26 @@ def fetch_tii_similarity_pdf_status result.status end end + + # If this submission is not progressing due to a user not accepting the EULA, then + # check if the user has accepted the EULA now and retry + def attempt_retry_on_no_eula + if self.retry == false && status_sym == :created && error_message == NO_USER_ACCEPTED_EULA_ERROR + # If the student has now submitted the eula... + unless entity.submitted_by.accepted_tii_eula? + # Try reassigning the submitted_by so that it checks for tutor + # or convenor eula + entity.submitted_by = entity.submitted_by_user + end + + # If we can submit from someone... + if submitted_by_user.accepted_tii_eula? + # Save any changes to the entity + entity.save + save_and_reschedule + end + + end + end + end diff --git a/app/models/turn_it_in/tii_action_upload_task_resources.rb b/app/models/turn_it_in/tii_action_upload_task_resources.rb index 0dec131a81..7501a90c4f 100644 --- a/app/models/turn_it_in/tii_action_upload_task_resources.rb +++ b/app/models/turn_it_in/tii_action_upload_task_resources.rb @@ -25,7 +25,7 @@ def update_from_attachment_status(response) private def run - unless tii_group_id.present? + if tii_group_id.blank? save_and_log_custom_error "Group id does not exist for task definition #{task_definition.id} - cannot upload group attachments" return end diff --git a/app/models/turn_it_in/tii_group_attachment.rb b/app/models/turn_it_in/tii_group_attachment.rb index b15f56dd3e..4782a7e31b 100644 --- a/app/models/turn_it_in/tii_group_attachment.rb +++ b/app/models/turn_it_in/tii_group_attachment.rb @@ -9,7 +9,7 @@ class TiiGroupAttachment < ApplicationRecord before_destroy :delete_attachment - enum status: { + enum :status, { created: 0, has_id: 1, uploaded: 2, @@ -53,7 +53,7 @@ def self.find_or_create_from_task_definition(task_definition, filename) private def delete_attachment - return unless group_attachment_id.present? + return if group_attachment_id.blank? TiiActionDeleteGroupAttachment.create( entity: nil, diff --git a/app/models/turn_it_in/tii_submission.rb b/app/models/turn_it_in/tii_submission.rb index c12eee75a5..8e970b0749 100644 --- a/app/models/turn_it_in/tii_submission.rb +++ b/app/models/turn_it_in/tii_submission.rb @@ -32,7 +32,7 @@ def submitted_by submitted_by_user end - enum status: { + enum :status, { created: 0, has_id: 1, uploaded: 2, @@ -68,6 +68,13 @@ def create_viewer_url(user) ).perform end + # Should we flag this task for high similarity? + # + # @return [Boolean] true if the task should be flagged, false otherwise + def should_flag? + overall_match_percentage > task.tii_match_pct(idx) + end + private # Delete the turn it in submission for a task diff --git a/app/models/turn_it_in/user_tii_module.rb b/app/models/turn_it_in/user_tii_module.rb index bb183f4f4b..59fcb5c56c 100644 --- a/app/models/turn_it_in/user_tii_module.rb +++ b/app/models/turn_it_in/user_tii_module.rb @@ -18,7 +18,7 @@ def accept_tii_eula(eula_version = TurnItIn.eula_version) end def accepted_tii_eula? - return false unless Doubtfire::Application.config.tii_enabled + return false unless TurnItIn.enabled? return true unless TiiActionFetchFeaturesEnabled.eula_required? tii_eula_version == TurnItIn.eula_version diff --git a/app/models/tutor_feedback_score.rb b/app/models/tutor_feedback_score.rb new file mode 100644 index 0000000000..e18ed56749 --- /dev/null +++ b/app/models/tutor_feedback_score.rb @@ -0,0 +1,4 @@ +class TutorFeedbackScore < ApplicationRecord + belongs_to :unit_role + belongs_to :task_definition +end diff --git a/app/models/tutor_note.rb b/app/models/tutor_note.rb new file mode 100644 index 0000000000..dbc42645d7 --- /dev/null +++ b/app/models/tutor_note.rb @@ -0,0 +1,14 @@ +class TutorNote < ApplicationRecord + belongs_to :unit_role + belongs_to :user + belongs_to :task, optional: true + belongs_to :reply_to, class_name: "TutorNote", optional: true + + def task_definition_id + task&.task_definition&.id + end + + def project_id + task&.project&.id + end +end diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index a27c3fedeb..6c31fe19b3 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -7,7 +7,7 @@ class Tutorial < ApplicationRecord has_one :tutor, through: :unit_role, source: :user - has_many :groups + has_many :groups, dependent: :restrict_with_exception has_many :tutorial_enrolments, dependent: :destroy has_many :projects, through: :tutorial_enrolments diff --git a/app/models/tutorial_enrolment.rb b/app/models/tutorial_enrolment.rb index 2f5fc832d2..29c2a88833 100644 --- a/app/models/tutorial_enrolment.rb +++ b/app/models/tutorial_enrolment.rb @@ -8,7 +8,7 @@ class TutorialEnrolment < ApplicationRecord validates :project, presence: true # Always add a unique index to the DB to prevent new records from passing the validations when checked at the same time before being written - validates_uniqueness_of :tutorial, :scope => :project, message: 'already exists for the selected student' + validates :tutorial, uniqueness: { :scope => :project, message: 'already exists for the selected student' } # Ensure only one tutorial stream per stream validate :ensure_only_one_tutorial_per_stream, on: :create @@ -90,7 +90,7 @@ def action_on_student_leave_tutorial(for_tutorial_id = nil) result = :none_can_leave # Now get the group - project.groups.where(tutorial_id: for_tutorial_id || tutorial_id).each do |grp| + project.groups.where(tutorial_id: for_tutorial_id || tutorial_id).find_each do |grp| # You can move if the tutorial allows it next unless grp.limit_members_to_tutorial? @@ -129,7 +129,7 @@ def validate_tutorial_change abbr = Tutorial.find(id_from).abbreviation errors.add(:groups, "require #{project.student.name} to be in tutorial #{abbr}") else # leave after remove from group - project.groups.where(tutorial_id: id_from).each do |grp| + project.groups.where(tutorial_id: id_from).find_each do |grp| # Skip groups that can be in other tutorials next unless grp.limit_members_to_tutorial? @@ -145,7 +145,7 @@ def validate_tutorial_change # Check group removal on delete def remove_from_groups_on_destroy - project.groups.where(tutorial_id: tutorial_id).each do |grp| + project.groups.where(tutorial_id: tutorial_id).find_each do |grp| # Skip groups that can be in other tutorials next unless grp.limit_members_to_tutorial? diff --git a/app/models/tutorial_stream.rb b/app/models/tutorial_stream.rb index 2b0bb38208..4cd8584f6e 100644 --- a/app/models/tutorial_stream.rb +++ b/app/models/tutorial_stream.rb @@ -7,7 +7,9 @@ class TutorialStream < ApplicationRecord before_destroy :can_destroy?, prepend: true has_many :tutorials, dependent: :destroy - has_many :task_definitions, -> { order 'start_date ASC, abbreviation ASC' } + has_many :task_definitions, dependent: :restrict_with_exception, inverse_of: :tutorial_stream + + # Validations - methods called are private validates :unit, presence: true validates :activity_type, presence: true @@ -17,10 +19,6 @@ class TutorialStream < ApplicationRecord validates :name, presence: true, uniqueness: { scope: :unit, message: "%{value} already exists in this unit" } validates :abbreviation, presence: true, uniqueness: { scope: :unit, message: "%{value} already exists in this unit" } - def self.find_by_abbr_or_name(data) - TutorialStream.find_by(abbreviation: data) || TutorialStream.find_by(name: data) - end - private def can_destroy? @@ -31,7 +29,7 @@ def can_destroy? throw :abort elsif unit.tutorial_streams.count.eql? 2 other_tutorial_stream = (self.eql? unit.tutorial_streams.first) ? unit.tutorial_streams.second : unit.tutorial_streams.first - task_definitions.update_all(tutorial_stream_id: other_tutorial_stream.id) + task_definitions.find_each { |td| td.update(tutorial_stream_id: other_tutorial_stream.id) } task_definitions.clear true elsif unit.tutorial_streams.count.eql? 1 @@ -45,7 +43,7 @@ def handle_associated_task_defs return if unit.task_definitions.empty? or unit.tutorial_streams.count > 1 if unit.task_definitions.exists? and unit.tutorial_streams.count.eql? 1 - unit.task_definitions.update_all(tutorial_stream_id: id) + unit.task_definitions.find_each { |td| td.update(tutorial_stream_id: id) } end end end diff --git a/app/models/unit.rb b/app/models/unit.rb index 175e62c790..c3b98d6cfc 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1,11 +1,22 @@ +# frozen_string_literal: true + require 'csv' require 'bcrypt' require 'json' require 'moss_ruby' require 'csv_helper' require 'grade_helper' +require 'securerandom' class Unit < ApplicationRecord + DEFAULT_GRADE_DEFINITIONS = [ + { 'id' => 'fail', 'value' => -1, 'label' => 'Fail', 'abbreviation' => 'F' }, + { 'id' => 'pass', 'value' => 0, 'label' => 'Pass', 'abbreviation' => 'P' }, + { 'id' => 'credit', 'value' => 1, 'label' => 'Credit', 'abbreviation' => 'C' }, + { 'id' => 'distinction', 'value' => 2, 'label' => 'Distinction', 'abbreviation' => 'D' }, + { 'id' => 'high-distinction', 'value' => 3, 'label' => 'High Distinction', 'abbreviation' => 'HD' } + ].freeze + include ApplicationHelper include FileHelper include MimeCheckHelpers @@ -28,7 +39,13 @@ def self.permissions :download_stats, :download_unit_csv, :download_grades, - :exceed_capacity + :exceed_capacity, + :get_los, + :get_feedback_chips, + :download_jplag_report, + :get_marking_sessions, + :get_tutor_times, + :get_staff_notes ] # What can convenors do with units? @@ -40,15 +57,30 @@ def self.permissions :download_unit_csv, :update, :employ_staff, + :grant_spec_con, :add_tutorial, :add_task_def, :provide_feedback, + :provide_bulk_feedback, :change_project_enrolment, :download_stats, + :download_overflow_stats, :download_grades, + :download_jplag_report, :rollover_unit, :exceed_capacity, - :perform_overseer_assessment_test + :perform_overseer_assessment_test, + :get_los, + :create_feedback_chips, + :get_feedback_chips, + :get_tutor_times, + :get_tutor_times_summary, + :get_marking_sessions, + :upload_grades_csv, + :get_staff_notes, + :capture_task_completion_snapshot, + :mannage_communications, + :delete_engagement ] # What can admin do with units? @@ -64,9 +96,19 @@ def self.permissions :add_tutorial, :add_task_def, :download_stats, + :download_overflow_stats, :download_unit_csv, :download_grades, - :exceed_capacity + :exceed_capacity, + :get_los, + :create_feedback_chips, + :get_feedback_chips, + :grant_spec_con, + :download_jplag_report, + :get_marking_sessions, + :get_staff_notes, + :get_tutor_times, + :mannage_communications, ] # What can auditors do with units? @@ -74,6 +116,7 @@ def self.permissions :get_unit, :get_students, :download_stats, + :get_feedback_chips ] # What can other users do with units? @@ -98,7 +141,7 @@ def role_for(user) elsif active_projects.where('projects.user_id=:id', id: user.id).count == 1 Role.student elsif user.has_auditor_capability? && - start_date >= Date.today - Doubtfire::Application.config.auditor_unit_access_years && + start_date >= Time.zone.today - Doubtfire::Application.config.auditor_unit_access_years && end_date < DateTime.now Role.auditor elsif user.has_admin_capability? @@ -111,36 +154,43 @@ def role_for(user) # Ensure before destroy is above relations - as this needs to clear main convenor before unit roles are deleted before_destroy do update(main_convenor_id: nil) - delete_associated_files end + after_destroy :delete_associated_files + + after_update :move_files_on_code_change, if: :saved_change_to_code? after_update :propogate_date_changes_to_tasks, if: :saved_change_to_start_date? + after_update :update_overdue_tasks_aip, if: :saved_change_to_mark_late_submissions_as_assess_in_portfolio? + after_update :refresh_communication_schedule_caches, if: :saved_change_to_communication_schedule_inputs? # Model associations. # When a Unit is destroyed, any TaskDefinitions, Tutorials, and ProjectConvenor instances will also be destroyed. - has_many :projects, dependent: :destroy # projects first to remove tasks - has_many :active_projects, -> { where enrolled: true }, class_name: 'Project' - has_many :group_sets, dependent: :destroy # group sets next to remove groups - has_many :task_definitions, -> { order 'start_date ASC, abbreviation ASC' }, dependent: :destroy - has_many :tutorials, dependent: :destroy # tutorials need groups and tasks deleted before it... - has_many :tutorial_streams, dependent: :destroy - has_many :unit_roles, dependent: :destroy - has_many :learning_outcomes, dependent: :destroy - has_many :comments, through: :projects + has_many :projects, dependent: :destroy, inverse_of: :unit # projects first to remove tasks + has_many :group_sets, dependent: :destroy, inverse_of: :unit # group sets next to remove groups + has_many :task_definitions, dependent: :destroy, inverse_of: :unit + has_many :tutorials, dependent: :destroy, inverse_of: :unit # tutorials need groups and tasks deleted before it... + has_many :tutorial_streams, dependent: :destroy, inverse_of: :unit + has_many :unit_roles, dependent: :destroy, inverse_of: :unit + has_many :learning_outcomes, as: :context, dependent: :destroy # inverse_of: :unit + has_many :marking_sessions, dependent: :destroy + has_many :task_completion_snapshots, dependent: :destroy, inverse_of: :unit + has_many :peer_progress_snapshots, dependent: :destroy, inverse_of: :unit + has_many :communication_sets, class_name: 'CommunicationSet', dependent: :destroy + has_many :communication_rules, through: :communication_sets, class_name: 'CommunicationRule' + has_many :communication_set_schedules, through: :communication_sets, class_name: 'CommunicationSetSchedule' + has_many :comments, through: :projects has_many :tasks, through: :projects has_many :groups, through: :group_sets has_many :tutorial_enrolments, through: :tutorials has_many :group_memberships, through: :groups has_many :teaching_staff, through: :unit_roles, class_name: 'User', source: 'user' - has_many :learning_outcome_task_links, through: :task_definitions has_many :task_engagements, through: :projects has_many :tii_submissions, through: :tasks has_many :tii_group_attachments, through: :task_definitions has_many :campuses, through: :tutorials - has_many :convenors, -> { joins(:role).where('roles.name = :role', role: 'Convenor') }, class_name: 'UnitRole' - has_many :staff, -> { joins(:role).where('roles.name = :role_convenor or roles.name = :role_tutor', role_convenor: 'Convenor', role_tutor: 'Tutor') }, class_name: 'UnitRole' + has_one :d2l_assessment_mapping, dependent: :destroy # Unit has a teaching period belongs_to :teaching_period, optional: true @@ -161,14 +211,26 @@ def role_for(user) validates :code, uniqueness: { scope: :teaching_period, message: "%{value} already exists in this teaching period" }, if: :has_teaching_period? validates :extension_weeks_on_resubmit_request, :numericality => { :greater_than_or_equal_to => 0 } + validates :feedback_warning_threshold_days, + numericality: { greater_than_or_equal_to: 0 } + + validates :feedback_overflow_threshold_days, + numericality: { greater_than_or_equal_to: 0 } + + validate :warning_not_greater_than_overflow + validate :validate_end_date_after_start_date validate :ensure_teaching_period_dates_match, if: :has_teaching_period? - validate :ensure_main_convenor_is_appropriate + validate :ensure_main_convenor_is_appropriate, if: :main_convenor_id_changed? # Portfolio autogen date validations, must be after start date and before or equal to end date validate :autogen_date_within_unit_active_period, if: -> { start_date_changed? || end_date_changed? || teaching_period_id_changed? || portfolio_auto_generation_date_changed? } + validate :cant_disable_aip_only_if_aip_tasks_exist + validate :grade_definitions_are_valid + validate :configured_grades_preserve_used_values, if: :will_save_change_to_grade_values? + scope :current, -> { current_for_date(Time.zone.now) } scope :current_for_date, ->(date) { where('start_date <= ? AND end_date >= ?', date, date) } scope :not_current, -> { not_current_for_date(Time.zone.now) } @@ -177,12 +239,56 @@ def role_for(user) scope :set_inactive, -> { where('active = ?', false) } include UnitTiiModule + include UnitSimilarityModule + validate :warning_not_greater_than_overflow + + def warning_not_greater_than_overflow + return if feedback_warning_threshold_days <= feedback_overflow_threshold_days + + errors.add( + :feedback_warning_threshold_days, + 'must be less than or equal to the overflow threshold' + ) + end + def detailed_name "#{name} #{teaching_period.present? ? teaching_period.detailed_name : start_date.strftime('%Y-%m-%d')}" end + def active_projects + projects.where(enrolled: true) + end + + def refresh_communication_schedule_caches + communication_set_schedules.find_each(&:refresh_next_run_at!) + end + + def saved_change_to_communication_schedule_inputs? + saved_change_to_active? || saved_change_to_start_date? || saved_change_to_end_date? + end + + def ordered_task_definitions + return task_definitions.order('start_date ASC, abbreviation ASC') unless task_definitions.loaded? + + task_definitions.sort_by do |task_definition| + [ + task_definition.start_date.nil? ? 0 : 1, + task_definition.start_date, + task_definition.abbreviation.to_s + ] + end + end + + def convenors + unit_roles.where(role_id: Role.convenor_id) + end + + def staff + unit_roles.where(role_id: [Role.convenor_id, Role.tutor_id]) + end + def docker_image_name_tag return nil if overseer_image.nil? @@ -218,9 +324,9 @@ def teaching_period_id=(tp_id) def teaching_period=(tp) if tp.present? - write_attribute(:start_date, tp.start_date) - write_attribute(:end_date, tp.end_date) - write_attribute(:teaching_period_id, tp.id) + self[:start_date] = tp.start_date + self[:end_date] = tp.end_date + self[:teaching_period_id] = tp.id end super(tp) end @@ -229,11 +335,59 @@ def has_teaching_period? self.teaching_period.present? end + def grade_values + grade_definitions.filter_map { |definition| definition['value'] unless definition['value'] == -1 } + end + + def grade_definitions + normalize_grade_definitions(self[:grade_values]) + end + + def grade_value?(value) + grade_values.include?(value.to_i) + end + + def assessment_grade_value?(value) + grade_definitions.any? { |definition| definition['value'] == value.to_i } + end + + def grade_definition(value) + return nil if value.nil? + + grade_definitions.find { |definition| definition['value'] == value.to_i } + end + + def grade_label(value) + grade_definition(value)&.fetch('label', nil) || GradeHelper.grade_for(value) + end + + def grade_abbreviation(value) + grade_definition(value)&.fetch('abbreviation', nil) || GradeHelper.short_grade_for(value) + end + + def grade_definitions=(definitions) + normalized = normalize_grade_definitions(definitions, sort: false) + fail_definition = normalized.find { |definition| definition['value'] == -1 } + target_definitions = normalized.reject { |definition| definition['value'] == -1 } + + fail_definition['value'] = -1 + target_definitions.each_with_index { |definition, index| definition['value'] = index } + self[:grade_values] = grade_values_for_column([fail_definition, *target_definitions]) + end + + def grade_values_for_column(definitions) + if self.class.type_for_attribute('grade_values').is_a?(ActiveRecord::Type::Json) + definitions + else + definitions.to_json + end + end + def ensure_teaching_period_dates_match - if read_attribute(:start_date) != teaching_period.start_date + if self[:start_date] != teaching_period.start_date errors.add(:start_date, "should match teaching period date") end - if read_attribute(:end_date) != teaching_period.end_date + if self[:end_date] != teaching_period.end_date errors.add(:end_date, "should match teaching period date") end end @@ -243,9 +397,93 @@ def ensure_main_convenor_is_appropriate errors.add(:main_convenor, "must be a staff member from unit") unless id == main_convenor.unit_id errors.add(:main_convenor, "must be configured to administer unit") unless main_convenor.is_convenor? + errors.add(:main_convenor, "cannot be observer only") if main_convenor.observer_only? errors.add(:main_convenor, "must be capable of administering units - ensure user has appropriate permissions (contact admin staff to update)") unless main_convenor_user.has_convenor_capability? end + def cant_disable_aip_only_if_aip_tasks_exist + return unless will_save_change_to_mark_late_submissions_as_assess_in_portfolio? + return if mark_late_submissions_as_assess_in_portfolio? # only care about disabling + + if tasks.where(task_status_id: TaskStatus.assess_in_portfolio.id).exists? + errors.add(:mark_late_submissions_as_assess_in_portfolio, "cannot be disabled while tasks are in the Assess in Portfolio state") + end + end + + def grade_definitions_are_valid + definitions = grade_definitions + values = definitions.map { |definition| definition['value'] } + target_values = values.reject { |value| value == -1 } + + errors.add(:grade_definitions, 'must include a failure grade and at least one target grade') if definitions.length < 2 + errors.add(:grade_definitions, 'must include index -1 exactly once') unless values.count(-1) == 1 + errors.add(:grade_definitions, 'target grade indexes must be unique non-negative integers') unless target_values.all? { |value| value >= 0 } && target_values.uniq.length == target_values.length + errors.add(:grade_definitions, 'must use unique identifiers') unless definitions.map { |definition| definition['id'] }.uniq.length == definitions.length + errors.add(:grade_definitions, 'must use unique labels') unless definitions.map { |definition| definition['label'].downcase }.uniq.length == definitions.length + errors.add(:grade_definitions, 'must use unique abbreviations') unless definitions.map { |definition| definition['abbreviation'].downcase }.uniq.length == definitions.length + + definitions.each do |definition| + errors.add(:grade_definitions, 'labels must be present and no longer than 50 characters') unless definition['label'].present? && definition['label'].length <= 50 + errors.add(:grade_definitions, 'abbreviations must be present and no longer than 10 characters') unless definition['abbreviation'].present? && definition['abbreviation'].length <= 10 + end + end + + def configured_grades_preserve_used_values + used_values = task_definitions.distinct.pluck(:target_grade) + used_values |= projects.distinct.pluck(:target_grade, :submitted_grade).flatten.compact + used_values |= tasks.distinct.pluck(:grade).compact + used_values |= communication_rules.joins(:communication_conditions) + .pluck('communication_conditions.target_grade', 'communication_conditions.task_target_grade') + .flatten + .compact + used_values |= communication_rules.joins(:communication_actions) + .pluck('communication_actions.target_grade') + .compact + previous_definitions = normalize_grade_definitions(attribute_in_database('grade_values')) + current_by_id = grade_definitions.index_by { |definition| definition['id'] } + + changed_values = used_values.select do |value| + previous_definition = previous_definitions.find { |definition| definition['value'] == value } + current_definition = current_by_id[previous_definition&.fetch('id', nil)] + current_definition.nil? || current_definition['value'] != value + end + + errors.add(:grade_definitions, "cannot remove or reorder grades currently in use at indexes: #{changed_values.uniq.sort.join(', ')}") if changed_values.any? + end + + def normalize_grade_definitions(raw_definitions, sort: true) + raw_definitions = DEFAULT_GRADE_DEFINITIONS if raw_definitions.blank? + raw_definitions = JSON.parse(raw_definitions) if raw_definitions.is_a?(String) + + definitions = Array(raw_definitions).map do |definition| + definition = definition.to_h if definition.respond_to?(:to_h) + if definition.is_a?(Hash) + definition = definition.stringify_keys + { + 'id' => definition['id'].presence || SecureRandom.uuid, + 'value' => definition['value'].to_i, + 'label' => definition['label'].to_s.strip, + 'abbreviation' => definition['abbreviation'].to_s.strip.upcase + } + else + value = definition.to_i + default = DEFAULT_GRADE_DEFINITIONS.find { |item| item['value'] == value } + default&.dup || { + 'id' => "grade-#{value}", + 'value' => value, + 'label' => "Grade #{value}", + 'abbreviation' => "G#{value}" + } + end + end + + unless definitions.any? { |definition| definition['value'] == -1 } + definitions.unshift(DEFAULT_GRADE_DEFINITIONS.first.dup) + end + + sort ? definitions.sort_by { |definition| definition['value'] } : definitions + end + def validate_end_date_after_start_date if end_date.present? && start_date.present? && end_date < start_date errors.add(:end_date, "should be after the Start date") @@ -258,16 +496,23 @@ def autogen_date_within_unit_active_period end end - def rollover(teaching_period, start_date, end_date) + def rollover(teaching_period, start_date, end_date, new_code) new_unit = self.dup + copied_task_definitions = [] + + new_unit.code = new_code if new_code.present? if teaching_period.present? new_unit.teaching_period = teaching_period else + new_unit.teaching_period = nil new_unit.start_date = start_date new_unit.end_date = end_date end + # Clear archived + new_unit.archived = false + if self.portfolio_auto_generation_date.present? # Update the portfolio auto generation date to be the same day of the week and week number as the old date new_unit.portfolio_auto_generation_date = new_unit.date_for_week_and_day(week_number(self.portfolio_auto_generation_date), Date::ABBR_DAYNAMES[self.portfolio_auto_generation_date.wday]) @@ -291,9 +536,28 @@ def rollover(teaching_period, start_date, end_date) new_unit.group_sets << group_set.dup end + # Old outcome to new outcome mapping + outcome_mapping = {} + + # Duplicate unit learning outcomes - before task definitions as they are linked to them + learning_outcomes.each do |learning_outcome| + new_outcome = learning_outcome.dup + new_outcome.context = new_unit + new_outcome.save! + new_unit.learning_outcomes << new_outcome + outcome_mapping[learning_outcome] = new_outcome + end + # Duplicate task definitions task_definitions.each do |td| new_td = td.copy_to(new_unit) + copied_task_definitions << new_td + + td.learning_outcomes.each do |learning_outcome| # for each old task definition, duplicate the learning outcomes associated with it aswell + new_outcome = learning_outcome.dup + new_td.learning_outcomes << new_outcome + outcome_mapping[learning_outcome] = new_outcome + end # Update default task definition if necessary if self.draft_task_definition == td @@ -301,25 +565,66 @@ def rollover(teaching_period, start_date, end_date) end end - # Duplicate unit learning outcomes - learning_outcomes.each do |learning_outcome| - new_unit.learning_outcomes << learning_outcome.dup + task_definitions.each do |td| + new_td = new_unit.task_definitions.find_by(abbreviation: td.abbreviation) + # Duplicate task prerequisites + td.task_prerequisites.each do |prereq| + new_prerequisite_td = new_unit.task_definitions.find_by(abbreviation: prereq.prerequisite.abbreviation) + TaskPrerequisite.create!( + task_definition: new_td, + prerequisite: new_prerequisite_td, + task_status_id: prereq.task_status_id + ) + end + + # Duplicate discussion prompts + td.discussion_prompts.each do |prompt| + DiscussionPrompt.create!({ + task_definition: new_td, + content: prompt.content, + priority: prompt.priority + }) + end + end + + # Link outcomes + outcome_mapping.each do |old_outcome, new_outcome| + old_outcome.linked_outcomes.each do |old_linked_outcome| + new_target = outcome_mapping[old_linked_outcome] || old_linked_outcome + + if new_outcome.present? && new_target.present? + LearningOutcomeLink.create!(source_id: new_outcome.id, target_id: new_target.id) + end + end end - # Duplicate alignments - task_outcome_alignments.each do |align| - align.duplicate_to(new_unit) + communication_sets.each do |communication_set| + communication_set.copy_to(new_unit) end - new_unit - end + # Now duplicate all feedback chips + chip_mapping = {} - def ordered_ilos - learning_outcomes.order(:ilo_number) - end + outcome_mapping.each do |source_outcome, new_outcome| + source_outcome.feedback_chips.each do |chip| + new_chip = chip.dup + new_outcome.feedback_chips << new_chip + new_chip.learning_outcome_id = new_outcome.id + new_chip.parent_chip_id = nil + new_chip.save! + chip_mapping[chip] = new_chip + end + + source_outcome.feedback_chips.where.not(parent_chip_id: nil).find_each do |old_chip| + child_chip = chip_mapping[old_chip] + parent_chip = chip_mapping[old_chip.parent_chip] + child_chip.update(parent_chip_id: parent_chip.id) + end + end + + NewTaskAvailableNotificationJob.track_and_enqueue_all(copied_task_definitions) - def task_outcome_alignments - learning_outcome_task_links.where('task_id is NULL') + new_unit end def student_tasks @@ -331,7 +636,7 @@ def self.for_user_admin(user) Unit.all elsif user.has_auditor_capability? # Limit range of units that the auditor has access to - earliest_unit_start_date = Date.today - Doubtfire::Application.config.auditor_unit_access_years + earliest_unit_start_date = Time.zone.today - Doubtfire::Application.config.auditor_unit_access_years Unit.all.where('start_date >= :earliest_unit_start_date AND end_date < :today', earliest_unit_start_date: earliest_unit_start_date, today: DateTime.now) else Unit.joins(:unit_roles).where('unit_roles.user_id = :user_id AND unit_roles.role_id = :convenor_role', user_id: user.id, convenor_role: Role.convenor.id) @@ -343,7 +648,7 @@ def self.default unit.name = 'New Unit' unit.description = 'Enter a description for this unit.' - unit.start_date = Date.today + unit.start_date = Time.zone.today unit.end_date = 13.weeks.from_now unit @@ -356,9 +661,7 @@ def tutors User.teaching(self) end - def main_convenor_user - main_convenor.user - end + delegate :user, to: :main_convenor, prefix: true def students projects @@ -373,6 +676,7 @@ def student_query(enrolled) .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id') .joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id') .joins('LEFT OUTER JOIN tutorial_streams ON tutorials.tutorial_stream_id = tutorial_streams.id') + .joins('LEFT OUTER JOIN staff_notes ON staff_notes.project_id = projects.id') .group( 'projects.id', 'projects.target_grade', @@ -389,12 +693,14 @@ def student_query(enrolled) 'projects.compile_portfolio', 'projects.grade', 'projects.grade_rationale', + 'projects.spec_con_days', ) .select( 'projects.id AS project_id', 'projects.enrolled AS enrolled', 'projects.task_stats AS task_stats', 'projects.campus_id AS campus_id', + 'projects.spec_con_days AS spec_con_days', 'users.first_name AS first_name', 'users.last_name AS last_name', 'users.nickname AS nickname', @@ -407,12 +713,15 @@ def student_query(enrolled) 'projects.compile_portfolio AS compile_portfolio', 'projects.grade AS grade', 'projects.grade_rationale AS grade_rationale', + 'projects.spec_con_days AS spec_con_days', 'projects.portfolio_production_date AS portfolio_production_date', 'MAX(CASE WHEN task_similarities.flagged THEN task_similarities.pct ELSE 0 END) AS task_similarities_max_pct', # Get tutorial for each stream in unit *tutorial_streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.id ELSE NULL END) AS tutorial_#{s.id}" }, # Get tutorial for case when no stream - "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.id ELSE NULL END) AS tutorial" + "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.id ELSE NULL END) AS tutorial", + 'COUNT(DISTINCT staff_notes.id) AS staff_note_count', + 'projects.portfolio_submission_date as portfolio_submission_date' ) .order('users.first_name') @@ -446,6 +755,9 @@ def student_query(enrolled) similarity_flag: t.task_similarities_max_pct > 0, has_portfolio: !t.portfolio_production_date.nil?, stats: map_stats.call(t), + staff_note_count: t.staff_note_count, + portfolio_submission_date: t.portfolio_submission_date, + spec_con_days: t.spec_con_days, tutorial_enrolments: tutorial_streams.map do |s| { stream_abbr: s.abbreviation, @@ -494,6 +806,10 @@ def employ_staff(user, role) end end + def unit_role_for(user) + unit_roles.find_by(user: user) + end + # Adds a user to this project. def enrol_student(user, campus) # Validates that a student is not already assigned to the unit @@ -538,12 +854,93 @@ def sync_enrolments result end + def import_grades_from_csv(file, assessor_id, progress_callback: nil) + success = [] + errors = [] + ignored = [] + + assessor = User.find(assessor_id) + + csv = CSV.new(File.read(file), headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]) + # Read the header row to determine what kind of file it is + if csv.header_row? + csv.shift + else + errors << { row: [], message: "Header row missing" } + return + end + + total_rows = csv.read.size + progress_callback.call(message: "Parsing CSV", rows_processed: 0, total_rows: total_rows) if progress_callback + + + row_count = 0 + # Loop over csv rows converting to hash values + CSV.foreach(file, headers: true, + header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]) do |row| + begin + row_count += 1 + progress_callback.call(message: "Importing grades", rows_processed: row_count, total_rows: total_rows) if progress_callback + + missing = missing_headers(row, %w(unit_code username student_id rationale)) + if missing.count > 0 + errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } + next + end + + if self.code != row['unit_code'] + ignored << { row: row, message: "Unit code is different" } + next + end + + student = User.find_by(username: row['username']) + + if student.nil? + errors << { row: row, message: "Could not find student" } + next + end + + project = self.projects.find_by(user: student) + + if project.nil? + errors << { row: row, message: "Student is not enrolled in unit" } + next + end + + if project.grade == row['grade'].to_i && project.grade_rationale.to_s.strip == row['rationale'].to_s.strip + ignored << { row: row, message: "No change" } + next + end + + + + project.update!( + grade: row['grade'], + grade_rationale: row['rationale'], + assessor: assessor + ) + + success << { row: row, message: "Grade updated: #{row['grade']}" } + rescue Exception => e + errors << { row: row, message: e.message } + end + end # for each csv row + + { + success: success, + ignored: ignored, + errors: errors + } + end # # Imports users into a project from CSV file. # Format: Unit Code, Student ID,First Name, Surname, email, tutorial, campus # Expected columns: unit_code, username, first_name, last_name, email, tutorial, campus # - def import_users_from_csv(file) + def import_users_from_csv(file, progress_callback: nil) success = [] errors = [] ignored = [] @@ -556,8 +953,7 @@ def import_users_from_csv(file) csv = CSV.new(File.read(file), headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(i) { i.nil? ? '' : i }, ->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]) - + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]) # Read the header row to determine what kind of file it is if csv.header_row? csv.shift @@ -566,10 +962,20 @@ def import_users_from_csv(file) return end + progress_callback.call(message: "Parsing CSV", rows_processed: 0) if progress_callback + # Check if these headers should be processed by institution file or from DF format + # Asking "Who will convert the users to the right format?" + # If these are "institution formatted"? then use import settings from institution settings + # Which contain: + # - function to check if row is missing header values + # - function to convert row to hash in the required format + # - booleans for replacing tutorial/campus if different if Doubtfire::Application.config.institution_settings.are_headers_institution_users? csv.headers + logger.debug 'Importing users using institution\'s import settings' import_settings = Doubtfire::Application.config.institution_settings.user_import_settings_for(csv.headers) else + logger.debug 'Importing users using default import settings' if tutorial_streams.count > 0 stream_names = tutorial_stream_abbr.map { |abbr| abbr.downcase } else @@ -577,14 +983,16 @@ def import_users_from_csv(file) end # Settings include: - # missing_headers_lambda - lambda to check if row is missing key data - # fetch_row_data_lambda - lambda to convert row from csv to required import data + # missing_headers_lambda - lambda to check if row is missing key data, so that students missing + # any of these header names does not get imported and the missing header + # is reported. + # fetch_row_data_lambda - lambda to convert row from csv to required import data. Called for each + # row that has the required headers. # replace_existing_tutorial - boolean to indicate if tutorials in csv override ones in doubtfire # replace_existing_campus - boolean to indicate if campus in csv override ones in doubtfire import_settings = { missing_headers_lambda: ->(row) { - missing_headers(row, %w(unit_code username student_id first_name last_name email campus)) - missing_headers(row, stream_names) + missing_headers(row, %w(unit_code username student_id first_name last_name email campus) + stream_names) }, fetch_row_data_lambda: ->(row, unit) { tutorials = [] @@ -607,25 +1015,28 @@ def import_users_from_csv(file) } }, replace_existing_tutorial: true, - replace_existing_campus: true + replace_existing_campus: true, + merge_duplicate_students: false, # If true, will merge duplicate students into one entry, combining their tutorials } end student_list = [] + row_count = 0 # Loop over csv rows converting to hash values CSV.foreach(file, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(i) { i.nil? ? '' : i }, ->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]) do |row| + converters: [->(i) { i.nil? ? '' : i }, ->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]) do |row| # Check data has headers missing = import_settings[:missing_headers_lambda].call(row) if missing.count > 0 errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } next end - + row_count += 1 begin # Convert to hash... + progress_callback.call(message: "Parsing CSV", total_rows: row_count) if progress_callback row_data = import_settings[:fetch_row_data_lambda].call(row, self) row_data[:row] = row # Store in list... @@ -636,7 +1047,7 @@ def import_users_from_csv(file) end # for each csv row # Now process the listt - sync_enrolment_with(student_list, import_settings, result) + sync_enrolment_with(student_list, import_settings, result, progress_callback: progress_callback) end # Sync the unit enrolment details eith the list of enrolment data. The enrolment data @@ -659,9 +1070,10 @@ def import_users_from_csv(file) # fetch_row_data_lambda - lambda to convert row from csv to required import data # replace_existing_tutorial - boolean to indicate if tutorials in csv override ones in doubtfire # replace_existing_campus - boolean to indicate if campus in csv override ones in doubtfire - def sync_enrolment_with(enrolment_data, import_settings, result) + def sync_enrolment_with(enrolment_data, import_settings, result, progress_callback: nil) + progress_callback.call(message: "Validating CSV", rows_processed: 0) if progress_callback + # Get lists for reporting results - success = result[:success] errors = result[:errors] ignored = result[:ignored] @@ -673,7 +1085,7 @@ def sync_enrolment_with(enrolment_data, import_settings, result) enrolment_data.each do |row_data| begin if row_data[:username].nil? - ignored << { row: row, message: "Skipping row with missing username" } + ignored << { row: row_data[:row], message: "Skipping row with missing username" } next end @@ -692,7 +1104,31 @@ def sync_enrolment_with(enrolment_data, import_settings, result) if changes.key? username if row_data[:enrolled] # they should be enrolled - record that... overriding anything else # record previous row as ignored - ignored << { row: changes[username][:row], message: "Skipping duplicate role - ensuring enrolled" } + ignored_entry = { row: changes[username][:row], message: 'Skipping duplicate role - ensuring enrolled.' } + + # Allocate+ csv data may have duplicate student rows for each tutorial enrolment + if import_settings[:merge_duplicate_students] + # Init the tutorials array if nil + row_data[:tutorials] ||= [] + changes[username][:tutorials] ||= [] + + # Combine the tutorials from both rows so that the student is enrolled into each tutorial + row_data[:tutorials].concat(changes[username][:tutorials]) + + # Prefer values from previous row, but fallback to current row data if nil + row_data[:unit_code] = changes[username][:unit_code] || row_data[:unit_code] + row_data[:username] = changes[username][:username] || row_data[:username] + row_data[:student] = changes[username][:student] || row_data[:student] + row_data[:first] = changes[username][:first] || row_data[:first] + row_data[:last] = changes[username][:last] || row_data[:last] + row_data[:nickname] = changes[username][:nickname] || row_data[:nickname] + row_data[:email] = changes[username][:email] || row_data[:email] + row_data[:enrolled] = changes[username][:enrolled] || row_data[:enrolled] + row_data[:campus] = changes[username][:campus] || row_data[:campus] + ignored_entry[:message] += ' Merged duplicate student data.' + end + + ignored << ignored_entry changes[username] = row_data else # record this row as skipped @@ -706,7 +1142,7 @@ def sync_enrolment_with(enrolment_data, import_settings, result) end end # for each csv row - update_student_enrolments(changes, import_settings, result) + update_student_enrolments(changes, import_settings, result, progress_callback: progress_callback) end # csv import # Apply enrolment changes. The changes parameter should be: @@ -729,7 +1165,7 @@ def sync_enrolment_with(enrolment_data, import_settings, result) # - :replace_existing_campus boolean # # Returns hash with :success, :ignored, :errors - def update_student_enrolments(changes, import_settings, result) + def update_student_enrolments(changes, import_settings, result, progress_callback: nil) tutorial_cache = {} # Get lists for reporting results success = result[:success] @@ -737,7 +1173,8 @@ def update_student_enrolments(changes, import_settings, result) ignored = result[:ignored] # now apply the changes... - changes.each_value do |row_data| + changes.each_value.with_index(1) do |row_data, row_count| + progress_callback&.call(message: "Importing students", total_rows: changes.count, rows_processed: row_count) begin row = row_data[:row] username = row_data[:username].downcase @@ -762,33 +1199,13 @@ def update_student_enrolments(changes, import_settings, result) # Perform withdraw if needed... unless row_data[:enrolled] - # Find the user - project_participant = User.where(username: username) - - # If they dont exist... ignore - if project_participant.nil? || project_participant.count == 0 - ignored << { row: row, message: "Ignoring student to withdraw, as not enrolled" } - else - # Get the user's project - user_project = projects.where(user_id: project_participant.first.id).first - - # If no project... then not enrolled - if user_project.nil? || !user_project.enrolled - ignored << { row: row, message: "Ignoring student to withdraw, as not enrolled" } - else - # Withdraw... - user_project.enrolled = false - user_project.save - success << { row: row, message: "Student was withdrawn" } - end - end - + withdraw_user(username, success, ignored) # Move to next row as this was a withdraw... next end # Find the campus - campus = campus_data.present? ? Campus.find_by_abbr_or_name(campus_data) : nil + campus = campus_data.present? ? Campus.find_by('abbreviation = :name OR name = :name', name: campus_data) : nil if campus_data.present? && campus.nil? errors << { row: row, message: "Unable to find campus (#{campus_data})" } next @@ -815,13 +1232,14 @@ def update_student_enrolments(changes, import_settings, result) # if project_participant.persisted? # Add in the student id if it was supplied... - if (project_participant.student_id.nil? || project_participant.student_id.empty? || project_participant.student_id != student_id) && student_id.present? + if (project_participant.student_id.blank? || project_participant.student_id != student_id) && student_id.present? project_participant.student_id = student_id project_participant.save! end # Clear success message... - success_message = '' + success_message = String.new('') + ignored_message = String.new('No change.') # Now find the project for the user user_project = projects.where(user_id: project_participant.id).first @@ -830,7 +1248,7 @@ def update_student_enrolments(changes, import_settings, result) if user_project.nil? # Enrol user... user_project = enrol_student(project_participant, campus) - success_message = 'Enrolled student' + success_message << 'Enrolled student.' new_project = true else new_project = false # We are updating existing project @@ -858,20 +1276,24 @@ def update_student_enrolments(changes, import_settings, result) tutorial = tutorial_cache[tutorial_code] || tutorial_with_abbr(tutorial_code) tutorial_cache[tutorial_code] ||= tutorial - if tutorial.present? - # Use tutorial as we have it :) - begin + next if tutorial.blank? + + # Use tutorial as we have it :) + begin + unless user_project.enrolled_in?(tutorial) user_project.enrol_in tutorial success_message << ' Enrolled in ' << tutorial.abbreviation - rescue Exception => e - success_message << " UNABLE TO enroll in #{tutorial.abbreviation} #{e.message}" + next end + ignored_message << ' No change to ' << tutorial.abbreviation + rescue Exception => e + errors << { row: row, message: "#{success_message} UNABLE TO enroll in #{tutorial.abbreviation} #{e.message}" } end end end if success_message.empty? - ignored << { row: row, message: 'No change.' } + ignored << { row: row, message: ignored_message } else success << { row: row, message: success_message } end @@ -886,6 +1308,32 @@ def update_student_enrolments(changes, import_settings, result) result end + def withdraw_user(username, result) + success = result[:success] + ignored = result[:ignored] + + # Find the user + project_participant = User.where(username: username) + + # If they dont exist... ignore + if project_participant.nil? || project_participant.count == 0 + ignored << { row: row, message: "Ignoring student to withdraw, as not enrolled" } + else + # Get the user's project + user_project = projects.where(user_id: project_participant.first.id).first + + # If no project... then not enrolled + if user_project.nil? || !user_project.enrolled + ignored << { row: row, message: "Ignoring student to withdraw, as not enrolled" } + else + # Withdraw... + user_project.enrolled = false + user_project.save + success << { row: row, message: "Student was withdrawn" } + end + end + end + # Use the values in the CSV to set the enrolment of these # students to false for this unit. # CSV should contain just the usernames to withdraw @@ -901,7 +1349,7 @@ def unenrol_users_from_csv(file) CSV.parse(data, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| # Make sure we're not looking at the header or an empty line next if row[0] =~ /(username)|(((unit)|(subject))_code)/ @@ -960,7 +1408,7 @@ def export_users_to_csv grp_sets = group_sets CSV.generate do |csv| - csv << (%w(unit_code campus username student_id preferred_name first_name last_name email) + + csv << (%w(unit_code campus username student_id preferred_name first_name last_name email spec_con_days) + (streams.count > 0 ? streams.map { |t| t.abbreviation } : ['tutorial'])) active_projects @@ -972,7 +1420,7 @@ def export_users_to_csv 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', 'LEFT OUTER JOIN tutorials ON tutorial_enrolments.tutorial_id = tutorials.id' ).select( - 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', + 'projects.id as project_id', 'projects.spec_con_days as spec_con_days', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', 'users.last_name as last_name', 'users.email as email', 'users.nickname as nickname', 'campuses.abbreviation as campus_abbreviation', # Get tutorial for each stream in unit *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, @@ -989,7 +1437,8 @@ def export_users_to_csv row['nickname'], row['first_name'], row['last_name'], - row['email'] + row['email'], + row['spec_con_days'] ] + [1].map do if streams.empty? [row['tutorial']] @@ -1001,15 +1450,47 @@ def export_users_to_csv end end - def export_learning_outcome_to_csv + def export_learning_outcome_to_csv(include_tlos: false) CSV.generate do |row| row << LearningOutcome.csv_header - learning_outcomes.each do |outcome| + unit_outcomes = learning_outcomes + task_outcomes = if include_tlos + task_definitions.map(&:learning_outcomes).flatten + else + [] + end + + all_outcomes = (unit_outcomes + task_outcomes).uniq + + all_outcomes.each do |outcome| outcome.add_csv_row row end end end + def export_feedback_chips_to_csv(include_tlos: false) + CSV.generate do |row| + row << Feedback::FeedbackChip.csv_header + unit_outcomes = learning_outcomes + task_outcomes = if include_tlos + task_definitions.map(&:learning_outcomes).flatten + else + [] + end + + all_outcomes = (unit_outcomes + task_outcomes).uniq + all_outcomes.each do |outcome| + outcome.feedback_chips.each do |chip| + chip.add_csv_row row + end + end + end + end + + def export_title + code + end + def import_outcomes_from_csv(file) result = { success: [], @@ -1022,12 +1503,12 @@ def import_outcomes_from_csv(file) CSV.parse(data, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| # Make sure we're not looking at the header or an empty line next if row[0] =~ /unit_code/ begin - LearningOutcome.create_from_csv(self, row, result) + LearningOutcome.create_from_csv(self, nil, row, result) rescue Exception => e result[:errors] << { row: row, message: e.message.to_s } end @@ -1036,136 +1517,56 @@ def import_outcomes_from_csv(file) result end - def export_task_alignment_to_csv - LearningOutcomeTaskLink.export_task_alignment_to_csv(self, self) - end - - # Use the values in the CSV to setup task alignments - def import_task_alignment_from_csv(file, for_project) + # Import the actual groups from a csv - no students... + def import_groups_from_csv(group_set, file) success = [] errors = [] ignored = [] + logger.info "Starting import of group for #{group_set.name} for #{code}" + data = read_file_to_str(file) CSV.parse(data, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| - # Make sure we're not looking at the header or an empty line - next if row[0] =~ /unit_code/ + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| + next if row[0] =~ /^(group_name)|(name)/ # Skip header begin - unit_code = row['unit_code'] - - if unit_code != code - ignored << { row: row, message: "Invalid unit code. #{unit_code} does not match #{code}" } + missing = missing_headers(row, %w(group_name tutorial capacity_adjustment)) + if missing.count > 0 + errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } next end - outcome_abbr = row['learning_outcome'] - outcome = learning_outcomes.where('abbreviation = :abbr', abbr: outcome_abbr).first + change = '' - if outcome.nil? - errors << { row: row, message: "Unable to locate learning outcome with abbreviation #{outcome_abbr}" } - next - end + # Get name from csv + group_name = row['group_name'].strip unless row['group_name'].nil? - task_def_abbr = row['task_abbr'] - task_def = task_definitions.where('abbreviation = :abbr', abbr: task_def_abbr).first + # Find or create the group object + grp = group_set.groups.find_or_create_by(name: group_name) - if task_def.nil? - errors << { row: row, message: "Unable to locate task with abbreviation #{task_def_abbr}" } - next - end + # Find the tutorial + tutorial_abbr = row['tutorial'].strip unless row['tutorial'].nil? + tutorial = tutorial_with_abbr(tutorial_abbr) - rating = row['rating'].to_i - description = row['description'] + if tutorial.nil? + change += ' Created new tutorial.' - if for_project.nil? - link = LearningOutcomeTaskLink.find_or_create_by(task_definition_id: task_def.id, learning_outcome_id: outcome.id, task_id: nil) - else - task = for_project.tasks.where('task_definition_id = :tdid', tdid: task_def.id).first + campus_data = row['campus'].strip unless row['campus'].nil? + campus = Campus.find_by('abbreviation = :name OR name = :name', name: campus_data) - if task.nil? - errors << { row: row, message: "Unable to locate task related to #{task_def_abbr}" } - next - end - link = LearningOutcomeTaskLink.find_or_create_by(task_definition_id: task_def.id, learning_outcome_id: outcome.id, task_id: task.id) - end - - link.rating = rating - link.description = description - - link.save! - - if link.new_record? - success << { row: row, message: "Link between task #{task_def.abbreviation} and outcome #{outcome.abbreviation} created for unit" } - else - success << { row: row, message: "Link between task #{task_def.abbreviation} and outcome #{outcome.abbreviation} updated for unit" } - end - rescue Exception => e - errors << { row: row, message: e.message.to_s } - end - end - - { - success: success, - ignored: ignored, - errors: errors - } - end - - # Import the actual groups from a csv - no students... - def import_groups_from_csv(group_set, file) - success = [] - errors = [] - ignored = [] - - logger.info "Starting import of group for #{group_set.name} for #{code}" - - data = read_file_to_str(file) - - CSV.parse(data, - headers: true, - header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| - next if row[0] =~ /^(group_name)|(name)/ # Skip header - - begin - missing = missing_headers(row, %w(group_name tutorial capacity_adjustment)) - if missing.count > 0 - errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } - next - end - - change = '' - - # Get name from csv - group_name = row['group_name'].strip unless row['group_name'].nil? - - # Find or create the group object - grp = group_set.groups.find_or_create_by(name: group_name) - - # Find the tutorial - tutorial_abbr = row['tutorial'].strip unless row['tutorial'].nil? - tutorial = tutorial_with_abbr(tutorial_abbr) - - if tutorial.nil? - change += ' Created new tutorial.' - - campus_data = row['campus'].strip unless row['campus'].nil? - campus = Campus.find_by_abbr_or_name(campus_data) - - tutorial = add_tutorial( - 'Monday', - '8:00am', - 'TBA', - main_convenor_user, - campus, - nil, # capacity - tutorial_abbr - ) + tutorial = add_tutorial( + 'Monday', + '8:00am', + 'TBA', + main_convenor_user, + campus, + nil, # capacity + tutorial_abbr + ) end # If it is new we need to load details from the csv @@ -1204,7 +1605,7 @@ def import_student_groups_from_csv(group_set, file) CSV.parse(data, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| next if row[0] =~ /^(group_name)|(name)/ # Skip header begin @@ -1245,7 +1646,9 @@ def import_student_groups_from_csv(group_set, file) project.enrol_in(grp.tutorial) end - grp.add_member(project) + # Bulk imports can add many students in one request. Do not send a + # separate notification for every CSV row. + grp.add_member(project, notify: false) success << { row: row, message: "Added #{username} to #{grp.name}." } rescue Exception => e @@ -1318,38 +1721,49 @@ def date_for_week_and_day(week, day) start_day_num = start_date.wday - start_date + week.weeks + (day_num - start_day_num).days + start_date + (week - 1).weeks + (day_num - start_day_num).days end end def week_number(date) + return nil if date.nil? || start_date.nil? + if teaching_period.present? teaching_period.week_number(date) else - ((date - start_date) / 1.week).floor + 1 + target_date = date.to_date + unit_start_date = start_date.to_date + ((target_date - unit_start_date).to_i / 7).floor + 1 end end - def import_tasks_from_csv(file) + def import_tasks_from_csv(file, notify: true) success = [] errors = [] ignored = [] + imported_task_definitions = [] data = read_file_to_str(file) + prerequisites_by_task = {} + CSV.parse(data, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_').to_sym unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| - next if row[0] =~ /^(Task Name)|(name)/ # Skip header + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| + next if ['Task Name', 'name'].include?(row[0].to_s.strip) # Skip header rows begin - missing = missing_headers(row, TaskDefinition.csv_columns) + missing = missing_headers(row, TaskDefinition.required_csv_columns) if missing.count > 0 errors << { row: row, message: "Missing headers: #{missing.join(', ')}" } next end + existing_task_definition = task_definitions.find_by(abbreviation: row[:abbreviation]&.strip) + existing_task_definition ||= task_definitions.find_by(name: row[:name]&.strip) + existing_task_definition&.task_prerequisites&.destroy_all + task_definition, new_task, message = TaskDefinition.task_def_for_csv_row(self, row) if task_definition.nil? @@ -1357,12 +1771,53 @@ def import_tasks_from_csv(file) next end + prerequisites_by_task[task_definition.abbreviation] = JSON.parse(row[:task_prerequisites]) unless row[:task_prerequisites].nil? + imported_task_definitions << task_definition if new_task + success << { row: row, message: message } rescue Exception => e errors << { row: row, message: e.message } end end + # Parse task prerequisites once all task definitions have been imported + prerequisites_by_task.each do |task_abbreviation, prerequisites_list| + td = task_definitions.find_by(abbreviation: task_abbreviation) + next if td.nil? + + begin + td.task_prerequisites.destroy_all + + next if prerequisites_list.nil? + next if prerequisites_list.empty? + prerequisites_list.each do |prerequisite| + abbreviation = prerequisite['abbreviation'] + prerequisite_td = task_definitions.find_by(abbreviation: abbreviation) + task_status_id = prerequisite['task_status_id'].to_i + + if prerequisite_td.nil? + errors << { + row: "TaskDef '#{task_abbreviation}' prerequisites: #{prerequisites_list}", + message: "Unable to find prerequisite task definition with abbreviation #{abbreviation}." + } + next + end + + TaskPrerequisite.create!({ + task_definition_id: td.id, + prerequisite: prerequisite_td, + task_status_id: task_status_id + }) + end + rescue Exception => e + errors << { row: "TaskDef '#{task_abbreviation}' prerequisites: #{prerequisites_list}", message: e.message } + end + end + + if notify + NewTaskAvailableNotificationJob.track_and_enqueue_all(imported_task_definitions) + end + { success: success, ignored: ignored, @@ -1383,23 +1838,161 @@ def tutorial_stream_abbr tutorial_streams.map { |ts| ts.abbreviation } end + def times_tasks_have_been_assessed + CSV.generate() do |csv| + # Add headers + csv << [ + 'Tutorial', + 'Tutor', + 'Student Username', + 'Student Name', + 'Project ID', + 'Task Definition', + 'Task ID', + 'complete', + 'need_help', + 'fix_and_resubmit', + 'redo', + 'discuss', + 'demonstrate', + 'ready_for_feedback', + 'discussed_in_class' + ] + + tasks + .joins("LEFT JOIN task_engagements ON task_engagements.task_id = tasks.id") + .joins(:task_definition) + .joins("INNER JOIN users ON users.id = projects.user_id") + .joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)") + .joins("LEFT JOIN tutorials ON tutorials.id = sq.tutorial_id") + .joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND task_comments.content_type = 'discussed_in_class'") + .select( + "tutorials.abbreviation AS tutorial_abbreviation", + "tutorials.unit_role_id as unit_role_id", + "users.username AS username", + "users.first_name AS first_name", + "users.last_name AS last_name", + "tasks.project_id AS project_id", + "task_definitions.abbreviation AS task_abbr", + "tasks.id AS task_id", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Complete') AS complete_count", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Need Help') AS need_help_count", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Fix and Resubmit') AS fix_and_resubmit_count", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Redo') AS redo_count", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Discuss') AS discuss_count", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Demonstrate') AS demonstrate_count", + "(SELECT COUNT(*) FROM task_engagements te WHERE te.task_id = tasks.id AND te.engagement = 'Ready for Feedback') AS ready_for_feedback_count", + "(SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = tasks.id AND tc.content_type = 'discussed_in_class') AS discussed_in_class_count", + ) + .group("tasks.id, task_definitions.abbreviation, tutorials.id, users.id") + .each do |row| + csv << [ + row["tutorial_abbreviation"], + row["unit_role_id"].present? ? UnitRole.find(row["unit_role_id"]).user.name : "", + row["username"], + "#{row['first_name']} #{row['last_name']}", + row["project_id"], + row["task_abbr"], + row["task_id"], + row["complete_count"], + row["need_help_count"], + row["fix_and_resubmit_count"], + row["redo_count"], + row["discuss_count"], + row["demonstrate_count"], + row["ready_for_feedback_count"], + row["discussed_in_class_count"] + ] + end + end + end + + + def days_awaiting_feedback_by_tutorial_csv + CSV.generate() do |csv| + # Add headers + csv << [ + 'Tutorial', + 'Tutor', + 'Username', + 'Student Name', + 'Project ID', + 'Task Definition', + 'Task ID', + 'Days Awaiting Feedback', + 'Days Awaiting Feedback (Incl. Breaks)' + ] + + # Add data + rows = tasks + .includes(project: { unit: { teaching_period: :breaks } }) + .joins(:task_definition) + .joins('INNER JOIN users ON users.id = projects.user_id') + .joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)") + .joins('LEFT JOIN tutorials ON tutorials.id = sq.tutorial_id') + .select( + 'users.username AS username', + 'users.first_name AS first_name', + 'users.last_name AS last_name', + 'tasks.id as task_id', + 'task_definitions.abbreviation as task_abbr', + 'tasks.project_id as project_id', + 'tasks.submission_date', + 'tutorial_id', + 'tutorials.unit_role_id as unit_role_id', + 'tutorials.abbreviation AS tutorial_abbreviation' + ) + .group('tasks.id', 'task_definitions.abbreviation', 'tasks.project_id', 'tutorial_id', 'unit_role_id', 'tasks.submission_date') + .where(projects: { enrolled: true }) + .where(task_status: TaskStatus.ready_for_feedback) + .to_a + + unit_roles_by_id = UnitRole.includes(:user).where(id: rows.map(&:unit_role_id).compact.uniq).index_by(&:id) + + rows + .sort_by { |row| [row.unit_role_id || Float::INFINITY, -row.days_awaiting_feedback] } + .each do |row| + csv << [ + row['tutorial_abbreviation'], + row.unit_role_id.present? ? unit_roles_by_id[row.unit_role_id]&.user&.name.to_s : '', + row['username'], + "#{row['first_name']} #{row['last_name']}", + row['project_id'], + row['task_abbr'], + row['task_id'], + row.days_awaiting_feedback, + row.calendar_days_awaiting_feedback + ] + end + end + end + def task_completion_csv + task_completion_csv_generator() + end + + def task_completion_csv_generator(task_status_uses_id: false) task_def_by_grade = task_definitions_by_grade streams = tutorial_streams grp_sets = group_sets + base_headers = [ + 'Student ID', + 'Username', + 'Student Name', + ] + base_headers << 'Campus' + base_headers.push( + 'Target Grade', + 'Email', + 'Portfolio', + 'Grade', + 'Rationale', + 'Assessor', + ) CSV.generate() do |csv| # Add header row - csv << ([ - 'Student ID', - 'Username', - 'Student Name', - 'Target Grade', - 'Email', - 'Portfolio', - 'Grade', - 'Rationale', - ] + + csv << (base_headers + (streams.count > 0 ? streams.map { |t| t.abbreviation } : ['Tutorial']) + grp_sets.map(&:name) + task_def_by_grade.map do |task_definition| @@ -1414,18 +2007,25 @@ def task_completion_csv # Get the details to fetch for each task definition... td_select = task_def_by_grade.map do |td| result = [] - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + if task_status_uses_id + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" + else + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + end result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? result end.flatten + group_table_alias = 'task_completion_groups' + # Query across all projects, joined to task's via definitions to ensure all definitions are covered active_projects .joins( :unit, 'INNER JOIN users ON projects.user_id = users.id', + 'LEFT OUTER JOIN campuses ON campuses.id = projects.campus_id', 'INNER JOIN task_definitions ON task_definitions.unit_id = units.id', 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', @@ -1433,28 +2033,33 @@ def task_completion_csv 'LEFT OUTER JOIN tasks ON tasks.task_definition_id = task_definitions.id AND projects.id = tasks.project_id', 'LEFT OUTER JOIN task_statuses ON tasks.task_status_id = task_statuses.id', 'LEFT OUTER JOIN group_memberships ON group_memberships.project_id = projects.id AND group_memberships.active = TRUE', - 'LEFT OUTER JOIN groups ON groups.id = group_memberships.group_id' + "LEFT OUTER JOIN #{Group.quoted_table_name} #{group_table_alias} ON #{group_table_alias}.id = group_memberships.group_id" ).select( - 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', - 'users.last_name as last_name', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', + 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', 'projects.assessor_id as project_assessor', + 'users.last_name as last_name', 'campuses.abbreviation as campus_abbreviation', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', *td_select, # Get tutorial for each stream in unit *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, # Get tutorial for case when no stream "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial", - *grp_sets.map { |gs| "MAX(CASE WHEN groups.group_set_id = #{gs.id} THEN groups.name ELSE NULL END) AS grp_#{gs.id}" } + *grp_sets.map { |gs| "MAX(CASE WHEN #{group_table_alias}.group_set_id = #{gs.id} THEN #{group_table_alias}.name ELSE NULL END) AS grp_#{gs.id}" } ).group( - 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' + 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'campus_abbreviation', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' ).each do |row| - csv << ([ + student_details = [ row['student_id'], row['username'], "#{row['first_name']} #{row['last_name']}", - GradeHelper.grade_for(row['target_grade']), + ] + student_details << row['campus_abbreviation'] + + csv << (student_details + [ + grade_label(row['target_grade']), row['email'], - row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], true)), + row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], create: true)), row['grade'] > 0 ? row['grade'] : nil, - row['grade_rationale'] + row['grade_rationale'], + row['project_assessor'] ] + [1].map do if streams.empty? [row['tutorial']] @@ -1464,8 +2069,12 @@ def task_completion_csv end.flatten + grp_sets.map do |gs| row["grp_#{gs.id}"] end + task_def_by_grade.map do |td| - result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] - result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? + if task_status_uses_id + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.id : row["status_#{td.id}"].to_i] + else + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] + end + result << grade_abbreviation(row["grade_#{td.id}"]) if td.is_graded? result << row["stars_#{td.id}"] if td.has_stars? result << row["people_#{td.id}"] if td.is_group_task? result @@ -1474,21 +2083,62 @@ def task_completion_csv end end + def staff_notes_csv + CSV.generate() do |csv| + # Add headers + csv << [ + 'Student Username', + 'Project ID', + 'Student Name', + 'Staff Note', + 'Created', + 'Author Name', + 'Author Username', + ] + + StaffNote.joins(project: :unit) + .where(units: { id: id }) + .order('projects.id', 'created_at') + .each do |row| + csv << [ + row.project.student.username, + row.project.id.to_s, + row.project.student.name, + row.note, + row.created_at.localtime, + row.user.name, + row.user.username, + ] + end + end + end + + def get_portfolio_zip_filename(current_user) + filename = FileHelper.sanitized_filename("portfolios-#{code}-#{current_user.username}") + "#{FileHelper.tmp_file(filename)}.zip" + end + # # Create a temp zip file with all student portfolios # - def get_portfolio_zip(current_user) + def get_portfolio_zip(current_user, progress_callback: nil) # Get a temp file path - filename = FileHelper.sanitized_filename("portfolios-#{code}-#{current_user.username}") - result = "#{FileHelper.tmp_file(filename)}.zip" + portfolio_zip_name = get_portfolio_zip_filename(current_user) - return result if File.exist?(result) + # All active projects with a compiled portfolio + portfolio_projects = active_projects.select(&:portfolio_available) + progress_callback.call(message: "Initialising portfolio download", total_rows: portfolio_projects.count, rows_processed: portfolio_projects.count) if progress_callback + + return portfolio_zip_name if File.exist?(portfolio_zip_name) + + progress_callback.call(message: "Initialising portfolio download", total_rows: portfolio_projects.count, rows_processed: 0) if progress_callback + count = 0 # Create a new zip - Zip::File.open(result, Zip::File::CREATE) do |zip| - active_projects.each do |project| - # Skip if no portfolio at this time... - next unless project.portfolio_available + Zip::File.open(portfolio_zip_name, Zip::File::CREATE) do |zip| + portfolio_projects.each do |project| + count += 1 + progress_callback.call(message: "Compressing portfolios", rows_processed: count) if progress_callback # Add file to zip in grade folder src_path = project.portfolio_path @@ -1498,7 +2148,7 @@ def get_portfolio_zip(current_user) zip.add(dst_path, src_path) end # active_projects end # zip - result + portfolio_zip_name end # @@ -1530,26 +2180,34 @@ def get_task_resources_zip # # Create a temp zip file with all submission PDFs for a task # - def get_task_submissions_pdf_zip(current_user, td) + def get_task_submissions_pdf_zip(current_user, td, progress_callback: nil) # Get a temp file path result = FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-pdfs.zip") tasks_with_files = td.related_tasks_with_files + progress_callback.call(message: "Initialising submission pdfs download", total_rows: tasks_with_files.count, rows_processed: tasks_with_files.count) if progress_callback return result if File.exist?(result) + progress_callback.call(message: "Initialising submission pdfs download", total_rows: tasks_with_files.count, rows_processed: 0) if progress_callback + + count = 0 + # Create a new zip Zip::File.open(result, Zip::File::CREATE) do |zip| Dir.mktmpdir do |dir| # Extract all of the files... tasks_with_files.each do |task| + count += 1 + progress_callback.call(message: "Compressing submission pdfs", rows_processed: count) if progress_callback + path_part = if td.is_group_task? && task.group task.group.name.to_s else task.student.username.to_s end - FileUtils.cp task.portfolio_evidence_path, File.join(dir, path_part.to_s) + '.pdf' + FileUtils.cp task.final_pdf_path, File.join(dir, path_part.to_s) + '.pdf' end # each task # Copy files into zip @@ -1563,19 +2221,26 @@ def get_task_submissions_pdf_zip(current_user, td) # # Create a temp zip file with all submissions for a task # - def get_task_submissions_zip(current_user, td) + def get_task_submissions_zip(current_user, td, progress_callback: nil) # Get a temp file path result = FileHelper.tmp_file("submissions-#{code}-#{td.abbreviation}-#{current_user.username}-files.zip") tasks_with_files = td.related_tasks_with_files + progress_callback.call(message: "Initialising submission files download", total_rows: tasks_with_files.count, rows_processed: tasks_with_files.count) if progress_callback return result if File.exist?(result) + progress_callback.call(message: "Initialising submission files download", total_rows: tasks_with_files.count, rows_processed: 0) if progress_callback + + count = 0 # Create a new zip Zip::File.open(result, Zip::File::CREATE) do |zip| Dir.mktmpdir do |dir| # Extract all of the files... tasks_with_files.each do |task| + count += 1 + progress_callback.call(message: "Compressing submission files", rows_processed: count) if progress_callback + path_part = if td.is_group_task? && task.group task.group.name.to_s else @@ -1597,35 +2262,6 @@ def get_task_submissions_zip(current_user, td) result end - # - # Create an ILO - # - def add_ilo(name, desc, abbr) - next_num = learning_outcomes.count + 1 - - LearningOutcome.create!( - unit_id: id, - name: name, - description: desc, - abbreviation: abbr, - ilo_number: next_num - ) - end - - # - # Reorder ILO sequence numbers based on ILO update - # - def move_ilo(ilo, new_num) - if ilo.ilo_number < new_num - logger.debug "Moving ILOs up #{ilo.ilo_number} to #{new_num}" - learning_outcomes.where("ilo_number > #{ilo.ilo_number} and ilo_number <= #{new_num}").find_each { |ilo| ilo.ilo_number -= 1; ilo.save } - elsif ilo.ilo_number > new_num - learning_outcomes.where("ilo_number < #{ilo.ilo_number} and ilo_number >= #{new_num}").find_each { |ilo| ilo.ilo_number += 1; ilo.save } - end - ilo.ilo_number = new_num - ilo.save - end - # # Get all of the related tasks # @@ -1674,9 +2310,6 @@ def import_task_files_from_zip(zip_file) result end - - - def tasks_as_hash(data) task_ids = data.map(&:task_id).uniq data.map do |t| @@ -1702,54 +2335,62 @@ def tasks_as_hash(data) def tutorial_enrolment_subquery tutorial_enrolments .joins(:tutorial) - .select('tutorials.tutorial_stream_id as tutorial_stream_id', 'tutorials.id as tutorial_id', 'project_id').to_sql + .select('tutorials.tutorial_stream_id as tutorial_stream_id', 'tutorials.id as tutorial_id', 'project_id', 'tutorials.unit_role_id as unit_role_id').to_sql end # # Return all tasks from the database for this unit and given user # - def get_all_tasks_for(user) - student_tasks. - joins(:task_status). - joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)"). - joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment')"). - joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}"). - joins("LEFT JOIN task_pins ON task_pins.task_id = tasks.id AND task_pins.user_id = #{user.id}"). - joins('LEFT OUTER JOIN task_similarities ON tasks.id = task_similarities.task_id'). - select( - 'sq.tutorial_id AS tutorial_id', - 'sq.tutorial_stream_id AS tutorial_stream_id', - 'tasks.id', - "SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread", - 'COUNT(distinct task_pins.task_id) != 0 as pinned', - "SUM(case when task_comments.date_extension_assessed IS NULL AND task_comments.type = 'ExtensionComment' AND NOT task_comments.id IS NULL THEN 1 ELSE 0 END) > 0 as has_extensions", - 'project_id', - 'tasks.id as task_id', - 'task_definition_id', - 'task_definitions.start_date as start_date', - 'task_statuses.id as status_id', - 'completion_date', - 'times_assessed', - 'submission_date', - 'tasks.grade as grade', - 'quality_pts', - 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count' - ). - group( - 'sq.tutorial_id', - 'sq.tutorial_stream_id', - 'task_statuses.id', - 'project_id', - 'tasks.id', - 'task_definition_id', - 'task_definitions.start_date', - 'status_id', - 'completion_date', - 'times_assessed', - 'submission_date', - 'grade', - 'quality_pts' - ) + def get_all_tasks_for(user, my_tutorials_only = false) + result = student_tasks. + joins(:task_status). + joins("LEFT OUTER JOIN (#{tutorial_enrolment_subquery}) as sq ON sq.project_id = projects.id AND (sq.tutorial_stream_id = task_definitions.tutorial_stream_id OR sq.tutorial_stream_id IS NULL)"). + joins("LEFT JOIN task_comments ON task_comments.task_id = tasks.id AND (task_comments.type IS NULL OR task_comments.type <> 'TaskStatusComment') AND (task_comments.content_type IS NULL OR (task_comments.content_type <> 'plan' AND task_comments.content_type <> 'discussed_in_class'))"). + joins("LEFT JOIN comments_read_receipts crr ON crr.task_comment_id = task_comments.id AND crr.user_id = #{user.id}"). + joins("LEFT JOIN task_pins ON task_pins.task_id = tasks.id AND task_pins.user_id = #{user.id}"). + joins('LEFT OUTER JOIN task_similarities ON tasks.id = task_similarities.task_id'). + select( + 'sq.tutorial_id AS tutorial_id', + 'sq.tutorial_stream_id AS tutorial_stream_id', + 'tasks.id', + "SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) as number_unread", + 'COUNT(distinct task_pins.task_id) != 0 as pinned', + "SUM(case when task_comments.date_extension_assessed IS NULL AND task_comments.type = 'ExtensionComment' AND NOT task_comments.id IS NULL THEN 1 ELSE 0 END) > 0 as has_extensions", + 'project_id', + 'tasks.id as task_id', + 'task_definition_id', + 'task_definitions.start_date as start_date', + 'task_statuses.id as status_id', + 'completion_date', + 'times_assessed', + 'submission_date', + 'tasks.grade as grade', + 'quality_pts', + 'SUM(case when task_similarities.flagged then 1 else 0 end) as similar_to_count' + ). + group( + 'sq.tutorial_id', + 'sq.tutorial_stream_id', + 'task_statuses.id', + 'project_id', + 'tasks.id', + 'task_definition_id', + 'task_definitions.start_date', + 'status_id', + 'completion_date', + 'times_assessed', + 'submission_date', + 'grade', + 'quality_pts' + ) + if my_tutorials_only + unit_role = unit_role_for(user) + unless unit_role.nil? + result = result.where('sq.unit_role_id = :unit_role_id', unit_role_id: unit_role.id) + end + end + + result end # @@ -1757,7 +2398,7 @@ def get_all_tasks_for(user) # def tasks_awaiting_feedback(user) get_all_tasks_for(user) - .where('task_statuses.id IN (:ids)', ids: [TaskStatus.discuss, TaskStatus.redo, TaskStatus.demonstrate, TaskStatus.fix_and_resubmit]) + .where('task_statuses.id IN (:ids)', ids: [TaskStatus.discuss, TaskStatus.rediscuss, TaskStatus.attention_required, TaskStatus.redo, TaskStatus.demonstrate, TaskStatus.fix_and_resubmit]) .order('task_definition_id') end @@ -1772,12 +2413,136 @@ def tasks_awaiting_feedback(user) # time a task has been "actioned", either the submission date or latest # student comment -- whichever is newer. # - def tasks_for_task_inbox(user) - get_all_tasks_for(user) + def tasks_for_task_inbox(user, my_students_only = false) + get_all_tasks_for(user, my_students_only) .having('task_statuses.id IN (:ids) OR COUNT(task_pins.task_id) > 0 OR SUM(case when crr.user_id is null AND NOT task_comments.id is null then 1 else 0 end) > 0', ids: [TaskStatus.ready_for_feedback, TaskStatus.need_help]) .order('pinned DESC, submission_date ASC, MAX(task_comments.created_at) ASC, task_definition_id ASC') end + # + # Return the tasks that have been selected to be moderated by the tutor's mentor + # + # Tasks that are included are: + # - If a ModeratedTask exists for a task, and has not been dismissed + # - A TaskComment exists from the task's tutor, that is atleast 15 minutes old + # + # Tasks with the oldest feedback are shown first + # + def tasks_for_moderation(user) + my_unit_role = unit_role_for(user) + mentees = my_unit_role ? staff.where(mentor_id: my_unit_role.id) : staff.none + + tasks = student_tasks + .joins(:moderated_task) + .where(moderated_tasks: { state: %i[open waiting_for_new_feedback] }) + .where(projects: { unit_id: id }) + .joins(:task_definition) + .joins(project: { tutorial_enrolments: :tutorial }) + .where('tutorials.tutorial_stream_id = task_definitions.tutorial_stream_id') + .select( + 'tasks.id AS task_id', + 'tasks.project_id', + 'tasks.task_definition_id', + # 'tutorials.id AS tutorial_id', + 'tasks.task_status_id AS status_id', + 'tasks.completion_date', + 'tasks.submission_date', + # 'tasks.times_assessed', + # 'tasks.grade', + # 'tasks.quality_pts', + # '0 AS number_unread', + # '0 AS similar_to_count', + # 'false AS pinned', + # 'false AS has_extensions', + 'tasks.*', + ) + .distinct + + if my_unit_role.nil? || my_unit_role.role != Role.convenor + tasks = tasks.where(tutorials: { unit_role_id: mentees.select(:id) }) + end + + # Only include tasks where the tutor's latest comment is at least 15 minutes old + # to ensure that the feedback is likely complete before adding it for moderation + comment_threshold = 15.minutes.ago + + tasks = tasks.to_a.select do |task| + mt = task.moderated_task + next false if mt.nil? + next true if mt.escalation? + + feedback_time = task.last_tutor_feedback_at + feedback_time.present? && + feedback_time <= comment_threshold && + feedback_time > (mt.last_moderated_date || epoch) + end + + tasks.sort_by { |task| [task.moderated_task.escalation? ? 0 : 1, task.last_tutor_feedback_at || Time.zone.at(0)] } + end + + + # + # Return the tasks that have been waiting for feedback the longest (past the unit's feedback threshold setting) + # + # Tasks that are included are: + # - If the task is ready for feedback + # - Has been submitted more than {{ unit.feedback_overflow_threshold_days }} days ago + # - Has not been claimed by another tutor + # + # Tasks claimed by the current user will be shown first, then sorted by showing oldest submitted tasks first + # + def tasks_for_overflow_marking(user) + threshold = feedback_overflow_threshold_days.days.ago + unit_role_id = unit_role_for(user)&.id + + tasks = student_tasks + .includes(:comments, :overflow_task_claim) + .where(projects: { unit_id: id }) + .joins(:task_definition) + .where(tasks: { task_status_id: TaskStatus.ready_for_feedback.id }) + .where('tasks.submission_date <= ?', threshold) + .select( + 'tasks.id AS task_id', + 'tasks.project_id', + 'tasks.task_definition_id', + '(SELECT te.tutorial_id + FROM tutorial_enrolments te + WHERE te.project_id = tasks.project_id + ORDER BY te.created_at DESC + LIMIT 1) AS tutorial_id', + 'tasks.task_status_id AS status_id', + 'tasks.completion_date', + 'tasks.submission_date', + 'tasks.times_assessed', + 'tasks.grade', + 'tasks.quality_pts', + '0 AS number_unread', + '0 AS similar_to_count', + 'false AS pinned', + 'false AS has_extensions', + 'tasks.*' + ) + .distinct + .to_a + + # Filter out tasks claimed by someone else (active claim only) + tasks = tasks.select do |task| + claim = task.active_overflow_task_claim + claim.nil? || claim.claimed_by_unit_role_id == unit_role_id + end + + # Sort: + # 1) tasks claimed by current user (active claim) first + # 2) then oldest submission first + tasks.sort_by! do |task| + claim = task.active_overflow_task_claim + claimed_by_me = claim && claim.claimed_by_unit_role_id == unit_role_id ? 0 : 1 + [claimed_by_me, task.submission_date] + end + + tasks + end + # # Return stats on the number of students in each status for each task / tutorial # @@ -1881,7 +2646,7 @@ def _student_task_completion_data_base def _calculate_task_completion_stats(data) values = data.map { |r| r[:num] } - if values && !values.empty? + if values.present? values.sort! median_value = if values.length.even? @@ -1927,186 +2692,18 @@ def student_task_completion_stats result[:tutorial][t.id] = _calculate_task_completion_stats(data.select { |r| r[:tutorial_id] == t.id }) end - for i in GradeHelper::RANGE do + for i in grade_values do result[:grade][i] = _calculate_task_completion_stats(data.select { |r| r[:grade] == i }) end result end - # - # Returns a result that maps tutorial_id -> { student outcome map } - # Where the student outcome map contains each LO and its rating for that student (no student id) - # - def student_ilo_progress_stats - data = student_tasks - .joins(task_definition: :learning_outcome_task_links) - .joins(:task_status) - .joins('LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id') - .joins('LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = task_definitions.tutorial_stream_id OR tutorials.tutorial_stream_id IS NULL)') - .select('tutorials.tutorial_stream_id as tutorial_stream_id, tutorial_enrolments.tutorial_id as tutorial_id, projects.id as project_id, task_statuses.id as status_id, task_definitions.target_grade, learning_outcome_task_links.learning_outcome_id, learning_outcome_task_links.rating, COUNT(tasks.id) as num') - .where('projects.enrolled = TRUE AND learning_outcome_task_links.task_id is NULL') - .group('tutorial_enrolments.tutorial_id, tutorials.tutorial_stream_id, projects.id, task_statuses.id, task_definitions.target_grade, learning_outcome_task_links.learning_outcome_id, learning_outcome_task_links.rating') - .order('tutorial_enrolments.tutorial_id, projects.id') - .map do |r| - { - project_id: r.project_id, - tutorial_id: r.tutorial_id, - tutorial_stream_id: r.tutorial_stream_id, - learning_outcome_id: r.learning_outcome_id, - rating: r.rating, - grade: r.target_grade, - status: TaskStatus.id_to_key(r.status_id), - num: r.num - } - end - - grade_weight = { 0 => 1, 1 => 2, 2 => 4, 3 => 8 } - status_weight = { - not_started: 0.0, - fail: 0.0, - working_on_it: 0.0, - need_help: 0.0, - redo: 0.1, - feedback_exceeded: 0.1, - fix_and_resubmit: 0.3, - time_exceeded: 0.5, - ready_for_feedback: 0.7, - discuss: 0.8, - demonstrate: 0.8, - complete: 1.0 - } - - result = {} - - # order by tutorial and project... - current = nil - data.each do |e| - # chech for change in tutorial - if current.nil? || e[:tutorial_id] != current[:tutorial_id] - # if there was a currentious element - if current - # add the project to the tutorial - current[:tutorial] << current[:project] - - # add the tutorial to the results - result[current[:tutorial_id]] = current[:tutorial] - end - - current = { project_id: e[:project_id], tutorial_id: e[:tutorial_id], tutorial: [], project: { id: e[:project_id] } } - elsif e[:project_id] != current[:project_id] # check change of project - # add the project to the tutorial - current[:tutorial] << current[:project] - - # reset the - current[:project_id] = e[:project_id] - current[:project] = { id: e[:project_id] } - end - - old_val = 0 - if current[:project].key? e[:learning_outcome_id] - old_val = current[:project][e[:learning_outcome_id]] - end - - current[:project][e[:learning_outcome_id]] = old_val + - (e[:rating] * status_weight[e[:status]] * grade_weight[e[:grade]] * e[:num]) - end - - # Add last project/tutorial to results - if current - # add the project to the tutorial - current[:tutorial] << current[:project] - - # add the tutorial to the results - result[current[:tutorial_id]] = current[:tutorial] - end - - result.each do |tutorial_id_key, array_of_ilo_scores| - result[tutorial_id_key] = array_of_ilo_scores.map do |map_scores| - map_scores.each { |ilo_id, score| map_scores[ilo_id] = score.round(1) } - map_scores - end - end - - result - end - - # Functions processes the ilo data to generate stats - # Can be passed all details, or details for one tutorial. - def _ilo_progress_summary(data) - result = {} - - learning_outcomes.each do |ilo| - if data.nil? - lower_value = upper_value = median_value = min_value = max_value = 0 - else - values = data.map { |e| e.key?(ilo.id) ? e[ilo.id] : 0 } - values = values.sort - - median_value = if values.length.even? - ((values[values.length / 2] + values[(values.length / 2) - 1]) / 2.0).round(1) - else - values[values.length / 2] - end - - lower_value = values[values.length * 3 / 10] - upper_value = values[values.length * 8 / 10] - min_value = values.first - max_value = values.last - end - - result[ilo.id] = { - median: median_value, - lower: lower_value, - upper: upper_value, - min: min_value, - max: max_value - } - end - - result - end - - # - # Returns the details of the ILO progress for students in the class. - # - def ilo_progress_class_details - result = {} - data = student_ilo_progress_stats - - return {} if data.nil? - - tutorials.each do |tute| - result[tute.id] = _ilo_progress_summary(data[tute.id]) - # if data[tute.id] - # result[tute.id][:students] = data[tute.id] - # else - # result[tute.id][:students] = [] - # end - end - - result['all'] = _ilo_progress_summary(data.values.reduce(:+)) - - result - end - - def ilo_progress_class_stats - temp = student_ilo_progress_stats.values - - return {} if temp.nil? - - data = temp.reduce(:+) - - _ilo_progress_summary(data) - end - def student_grades_csv - students_with_grades = active_projects.where('grade > 0') - CSV.generate do |row| - row << %w(unit_code username student_id grade rationale) - students_with_grades.each do |project| - row << [project.unit.code, project.student.username, project.student.student_id, project.grade, project.grade_rationale] + row << %w(unit_code username student_id target_grade submitted_grade portfolio_submission_date portfolio_production_date has_portfolio spec_con_days grade rationale assessor assessor_id) + active_projects.each do |project| + row << [project.unit.code, project.student.username, project.student.student_id, project.target_grade, project.submitted_grade, project.portfolio_submission_date, project.portfolio_production_date, project.portfolio_exists?, project.spec_con_days, project.grade, project.grade_rationale, project.assessor&.name, project.assessor&.id] end end end @@ -2130,6 +2727,43 @@ def tutor_assessment_csv end end + def overflow_task_claims_csv + CSV.generate do |csv| + csv << [ + 'Tutor who claimed', + 'Claiming Unit Role ID', + 'Original Tutor', + 'Student Username', + 'Student ID', + 'Task ID', + 'Task Definition', + 'Days Awaiting Feedback', + 'Timestamp' + ] + + OverflowTaskClaimLog + .where(unit_id: id) + .includes(:claimed_by_user, :original_tutor_user, :student_user, task: :task_definition) + .order(:claimed_at) + .each do |claim| + task = claim.task + student = claim.student_user + + csv << [ + claim.claimed_by_user&.name, + claim.claimed_by_unit_role_id, + claim.original_tutor_user&.name, + student&.username, + student&.student_id, + claim.task_id, + task&.task_definition&.abbreviation, + claim.days_awaiting_feedback, + claim.claimed_at, + ] + end + end + end + #---------------------------------------------------------------------------- # Task updates from offline download/upload #---------------------------------------------------------------------------- @@ -2146,7 +2780,7 @@ def check_mark_csv_headers end def readme_text - path = Rails.root.join('public', 'resources', 'marking_package_readme.txt') + path = Rails.root.join("public/resources/marking_package_readme.txt") File.read path end @@ -2180,9 +2814,9 @@ def generate_batch_task_zip(user, tasks) csv_str << "\n#{student.username.tr(',', '_')},#{student.name.tr(',', '_')},#{task.project.tutorial_for(task.task_definition).abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_by(task.project.student).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",#{mark_col},,,#{task.task_definition.max_quality_pts}," - src_path = task.portfolio_evidence_path + src_path = task.final_pdf_path - next if src_path.nil? || src_path.empty? + next if src_path.blank? next unless File.exist? src_path # make dst path of "/.pdf" @@ -2203,9 +2837,9 @@ def generate_batch_task_zip(user, tasks) csv_str << "\nGRP_#{grp.id}_#{subm.id},#{grp.name.tr(',', '_')},#{grp.tutorial.abbreviation},#{task.task_definition.abbreviation.tr(',', '_')},\"#{task.last_comment_not_by(user).gsub(/"/, '""')}\",\"#{task.last_comment_by(user).gsub(/"/, '""')}\",rff,,#{task.task_definition.max_quality_pts}," - src_path = task.portfolio_evidence_path + src_path = task.final_pdf_path - next if src_path.nil? || src_path.empty? + next if src_path.blank? next unless File.exist? src_path # make dst path of "/.pdf" @@ -2237,8 +2871,8 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) # read data from CSV CSV.parse(csv_str, headers: true, return_headers: true, - header_converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '').downcase unless body.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |task_entry| + header_converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')&.downcase }], + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |task_entry| group = nil # check the header row @@ -2318,15 +2952,28 @@ def update_task_status_from_csv(user, csv_str, success, _ignored, errors) end begin - task.trigger_transition(trigger: task_entry['status'], by_user: user, quality: task_entry['new quality'].to_i) # saves task - task.grade_task(task_entry['new grade']) # try to grade task if need be + requested_status = TaskStatus.status_for_name(task_entry['status'].to_s) + raise "Unable to update task status to '#{task_entry['status']}'." if requested_status.nil? - if task_entry['new comment'].nil? || task_entry['new comment'].empty? - success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } - else - task.add_text_comment user, task_entry['new comment'] - success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } - success << { row: {}, message: "Added comment to #{task.task_definition.abbreviation} for #{owner_text}" } + status_changed = false + comment_added = false + + unless task.task_status == requested_status + task.trigger_transition(trigger: task_entry['status'], by_user: user, quality: task_entry['new quality'].to_i) # saves task + task.grade_task(task_entry['new grade']) # try to grade task if need be + status_changed = true + end + + new_comment = task_entry['new comment'].to_s.strip + last_comment = task.comments.last&.comment.to_s.strip + if new_comment.present? && last_comment != new_comment + task.add_text_comment user, new_comment + comment_added = true + end + + if status_changed || comment_added + success << { row: task_entry, message: "Updated task #{task.task_definition.abbreviation} for #{owner_text}" } if status_changed + success << { row: {}, message: "Added comment to #{task.task_definition.abbreviation} for #{owner_text}" } if comment_added end rescue Exception => e errors << { row: task_entry, message: e.message } @@ -2424,69 +3071,71 @@ def upload_batch_task_zip_or_csv(user, file) # read keys from CSV - to check that files exist in csv entry_data = CSV.parse(csv_str, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]) + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]) # Copy over the updated/marked files to the file system zip.each do |file| # Skip processing marking file - next if File.basename(file[:name]) == 'marks.csv' || File.basename(file[:name]) == 'readme.txt' + next if ['marks.csv', 'readme.txt'].include?(File.basename(file.name)) # Test filename pattern - if (/.*-\d+.pdf/i =~ File.basename(file[:name])) != 0 - if file[:name][-1] != '/' - ignored << { row: "File #{file[:name]}", message: 'Does not appear to be a task PDF.' } + if (/.*-\d+.pdf/i =~ File.basename(file.name)) != 0 + if file.name[-1] != '/' + ignored << { row: "File #{file.name}", message: 'Does not appear to be a task PDF.' } end next end - if (/\._.*/ =~ File.basename(file[:name])) == 0 - ignored << { row: "File #{file[:name]}", message: 'Does not appear to be a task PDF.' } + if (/\._.*/ =~ File.basename(file.name)) == 0 + ignored << { row: "File #{file.name}", message: 'Does not appear to be a task PDF.' } next end # Extract the id from the filename - task_id_from_filename = File.basename(file[:name], '.pdf').split('-').last + task_id_from_filename = File.basename(file.name, '.pdf').split('-').last task = Task.find_by(id: task_id_from_filename) if task.nil? - ignored << { row: "File #{file[:name]}", message: 'Unable to find associated task.' } + ignored << { row: "File #{file.name}", message: 'Unable to find associated task.' } next end # Ensure that this task's id is inside entry_data task_entry = entry_data.select { |t| t['task'] == task.task_definition.abbreviation.tr(',', '_') && t['username'] == task.project.user.username }.first if task_entry.nil? - # error!({"error" => "File #{file[:name]} has a mismatch of task id ##{task.id} (this task id does not exist in marks.csv)"}, 403) - errors << { row: "File #{file[:name]}", message: "Task id #{task.id} not in marks.csv" } + # error!({"error" => "File #{file.name} has a mismatch of task id ##{task.id} (this task id does not exist in marks.csv)"}, 403) + errors << { row: "File #{file.name}", message: "Task id #{task.id} not in marks.csv" } next end if task.unit != self - errors << { row: "File #{file[:name]}", message: 'This task does not relate to this unit.' } + errors << { row: "File #{file.name}", message: 'This task does not relate to this unit.' } next end # Can the user assess this task? unless AuthorisationHelpers.authorise? user, task, :put - errors << { row: "File #{file[:name]}", error: "You do not have permission to assess task with id #{task.id}" } + errors << { row: "File #{file.name}", error: "You do not have permission to assess task with id #{task.id}" } next end - # Read into the task's portfolio_evidence path the new file - tmp_file = File.join(tmp_dir, File.basename(file[:name])) - task.portfolio_evidence_path = task.final_pdf_path + # Read into the task's final pdf path the new file + tmp_file = File.join(tmp_dir, File.basename(file.name)) # get file out of zip... to tmp_file file.extract(tmp_file) { true } # copy tmp_file to dest - if FileHelper.copy_pdf(tmp_file, task.portfolio_evidence_path) + destination_path = task.final_pdf_path(ignore_portfolio_evidence: true) + + if FileHelper.copy_pdf(tmp_file, destination_path) + task.update(portfolio_evidence: nil) if task.portfolio_evidence.present? if task.group.nil? - success << { row: "File #{file[:name]}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } + success << { row: "File #{file.name}", message: "Replace PDF of task #{task.task_definition.abbreviation} for #{task.student.name}" } else - success << { row: "File #{file[:name]}", message: "Replace PDF of group task #{task.task_definition.abbreviation} for #{task.group.name}" } + success << { row: "File #{file.name}", message: "Replace PDF of group task #{task.task_definition.abbreviation} for #{task.group.name}" } end FileUtils.rm tmp_file else - errors << { row: "File #{file[:name]}", message: 'The file does not appear to be a valid PDF.' } + errors << { row: "File #{file.name}", message: 'The file does not appear to be a valid PDF.' } next end end @@ -2503,15 +3152,345 @@ def upload_batch_task_zip_or_csv(user, file) } end + def batch_feedback_csv_required_headers + ['username', 'student id', 'status', 'comment'] + end + + def parse_batch_feedback_csv(csv_str, return_headers: false) + CSV.parse( + csv_str, + headers: true, + return_headers: return_headers, + header_converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')&.downcase }], + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }] + ) + end + + def find_project_for_batch_feedback_csv_entry(task_entry) + username = task_entry['username'].to_s.strip + student_id = task_entry['student id'].to_s.strip + + return [nil, 'Provide either Username or Student ID.'] if username.blank? && student_id.blank? + + username_project = if username.present? + projects.joins(:user).where('LOWER(users.username) = ?', username.downcase).first + end + student_id_project = if student_id.present? + projects.joins(:user).where(users: { student_id: student_id }).first + end + + if username.present? && username_project.nil? && student_id.blank? + return [nil, "Unable to find student with username '#{username}'."] + end + + if student_id.present? && student_id_project.nil? && username.blank? + return [nil, "Unable to find student with student ID '#{student_id}'."] + end + + if username_project.present? && student_id_project.present? && username_project != student_id_project + return [nil, "Username '#{username}' and student ID '#{student_id}' refer to different students."] + end + + project = username_project || student_id_project + return [nil, 'Unable to find student project for this row.'] if project.nil? + + [project, nil] + end + + def build_batch_feedback_task_rows(task_definition, csv_str, errors, zip: nil, progress_callback: nil) + task_rows = [] + + csv_str.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') + csv_str.tr!("\r", "\n") + csv_str.gsub!("\n\n", "\n") + + entries = parse_batch_feedback_csv(csv_str, return_headers: true) + total_rows = 0 + + entries.each do |task_entry| + next if task_entry.header_row? + next if task_entry.to_hash.values.all? { |value| value.to_s.strip.blank? } + + total_rows += 1 + end + + rows_processed = 0 + + progress_callback&.call(message: 'Validating batch feedback rows', total_rows: total_rows, rows_processed: 0) + + entries.each do |task_entry| + if task_entry.header_row? + batch_feedback_csv_required_headers.each do |expect_header| + unless task_entry.to_hash.keys.include?(expect_header) + errors << { row: task_entry, message: "Missing header '#{expect_header}', ensure first row has header information." } + return nil + end + end + next + end + + next if task_entry.to_hash.values.all? { |value| value.to_s.strip.blank? } + + rows_processed += 1 + progress_callback&.call(message: 'Validating batch feedback rows', rows_processed: rows_processed) + + project, project_error = find_project_for_batch_feedback_csv_entry(task_entry) + if project_error.present? + errors << { row: task_entry, message: project_error } + next + end + + task = project.task_for_task_definition(task_definition) + if task.nil? + errors << { row: task_entry, message: "Unable to find task for #{task_definition.abbreviation}" } + next + end + + if task.group_task? + errors << { row: task_entry, message: 'Batch feedback upload does not support group tasks.' } + next + end + + status = task_entry['status'].to_s.strip + if status.blank? + errors << { row: task_entry, message: 'Status cannot be blank.' } + next + end + + if TaskStatus.status_for_name(status).nil? + errors << { row: task_entry, message: "Unable to update task status to '#{status}'." } + next + end + + student_entries = zip.nil? ? [] : batch_feedback_entries_for_project(zip, project) + pdf_entries = zip.nil? ? [] : batch_feedback_named_pdf_entries_for_project(zip, project) + + if zip.present? + if student_entries.any? && pdf_entries.empty? + expected_identifier = batch_feedback_primary_identifier_for_project(project) + errors << { + row: task_entry, + message: "Expected a PDF named #{expected_identifier}.pdf inside #{expected_identifier}'s folder." + } + next + end + + if pdf_entries.length > 1 + expected_identifier = batch_feedback_primary_identifier_for_project(project) + errors << { + row: task_entry, + message: "Found multiple PDFs named #{expected_identifier}.pdf inside #{expected_identifier}'s folder." + } + next + end + end + + task_rows << { + task: task, + project: project, + task_entry: task_entry, + pdf_entry: pdf_entries.first + } + end + + task_rows + end + + def write_batch_feedback_csv_file(task_rows) + file = Tempfile.new(["batch_feedback_#{id}_", '.csv']) + file.write(build_batch_feedback_legacy_marks_csv(task_rows)) + file.rewind + file + end + + def upload_batch_feedback_csv(user, task_definition, file, progress_callback: nil) + success = [] + errors = [] + ignored = [] + + type = mime_type(file["tempfile"].path) + + unless mime_in_list?(file["tempfile"].path, ['text/', 'text/plain', 'text/csv', 'application/zip', 'multipart/x-gzip', 'multipart/x-zip', 'application/x-gzip', 'application/octet-stream']) + errors << { row: {}, message: "File given is not a csv or zip file - detected #{type}" } + return { + success: success, + ignored: ignored, + errors: errors + } + end + + if type.start_with?('text/', 'text/plain', 'text/csv') + task_rows = build_batch_feedback_task_rows( + task_definition, + File.read(file["tempfile"].path), + errors, + progress_callback: progress_callback + ) + + if task_rows.blank? + return { + success: success, + ignored: ignored, + errors: errors + } + end + + converted_csv = write_batch_feedback_csv_file(task_rows) + progress_callback&.call(message: 'Applying batch feedback updates', total_rows: task_rows.count, rows_processed: 0) + result = upload_batch_task_zip_or_csv(user, { 'tempfile' => converted_csv }) + result[:errors] = errors + result[:errors] + result + else + upload_batch_feedback_zip(user, task_definition, file, progress_callback: progress_callback) + end + ensure + converted_csv.close! if defined?(converted_csv) && converted_csv.present? + end + + def batch_feedback_primary_identifier_for_project(project) + project.user.student_id.to_s.strip.presence || project.user.username.to_s.strip + end + + def batch_feedback_identifiers_for_project(project) + [ + project.user.student_id.to_s.strip.presence, + project.user.username.to_s.strip.presence + ].compact.uniq + end + + def batch_feedback_entries_for_identifier(zip, identifier) + zip.select do |entry| + path_parts = entry.name.split('/').reject(&:blank?) + next false if path_parts.empty? + + if entry.name_is_directory? + path_parts.any? { |part| part.casecmp(identifier).zero? } + else + path_parts[0...-1].any? { |part| part.casecmp(identifier).zero? } + end + end + end + + def batch_feedback_entries_for_project(zip, project) + batch_feedback_identifiers_for_project(project).flat_map do |identifier| + batch_feedback_entries_for_identifier(zip, identifier) + end.uniq + end + + def batch_feedback_named_pdf_entries_for_identifier(zip, identifier) + batch_feedback_entries_for_identifier(zip, identifier).select do |entry| + next false if entry.name_is_directory? + next false unless File.extname(entry.name).casecmp('.pdf').zero? + + File.basename(entry.name, '.pdf').casecmp(identifier).zero? + end + end + + def batch_feedback_named_pdf_entries_for_project(zip, project) + batch_feedback_identifiers_for_project(project).flat_map do |identifier| + batch_feedback_named_pdf_entries_for_identifier(zip, identifier) + end.uniq + end + + def build_batch_feedback_legacy_marks_csv(task_rows) + CSV.generate do |csv| + csv << check_mark_csv_headers.split(',') + + task_rows.each do |task_row| + task = task_row[:task] + project = task_row[:project] + task_entry = task_row[:task_entry] + tutorial = project.tutorial_for(task.task_definition)&.abbreviation.to_s + + csv << [ + project.user.username, + project.user.name, + tutorial, + task.task_definition.abbreviation, + task_entry['status'].to_s.strip, + '', + '', + task_entry['comment'].to_s.strip + ] + end + end + end + + def upload_batch_feedback_zip(user, task_definition, file, progress_callback: nil) + success = [] + errors = [] + ignored = [] + repacked_zip = Tempfile.new(["batch_feedback_#{id}_", '.zip']) + + Zip::File.open(file["tempfile"].path) do |zip| + marking_file = zip.glob('**/marks.csv').first + if marking_file.nil? + errors << { row: {}, message: 'No marks.csv contained in zip.' } + return { + success: success, + ignored: ignored, + errors: errors + } + end + + csv_str = marking_file.get_input_stream.read + csv_str.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless csv_str.nil? + + task_rows = build_batch_feedback_task_rows(task_definition, csv_str, errors, zip: zip, progress_callback: progress_callback) + + if task_rows.blank? + return { + success: success, + ignored: ignored, + errors: errors + } + end + + Zip::File.open(repacked_zip.path, Zip::File::CREATE) do |output_zip| + output_zip.get_output_stream('marks.csv') do |f| + f.write(build_batch_feedback_legacy_marks_csv(task_rows)) + end + + progress_callback&.call(message: 'Preparing PDF replacements', total_rows: task_rows.count, rows_processed: 0) + + task_rows.each_with_index do |task_row, index| + next if task_row[:pdf_entry].nil? + + output_name = "#{task_row[:task].task_definition.abbreviation}-#{task_row[:task].id}.pdf" + output_zip.get_output_stream(output_name) do |f| + f.write(task_row[:pdf_entry].get_input_stream.read) + end + + progress_callback&.call(message: 'Preparing PDF replacements', rows_processed: index + 1) + end + end + end + + result = upload_batch_task_zip_or_csv(user, { 'tempfile' => repacked_zip }) + result[:errors] = errors + result[:errors] + result + ensure + repacked_zip.close! if defined?(repacked_zip) && repacked_zip.present? + end + def send_weekly_status_emails(summary_stats) return unless send_notifications summary_stats[:unit] = self - summary_stats[:unit_week_comments] = comments.where("task_comments.created_at > :start AND task_comments.created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count - summary_stats[:unit_week_engagements] = task_engagements.where("task_engagements.engagement_time > :start AND task_engagements.engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count + summary_stats[:tutorials] = {} + summary_stats[:tutorial_streams] = {} + summary_stats[:staff] ||= {} summary_stats[:revert_count] = 0 summary_stats[:revert] = {} - summary_stats[:staff] = {} + summary_stats[:oldest_task_days] ||= 0 + + summary_stats[:unit_week_comments] = + comments + .where("task_comments.created_at > :start AND task_comments.created_at < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]) + .where(content_type: :text) + .count + + summary_stats[:unit_week_engagements] = task_engagements.where("task_engagements.engagement_time > :start AND task_engagements.engagement_time < :end", start: summary_stats[:week_start], end: summary_stats[:week_end]).count days_to_end_of_unit = (end_date.to_date - DateTime.now).to_i days_from_start_of_unit = (DateTime.now - start_date.to_date).to_i @@ -2519,6 +3498,12 @@ def send_weekly_status_emails(summary_stats) return if days_from_start_of_unit < 4 || days_to_end_of_unit < 0 staff.each do |ur| + summary_stats[:staff][ur.user] ||= {} + summary_stats[:staff][ur.user][:staff_engagements] ||= 0 + summary_stats[:staff][ur.user][:tasks_awaiting_feedback_count] ||= 0 + summary_stats[:staff][ur.user][:weekly_engagements_count] ||= 0 + summary_stats[:staff][ur.user][:weekly_total_tasks_discussed] ||= 0 + summary_stats[:staff][ur.user][:oldest_task_days] ||= 0 summary_stats[:revert][ur.user] = [] end @@ -2526,27 +3511,276 @@ def send_weekly_status_emails(summary_stats) project.send_weekly_status_email(summary_stats, days_from_start_of_unit > 28 && days_to_end_of_unit > 14) end - summary_stats[:num_students_without_tutors] = active_projects.joins('LEFT OUTER JOIN tutorial_enrolments on tutorial_enrolments.project_id = projects.id').where('tutorial_enrolments.tutorial_id' => nil).count - - staff.each do |ur| - ur.populate_summary_stats(summary_stats) + tutorial_streams.each do |tutorial_stream| + summary_stats[:tutorial_streams][tutorial_stream] ||= {} + + # count projects that have NO enrolment for any tutorial in this stream + projects_in_unit = active_projects.select(:id) + tutorial_ids_in_stream = tutorial_stream.tutorials.select(:id) + projects_without_tutor = Project + .where(id: projects_in_unit) + .where(enrolled: true) + .where.not( + id: TutorialEnrolment + .where(tutorial_id: tutorial_ids_in_stream) + .select(:project_id) + ) + .distinct + + summary_stats[:tutorial_streams][tutorial_stream][:num_students_without_tutors] = projects_without_tutor.count + + stream_linked_to_task_definition = task_definitions.any? { |td| td.tutorial_stream_id == tutorial_stream.id } + summary_stats[:tutorial_streams][tutorial_stream][:stream_linked_to_task_definition] = stream_linked_to_task_definition + + # Continue if this tutorial stream is not linked to any task definition + next unless stream_linked_to_task_definition + + row = summary_stats[:tutorial_streams][tutorial_stream][:tutorials] ||= {} + + tutorial_stream.tutorials.each do |tutorial| + # Create new entry for tutorial row + row = summary_stats[:tutorial_streams][tutorial_stream][:tutorials][tutorial] ||= {} + tutorial.unit_role.populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) + end end + # Group tutorials by tutor + group_tutorials_by_tutor(summary_stats) + staff.each do |ur| - ur.send_weekly_status_email(summary_stats) + ur.send_weekly_status_email(summary_stats) end summary_stats[:staff] = {} end + def group_tutorials_by_tutor(summary_stats) + summary_stats[:tutorial_streams].each_value do |tutorial_stream_data| + next unless tutorial_stream_data[:stream_linked_to_task_definition] + tutorial_stream_data[:unit_roles] ||= {} + + tutorial_stream_data[:tutorials].each do |tutorial, data| + unit_role = tutorial.unit_role + unit_role_row = tutorial_stream_data[:unit_roles][unit_role] ||= { + staff_engagements: 0, + tasks_awaiting_feedback_count: 0, + weekly_engagements_count: 0, + total_tasks_discussed: 0, + weekly_tasks_discussed: 0, + oldest_task_days: 0, + number_of_students: 0, + total_staff_engagements: 0, + total_comments: [], + sent_comments: [], + tutorials: 0 + } + unit_role_row[:staff_engagements] += data[:staff_engagements].count + unit_role_row[:tasks_awaiting_feedback_count] += data[:tasks_awaiting_feedback_count] + unit_role_row[:weekly_engagements_count] += data[:weekly_engagements_count] + unit_role_row[:total_tasks_discussed] += data[:total_tasks_discussed].count + unit_role_row[:weekly_tasks_discussed] += data[:weekly_tasks_discussed].count + unit_role_row[:oldest_task_days] = [unit_role_row[:oldest_task_days], data[:oldest_task_days]].max + unit_role_row[:number_of_students] += data[:number_of_students] + unit_role_row[:total_staff_engagements] += data[:total_staff_engagements] + unit_role_row[:total_comments].concat(data[:total_comments].to_a) + unit_role_row[:sent_comments].concat(data[:sent_comments].to_a) + unit_role_row[:tutorials] += 1 + end + end + end + + def archive_submissions(out) + out.puts "Unit: #{code} - #{name}" + projects.each do |project| + project.archive_submissions(out) + end + end + + def move_files_to_archive + FileUtils.mkdir_p FileHelper.archive_root + FileUtils.mkdir_p FileHelper.root_portfolio_dir(archived: true) + + # Indicate unit is now archived + update(archived: true) + + # Move work + archive_work_path = FileHelper.unit_work_root(self, archived: :force) + original_work_path = FileHelper.unit_work_root(self, archived: false) + + if File.exist?(original_work_path) && ! File.exist?(archive_work_path) + FileUtils.mv(original_work_path, archive_work_path) + end + + # Move portfolios + archive_portfolio_path = FileHelper.unit_portfolio_dir(self, create: false, archived: :force) + original_portfolio_path = FileHelper.unit_portfolio_dir(self, create: false, archived: false) + + if File.exist?(original_portfolio_path) && ! File.exist?(archive_portfolio_path) + FileUtils.mv(original_portfolio_path, archive_portfolio_path) + end + + # Move submission history + archive_submission_history_path = FileHelper.unit_submission_history_dir(self, archived: :force) + original_submission_history_path = FileHelper.unit_submission_history_dir(self, archived: false) + + if File.exist?(original_submission_history_path) && ! File.exist?(archive_submission_history_path) + FileUtils.mkdir_p(FileHelper.root_submission_history_dir(archived: true)) + FileUtils.mv(original_submission_history_path, archive_submission_history_path) + end + + # Move JPlag reports + archive_jplag_report_path = FileHelper.unit_jplag_report_dir(self, create: false, archived: :force) + original_jplag_report_path = FileHelper.unit_jplag_report_dir(self, create: false, archived: false) + + if File.exist?(original_jplag_report_path) && ! File.exist?(archive_jplag_report_path) + FileUtils.mkdir_p(FileHelper.root_jplag_report_dir(archived: true)) + FileUtils.mv(original_jplag_report_path, archive_jplag_report_path) + end + end + + def get_tutor_times(start_date: nil, end_date: nil, timezone: nil, ignore_sessions_during_tutorials: false) + query = MarkingSession.where(unit_id: id) + + + tz = Time.zone + tz = ActiveSupport::TimeZone[timezone] if timezone + + end_date = if end_date.present? + tz.parse(end_date.to_s).end_of_day + else + tz.today.end_of_day + end + + start_date = if start_date.present? + tz.parse(start_date.to_s).beginning_of_day + else + (end_date - 7.days).beginning_of_day + end + + query = query.where(start_time: start_date..end_date) + + + query = query.where(during_tutorial: false) if ignore_sessions_during_tutorials + + # Precompute sessions grouped by user_id + sessions_by_user = query.to_a.group_by(&:user_id).transform_values do |sessions| + sessions.sum(&:duration_minutes) + end + # => { 2 => 16, 3 => 45, ... } + + # Precompute activities grouped by session_id and action + activities_by_session = SessionActivity + .joins(:marking_session) + .where(marking_sessions: { unit_id: id }) + .group(:marking_session_id, :action) + .count + # => { [12, "assessing"] => 3, [12, "add-comment"] => 1, ... } + + # Build a mapping from session_id => user_id for quick lookup + session_to_user = query.pluck(:id, :user_id).to_h + # => { 12 => 2, 13 => 2, 14 => 2 } + + tutor_summary = Hash.new { |h, k| h[k] = { total_minutes: 0, assessments: 0, comments: 0 } } + + # Sum session durations per user + sessions_by_user.each do |user_id, minutes| + tutor_summary[user_id][:total_minutes] = minutes + end + + # Sum activities per user + activities_by_session.each do |(session_id, action), count| + user_id = session_to_user[session_id] + next unless user_id + + case action + when "add-comment" + tutor_summary[user_id][:comments] += count + when "assessing" + tutor_summary[user_id][:assessments] += count + end + end + + # Get names of the tutors + users = User.where(id: tutor_summary.keys).index_by(&:id).transform_values(&:name) + + tutor_summary.map do |user_id, data| + { + user_id: user_id, + tutor_name: users[user_id], + total_minutes: data[:total_minutes], + assessments_made: data[:assessments], + comments_made: data[:comments] + } + end + end + + def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_sessions_during_tutorials: false) + summary = get_tutor_times(start_date: start_date, end_date: end_date, timezone: timezone, ignore_sessions_during_tutorials: ignore_sessions_during_tutorials) + + CSV.generate() do |csv| + # Add headers + csv << [ + 'User ID', + 'Tutor', + 'Total Minutes', + 'Assessments', + 'Comments', + ] + + + summary.each do |row| + csv << [ + row[:user_id].to_s, + row[:tutor_name], + row[:total_minutes].to_s, + row[:assessments_made].to_s, + row[:comments_made].to_s, + ] + end + end + end + + def capture_task_complete_stats_snapshot!(snapshot_time: Time.zone.now) + snapshot_payload = task_completion_csv_generator(task_status_uses_id: true) + + timestamp = snapshot_time.to_i.to_s + + task_completion_snapshots + .find_or_initialize_by(snapshot_timestamp: timestamp) + .tap do |snapshot| + snapshot.save! + snapshot.store_stats!(snapshot_payload) + end + end + private def delete_associated_files - FileUtils.rm_rf FileHelper.unit_dir(self) - FileUtils.rm_rf FileHelper.unit_portfolio_dir(self) + unit_path = FileHelper.unit_dir(self, create: false) + unit_portfolio_path = FileHelper.unit_portfolio_dir(self, create: false) + submission_history_path = FileHelper.unit_submission_history_dir(self) + jplag_report_path = FileHelper.unit_jplag_report_dir(self, create: false) + + FileUtils.rm_rf unit_path + FileUtils.rm_rf unit_portfolio_path + FileUtils.rm_rf submission_history_path + FileUtils.rm_rf jplag_report_path + FileUtils.cd FileHelper.student_work_dir end + def update_overdue_tasks_aip + return unless saved_change_to_mark_late_submissions_as_assess_in_portfolio? && mark_late_submissions_as_assess_in_portfolio + + # If the mark_late_submissions_as_assess_in_portfolio was enabled, move all Time & Feedback exceeded tasks to Assess in Portfolio + overdue_statuses = [TaskStatus.time_exceeded.id] + + tasks.where(task_status_id: overdue_statuses).find_each do |task| + task.add_status_comment(main_convenor.user, TaskStatus.assess_in_portfolio) + task.update(task_status_id: TaskStatus.assess_in_portfolio.id) + end + end + def propogate_date_changes_to_tasks return unless saved_change_to_start_date? @@ -2559,4 +3793,38 @@ def propogate_date_changes_to_tasks td.propogate_date_changes date_diff end end + + def move_files_on_code_change + return unless saved_change_to_code? + + old_dir = FileHelper.dir_for_unit_code_and_id(saved_change_to_code[0], id, create: false, archived: archived) + if File.exist? old_dir + new_dir = FileHelper.unit_dir(self, create: false) + FileUtils.mv(old_dir, new_dir) unless File.exist?(new_dir) + end + + old_submission_history_dir = File.join( + FileHelper.root_submission_history_dir(archived: archived), + FileHelper.sanitized_path("#{saved_change_to_code[0]}-#{id}") + ) + + if File.exist? old_submission_history_dir + new_submission_history_dir = FileHelper.unit_submission_history_dir(self) + FileUtils.mv(old_submission_history_dir, new_submission_history_dir) unless File.exist? new_submission_history_dir + end + + old_jplag_report_dir = File.join( + FileHelper.root_jplag_report_dir(archived: archived), + FileHelper.sanitized_path("#{saved_change_to_code[0]}-#{id}") + ) + + if File.exist? old_jplag_report_dir + new_jplag_report_dir = FileHelper.unit_jplag_report_dir(self, create: false) + FileUtils.mv(old_jplag_report_dir, new_jplag_report_dir) unless File.exist? new_jplag_report_dir + end + + # rubocop:disable Rails/SkipsModelValidations + tasks.where('portfolio_evidence IS NOT NULL').update_all("portfolio_evidence = REPLACE(portfolio_evidence, '#{saved_change_to_code[0]}-#{id}', '#{code}-#{id}')") + # rubocop:enable Rails/SkipsModelValidations + end end diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 9985a5174a..68fc0f5e09 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -5,18 +5,23 @@ class UnitRole < ApplicationRecord belongs_to :role, optional: false # Foreign key + belongs_to :mentor, class_name: 'UnitRole', optional: true + has_many :tutorials, class_name: 'Tutorial', dependent: :nullify has_many :projects, through: :tutorials has_many :tasks, through: :projects has_many :task_engagements, through: :tasks has_many :comments, through: :tasks + has_many :tutor_notes, dependent: :destroy + validates :unit_id, presence: true validates :user_id, presence: true validates :role_id, presence: true validate :ensure_valid_user_for_role validate :ensure_convenor, if: :is_main_convenor? + validate :main_convenor_cant_be_observer before_destroy do if is_main_convenor? @@ -33,7 +38,9 @@ def tasks_awaiting_feedback end def oldest_task_awaiting_feedback - tasks_awaiting_feedback.order("submission_date ASC").first + tasks_awaiting_feedback + .includes(project: { unit: { teaching_period: :breaks } }) + .max_by(&:days_awaiting_feedback) end # @@ -46,12 +53,15 @@ def self.permissions ] # What can tutors do with unit roles? tutor_role_permissions = [ - :get + :get, + :create_tutor_note ] # What can convenors do with unit roles? convenor_role_permissions = [ :get, - :delete + :delete, + :delete_tutor_note, + :create_tutor_note ] # What can nil users do with unit roles? nil_role_permissions = [] @@ -66,7 +76,7 @@ def self.permissions end def self.tasks_to_review(user) - Tutorial.find_by_user(user) + Tutorial.find_by(user: user) .map(&:projects) .flatten .map(&:tasks) @@ -107,45 +117,97 @@ def number_of_students # # Add data to the summary stats about this staff member # - def populate_summary_stats(summary_stats) + def populate_summary_stats(summary_stats, tutorial_stream, tutorial, row) data = {} data[:staff] = user data[:unit_role] = self - data[:engagements] = task_engagements - .where( - "task_engagements.engagement_time >= :start AND task_engagements.engagement_time < :end", - start: summary_stats[:week_start], end: summary_stats[:week_end] - ) - - data[:total_engagements_count] = task_engagements.count - data[:weekly_engagements_count] = data[:engagements].count - - if tasks_awaiting_feedback.count > 0 - data[:oldest_task_days] = (Time.zone.today - tasks_awaiting_feedback.order("submission_date ASC").first.submission_date.to_date).to_i - data[:tasks_awaiting_feedback_count] = tasks_awaiting_feedback.count + # All task engagements for this tutorial + all_engagements = TaskEngagement + .joins(task: [:project, :task_definition]) + .where(projects: { id: tutorial.projects.select(:id) }) + .where(task_definitions: { tutorial_stream_id: tutorial_stream.id }) + .distinct + + weekly_engagements = all_engagements + .where("task_engagements.engagement_time >= :start AND task_engagements.engagement_time < :end", + start: summary_stats[:week_start], end: summary_stats[:week_end]) + + data[:engagements] = all_engagements + data[:total_staff_engagements] = all_engagements.count + data[:staff_engagements] = weekly_engagements.where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.rediscuss.name, TaskStatus.attention_required.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]) + + # Weekly task engagements for this tutorial + data[:weekly_engagements_count] = weekly_engagements.count + + tutorial_tasks = tasks_awaiting_feedback + .joins(task_definition: :tutorial_stream) + .joins(project: { tutorial_enrolments: :tutorial }) + .where(tutorials: { id: tutorial.id }) + .where(task_definitions: { tutorial_stream_id: tutorial_stream.id }) + .distinct + + if tutorial_tasks.count > 0 + oldest_task = tutorial_tasks + .includes(project: { unit: { teaching_period: :breaks } }) + .max_by(&:days_awaiting_feedback) + + data[:oldest_task_days] = oldest_task&.days_awaiting_feedback || 0 + data[:tasks_awaiting_feedback_count] = tutorial_tasks.count else data[:oldest_task_days] = 0 data[:tasks_awaiting_feedback_count] = 0 end - data[:number_of_students] = number_of_students + data[:number_of_students] = tutorial.projects.count + tutorial_task_ids = tutorial.projects.joins(:tasks).pluck('tasks.id') + + total_comments = comments + .where(task_id: tutorial_task_ids) + .where("task_comments.user_id = :staff_id", + staff_id: data[:staff].id) + .where(content_type: [:text, :assessment, :audio, :image, :pdf, :discussion, :extension, :discussed_in_class]) + .distinct + + data[:total_tasks_discussed] = total_comments + .where(content_type: :discussed_in_class) + + data[:weekly_tasks_discussed] = data[:total_tasks_discussed] + .where("task_comments.created_at > :start", start: Time.zone.now - 7.days) - data[:total_staff_engagements] = task_engagements.where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]).count - data[:staff_engagements] = data[:engagements].where(engagement: [TaskStatus.complete.name, TaskStatus.feedback_exceeded.name, TaskStatus.redo.name, TaskStatus.discuss.name, TaskStatus.demonstrate.name, TaskStatus.fail.name]).count + data[:received_comments] = total_comments + .where("recipient_id = :staff_id AND task_comments.created_at > :start", + staff_id: data[:staff].id, + start: Time.zone.now - 7.days) - data[:received_comments] = comments.where("recipient_id = :staff_id AND task_comments.created_at > :start", staff_id: data[:staff].id, start: Time.zone.today - 7.days).count - data[:sent_comments] = comments.where("task_comments.user_id = :staff_id AND task_comments.created_at > :start", staff_id: data[:staff].id, start: Time.zone.today - 7.days).count - data[:total_comments] = comments.where("task_comments.user_id = :staff_id", staff_id: data[:staff].id).count + data[:sent_comments] = total_comments + .where("task_comments.user_id = :staff_id AND task_comments.created_at > :start", + staff_id: data[:staff].id, + start: Time.zone.now - 7.days) - summary_stats[:staff][self] = data + data[:total_comments] = total_comments + + summary_stats[:staff][data[:staff]][:staff_engagements] += data[:staff_engagements].count + summary_stats[:staff][data[:staff]][:tasks_awaiting_feedback_count] += tutorial_tasks.count + summary_stats[:staff][data[:staff]][:weekly_engagements_count] += weekly_engagements.count + summary_stats[:staff][data[:staff]][:weekly_total_tasks_discussed] += data[:weekly_tasks_discussed].count + summary_stats[:staff][data[:staff]][:oldest_task_days] = [ + summary_stats[:staff][data[:staff]][:oldest_task_days], + data[:oldest_task_days] + ].max + + row.replace(data) end def send_weekly_status_email(summary_stats) return unless user.receive_feedback_notifications - NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now + begin + NotificationsMailer.weekly_staff_summary(self, summary_stats).deliver_now + rescue StandardError => e + Rails.logger.error "Failed to send weekly staff summary email to #{user.email} - #{e.message}" + end end def ensure_valid_user_for_role @@ -156,6 +218,12 @@ def ensure_valid_user_for_role end end + def main_convenor_cant_be_observer + if unit.main_convenor_id == id && observer_only + errors.add(:observer_only, 'cannot be set for the main convenor') + end + end + def is_main_convenor? unit.main_convenor_id == id end @@ -163,4 +231,111 @@ def is_main_convenor? def ensure_convenor errors.add(:user, 'must retain current role to administer units as they are currently the main contact for the unit') unless is_convenor? end + + def get_marking_sessions(start_date: nil, end_date: nil, timezone: nil) + tz = Time.zone + tz = ActiveSupport::TimeZone[timezone] if timezone + + end_date = if end_date.present? + tz.parse(end_date.to_s).end_of_day + else + tz.today.end_of_day + end + + start_date = if start_date.present? + tz.parse(start_date.to_s).beginning_of_day + else + (end_date - 7.days).beginning_of_day + end + + query = MarkingSession + .where(user_id: user.id, unit_id: unit_id) + .where(start_time: start_date..end_date) + .order(:start_time) + + Entities::MarkingSessionEntity.represent(query).as_json + end + + def get_marking_sessions_csv(start_date: nil, end_date: nil, timezone: nil) + result = get_marking_sessions(start_date: start_date, end_date: end_date, timezone: timezone) + + tz = Time.zone + tz = ActiveSupport::TimeZone[timezone] if timezone + + CSV.generate do |csv| + # Add headers + csv << [ + 'Start Date', + 'Start Time', + 'End Date', + 'End Time', + 'Timezone', + 'Total Duration (m)', + 'Total Duration (h)', + 'Submissions Opened', + 'Comments Added', + 'Assessments Made', + 'During Tutorial' + ] + + result.each do |row| + start_time = row[:start_time].in_time_zone(tz) + end_time = row[:end_time].in_time_zone(tz) + + csv << [ + start_time.strftime('%Y-%m-%d %A'), + start_time.strftime('%H:%M'), + end_time.strftime('%Y-%m-%d %A'), + end_time.strftime('%H:%M'), + "#{tz.name} #{start_time.strftime('%:z')}", + row[:duration_minutes], + (row[:duration_minutes].to_f / 60).round(1), + row[:submissions_opened], + row[:comments_added], + row[:assessments], + row[:during_tutorial] ? 'TRUE' : 'FALSE' + ] + end + end + + end + + def add_tutor_note(user, text, task_id = nil, reply_to_id = nil) + text = text.strip + return nil if user.nil? || text.nil? || text.empty? + + ln = tutor_notes.last + + # don't add if duplicate note + return if ln && ln.user == user && ln.note == text + + note = TutorNote.create + note.note = text + note.user = user + note.unit_role = self + note.reply_to_id = reply_to_id + note.task_id = task_id + note.read_by_unit_role = false + note.save! + note + end + + def should_moderate_task?(task) + td = task.task_definition + td_rep = TutorFeedbackScore.find_by(unit_role: self, task_definition: td) + if td_rep.nil? + td_rep = TutorFeedbackScore.create!({ + unit_role: self, + task_definition: td, + score: 50 + }) + end + + # We sample randomly during a task submission + if rand(0..100) > td_rep.score + return true + end + + false + end end diff --git a/app/models/user.rb b/app/models/user.rb index 6e016badfd..fc3160178f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,8 @@ class User < ApplicationRecord include UserTiiModule + after_update :move_files_on_username_change, if: :saved_change_to_username? + ### # Authentication ### @@ -92,33 +94,51 @@ def authenticate?(data) # Force-generates a new authentication token, regardless of whether or not # it is actually expired # - def generate_authentication_token!(remember = false) + def generate_authentication_token!(remember: false, expiry: nil, token_type: :general, force_new: true) # Ensure this user is saved... so it has an id self.save unless self.persisted? - AuthToken.generate(self, remember) + expiry_duration = + if token_type.to_sym == :refresh_token + Doubtfire::Application.config.refresh_token_expiry + else + Doubtfire::Application.config.access_token_expiry + end + expiry ||= Time.zone.now + expiry_duration + + # Reuse tokens for up to 75% of their configured lifetime, then rotate early. + token_reuse_duration = expiry_duration * 0.75 + + # Get a recent token, or create a new one + token = self.auth_tokens.where(token_type: token_type).last unless force_new + if token.nil? || token.auth_token_expiry <= Time.zone.now || token.created_at <= Time.zone.now - token_reuse_duration + token = AuthToken.generate(self, remember, expiry, token_type) + end + + token end # # Generate an authentication token that will expire in 30 seconds # def generate_temporary_authentication_token! - # Ensure this user is saved... so it has an id - self.save unless self.persisted? - AuthToken.generate(self, false, Time.zone.now + 30.seconds) + generate_authentication_token!(expiry: Time.zone.now + 30.seconds, token_type: :login) end # - # Returns whether the authentication token has expired + # Generate an authentication token for scorm asset retrieval # - def authentication_token_expired? - auth_token_expiry.nil? || auth_token_expiry <= Time.zone.now + def generate_scorm_authentication_token! + generate_authentication_token!(token_type: :scorm) end # # Returns authentication of the user # - def token_for_text?(a_token) - self.auth_tokens.each do |token| + def token_for_text?(a_token, token_type) + tokens_to_check = self.auth_tokens + tokens_to_check = tokens_to_check.where(token_type: token_type) if token_type.present? + + tokens_to_check.each do |token| if a_token == token.authentication_token return token end @@ -132,10 +152,21 @@ def token_for_text?(a_token) # Model associations belongs_to :role, optional: false # Foreign Key - has_many :unit_roles, dependent: :destroy - has_many :projects, dependent: :destroy - has_many :auth_tokens, dependent: :destroy - has_one :webcal, dependent: :destroy + has_many :unit_roles, dependent: :destroy, inverse_of: :user + has_many :projects, dependent: :restrict_with_exception, inverse_of: :user + has_many :engagements, dependent: :restrict_with_exception, inverse_of: :user + has_many :engagement_comments, dependent: :restrict_with_exception, inverse_of: :user + has_many :auth_tokens, dependent: :destroy, inverse_of: :user + has_many :user_oauth_tokens, dependent: :destroy, inverse_of: :user + has_many :user_oauth_states, dependent: :destroy, inverse_of: :user + has_one :webcal, dependent: :destroy, inverse_of: :user + has_many :chip_usage, dependent: :destroy, inverse_of: :tutor, class_name: 'Feedback::ChipUsage' + + has_many :marking_sessions, dependent: :destroy + + # Notifications feature + has_many :notifications, dependent: :destroy, inverse_of: :user + has_many :push_subscriptions, dependent: :destroy, inverse_of: :user # Model validations/constraints validates :first_name, presence: true @@ -301,7 +332,14 @@ def self.permissions :get_teaching_periods, :admin_overseer, - :use_overseer + :use_overseer, + + :get_feedback_chips, + :create_feedback_chips, # create global feedback chips + :get_los, + :update_glos, + + :get_scorm_token ] # What can auditors do with users? @@ -315,11 +353,14 @@ def self.permissions :audit_units, :get_teaching_periods, - :use_overseer + :use_overseer, + :get_scorm_token, + :get_feedback_chips ] # What can convenors do with users? convenor_role_permissions = [ + :get_all_units, :promote_user, :list_users, :create_user, @@ -332,20 +373,30 @@ def self.permissions :convene_units, :get_staff_list, :get_teaching_periods, - :use_overseer + :use_overseer, + :get_scorm_token, + + :get_feedback_chips, + :get_los, + :update_glos ] # What can tutors do with users? tutor_role_permissions = [ :get_unit_roles, :download_unit_csv, - :get_teaching_periods + :get_teaching_periods, + :get_scorm_token, + + :get_feedback_chips, + :get_los, + :update_glos ] # What can students do with users? student_role_permissions = [ - :get_teaching_periods - + :get_teaching_periods, + :get_scorm_token ] # Return the permissions hash @@ -396,6 +447,40 @@ def name "#{fn} #{sn}" end + def move_files_on_username_change + old_username = saved_change_to_username[0] + + # Move all files to the new username + projects.find_each do |project| + # Move the task files + old_path = FileHelper.project_work_root(project, username: old_username) + new_path = FileHelper.project_work_root(project, username: username) + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + # rubocop:disable Rails/SkipsModelValidations + project.tasks.where('portfolio_evidence IS NOT NULL').update_all("portfolio_evidence = REPLACE(portfolio_evidence, '#{FileHelper.sanitized_path(old_username)}', '#{FileHelper.sanitized_path(username)}')") + # rubocop:enable Rails/SkipsModelValidations + + # Now move submission history files + old_path = FileHelper.project_submission_history_dir(project, username: old_username) + new_path = FileHelper.project_submission_history_dir(project, username: username) + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + + # Now move the portfolio folder + old_path = FileHelper.student_portfolio_dir(project.unit, old_username, create: false) + new_path = FileHelper.student_portfolio_dir(project.unit, username, create: false) + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + + # Lastly move the portfolio file + old_path = "#{new_path}/#{old_username}-portfolio.pdf" + new_path = "#{new_path}/#{username}-portfolio.pdf" + + FileUtils.mv(old_path, new_path) if File.exist?(old_path) + end + end + def self.export_to_csv exportables = csv_columns.map { |col| col == 'role' ? 'role_id' : col } CSV.generate do |row| @@ -442,7 +527,7 @@ def self.import_from_csv(current_user, file) CSV.parse(data, headers: true, header_converters: [->(i) { i.nil? ? '' : i }, :downcase, ->(hdr) { hdr.strip.tr(' ', '_') unless hdr.nil? }], - converters: [->(body) { body.encode!('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') unless body.nil? }]).each do |row| + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |row| next if row[0] =~ /(email)|(username)/ begin @@ -461,7 +546,7 @@ def self.import_from_csv(current_user, file) pass_checks = true %w(username email role first_name).each do |col| - next unless row[col].nil? || row[col].empty? + next if row[col].present? errors << { row: row, message: "The #{col} cannot be blank or empty" } pass_checks = false @@ -519,4 +604,11 @@ def self.import_from_csv(current_user, file) errors: errors } end + + def get_marking_sessions(unit, start_date: nil, end_date: nil, timezone: nil) + unit_role = unit.unit_role_for(self) + unless unit_role.nil? + unit_role.get_marking_sessions(start_date: start_date, end_date: end_date, timezone: timezone) + end + end end diff --git a/app/models/webcal.rb b/app/models/webcal.rb index 06ce4f3f61..d0ca3c3e3b 100644 --- a/app/models/webcal.rb +++ b/app/models/webcal.rb @@ -13,6 +13,20 @@ def self.valid_time_units %w(W D H M) end + def reminder_trigger + return nil unless reminder? + + trigger = + case reminder_unit + when 'W', 'D' + "-P#{reminder_time}#{reminder_unit}" + when 'H', 'M' + "-PT#{reminder_time}#{reminder_unit}" + end + + Icalendar::Values::Duration.new(trigger, 'RELATED' => 'START') + end + # # Represents the presence of `reminder_time` and `reminder_unit`. # @@ -28,7 +42,7 @@ def reminder? def task_definitions TaskDefinition .joins(:unit, unit: :projects) - .includes(:unit, unit: :projects) + .includes(:grade_due_dates, unit: :projects) .where( projects: { user_id: user_id, enrolled: true }, units: { active: true } @@ -71,8 +85,15 @@ def to_ical(task_defs = task_definitions) ical.publish ical.prodid = Doubtfire::Application.config.institution[:product_name] - # load all of the tasks... uses the preloaded project - tasks = Task.where(task_definition: task_defs, project: task_defs.map { |t| t.unit.projects.first }.uniq) + projects = Project + .includes(:unit) + .where(user_id: user_id, enrolled: true, unit_id: task_defs.map(&:unit_id).uniq) + .index_by(&:unit_id) + + tasks = Task + .includes(:project, task_definition: :grade_due_dates) + .where(task_definition: task_defs, project: projects.values) + .index_by(&:task_definition_id) # Add iCalendar events for the specified definition. task_defs.each do |td| @@ -83,7 +104,7 @@ def to_ical(task_defs = task_definitions) ev_date_format = '%Y%m%d' ev_reminders = reminder? - ev_reminder_trigger = "-PT#{reminder_time}#{reminder_unit}" + ev_reminder_trigger = reminder_trigger # Add event for start date, if the user opted in. if include_start_dates @@ -91,7 +112,8 @@ def to_ical(task_defs = task_definitions) ev.uid = "S-#{td.id}" ev.summary = event_name_for_task_definition(td, 'start') ev.status = 'CONFIRMED' - ev.dtstart = ev.dtend = Icalendar::Values::Date.new(td.start_date.strftime(ev_date_format)) + start_date = Webcal.start_date_for_task_definition(td, tasks[td.id], projects[td.unit_id]) + ev.dtstart = ev.dtend = Icalendar::Values::Date.new(start_date.strftime(ev_date_format)) Webcal.add_metadata_to_ical_event(ev, td) @@ -110,7 +132,8 @@ def to_ical(task_defs = task_definitions) ev.uid = "E-#{td.id}" ev.summary = event_name_for_task_definition(td, 'end') ev.status = 'CONFIRMED' - ev.dtstart = ev.dtend = Icalendar::Values::Date.new(Webcal.end_date_for_task_definition(td, tasks).strftime(ev_date_format)) + end_date = Webcal.end_date_for_task_definition(td, tasks[td.id], projects[td.unit_id]) + ev.dtstart = ev.dtend = Icalendar::Values::Date.new(end_date.strftime(ev_date_format)) Webcal.add_metadata_to_ical_event(ev, td) @@ -137,9 +160,27 @@ def to_ical(task_defs = task_definitions) # # Returns the target/extended date for the specified task definition. # - def self.end_date_for_task_definition(task_def, tasks) - task = tasks.select { |t| t.task_definition_id == task_def.id }.first - task.present? ? task.due_date : task_def.target_date + def self.end_date_for_task_definition(task_def, task = nil, project = nil) + return task.local_due_date if task.present? + + flexible_grade_date_for_task_definition(task_def, project, :target_date) || task_def.target_date + end + + # + # Returns the start date for the specified task definition. + # + def self.start_date_for_task_definition(task_def, task = nil, project = nil) + return task.local_start_date if task.present? + + flexible_grade_date_for_task_definition(task_def, project, :start_date) || task_def.start_date + end + + def self.flexible_grade_date_for_task_definition(task_def, project, date_type) + return nil unless project&.unit&.allow_flexible_dates + + return task_def.grade_target_date(project.target_grade) if date_type == :target_date + + task_def.grade_start_date(project.target_grade) end # diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb new file mode 100644 index 0000000000..0e7b9d4f6c --- /dev/null +++ b/app/services/notification_service.rb @@ -0,0 +1,128 @@ +# Central entry point for raising a notification. +# +# Creates the in-app record and fans out to the enabled delivery channels +# (email and push through Sidekiq). A single category toggle (the user's +# receive_*_notifications preference) gates every channel: if the category is +# off, the notification is suppressed entirely. Per-channel granularity +# (a type x channel matrix) is deferred to a future iteration. +# +# Usage: +# NotificationService.notify( +# user: project.student, +# type: 'feedback', +# event: 'task_comment_created', +# message: "New feedback is ready for #{task_definition.name}.", +# link: "/#/projects/#{project.id}" +# ) +class NotificationService + # Raise a notification for a user. Returns the created Notification, or nil if + # the user's preference suppresses this category. + # + # type - the category the user's preference switches on, one of + # Notification::TYPES. + # event - the specific thing that happened, e.g. 'task_comment_created'. + # Required, so every notification can be traced back to its source. + def self.notify(user:, type:, event:, message:, link: nil, dedupe_key: nil) + notification = reserve( + user: user, + type: type, + event: event, + message: message, + link: link, + dedupe_key: dedupe_key + ) + + deliver(notification) + end + + # Persist a notification without running its delivery channels. Callers that + # need a short eligibility lock can commit this reservation, release the + # lock, and then call `deliver` without holding a row lock across network I/O. + def self.reserve(user:, type:, event:, message:, link: nil, dedupe_key: nil) + type = type.to_s + return nil unless deliver_to?(user, type) + + create_notification( + user: user, + notification_type: type, + event: event.to_s, + message: message, + link: link, + dedupe_key: dedupe_key + ) + end + + def self.deliver(notification) + return nil if notification.nil? + + # Concurrent or retried fan-outs can reserve the same immutable event. A + # lock on that notification (not on the student's project) serializes only + # its push hand-off. Email is queued once by Notification's after_commit + # hook, so it cannot be consumed before an enclosing transaction commits + # and a dedupe retry cannot queue it twice. delivered_at tracks the async + # push hand-off; a failed hand-off stays retryable. + notification.with_lock do + unless notification.delivered_at? + push_queued = queue_push(notification) + notification.update!(delivered_at: Time.current) if push_queued + end + end + + notification + end + + # Whether the user's category preference allows this notification type. + def self.deliver_to?(user, type) + pref = Notification::PREFERENCE_FOR_TYPE[type.to_s] + return true if pref.nil? # types without a preference are always sent + + user.public_send(pref) + end + + # A non-null dedupe key is an immutable event identity. The unique database + # index makes concurrent fan-out jobs race safely: exactly one insert wins, + # and only that winner runs the after_commit hook that queues the email. + def self.create_notification(**attributes) + Notification.transaction(requires_new: true) do + Notification.create!(**attributes) + end + rescue ActiveRecord::RecordNotUnique + raise if attributes[:dedupe_key].blank? + + Notification.find_by!( + user: attributes.fetch(:user), + dedupe_key: attributes.fetch(:dedupe_key) + ) + end + private_class_method :create_notification + + # Email channel. Called from Notification's after_commit hook, never directly + # from notify or deliver, so the notification is committed before the job exists. + # + # Queue only the stable Notification id; message content, recipient details + # and other student data remain in the database. Queue connection errors are + # best-effort so the in-app record and push delivery are not blocked. Delivery + # failures are raised by the job for Sidekiq to retry. + def self.queue_email(notification) + NotificationEmailJob.perform_async(notification.id) + rescue StandardError => e + Rails.logger.error( + "Failed to queue notification email for Notification #{notification.id}: #{e.class}" + ) + false + end + + # Push channel. Queue only the stable Notification id so no student or + # notification content is copied into Redis. A failed hand-off leaves + # delivered_at unset, allowing the existing availability retry to try the + # push hand-off again without duplicating the after-commit email. + def self.queue_push(notification) + PushNotificationDeliveryJob.perform_async(notification.id) + rescue StandardError => e + Rails.logger.error( + "Failed to queue notification push for Notification #{notification.id}: #{e.class}" + ) + false + end + private_class_method :queue_push +end diff --git a/app/services/peer_progress_aggregation_service.rb b/app/services/peer_progress_aggregation_service.rb new file mode 100644 index 0000000000..6ad693f5c7 --- /dev/null +++ b/app/services/peer_progress_aggregation_service.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# Calculates and stores task-level peer-progress snapshots for one unit. +# +# This service stores aggregate values only. It does not authorise students, +# apply the small-cohort display threshold, or expose API response data. +class PeerProgressAggregationService + class UnsupportedTaskStatusError < StandardError; end + + def self.call(unit:, calculated_at: Time.zone.now) + new(unit: unit, calculated_at: calculated_at).call + end + + def initialize(unit:, calculated_at:) + unless unit.is_a?(Unit) && unit.persisted? + raise ArgumentError, 'unit must be a persisted Unit' + end + raise ArgumentError, 'calculated_at is required' if calculated_at.blank? + + @unit = unit + @calculated_at = calculated_at + end + + def call + snapshots = [] + + PeerProgressSnapshot.transaction do + existing_snapshots = PeerProgressSnapshot.where(unit: unit).index_by do |snapshot| + [snapshot.task_definition_id, snapshot.target_grade] + end + + unit.grade_values.map(&:to_i).uniq.sort.each do |target_grade| + cohort = unit.active_projects.where(target_grade: target_grade) + cohort_size = cohort.count + + task_definitions = unit.task_definitions + .where('target_grade <= ?', target_grade) + .order(:id) + + submitted_counts = submitted_counts_for( + cohort: cohort, + task_definitions: task_definitions + ) + status_counts = status_counts_for( + cohort: cohort, + task_definitions: task_definitions, + cohort_size: cohort_size + ) + + task_definitions.each do |task_definition| + key = [task_definition.id, target_grade] + submitted_count = submitted_counts.fetch(task_definition.id, 0) + + snapshot = existing_snapshots[key] || PeerProgressSnapshot.new( + unit: unit, + task_definition: task_definition, + target_grade: target_grade + ) + + snapshot.assign_attributes( + cohort_size: cohort_size, + submitted_count: submitted_count, + submitted_percentage: percentage( + submitted_count: submitted_count, + cohort_size: cohort_size + ), + status_counts: status_counts.fetch(task_definition.id), + calculated_at: calculated_at + ) + + snapshot.save! + snapshots << snapshot + end + end + end + + snapshots + end + + private + + attr_reader :unit, :calculated_at + + def submitted_counts_for(cohort:, task_definitions:) + Task + .where( + project_id: cohort.select(:id), + task_definition_id: task_definitions.select(:id) + ) + .where.not(file_uploaded_at: nil) + .group(:task_definition_id) + .distinct + .count(:project_id) + end + + def status_counts_for(cohort:, task_definitions:, cohort_size:) + materialized_counts = Task + .where( + project_id: cohort.select(:id), + task_definition_id: task_definitions.select(:id) + ) + .group(:task_definition_id, :task_status_id) + .distinct + .count(:project_id) + + task_definitions.to_h do |task_definition| + counts = PeerProgressDistributionPolicy::STATUS_KEYS.index_with { 0 } + + materialized_counts.each do |(task_definition_id, status_id), count| + next unless task_definition_id == task_definition.id + + status = canonical_status_for(status_id) + counts[status] += count + end + + missing_task_count = cohort_size - counts.values.sum + if missing_task_count.negative? + raise ArgumentError, + 'task status counts exceed the peer-progress cohort size' + end + + counts['not_started'] += missing_task_count + [task_definition.id, counts] + end + end + + def canonical_status_for(status_id) + id = status_id.to_i + expected_status = PeerProgressDistributionPolicy::STATUS_KEYS[id - 1] + mapped_status = TaskStatus.id_to_key(id).to_s if expected_status.present? + + return mapped_status if expected_status.present? && + mapped_status == expected_status + + # TaskStatus.id_to_key deliberately falls back to not_started for unknown + # IDs. That is useful elsewhere, but would silently corrupt an aggregate + # if a new lifecycle state were introduced without extending this policy. + raise UnsupportedTaskStatusError, + 'peer-progress aggregation encountered an unsupported task status' + end + + def percentage(submitted_count:, cohort_size:) + return nil if cohort_size.zero? + + ((submitted_count * 100.0) / cohort_size).round(2) + end +end diff --git a/app/services/peer_progress_distribution_policy.rb b/app/services/peer_progress_distribution_policy.rb new file mode 100644 index 0000000000..75786e2df1 --- /dev/null +++ b/app/services/peer_progress_distribution_policy.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +# Builds the public, privacy-preserving task-status distribution from an +# internal peer-progress snapshot. +# +# Quantising every status independently is not sufficient on its own. When the +# buckets are considered together, their sum constraint can occasionally make +# a raw count unique (for example, a cohort of 24 split into 6 and 18). Before +# releasing a vector, this policy assumes an observer knows the cohort size and +# verifies that every status still has at least two feasible raw counts. +class PeerProgressDistributionPolicy + PERCENTAGE_BUCKET_SIZE = 10.0 + + STATUS_KEYS = %w[ + not_started + complete + need_help + working_on_it + fix_and_resubmit + feedback_exceeded + redo + discuss + ready_for_feedback + demonstrate + fail + time_exceeded + assess_in_portfolio + attention_required + rediscuss + ].freeze + + def self.quantised_percentage(value) + ((value.to_f / PERCENTAGE_BUCKET_SIZE).round * + PERCENTAGE_BUCKET_SIZE).to_f + end + + def self.percentage(count:, cohort_size:) + return nil unless cohort_size.to_i.positive? + + ((count.to_i * 100.0) / cohort_size).round(2) + end + + def self.quantised_count_percentage(count:, cohort_size:) + quantised_percentage( + percentage(count: count, cohort_size: cohort_size) + ) + end + + def self.build(status_counts:, cohort_size:) + counts = normalized_counts(status_counts) + return nil if counts.nil? || cohort_size.to_i <= 0 + return nil unless counts.values.sum == cohort_size + + distribution = STATUS_KEYS.map do |status| + { + status: status, + percentage: quantised_count_percentage( + count: counts.fetch(status), + cohort_size: cohort_size + ) + } + end + + return nil unless preserves_count_ambiguity?( + distribution: distribution, + cohort_size: cohort_size + ) + + distribution + end + + def self.valid_status_counts?(status_counts, cohort_size:) + counts = normalized_counts(status_counts) + + counts.present? && counts.values.sum == cohort_size + end + + def self.normalized_counts(status_counts) + return nil unless status_counts.is_a?(Hash) + + counts = status_counts.transform_keys(&:to_s) + return nil unless counts.keys.sort == STATUS_KEYS.sort + return nil unless counts.values.all? do |value| + value.is_a?(Integer) && value >= 0 + end + + counts + end + private_class_method :normalized_counts + + def self.preserves_count_ambiguity?(distribution:, cohort_size:) + ranges = distribution.map do |entry| + count_range_for_bucket( + entry.fetch(:percentage), + cohort_size + ) + end + + minimum_sum = ranges.sum(&:begin) + maximum_sum = ranges.sum(&:end) + + ranges.all? do |range| + other_minimum = minimum_sum - range.begin + other_maximum = maximum_sum - range.end + feasible_minimum = [range.begin, cohort_size - other_maximum].max + feasible_maximum = [range.end, cohort_size - other_minimum].min + + feasible_maximum - feasible_minimum >= 1 + end + end + private_class_method :preserves_count_ambiguity? + + # The quantised value is monotonic as count increases. Binary-searching both + # edges avoids rebuilding every possible count bucket on every student GET: + # detailed policy evaluation is O(statuses * log(cohort_size)), with no + # unbounded cohort-size cache. + def self.count_range_for_bucket(bucket, cohort_size) + first = binary_search_count(cohort_size) do |count| + quantised_count_percentage( + count: count, + cohort_size: cohort_size + ) >= bucket + end + last = binary_search_count(cohort_size, upper: true) do |count| + quantised_count_percentage( + count: count, + cohort_size: cohort_size + ) <= bucket + end + + first..last + end + private_class_method :count_range_for_bucket + + def self.binary_search_count(cohort_size, upper: false) + low = 0 + high = cohort_size + + while low < high + midpoint = (low + high + (upper ? 1 : 0)) / 2 + if yield(midpoint) + upper ? low = midpoint : high = midpoint + else + upper ? high = midpoint - 1 : low = midpoint + 1 + end + end + + low + end + private_class_method :binary_search_count +end diff --git a/app/services/peer_progress_viewer_policy.rb b/app/services/peer_progress_viewer_policy.rb new file mode 100644 index 0000000000..40b0f011f2 --- /dev/null +++ b/app/services/peer_progress_viewer_policy.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# Converts an internal whole-cohort snapshot into peer-only exact aggregates +# for one authenticated viewer. Public quantisation and vector ambiguity checks +# are applied afterwards; raw values from this policy never cross the API. +class PeerProgressViewerPolicy + def self.viewer_context_current?(snapshot:, viewer_project:, viewer_task:) + project_current = viewer_project.persisted? && + viewer_project.updated_at.present? && + viewer_project.updated_at <= snapshot.calculated_at + task_current = !viewer_task.persisted? || + (viewer_task.updated_at.present? && + viewer_task.updated_at <= snapshot.calculated_at) + + project_current && task_current + end + + def self.build(snapshot:, viewer_project:, viewer_task:) + return nil unless viewer_context_current?( + snapshot: snapshot, + viewer_project: viewer_project, + viewer_task: viewer_task + ) + return nil unless snapshot.submitted_count.is_a?(Integer) + return nil unless snapshot.submitted_count.between?( + 0, + snapshot.cohort_size + ) + return nil unless PeerProgressDistributionPolicy.valid_status_counts?( + snapshot.status_counts, + cohort_size: snapshot.cohort_size + ) + + peer_cohort_size = snapshot.cohort_size - 1 + return nil if peer_cohort_size.negative? + + counts = snapshot.status_counts.to_h.transform_keys(&:to_s).dup + viewer_status = canonical_status(viewer_task.task_status_id) + return nil if viewer_status.nil? || counts.fetch(viewer_status).zero? + + counts[viewer_status] -= 1 + submitted_count = snapshot.submitted_count + submitted_count -= 1 if viewer_task.file_uploaded_at.present? + return nil unless submitted_count.between?(0, peer_cohort_size) + return nil unless PeerProgressDistributionPolicy.valid_status_counts?( + counts, + cohort_size: peer_cohort_size + ) + + { + cohort_size: peer_cohort_size, + submitted_count: submitted_count, + status_counts: counts + } + end + + def self.public_metrics(peer_progress) + counts = peer_progress.fetch(:status_counts) + cohort_size = peer_progress.fetch(:cohort_size) + distribution = PeerProgressDistributionPolicy.build( + status_counts: counts, + cohort_size: cohort_size + ) + + { + submitted_percentage: + PeerProgressDistributionPolicy.quantised_count_percentage( + count: peer_progress.fetch(:submitted_count), + cohort_size: cohort_size + ), + completed_percentage: + PeerProgressDistributionPolicy.quantised_count_percentage( + count: counts.fetch('complete'), + cohort_size: cohort_size + ), + status_distribution: distribution, + distribution_unavailable_reason: + distribution.nil? ? 'privacy_protection' : nil + } + end + + def self.canonical_status(status_id) + id = status_id.to_i + expected_status = PeerProgressDistributionPolicy::STATUS_KEYS[id - 1] + mapped_status = TaskStatus.id_to_key(id).to_s if expected_status.present? + + mapped_status if mapped_status == expected_status + end + private_class_method :canonical_status +end diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb new file mode 100644 index 0000000000..05c0f4983f --- /dev/null +++ b/app/services/push_notification_service.rb @@ -0,0 +1,218 @@ +# Web Push delivery channel. +# +# PushNotificationDeliveryJob calls this for every notification handed off by +# NotificationService. Two properties make that safe: +# +# * without VAPID keys it is a no-op, so the app behaves exactly as it did +# before push existed for anyone who has not configured them +# * one browser failing never stops attempts to the others; transient failures +# are raised only after fan-out so Sidekiq can retry the delivery job +# +# Because the shared fan-out queues the job, every event that queues an email +# also queues a push, with no per-event work. +# +# Key generation and setup: docs/notifications/push-setup.md. +class PushNotificationService + class DeliveryError < StandardError; end + + # Push services reject a payload much over 4KB once encrypted. Nothing here + # comes close, but the message is user-facing text assembled from names and + # task titles, so it is trimmed rather than trusted. + MAX_BODY_LENGTH = 400 + + # Lock-screen copy is a separate channel from the richer in-app notification + # and email. These v2 events contain free-form names or precise schedule data + # in Notification#message, so use bounded, reviewed copy whenever the payload + # is rendered. Keeping the decision here means direct delivery and any future + # payload regeneration cannot accidentally bypass the privacy boundary. + LOCK_SCREEN_BODY_OVERRIDES = { + 'tutorial_changed' => 'Your tutorial details changed.', + 'group_membership_changed' => 'Your group membership changed.', + 'task_submitted' => 'A task is ready for marking.', + 'portfolio_received' => 'Your portfolio submission was received.' + }.freeze + + # MN-C03 BEGIN: safe click route constants + SAFE_CLICK_FALLBACK = '/notifications'.freeze + MAX_CLICK_LINK_LENGTH = 256 + FORBIDDEN_CLICK_LINK_TEXT = /[\u0000-\u001f\u007f\s\\?#%]/ + SAFE_PROJECT_ROOT_LINK = %r{\A/projects/[1-9]\d*/(?:dashboard|groups)\z} + SAFE_PROJECT_TASK_LINK = %r{\A/projects/[1-9]\d*/dashboard/[A-Za-z0-9][A-Za-z0-9._-]{0,31}\z}x + # MN-C03 END: safe click route constants + # Seconds. web-push sets no timeouts of its own, so without these a push + # service that accepts a connection and then never answers holds a Sidekiq + # worker thread and reduces delivery capacity until the process kills it. + # + # Both are passed together on purpose. web-push 3.0.1 guards read_timeout on + # open_timeout being present (lib/web_push/request.rb line 15), so passing + # read_timeout alone silently does nothing. + OPEN_TIMEOUT = 5 + READ_TIMEOUT = 5 + SSL_TIMEOUT = 5 + + # Push services otherwise retain messages for the gem default of four weeks. + # OnTrack notifications describe current workflow state, so delivering one + # days or weeks later is misleading. One hour tolerates a short disconnect + # without surfacing stale due-date or status alerts. + MESSAGE_TTL = 1.hour.to_i + MESSAGE_URGENCY = 'normal'.freeze + + def self.deliver(notification) + return unless configured? + + subscriptions = notification.user.push_subscriptions.to_a + return if subscriptions.empty? + + payload = payload_for(notification) + + failures = [] + subscriptions.each do |subscription| + deliver_to(subscription, payload) + rescue StandardError => e + # Finish the fan-out before raising. This preserves delivery to healthy + # browsers while ensuring a provider outage reaches Sidekiq's retry path. + Rails.logger.error "Failed to push to subscription #{subscription.id}: #{e.class}" + failures << e + end + + return if failures.empty? + + raise DeliveryError, + "Push delivery failed for #{failures.length} subscription(s)", + cause: failures.first + end + + # The shape Angular's own ngsw-worker.js understands. It looks for a top level + # "notification" key and displays the notification itself, which is why none of + # this needs a hand written service worker. Use any other shape and somebody + # has to write one. + # + # data.link is what MN-C03 reads to decide where to send the user on click. + # + # tag and renotify are both in the service worker's own list of forwarded + # option names (ngsw-worker.js, NOTIFICATION_OPTION_NAMES), so they reach + # showNotification without any change on the web side. + def self.payload_for(notification) + click_link = safe_click_link(notification.link) + + { + notification: { + title: Doubtfire::Application.config.institution[:product_name], + body: body_for(notification), + tag: tag_for(notification), + # False, so a replacement updates the banner without making a sound or + # vibrating again. + # + # A burst is the case this exists for: a tutor working through one task + # posts five comments in two minutes. The tag already collapses those + # into one banner, and renotify: true would put the buzz back on every + # one of them, which is most of what made the burst worth collapsing. + # The user has already been interrupted once and the banner is already + # on their screen saying the newest thing. + # + # The cost is that a genuinely new message inside an ongoing + # conversation arrives silently while the old banner is still up. That + # is the right way round: the person has been told, and the alternative + # is being told five times. + renotify: false, + data: { + notification_id: notification.id, + link: click_link, + onActionClick: { + default: { + operation: 'focusLastFocusedOrOpen', + url: click_link + } + } + } + } + }.to_json + end + + def self.safe_click_link(link) + return SAFE_CLICK_FALLBACK unless link.is_a?(String) + return SAFE_CLICK_FALLBACK if link.empty? || link.length > MAX_CLICK_LINK_LENGTH + return SAFE_CLICK_FALLBACK unless link == link.strip + return SAFE_CLICK_FALLBACK if link.match?(FORBIDDEN_CLICK_LINK_TEXT) + return link if link == SAFE_CLICK_FALLBACK || link.match?(SAFE_PROJECT_ROOT_LINK) + return link if link.match?(SAFE_PROJECT_TASK_LINK) + + SAFE_CLICK_FALLBACK + end + + def self.body_for(notification) + LOCK_SCREEN_BODY_OVERRIDES + .fetch(notification.event.to_s, notification.message.to_s) + .truncate(MAX_BODY_LENGTH) + end + + # Use the event and validated destination as the collapse key so repeated + # pushes about the same event and task can replace one banner. + # + # notification_type is intentionally not used because several different + # events share one category. For example, a task status change must not + # silently replace a due-date warning about the same task. + # + # A missing or rejected destination receives a notification-specific tag. + # This prevents unrelated downgraded notifications from replacing each other + # and prevents the rejected raw route from being copied into the tag. + def self.tag_for(notification) + click_link = safe_click_link(notification.link) + return "notification-#{notification.id}" unless click_link == notification.link + + "#{notification.event}:#{click_link}" + end + + def self.deliver_to(subscription, payload) + # Checked again here rather than trusted from the row. PushSubscription + # validates this on write, but rows created before that validation existed + # were never checked, and this is the line that actually makes the outbound + # request. Refusing here is what stops a stored bad endpoint being used. + unless PushSubscription.push_service_endpoint?(subscription.endpoint) + Rails.logger.error "Refusing to push to subscription #{subscription.id}: endpoint is not a recognised push service" + return + end + + WebPush.payload_send( + message: payload, + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + vapid: vapid_details, + ttl: MESSAGE_TTL, + urgency: MESSAGE_URGENCY, + open_timeout: OPEN_TIMEOUT, + read_timeout: READ_TIMEOUT, + ssl_timeout: SSL_TIMEOUT + ) + rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription => e + # 410 or 404. The browser has thrown this registration away, or it was never + # valid. Nothing will ever reach it again, so drop the row rather than retry + # it on every notification from here to the end of time. + Rails.logger.info "Removing dead push subscription #{subscription.id}: #{e.class}" + subscription.destroy + end + + def self.vapid_details + { + subject: vapid_subject, + public_key: ENV.fetch('DOUBTFIRE_VAPID_PUBLIC_KEY'), + private_key: ENV.fetch('DOUBTFIRE_VAPID_PRIVATE_KEY') + } + end + + # Push services want a way to contact whoever is sending, as a mailto: or a + # URL. They reject the request outright if it is missing. + def self.vapid_subject + ENV['DOUBTFIRE_VAPID_SUBJECT'].presence || + Doubtfire::Application.config.institution[:host].presence || + 'mailto:noreply@doubtfire.local' + end + + # True once VAPID keys are configured. Keeps the fan-out safe to call today. + def self.configured? + ENV['DOUBTFIRE_VAPID_PUBLIC_KEY'].present? && ENV['DOUBTFIRE_VAPID_PRIVATE_KEY'].present? + end + + private_class_method :body_for, :safe_click_link, :deliver_to, :vapid_details, :vapid_subject +end diff --git a/app/services/readiness_check.rb b/app/services/readiness_check.rb new file mode 100644 index 0000000000..6d25f41251 --- /dev/null +++ b/app/services/readiness_check.rb @@ -0,0 +1,28 @@ +class ReadinessCheck + DATABASE_QUERY = 'SELECT 1'.freeze + + def initialize(database_connection_pool: ActiveRecord::Base.connection_pool, redis: Sidekiq) + @database_connection_pool = database_connection_pool + @redis = redis + end + + def ready? + database_ready? && redis_ready? + rescue StandardError + false + end + + private + + def database_ready? + result = @database_connection_pool.with_connection do |connection| + connection.select_value(DATABASE_QUERY) + end + + result.to_s == '1' + end + + def redis_ready? + @redis.redis(&:ping) == 'PONG' + end +end diff --git a/app/services/session_tracker.rb b/app/services/session_tracker.rb new file mode 100644 index 0000000000..441d1e5d75 --- /dev/null +++ b/app/services/session_tracker.rb @@ -0,0 +1,115 @@ +class SessionTracker + THRESHOLD = 15 # minutes + + def self.record_assessment_activity(action:, user:, project:, ip_address:, task: nil, unit: nil) + unit = project.unit if project + role = unit.role_for(user) if unit + + return if role.nil? + + if role != Role.admin && role != Role.convenor && role != Role.tutor + return + end + + session = find_or_create_session(user, project.unit, ip_address) + + activity = session.session_activities.create!( + action: action, + project_id: project.id, + task_id: task&.id, + task_definition_id: task&.task_definition_id, + created_at: Time.zone.now + ) + + session.update_session_details + + activity + end + + # Finds the most recent active session for the given user/unit/ip. + # If there's been less than THRESHOLD minutes of inactivity, it returns that session. + # Otherwise, it creates a new session starting now + # + # This means a session can last indefinitely as long as there's been an action within 15 minutes of the previous action + # + def self.find_or_create_session(user, unit, ip_address) + now = Time.zone.now + session = MarkingSession + .where(user: user, unit: unit) + .where("end_time IS NULL OR end_time > ?", THRESHOLD.minutes.ago) + .order(start_time: :desc) + .first + + # Find unit_role + unit_role = unit.unit_roles.find_by(user: user) + activity_type = ActivityType.find_by(abbreviation: "Feedback") + tutorial_streams = unit_role && activity_type ? unit.tutorial_streams.where(activity_type: activity_type) : [] + is_during_tutorial = false + + tutorial_streams.each do |stream| + # TODO: tutorial stream refactor should have an option whether or not to this stream should be used to split marking sessions + next if stream.name == "D/HD Feedback" + + tutorials = stream.tutorials.where(unit_role: unit_role) + tutorials.each do |tutorial| + # Skip if day does not match + next if tutorial.meeting_day != now.strftime('%A') + + tz = Time.zone + tz = ActiveSupport::TimeZone[tutorial.campus&.timezone] if tutorial.campus&.timezone.present? + tutorial_start = tz.parse("#{now.to_date} #{tutorial.meeting_time}") + + tutorial_end = tutorial_start + 2.hours + + if now >= tutorial_start && now < tutorial_end + is_during_tutorial = true + end + + if session.nil? || session.start_time.nil? + # No session yet, create new one + session = MarkingSession.create!( + user: user, + unit: unit, + ip_address: ip_address, + start_time: now, + during_tutorial: now >= tutorial_start && now < tutorial_end + ) + + elsif session.start_time < tutorial_start && now >= tutorial_start && now < tutorial_end + # Session before tutorial, but now is during tutorial + session.update(end_time: tutorial_start - 1.second) + session = MarkingSession.create!( + user: user, + unit: unit, + ip_address: ip_address, + start_time: now, + during_tutorial: true + ) + elsif session.start_time >= tutorial_start && session.start_time < tutorial_end && now >= tutorial_end + # Session started during tutorial, now past tutorial + session.update(end_time: tutorial_end) + session = MarkingSession.create!( + user: user, + unit: unit, + ip_address: ip_address, + start_time: now, + during_tutorial: false + ) + end + end + end + + # Fallback: if still nil, create a session outside tutorial + session ||= MarkingSession.create!( + user: user, + unit: unit, + ip_address: ip_address, + start_time: now, + during_tutorial: false + ) + + session.update(during_tutorial: is_during_tutorial) + + session + end +end diff --git a/app/services/task_prioritization_service.rb b/app/services/task_prioritization_service.rb new file mode 100644 index 0000000000..b4b7a7fe08 --- /dev/null +++ b/app/services/task_prioritization_service.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +class TaskPrioritizationService + Candidate = Data.define(:project, :task_definition, :task, :due_date, :blocked) + + DEADLINE_HORIZON_DAYS = 28 + DEADLINE_WEIGHT = 0.60 + WORKLOAD_WEIGHT = 0.25 + TASK_SIZE_WEIGHT = 0.15 + WORKLOAD_MIDPOINT = 5.0 + PREREQUISITE_STATUS_LEVELS = { + attention_required: 0, + ready_for_feedback: 1, + assess_in_portfolio: 1, + discuss: 2, + rediscuss: 2, + demonstrate: 2, + complete: 3 + }.freeze + + def initialize(user, today: Time.zone.today) + @user = user + @today = today + end + + def call + candidates = remaining_candidates + recommendation_candidates = candidates.reject(&:blocked) + task_size_scores = calculate_task_size_scores(candidates) + workload_scores = calculate_workload_scores(candidates, task_size_scores) + + recommendations = recommendation_candidates.map do |candidate| + [candidate, build_recommendation(candidate, task_size_scores, workload_scores)] + end + sorted_recommendations = recommendations.sort_by do |candidate, recommendation| + [ + -recommendation[:priority_score], + candidate.due_date || Date.new(9999, 12, 31), + recommendation[:project_id], + recommendation[:task_definition_id] + ] + end + + sorted_recommendations.map(&:last) + end + + private + + attr_reader :today, :user + + def remaining_candidates + projects.flat_map do |project| + tasks_by_definition = project.tasks.index_by(&:task_definition_id) + + assigned_task_definitions(project).filter_map do |task_definition| + task = tasks_by_definition[task_definition.id] + next if task && final_status_ids.include?(task.task_status_id) + + Candidate.new( + project: project, + task_definition: task_definition, + task: task, + due_date: effective_due_date(project, task_definition, task)&.to_date, + blocked: blocked_by_prerequisite?(task_definition, tasks_by_definition) + ) + end + end + end + + def projects + Project + .for_user(user, false) + .includes( + { tasks: [:task_status, { task_definition: :grade_due_dates }] }, + { unit: { task_definitions: [:grade_due_dates, :task_prerequisites] } } + ) + end + + def assigned_task_definitions(project) + @assigned_task_definitions ||= {} + @assigned_task_definitions[project.id] ||= project.unit.task_definitions.select do |task_definition| + task_definition.target_grade <= project.target_grade.to_i + end + end + + def final_status_ids + @final_status_ids ||= [ + TaskStatus.complete.id, + TaskStatus.fail.id, + TaskStatus.feedback_exceeded.id, + TaskStatus.time_exceeded.id, + TaskStatus.assess_in_portfolio.id, + TaskStatus.ready_for_feedback.id + ] + end + + def effective_due_date(project, task_definition, task) + return task.local_due_date if task + + if project.unit.allow_flexible_dates + grade_target_date = task_definition.grade_target_date(project.target_grade.to_i) + return grade_target_date if grade_target_date + end + + task_definition.target_date + end + + def blocked_by_prerequisite?(task_definition, tasks_by_definition) + task_definition.task_prerequisites.any? do |link| + prerequisite_task = tasks_by_definition[link.prerequisite_id] + next true unless prerequisite_task&.ready_or_complete? + + current_level = PREREQUISITE_STATUS_LEVELS[prerequisite_task.status] + required_level = PREREQUISITE_STATUS_LEVELS[TaskStatus.id_to_key(link.task_status_id)] + + current_level.nil? || required_level.nil? || current_level < required_level + end + end + + # Weighting is comparable within a unit, not across units. The denominator + # includes all work assigned at the student's target grade, so completing a + # task does not inflate the relative size of every task that remains. + def calculate_task_size_scores(candidates) + project_totals = candidates.map(&:project).uniq.to_h do |project| + assigned_definitions = assigned_task_definitions(project) + total_weight = assigned_definitions.sum { |task_definition| definition_weight(task_definition) } + + [project.id, { weight: total_weight, count: assigned_definitions.length }] + end + + candidates.to_h do |candidate| + totals = project_totals.fetch(candidate.project.id) + score = if totals[:weight].positive? + (task_weight(candidate) / totals[:weight]) * 100 + elsif totals[:count].positive? + 100.0 / totals[:count] + else + 0 + end + [candidate, score] + end + end + + # Workload pressure is full-project percentage points due by this task's date + # per available day. A fixed saturating curve maps five percentage points per + # day to 50 without rescaling recommendations against one another. + # Grouping equal dates before accumulating preserves the inclusive + # "work due by this date" semantics without rescanning every candidate. + def calculate_workload_scores(candidates, task_size_scores) + workload_scores = candidates.index_with { 0 } + candidates_with_due_dates = candidates.select(&:due_date).group_by(&:due_date) + cumulative_work = 0.0 + + candidates_with_due_dates.sort_by { |due_date, _| due_date }.each do |due_date, due_candidates| + cumulative_work += due_candidates.sum { |candidate| task_size_scores.fetch(candidate) } + available_days = [(due_date - today).to_i, 1].max + raw_pressure = cumulative_work / available_days + pressure = (raw_pressure * 100) / (raw_pressure + WORKLOAD_MIDPOINT) + + due_candidates.each do |candidate| + workload_scores[candidate] = pressure + end + end + + workload_scores + end + + def task_weight(candidate) + definition_weight(candidate.task_definition) + end + + def definition_weight(task_definition) + [task_definition.weighting.to_f, 0].max + end + + def deadline_score(candidate) + return 0 unless candidate.due_date + + days_left = (candidate.due_date - today).to_i + return 100 if days_left <= 0 + return 0 if days_left >= DEADLINE_HORIZON_DAYS + + ((DEADLINE_HORIZON_DAYS - days_left) / DEADLINE_HORIZON_DAYS.to_f) * 100 + end + + def build_recommendation(candidate, task_size_scores, workload_scores) + priority_score = + (DEADLINE_WEIGHT * deadline_score(candidate)) + + (WORKLOAD_WEIGHT * workload_scores.fetch(candidate)) + + (TASK_SIZE_WEIGHT * task_size_scores.fetch(candidate)) + priority_score = priority_score.clamp(0, 100) + + { + task_id: candidate.task&.id, + task_definition_id: candidate.task_definition.id, + task_name: candidate.task_definition.name, + project_id: candidate.project.id, + unit_id: candidate.project.unit_id, + priority_score: priority_score.round(2) + } + end +end diff --git a/app/sidekiq/accept_overseer_job.rb b/app/sidekiq/accept_overseer_job.rb new file mode 100644 index 0000000000..a494356a1e --- /dev/null +++ b/app/sidekiq/accept_overseer_job.rb @@ -0,0 +1,301 @@ +require 'yaml' +require 'open3' + +class AcceptOverseerJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first, args.last, 'overseer-assessment'] }, + on_conflict: :reject, + retry: 1 + + def perform(task_id, _output_path, docker_image_name_tag, submission, assessment, timestamp, overseer_assessment_id) + logger.info "Starting overseer job..." + + at(0) + total(1) + + task = Task.find(task_id) + task_definition = task.task_definition + + raise "PDF is still compiling" if task.processing_pdf? || !task.has_done_file? + + raise "Submission file not found: #{submission}" unless File.exist?(submission) + + active_overseer_steps = task_definition.overseer_steps.select(&:enabled) + + raise "Task definition has no enabled overseer steps #{task.unit.detailed_name} #{task_definition.abbreviation}" if active_overseer_steps.empty? + + oa = OverseerAssessment.find(overseer_assessment_id) + oa.update!( + total_steps: active_overseer_steps.size + ) + + work_dir_name = "#{task.id}-#{overseer_assessment_id}" + + work_dir = Rails.root.join("tmp", "overseer", work_dir_name) + FileUtils.mkdir_p(work_dir) + + extract_student_submission_files(task, submission, work_dir, timestamp) + extract_overseer_resource_files(assessment, work_dir) + success_status = nil + failure_status = nil + + comment = task.comments.find_by(commentable: oa) + unless comment + oa.add_assessment_comment("Tests in progress") + end + + steps_attempted = 0 + steps_passed = 0 + + assessment_pass = true + + overseer_image = task_definition.overseer_image || + task.unit.overseer_image || + OverseerImage.find_by(tag: docker_image_name_tag) + ensure_docker_image_present(docker_image_name_tag, overseer_image) + + active_overseer_steps.each do |step| + result = run_overseer_step( + step: step, + work_dir: work_dir, + work_dir_name: work_dir_name, + task_id: task_id, + timestamp: timestamp, + docker_image_name_tag: docker_image_name_tag, + overseer_assessment_id: overseer_assessment_id + ) + + steps_attempted += 1 + + if result.valid? && result.pass + steps_passed += 1 + if step.halt_on_success && step.status_on_success_id + success_status = TaskStatus.find(step.status_on_success_id) + break + end + elsif step.halt_on_failure + failure_status = TaskStatus.find(step.status_on_failure_id) if step.status_on_failure_id + assessment_pass = false + break + end + end + + oa.update_assessment_comment("Tests complete: #{steps_passed} / #{active_overseer_steps.count}") + + if steps_attempted == steps_passed && assessment_pass + oa.update!(status: :passed) + unless success_status.nil? + # TODO: have an override status setting for the step? eg. if the task is overdue, let it remain overdue, otherwise use this task status + task.update!(task_status: success_status) + task.add_status_comment(task.project.tutor_for(task.task_definition), success_status) + + oa.update!(result_task_status: success_status.status_key.to_s) + end + else + oa.update!(status: :failed) + # preserve_status_on_failure = [TaskStatus.time_exceeded.id, TaskStatus.assess_in_portfolio.id].include?(task.task_status_id) + + unless failure_status.nil? # || preserve_status_on_failure + # TODO: have an override status setting for the step? eg. if the task is overdue, let it remain overdue, otherwise use this task status + task.update!(task_status: failure_status) + task.add_status_comment(task.project.tutor_for(task.task_definition), failure_status) + oa.update!(result_task_status: failure_status.status_key.to_s) + end + task.add_text_comment(task.project.tutor_for(task.task_definition), "**Automated comment**: Some tests did not pass for this submission. Please review the Overseer report, verify your output, and resubmit.") + end + + logger.info "Completed overseer job" + rescue StandardError => e + logger.error e + raise e + ensure + FileUtils.rm_rf(work_dir) if work_dir + end + + def ensure_docker_image_present(docker_image_name_tag, overseer_image) + _, _, inspect_status = Open3.capture3('docker', 'image', 'inspect', docker_image_name_tag) + return if inspect_status.success? + + raise "Docker image #{docker_image_name_tag} is not configured" if overseer_image.nil? + + overseer_image.pull_from_docker + return if overseer_image.success? + + raise "Unable to pull Docker image #{docker_image_name_tag}: #{overseer_image.pulled_image_text}" + end + + def run_overseer_step(step:, work_dir:, work_dir_name:, task_id:, timestamp:, docker_image_name_tag:, overseer_assessment_id:) + script_contents = step.run_command + raise "Execution script is empty" if script_contents.blank? + + decoded = + if script_contents.start_with?("b64:") + begin + Base64.urlsafe_decode64(script_contents.delete_prefix("b64:")) + rescue ArgumentError + raise "Invalid overseer script content" + end + else + script_contents + end + + # Create script + run_sh_path = File.join(work_dir, 'run.sh') + File.write(run_sh_path, decoded) + + # Ensure script is executable + system("chmod +x #{run_sh_path}") + + mount = Doubtfire::Application.config.overseer_workdir_volume_mount + volume_mount = + if mount.nil? + # Fallback for development only — mounts the entire overseer container volume, + # allowing all task work directories to be accessible. This should never be + # used in production, as it breaks isolation between tasks. + "--volumes-from #{Doubtfire::Application.config.overseer_fallback_volume_container}" + else + # Absolute path on the hosting server to the shared mount + "-v #{mount}/#{work_dir_name}:/overseer/work-dir/#{work_dir_name}" + end + + # Max runtime (seconds) before force-killing the step (exit status 124) + timeout = step.timeout + timeout = 30 if timeout.nil? || timeout.negative? + + container_name = "overseer-#{task_id}-#{timestamp}" + + command = %( + timeout #{timeout} docker run --rm -i \ + --pull never \ + --cpus 1 \ + --network none \ + #{volume_mount} \ + --name #{container_name} \ + #{docker_image_name_tag} \ + bash -c "cd /overseer/work-dir/#{work_dir_name} && timeout #{timeout} ./run.sh" + ) + + stdin_input_file = nil + expected_output_file = nil + + # Retrieve names of input/output files + if step.step_type == 'output_diff' + stdin_input_file = step.stdin_input_file.present? ? File.join(work_dir, step.stdin_input_file) : nil + expected_output_file = step.expected_output_file.present? ? File.join(work_dir, step.expected_output_file) : nil + end + + output = "" + status = nil + stdin_contents = nil + + # Execute script and capture output + Open3.popen2e(command) do |stdin, stdout_err, wait_thr| + # If input file exists, pass it as standard input + if stdin_input_file && File.exist?(stdin_input_file) + File.open(stdin_input_file, 'rb') { |f| IO.copy_stream(f, stdin) } + stdin_contents = File.read(stdin_input_file) + stdin.close + end + + stdout_err.each { |line| output << line } + status = wait_thr.value + end + + output = output.chomp + pass = status.exitstatus == 0 + + expected_output_contents = nil + + # If step type is comparing output, retrieve expected output file contents + if step.step_type == 'output_diff' + expected_output_contents = + if expected_output_file && File.exist?(expected_output_file) + File.read(expected_output_file) + else + '' + end + matches_output = if step.partial_output_diff + output.include?(expected_output_contents) + else + output == expected_output_contents + end + + pass = false unless matches_output + end + + feedback_message = + if step.feedback_message.blank? + if step.step_type == 'output_diff' + "Your output did not match the expected result." + else + "This test did not complete successfully. Check the output for any errors." + end + else + step.feedback_message + end + + output_truncated = output&.first(20_000) + + OverseerStepResult.create!( + overseer_assessment_id: overseer_assessment_id, + overseer_step: step, + exit_status: status.exitstatus, + pass: pass, + feedback_message: feedback_message, + stdout: output_truncated, + stdin: stdin_contents, + expected_output: expected_output_contents, + stdout_sha256: Digest::SHA256.hexdigest(output), + stdin_sha256: stdin_contents && Digest::SHA256.hexdigest(stdin_contents), + expected_output_sha256: expected_output_contents && Digest::SHA256.hexdigest(expected_output_contents) + ) + end + + def extract_student_submission_files(task, submission, work_dir, timestamp) + # Submission files are stored directly under their timestamp in the task archive. + Zip::File.open(submission) do |history_zip| + prefix = "#{FileHelper.sanitized_path(timestamp.to_s)}/" + entries = history_zip.entries.reject(&:name_is_directory?).select { |entry| entry.name.start_with?(prefix) } + raise "Submission history entries not found for timestamp: #{timestamp}" if entries.empty? + + extract_submission_entries(task, entries, work_dir, prefix) + end + end + + def extract_submission_entries(task, entries, work_dir, prefix) + # Extract submission files, removing any parent folders. + entries.each do |entry| + parts = entry.name.delete_prefix(prefix).split('/') + next unless parts.first == task.id.to_s && parts.length >= 2 + + file_name = parts.second + index = file_name.to_i + + file = task.upload_requirements[index] + final_name = file['name'] + + dest_path = File.join(work_dir, final_name) + FileUtils.mkdir_p(File.dirname(dest_path)) + entry.extract(dest_path) { true } + end + end + + def extract_overseer_resource_files(assessment, work_dir) + # Extract optional assessment resources + if File.exist?(assessment) + Zip::File.open(assessment) do |zip_file| + zip_file.each do |entry| + dest_path = File.join(work_dir, entry.name) + FileUtils.mkdir_p(File.dirname(dest_path)) + zip_file.extract(entry, dest_path) { true } # overwrite if exists + end + end + end + end +end diff --git a/app/sidekiq/accept_submission_job.rb b/app/sidekiq/accept_submission_job.rb index 86235f2c25..eaaf9b380e 100644 --- a/app/sidekiq/accept_submission_job.rb +++ b/app/sidekiq/accept_submission_job.rb @@ -2,18 +2,97 @@ class AcceptSubmissionJob include Sidekiq::Job include LogHelper - def perform(task_id, user_id, accepted_tii_eula) - task = Task.find(task_id) - user = User.find(user_id) + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false - # Convert submission to PDF - task.convert_submission_to_pdf + def perform(task_id, user_id, accepted_tii_eula, test_submission) + begin + # Ensure cwd is valid... + FileUtils.cd(Rails.root) + rescue StandardError => e + logger.error e + end + + begin + task = Task.find(task_id) + user = User.find(user_id) + rescue StandardError => e + logger.error e + return + end + + begin + logger.info "Accepting submission for task #{task.id} by user #{user.id}" + # Convert submission to PDF + task.convert_submission_to_pdf(log_to_stdout: true) + rescue StandardError => e + logger.error e + + # Send email to student if task pdf failed + if task.project.student.receive_task_notifications + begin + PortfolioEvidenceMailer.task_pdf_failed(task.project, [task]).deliver + rescue StandardError => e + logger.error "Failed to send task pdf failed email for project #{task.project.id}!\n#{e.message}" + end + end + + begin + # Notify system admin + if defined?(Sentry) + Sentry.capture_exception( + e, + extra: { + task_id: task.id, + task_definition_abbreviation: task.task_definition.abbreviation, + latex_log_message: e.respond_to?(:log_message) ? e.log_message.to_s.last(5000) : nil + } + ) + end + mail = ErrorLogMailer.error_message('Accept Submission', "Failed to convert submission to PDF for task #{task.log_details}", e) + mail.deliver if mail.present? + rescue StandardError => e + logger.error "Failed to send error log to admin" + end + + return + end + + # Mark this task for moderation + tutor_user = task.project.tutor_for(task.task_definition) + if tutor_user && !test_submission + tutor = task.unit.unit_role_for(tutor_user) + if tutor&.should_moderate_task?(task) + logger.info "Marking task #{task.id} for moderation (project #{task.project.id})" + task.mark_as_moderated + end + end # When converted, we can now send documents to turn it in for checking - if TurnItIn.functional? + if TurnItIn.enabled? && !test_submission task.send_documents_to_tii(user, accepted_tii_eula: accepted_tii_eula) end + + if SubmissionHistory.enabled_requirements(task).any? + submission_timestamp = Time.now.utc.to_i + SubmissionHistory.mark_pending(task) + CreateSubmissionHistoryJob.perform_async(task.id, submission_timestamp, test_submission) + elsif task.overseer_enabled? || test_submission + logger.error "Overseer assessment was not performed because task definition #{task.task_definition.id} has no submission history files configured" + end rescue StandardError => e # to raise error message to avoid unnecessary retry logger.error e + if defined?(Sentry) + Sentry.capture_exception( + e, + extra: { + task_id: task&.id, + task_definition_abbreviation: task&.task_definition&.abbreviation + } + ) + end + task.clear_in_process end end diff --git a/app/sidekiq/aggregate_peer_progress_job.rb b/app/sidekiq/aggregate_peer_progress_job.rb new file mode 100644 index 0000000000..c320e7e94a --- /dev/null +++ b/app/sidekiq/aggregate_peer_progress_job.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +class AggregatePeerProgressJob + class AggregationError < StandardError; end + + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: lambda { |args| + [args.first || 'all-active-units'] + }, + on_conflict: :reject, + retry: 3 + + def perform(unit_id = nil) + return enqueue_active_units if unit_id.blank? + + aggregate_unit(Unit.find(unit_id)) + rescue StandardError => e + log_unit_id = unit_id.presence || 'all-active-units' + failure_message = + "Peer progress aggregation failed for unit_id=#{log_unit_id}: " \ + "#{e.class.name}" + + logger.error(failure_message) + raise AggregationError, failure_message, cause: nil + end + + private + + def enqueue_active_units + logger.info( + 'Queueing peer progress aggregation for active units...' + ) + + # Only units whose convenor has opted in. Aggregating the rest would store + # derived cohort statistics for units that never enabled the feature, and + # the endpoint returns early on peer_progress_enabled? so those rows could + # never be served anyway. + Unit.active_units.where(peer_progress_enabled: true).find_each do |unit| + self.class.perform_async(unit.id) + end + + logger.info( + 'Queued peer progress aggregation jobs.' + ) + end + + def aggregate_unit(unit) + unless unit.active? + logger.info( + "Skipping peer progress aggregation for inactive unit_id=#{unit.id}" + ) + return + end + + unless unit.peer_progress_enabled? + logger.info( + "Skipping peer progress aggregation for unit_id=#{unit.id}, " \ + 'peer progress is not enabled' + ) + return + end + + logger.info( + "Starting peer progress aggregation for unit_id=#{unit.id}..." + ) + + at(0) + total(1) + + PeerProgressAggregationService.call( + unit: unit, + calculated_at: Time.zone.now + ) + + at(1) + + logger.info( + "Completed peer progress aggregation for unit_id=#{unit.id}." + ) + end +end diff --git a/app/sidekiq/aggregate_task_completion_stats_job.rb b/app/sidekiq/aggregate_task_completion_stats_job.rb new file mode 100644 index 0000000000..4bb375ebec --- /dev/null +++ b/app/sidekiq/aggregate_task_completion_stats_job.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class AggregateTaskCompletionStatsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id = nil) + logger.info 'Starting task completion stats aggregation...' + + at(0) + total(1) + + if unit_id.present? + Unit.find(unit_id).capture_task_complete_stats_snapshot! + else + Unit.active_units.find_each(&:capture_task_complete_stats_snapshot!) + end + + at(1) + logger.info 'Completed task completion stats aggregation!' + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/archive_old_units_job.rb b/app/sidekiq/archive_old_units_job.rb new file mode 100644 index 0000000000..db4ce2f11b --- /dev/null +++ b/app/sidekiq/archive_old_units_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Move old units to archive folder +class ArchiveOldUnitsJob + include Sidekiq::Job + + def perform + return unless Doubtfire::Application.config.archive_units + + archive_period = Doubtfire::Application.config.unit_archive_after_period + + archive_period = 1.year if archive_period < 1.year + + units = Unit.where(archived: false).where('end_date < :archive_before', archive_before: DateTime.now - archive_period) + + units.find_each(&:move_files_to_archive) + rescue StandardError => e + begin + # Notify system admin + Sentry.capture_exception(e) if defined?(Sentry) + mail = ErrorLogMailer.error_message('Archive Units', "Failed to move old units to archive", e) + mail.deliver if mail.present? + + logger.error e + rescue StandardError => e + logger.error "Failed to send error log to admin" + end + end +end diff --git a/app/sidekiq/clear_access_tokens_job.rb b/app/sidekiq/clear_access_tokens_job.rb new file mode 100644 index 0000000000..b9a4524bb0 --- /dev/null +++ b/app/sidekiq/clear_access_tokens_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Remove auth tokens and oauth state and tokens that have expired +class ClearAccessTokensJob + include Sidekiq::Job + + def perform + UserOauthToken.destroy_old_tokens + UserOauthState.destroy_old_states + + AuthToken.destroy_old_tokens + end +end diff --git a/app/sidekiq/communication_rule_job.rb b/app/sidekiq/communication_rule_job.rb new file mode 100644 index 0000000000..3d1b02c6ce --- /dev/null +++ b/app/sidekiq/communication_rule_job.rb @@ -0,0 +1,39 @@ +class CommunicationRuleJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first, args.last, 'communication-rule'] }, + on_conflict: :reject, + retry: 1 + + def perform(rule_id) + logger.info "Starting communication rule job..." + + at(0) + total(1) + + rule = CommunicationRule.find(rule_id) + + projects = rule.communication_set.preview_projects_for_rule(rule) + store( + result: projects.map do |project| + { + username: project.user&.username, + student_id: project.user&.student_id, + target_grade: project.target_grade, + last_sign_in_at: project.user&.last_sign_in_at + } + end + ) + + logger.info "Completed communication job" + rescue StandardError => e + logger.error e + raise e + end + +end diff --git a/app/sidekiq/create_submission_history_job.rb b/app/sidekiq/create_submission_history_job.rb new file mode 100644 index 0000000000..9efedb25ff --- /dev/null +++ b/app/sidekiq/create_submission_history_job.rb @@ -0,0 +1,27 @@ +class CreateSubmissionHistoryJob + include Sidekiq::Job + include LogHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first, 'submission-history'] }, + on_conflict: :reject, + retry: false + + def perform(task_id, submission_timestamp, test_submission) + task = Task.find(task_id) + history = SubmissionHistory.create_archive!(task, submission_timestamp) + + return unless task.overseer_enabled? || test_submission + + assessment = OverseerAssessment.create_for(history, test_submission) + return if assessment.nil? + + assessment.update!(student_notified_at: Time.current) if test_submission + assessment.send_to_overseer(test_submission: test_submission) + rescue StandardError => e + logger.error e + Sentry.capture_exception(e, extra: { task_id: task_id }) if defined?(Sentry) + ensure + SubmissionHistory.clear_pending(task) if task + end +end diff --git a/app/sidekiq/d2l_post_grades_job.rb b/app/sidekiq/d2l_post_grades_job.rb new file mode 100644 index 0000000000..10bbf80e59 --- /dev/null +++ b/app/sidekiq/d2l_post_grades_job.rb @@ -0,0 +1,42 @@ +require 'csv' + +class D2lPostGradesJob + include Sidekiq::Job + include LogHelper + + sidekiq_options lock: :until_executed + + def perform(unit_id, user_id) + unit = Unit.find(unit_id) + user = User.find(user_id) + + logger.info "Posting grades for unit #{unit.id} by user #{user.id}" + + result = D2lIntegration.post_grades(unit, user) + + CSV.open(D2lIntegration.result_file_path(unit), "wb") do |csv| + csv << %w[Status Id Grade Message] + result.each do |r| + csv << r.split(",") + end + end + + logger.info "Finished posting grades for unit #{unit.id} by user #{user.id}" + + mail = D2lResultMailer.result_message(unit, user) + mail.deliver if mail.present? + + logger.info "Sent email to user #{user.id} for unit #{unit.id} grade transfer result" + rescue StandardError => e + logger.error e + + begin + mail = D2lResultMailer.result_message(unit, user, result_message: "failed. Please check the D2L settings for the unit, and your permissions within D2L to upload results. #{e.message}", success: false) + mail.deliver if mail.present? + + logger.info "Sent fail email to user #{user.id} for unit #{unit.id} grade transfer result" + rescue StandardError => exception + logger.error exception + end + end +end diff --git a/app/sidekiq/download_marking_sessions_job.rb b/app/sidekiq/download_marking_sessions_job.rb new file mode 100644 index 0000000000..f06f5b5fd3 --- /dev/null +++ b/app/sidekiq/download_marking_sessions_job.rb @@ -0,0 +1,35 @@ +require 'csv' + +class DownloadMarkingSessionsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_role_id, start_date, end_date, timezone) + start_date = Date.parse(start_date) if start_date + end_date = Date.parse(end_date) if end_date + logger.info "Starting tutor marking sessions csv download..." + + at(0) + total(1) + + unit_role = UnitRole.find(unit_role_id) + csv = unit_role.get_marking_sessions_csv(start_date: start_date, end_date: end_date, timezone: timezone) + + store(result: csv) + + logger.info "Completed tutor marking sessions csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_overflow_task_claims_csv_job.rb b/app/sidekiq/download_overflow_task_claims_csv_job.rb new file mode 100644 index 0000000000..5fd7429b66 --- /dev/null +++ b/app/sidekiq/download_overflow_task_claims_csv_job.rb @@ -0,0 +1,33 @@ +require 'csv' + +class DownloadOverflowTaskClaimsCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id) + logger.info "Starting overflow task claims csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.overflow_task_claims_csv + + store(result: csv) + + logger.info "Completed overflow task claims csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_portfolios_job.rb b/app/sidekiq/download_portfolios_job.rb new file mode 100644 index 0000000000..378ded4d47 --- /dev/null +++ b/app/sidekiq/download_portfolios_job.rb @@ -0,0 +1,34 @@ +require 'csv' + +class DownloadPortfoliosJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args }, + on_conflict: :reject, + retry: false + + def perform(current_user_id, unit_id) + logger.info "Starting portfolio download..." + + at(0) + total(0) + + unit = Unit.find(unit_id) + current_user = User.find(current_user_id) + + output_zip = unit.get_portfolio_zip(current_user, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: File.basename(output_zip)) + + logger.info "Compressed portfolio downloads!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_submission_files_job.rb b/app/sidekiq/download_submission_files_job.rb new file mode 100644 index 0000000000..0c5ece014e --- /dev/null +++ b/app/sidekiq/download_submission_files_job.rb @@ -0,0 +1,35 @@ +require 'csv' + +class DownloadSubmissionFilesJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args }, + on_conflict: :reject, + retry: false + + def perform(current_user_id, unit_id, task_definition_id) + logger.info "Starting submission files download..." + + at(0) + total(0) + + unit = Unit.find(unit_id) + td = TaskDefinition.find(task_definition_id) + current_user = User.find(current_user_id) + + output_zip = unit.get_task_submissions_zip(current_user, td, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: File.basename(output_zip)) + + logger.info "Compressed submission files download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_submission_pdfs_job.rb b/app/sidekiq/download_submission_pdfs_job.rb new file mode 100644 index 0000000000..015dd3a5b1 --- /dev/null +++ b/app/sidekiq/download_submission_pdfs_job.rb @@ -0,0 +1,35 @@ +require 'csv' + +class DownloadSubmissionPdfsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args }, + on_conflict: :reject, + retry: false + + def perform(current_user_id, unit_id, task_definition_id) + logger.info "Starting submission pdfs download..." + + at(0) + total(0) + + unit = Unit.find(unit_id) + td = TaskDefinition.find(task_definition_id) + current_user = User.find(current_user_id) + + output_zip = unit.get_task_submissions_pdf_zip(current_user, td, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: File.basename(output_zip)) + + logger.info "Compressed submission pdfs downloads!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_task_assessment_counts_csv_job.rb b/app/sidekiq/download_task_assessment_counts_csv_job.rb new file mode 100644 index 0000000000..a136168a1a --- /dev/null +++ b/app/sidekiq/download_task_assessment_counts_csv_job.rb @@ -0,0 +1,33 @@ +require 'csv' + +class DownloadTaskAssessmentCountsCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id) + logger.info "Starting task assessment counts csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.times_tasks_have_been_assessed + + store(result: csv) + + logger.info "Completed task assessment counts csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_task_completion_csv_job.rb b/app/sidekiq/download_task_completion_csv_job.rb new file mode 100644 index 0000000000..3ea9bc1516 --- /dev/null +++ b/app/sidekiq/download_task_completion_csv_job.rb @@ -0,0 +1,33 @@ +require 'csv' + +class DownloadTaskCompletionCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id) + logger.info "Starting task completion csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.task_completion_csv + + store(result: csv) + + logger.info "Completed task completion csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb b/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb new file mode 100644 index 0000000000..6be0876a40 --- /dev/null +++ b/app/sidekiq/download_tasks_awaiting_feedback_csv_job.rb @@ -0,0 +1,33 @@ +require 'csv' + +class DownloadTasksAwaitingFeedbackCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id) + logger.info "Starting tasks awaiting feedback csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.days_awaiting_feedback_by_tutorial_csv + + store(result: csv) + + logger.info "Completed tasks awaiting feedback csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_tutor_assessment_stats_job.rb b/app/sidekiq/download_tutor_assessment_stats_job.rb new file mode 100644 index 0000000000..4a1f8a3728 --- /dev/null +++ b/app/sidekiq/download_tutor_assessment_stats_job.rb @@ -0,0 +1,33 @@ +require 'csv' + +class DownloadTutorAssessmentStatsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id) + logger.info "Starting tutor assessment csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.tutor_assessment_csv + + store(result: csv) + + logger.info "Completed tutor assessment csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/download_unit_tutor_times_summary_job.rb b/app/sidekiq/download_unit_tutor_times_summary_job.rb new file mode 100644 index 0000000000..3bbda469f9 --- /dev/null +++ b/app/sidekiq/download_unit_tutor_times_summary_job.rb @@ -0,0 +1,35 @@ +require 'csv' + +class DownloadUnitTutorTimesSummaryJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, start_date, end_date, timezone, ignore_sessions_during_tutorials) + start_date = Date.parse(start_date) if start_date + end_date = Date.parse(end_date) if end_date + logger.info "Starting unit tutor times summary csv download..." + + at(0) + total(1) + + unit = Unit.find(unit_id) + csv = unit.get_tutor_times_csv(start_date: start_date, end_date: end_date, timezone: timezone, ignore_sessions_during_tutorials: ignore_sessions_during_tutorials) + + store(result: csv) + + logger.info "Completed unit tutor times summary csv download!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/execute_communication_set_job.rb b/app/sidekiq/execute_communication_set_job.rb new file mode 100644 index 0000000000..fa279e1124 --- /dev/null +++ b/app/sidekiq/execute_communication_set_job.rb @@ -0,0 +1,601 @@ +require 'csv' + +class ExecuteCommunicationSetJob + include Sidekiq::Job + include Sidekiq::Status::Worker + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args[0], args[1], 'communication-set'] }, + on_conflict: :reject, + retry: 1 + + def perform(communication_set_id, target_rule_id = nil) + communication_set = CommunicationSet.find(communication_set_id) + rules = communication_set.communication_rules.to_a + target_rule_id = target_rule_id&.to_i + + if target_rule_id.present? && rules.none? { |rule| rule.id == target_rule_id } + raise ActiveRecord::RecordNotFound, "CommunicationRule #{target_rule_id} not found in set #{communication_set_id}" + end + + eligible_projects = communication_set.eligible_projects + remaining_projects = eligible_projects.dup + executed_rules = [] + action_results = [] + + rules_to_process = + if target_rule_id.present? + cutoff_index = rules.index { |rule| rule.id == target_rule_id } + rules.first(cutoff_index + 1) + else + rules + end + + at(0) + total(rules_to_process.length.nonzero? || 1) + + rules_to_process.each_with_index do |rule, index| + matched_projects = rule.matching_projects(remaining_projects) + + executed_rules << { + rule_id: rule.id, + rule_name: rule.name, + matched_project_ids: matched_projects.map(&:id) + } + + should_execute_actions = target_rule_id.present? ? rule.id == target_rule_id : true + + if should_execute_actions + rule_action_results = rule.communication_actions.flat_map do |action| + execute_action(action, matched_projects, communication_set.unit, rule) + end + + action_results.concat(rule_action_results) + if rule.send_log_to_convenors? + action_results.concat( + send_action_log_to_convenors( + matched_projects, + communication_set.unit, + rule, + rule_action_results + ) + ) + end + end + + remaining_projects -= matched_projects + at(index + 1) + end + + store( + result: { + communication_set_id: communication_set.id, + target_rule_id: target_rule_id, + executed_rule_ids: executed_rules.map { |item| item[:rule_id] }, + remaining_project_ids: remaining_projects.map(&:id), + rules: executed_rules, + actions: action_results + } + ) + rescue StandardError => e + logger.error("ExecuteCommunicationSetJob failed: #{e.class} #{e.message}") + raise e + end + + private + + def execute_action(action, projects, unit, rule) + case action.type + when 'ChangeTargetGradeAction' + execute_change_target_grade_action(action, projects) + when 'EmailStudentAction' + execute_email_student_action(action, projects, unit, rule) + when 'EmailStaffAction' + execute_email_staff_action(action, projects, unit, rule) + when 'TaskCommentAction' + execute_task_comment_action(action, projects, unit, rule) + else + [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + reason: 'unsupported action type' + }] + end + end + + def execute_change_target_grade_action(action, projects) + projects.map do |project| + previous_target_grade = project.target_grade + + project.update!(target_grade: action.target_grade) + + { + action_id: action.id, + action_type: action.type, + status: 'updated', + project_id: project.id, + username: project.user&.username, + previous_target_grade: previous_target_grade, + target_grade: action.target_grade + } + end + end + + def execute_email_student_action(action, projects, unit, rule) + projects.filter_map do |project| + recipient = project.user + sender = sender_for(unit) + + if recipient&.email.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + reason: 'student email missing' + } + end + + if sender.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + reason: 'sender email missing' + } + end + + subject = render_template(action.subject, project, unit, rule, projects.length) + body = render_template(action.body, project, unit, rule, projects.length) + + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule + ).deliver_now + + { + action_id: action.id, + action_type: action.type, + status: 'sent', + project_id: project.id, + username: recipient.username, + recipient_email: recipient.email + } + end + end + + def execute_email_staff_action(action, projects, unit, rule) + projects.flat_map do |project| + sender = sender_for(unit) + + if sender.blank? + next [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'sender email missing' + }] + end + + staff_recipients_for(project, unit, action).map do |recipient| + subject = render_template(action.subject, project, unit, rule, projects.length) + body = render_template(action.body, project, unit, rule, projects.length) + + CommunicationsMailer.communication_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule + ).deliver_now + + { + action_id: action.id, + action_type: action.type, + status: 'sent', + project_id: project.id, + username: project.user&.username, + recipient_email: recipient.email, + recipient_username: recipient.username + } + end + end + end + + def execute_task_comment_action(action, projects, unit, rule) + comment_author = sender_user_for(unit) + + if comment_author.blank? + return [{ + action_id: action.id, + action_type: action.type, + status: 'skipped', + reason: 'comment author missing' + }] + end + + comment_text_template = action.body.to_s.strip + + projects.map do |project| + task_definition = action.task_definition || unit.task_definitions.find_by(id: action.task_definition_id) + if task_definition.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'task definition missing' + } + end + + task = project.task_for_task_definition(task_definition) + rendered_comment = render_template(comment_text_template, project, unit, rule, projects.length) + + if rendered_comment.blank? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'comment text missing' + } + end + + comment = task.add_text_comment(comment_author, rendered_comment) + + if comment.nil? + next { + action_id: action.id, + action_type: action.type, + status: 'skipped', + project_id: project.id, + username: project.user&.username, + reason: 'duplicate comment' + } + end + + { + action_id: action.id, + action_type: action.type, + status: 'commented', + project_id: project.id, + username: project.user&.username, + task_definition_id: task_definition.id, + task_definition_name: task_definition.name, + comment_id: comment.id + } + end + end + + def send_action_log_to_convenors(projects, unit, rule, prior_action_results) + sender = sender_for(unit) + + if sender.blank? + return [{ + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'skipped', + reason: 'sender email missing' + }] + end + + recipients = unit.convenors.includes(:user).map(&:user).select { |user| user&.email.present? }.uniq(&:id) + + if recipients.empty? + return [{ + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'skipped', + reason: 'convenor email missing' + }] + end + + csv_content = build_action_log_csv(rule, projects, prior_action_results) + csv_filename = "communication-rule-#{rule.id}-action-log.csv" + body = action_log_email_body(rule, prior_action_results) + subject = action_log_email_subject(unit, rule) + + recipients.map do |recipient| + CommunicationsMailer.action_log_email( + to: formatted_email(recipient), + from: sender, + subject: subject, + body: body, + recipient: recipient, + sender: sender_user_for(unit), + unit: unit, + rule: rule, + csv_content: csv_content, + csv_filename: csv_filename, + affected_students_count: projects.length + ).deliver_now + + { + action_id: nil, + action_type: 'SendLogToConvenors', + status: 'sent', + recipient_email: recipient.email, + recipient_username: recipient.username, + attachment_filename: csv_filename, + affected_students_count: projects.length + } + end + end + + def staff_recipients_for(project, unit, action) + recipients = [] + + if action.email_tutors + recipients.concat( + project.tutorial_enrolments.filter_map do |tutorial_enrolment| + tutorial_enrolment.tutorial&.unit_role&.user + end + ) + end + + if action.email_convenors + recipients.concat(unit.convenors.includes(:user).map(&:user)) + end + + recipients.select { |recipient| recipient&.email.present? }.uniq(&:id) + end + + def render_template(template, project, unit, rule, affected_students_count, target_grade_override = nil, action_results = []) + return '' if template.blank? + + student = project&.user + target_grade_value = target_grade_override.nil? ? project&.target_grade : target_grade_override + + replacements = { + '{{student.first_name}}' => student&.first_name.to_s, + '{{student.last_name}}' => student&.last_name.to_s, + '{{student.preferred_name}}' => (student&.nickname.presence || student&.first_name).to_s, + '{{student.full_name}}' => [student&.first_name, student&.last_name].compact.join(' '), + '{{student.username}}' => student&.username.to_s, + '{{student.student_id}}' => student&.student_id.to_s, + '{{affected_students_count}}' => affected_students_count.to_s, + '{{unit.code}}' => unit.code.to_s, + '{{unit.name}}' => unit.name.to_s, + '{{rule.name}}' => rule.name.to_s, + '{{target_grade}}' => target_grade_name(target_grade_value, unit), + '{{conditions_summary}}' => conditions_summary(rule), + '{{actions_summary}}' => actions_summary(rule, action_results) + } + + replacements.reduce(template.dup) do |rendered, (token, value)| + rendered.gsub(token, value) + end + end + + def target_grade_name(value, unit = nil) + (unit&.grade_label(value) || GradeHelper.grade_for(value)).to_s + end + + def formatted_email(user) + return nil if user&.email.blank? + + %("#{user.name}" <#{user.email}>) + end + + def sender_for(unit) + formatted_email(sender_user_for(unit)) + end + + def sender_user_for(unit) + unit.main_convenor_user || unit.convenors.includes(:user).first&.user + end + + def conditions_summary(rule) + rule.communication_conditions.map do |condition| + "- #{human_condition_summary(condition)}" + end.join("\n") + end + + def actions_summary(rule, action_results) + return rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") if action_results.blank? + + rule.communication_actions.map { |action| "- #{human_action_summary(action)}" }.join("\n") + end + + def build_action_log_csv(rule, projects, action_results) + action_order = rule.communication_actions.each_with_index.to_h { |action, index| [action.id, index] } + ordered_results = action_results.sort_by do |result| + project = projects.find { |item| item.id == result[:project_id] } + student = project&.user + + [ + student&.username.to_s, + action_order.fetch(result[:action_id], Float::INFINITY), + result[:recipient_email].to_s + ] + end + + CSV.generate(headers: true) do |csv| + csv << [ + 'student_username', + 'student_id', + 'student_name', + 'rule_name', + 'action_type', + 'status', + 'details', + # 'previous_target_grade', + # 'new_target_grade', + 'recipient_email', + 'executed_at' + ] + + ordered_results.each do |result| + project = projects.find { |item| item.id == result[:project_id] } + student = project&.user + details = if result[:status] == 'updated' + "Changed target grade from #{target_grade_name(result[:previous_target_grade], rule.unit)} to #{target_grade_name(result[:target_grade], rule.unit)}" + elsif result[:status] == 'commented' + task_definition = TaskDefinition.find_by(id: result[:task_definition_id]) + "Added comment to #{task_definition_label(task_definition)}" + elsif result[:recipient_email].present? + "Sent email to #{result[:recipient_email]}" + else + result[:reason].to_s + end + + csv << [ + student&.username, + student&.student_id, + student&.name, + rule.name, + result[:action_type], + result[:status], + details, + # target_grade_name(result[:previous_target_grade]), + # target_grade_name(result[:target_grade]), + result[:recipient_email], + Time.current.iso8601 + ] + end + end + end + + def action_log_email_subject(unit, rule) + "#{unit.code} #{rule.name} action log" + end + + def action_log_email_body(rule, action_results) + [ + action_log_conditions_intro(rule), + conditions_summary(rule), + 'The following actions have been applied to these students:', + actions_summary(rule, action_results) + ].join("\n") + end + + def action_log_conditions_intro(rule) + if rule.operator == 'or' + 'A scheduled rule has been run for students that match any of the following conditions:' + else + 'A scheduled rule has been run for students that match all of the following conditions:' + end + end + + def human_condition_summary(condition) + case condition.type + when 'TaskDefinitionStatusCondition' + predicate = condition.operator == 'not_equal_to' ? 'Not In' : 'In' + task = TaskDefinition.find_by(id: condition.task_definition_id) + task_label = if task + "Task #{task.abbreviation} #{task.name}" + else + "Task #{condition.task_definition_id}" + end + "Students that have #{task_label} #{predicate} [#{Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ')}]" + when 'TargetGradeCondition' + "Students with a Target Grade #{operator_label(condition.operator)} #{target_grade_name(condition.target_grade, condition.communication.unit)}" + when 'TaskStatusCountCondition' + grade_label = target_grade_name(condition.task_target_grade, condition.communication.unit) + statuses = Array(condition.task_statuses).map { |status| status.to_s.titleize }.join(', ') + "Students that have #{operator_label(condition.operator)} #{condition.task_status_count} #{grade_label} tasks in [#{statuses}]" + when 'LoginStatusCondition' + "Students whose last sign in is #{condition.operator.to_s.humanize.downcase} #{condition.last_sign_in_at}" + when 'SpecConCondition' + "Students with Special Consideration Days #{operator_label(condition.operator)} #{condition.spec_con_days}" + when 'TutorialEnrolmentCondition' + tutorial = Tutorial.find_by(id: condition.tutorial_id) + tutorial_label = + if tutorial + [tutorial.abbreviation, tutorial.name].compact.join(' ') + else + "Tutorial #{condition.tutorial_id}" + end + "Students #{enrolment_label(condition.operator).downcase} #{tutorial_label}" + when 'TutorialStreamEnrolmentCondition' + tutorial_stream = TutorialStream.find_by(id: condition.tutorial_stream_id) + stream_label = + if tutorial_stream + [tutorial_stream.abbreviation, tutorial_stream.name].compact.join(' ') + else + "Tutorial Stream #{condition.tutorial_stream_id}" + end + "Students #{enrolment_label(condition.operator).downcase} #{stream_label}" + when 'CampusCondition' + campus = Campus.find_by(id: condition.campus_id) + campus_label = campus&.name || "Campus #{condition.campus_id}" + "Students #{enrolment_label(condition.operator).downcase} #{campus_label}" + else + "#{condition.type.to_s.underscore.humanize} #{condition.operator.to_s.humanize}" + end + end + + def human_action_summary(action) + case action.type + when 'EmailStudentAction' + 'Send email' + when 'EmailStaffAction' + 'Send staff email' + when 'ChangeTargetGradeAction' + "Change Target Grade to #{target_grade_name(action.target_grade, action.communication_rule.unit)}" + when 'TaskCommentAction' + "Add comment to #{task_definition_label(action.task_definition)}" + else + human_action_type_name(action.type) + end + end + + def human_action_type_name(type) + case type + when 'EmailStudentAction' + 'Send email' + when 'EmailStaffAction' + 'Send staff email' + when 'ChangeTargetGradeAction' + 'Change target grade' + when 'TaskCommentAction' + 'Add task comment' + else + type.to_s.underscore.humanize + end + end + + def operator_label(operator) + case operator.to_s + when 'greater_than' + 'Greater Than' + when 'greater_than_or_equal_to' + 'Greater Than Or Equal To' + when 'less_than' + 'Less Than' + when 'less_than_or_equal_to' + 'Less Than Or Equal To' + when 'equal_to' + 'Equal To' + when 'not_equal_to' + 'Not Equal To' + else + operator.to_s.humanize + end + end + + def enrolment_label(operator) + operator.to_s == 'not_enrolled_in' ? 'Not Enrolled In' : 'Enrolled In' + end + + def task_definition_label(task_definition) + return 'Task' if task_definition.blank? + + "Task #{task_definition.abbreviation} #{task_definition.name}" + end +end diff --git a/app/sidekiq/execute_communication_set_schedule_job.rb b/app/sidekiq/execute_communication_set_schedule_job.rb new file mode 100644 index 0000000000..6960a96dd4 --- /dev/null +++ b/app/sidekiq/execute_communication_set_schedule_job.rb @@ -0,0 +1,24 @@ +class ExecuteCommunicationSetScheduleJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first, 'communication-set-schedule'] }, + on_conflict: :reject, + retry: 1 + + def perform(schedule_id) + schedule = CommunicationSetSchedule.find(schedule_id) + return unless schedule.active? + return unless schedule.unit.active? + + now = Time.zone.now + return unless schedule.due?(now) + + ExecuteCommunicationSetJob.perform_async(schedule.communication_set_id) + schedule.update!( + last_enqueued_at: now, + last_run_at: now, + next_run_at: schedule.next_occurrence_after(now + 1.second) + ) + end +end diff --git a/app/sidekiq/import_batch_feedback_job.rb b/app/sidekiq/import_batch_feedback_job.rb new file mode 100644 index 0000000000..e8504d6eb2 --- /dev/null +++ b/app/sidekiq/import_batch_feedback_job.rb @@ -0,0 +1,39 @@ +class ImportBatchFeedbackJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args.first(3) }, + on_conflict: :reject, + retry: false + + def perform(unit_id, assessor_id, task_definition_id, path_to_upload) + logger.info "Starting batch feedback import..." + + at(0, 'Preparing batch feedback import') + total(1) + + unit = Unit.find(unit_id) + task_definition = unit.task_definitions.find(task_definition_id) + assessor = User.find(assessor_id) + + file = File.open(path_to_upload, 'rb') + result = unit.upload_batch_feedback_csv( + assessor, + task_definition, + { 'tempfile' => file }, + progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) unless rows_processed.nil? + } + ) + + store(result: result.to_json) + + logger.info "Completed batch feedback import!" + ensure + file&.close if defined?(file) && file.present? + FileUtils.rm_f(path_to_upload) if path_to_upload.present? + end +end diff --git a/app/sidekiq/import_grades_csv_job.rb b/app/sidekiq/import_grades_csv_job.rb new file mode 100644 index 0000000000..ce9ddd560f --- /dev/null +++ b/app/sidekiq/import_grades_csv_job.rb @@ -0,0 +1,39 @@ +require 'csv' + +class ImportGradesCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, assessor_id, path_to_csv) + logger.info "Starting grade imports..." + + at(0) + + ensure_csv!(path_to_csv) + + unit = Unit.find(unit_id) + result = unit.import_grades_from_csv(path_to_csv, assessor_id, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: result.to_json) + + FileUtils.rm(path_to_csv) + + logger.info "Completed grade imports!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/import_students_csv_job.rb b/app/sidekiq/import_students_csv_job.rb new file mode 100644 index 0000000000..3669c53dbf --- /dev/null +++ b/app/sidekiq/import_students_csv_job.rb @@ -0,0 +1,39 @@ +require 'csv' + +class ImportStudentsCsvJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, path_to_csv) + logger.info "Starting user imports... (CSV)" + + at(0) + + ensure_csv!(path_to_csv) + + unit = Unit.find(unit_id) + result = unit.import_users_from_csv(path_to_csv, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: result.to_json) + + FileUtils.rm(path_to_csv) + + logger.info "Completed user imports (CSV)!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/import_students_job.rb b/app/sidekiq/import_students_job.rb new file mode 100644 index 0000000000..363a49d793 --- /dev/null +++ b/app/sidekiq/import_students_job.rb @@ -0,0 +1,39 @@ +require 'csv' + +class ImportStudentsJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, path_to_csv) + logger.info "Starting user imports..." + + at(0) + + ensure_csv!(path_to_csv) + + unit = Unit.find(unit_id) + result = unit.import_users_from_csv(path_to_csv, progress_callback: lambda { |message: nil, total_rows: nil, rows_processed: nil| + total(total_rows) if total_rows + at(rows_processed, message) if rows_processed + }) + + store(result: result.to_json) + + FileUtils.rm(path_to_csv) + + logger.info "Completed user imports!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/import_students_lti_job.rb b/app/sidekiq/import_students_lti_job.rb new file mode 100644 index 0000000000..18a6d3f9f4 --- /dev/null +++ b/app/sidekiq/import_students_lti_job.rb @@ -0,0 +1,92 @@ +require 'csv' + +class ImportStudentsLtiJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + include MimeCheckHelpers + include CsvHelper + include LtiHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(unit_id, members) + logger.info "Starting user imports... (Lti)" + + at(0) + total(members.count) + + unit = Unit.find(unit_id) + + result = { + success: [], + ignored: [], + errors: [] + } + + members.each_with_index do |member, i| + at(i) + valid_member, missing = valid_lti_member?(member) + unless valid_member + result[:ignored] << { row: member, message: "Missing required fields: #{missing.join(', ')}" } + next + end + + user_id_data = { + login_id: member["user_id"], + email: member["email"], + username: member["email"][/(.*)@/, 1] + } + + user = User.find_by(login_id: user_id_data[:login_id]) || + User.find_by(username: user_id_data[:username]) || + User.find_by(email: user_id_data[:email]) || + User.create! do |new_user| + # Update new user with details from the SAML response + Doubtfire::Application.config.institution_settings.update_user_from_lti_response( + new_user, + user_id_data, + member + ) + end + + if user.valid? + unit_role = Doubtfire::Application.config.institution_settings.should_employ_lti_member(member) + unless unit_role.nil? + staff = unit.employ_staff(user, unit_role) + if staff&.valid? + result[:success] << { row: member, message: "Successfully added staff (#{unit_role.name})" } + end + end + + unless Doubtfire::Application.config.institution_settings.should_enrol_lti_member(member) + result[:ignored] << { row: member, message: "Enrolment skipped by institution setting" } + next + end + + project = unit.enrol_student(user, nil) + if project.valid? + result[:success] << { row: member, message: "Successfully enrolled user" } + else + result[:errors] << { row: member, message: "Failed to enrol student" } + end + else + result[:errors] << { row: member, message: "Failed to create user" } + end + rescue StandardError => e + result[:errors] << { row: member, message: e } + end + + store(result: result.to_json) + + logger.info "Completed user imports (Lti)!" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/login_docker_job.rb b/app/sidekiq/login_docker_job.rb new file mode 100644 index 0000000000..9616c1f3c8 --- /dev/null +++ b/app/sidekiq/login_docker_job.rb @@ -0,0 +1,27 @@ +require 'English' + +class LoginDockerJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args }, + on_conflict: :reject, + retry: 1 + + def perform + command = "echo \"${DOCKER_TOKEN}\" | docker login --username ${DOCKER_USER} --password-stdin ${DOCKER_PROXY_URL}" + response = `#{command} 2>&1` + status = $CHILD_STATUS.exitstatus + + if status != 0 + logger.error("Docker login failed with status #{status}: #{response}") + raise "Docker login failed: #{response}" + end + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/new_task_available_notification_job.rb b/app/sidekiq/new_task_available_notification_job.rb new file mode 100644 index 0000000000..81a4d5c17d --- /dev/null +++ b/app/sidekiq/new_task_available_notification_job.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +class NewTaskAvailableNotificationJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'new_task_available' + TYPE = 'task' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: 3 + + def self.enqueue(task_definition_id) + perform_async(task_definition_id) + rescue StandardError => e + Rails.logger.error( + "Failed to enqueue new-task notification for TaskDefinition #{task_definition_id}: " \ + "#{e.class} - #{e.message}" + ) + nil + end + + def self.track_and_enqueue(task_definition) + task_definition.enable_new_task_notifications! + enqueue(task_definition.id) + rescue StandardError => e + Rails.logger.error( + "Failed to track new-task notification for TaskDefinition #{task_definition.id}: " \ + "#{e.class} - #{e.message}" + ) + enqueue(task_definition.id) + end + + def self.track_and_enqueue_all(task_definitions) + task_definition_ids = task_definitions.map do |task_definition| + begin + task_definition.enable_new_task_notifications! + rescue StandardError => e + Rails.logger.error( + "Failed to track new-task notification for TaskDefinition #{task_definition.id}: " \ + "#{e.class} - #{e.message}" + ) + end + + task_definition.id + end + + perform_bulk(task_definition_ids.map { |id| [id] }) unless task_definition_ids.empty? + rescue StandardError => e + Rails.logger.error( + "Failed to bulk enqueue new-task notifications: #{e.class} - #{e.message}" + ) + nil + end + + def self.deliver(project, task_definition) + notification = nil + + # Recheck mutable eligibility under a short row lock. The reservation is + # committed before channel jobs are handed off; provider network I/O occurs + # in workers and never holds the project lock. + project.with_lock do + project.reload + unit = project.unit + eligible = unit.active && project.enrolled && project.target_grade.present? && + task_definition.target_grade <= project.target_grade + + if eligible + notification = NotificationService.reserve( + user: project.student, + type: TYPE, + event: EVENT, + message: "A new task is available: #{task_definition.abbreviation} in #{unit.code}.", + link: "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}", + dedupe_key: "#{EVENT}:task-definition:#{task_definition.id}" + ) + end + end + + NotificationService.deliver(notification) + end + + def perform(task_definition_id) + task_definition = TaskDefinition.find_by(id: task_definition_id) + return if task_definition.nil? + + # If the workflow's best-effort marker write failed, the queued job repairs + # it before checking availability. A transient database failure raises and + # lets Sidekiq retry instead of losing a future release permanently. + task_definition.enable_new_task_notifications! if task_definition.new_task_notifications_from.nil? + + unit = task_definition.unit + return unless unit.active + + failed_project_ids = [] + + unit.projects.where(enrolled: true).includes(:user).find_in_batches(batch_size: BATCH_SIZE) do |projects| + tasks = Task.where( + project_id: projects.map(&:id), + task_definition_id: task_definition.id + ).includes({ project: :unit }, task_definition: :grade_due_dates).index_by(&:project_id) + + projects.each do |project| + notify_project(project, task_definition, tasks[project.id]) + rescue StandardError => e + failed_project_ids << project.id + + Rails.logger.error( + "Failed new-task notification for TaskDefinition #{task_definition.id}, " \ + "Project #{project.id}: #{e.class} - #{e.message}" + ) + end + end + + return if failed_project_ids.empty? + + raise "New-task notifications failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def notify_project(project, task_definition, task) + return if project.target_grade.nil? + return if task_definition.target_grade > project.target_grade + + # A newly created task is only available when the student's effective + # start date has arrived. Webcal applies flexible, grade-specific and + # student-specific dates without creating a Task row just to notify. + available_on = Webcal.start_date_for_task_definition( + task_definition, + task, + project + ) + return if available_on.to_date > Time.zone.today + + self.class.deliver(project, task_definition) + end +end diff --git a/app/sidekiq/notification_email_job.rb b/app/sidekiq/notification_email_job.rb new file mode 100644 index 0000000000..965136b9a0 --- /dev/null +++ b/app/sidekiq/notification_email_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class NotificationEmailJob + include Sidekiq::Job + + # The queue carries only the stable Notification id. Message content, + # recipient details and other student data remain in the database and are + # loaded by the worker. + # + # Student facing email runs on its own queue so it does not wait behind a + # multi minute PDF build or CSV export on the default queue. A worker has to + # be listening on `mailers` for any of this to be picked up. + sidekiq_options queue: :mailers, retry: 3 + + def perform(notification_id) + # A producer may enqueue from inside a wider database transaction. Raising + # on a not-yet-visible row makes Sidekiq retry after that transaction commits + # instead of acknowledging and permanently dropping the delivery. + notification = Notification.find(notification_id) + + # The category preference was checked when the notification was raised, but + # a retried job can run hours later. Ask again so a preference the user has + # switched off in the meantime stays off. + return unless NotificationService.deliver_to?(notification.user, notification.notification_type) + + NotificationsMailer.single_notification(notification).deliver_now + end +end diff --git a/app/sidekiq/notify_tutor_notes_job.rb b/app/sidekiq/notify_tutor_notes_job.rb new file mode 100644 index 0000000000..194c6b4f35 --- /dev/null +++ b/app/sidekiq/notify_tutor_notes_job.rb @@ -0,0 +1,12 @@ +class NotifyTutorNotesJob + include Sidekiq::Job + + def perform(tutor_note_id, recipient_user_id) + tutor_note = TutorNote.find(tutor_note_id) + recipient = User.find(recipient_user_id) + + TutorNoteMailer.notify_tutor_note(tutor_note, recipient).deliver + rescue StandardError => e + Rails.logger.error("Failed to send tutor note email for TutorNote #{tutor_note_id} to User #{recipient_user_id}: #{e.class} - #{e.message}") + end +end diff --git a/app/sidekiq/poll_communication_set_schedules_job.rb b/app/sidekiq/poll_communication_set_schedules_job.rb new file mode 100644 index 0000000000..b5f0f803f2 --- /dev/null +++ b/app/sidekiq/poll_communication_set_schedules_job.rb @@ -0,0 +1,19 @@ +class PollCommunicationSetSchedulesJob + include Sidekiq::Job + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['poll-communication-set-schedules'] }, + on_conflict: :reject, + retry: 1 + + def perform + CommunicationSetSchedule + .includes(:communication_set) + .active + .with_active_unit + .due(Time.zone.now) + .find_each do |schedule| + ExecuteCommunicationSetScheduleJob.perform_async(schedule.id) + end + end +end diff --git a/app/sidekiq/pull_docker_image_job.rb b/app/sidekiq/pull_docker_image_job.rb new file mode 100644 index 0000000000..5b1e9aa798 --- /dev/null +++ b/app/sidekiq/pull_docker_image_job.rb @@ -0,0 +1,31 @@ +class PullDockerImageJob + include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + include FileHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false + + def perform(overseer_image_id) + logger.info "Starting pull docker image job..." + + at(0) + total(1) + + overseer_image = OverseerImage.find(overseer_image_id) + overseer_image.pull_from_docker + + store(result: overseer_image.pulled_image_text) + + at(1) + + logger.info "Completed pull docker image job" + rescue StandardError => e + logger.error e + raise e + end +end diff --git a/app/sidekiq/push_notification_delivery_job.rb b/app/sidekiq/push_notification_delivery_job.rb new file mode 100644 index 0000000000..a93da07d9e --- /dev/null +++ b/app/sidekiq/push_notification_delivery_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PushNotificationDeliveryJob + include Sidekiq::Job + + # Keep provider network I/O off `default`. The development stack cannot + # safely consume that queue because it also contains submission/PDF jobs + # whose supporting services are not present there. + sidekiq_options queue: :notifications, retry: 3 + + # Redis carries only the stable database id. The worker reloads the current + # notification and subscription state immediately before delivery. + def perform(notification_id) + # A producer may enqueue from inside a wider database transaction. Raising + # on a not-yet-visible row makes Sidekiq retry after that transaction commits + # instead of acknowledging and permanently dropping the delivery. + notification = Notification.find(notification_id) + PushNotificationService.deliver(notification) + end +end diff --git a/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb b/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb new file mode 100644 index 0000000000..ad3d8abf77 --- /dev/null +++ b/app/sidekiq/refresh_moderation_feedback_timestamps_job.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class RefreshModerationFeedbackTimestampsJob + include Sidekiq::Job + + def perform + ModeratedTask + .where(state: %i[open waiting_for_new_feedback]) + .joins(task: { project: :unit }) + .where(units: { active: true }) + .includes(task: :comments) + .find_each do |moderated_task| + task = moderated_task.task + next if task.nil? + + tutor_user = task.tutor + latest_feedback_time = nil + + unless tutor_user.nil? + sorted_comments = task.comments.sort_by(&:created_at) + tutor_comments = sorted_comments.select.with_index do |comment, idx| + next_comment = sorted_comments[idx + 1] + + # Automated comments after a status comment should be filtered out (Overseer test result, corrupt submission) + # Except for when its been updated due to a prerequisite fix + is_automated_status = + comment.content_type == "status" && + next_comment&.content_type == "text" && + (next_comment.comment&.downcase&.include?("**automated comment**: some tests did not pass") || + next_comment.comment&.downcase&.include?("**automated comment**: something went wrong with your submission") || + next_comment.comment&.downcase&.include?("**automated comment**: a prerequisite task was updated to fix and resubmit")) + + comment.user_id == tutor_user.id && + %w[status discussed_in_class text].include?(comment.content_type) && + (comment.content_type != "status" || comment.task_status_id != TaskStatus.time_exceeded.id) && + !is_automated_status && + !comment.comment&.downcase&.include?("**automated comment**:") + end + + latest_feedback_time = tutor_comments.max_by(&:created_at)&.created_at + end + + next if task.last_tutor_feedback_at == latest_feedback_time + + task.update!(last_tutor_feedback_at: latest_feedback_time) + rescue StandardError + next + end + end +end diff --git a/app/sidekiq/send_due_soon_reminders_job.rb b/app/sidekiq/send_due_soon_reminders_job.rb new file mode 100644 index 0000000000..c72048c9d7 --- /dev/null +++ b/app/sidekiq/send_due_soon_reminders_job.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# Remind students about work that is nearly due. +# +# Every other notification in this feature hangs off something a person did: a +# comment was posted, a due date was edited, a status changed. A deadline +# approaching is nobody doing anything, so there is no model to hook and this +# has to be swept for on a schedule. config/schedule.yml runs it. +# +# Recipients come from projects and not from Task rows. OnTrack creates a Task +# row the first time anyone touches the task, so the students who have not +# started have no row, and they are exactly the ones a reminder is for. +# +# Nothing here may call Project#task_for_task_definition, which creates the row +# it cannot find, and nothing may call Project#task_definitions_and_status +# either, because that calls it. This reads project.tasks once per project and +# looks the row up in a hash instead. +class SendDueSoonRemindersJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'task_due_soon' + TYPE = 'task' + + # How far ahead counts as soon, in days. + # + # Three, which is long enough to still do something about it over a weekend + # and short enough that the reminder is about this task rather than about the + # rest of the trimester. Project#top_tasks uses seven for the same idea, and + # seven days of warning on a weekly task is most of the tasks a student has, + # which is a list rather than a reminder. + WINDOW_DAYS = 3 + + # The statuses that mean the student still owes work. + # + # :discuss and :demonstrate are deliberately out. Both mean the student has + # submitted and is waiting on a tutor, so telling them their task is due soon + # is both wrong and the kind of wrong that makes people stop reading + # notifications. Everything past those, complete and fail and the rest, is + # finished with as far as a deadline is concerned. + OUTSTANDING_STATUSES = %i[ + not_started + working_on_it + need_help + fix_and_resubmit + redo + ].freeze + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['send-due-soon-reminders'] }, + on_conflict: :reject, + retry: 1 + + def perform + today = Time.zone.today + horizon = today + WINDOW_DAYS.days + failed_project_ids = [] + + # Units first and then their projects, the same shape as + # NewTaskAvailableNotificationJob, so the unit and its task definitions are + # loaded once per cohort rather than once per student. + Unit.where(active: true).find_each(batch_size: BATCH_SIZE) do |unit| + remind_unit(unit, today, horizon, failed_project_ids) + end + + return if failed_project_ids.empty? + + # Collected and re-raised at the end rather than swallowed, which is what + # NewTaskAvailableNotificationJob does and for the same reason. Logging and + # carrying on would leave perform successful, Sidekiq would schedule no + # retry, and a student whose task is due today is filtered out as overdue + # tomorrow, so that reminder is gone for good. Re-running the whole sweep is + # safe because of the duplicate guard in notify. + raise "Due-soon reminders failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def remind_unit(unit, today, horizon, failed_project_ids) + # Read once for the whole cohort. Asking per project is where the query + # count runs away: five hundred students against twenty task definitions is + # five hundred of the same query. + task_definitions = unit.task_definitions.to_a + return if task_definitions.empty? + + unit.active_projects + .where.not(target_grade: nil) + .includes(:user) + .find_each(batch_size: BATCH_SIZE) do |project| + remind_project(project, unit, task_definitions, today, horizon) + rescue StandardError => e + failed_project_ids << project.id + + Rails.logger.error( + "Failed due-soon reminders for Project #{project.id}: #{e.class} - #{e.message}" + ) + end + end + + def remind_project(project, unit, task_definitions, today, horizon) + # One query for this student's rows, then look each one up. The row is only + # read, never created. + tasks = project.tasks.includes(:task_status, :task_definition).index_by(&:task_definition_id) + + task_definitions.each do |task_definition| + next if task_definition.target_grade > project.target_grade + + task = tasks[task_definition.id] + next unless outstanding?(task) + + due = due_date_for(task_definition, task, project) + next if due.nil? + + due = due.to_date + next if due < today || due > horizon + + notify(project, unit, task_definition) + end + end + + # Whether this student still owes work on this task. + # + # No row means nobody has touched it, which is not_started by any other name + # and is the state a reminder is most for. + def outstanding?(task) + return true if task.nil? + + OUTSTANDING_STATUSES.include?(task.status) + end + + # When this task is due for this student. + # + # Webcal already answers exactly this question, for exactly this pair of + # cases, and it is what the calendar feed shows the student. Writing it again + # here would mean two answers to "when is this due" that could disagree. + # + # With a Task row it is Task#local_due_date, which knows about extensions and + # about a unit's flexible dates. Without one it is still not simply the task + # definition's target date: on a unit with flexible dates the grade level + # override applies before any row exists, so a grade 2 student can be due days + # away from the unit's own date without ever having opened the task. + def due_date_for(task_definition, task, project) + Webcal.end_date_for_task_definition(task_definition, task, project) + end + + def notify(project, unit, task_definition) + student = project.student + link = "/projects/#{project.id}/dashboard/#{task_definition.abbreviation}" + + # One reminder per student per task, ever. + # + # This job runs again tomorrow and the task is still due soon tomorrow, so + # without this the same student is reminded every morning until the deadline + # passes. The index on (user_id, event) is what makes asking cheap enough to + # do once per candidate task. + return if Notification.exists?( + user_id: student.id, + notification_type: TYPE, + event: EVENT, + link: link + ) + + NotificationService.notify( + user: student, + type: TYPE, + event: EVENT, + message: "#{task_definition.abbreviation} in #{unit.code} is due soon.", + link: link + ) + end +end diff --git a/app/sidekiq/send_new_task_available_notifications_job.rb b/app/sidekiq/send_new_task_available_notifications_job.rb new file mode 100644 index 0000000000..5d9969c291 --- /dev/null +++ b/app/sidekiq/send_new_task_available_notifications_job.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# Notify students when a future-dated task becomes available. +class SendNewTaskAvailableNotificationsJob + include Sidekiq::Job + + BATCH_SIZE = 100 + CATCH_UP_DAYS = 7 + SETTLE_TIME = 1.hour + ROLLING_WRITER_TOLERANCE = 1.minute + + sidekiq_options lock: :until_executed, + lock_args_method: ->(_args) { ['send-new-task-available-notifications'] }, + on_conflict: :reject, + retry: 3 + + def perform + today = Time.zone.today + failed_project_ids = [] + + Unit.where(active: true).find_each(batch_size: BATCH_SIZE) do |unit| + notify_unit(unit, today, failed_project_ids) + end + + return if failed_project_ids.empty? + + raise "New-task availability notifications failed for projects: #{failed_project_ids.join(', ')}" + end + + private + + def notify_unit(unit, today, failed_project_ids) + task_definitions = candidate_task_definitions(unit, today) + return if task_definitions.empty? + + unit.active_projects + .where.not(target_grade: nil) + .includes(:user) + .find_in_batches(batch_size: BATCH_SIZE) do |projects| + tasks_by_project = Task + .where( + project_id: projects.map(&:id), + task_definition_id: task_definitions.map(&:id) + ) + .includes({ project: :unit }, task_definition: :grade_due_dates) + .group_by(&:project_id) + .transform_values { |tasks| tasks.index_by(&:task_definition_id) } + + projects.each do |project| + notify_project( + project, + task_definitions, + tasks_by_project.fetch(project.id, {}), + today + ) + rescue StandardError => e + failed_project_ids << project.id + Rails.logger.error( + "Failed new-task availability notifications for Project #{project.id}: " \ + "#{e.class} - #{e.message}" + ) + end + end + end + + def candidate_task_definitions(unit, today) + # Definitions written by an older process during a rolling deployment get + # the database-default marker. Give multi-step imports/copies time to finish + # before the sweep can observe them; current workflows enqueue explicitly. + tracked = unit.task_definitions + .where.not(new_task_notifications_from: nil) + .where('created_at <= ?', Time.current - SETTLE_TIME) + window = (today - CATCH_UP_DAYS.days).beginning_of_day..today.end_of_day + + ids = tracked.where(created_at: window).ids + ids.concat(tracked.where(start_date: window).ids) + ids.concat( + TaskDefinitionGradeDueDate.where( + task_definition_id: tracked.select(:id), + start_date: window + ).distinct.pluck(:task_definition_id) + ) + ids.concat( + Task.where( + task_definition_id: tracked.select(:id), + target_start_date: window + ).distinct.pluck(:task_definition_id) + ) + ids.concat( + Task.where(task_definition_id: tracked.select(:id)) + .where('extensions < 0') + .distinct + .pluck(:task_definition_id) + ) + + tracked.where(id: ids.uniq).includes(:grade_due_dates).to_a + end + + def notify_project(project, task_definitions, tasks, today) + task_definitions.each do |task_definition| + next if task_definition.target_grade > project.target_grade + + available_on = Webcal.start_date_for_task_definition( + task_definition, + tasks[task_definition.id], + project + ).to_date + + tracking_from = task_definition.new_task_notifications_from.to_date + recently_created = task_definition.created_at >= + task_definition.new_task_notifications_from - ROLLING_WRITER_TOLERANCE && + task_definition.created_at.to_date >= today - CATCH_UP_DAYS.days + release_in_window = available_on.between?( + [tracking_from, today - CATCH_UP_DAYS.days].max, + today + ) + next unless recently_created || release_in_window + next if available_on > today + + NewTaskAvailableNotificationJob.deliver(project, task_definition) + end + end +end diff --git a/app/sidekiq/task_due_date_changed_notification_job.rb b/app/sidekiq/task_due_date_changed_notification_job.rb new file mode 100644 index 0000000000..63a80fa7b2 --- /dev/null +++ b/app/sidekiq/task_due_date_changed_notification_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class TaskDueDateChangedNotificationJob + include Sidekiq::Job + + BATCH_SIZE = 100 + EVENT = 'task_due_date_changed' + TYPE = 'task' + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { args.first(3) }, + on_conflict: :reject, + retry: false + + def perform(task_definition_id, _previous_due_date, new_due_date) + task_definition = TaskDefinition.find_by(id: task_definition_id) + return if task_definition.nil? + return unless task_definition.unit.active + return unless current_due_date(task_definition) == new_due_date + + eligible_projects(task_definition).find_each(batch_size: BATCH_SIZE) do |project| + notify_project(project, task_definition) + end + end + + private + + def eligible_projects(task_definition) + task_definition.unit + .active_projects + .where( + 'projects.target_grade >= ?', + task_definition.target_grade + ) + .includes(:user) + end + + def current_due_date(task_definition) + task_definition[:due_date]&.to_date&.iso8601 + end + + def notify_project(project, task_definition) + NotificationService.notify( + user: project.student, + type: TYPE, + event: EVENT, + message: "The due date for #{task_definition.abbreviation} " \ + "in #{task_definition.unit.code} has changed.", + link: "/projects/#{project.id}/dashboard/" \ + "#{task_definition.abbreviation}" + ) + rescue StandardError => e + Rails.logger.error( + "Failed due-date notification for TaskDefinition " \ + "#{task_definition.id}, Project #{project.id}: " \ + "#{e.class} - #{e.message}" + ) + end +end diff --git a/app/sidekiq/tii_check_progress_job.rb b/app/sidekiq/tii_check_progress_job.rb index c4db362686..ca8084bee0 100644 --- a/app/sidekiq/tii_check_progress_job.rb +++ b/app/sidekiq/tii_check_progress_job.rb @@ -7,6 +7,10 @@ class TiiCheckProgressJob include Sidekiq::Job def perform + return unless TurnItIn.enabled? + + TurnItIn.check_and_retry_submissions_with_updated_eula + run_waiting_actions TurnItIn.check_and_update_eula TurnItIn.check_and_update_features @@ -16,7 +20,7 @@ def run_waiting_actions # Get the actions waiting to retry, where last run is more than 30 minutes ago, and run them TiiAction.where(retry: true, complete: false) .where('(last_run IS NULL AND created_at < :date) OR last_run < :date', date: DateTime.now - 30.minutes) - .each do |action| + .find_each do |action| action.perform # Stop if the service is not available diff --git a/app/sidekiq/tii_register_web_hook_job.rb b/app/sidekiq/tii_register_web_hook_job.rb index f2149bfcdf..f61de7bea4 100644 --- a/app/sidekiq/tii_register_web_hook_job.rb +++ b/app/sidekiq/tii_register_web_hook_job.rb @@ -6,6 +6,8 @@ class TiiRegisterWebHookJob include Sidekiq::Job def perform + return unless TurnItIn.enabled? + (TiiActionRegisterWebhook.last || TiiActionRegisterWebhook.create).perform end end diff --git a/app/views/communications_mailer/action_log_email.html.erb b/app/views/communications_mailer/action_log_email.html.erb new file mode 100644 index 0000000000..7df5658bf1 --- /dev/null +++ b/app/views/communications_mailer/action_log_email.html.erb @@ -0,0 +1,69 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+ +

Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>,

+ + <% @body_paragraphs.each do |paragraph| %> +

<%= paragraph %>

+ <% end %> + +
+ +

+ <%= @affected_students_count %> + students matched this rule and were affected by the configured actions. +

+ +

A full log of actions has been attached as a CSV.

+ +

+ Cheers,
+ The <%= @doubtfire_product_name %> Team +

+ +
+ + diff --git a/app/views/communications_mailer/action_log_email.text.erb b/app/views/communications_mailer/action_log_email.text.erb new file mode 100644 index 0000000000..f980a817f3 --- /dev/null +++ b/app/views/communications_mailer/action_log_email.text.erb @@ -0,0 +1,18 @@ +Hi <%= @recipient&.nickname.presence || @recipient&.first_name || @recipient&.name %>, + +<% @body_paragraphs.each do |paragraph| %> +<%= paragraph %> + +<% end %> +--- + +<%= @affected_students_count %> students matched this rule and were affected by the configured actions. + +A full log of actions has been attached as a CSV. + +Cheers, +The <%= @doubtfire_product_name %> Team + +--- + +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/communications_mailer/communication_email.html.erb b/app/views/communications_mailer/communication_email.html.erb new file mode 100644 index 0000000000..7933f82475 --- /dev/null +++ b/app/views/communications_mailer/communication_email.html.erb @@ -0,0 +1,53 @@ + + + + + + +
+

<%= @doubtfire_product_name %> Notification

+ + <% @body_paragraphs.each do |paragraph| %> +

<%= paragraph %>

+ <% end %> + +
+ + diff --git a/app/views/communications_mailer/communication_email.text.erb b/app/views/communications_mailer/communication_email.text.erb new file mode 100644 index 0000000000..5ad89c15fb --- /dev/null +++ b/app/views/communications_mailer/communication_email.text.erb @@ -0,0 +1,12 @@ +<%# Hi <%= @recipient.nickname.presence || @recipient.first_name %> %> + +<% @body_paragraphs.each do |paragraph| %> +<%= paragraph %> + +<% end %> +<%# Cheers, +The <%= @doubtfire_product_name %> Team on behalf of <%= @sender.name %> %> + +--- + +Generated with <%= @doubtfire_product_name %> diff --git a/app/views/d2l_result_mailer/result_message.text.erb b/app/views/d2l_result_mailer/result_message.text.erb new file mode 100644 index 0000000000..3c039cfe28 --- /dev/null +++ b/app/views/d2l_result_mailer/result_message.text.erb @@ -0,0 +1,10 @@ +Hi <%= @user.first_name %>, + +The grade transfer to D2L for <%= @unit.code %> has <%= @result_message %>. + +<% if @has_file %> +Please see the attached file for details on the results transferred. + +<% end %> +Cheers, +The <%= @doubtfire_product_name %> Team diff --git a/app/views/error_log_mailer/error_message.text.erb b/app/views/error_log_mailer/error_message.text.erb new file mode 100644 index 0000000000..e25167d08b --- /dev/null +++ b/app/views/error_log_mailer/error_message.text.erb @@ -0,0 +1,3 @@ +Something went wrong with <%= @doubtfire_product_name %>, and the following log entry was created: + +<%= @error_log %> diff --git a/app/views/layouts/application.pdf.erbtex b/app/views/layouts/application.pdf.erbtex index ab70189c8d..4c1c48e55f 100644 --- a/app/views/layouts/application.pdf.erbtex +++ b/app/views/layouts/application.pdf.erbtex @@ -1,4 +1,17 @@ -<% @latex_config={ :recipe => [ {:command => "lualatex",:runs => 2} ], :supporting => Rails.root.join('app', 'views', 'layouts', 'jupynotex.py') } %> +<% +@latex_config = { + :supporting => [ + Rails.root.join('app', 'views', 'layouts', 'jupynotex.py'), + Rails.root.join('lib', 'shell', 'latex_run.sh') + ], + :recipe => [ + { :command => './latex_run.sh', :runs => 1 } + ], + :preservework => false, + :workdir => -> { @work_id } +} +%> + \DocumentMetadata{uncompress} \documentclass[11pt,a4paper]{article} \usepackage[T1]{fontenc} @@ -14,6 +27,12 @@ } \usepackage[fencedCode,hashEnumerators,pipeTables,texMathDollars]{markdown} +\markdownSetup{rendererPrototypes={ + ulBeginTight={\begin{itemize}}, + ulEndTight={\end{itemize}}, + olBeginTight={\begin{enumerate}}, + olEndTight={\end{enumerate}} +}} \usepackage{luatextra} \defaultfontfeatures{Ligatures=TeX} @@ -27,18 +46,24 @@ \usepackage{amssymb} \usepackage{colortbl} \usepackage{lastpage} +\usepackage{multirow} \usepackage[colorlinks]{hyperref} +\usepackage{longtable} \hypersetup{colorlinks, linkcolor=black, filecolor=black, urlcolor=blue, citecolor=black} - +<% +if @include_pax +%> \usepackage{newpax} \newpaxsetup{usefileattributes=true, addannots=true} \directlua{require("newpax")} <%= yield :preamble_newpax %> - +<% +end +%> \epstopdfDeclareGraphicsRule{.tif}{png}{.png}{convert #1 \OutputFile} \AppendGraphicsExtensions{.tif} \epstopdfDeclareGraphicsRule{.tiff}{png}{.png}{convert #1 \OutputFile} @@ -52,6 +77,7 @@ \graphicspath{ {<%= @image_path %>/} } +\newcolumntype{P}[1]{>{\raggedright\arraybackslash}p{#1}} \fancypagestyle{title}{% \renewcommand{\headrulewidth}{0pt} @@ -59,7 +85,7 @@ \fancyhead[R]{} \fancyfoot[C] { - \footnotesize Produced by Doubtfire + \footnotesize Produced by <%= @doubtfire_product_name %> } \fancyfoot[R]{} \fancyfoot[L]{} @@ -89,6 +115,7 @@ \definecolor{demo}{rgb}{0.259, 0.545, 0.792} \definecolor{fail}{rgb}{0.851, 0.216, 0.075} \definecolor{timeexceeded}{rgb}{0.851, 0.216, 0.075} +\definecolor{assessinportfolio}{rgb}{0.568, 0.721, 0.568} \begin{document} diff --git a/app/views/layouts/jupynotex.py b/app/views/layouts/jupynotex.py index b3ce34cd37..7b3c49f2eb 100644 --- a/app/views/layouts/jupynotex.py +++ b/app/views/layouts/jupynotex.py @@ -25,7 +25,7 @@ # markdown start/end MARKDOWN_BEGIN = [r"\begin{markdown}"] -MARKDOWN_END = [r"\end{markdown}"] +MARKDOWN_END = [r"\end{markdown}"+"\n"] # highlighers for different languages (block beginning and ending) HIGHLIGHTERS = { @@ -60,6 +60,13 @@ def _validator_positive_int(value): return value +def _as_lines(value): + """Return notebook text stored as either a string or list of strings.""" + if isinstance(value, str): + return value.splitlines() + return value + + def _process_plain_text(lines, config_options=None): """Wrap a series of lines around a verbatim indication.""" if config_options is None: @@ -67,6 +74,7 @@ def _process_plain_text(lines, config_options=None): result = [] result.extend(VERBATIM_BEGIN) + lines = _as_lines(lines) for line in lines: line = line.strip() @@ -169,8 +177,8 @@ def __init__(self, path, config_options): with open(path, 'rt', encoding='utf8') as fh: nb_data = json.load(fh) - # get the languaje, to highlight - lang = nb_data['metadata']['language_info']['name'] + # get the language, when available, to highlight + lang = nb_data.get('metadata', {}).get('language_info', {}).get('name') self._highlight_delimiters = HIGHLIGHTERS.get(lang, HIGHLIGHTERS[None]) # get all cells @@ -186,7 +194,7 @@ def _validate_config(self, config): def _proc_src(self, content): """Process the source of a cell.""" - source = content['source'] + source = _as_lines(content['source']) result = [] if content['cell_type'] == 'code': begin, end = self._highlight_delimiters diff --git a/app/views/notifications_mailer/discussion_request_created.html.erb b/app/views/notifications_mailer/discussion_request_created.html.erb new file mode 100644 index 0000000000..4887fd9866 --- /dev/null +++ b/app/views/notifications_mailer/discussion_request_created.html.erb @@ -0,0 +1,24 @@ +

Hi <%= @user.name %>,

+ +

+ A discussion prompt is ready for you in <%= @doubtfire_product_name %>. +

+ +

+ The prompt is not included in this email. Open the task to listen and + respond. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your feedback notifications are turned on. + You can change that at + your profile. +

diff --git a/app/views/notifications_mailer/discussion_request_created.text.erb b/app/views/notifications_mailer/discussion_request_created.text.erb new file mode 100644 index 0000000000..7ac7375526 --- /dev/null +++ b/app/views/notifications_mailer/discussion_request_created.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +A discussion prompt is ready for you in <%= @doubtfire_product_name %>. + +The prompt is not included in this email. Open the task to listen and respond. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You are receiving this because your feedback notifications are turned on. +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/extension_assessed.html.erb b/app/views/notifications_mailer/extension_assessed.html.erb new file mode 100644 index 0000000000..2fc33dabfb --- /dev/null +++ b/app/views/notifications_mailer/extension_assessed.html.erb @@ -0,0 +1,20 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ Your extension request has been assessed. + Open the task in <%= @doubtfire_product_name %> to review the current task details. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ This notification is sent when an extension request is assessed. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/extension_assessed.text.erb b/app/views/notifications_mailer/extension_assessed.text.erb new file mode 100644 index 0000000000..a5042f2587 --- /dev/null +++ b/app/views/notifications_mailer/extension_assessed.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +Your extension request has been assessed. +Open the task in <%= @doubtfire_product_name %> to review the current task details. + +<% if @notification.link.present? -%> +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +This notification is sent when an extension request is assessed. \ No newline at end of file diff --git a/app/views/notifications_mailer/group_membership_changed.html.erb b/app/views/notifications_mailer/group_membership_changed.html.erb new file mode 100644 index 0000000000..cadc8b7c4d --- /dev/null +++ b/app/views/notifications_mailer/group_membership_changed.html.erb @@ -0,0 +1,20 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ This notification was sent because your group membership changed in + <%= @doubtfire_product_name %>. +

+ +

+ You can view your current group information in <%= @doubtfire_product_name %>. +

+ +<% if @notification.link.present? %> +

+ + Open your group in <%= @doubtfire_product_name %> + +

+<% end %> diff --git a/app/views/notifications_mailer/group_membership_changed.text.erb b/app/views/notifications_mailer/group_membership_changed.text.erb new file mode 100644 index 0000000000..cec5fa9de5 --- /dev/null +++ b/app/views/notifications_mailer/group_membership_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +This notification was sent because your group membership changed in <%= @doubtfire_product_name %>. + +You can view your current group information in <%= @doubtfire_product_name %>. +<% if @notification.link.present? -%> + +Open your group in <%= @doubtfire_product_name %>: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> diff --git a/app/views/notifications_mailer/new_task_available.html.erb b/app/views/notifications_mailer/new_task_available.html.erb new file mode 100644 index 0000000000..fcd4223fd9 --- /dev/null +++ b/app/views/notifications_mailer/new_task_available.html.erb @@ -0,0 +1,22 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ A new task is now available in <%= @doubtfire_product_name %>. + Open the task to review its details. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at + your profile. +

\ No newline at end of file diff --git a/app/views/notifications_mailer/new_task_available.text.erb b/app/views/notifications_mailer/new_task_available.text.erb new file mode 100644 index 0000000000..a53d604fc8 --- /dev/null +++ b/app/views/notifications_mailer/new_task_available.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +A new task is now available in <%= @doubtfire_product_name %>. +Open the task to review its details. + +<% if @notification.link.present? -%> +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. \ No newline at end of file diff --git a/app/views/notifications_mailer/portfolio_received.html.erb b/app/views/notifications_mailer/portfolio_received.html.erb new file mode 100644 index 0000000000..18510bd165 --- /dev/null +++ b/app/views/notifications_mailer/portfolio_received.html.erb @@ -0,0 +1,21 @@ +

Hi <%= @user.first_name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

+ + View the current status in <%= @doubtfire_product_name %> + +

+<% end %> + +

+ This receipt confirms when the submission was received. It does not confirm + an assessment outcome. +

+ +

+ You can manage your notification preferences in + your profile. +

diff --git a/app/views/notifications_mailer/portfolio_received.text.erb b/app/views/notifications_mailer/portfolio_received.text.erb new file mode 100644 index 0000000000..666718aedd --- /dev/null +++ b/app/views/notifications_mailer/portfolio_received.text.erb @@ -0,0 +1,14 @@ +Hi <%= @user.first_name %>, + +<%= @notification.message %> + +Open <%= @doubtfire_product_name %> to view its current status. +<% if @notification.link.present? -%> + +View the current status: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +This receipt confirms when the submission was received. It does not confirm an assessment outcome. + +You can manage your notification preferences here: +<%= @unsubscribe_url %> diff --git a/app/views/notifications_mailer/single_notification.html.erb b/app/views/notifications_mailer/single_notification.html.erb new file mode 100644 index 0000000000..0538d63457 --- /dev/null +++ b/app/views/notifications_mailer/single_notification.html.erb @@ -0,0 +1,12 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

View in <%= @doubtfire_product_name %>

+<% end %> + +

+ You are receiving this because your notification preferences allow it. + You can update them at your profile. +

diff --git a/app/views/notifications_mailer/single_notification.text.erb b/app/views/notifications_mailer/single_notification.text.erb new file mode 100644 index 0000000000..af0c03b196 --- /dev/null +++ b/app/views/notifications_mailer/single_notification.text.erb @@ -0,0 +1,9 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> +<% if @notification.link.present? -%> + +View it here: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can update your notification preferences at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_comment_created.html.erb b/app/views/notifications_mailer/task_comment_created.html.erb new file mode 100644 index 0000000000..3ddbeb7a26 --- /dev/null +++ b/app/views/notifications_mailer/task_comment_created.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The comment is not included in this email. Open the task in + <%= @doubtfire_product_name %> to read it and reply. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your feedback notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_comment_created.text.erb b/app/views/notifications_mailer/task_comment_created.text.erb new file mode 100644 index 0000000000..375494cc38 --- /dev/null +++ b/app/views/notifications_mailer/task_comment_created.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The comment is not included in this email. Open the task in <%= @doubtfire_product_name %> to read it and reply. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_due_date_changed.html.erb b/app/views/notifications_mailer/task_due_date_changed.html.erb new file mode 100644 index 0000000000..8e8c044244 --- /dev/null +++ b/app/views/notifications_mailer/task_due_date_changed.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The new due date is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see it. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_due_date_changed.text.erb b/app/views/notifications_mailer/task_due_date_changed.text.erb new file mode 100644 index 0000000000..a9c61af991 --- /dev/null +++ b/app/views/notifications_mailer/task_due_date_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The new due date is not included in this email. Open the task in <%= @doubtfire_product_name %> to see it. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_due_soon.html.erb b/app/views/notifications_mailer/task_due_soon.html.erb new file mode 100644 index 0000000000..345e15906e --- /dev/null +++ b/app/views/notifications_mailer/task_due_soon.html.erb @@ -0,0 +1,22 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The deadline is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see when it is due and what is left to do. +

+ +<% if @notification.link.present? %> +

+ + Open the task + +

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at + your profile. +

diff --git a/app/views/notifications_mailer/task_due_soon.text.erb b/app/views/notifications_mailer/task_due_soon.text.erb new file mode 100644 index 0000000000..76c532794d --- /dev/null +++ b/app/views/notifications_mailer/task_due_soon.text.erb @@ -0,0 +1,12 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The deadline is not included in this email. Open the task in <%= @doubtfire_product_name %> to see when it is due and what is left to do. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You are receiving this because your task notifications are turned on. +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_status_changed.html.erb b/app/views/notifications_mailer/task_status_changed.html.erb new file mode 100644 index 0000000000..4b19235318 --- /dev/null +++ b/app/views/notifications_mailer/task_status_changed.html.erb @@ -0,0 +1,17 @@ +

Hi <%= @user.name %>,

+ +

<%= @notification.message %>

+ +

+ The new status is not included in this email. Open the task in + <%= @doubtfire_product_name %> to see it. +

+ +<% if @notification.link.present? %> +

Open the task

+<% end %> + +

+ You are receiving this because your task notifications are turned on. + You can change that at your profile. +

diff --git a/app/views/notifications_mailer/task_status_changed.text.erb b/app/views/notifications_mailer/task_status_changed.text.erb new file mode 100644 index 0000000000..53f7f7f256 --- /dev/null +++ b/app/views/notifications_mailer/task_status_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +<%= @notification.message %> + +The new status is not included in this email. Open the task in <%= @doubtfire_product_name %> to see it. +<% if @notification.link.present? -%> + +Open the task: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +You can turn these emails off at <%= @unsubscribe_url %>. diff --git a/app/views/notifications_mailer/task_submitted.html.erb b/app/views/notifications_mailer/task_submitted.html.erb new file mode 100644 index 0000000000..55475178ce --- /dev/null +++ b/app/views/notifications_mailer/task_submitted.html.erb @@ -0,0 +1,18 @@ +

Hi <%= @user.first_name %>,

+ +

<%= @notification.message %>

+ +<% if @notification.link.present? %> +

+ + Open the task in <%= @doubtfire_product_name %> + +

+<% end %> + +

The submission and any assessment content are not included in this email.

+ +

+ You can manage your notification preferences in + your profile. +

diff --git a/app/views/notifications_mailer/task_submitted.text.erb b/app/views/notifications_mailer/task_submitted.text.erb new file mode 100644 index 0000000000..d08ccfa52e --- /dev/null +++ b/app/views/notifications_mailer/task_submitted.text.erb @@ -0,0 +1,13 @@ +Hi <%= @user.first_name %>, + +<%= @notification.message %> + +Open the task to review the submission: +<% if @notification.link.present? -%> +<%= @doubtfire_host %><%= @notification.link %> +<% end -%> + +The submission and any assessment content are not included in this email. + +You can manage your notification preferences here: +<%= @unsubscribe_url %> diff --git a/app/views/notifications_mailer/tutorial_changed.html.erb b/app/views/notifications_mailer/tutorial_changed.html.erb new file mode 100644 index 0000000000..ee4790c6c1 --- /dev/null +++ b/app/views/notifications_mailer/tutorial_changed.html.erb @@ -0,0 +1,15 @@ +

Hi <%= @user.name %>,

+ +

Your tutorial has changed.

+ +

<%= @notification.message %>

+ +

Please use the new tutorial day and time for your next class.

+ +<% if @notification.link.present? %> +

+ + Open your unit in <%= @doubtfire_product_name %> + +

+<% end %> diff --git a/app/views/notifications_mailer/tutorial_changed.text.erb b/app/views/notifications_mailer/tutorial_changed.text.erb new file mode 100644 index 0000000000..551cfe6ee2 --- /dev/null +++ b/app/views/notifications_mailer/tutorial_changed.text.erb @@ -0,0 +1,11 @@ +Hi <%= @user.name %>, + +Your tutorial has changed. + +<%= @notification.message %> + +Please use the new tutorial day and time for your next class. +<% if @notification.link.present? -%> + +Open your unit in <%= @doubtfire_product_name %>: <%= @doubtfire_host %><%= @notification.link %> +<% end -%> diff --git a/app/views/notifications_mailer/weekly_staff_summary.html.erb b/app/views/notifications_mailer/weekly_staff_summary.html.erb index bab52a8b70..c8360dfde8 100644 --- a/app/views/notifications_mailer/weekly_staff_summary.html.erb +++ b/app/views/notifications_mailer/weekly_staff_summary.html.erb @@ -2,26 +2,29 @@ -
+

<%= @summary_stats[:unit].name %> - Weekly Summary

<%= "#{@summary_stats[:week_start].day.ordinalize} #{@summary_stats[:week_start].strftime("%B %Y")}" %> to <%= "#{@summary_stats[:week_end].day.ordinalize} #{@summary_stats[:week_end].strftime("%B %Y")}" %>

- +

Hi <%= @staff.first_name %>,

Hope you had a good week! Here's a summary of what has happened in this unit over the last week. @@ -66,6 +63,7 @@

  • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
  • Your student's tasks changed state <%= @data[:weekly_engagements_count] %> time<%= "s" unless @data[:weekly_engagements_count] == 1 %>.
  • You assigned a new status to <%= @data[:staff_engagements] %> task<%= "s" unless @data[:staff_engagements] == 1 %>.
  • +
  • You have discussed <%= @data[:weekly_total_tasks_discussed] %> task<%= "s" unless @data[:weekly_total_tasks_discussed] == 1 %>.
  • You have <%= @data[:tasks_awaiting_feedback_count] %> task<%= "s" unless @data[:tasks_awaiting_feedback_count] == 1 %> waiting for feedback.
  • <% if @data[:tasks_awaiting_feedback_count] > 0 %>
  • The oldest of these tasks was submitted <%= @data[:oldest_task_days] %> day<%= "s" unless @data[:oldest_task_days] == 1 %> ago.
  • @@ -82,61 +80,81 @@

    <% end %> +<%# Else if unit role has no students %> <% else %>

    • In total, there <%= were_was(@summary_stats[:unit_week_comments]) %> <%= @summary_stats[:unit_week_comments] %> comment<%= "s" if @summary_stats[:unit_week_comments] != 1 %> made in this unit.
    • Tasks changed state <%= @summary_stats[:unit_week_engagements] %> time<%= "s" unless @summary_stats[:unit_week_engagements] == 1 %> in this unit.
    • -
    -

    -<% end %> -<% if @unit_role.is_convenor? %> - - - - - - - - - - - - - - - - - - -<% @summary_stats[:staff].each do | user, data | %> -<% next unless data[:number_of_students] > 0%> - - - - - - - - - ><%= '%6i' % data[:oldest_task_days] %> days - -<% end %> - -
    Name# StudAssessmentsCommentsAwaiting FeedbackOldest Task
    TotalWeekTotalWeek
    <%= '%-12s' % data[:staff].name.truncate(12) %><%= '%8i' % data[:number_of_students] %><%= '%7i' % data[:total_staff_engagements] %><%= '%6i' % data[:staff_engagements] %><%= '%5i' % data[:total_comments] %><%= '%5i' % data[:sent_comments] %><%= '%8i' % data[:tasks_awaiting_feedback_count] %>
    -<% if @summary_stats[:num_students_without_tutors] > 0 %> -

    - Please note that <%=@summary_stats[:num_students_without_tutors]%> student<%= "s" unless @summary_stats[:num_students_without_tutors] == 1 %> <%= are_is(@summary_stats[:num_students_without_tutors]) %> not allocated to a tutorial. Work submitted by <%= this_these(@summary_stats[:num_students_without_tutors]) %> student<%= "s" unless @summary_stats[:num_students_without_tutors] == 1 %> will not appear in any of the tutor inboxes! +

    <% end %> + +<% if @unit_role.is_convenor? && !@summary_stats[:tutorial_streams].nil? && !@summary_stats[:tutorial_streams].empty?%> + <%# Create a table for each tutorial stream %> + <% @summary_stats[:tutorial_streams].each do |tutorial_stream, tutorial_stream_data | %> + <% next unless tutorial_stream_data[:stream_linked_to_task_definition] %> +

    + Tutorial Stream: <%= tutorial_stream.name %> +

    + <%# Skip table if no tutorials%> + <% unless tutorial_stream_data.nil? || tutorial_stream_data[:tutorials].nil?%> + + + + + + + + + + + + + + + + + + + + + + <% sorted_data = tutorial_stream_data[:unit_roles].sort_by do |unit_role, data| + [-data[:oldest_task_days].to_i, -data[:tasks_awaiting_feedback_count].to_i] + end %> + <% sorted_data.each do | unit_role, data | %> + <% next unless data[:number_of_students] > 0%> + + + + + + + + + + + + + <% end %> + +
    NameStudentsAssessmentsCommentsAwaiting FeedbackOldest TaskDiscussions
    TotalWeekTotalWeekTotalWeek
    <%= '%-12s' % unit_role.user.name.truncate(14) %><%= '%8i' % data[:number_of_students] %><%= '%7i' % data[:total_staff_engagements] %><%= '%6i' % data[:staff_engagements] %><%= '%5i' % data[:total_comments].uniq.count %><%= '%5i' % data[:sent_comments].uniq.count %><%= '%8i' % data[:tasks_awaiting_feedback_count] %>><%= '%6i' % data[:oldest_task_days] %> days<%= '%5i' % data[:total_tasks_discussed] %><%= '%5i' % data[:weekly_tasks_discussed] %>
    + <% end %> + <% if tutorial_stream_data[:num_students_without_tutors] > 0 %> +

    + Please note that <%=tutorial_stream_data[:num_students_without_tutors]%> student<%= "s" unless tutorial_stream_data[:num_students_without_tutors] == 1 %> <%= are_is(tutorial_stream_data[:num_students_without_tutors]) %> not allocated to a tutorial in this stream. Work submitted by <%= this_these(tutorial_stream_data[:num_students_without_tutors]) %> student<%= "s" unless tutorial_stream_data[:num_students_without_tutors] == 1 %> will not appear in any of the tutor inboxes! +

    + <% end %> + <% end %> <% end %>

    Cheers,
    The <%= @doubtfire_product_name %> Team on behalf of <%= @convenor.name %>

    -
    -