ui: Use $lib in imports
This commit is contained in:
parent
10d0a6a836
commit
d6f620bc0d
54 changed files with 146 additions and 146 deletions
19
ui/src/lib/components/AuthButton.svelte
Normal file
19
ui/src/lib/components/AuthButton.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
let auth_route = 'auth/CRI'
|
||||
$: {
|
||||
if ($page.url.searchParams.get('next')) {
|
||||
auth_route = 'auth/CRI?next=' + encodeURIComponent($page.url.searchParams.get('next'));
|
||||
} else {
|
||||
auth_route = 'auth/CRI?';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a href={auth_route} target="_self" class="{className}">
|
||||
<slot></slot>
|
||||
</a>
|
||||
27
ui/src/lib/components/BuildState.svelte
Normal file
27
ui/src/lib/components/BuildState.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let repo_pull_state = null
|
||||
</script>
|
||||
|
||||
{#await repo_pull_state}
|
||||
<div class="spinner-grow spinner-grow-sm mx-1" role="status"></div>
|
||||
{:then state}
|
||||
{#if state.status == "pending" || state.status == "running"}
|
||||
<div
|
||||
class="spinner-grow spinner-grow-sm mx-1"
|
||||
class:text-primary={state.status == "pending"}
|
||||
class:text-warning={state.status == "running"}
|
||||
title="La récupération est en cours"
|
||||
role="status"
|
||||
></div>
|
||||
{:else if state.status == "success"}
|
||||
<i class="bi bi-check-circle-fill text-success mx-1" title="La récupération s'est bien passée"></i>
|
||||
{:else if state.status == "failure" || state.status == "killed"}
|
||||
<i class="bi bi-exclamation-circle-fill text-danger mx-1" title="La récupération ne s'est pas bien passée" style="cursor: pointer" on:click={() => dispatch('show_logs')}></i>
|
||||
{:else}
|
||||
{state.status}
|
||||
{/if}
|
||||
{/await}
|
||||
47
ui/src/lib/components/Correction.svelte
Normal file
47
ui/src/lib/components/Correction.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import CorrectionResponses from './CorrectionResponses.svelte';
|
||||
|
||||
export let question = null;
|
||||
export let cts = null;
|
||||
export let child = null;
|
||||
export let filter = "";
|
||||
export let notCorrected = false;
|
||||
export let showStudent = false;
|
||||
export let templates = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
{#if question && (question.kind == 'mcq' || question.kind == 'ucq')}
|
||||
{#await question.getProposals()}
|
||||
<div class="text-center mt-4">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Récupération des propositions…</span>
|
||||
</div>
|
||||
{:then proposals}
|
||||
<CorrectionResponses
|
||||
{cts}
|
||||
bind:this={child}
|
||||
{filter}
|
||||
{question}
|
||||
{notCorrected}
|
||||
{proposals}
|
||||
{showStudent}
|
||||
{templates}
|
||||
on:nb_responses={(v) => dispatch('nb_responses', v.detail)}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
<CorrectionResponses
|
||||
{cts}
|
||||
bind:this={child}
|
||||
{filter}
|
||||
{question}
|
||||
{notCorrected}
|
||||
{showStudent}
|
||||
{templates}
|
||||
on:nb_responses={(v) => dispatch('nb_responses', v.detail)}
|
||||
/>
|
||||
{/if}
|
||||
109
ui/src/lib/components/CorrectionPieChart.svelte
Normal file
109
ui/src/lib/components/CorrectionPieChart.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts">
|
||||
import Chart from 'svelte-frappe-charts';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let question = null;
|
||||
|
||||
function refreshProposals() {
|
||||
let req = question.getProposals();
|
||||
|
||||
req.then((proposals) => {
|
||||
const proposal_idx = { };
|
||||
for (const proposal of proposals) {
|
||||
data.labels.push(proposal.label);
|
||||
data.datasets[0].values.push(0);
|
||||
proposal_idx[proposal.id] = new String(data.labels.length - 1);
|
||||
}
|
||||
|
||||
req_responses = question.getResponses();
|
||||
req_responses.then((responses) => {
|
||||
for (const res of responses) {
|
||||
const rsplt = res.value.split(',');
|
||||
for (const r of rsplt) {
|
||||
data.datasets[0].values[proposal_idx[r]] += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
let req_proposals = null;
|
||||
export let proposals = null;
|
||||
let req_responses = null;
|
||||
let mean = null;
|
||||
|
||||
export let data = {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
values: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (!proposals) {
|
||||
if (question.kind && (question.kind == "int" || question.kind.startsWith("list"))) {
|
||||
req_responses = question.getResponses();
|
||||
req_responses.then((responses) => {
|
||||
const values = [];
|
||||
const proposal_idx = { };
|
||||
for (const response of responses) {
|
||||
let ress = [];
|
||||
if (question.kind.startsWith("list")) {
|
||||
ress = response.value.split('\n');
|
||||
} else {
|
||||
ress.push(response.value);
|
||||
}
|
||||
for (const res of ress) {
|
||||
if (res == "") continue;
|
||||
if (proposal_idx[res]) {
|
||||
data.datasets[0].values[proposal_idx[res]] += 1;
|
||||
values.push(Number(res));
|
||||
} else {
|
||||
data.labels.push(res);
|
||||
data.datasets[0].values.push(1);
|
||||
proposal_idx[res] = new String(data.labels.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (question.kind == "int") {
|
||||
mean = Math.trunc(values.reduce((p, e) => p + e) / values.length*10)/10;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
req_proposals = refreshProposals();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{className}">
|
||||
{#await req_proposals}
|
||||
<div class="text-center mt-4">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Récupération des propositions…</span>
|
||||
</div>
|
||||
{:then}
|
||||
{#await req_responses}
|
||||
<div class="text-center mt-4">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Récupération des réponses…</span>
|
||||
</div>
|
||||
{:then}
|
||||
{#if mean !== null}
|
||||
<div class="text-center">
|
||||
Moyenne représentative : <strong>{mean}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
{#if question.kind === "mcq"}
|
||||
<Chart data={data} type="bar" />
|
||||
{:else if question.kind === "list"}
|
||||
<Chart data={data} type="pie" />
|
||||
{:else}
|
||||
<Chart data={data} type="pie" maxSlices="9" />
|
||||
{/if}
|
||||
{/await}
|
||||
{/await}
|
||||
</div>
|
||||
150
ui/src/lib/components/CorrectionReference.svelte
Normal file
150
ui/src/lib/components/CorrectionReference.svelte
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<script>
|
||||
import { CorrectionTemplate } from '$lib/correctionTemplates';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
export let cts = null;
|
||||
export let nb_responses = 0;
|
||||
export let question = null;
|
||||
export let templates = [];
|
||||
|
||||
export let filter = "";
|
||||
|
||||
function addTemplate() {
|
||||
const ct = new CorrectionTemplate()
|
||||
if (question) {
|
||||
ct.id_question = question.id;
|
||||
}
|
||||
templates.push(ct);
|
||||
templates = templates;
|
||||
}
|
||||
|
||||
function genTemplates() {
|
||||
question.getProposals().then((proposals) => {
|
||||
let i = 0;
|
||||
for (const p of proposals) {
|
||||
// Search proposal in templates
|
||||
let found = false;
|
||||
for (const tpl of templates) {
|
||||
if (tpl.regexp.indexOf(p.id.toString()) !== -1) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
const ct = new CorrectionTemplate()
|
||||
ct.id_question = question.id;
|
||||
ct.regexp = p.id.toString();
|
||||
ct.label = String.fromCharCode(97 + i);
|
||||
ct.save().then((ct) => {
|
||||
templates.push(ct);
|
||||
templates = templates;
|
||||
});
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function delTemplate(tpl) {
|
||||
tpl.delete().then(() => {
|
||||
const idx = templates.findIndex((e) => e.id === tpl.id);
|
||||
if (idx >= 0) {
|
||||
templates.splice(idx, 1);
|
||||
}
|
||||
templates = templates;
|
||||
});
|
||||
}
|
||||
|
||||
function submitTemplate(tpl) {
|
||||
tpl.save().then(() => {
|
||||
templates = templates;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{className}">
|
||||
{#each templates as template (template.id)}
|
||||
<form class="row mb-2" on:submit|preventDefault={() => submitTemplate(template)}>
|
||||
<div class="col-2">
|
||||
<div class="input-group">
|
||||
<input
|
||||
placeholder="RegExp"
|
||||
class="form-control"
|
||||
class:bg-warning={template.regexp && template.regexp === filter}
|
||||
bind:value={template.regexp}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
class:btn-outline-secondary={!template.regexp || template.regexp !== filter}
|
||||
class:btn-outline-warning={template.regexp && template.regexp === filter}
|
||||
on:click={() => { if (filter == template.regexp) filter = ''; else filter = template.regexp; } }
|
||||
>
|
||||
<i class="bi bi-filter"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<input placeholder="Intitulé" class="form-control" bind:value={template.label}>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="-12"
|
||||
class="form-control"
|
||||
bind:value={template.score}
|
||||
>
|
||||
</div>
|
||||
<div class="col">
|
||||
<textarea
|
||||
placeholder="Explication pour l'étudiant"
|
||||
class="form-control form-control-sm"
|
||||
bind:value={template.score_explaination}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="col-1 d-flex flex-column">
|
||||
<div class="text-end">
|
||||
{#if cts && template.id && cts[template.id.toString()]}
|
||||
{Math.trunc(Object.keys(cts[template.id.toString()]).length/nb_responses*1000)/10} %
|
||||
{:else}
|
||||
N/A
|
||||
{/if}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
on:click={() => delTemplate(template)}
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
>
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info me-1"
|
||||
on:click={addTemplate}
|
||||
disabled={templates.length > 0 && !templates[templates.length-1].id}
|
||||
>
|
||||
<i class="bi bi-plus"></i> Ajouter un template
|
||||
</button>
|
||||
{#if question.kind == "mcq" || question.kind == "ucq"}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-info me-1"
|
||||
on:click={genTemplates}
|
||||
>
|
||||
<i class="bi bi-magic"></i> Générer les templates
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
124
ui/src/lib/components/CorrectionResponseFooter.svelte
Normal file
124
ui/src/lib/components/CorrectionResponseFooter.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<script>
|
||||
import { user } from '$lib/stores/user';
|
||||
import { autoCorrection } from '$lib/correctionTemplates';
|
||||
|
||||
export let cts = null;
|
||||
export let rid = 0;
|
||||
export let response = null;
|
||||
export let templates = [];
|
||||
|
||||
let my_tpls = { };
|
||||
let my_correction = null;
|
||||
|
||||
function submitCorrection() {
|
||||
if (response.score === undefined || response.score === null) {
|
||||
if (my_correction && my_correction.score !== undefined) {
|
||||
response.score = my_correction.score;
|
||||
} else {
|
||||
response.score = 100;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.score_explaination === undefined || response.score_explaination === null) {
|
||||
if (my_correction && my_correction.score_explaination !== undefined) {
|
||||
response.score_explaination = my_correction.score_explaination;
|
||||
}
|
||||
}
|
||||
|
||||
response.id_corrector = $user.id
|
||||
response.time_scored = (new Date()).toISOString()
|
||||
|
||||
response.save().then((res) => {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
$: {
|
||||
if (cts && templates && response && response.id_user) {
|
||||
for (const t of templates) {
|
||||
if (my_tpls[t.id] === undefined && cts[t.id.toString()]) {
|
||||
my_tpls[t.id] = cts[t.id.toString()][response.id_user] !== undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="row"
|
||||
on:submit|preventDefault={submitCorrection}
|
||||
>
|
||||
<div class="col-auto">
|
||||
<button
|
||||
class="btn btn-success me-1"
|
||||
class:mt-4={rid%2}
|
||||
>
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="row row-cols-3">
|
||||
{#each templates as template (template.id)}
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
id="r{response.id}t{template.id}"
|
||||
on:change={() => {my_tpls[template.id] = !my_tpls[template.id]; autoCorrection(response.id_user, my_tpls).then((r) => my_correction = r); }}
|
||||
checked={my_tpls[template.id]}
|
||||
>
|
||||
<label
|
||||
class="form-check-label"
|
||||
for="r{response.id}t{template.id}"
|
||||
>
|
||||
{template.label}
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
placeholder="Score"
|
||||
bind:value={response.score}
|
||||
>
|
||||
{#if my_correction}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-light"
|
||||
on:click={() => { response.score = my_correction.score; response.score_explaination = my_correction.score_explaination; }}
|
||||
>
|
||||
{my_correction.score}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="input-group-text">/100</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
class="form-control mb-2"
|
||||
placeholder="Appréciation"
|
||||
bind:value={response.score_explaination}
|
||||
></textarea>
|
||||
</div>
|
||||
</form>
|
||||
{#if my_correction}
|
||||
<div
|
||||
class="alert row mt-1 mb-0"
|
||||
class:bg-success={my_correction.score > 100}
|
||||
class:alert-success={my_correction.score >= 95 && my_correction.score <= 100}
|
||||
class:alert-info={my_correction.score < 95 && my_correction.score >= 70}
|
||||
class:alert-warning={my_correction.score < 70 && my_correction.score >= 45}
|
||||
class:alert-danger={my_correction.score < 45}
|
||||
>
|
||||
<strong class="col-auto">
|
||||
{my_correction.score} %
|
||||
</strong>
|
||||
<div class="col">
|
||||
{my_correction.score_explaination}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
137
ui/src/lib/components/CorrectionResponses.svelte
Normal file
137
ui/src/lib/components/CorrectionResponses.svelte
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import QuestionProposals from './QuestionProposals.svelte';
|
||||
import ResponseCorrected from './ResponseCorrected.svelte';
|
||||
import CorrectionResponseFooter from './CorrectionResponseFooter.svelte';
|
||||
import { autoCorrection } from '$lib/correctionTemplates';
|
||||
import { getUser } from '$lib/users';
|
||||
|
||||
export let cts = null;
|
||||
export let filter = "";
|
||||
export let question = null;
|
||||
export let proposals = null;
|
||||
export let notCorrected = false;
|
||||
export let showStudent = false;
|
||||
export let templates = false;
|
||||
|
||||
function refreshResponses() {
|
||||
let req = question.getResponses();
|
||||
|
||||
req.then((res) => {
|
||||
responses = res;
|
||||
dispatch('nb_responses', res.length);
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let req_responses = refreshResponses();
|
||||
let responses = [];
|
||||
|
||||
let filteredResponses = [];
|
||||
$:{
|
||||
filteredResponses = responses.filter((r) => (notCorrected || r.time_scored <= r.time_reported || !r.time_scored) && (!filter || ((filter[0] == '!' && !r.value.match(filter.substring(1))) || r.value.match(filter))));
|
||||
}
|
||||
|
||||
export async function applyCorrections() {
|
||||
for (const r of filteredResponses) {
|
||||
const my_correction = { };
|
||||
let has_no_lost_answer = false;
|
||||
let completed_correction = false;
|
||||
|
||||
for (const tpl of templates) {
|
||||
if (tpl.score >= 0) has_no_lost_answer = true;
|
||||
if (!tpl.regexp && tpl.label) continue;
|
||||
|
||||
if (tpl.regexp && (tpl.regexp[0] == '!' && !r.value.match(tpl.regexp.substring(1))) || r.value.match(tpl.regexp)) {
|
||||
my_correction[tpl.id] = true;
|
||||
completed_correction = true;
|
||||
} else {
|
||||
my_correction[tpl.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid correction template AND valid answer is defined,
|
||||
// don't consider the absence of match as valid answer.
|
||||
if (!completed_correction && has_no_lost_answer) continue;
|
||||
|
||||
const auto = await autoCorrection(r.id_user, my_correction);
|
||||
r.score = auto.score;
|
||||
r.score_explaination = auto.score_explaination;
|
||||
await r.save();
|
||||
}
|
||||
req_responses = refreshResponses();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await req_responses}
|
||||
<div class="text-center mt-4">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Récupération des réponses…</span>
|
||||
</div>
|
||||
{:then}
|
||||
{#each filteredResponses as response, rid (response.id)}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
{#if question.kind == 'mcq' || question.kind == 'ucq'}
|
||||
{#if !proposals}
|
||||
<div class="alert bg-danger">
|
||||
Une erreur s'est produite, aucune proposition n'a été chargée
|
||||
</div>
|
||||
{:else}
|
||||
<QuestionProposals
|
||||
kind={question.kind}
|
||||
prefixid={'r' + response.id}
|
||||
{proposals}
|
||||
readonly
|
||||
value={response.value}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<p
|
||||
class="card-text"
|
||||
style="white-space: pre-line"
|
||||
>
|
||||
{response.value}
|
||||
</p>
|
||||
{/if}
|
||||
<ResponseCorrected
|
||||
{response}
|
||||
/>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<CorrectionResponseFooter
|
||||
{cts}
|
||||
{rid}
|
||||
bind:response={response}
|
||||
{templates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if showStudent}
|
||||
<div class="col-auto">
|
||||
<div class="text-center mt-2" style="max-width: 110px">
|
||||
{#await getUser(response.id_user)}
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
{:then user}
|
||||
<a href="/users/{user.login}">
|
||||
<img class="img-thumbnail" src="https://photos.cri.epita.fr/thumb/{user.login}" alt="avatar {user.login}">
|
||||
<div
|
||||
class="text-truncate"
|
||||
title={user.login}
|
||||
>
|
||||
{user.login}
|
||||
</div>
|
||||
</a>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/await}
|
||||
17
ui/src/lib/components/DateFormat.svelte
Normal file
17
ui/src/lib/components/DateFormat.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
export let date;
|
||||
export let dateStyle = "long";
|
||||
export let timeStyle = "long";
|
||||
|
||||
function formatDate(input, dateStyle, timeStyle) {
|
||||
if (typeof input === 'string') {
|
||||
input = new Date(input);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle,
|
||||
timeStyle,
|
||||
}).format(input);
|
||||
}
|
||||
</script>
|
||||
|
||||
{formatDate(date, dateStyle, timeStyle)}
|
||||
26
ui/src/lib/components/DateTimeInput.svelte
Normal file
26
ui/src/lib/components/DateTimeInput.svelte
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<script>
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export let format = 'YYYY-MM-DD HH:mm';
|
||||
export let date = new Date();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let id = null;
|
||||
|
||||
let internal;
|
||||
|
||||
const input = (x) => (internal = dayjs(x).format(format));
|
||||
const output = (x) => {
|
||||
const d = dayjs(x, format).toDate();
|
||||
if (d) {
|
||||
date = d.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
$: input(date)
|
||||
$: output(internal)
|
||||
</script>
|
||||
|
||||
<input type="datetime-local" class={className} id={id} bind:value={internal}>
|
||||
43
ui/src/lib/components/ListInput.svelte
Normal file
43
ui/src/lib/components/ListInput.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script>
|
||||
import { createEventDispatcher, tick } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let qid = "";
|
||||
export let kind = "list";
|
||||
export let value = "";
|
||||
export let placeholder = "";
|
||||
|
||||
let nb;
|
||||
let mval = [];
|
||||
$: mval = value?value.split('\n'):[];
|
||||
$: {
|
||||
nb = Number(kind.substring(4));
|
||||
while (mval.length < nb) {
|
||||
mval.push("");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each mval as v, i}
|
||||
<input
|
||||
id={qid + "l" + i}
|
||||
class={className}
|
||||
type="text"
|
||||
bind:value={mval[i]}
|
||||
{placeholder}
|
||||
on:change={() => { value = mval.join('\n').trim(); dispatch("change"); }}
|
||||
on:blur={() => { if (kind == "list" && i == mval.length - 1 && mval[i] == "") { mval.pop(); mval = mval; } }}
|
||||
>
|
||||
{/each}
|
||||
{#if kind == "list" && (mval.length == 0 || mval[mval.length-1] != "")}
|
||||
<input
|
||||
class={className}
|
||||
type="text"
|
||||
{placeholder}
|
||||
on:focus={() => { mval.push(""); mval = mval; tick().then(() => document.getElementById(qid + "l" + (mval.length-1)).focus()); }}
|
||||
>
|
||||
{/if}
|
||||
78
ui/src/lib/components/ListInputResponses.svelte
Normal file
78
ui/src/lib/components/ListInputResponses.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script>
|
||||
import CorrectionPieChart from './CorrectionPieChart.svelte';
|
||||
|
||||
export let responses = {};
|
||||
export let users = {};
|
||||
|
||||
let res = {};
|
||||
let labels = [];
|
||||
let graph_data = { labels, datasets:[{values: []}] };
|
||||
$: {
|
||||
res = { };
|
||||
labels = [];
|
||||
responses = responses;
|
||||
for (const user in responses) {
|
||||
for (const ures of responses[user].split('\n')) {
|
||||
if (ures === "") continue;
|
||||
|
||||
if (res[ures] === undefined) {
|
||||
res[ures] = [];
|
||||
}
|
||||
res[ures].push(user);
|
||||
|
||||
if (res[ures].length == 2) {
|
||||
labels.push(ures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labels.sort((a,b) => (res[a].length - res[b].length))
|
||||
graph_data.labels = labels;
|
||||
graph_data.datasets[0].values = labels.map((l) => res[l].length);
|
||||
console.log(graph_data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<CorrectionPieChart
|
||||
question={{kind: 'list'}}
|
||||
proposals={[]}
|
||||
data={graph_data}
|
||||
/>
|
||||
<div class="card mb-4">
|
||||
<ul class="list-group list-group-flush">
|
||||
{#each labels as rep, rid (rid)}
|
||||
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{rep}
|
||||
</span>
|
||||
<div>
|
||||
{#each res[rep] as user}
|
||||
<a href="users/{user}" target="_blank" class="badge bg-dark rounded-pill">
|
||||
{#if users && users[user]}
|
||||
{users[user].login}
|
||||
{:else}
|
||||
{user}
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{#each Object.keys(res) as rep, rid (rid)}
|
||||
{#if labels.indexOf(rep) == -1}
|
||||
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
{rep}
|
||||
</span>
|
||||
<a href="users/{res[rep]}" target="_blank" class="badge bg-dark rounded-pill">
|
||||
{#if users && users[res[rep]]}
|
||||
{users[res[rep]].login}
|
||||
{:else}
|
||||
{res[rep]}
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
158
ui/src/lib/components/QuestionForm.svelte
Normal file
158
ui/src/lib/components/QuestionForm.svelte
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import ListInput from './ListInput.svelte';
|
||||
import QuestionHeader from './QuestionHeader.svelte';
|
||||
import QuestionProposals from './QuestionProposals.svelte';
|
||||
import ResponseCorrected from './ResponseCorrected.svelte';
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
export let question;
|
||||
export let qid;
|
||||
export let response_history = null;
|
||||
export let readonly = false;
|
||||
export let corrections = null;
|
||||
export let survey = null;
|
||||
export let value = "";
|
||||
|
||||
export let edit = false;
|
||||
|
||||
function saveQuestion() {
|
||||
question.save().then((response) => {
|
||||
question.description = response.description;
|
||||
question = question;
|
||||
edit = false;
|
||||
})
|
||||
}
|
||||
function editQuestion() {
|
||||
edit = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card my-3 {className}">
|
||||
<QuestionHeader
|
||||
bind:question={question}
|
||||
{qid}
|
||||
{edit}
|
||||
>
|
||||
{#if $user && $user.is_admin}
|
||||
<button type="button" class="btn btn-sm btn-danger ms-1 float-end" on:click={() => dispatch('delete')}>
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
{#if edit}
|
||||
<button type="button" class="btn btn-sm btn-success ms-1 float-end" on:click={saveQuestion}>
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
{:else}
|
||||
<button type="button" class="btn btn-sm btn-primary ms-1 float-end" on:click={editQuestion}>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</QuestionHeader>
|
||||
<slot></slot>
|
||||
{#if question.kind != 'none'}
|
||||
<div class="card-body">
|
||||
{#if false && response_history}
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<div class="col-auto">
|
||||
Historique :
|
||||
<select class="form-select">
|
||||
<option value="new">Actuel</option>
|
||||
{#each response_history as history (history.id)}
|
||||
<option value={history.id}>{new Intl.DateTimeFormat('default', { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'}).format(new Date(history.time_submit))}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if edit}
|
||||
{#if question.kind && (question.kind == 'text' || question.kind == 'int' || question.kind.startsWith('list'))}
|
||||
<div class="form-group row">
|
||||
<label class="col-2 col-form-label" for="q{qid}placeholder">Placeholder</label>
|
||||
<div class="col">
|
||||
<input class="form-control" id="q{qid}placeholder" bind:value={question.placeholder}>
|
||||
</div>
|
||||
</div>
|
||||
{:else if question.kind}
|
||||
{#if !question.id}
|
||||
Veuillez enregistrer la question pour pouvoir ajouter des propositions.
|
||||
{:else}
|
||||
{#await question.getProposals()}
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Chargement des choix …</span>
|
||||
</div>
|
||||
{:then proposals}
|
||||
<QuestionProposals
|
||||
edit
|
||||
id_question={question.id}
|
||||
kind={question.kind}
|
||||
{proposals}
|
||||
readonly
|
||||
live={survey && survey.direct !== null}
|
||||
{corrections}
|
||||
bind:value={value}
|
||||
on:change={() => { dispatch("change"); }}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if question.kind == 'mcq' || question.kind == 'ucq'}
|
||||
{#await question.getProposals()}
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Chargement des choix …</span>
|
||||
</div>
|
||||
{:then proposals}
|
||||
<QuestionProposals
|
||||
kind={question.kind}
|
||||
{proposals}
|
||||
{readonly}
|
||||
live={survey && survey.direct !== null}
|
||||
{corrections}
|
||||
bind:value={value}
|
||||
on:change={() => { dispatch("change"); }}
|
||||
/>
|
||||
{/await}
|
||||
{:else if readonly}
|
||||
<p class="card-text alert alert-secondary" style="white-space: pre-line">{value}</p>
|
||||
{:else if question.kind == 'int'}
|
||||
<input
|
||||
class="ml-5 col-sm-2 form-control"
|
||||
type="number"
|
||||
bind:value={value}
|
||||
placeholder={question.placeholder}
|
||||
on:change={() => { dispatch("change"); }}
|
||||
>
|
||||
{:else if question.kind && question.kind.startsWith('list')}
|
||||
<ListInput
|
||||
class="ml-5 col-sm-2 form-control my-1"
|
||||
kind={question.kind}
|
||||
bind:value={value}
|
||||
placeholder={question.placeholder}
|
||||
on:change={() => { dispatch("change"); }}
|
||||
/>
|
||||
{:else}
|
||||
<textarea
|
||||
class="form-control"
|
||||
rows="6"
|
||||
bind:value={value}
|
||||
placeholder={question.placeholder}
|
||||
on:change={() => { dispatch("change"); }}
|
||||
></textarea>
|
||||
{/if}
|
||||
|
||||
{#if survey && survey.corrected && response_history}
|
||||
<ResponseCorrected
|
||||
response={response_history}
|
||||
{survey}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
50
ui/src/lib/components/QuestionHeader.svelte
Normal file
50
ui/src/lib/components/QuestionHeader.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
export let question = null;
|
||||
export let qid = null;
|
||||
export let edit = false;
|
||||
export let nodescription = false;
|
||||
</script>
|
||||
|
||||
<div class="card-header {className}">
|
||||
<slot></slot>
|
||||
|
||||
{#if edit}
|
||||
<div class="card-title row">
|
||||
<label for="q{qid}title" class="col-auto col-form-label font-weight-bold">Titre :</label>
|
||||
<div class="col"><input id="q{qid}title" class="form-control" bind:value={question.title}></div>
|
||||
</div>
|
||||
{:else}
|
||||
<h4 class="card-title mb-0">{#if qid !== null}{qid + 1}. {/if}{question.title}</h4>
|
||||
{/if}
|
||||
|
||||
{#if edit}
|
||||
<div class="form-group row">
|
||||
<label class="col-2 col-form-label" for="q{qid}kind">Type de réponse</label>
|
||||
<div class="col">
|
||||
<select class="form-select" id="q{qid}kind" bind:value={question.kind}>
|
||||
<option value="text">Texte</option>
|
||||
<option value="list">Liste de champs de texte</option>
|
||||
<option value="list1">Liste de champs de texte (1 champ)</option>
|
||||
<option value="list2">Liste de champs de texte (2 champs)</option>
|
||||
<option value="list5">Liste de champs de texte (5 champs)</option>
|
||||
<option value="int">Entier</option>
|
||||
<option value="ucq">QCU</option>
|
||||
<option value="mcq">QCM</option>
|
||||
<option value="none">Rien</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea class="form-control mb-2" bind:value={question.desc_raw} placeholder="Description de la question"></textarea>
|
||||
{:else if question.description && !nodescription}
|
||||
<p class="card-text mt-2">{@html question.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
134
ui/src/lib/components/QuestionProposals.svelte
Normal file
134
ui/src/lib/components/QuestionProposals.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import { QuestionProposal } from '$lib/questions';
|
||||
|
||||
export let edit = false;
|
||||
export let proposals = [];
|
||||
export let live = false;
|
||||
export let kind = 'mcq';
|
||||
export let prefixid = '';
|
||||
export let readonly = false;
|
||||
export let corrections = null;
|
||||
export let id_question = 0;
|
||||
export let value;
|
||||
|
||||
let valueCheck = [];
|
||||
$: {
|
||||
if (value) {
|
||||
valueCheck = value.split(',');
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function addProposal() {
|
||||
const p = new QuestionProposal();
|
||||
p.id_question = id_question;
|
||||
proposals.push(p);
|
||||
proposals = proposals;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class:d-flex={live} class:justify-content-around={live}>
|
||||
{#each proposals as proposal, pid (proposal.id)}
|
||||
<div class="form-check">
|
||||
{#if kind == 'mcq'}
|
||||
<input
|
||||
type="checkbox"
|
||||
class:btn-check={live}
|
||||
class:form-check-input={!live}
|
||||
disabled={readonly}
|
||||
name={prefixid + 'proposal' + proposal.id_question}
|
||||
id={prefixid + 'p' + proposal.id}
|
||||
bind:group={valueCheck}
|
||||
value={proposal.id?proposal.id.toString():''}
|
||||
on:change={() => { value = valueCheck.join(','); dispatch("change"); }}
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
type="radio"
|
||||
class:btn-check={live}
|
||||
class:form-check-input={!live}
|
||||
disabled={readonly}
|
||||
name={prefixid + 'proposal' + proposal.id_question}
|
||||
id={prefixid + 'p' + proposal.id}
|
||||
bind:group={value}
|
||||
value={proposal.id?proposal.id.toString():''}
|
||||
on:change={() => { dispatch("change"); }}
|
||||
>
|
||||
{/if}
|
||||
{#if edit}
|
||||
<form on:submit|preventDefault={() => { proposal.save().then(() => proposal = proposal); } }>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
bind:value={proposal.label}
|
||||
on:input={() => proposal.changed = true}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
tabindex="-1"
|
||||
disabled={!proposal.id}
|
||||
on:click={() => { proposal.delete().then(() => { proposals.splice(pid, 1); proposals = proposals; }); }}
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
class:btn-success={proposal.changed}
|
||||
class:btn-outline-success={!proposal.changed}
|
||||
disabled={!proposal.changed}
|
||||
>
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<label
|
||||
class:form-check-label={!live}
|
||||
class:btn={live}
|
||||
class:btn-lg={live}
|
||||
class:btn-primary={live && !corrections && value && value.indexOf(proposal.id.toString()) != -1}
|
||||
class:btn-outline-primary={live && !corrections && (!value || value.indexOf(proposal.id.toString()) == -1)}
|
||||
class:btn-success={live && corrections && corrections[proposal.id] == 0}
|
||||
class:btn-warning={live && corrections && corrections[proposal.id] != 0 && corrections[proposal.id] != -100 && value && value.indexOf(proposal.id.toString()) != -1}
|
||||
class:btn-danger={live && corrections && corrections[proposal.id] == -100 && value && value.indexOf(proposal.id.toString()) != -1}
|
||||
class:btn-outline-warning={live && corrections && corrections[proposal.id] != 0 && corrections[proposal.id] != -100 && (!value || value.indexOf(proposal.id.toString()) == -1)}
|
||||
class:btn-outline-danger={live && corrections && corrections[proposal.id] == -100 && (!value || value.indexOf(proposal.id.toString()) == -1)}
|
||||
for={prefixid + 'p' + proposal.id}
|
||||
>
|
||||
{proposal.label}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if edit}
|
||||
{#if kind == 'mcq'}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
disabled
|
||||
checked
|
||||
>
|
||||
{:else}
|
||||
<input
|
||||
type="radio"
|
||||
class="form-check-input"
|
||||
disabled
|
||||
checked
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link"
|
||||
disabled={proposals.length > 0 && !proposals[proposals.length-1].id}
|
||||
on:click={addProposal}
|
||||
>
|
||||
ajouter
|
||||
</button>
|
||||
{/if}
|
||||
125
ui/src/lib/components/ResponseCorrected.svelte
Normal file
125
ui/src/lib/components/ResponseCorrected.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<script>
|
||||
import { user } from '$lib/stores/user';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
export let response = null;
|
||||
export let survey = null;
|
||||
let reportInProgress = false;
|
||||
|
||||
function report() {
|
||||
reportInProgress = true;
|
||||
response.report(survey).then((res) => {
|
||||
reportInProgress = false;
|
||||
response.time_reported = res.time_reported;
|
||||
if (res.time_reported >= res.time_scored) {
|
||||
ToastsStore.addToast({
|
||||
msg: "Ton signalement a bien été pris en compte.",
|
||||
color: "success",
|
||||
title: "Signaler une erreur de correction",
|
||||
});
|
||||
} else if (!res.time_reported) {
|
||||
ToastsStore.addToast({
|
||||
msg: "La correction de ta réponse n'est maintenant plus signalée, signalement annulé.",
|
||||
color: "info",
|
||||
title: "Signaler une erreur de correction",
|
||||
});
|
||||
} else {
|
||||
ToastsStore.addErrorToast({
|
||||
msg: "Quelque chose s'est mal passé lors du signalement du problème.\nSi le problème persiste, contacte directement ton professeur.",
|
||||
});
|
||||
}
|
||||
}, (error) => {
|
||||
reportInProgress = false;
|
||||
ToastsStore.addErrorToast({
|
||||
msg: "Une erreur s'est produite durant le signalement du problème : " + error + "\nSi le problème persiste, contacte directement ton professeur.",
|
||||
});
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if response.score !== undefined}
|
||||
<div
|
||||
class="alert row mb-0"
|
||||
class:alert-success={response.score >= 95}
|
||||
class:alert-info={response.score < 95 && response.score >= 70}
|
||||
class:alert-warning={response.score < 70 && response.score >= 45}
|
||||
class:alert-danger={response.score < 45}
|
||||
>
|
||||
<div class="col-auto">
|
||||
<strong
|
||||
title="Tu as obtenu un score de {response.score} %, ce qui correspond à {Math.trunc(response.score*10/5)/10}/20."
|
||||
>
|
||||
{response.score} %
|
||||
</strong>
|
||||
</div>
|
||||
<div class="col">
|
||||
{#if response.id_user == $user.id}
|
||||
<button
|
||||
type="button"
|
||||
class="d-block btn btn-sm float-end"
|
||||
class:btn-outline-success={!response.time_reported && response.score >= 95}
|
||||
class:btn-outline-info={!response.time_reported && response.score < 95 && response.score >= 70}
|
||||
class:btn-outline-warning={!response.time_reported && response.score < 70 && response.score >= 45}
|
||||
class:btn-outline-danger={!response.time_reported && response.score < 45}
|
||||
class:btn-success={response.time_reported && response.score >= 95}
|
||||
class:btn-info={response.time_reported && response.score < 95 && response.score >= 70}
|
||||
class:btn-warning={response.time_reported && response.score < 70 && response.score >= 45}
|
||||
class:btn-danger={response.time_reported && response.score < 45}
|
||||
title="Signaler un problème avec la correction"
|
||||
disabled={reportInProgress}
|
||||
on:click={report}
|
||||
>
|
||||
{#if reportInProgress}
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
{:else if response.time_reported > response.time_scored}
|
||||
<i class="bi bi-exclamation-octagon-fill"></i>
|
||||
{:else}
|
||||
<i class="bi bi-exclamation-octagon"></i>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if $user.is_admin && response.time_reported}
|
||||
{#if response.time_reported > response.time_scored}
|
||||
<i
|
||||
class="float-end bi bi-exclamation-octagon-fill"
|
||||
class:text-warning={response.score < 45}
|
||||
class:text-danger={response.score >= 45}
|
||||
></i>
|
||||
{:else}
|
||||
<i
|
||||
class="float-end bi bi-exclamation-octagon"
|
||||
class:text-warning={response.score < 45}
|
||||
class:text-danger={response.score >= 45}
|
||||
></i>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if response.score_explaination}
|
||||
{response.score_explaination}
|
||||
{:else if response.score === 100}
|
||||
<i class="bi bi-check"></i>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if response && survey}
|
||||
{#if response.value}
|
||||
<div class="alert alert-dark text-danger row mb-0">
|
||||
<div class="col-auto" style="margin: -0.4em; font-size: 2em;">
|
||||
🤯
|
||||
</div>
|
||||
<div class="col">
|
||||
<strong>Oups, tu sembles être passé entre les mailles du filet !</strong>
|
||||
Cette question a bien été corrigée, mais une erreur s'est produite dans la correction de ta réponse.
|
||||
<a href="mailto:nemunaire@nemunai.re?subject=Question non corrigée (questionnaire {survey.id})">Contacte ton enseignant</a> au plus vite.
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-danger row mb-0">
|
||||
<div class="col-auto" style="margin: -0.4em; font-size: 2em;">
|
||||
😟
|
||||
</div>
|
||||
<div class="col">
|
||||
<strong>Tu n'as pas répondu à cette question.</strong>
|
||||
Que s'est-il passé ?
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
31
ui/src/lib/components/StartStopLiveSurvey.svelte
Normal file
31
ui/src/lib/components/StartStopLiveSurvey.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let survey = null;
|
||||
</script>
|
||||
|
||||
{#if survey.direct !== null}
|
||||
<a href="surveys/{survey.id}/live" class="btn btn-danger ms-1 float-end" title="Aller au direct"><i class="bi bi-film"></i></a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary {className}"
|
||||
title="Terminer le direct"
|
||||
on:click={() => dispatch('end')}
|
||||
>
|
||||
<i class="bi bi-align-end"></i>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary {className}"
|
||||
title="Commencer le direct"
|
||||
on:click={() => {survey.shown = true; survey.direct = 0; survey.start_availability = new Date(); survey.end_availability = new Date(Date.now() + 43200000); survey.save().then(() => dispatch('update'));}}
|
||||
>
|
||||
<i class="bi bi-align-start"></i>
|
||||
</button>
|
||||
{/if}
|
||||
77
ui/src/lib/components/StudentGrades.svelte
Normal file
77
ui/src/lib/components/StudentGrades.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script>
|
||||
import { getSurveys } from '$lib/surveys';
|
||||
import { getUsers, getGrades, getPromos } from '$lib/users';
|
||||
|
||||
export let promo = null;
|
||||
</script>
|
||||
|
||||
{#await getPromos() then promos}
|
||||
<div class="float-end me-2">
|
||||
<select class="form-select" bind:value={promo}>
|
||||
<option value={null}>tous</option>
|
||||
{#each promos as promo, pid (pid)}
|
||||
<option value={promo}>{promo}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/await}
|
||||
<h2>
|
||||
Étudiants {#if promo !== null}{promo}{/if}
|
||||
<small class="text-muted">Notes</small>
|
||||
</h2>
|
||||
|
||||
{#await getSurveys()}
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
Chargement des questionnaires corrigés…
|
||||
</div>
|
||||
{:then surveys}
|
||||
{#await getGrades()}
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
Chargement des notes…
|
||||
</div>
|
||||
{:then grades}
|
||||
<div class="card mb-5">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Login</th>
|
||||
{#each surveys as survey (survey.id)}
|
||||
{#if survey.corrected && (promo === null || survey.promo == promo)}
|
||||
<th><a href="surveys/{survey.id}" style="text-decoration: none">{survey.title}</a></th>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#await getUsers()}
|
||||
<tr>
|
||||
<td colspan="20">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
Chargement des étudiants…
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:then users}
|
||||
{#each users as user (user.id)}
|
||||
{#if promo === null || user.promo === promo}
|
||||
<tr>
|
||||
<td><a href="users/{user.id}" style="text-decoration: none">{user.id}</a></td>
|
||||
<td><a href="users/{user.login}" style="text-decoration: none">{user.login}</a></td>
|
||||
{#each surveys as survey (survey.id)}
|
||||
{#if survey.corrected && (promo === null || survey.promo == promo)}
|
||||
<td>{grades[user.id] && grades[user.id][survey.id]?grades[user.id][survey.id]:""}</td>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/await}
|
||||
{/await}
|
||||
56
ui/src/lib/components/SubmissionStatus.svelte
Normal file
56
ui/src/lib/components/SubmissionStatus.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import { getUserRendu } from '$lib/works';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let work = null;
|
||||
export let user = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let renduP = null;
|
||||
let submissionP = null;
|
||||
|
||||
if (work.submission_url != '-') {
|
||||
if (work.submission_url) {
|
||||
renduP = getUserRendu(work.submission_url, user);
|
||||
renduP.then((rendu) => {
|
||||
if (rendu !== null) {
|
||||
dispatch('done');
|
||||
}
|
||||
})
|
||||
} else {
|
||||
submissionP = work.getSubmission(user.id);
|
||||
submissionP.then((submission) => {
|
||||
dispatch('done');
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if work.submission_url == '-'}
|
||||
<!-- Display nothing -->
|
||||
{:else if work.submission_url}
|
||||
{#await renduP}
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
{:then rendu}
|
||||
{#if rendu === null}
|
||||
<i class="bi text-danger bi-exclamation-circle-fill" title="Rendu non réceptionné"></i>
|
||||
{:else}
|
||||
<i class="bi text-success bi-check-circle-fill" title={"Rendu effectué : " + JSON.stringify(rendu)}></i>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
|
||||
{/await}
|
||||
{:else}
|
||||
{#await submissionP}
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
{:then submission}
|
||||
<i class="bi text-success bi-check-circle-fill" title={"Rendu effectué : " + JSON.stringify(submission)}></i>
|
||||
{:catch error}
|
||||
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
|
||||
{/await}
|
||||
{/if}
|
||||
157
ui/src/lib/components/SurveyAdmin.svelte
Normal file
157
ui/src/lib/components/SurveyAdmin.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { getQuestions } from '$lib/questions';
|
||||
import DateTimeInput from './DateTimeInput.svelte';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let survey = null;
|
||||
|
||||
function saveSurvey() {
|
||||
survey.save().then((response) => {
|
||||
dispatch('saved', response);
|
||||
}, (error) => {
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
let deleteInProgress = false;
|
||||
function deleteSurvey() {
|
||||
deleteInProgress = true;
|
||||
survey.delete().then((response) => {
|
||||
deleteInProgress = false;
|
||||
goto(`surveys`);
|
||||
}, (error) => {
|
||||
deleteInProgress = false;
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function duplicateSurvey() {
|
||||
survey.duplicate().then((response) => {
|
||||
goto(`surveys/${response.id}`);
|
||||
}).catch((error) => {
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={saveSurvey}>
|
||||
|
||||
{#if survey.id}
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="title" class="col-form-label col-form-label-sm">Identifiant du questionnaire</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control-plaintext form-control-sm" id="title" value={survey.id}>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="title" class="col-form-label col-form-label-sm">Titre du questionnaire</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control form-control-sm" id="title" bind:value={survey.title}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||
<input type="number" step="1" min="0" max="2068" class="form-control form-control-sm" id="promo" bind:value={survey.promo}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="group" class="col-form-label col-form-label-sm">Restreindre au groupe</label>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||
<input class="form-control form-control-sm" id="group" bind:value={survey.group}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if survey.id}
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="direct" class="col-form-label col-form-label-sm">Question en direct</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
{#await getQuestions(survey.id) then questions}
|
||||
<select id="direct" class="form-select form-select-sm" bind:value={survey.direct}>
|
||||
<option value={null}>Pas de direct</option>
|
||||
<option value={0}>Pause</option>
|
||||
{#each questions as question (question.id)}
|
||||
<option value={question.id}>{question.id} - {question.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={survey.start_availability} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={survey.end_availability} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-3 mx-1 my-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="shown" bind:checked={survey.shown}>
|
||||
<label class="form-check-label" for="shown">
|
||||
Afficher le questionnaire
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="corrected" bind:checked={survey.corrected}>
|
||||
<label class="form-check-label" for="corrected">
|
||||
Marqué comme corrigé
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-10">
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
{#if survey.id}
|
||||
<button type="button" class="btn btn-danger" on:click={deleteSurvey} disabled={deleteInProgress}>
|
||||
{#if deleteInProgress}
|
||||
<div class="spinner-border spinner-border-sm text-light me-1" role="status"></div>
|
||||
{/if}
|
||||
Supprimer
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" on:click={duplicateSurvey}>Dupliquer avec ces nouveaux paramètres</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<hr>
|
||||
13
ui/src/lib/components/SurveyBadge.svelte
Normal file
13
ui/src/lib/components/SurveyBadge.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script>
|
||||
export let survey;
|
||||
let className = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
{#if survey.direct != null}<span class="badge bg-danger {className}">Direct</span>
|
||||
{:else if survey.startAvailability() > Date.now()}<span class="badge bg-info {className}">Prévu</span>
|
||||
{:else if survey.endAvailability() > Date.now()}<span class="badge bg-warning {className}">En cours</span>
|
||||
{:else if !survey.__start_availability}<span class="badge bg-dark {className}">Nouveau</span>
|
||||
{:else if !survey.corrected}<span class="badge bg-primary text-light {className}">Terminé</span>
|
||||
{:else}<span class="badge bg-success {className}">Corrigé</span>
|
||||
{/if}
|
||||
115
ui/src/lib/components/SurveyList.svelte
Normal file
115
ui/src/lib/components/SurveyList.svelte
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { user } from '$lib/stores/user';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import SurveyBadge from '$lib/components/SurveyBadge.svelte';
|
||||
import SubmissionStatus from '$lib/components/SubmissionStatus.svelte';
|
||||
import { getSurveys } from '$lib/surveys';
|
||||
import { getScore } from '$lib/users';
|
||||
|
||||
export let allworks = false;
|
||||
|
||||
let req_surveys = getSurveys(allworks);
|
||||
export let direct = null;
|
||||
|
||||
req_surveys.then((surveys) => {
|
||||
for (const survey of surveys) {
|
||||
if (survey.direct != null) {
|
||||
direct = survey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function gotoSurvey(survey) {
|
||||
if (survey.kind === "w") {
|
||||
goto(`works/${survey.id}`);
|
||||
} else if (survey.direct != null) {
|
||||
goto(`surveys/${survey.id}/live`);
|
||||
} else if ($user.is_admin) {
|
||||
goto(`surveys/${survey.id}/responses`);
|
||||
} else {
|
||||
goto(`surveys/${survey.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Intitulé</th>
|
||||
<th>Date</th>
|
||||
{#if $user}
|
||||
<th>Score</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
{#await req_surveys}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-3">
|
||||
<div class="spinner-border mx-3" role="status"></div>
|
||||
<span>Chargement des questionnaires …</span>
|
||||
</td>
|
||||
</tr>
|
||||
{:then surveys}
|
||||
<tbody style="cursor: pointer;">
|
||||
{#each surveys as survey, sid (survey.kind + survey.id)}
|
||||
{#if (survey.shown || survey.direct == null || ($user && $user.is_admin)) && (!$user || (!$user.was_admin || $user.promo == survey.promo) || $user.is_admin)}
|
||||
{#if $user && $user.is_admin && (sid == 0 || surveys[sid-1].promo != survey.promo)}
|
||||
<tr class="bg-info text-light">
|
||||
<th colspan="5" class="fw-bold">
|
||||
{survey.promo}
|
||||
</th>
|
||||
</tr>
|
||||
{/if}
|
||||
<tr on:click={e => gotoSurvey(survey)}>
|
||||
<td>
|
||||
{#if !survey.shown}<i class="bi bi-eye-slash-fill" title="Ce questionnaire n'est pas affiché aux étudiants"></i>{/if}
|
||||
{survey.title}
|
||||
{#if survey.group}<span class="badge bg-secondary">{survey.group}</span>{/if}
|
||||
{#if $user && survey.kind === "w" && survey.startAvailability() < Date.now()}
|
||||
<SubmissionStatus work={survey} user={$user} />
|
||||
{/if}
|
||||
<SurveyBadge {survey} class="float-end" />
|
||||
</td>
|
||||
{#if survey.startAvailability() > Date.now()}
|
||||
<td title="Disponible à partir du {survey.start_availability}">
|
||||
<DateFormat date={survey.start_availability} dateStyle="medium" timeStyle="medium" />
|
||||
<i class="bi bi-arrow-bar-right"></i>
|
||||
</td>
|
||||
{:else}
|
||||
<td title="Sera fermé le {survey.start_availability}">
|
||||
<i class="bi bi-arrow-bar-left"></i>
|
||||
<DateFormat date={survey.end_availability} dateStyle="medium" timeStyle="medium" />
|
||||
</td>
|
||||
{/if}
|
||||
{#if $user}
|
||||
{#if !survey.corrected}
|
||||
<td>N/A</td>
|
||||
{:else}
|
||||
<td>
|
||||
{#await getScore(survey)}
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
{:then score}
|
||||
{score.score}
|
||||
{:catch error}
|
||||
<i class="bi text-warning bi-exclamation-triangle-fill" title={error}></i>
|
||||
{/await}
|
||||
</td>
|
||||
{/if}
|
||||
{/if}
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
{/await}
|
||||
{#if $user && $user.is_admin}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<a href="surveys/new" class="btn btn-sm btn-primary">Ajouter un questionnaire</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{/if}
|
||||
</table>
|
||||
115
ui/src/lib/components/SurveyQuestions.svelte
Normal file
115
ui/src/lib/components/SurveyQuestions.svelte
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<script>
|
||||
import { user } from '$lib/stores/user';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
import QuestionForm from '$lib/components/QuestionForm.svelte';
|
||||
import { Question } from '$lib/questions';
|
||||
|
||||
export let survey = null;
|
||||
export let id_user = null;
|
||||
export let questions = [];
|
||||
let newquestions = [];
|
||||
let submitInProgress = false;
|
||||
|
||||
function submitAnswers() {
|
||||
submitInProgress = true;
|
||||
|
||||
const res = [];
|
||||
for (const r in responses) {
|
||||
res.push({
|
||||
"id": responses[r].id,
|
||||
"id_question": responses[r].id_question,
|
||||
"value": String(responses[r].value)
|
||||
})
|
||||
}
|
||||
|
||||
survey.submitAnswers(res, id_user).then((response) => {
|
||||
submitInProgress = false;
|
||||
ToastsStore.addToast({
|
||||
msg: "Vos réponses ont bien étés sauvegardées.",
|
||||
color: "success",
|
||||
title: "Questionnaire",
|
||||
});
|
||||
}, (error) => {
|
||||
submitInProgress = false;
|
||||
ToastsStore.addErrorToast({
|
||||
msg: "Une erreur s'est produite durant l'envoi de vos réponses : " + error + "\nVeuillez réessayer dans quelques instants.",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addQuestion() {
|
||||
const q = new Question();
|
||||
q.id_survey = survey.id;
|
||||
newquestions.push(q);
|
||||
newquestions = newquestions;
|
||||
}
|
||||
|
||||
function deleteQuestion(question, qid) {
|
||||
question.delete().then(() => {
|
||||
questions.splice(qid, 1);
|
||||
questions = questions;
|
||||
})
|
||||
}
|
||||
|
||||
function deleteNewQuestion(question, qid) {
|
||||
if (question.id) {
|
||||
question.delete().then(() => {
|
||||
newquestions.splice(qid, 1);
|
||||
newquestions = newquestions;
|
||||
})
|
||||
} else {
|
||||
newquestions.splice(qid, 1);
|
||||
newquestions = newquestions;
|
||||
}
|
||||
}
|
||||
|
||||
let responses = {};
|
||||
for (const q of questions) {
|
||||
responses[q.id] = {id_question: q.id, value: ""};
|
||||
}
|
||||
survey.retrieveAnswers(id_user).then((response) => {
|
||||
if (response) {
|
||||
for (const res of response.reverse()) {
|
||||
responses[res.id_question] = res;
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<form class="mb-5" on:submit|preventDefault={submitAnswers}>
|
||||
{#each questions as question, qid (question.id)}
|
||||
<QuestionForm
|
||||
{survey}
|
||||
qid={qid}
|
||||
question={question}
|
||||
response_history={responses[question.id]}
|
||||
readonly={survey.isFinished() && !$user.is_admin}
|
||||
on:delete={() => deleteQuestion(question, qid)}
|
||||
bind:value={responses[question.id].value}
|
||||
/>
|
||||
{/each}
|
||||
{#each newquestions as question, qid (qid)}
|
||||
<QuestionForm
|
||||
qid={questions.length + qid}
|
||||
question={question}
|
||||
edit
|
||||
on:delete={() => deleteNewQuestion(question, qid)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
{#if !survey.corrected || $user.is_admin}
|
||||
<button type="submit" class="btn btn-primary" disabled={submitInProgress || (survey.isFinished() && !$user.is_admin)}>
|
||||
{#if submitInProgress}
|
||||
<div class="spinner-border spinner-border-sm me-1" role="status"></div>
|
||||
{/if}
|
||||
Soumettre les réponses
|
||||
</button>
|
||||
{/if}
|
||||
{#if $user && $user.is_admin}
|
||||
<button type="button" class="btn btn-info" on:click={addQuestion}>
|
||||
Ajouter une question
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
18
ui/src/lib/components/Toaster.svelte
Normal file
18
ui/src/lib/components/Toaster.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script>
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
</script>
|
||||
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||||
{#each $ToastsStore.toasts as toast}
|
||||
<div class="toast show" role="alert">
|
||||
<div class="toast-header">
|
||||
<div class="bg-{toast.color} rounded me-2"> </div>
|
||||
<strong>{#if toast.title}{toast.title}{:else}Questionnaire{/if}</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{toast.msg}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
52
ui/src/lib/components/UserKeys.svelte
Normal file
52
ui/src/lib/components/UserKeys.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import { getKeys, getKey, Key } from '$lib/key';
|
||||
|
||||
export let student = null;
|
||||
</script>
|
||||
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Informations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#await getKeys(student.id)}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
Chargement des clefs…
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:then keys}
|
||||
{#if keys && keys.length > 0}
|
||||
{#each keys as keyid}
|
||||
{#await getKey(keyid, student.id)}
|
||||
Veuillez patienter
|
||||
{:then key}
|
||||
<tr>
|
||||
<td>{key.id}</td>
|
||||
<td>{key.type.toUpperCase()}</td>
|
||||
<td>
|
||||
<dl>
|
||||
{#each Object.keys(key.infos) as k}
|
||||
<dt class="float-start me-3 my-0 py-0">{k}</dt>
|
||||
<dd>{#if key.infos[k]}{key.infos[k]}{:else}<span class="fst-italic">-</span>{/if}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</td>
|
||||
</tr>
|
||||
{/await}
|
||||
{/each}
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center fst-italic">Cet utilisateur n'a pas défini de clef</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
61
ui/src/lib/components/UserSurveys.svelte
Normal file
61
ui/src/lib/components/UserSurveys.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import { getSurveys } from '$lib/surveys';
|
||||
import { getUser, getUserGrade, getUserScore } from '$lib/users';
|
||||
|
||||
export let student = null;
|
||||
export let allPromos = false;
|
||||
</script>
|
||||
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Titre</th>
|
||||
<th>Promo</th>
|
||||
<th>Avancement</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#await getSurveys()}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border me-2" role="status"></div>
|
||||
Chargement des questionnaires…
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:then surveys}
|
||||
{#each surveys as survey, sid (survey.id)}
|
||||
{#if allPromos || survey.promo === student.promo}
|
||||
<tr on:click={e => goto(`users/${student.id}/surveys/${survey.id}`)}>
|
||||
<td>{survey.id}</td>
|
||||
<td>{survey.title}</td>
|
||||
<td>{survey.promo}</td>
|
||||
{#await getUserGrade(student.id, survey)}
|
||||
<td>
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</td>
|
||||
{:then gr}
|
||||
<td title="{gr.grades}">
|
||||
{gr.avancement * 100} %
|
||||
</td>
|
||||
{/await}
|
||||
{#await getUserScore(student.id, survey)}
|
||||
<td>
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</td>
|
||||
{:then score}
|
||||
<td>
|
||||
{score.score}{#if score.score >= 0}/20{/if}
|
||||
</td>
|
||||
{/await}
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
</tbody>
|
||||
</table>
|
||||
60
ui/src/lib/components/ValidateSubmissions.svelte
Normal file
60
ui/src/lib/components/ValidateSubmissions.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script>
|
||||
import { user } from '$lib/stores/user';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
const rendus_baseurl = "https://adlin.nemunai.re/rendus/";
|
||||
|
||||
async function getUserRendus() {
|
||||
const res = await fetch(`${rendus_baseurl}${$user.login}.json`)
|
||||
if (res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<table class="table {className}">
|
||||
{#await getUserRendus()}
|
||||
Please wait...
|
||||
{:then rendus}
|
||||
<thead>
|
||||
<tr>
|
||||
{#each Object.keys(rendus) as renduname, rid (rid)}
|
||||
<th>{renduname}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{#each Object.keys(rendus) as renduname, rid (rid)}
|
||||
<th
|
||||
class:bg-danger={!rendus[renduname]}
|
||||
class:text-center={!rendus[renduname]}
|
||||
class:bg-success={rendus[renduname]}
|
||||
>
|
||||
{#if rendus[renduname]}
|
||||
<DateFormat date={rendus[renduname].date} dateStyle="medium" timeStyle="medium" /><br>
|
||||
<span class="hash" title={rendus[renduname].hash}>{rendus[renduname].hash}</span>
|
||||
{:else}
|
||||
–
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</tbody>
|
||||
{/await}
|
||||
</table>
|
||||
|
||||
<style>
|
||||
.hash {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
154
ui/src/lib/components/WorkAdmin.svelte
Normal file
154
ui/src/lib/components/WorkAdmin.svelte
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import DateTimeInput from './DateTimeInput.svelte';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
export let work = null;
|
||||
|
||||
function saveWork() {
|
||||
work.save().then((response) => {
|
||||
dispatch('saved', response);
|
||||
}, (error) => {
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function deleteWork() {
|
||||
work.delete().then((response) => {
|
||||
goto(`works`);
|
||||
}, (error) => {
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function duplicateWork() {
|
||||
work.duplicate().then((response) => {
|
||||
goto(`works/${response.id}`);
|
||||
}).catch((error) => {
|
||||
ToastsStore.addErrorToast({
|
||||
msg: error,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={saveWork}>
|
||||
|
||||
{#if work.id}
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="title" class="col-form-label col-form-label-sm">Identifiant du travail</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control-plaintext form-control-sm" id="title" value={work.id}>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="title" class="col-form-label col-form-label-sm">Titre du travail</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control form-control-sm" id="title" bind:value={work.title}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="promo" class="col-form-label col-form-label-sm">Promo</label>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||
<input type="number" step="1" min="0" max="2068" class="form-control form-control-sm" id="promo" bind:value={work.promo}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="group" class="col-form-label col-form-label-sm">Restreindre au groupe</label>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||
<input class="form-control form-control-sm" id="group" bind:value={work.group}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="tagprefix" class="col-form-label col-form-label-sm">Préfixe des tag à regarder</label>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||
<input class="form-control form-control-sm" id="tagprefix" bind:value={work.tag}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="submissionurl" class="col-form-label col-form-label-sm">URL validation la soumission</label>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-4 col-lg-2">
|
||||
<input class="form-control form-control-sm" id="submissionurl" bind:value={work.submission_url}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="start_availability" class="col-form-label col-form-label-sm">Date de début</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<DateTimeInput class="form-control form-control-sm" id="start_availability" bind:date={work.start_availability} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="end_availability" class="col-form-label col-form-label-sm">Date de fin</label>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<DateTimeInput class="form-control form-control-sm" id="end_availability" bind:date={work.end_availability} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-2">
|
||||
<div class="col-sm-3 text-sm-end">
|
||||
<label for="work_description" class="col-form-label col-form-label-sm">Description</label>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<textarea class="form-control" id="work_description" bind:value={work.descr_raw}></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-3 mx-1 my-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="shown" bind:checked={work.shown}>
|
||||
<label class="form-check-label" for="shown">
|
||||
Afficher le travail
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="corrected" bind:checked={work.corrected}>
|
||||
<label class="form-check-label" for="corrected">
|
||||
Marqué comme corrigé
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-10">
|
||||
<button type="submit" class="btn btn-primary">Enregistrer</button>
|
||||
{#if work.id}
|
||||
<button type="button" class="btn btn-danger" on:click={deleteWork}>Supprimer</button>
|
||||
<button type="button" class="btn btn-secondary" on:click={duplicateWork}>Dupliquer avec ces nouveaux paramètres</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
190
ui/src/lib/components/WorkRepository.svelte
Normal file
190
ui/src/lib/components/WorkRepository.svelte
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
import BuildState from '$lib/components/BuildState.svelte';
|
||||
import DateFormat from '$lib/components/DateFormat.svelte';
|
||||
import { WorkRepository, getRemoteRepositories, getRepositories } from '$lib/repositories';
|
||||
import { ToastsStore } from '$lib/stores/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let className = '';
|
||||
export { className as class };
|
||||
|
||||
export let work = {};
|
||||
export let user = null;
|
||||
export let readonly = false;
|
||||
|
||||
let repo_work = null;
|
||||
let repo_used = null;
|
||||
let remote_repos = [];
|
||||
let repo_pull_state = null;
|
||||
let show_logs = null;
|
||||
export let already_used = false;
|
||||
|
||||
function updatePullState(repo) {
|
||||
repo_pull_state = repo.getBuildState();
|
||||
show_logs = null;
|
||||
repo_pull_state.then((state) => {
|
||||
if (state.status == "pending" || state.status == "running") {
|
||||
setTimeout(() => updatePullState(repo), state.status == "pending" ? 1000 : 3000);
|
||||
} else if (state.status == "success") {
|
||||
dispatch('update_submission');
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showLogs(repo) {
|
||||
show_logs = repo.getBuildLogs()
|
||||
}
|
||||
|
||||
function refresh_repo_work(user) {
|
||||
if (user != null) {
|
||||
repo_work = getRepositories(work.id, user.id);
|
||||
} else {
|
||||
repo_work = getRepositories(work.id);
|
||||
}
|
||||
repo_work.then((repos) => {
|
||||
repo_used = repos[0];
|
||||
already_used = repos[0].already_used == true;
|
||||
updatePullState(repos[0])
|
||||
}, () => {
|
||||
repo_used = new WorkRepository({id_work: work.id});
|
||||
already_used = false;
|
||||
remote_repos = getRemoteRepositories(user?user.id:null);
|
||||
});
|
||||
}
|
||||
$: refresh_repo_work(user);
|
||||
|
||||
let submitInProgress = false;
|
||||
function submitWorkRepository() {
|
||||
submitInProgress = true;
|
||||
repo_used.save(user).then(() => {
|
||||
submitInProgress = false;
|
||||
refresh_repo_work(user);
|
||||
}, (error) => {
|
||||
submitInProgress = false;
|
||||
ToastsStore.addErrorToast({
|
||||
msg: "Une erreur s'est produite durant la création de la liaison au dépôt : " + error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function goRetrieveWork(repo) {
|
||||
submitInProgress = true;
|
||||
repo.retrieveWork().then(() => {
|
||||
submitInProgress = false;
|
||||
refresh_repo_work(user);
|
||||
}, (error) => {
|
||||
submitInProgress = false;
|
||||
ToastsStore.addErrorToast({msg: "Une erreur s'est produite : " + error});
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await repo_work}
|
||||
<div class="{className} text-center">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Chargement de vos préférences de rendu …</span>
|
||||
</div>
|
||||
{:then repos}
|
||||
{#each repos as repo (repo.id)}
|
||||
<div class="{className} card">
|
||||
<div class="card-body d-flex justify-content-between">
|
||||
<div class="d-flex flex-column justify-content-center pe-3">
|
||||
<div>
|
||||
<div class="row">
|
||||
<label for={repo.id + "url"} class="col-sm-4 col-form-label">Dépôt lié :</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control form-control-sm" style="font-family: monospace" disabled id={repo.id + "url"} value={repo.uri}>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 mb-0 pe-1">
|
||||
Vous pouvez ajouter un <span class="fst-italic">webhook</span> sur les <span class="fst-italic"><strong>Tag push events</strong></span> afin d'automatiser la récupération de votre travail. Dans les paramètres de votre dépôt sur GitLab, faite pointer un webhook sur <code>https://lessons.nemunai.re/api/callbacks/trigger.json</code> avec le secret ci-dessous.
|
||||
</p>
|
||||
<div class="row">
|
||||
<label for={repo.id + "secret"} class="col-sm-4 col-form-label">Webhook Secret token :</label>
|
||||
<div class="col-sm-8">
|
||||
<div class="input-group">
|
||||
<input type={repo.show_secret?"text":"password"} class="form-control form-control-sm" disabled id={repo.id + "secret"} value={repo.secret}>
|
||||
<button class="btn btn-sm btn-outline-info" on:click={() => { repo.show_secret = !repo.show_secret}}>
|
||||
<i class="bi" class:bi-eye={!repo.show_secret} class:bi-eye-slash={repo.show_secret}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Dernière récupération : <strong>{#if repo.last_check}<DateFormat date={new Date(repo.last_check)} dateStyle="medium" timeStyle="medium" />{:else}-{/if}</strong>
|
||||
{#if repo_pull_state}
|
||||
<BuildState {repo_pull_state} on:show_logs={() => showLogs(repo)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-column justify-content-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-info float-end mb-1"
|
||||
disable={submitInProgress || readonly}
|
||||
on:click={() => goRetrieveWork(repo)}
|
||||
>
|
||||
Récupérer mon travail
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger float-end mt-1"
|
||||
disable={submitInProgress || readonly}
|
||||
on:click={() => repo.delete().then(() => { refresh_repo_work(user) }, (error) => ToastsStore.addErrorToast({msg: "Une erreur s'est produite durant la suppression du lien : " + error}))}
|
||||
>
|
||||
Supprimer cette liaison
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if show_logs}
|
||||
{#await show_logs then logs}
|
||||
<div class="card-body d-flex justify-content-between bg-dark text-light">
|
||||
<pre class="pb-2">{#each logs as l (l.pos)}{l.out}{/each}</pre>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:catch}
|
||||
{#if !readonly}
|
||||
<p>
|
||||
Voici la liste des dépôts reconnus :
|
||||
</p>
|
||||
<form class="{className} form-floating" on:submit|preventDefault={submitWorkRepository}>
|
||||
{#await remote_repos}
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary mx-3" role="status"></div>
|
||||
<span>Récupération de vos dépôts GitLab …</span>
|
||||
</div>
|
||||
{:then rrepos}
|
||||
<select class="form-select col" disabled={readonly} bind:value={repo_used.uri}>
|
||||
{#each rrepos as r (r.Id)}
|
||||
<option value={r.ssh_url_to_repo}>{r.path_with_namespace}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<label>Dépôt GitLab pour ce travail :</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 btn btn-primary"
|
||||
disable={submitInProgress || readonly || !repo_used || !repo_used.uri}
|
||||
>
|
||||
Utiliser ce dépôt
|
||||
</button>
|
||||
{:catch err}
|
||||
<div class="text-danger">
|
||||
{err.message} Veuillez réessyer dans quelques instants…<br>Si le problème persiste, contactez votre professeur.
|
||||
</div>
|
||||
{/await}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 btn btn-info"
|
||||
title="Rafraîchir la liste des dépôts"
|
||||
on:click={() => remote_repos = getRemoteRepositories(user?user.id:null)}
|
||||
>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{/await}
|
||||
41
ui/src/lib/stores/toasts.js
Normal file
41
ui/src/lib/stores/toasts.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
function createToastsStore() {
|
||||
const { subscribe, set, update } = writable({toasts: []});
|
||||
|
||||
const addToast = (o) => {
|
||||
o.timestamp = new Date();
|
||||
|
||||
o.close = () => {
|
||||
update((i) => {
|
||||
i.toasts = i.toasts.filter((j) => {
|
||||
return !(j.title === o.title && j.msg === o.msg && j.timestamp === o.timestamp)
|
||||
});
|
||||
return i;
|
||||
});
|
||||
}
|
||||
|
||||
update((i) => {
|
||||
i.toasts.unshift(o);
|
||||
return i;
|
||||
});
|
||||
|
||||
o.cancel = setTimeout(o.close, o.dismiss?o.dismiss:5000);
|
||||
};
|
||||
|
||||
const addErrorToast = (o) => {
|
||||
if (!o.title) o.title = 'Une erreur est survenue !';
|
||||
if (!o.color) o.color = 'danger';
|
||||
|
||||
return addToast(o);
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
addToast,
|
||||
addErrorToast,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
export const ToastsStore = createToastsStore();
|
||||
27
ui/src/lib/stores/user.js
Normal file
27
ui/src/lib/stores/user.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
function createUserStore() {
|
||||
const { subscribe, set, update } = writable(undefined);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (auth) => {
|
||||
update((m) => auth);
|
||||
},
|
||||
update: (res_auth, cb=null) => {
|
||||
if (res_auth.status === 200) {
|
||||
res_auth.json().then((auth) => {
|
||||
update((m) => (Object.assign(m?m:{}, auth)));
|
||||
|
||||
if (cb) {
|
||||
cb(my);
|
||||
}
|
||||
});
|
||||
} else if (res_auth.status >= 400 && res_auth.status < 500) {
|
||||
update((m) => (null));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const user = createUserStore();
|
||||
Reference in a new issue