Move new ui to ui directory

This commit is contained in:
nemunaire 2022-03-01 13:43:45 +01:00
parent bb72c351ac
commit f85758ef33
56 changed files with 0 additions and 0 deletions

18
ui/src/app.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<base href="/">
<link href="css/bootstrap.min.css" type="text/css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
<script async defer data-website-id="7f459249-ea9f-43a6-a0d9-60bd9fa86c16" src="https://pythagore.p0m.fr/pythagore.js"></script>
%svelte.head%
</head>
<body>
<div style="position: fixed; bottom: 20px; right: 20px; z-index: -1; background-image: url('img/srstamps.png'); background-size: cover; width: 125px; height: 125px;">
</div>
<div id="svelte">%svelte.body%</div>
</body>
</html>

View 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&hellip;</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}

View file

@ -0,0 +1,112 @@
<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 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}&nbsp;%
{: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>
</div>

View file

@ -0,0 +1,124 @@
<script>
import { user } from '../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}&nbsp;%
</strong>
<div class="col">
{my_correction.score_explaination}
</div>
</div>
{/if}

View file

@ -0,0 +1,130 @@
<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 hilights = "";
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) && (!filter || r.value.match(filter)));
}
export async function applyCorrections() {
for (const r of filteredResponses) {
const my_correction = { };
for (const tpl of templates) {
if (!tpl.regexp) continue;
if (r.value.match(tpl.regexp)) {
my_correction[tpl.id] = true;
} else {
my_correction[tpl.id] = false;
}
}
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&hellip;</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}

View 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)}

View file

@ -0,0 +1,170 @@
<script>
import { createEventDispatcher } from 'svelte';
import QuestionHeader from './QuestionHeader.svelte';
import QuestionProposals from './QuestionProposals.svelte';
import ResponseCorrected from './ResponseCorrected.svelte';
import { user } from '../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 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 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 class="btn btn-sm btn-success ms-1 float-end" on:click={saveQuestion}>
<i class="bi bi-check"></i>
</button>
{:else}
<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>
<div class="card-body">
{#if false && response_history}
<div class="d-flex justify-content-end mb-2">
<div class="col-auto">
Historique&nbsp;:
<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 == 'text' || question.kind == 'int'}
<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}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des choix &hellip;</span>
</div>
{:then proposals}
<QuestionProposals
edit
id_question={question.id}
kind={question.kind}
{proposals}
readonly
bind:value={value}
on:change={() => { dispatch("change"); }}
/>
{/await}
{/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 &hellip;</span>
</div>
{:then proposals}
<QuestionProposals
kind={question.kind}
{proposals}
{readonly}
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}
<textarea
class="form-control"
rows="6"
bind:value={value}
placeholder={question.placeholder}
on:change={() => { dispatch("change"); }}
></textarea>
{/if}
{#if survey && survey.corrected}
<ResponseCorrected
response={response_history}
{survey}
/>
{/if}
{#if false}
<div ng-controller="ProposalsController" ng-if="question.kind == 'ucq' || question.kind == 'mcq'">
<div class="form-group form-check" ng-if="!question.edit && question.kind == 'mcq'" ng-repeat="proposal in proposals">
<input type="checkbox" class="form-check-input" id="p{proposal.id}" ng-model="question['p' + proposal.id]" disabled={readonly}>
<label class="form-check-label" for="p{proposal.id}">{proposal.label}</label>
</div>
<div class="form-group form-check" ng-if="!question.edit && question.kind == 'ucq'" ng-repeat="proposal in proposals">
<input type="radio" class="form-check-input" name="proposals{question.id}" id="p{proposal.id}" ng-model="question.value" value="{proposal.id}" disabled={survey.readonly}>
<label class="form-check-label" for="p{proposal.id}">{proposal.label}</label>
</div>
<div class="form-group row" ng-if="question.edit" ng-repeat="proposal in proposals">
<div class="col">
<input type="text" class="form-control" id="pi{proposal.id}" placeholder="Label" ng-model="proposal.label">
</div>
<div class="col-auto">
<button type="button" class="btn btn-success ml-1" ng-click="saveProposal()" ng-if="question.edit && (question.kind == 'ucq' || question.kind == 'mcq')"><i class="bi bi-check" ></i></button>
<button type="button" class="btn btn-danger ml-1" ng-click="deleteProposal()" ng-if="question.edit && (question.kind == 'ucq' || question.kind == 'mcq')"><i class="bi bi-trash-fill"></i></button>
</div>
</div>
<button type="button" class="btn btn-info ml-1" ng-click="addProposal()" ng-if="question.edit && (question.kind == 'ucq' || question.kind == 'mcq')" ng-disabled="!question.id"><i class="bi bi-plus"></i> Ajouter des proposals
</button><span ng-show="question.edit && (question.kind == 'ucq' || question.kind == 'mcq') && !question.id" class="ml-2" style="font-style:italic"> Créez la question pour ajouter des propositions</span>
</div>
<div class="ml-3 card-text alert alert-success" ng-if="!question.edit && (question.response.score_explaination || question.response.score)">
<div class="row">
<div class="col-auto">
<strong>{question.response.score}&nbsp;%</strong>
</div>
<p class="col mb-0" style="white-space: pre-line">{question.response.score_explaination}</p>
</div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,44 @@
<script>
import { createEventDispatcher } from 'svelte';
import { user } from '../stores/user';
const dispatch = createEventDispatcher();
let className = '';
export { className as class };
export let question = null;
export let qid = null;
export let edit = 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&nbsp;:</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="int">Entier</option>
<option value="ucq">QCU</option>
<option value="mcq">QCM</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}
<p class="card-text mt-2">{@html question.description}</p>
{/if}
</div>

View file

@ -0,0 +1,118 @@
<script>
import { createEventDispatcher } from 'svelte';
import { QuestionProposal } from '../lib/questions';
export let edit = false;
export let proposals = [];
export let kind = 'mcq';
export let prefixid = '';
export let readonly = false;
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>
{#each proposals as proposal, pid (proposal.id)}
<div class="form-check">
{#if kind == 'mcq'}
<input
type="checkbox"
class="form-check-input"
disabled={readonly}
name={prefixid + 'proposal' + proposal.id_question}
id={prefixid + 'p' + proposal.id}
bind:group={valueCheck}
value={proposal.id.toString()}
on:change={() => { value = valueCheck.join(','); dispatch("change"); }}
>
{:else}
<input
type="radio"
class="form-check-input"
disabled={readonly}
name={prefixid + 'proposal' + proposal.id_question}
id={prefixid + 'p' + proposal.id}
bind:group={value}
value={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"
for={prefixid + 'p' + proposal.id}
>
{proposal.label}
</label>
{/if}
</div>
{/each}
{#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"
on:click={addProposal}
>
ajouter
</button>
{/if}

View file

@ -0,0 +1,52 @@
<script>
export let response = null;
export let survey = null;
</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}&nbsp;%, ce qui correspond à {Math.trunc(response.score*10/5)/10}/20."
>
{response.score}&nbsp;%
</strong>
</div>
<div class="col">
{#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&nbsp;!</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é&nbsp;?
</div>
</div>
{/if}
{/if}

View 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&hellip;
</div>
{:then surveys}
{#await getGrades()}
<div class="d-flex justify-content-center">
<div class="spinner-border me-2" role="status"></div>
Chargement des notes&hellip;
</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&hellip;
</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][survey.id]?grades[user.id][survey.id]:"N/A"}</td>
{/if}
{/each}
</tr>
{/if}
{/each}
{/await}
</tbody>
</table>
</div>
{/await}
{/await}

View file

@ -0,0 +1,134 @@
<script>
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import { getQuestions } from '../lib/questions';
import { ToastsStore } from '../stores/toasts';
const dispatch = createEventDispatcher();
export let survey = null;
function saveSurvey() {
survey.save().then((response) => {
dispatch('saved');
}, (error) => {
ToastsStore.addErrorToast({
msg: error.errmsg,
});
})
}
function deleteSurvey() {
survey.delete().then((response) => {
goto(`surveys`);
}, (error) => {
ToastsStore.addErrorToast({
msg: error.errmsg,
});
})
}
function duplicateSurvey() {
survey.duplicate().then((response) => {
goto(`surveys/${response.id}`);
}).catch((error) => {
ToastsStore.addErrorToast({
msg: error.errmsg,
});
})
}
</script>
<form on:submit|preventDefault={saveSurvey}>
<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">{survey.id}
<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>
<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>
<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">
<input type="text" class="form-control form-control-sm" id="start_availability" bind:value={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">
<input type="text" class="form-control form-control-sm" id="end_availability" bind:value={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}>Supprimer</button>
<button type="button" class="btn btn-secondary" on:click={duplicateSurvey}>Dupliquer avec ces nouveaux paramètres</button>
{/if}
</div>
</div>
</form>
<hr>

View file

@ -0,0 +1,12 @@
<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.corrected}<span class="badge bg-primary text-light {className}">Terminé</span>
{:else}<span class="badge bg-success {className}">Corrigé</span>
{/if}

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '../stores/user';
import DateFormat from '../components/DateFormat.svelte';
import SurveyBadge from '../components/SurveyBadge.svelte';
import { getSurveys } from '../lib/surveys';
import { getScore } from '../lib/users';
let req_surveys = getSurveys();
export let direct = null;
req_surveys.then((surveys) => {
for (const survey of surveys) {
if (survey.direct != null) {
direct = survey;
}
}
});
</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 &hellip;</span>
</td>
</tr>
{:then surveys}
<tbody style="cursor: pointer;">
{#each surveys as survey, sid (survey.id)}
{#if (survey.shown || survey.direct != null) && (!$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 => goto(survey.direct != null ?`surveys/${survey.id}/live`:$user.is_admin?`surveys/${survey.id}/responses`:`surveys/${survey.id}`)}>
<td>
{survey.title}
<SurveyBadge {survey} class="float-end" />
</td>
{#if survey.start_availability > Date.now()}
<td>
<DateFormat date={survey.start_availability} dateStyle="medium" timeStyle="medium" />
<svg class="bi bi-arrow-bar-right" width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.146 6.646a.5.5 0 01.708 0l3 3a.5.5 0 010 .708l-3 3a.5.5 0 01-.708-.708L14.793 10l-2.647-2.646a.5.5 0 010-.708z" clip-rule="evenodd"></path><path fill-rule="evenodd" d="M8 10a.5.5 0 01.5-.5H15a.5.5 0 010 1H8.5A.5.5 0 018 10zm-2.5 6a.5.5 0 01-.5-.5v-11a.5.5 0 011 0v11a.5.5 0 01-.5.5z" clip-rule="evenodd"></path></svg>
</td>
{:else}
<td>
<svg class="bi bi-arrow-bar-left" width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.854 6.646a.5.5 0 00-.708 0l-3 3a.5.5 0 000 .708l3 3a.5.5 0 00.708-.708L5.207 10l2.647-2.646a.5.5 0 000-.708z" clip-rule="evenodd"></path><path fill-rule="evenodd" d="M12 10a.5.5 0 00-.5-.5H5a.5.5 0 000 1h6.5a.5.5 0 00.5-.5zm2.5 6a.5.5 0 01-.5-.5v-11a.5.5 0 011 0v11a.5.5 0 01-.5.5z" clip-rule="evenodd"></path></svg>
<DateFormat date={survey.end_availability} dateStyle="medium" timeStyle="medium" />
</td>
{/if}
{#if !$user}
{:else 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}
{/await}
</td>
{/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>

View file

@ -0,0 +1,111 @@
<script>
import { user } from '../stores/user';
import { ToastsStore } from '../stores/toasts';
import QuestionForm from '../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_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>

View file

@ -0,0 +1,18 @@
<script>
import { ToastsStore } from '../stores/toasts';
</script>
<div class="toast-container position-absolute 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">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</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>

View 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&hellip;
</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}&nbsp;%
</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>

View file

@ -0,0 +1,60 @@
<script>
import { user } from '../stores/user';
import DateFormat from '../components/DateFormat.svelte';
let className = '';
export { className as class };
const rendus_baseurl = "https://virli.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}
&ndash;
{/if}
</th>
{/each}
</tr>
</tbody>
{/await}
</table>
<style>
.hash {
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
</style>

1
ui/src/global.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="@sveltejs/kit" />

6
ui/src/hooks.js Normal file
View file

@ -0,0 +1,6 @@
export async function handle({ event, resolve }) {
const response = await resolve(event, {
ssr: false,
});
return response;
}

View file

@ -0,0 +1,88 @@
export class CorrectionTemplate {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, id_question, label, regexp, score, score_explaination }) {
this.id = id;
this.id_question = id_question;
this.regexp = regexp;
this.label = label;
this.score = score;
this.score_explaination = score_explaination;
}
async getCorrections() {
if (this.id) {
const res = await fetch(`api/questions/${this.id_question}/corrections/${this.id}`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
}
async save() {
const res = await fetch(this.id?`api/questions/${this.id_question}/corrections/${this.id}`:`api/questions/${this.id_question}/corrections`, {
method: this.id?'PUT':'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json();
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
async delete() {
if (this.id) {
const res = await fetch(`api/questions/${this.id_question}/corrections/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
} else {
return true;
}
}
}
export async function getCorrectionTemplates(qid) {
const res = await fetch(`api/questions/${qid}/corrections`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
const data = await res.json();
if (data === null) {
return [];
} else {
return (data).map((c) => new CorrectionTemplate(c))
}
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function autoCorrection(id_user, my_tpls) {
const res = await fetch(`api/users/${id_user}/corrections`, {
method: 'PUT',
headers: {'Accept': 'application/json'},
body: JSON.stringify(my_tpls),
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}

147
ui/src/lib/questions.js Normal file
View file

@ -0,0 +1,147 @@
import { Response } from './response';
export class QuestionProposal {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, id_question, label }) {
this.id = id;
this.id_question = id_question;
this.label = label;
if (this.changed !== undefined)
delete this.changed;
}
async save() {
const res = await fetch(this.id?`api/questions/${this.id_question}/proposals/${this.id}`:`api/questions/${this.id_question}/proposals`, {
method: this.id?'PUT':'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json();
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
async delete() {
if (this.id) {
const res = await fetch(`api/questions/${this.id_question}/proposals/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
}
export class Question {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, id_survey, title, description, desc_raw, placeholder, kind }) {
this.id = id;
this.id_survey = id_survey;
this.title = title;
this.description = description;
this.desc_raw = desc_raw;
this.placeholder = placeholder;
this.kind = kind;
}
async getProposals() {
const res = await fetch(`api/questions/${this.id}/proposals`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return (await res.json()).map((p) => new QuestionProposal(p))
} else {
throw new Error((await res.json()).errmsg);
}
}
async getMyResponse() {
const res = await fetch(`api/questions/${this.id}/response`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return new Response(await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}
async getResponses() {
const res = await fetch(`api/surveys/${this.id_survey}/questions/${this.id}/responses`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return (await res.json()).map((r) => new Response(r))
} else {
throw new Error((await res.json()).errmsg);
}
}
async save() {
const res = await fetch(this.id?`api/questions/${this.id}`:`api/surveys/${this.id_survey}/questions`, {
method: this.id?'PUT':'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json();
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
async delete() {
if (this.id) {
const res = await fetch(`api/questions/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
}
export async function getQuestion(qid) {
const res = await fetch(`api/questions/${qid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Question(await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getQuestions(sid) {
const res = await fetch(`api/surveys/${sid}/questions`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return (await res.json()).map((e) => new Question(e))
} else {
throw new Error((await res.json()).errmsg);
}
}

34
ui/src/lib/response.js Normal file
View file

@ -0,0 +1,34 @@
export class Response {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, id_question, id_user, value, time_submit, score, score_explaination, id_corrector, time_scored }) {
this.id = id;
this.id_question = id_question;
this.id_user = id_user;
this.value = value;
this.time_submit = time_submit;
this.score = score;
this.score_explaination = score_explaination;
this.id_corrector = id_corrector;
this.time_scored = time_scored;
}
async save() {
const res = await fetch(`api/questions/${this.id_question}/responses/${this.id}`, {
method: 'PUT',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json();
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
}

145
ui/src/lib/surveys.js Normal file
View file

@ -0,0 +1,145 @@
import { getQuestions } from './questions';
class Survey {
constructor(res) {
if (res) {
this.update(res);
}
}
update({ id, title, promo, group, shown, direct, corrected, start_availability, end_availability }) {
this.id = id;
this.title = title;
this.promo = promo;
this.group = group;
this.shown = shown;
this.direct = direct;
this.corrected = corrected;
if (this.start_availability != start_availability) {
this.start_availability = start_availability;
delete this.__start_availability;
}
if (this.end_availability != end_availability) {
this.end_availability = end_availability;
delete this.__end_availability;
}
}
startAvailability() {
if (!this.__start_availability) {
this.__start_availability = new Date(this.start_availability)
}
return this.__start_availability
}
endAvailability() {
if (!this.__end_availability) {
this.__end_availability = new Date(this.end_availability)
}
return this.__end_availability
}
isFinished() {
return this.endAvailability() < new Date();
}
async retrieveAnswers(id_user=null) {
const res = await fetch(id_user?`api/users/${id_user}/surveys/${this.id}/responses`:`api/surveys/${this.id}/responses`, {
method: 'GET',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async submitAnswers(answer, id_user=null) {
const res = await fetch(id_user?`api/users/${id_user}/surveys/${this.id}`:`api/surveys/${this.id}`, {
method: 'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(answer),
});
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
async save() {
const res = await fetch(this.id?`api/surveys/${this.id}`:'api/surveys', {
method: this.id?'PUT':'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const data = await res.json()
this.update(data);
return data;
} else {
throw new Error((await res.json()).errmsg);
}
}
async duplicate() {
if (this.id) {
const oldSurveyId = this.id;
delete this.id;
const res = await fetch(`api/surveys`, {
method: 'POST',
headers: {'Accept': 'application/json'},
body: JSON.stringify(this),
});
if (res.status == 200) {
const response = await res.json();
// Now recopy questions
const questions = await getQuestions(oldSurveyId);
for (const q of questions) {
delete q.id;
q.id_survey = response.id;
q.save();
}
return response;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
async delete() {
if (this.id) {
const res = await fetch(`api/surveys/${this.id}`, {
method: 'DELETE',
headers: {'Accept': 'application/json'},
});
if (res.status == 200) {
return true;
} else {
throw new Error((await res.json()).errmsg);
}
}
}
}
export async function getSurveys() {
const res = await fetch(`api/surveys`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return (await res.json()).map((s) => new Survey(s));
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getSurvey(sid) {
const res = await fetch(`api/surveys/${sid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return new Survey(await res.json());
} else {
throw new Error((await res.json()).errmsg);
}
}

61
ui/src/lib/users.js Normal file
View file

@ -0,0 +1,61 @@
export async function getPromos() {
const res = await fetch('api/promos', {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getUsers() {
const res = await fetch('api/users', {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getUser(uid) {
const res = await fetch(`api/users/${uid}`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getGrades(uid, survey) {
const res = await fetch(`api/grades`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getUserGrade(uid, survey) {
const res = await fetch(`api/users/${uid}/surveys/${survey.id}/grades`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getUserScore(uid, survey) {
const res = await fetch(`api/users/${uid}/surveys/${survey.id}/score`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}
export async function getScore(survey) {
const res = await fetch(`api/surveys/${survey.id}/score`, {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
return await res.json();
} else {
throw new Error((await res.json()).errmsg);
}
}

View file

@ -0,0 +1,146 @@
<script context="module">
import { user } from '../stores/user';
let stop_refresh = false;
let refresh_interval_auth = null;
async function refresh_auth(cb=null, interval=null) {
if (refresh_interval_auth)
clearInterval(refresh_interval_auth);
if (interval === null) {
interval = Math.floor(Math.random() * 200000) + 200000;
}
if (stop_refresh) {
return;
}
refresh_interval_auth = setInterval(refresh_auth, interval);
const res = await fetch('api/auth', {headers: {'Accept': 'application/json'}})
if (res.status == 200) {
const auth = await res.json();
user.set(auth);
} else {
user.set(null);
}
}
export async function load({ props, stuff, url }) {
refresh_auth();
const rroutes = url.pathname.split('/');
return {
props: {
...props,
rroute: rroutes.length>1?rroutes[1]:'',
},
stuff: {
...stuff,
refresh_auth,
}
};
}
</script>
<script>
import Toaster from '../components/Toaster.svelte';
export let rroute = '';
function switchAdminMode() {
var tmp = $user.is_admin;
$user.is_admin = $user.was_admin || false;
$user.was_admin = tmp;
}
function disconnectCurrentUser() {
fetch('api/auth/logout', {
method: 'POST'
}).then((response) => {
refresh_auth();
});
}
</script>
<svelte:head>
<title>SRS: MCQ and others courses related stuff</title>
</svelte:head>
<nav class="navbar navbar-expand-sm navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href=".">
SRS
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#adminMenu" aria-controls="adminMenu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="loggedMenu">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="adlin" target="_self">AdLin</a>
</li>
<li class="nav-item">
<a class="nav-link" href="fic" target="_self">FIC</a>
</li>
<li class="nav-item">
<a class="nav-link" class:active={rroute === 'surveys'} href="surveys">
Questionnaires
</a>
</li>
{#if $user && $user.is_admin}
<li class="nav-item"><a class="nav-link" class:active={rroute === 'users'} href="users">Étudiants</a></li>
{/if}
<li class="nav-item"><a class="nav-link" href="virli" target="_self">VIRLI</a></li>
</ul>
<ul class="navbar-nav ms-auto">
{#if $user}
{#if $user.was_admin}
<li class="nav-item">
<button class="btn btn-dark" on:click={switchAdminMode}>
Vue admin
</button>
</li>
{:else if $user.is_admin}
<li class="nav-item">
<button class="btn btn-light" on:click={switchAdminMode}>
Vue étudiant
</button>
</li>
{/if}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">
<img class="rounded-circle" src="//photos.cri.epita.fr/square/{$user.login}" alt="Menu" style="margin: -0.75em 0; max-height: 2.5em">
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" class:active={rroute === 'help'} href="help">Besoin d'aide&nbsp;?</a></li>
<li><a class="dropdown-item" class:active={rroute === 'bug-bounty'} href="bug-bounty">Bug Bounty</a></li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" on:click={disconnectCurrentUser}>
Se déconnecter
</button>
</li>
</ul>
</li>
{:else if $user === undefined}
<li class="nav-item">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</li>
{:else}
<li class="nav-item">
<a href="auth/CRI" target="_self" class="btn btn-dark">
Se connecter
</a>
</li>
{/if}
</ul>
</div>
</div>
</nav>
<div class="container mt-3">
<slot></slot>
</div>
<Toaster />

71
ui/src/routes/auth.svelte Normal file
View file

@ -0,0 +1,71 @@
<script context="module">
import { session } from '$app/stores';
export function load() {
if (session.id) {
return { redirect: '/', status: 302 };
}
return { };
}
</script>
<script>
import { goto } from '$app/navigation';
let auth = { username: "", password: "" };
let pleaseWait = false;
function logmein() {
pleaseWait = true;
fetch('api/auth', {
method: 'POST',
body: JSON.stringify(auth),
})
.then((response) => {
response.json().then((auth) => {
pleaseWait = false;
$session = auth;
goto(".");
})
})
.catch((response) => {
pleaseWait = false;
if (response.data)
addToast({
variant: "danger",
title: "Connexion impossible",
msg: (response.data ? response.data.errmsg : "Impossible de contacter le serveur"),
});
});
}
</script>
<div class="row">
<form class="col" on:submit|preventDefault={logmein}>
<h2>Accès à votre compte</h2>
<div class="mb-3">
<label for="login" class="form-label">CRI login</label>
<input class="form-control" id="login" bind:value={auth.username} placeholder="Entrer votre login" autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Mot de passe</label>
<input type="password" class="form-control" id="password" bind:value={auth.password} placeholder="Mot de passe">
</div>
<button type="submit" class="btn btn-info">
{#if pleaseWait}
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{/if}
Me connecter
</button>
</form>
<div class="col">
<h2>OpenId Connect</h2>
<div class="text-center">
<a href="auth/CRI" class="btn btn-primary" target="_self">
Me connecter avec mon compte CRI
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,89 @@
<h2>Bug Bounty</h2>
<p class="lead">
Comme tous les services accessibles en ligne, ce site présente un certain nombre de bugs et de vulnérabilités qui ne font pas partie des fonctionnalités attendues.
</p>
<p>
Par exception aux règles qui vous ont été données, vous êtes autorisés à rechercher des vulnérabilités sur tous les services (et leurs infrastructures afférentes) qui vous sont mis à disposition sur <code>nemunai.re</code>, selon les conditions suivantes :
</p>
<ul>
<li><strong>vous ne devez pas <ins>entraver volontairement</ins> la progression de vos camarades ou le fonctionnement d'une partie de l'infrastructure ;</strong></li>
<li><strong>vous devez maîtriser les outils que vous utiliser&nbsp;:</strong> certains outils mal maîtrisés peuvent bombarder de requêtes un service au point de le faire tomber. Les services mis à votre disposition ne constituent pas une plateforme d'entraînement à l'utilisation de ces outils, vous avez des machines virtuelles pour cela ;</li>
<li><strong>vous devez rapporter rapidement à <a href="mailto:bounty@nemunai.re">bounty@nemunai.re</a> tous les bugs ou vulnérabilités que vous découvrez.</strong></li>
</ul>
<p>
En plus du maintien des bénéfices que vous avez éventuellement pu obtenir (par exemple changer une note), vous obtiendrez également un bonus sur votre note finale.
À titre indicatif, voici ce à quoi vous pouvez vous attendre&nbsp;:
</p>
<div class="card mb-2">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th>Exemples de vulnérabilités</th>
<th>Points</th>
</tr>
</thead>
<tbody>
<tr>
<td>Faute d'orthographe/grammaire, non conformité, bug, ...</td>
<td>crédité dans le commit</td>
</tr>
<tr>
<td>XSS, CSRF, injection SQL/LDAP, ...</td>
<td>1 point</td>
</tr>
<tr>
<td>Violation de permission, fuite d'informations, ...</td>
<td>2 points</td>
</tr>
<tr>
<td>Exécution arbitraire de code</td>
<td>3 points</td>
</tr>
<tr>
<td>Exécution arbitraire de code sur l'hôte</td>
<td>5 points</td>
</tr>
</tbody>
</table>
</div>
<p>
Lorsque vous découvrez une vulnérabilité en groupe, précisez les noms et le rôle que chacun a eu dans la découverte.
</p>
<div class="alert alert-warning d-flex">
<i class="bi bi-exclamation-triangle me-3"></i>
<span>
À toute fin utile, l'usage et la non-divulgation d'une vulnérabilité sont <a href="https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006149839/" target="_blank">sanctionnables</a>.
</span>
</div>
<h3 class="mt-5 mb-3">Hall of Fame</h3>
<div class="card mb-3">
<div class="card-header">
Il était toujours possible de répondre aux questionnaires après l'heure de clôture.
<span class="badge bg-success shadow-lg">+2&nbsp;pts</span>
</div>
<div class="card-body">
<div class="row row-cols-6">
<img class="img-thumbnail" src="//photos.cri.epita.fr/mahe.charpy" alt="mahe.charpy">
<img class="img-thumbnail" src="//photos.cri.epita.fr/albin.parou" alt="albin.parou">
<img class="img-thumbnail" src="//photos.cri.epita.fr/sebastien.januszczak" alt="sebastien.januszczak">
<img class="img-thumbnail" src="//photos.cri.epita.fr/clement.lanata" alt="clement.lanata">
<img class="img-thumbnail" src="//photos.cri.epita.fr/alexandre.delorme" alt="alexandre.delorme">
<img class="img-thumbnail" src="//photos.cri.epita.fr/justin.puchelle" alt="justin.puchelle">
</div>
<p class="card-text mt-3">
Divulguée et corrigée le 19 novembre 2021.
<a href="https://git.nemunai.re/srs/atsebay.t/commit/5c53d2eaea9e7233bc8a08de2f40c040c0700c3e" target="_blank">Commit</a>
</p>
</div>
</div>
<div class="mb-5"></div>

View file

@ -0,0 +1,17 @@
<script context="module">
export async function load({ params }) {
return {
props: {
promo: params.promo,
}
};
}
</script>
<script>
import StudentGrades from '../../components/StudentGrades.svelte';
export let promo;
</script>
<StudentGrades {promo} />

View file

@ -0,0 +1,5 @@
<script>
import StudentGrades from '../../components/StudentGrades.svelte';
</script>
<StudentGrades />

40
ui/src/routes/help.svelte Normal file
View file

@ -0,0 +1,40 @@
<script>
function needhelp() {
}
</script>
<h2>Besoin d'aide&nbsp;?</h2>
<p class="lead">
Vous êtes nombreux et l'on n'est malheureusement pas en mesure de vous suivre régulièrement individuellement.
Nous restons néanmoins toujours disponibles lorsque vous avez besoin de notre aide.
</p>
<p>
D'une manière générale, si vous avez des problèmes, n'hésitez pas à contacter le professeur, que ce soit en cours ou par mail.
Il vaut mieux mettre des mots soi-même sur un problème que l'on rencontre plutôt que d'attendre le dernier moment, en se disant qu'on aura le temps de trouver une solution.
</p>
<p>
Peut-être que tu as raté&middot;e plusieurs rendus, ou peut-être que tu ne te sens plus le courage de continuer les projets.
</p>
<p>
Si tu souhaites me parler d'une situation qui t'a troublé&middot;e, d'un problème que tu rencontres ou me faire une remarque,
n'hésite pas à venir me voir lors d'un cours, par exemple à la pause ou à la fin&nbsp;;
je suis aussi joignable <a href="mailto:nemunaire@nemunai.re">par e-mail</a> ou bien <a href="https://matrix.to/#/@nemunaire:nemunai.re">sur Matrix</a> ou Teams.
</p>
<p class="mt-4">
Si tu souhaites juste avoir un peu plus d'attention, soit parce que tu te sens à l'écart, en difficulté ou autre&nbsp;:
<button
type="button"
class="btn btn-sm btn-primary"
on:click={needhelp}
>
Clique ce bouton
</button>
</p>
<div class="mb-5"></div>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { user } from '../stores/user';
import SurveyList from '../components/SurveyList.svelte';
import ValidateSubmissions from '../components/ValidateSubmissions.svelte';
let direct = null;
</script>
<div class="card bg-light">
<div class="card-body">
{#if $user}
<div class="row">
<div class="col-md">
<h1 class="card-text">
Bienvenue {$user.firstname}&nbsp;!
</h1>
<hr class="my-4">
{#if $user.promo != $user.current_promo}
<div class="alert alert-primary" role="alert">
<strong>Es-tu un {$user.current_promo}&nbsp;?</strong> Tu es actuellement enregistré comme un {$user.promo}, ce qui ne te permet pas d'accéder aux questionnaires de la promo {$user.current_promo}. <a href="mailto:nemunaire@nemunai.re?subject=Mauvaise promotion sur srs.nemunai.re&body=Bonjour, Je ne suis pas enregistré dans la bonne promotion sur le site srs.nemunai.re. Cordialement,">Contacte-moi</a> pour corriger cela.
</div>
{/if}
{#if direct}
<div class="alert alert-warning" role="alert">
<strong>Rejoins le cours maintenant&nbsp;!</strong> Il y a actuellement un questionnaire en direct&nbsp;: {direct.title}. <a href="surveys/{direct.id}/live">Clique ici pour le rejoindre</a>.
</div>
{/if}
<p class="lead">Tu as fait les rendus suivants&nbsp;:</p>
</div>
<div class="d-none d-md-block col-md-auto">
<img class="img-thumbnail" src="https://photos.cri.epita.fr/thumb/{$user.login}" alt="avatar {$user.login}" style="max-height: 150px">
</div>
</div>
<ValidateSubmissions />
<p class="lead">Voici la liste des questionnaires&nbsp;:</p>
{:else}
<p class="card-text lead">
Vous voici arrivés sur le site dédié aux cours d'<a href="https://adlin.nemunai.re/">Administration Linux avancée</a>, du <a href="https://srs.nemunai.re/fic/">FIC</a> et de <a href="https://virli.nemunai.re/">Virtualisation légère</a>.
</p>
<p class="card-text">
Vous devez <a href="auth/CRI" target="_self">vous identifier</a> pour accéder au contenu.
</p>
{/if}
<SurveyList bind:direct={direct} />
</div>
</div>

View file

@ -0,0 +1,39 @@
<script context="module">
import { getSurvey } from '../../../lib/surveys';
export async function load({ params, stuff }) {
const survey = getSurvey(params.sid);
return {
props: {
survey,
},
stuff: {
...stuff,
survey,
}
};
}
</script>
<script lang="ts">
export let survey;
</script>
{#await survey}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement du questionnaire &hellip;</span>
</div>
{:then}
<slot></slot>
{:catch error}
<div class="text-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
Questionnaire introuvable
</h2>
<span>{error}</span>
</div>
{/await}

View file

@ -0,0 +1,370 @@
<script context="module">
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
sid: params.sid,
},
};
}
</script>
<script>
import { user } from '../../../stores/user';
import SurveyAdmin from '../../../components/SurveyAdmin.svelte';
import SurveyBadge from '../../../components/SurveyBadge.svelte';
import { getQuestions } from '../../../lib/questions';
import { getUsers } from '../../../lib/users';
export let surveyP;
export let sid;
let survey;
let req_questions;
surveyP.then((s) => {
survey = s;
updateQuestions();
if (survey.direct !== null) {
wsconnect();
}
});
function updateQuestions() {
req_questions = getQuestions(survey.id);
}
let ws = null;
let ws_up = false;
let wsstats = null;
let current_question = null;
let responses = {};
let users = {};
function updateUsers() {
getUsers().then((usr) => {
const tmp = { };
for (const u of usr) {
tmp[u.id.toString()] = u;
}
users = tmp;
});
}
updateUsers();
let responsesbyid = { };
$: {
const tmp = { };
for (const response in responses) {
if (!tmp[response]) tmp[response] = [];
for (const r in responses[response]) {
tmp[response].push(responses[response][r]);
}
}
responsesbyid = tmp;
}
function wsconnect() {
if (ws !== null) return;
ws = new WebSocket((window.location.protocol == 'https'?'wss://':'ws://') + window.location.host + `/api/surveys/${sid}/ws-admin`);
ws.addEventListener("open", () => {
ws_up = true;
ws.send('{"action":"get_responses"}');
ws.send('{"action":"get_stats"}');
});
ws.addEventListener("close", (e) => {
ws_up = false;
console.log('Socket is closed. Reconnect will be attempted in 1 second.', e.reason);
ws = null;
setTimeout(function() {
wsconnect();
}, 1500);
});
ws.addEventListener("error", (err) => {
ws_up = false;
console.log('Socket closed due to error.', err.message);
ws = null;
});
ws.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
console.log(data);
if (data.action && data.action == "new_question") {
current_question = data.question;
} else if (data.action && data.action == "stats") {
wsstats = data.stats;
} else if (data.action && data.action == "new_response") {
if (!responses[data.question]) responses[data.question] = {};
responses[data.question][data.user] = data.value;
} else {
current_question = null;
}
});
}
</script>
{#await surveyP then survey}
{#if $user && $user.is_admin}
{#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 ms-1 float-end"
on:click={() => { if (confirm("Sûr ?")) ws.send('{"action":"end"}') }}
>
Terminer
</button>
{/if}
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
<small class="text-muted">
Administration
</small>
</h2>
{#if survey.direct !== null}
<div
class="badge rounded-pill ms-2"
class:bg-success={ws_up}
class:bg-danger={!ws_up}
>
{#if ws_up}Connecté{:else}Déconnecté{/if}
</div>
{:else}
<SurveyBadge
class="mx-2"
{survey}
/>
{/if}
</div>
{#if survey.direct === null}
<SurveyAdmin
{survey}
/>
{:else}
{#await req_questions}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<div class="card my-3">
<table class="table table-hover table-striped mb-0">
<thead>
<tr>
<th>
Question
<button
type="button"
class="btn btn-sm btn-info ms-1"
on:click={updateQuestions}
title="Rafraîchir les questions"
>
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</th>
<th>
Réponses
</th>
<th>
Actions
<button
type="button"
class="btn btn-sm btn-primary"
disabled={!current_question || !ws_up}
on:click={() => { ws.send('{"action":"pause"}')} }
>
<i class="bi bi-pause-fill"></i>
</button>
</th>
</tr>
</thead>
<tbody>
{#each questions as question (question.id)}
<tr>
<td>
{#if responses[question.id]}
<a href="surveys/{sid}/admin#q{question.id}_res">
{question.title}
</a>
{:else}
{question.title}
{/if}
</td>
<td>
{#if responses[question.id]}
{Object.keys(responses[question.id]).length}
{:else}
0
{/if}
{#if wsstats}/ {wsstats.nb_clients}{/if}
</td>
<td>
<button
type="button"
class="btn btn-sm btn-primary"
disabled={question.id === current_question || !ws_up}
on:click={() => { ws.send('{"action":"new_question", "question":' + question.id + '}')} }
>
<i class="bi bi-play-fill"></i>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_responses"}') }}
title="Rafraîchir les réponses"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-card-checklist"></i>
</button>
<h3>
Réponses
</h3>
{#if Object.keys(responses).length}
{#each Object.keys(responses) as q, qid (qid)}
{#await req_questions then questions}
{#each questions as question}
{#if question.id == q}
<h4 id="q{question.id}_res">
{question.title}
</h4>
{#if question.kind == 'ucq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e == proposal.id.toString()).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e == proposal.id.toString()).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else if question.kind == 'mcq'}
{#await question.getProposals()}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des propositions &hellip;</span>
</div>
{:then proposals}
<div class="card mb-4">
<table class="table table-sm table-striped table-hover mb-0">
<tbody>
{#each proposals as proposal (proposal.id)}
<tr>
<td>
{proposal.label}
</td>
<td>
{responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length}/{responsesbyid[q].length}
</td>
<td>
{Math.trunc(responsesbyid[q].filter((e) => e.indexOf(proposal.id.toString()) >= 0).length / responsesbyid[q].length * 1000)/10}&nbsp;%
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/await}
{:else}
<div class="card mb-4">
<ul class="list-group list-group-flush">
{#each Object.keys(responses[q]) as user, rid (rid)}
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<span>
{responses[q][user]}
</span>
<a href="users/{user}" target="_blank" class="badge bg-dark rounded-pill">
{#if users && users[user]}
{users[user].login}
{:else}
{user}
{/if}
</a>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/each}
{/await}
{/each}
{/if}
<hr>
<button
type="button"
class="btn btn-sm btn-info ms-1 float-end"
on:click={() => { ws.send('{"action":"get_stats"}') }}
title="Rafraîchir les stats"
>
<i class="bi bi-arrow-counterclockwise"></i>
<i class="bi bi-123"></i>
</button>
<button
type="button"
class="btn btn-sm btn-primary ms-1 float-end"
title="Rafraîchir la liste des utilisateurs"
on:click={updateUsers}
>
<i class="bi bi-arrow-clockwise"></i>
<i class="bi bi-people"></i>
</button>
<h3>
Connectés
{#if wsstats}
<small class="text-muted">{wsstats.nb_clients} utilisateurs</small>
{/if}
</h3>
{#if wsstats}
<div class="row row-cols-5 py-3">
{#each wsstats.users as login, lid (lid)}
<div class="col">
<div class="card">
<img alt="{login}" src="//photos.cri.epita.fr/thumb/{login}" class="card-img-top">
<div class="card-footer text-center text-truncate p-0">
<a href="users/{login}" target="_blank">
{login}
</a>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/await}

View file

@ -0,0 +1,66 @@
<script context="module">
import { getSurvey } from '../../../lib/surveys';
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
},
};
}
</script>
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '../../../stores/user';
import SurveyAdmin from '../../../components/SurveyAdmin.svelte';
import SurveyBadge from '../../../components/SurveyBadge.svelte';
import SurveyQuestions from '../../../components/SurveyQuestions.svelte';
import { getSurvey } from '../../../lib/surveys';
import { getQuestions } from '../../../lib/questions';
export let surveyP;
$: {
if (surveyP) {
surveyP.then((survey) => {
if (survey.direct && !$user.is_admin) {
goto(`surveys/${survey.id}/live`);
}
})
}
}
let edit = false;
</script>
{#await surveyP then survey}
{#if $user && $user.is_admin}
<button class="btn btn-primary ms-1 float-end" on:click={() => { edit = !edit; } } title="Éditer"><i class="bi bi-pencil"></i></button>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{#if survey.direct}
<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>
{/if}
{/if}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#if $user.is_admin && edit}
<SurveyAdmin {survey} on:saved={() => edit = false} />
{/if}
{#await getQuestions(survey.id)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<SurveyQuestions {survey} {questions} />
{/await}
{/await}

View file

@ -0,0 +1,151 @@
<script context="module">
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
sid: params.sid,
},
};
}
</script>
<script>
import { user } from '../../../stores/user';
import { ToastsStore } from '../../../stores/toasts';
import SurveyBadge from '../../../components/SurveyBadge.svelte';
import QuestionForm from '../../../components/QuestionForm.svelte';
import { getQuestion } from '../../../lib/questions';
export let surveyP;
export let sid;
let survey;
surveyP.then((s) => survey = s);
let ws_up = false;
let show_question = null;
let value;
let req_question;
let nosend = false;
function afterQUpdate(q) {
value = undefined;
if (q) {
q.getMyResponse().then((response) => {
if (response && response.value)
value = response.value;
})
}
}
$: {
if (show_question) {
req_question = getQuestion(show_question);
req_question.then(afterQUpdate);
}
}
function wsconnect() {
const ws = new WebSocket((window.location.protocol == 'https'?'wss://':'ws://') + window.location.host + `/api/surveys/${sid}/ws`);
ws.addEventListener("open", () => {
ws_up = true;
});
ws.addEventListener("close", (e) => {
ws_up = false;
show_question = false;
console.log('Socket is closed. Reconnect will be attempted in 1 second.', e.reason);
setTimeout(function() {
wsconnect();
}, 1500);
});
ws.addEventListener("error", (err) => {
ws_up = false;
console.log('Socket closed due to error.', err.message);
});
ws.addEventListener("message", (message) => {
const data = JSON.parse(message.data);
console.log(data);
if (data.action && data.action == "new_question") {
show_question = data.question;
} else {
show_question = null;
}
});
}
wsconnect();
function sendValue() {
if (show_question && value && !nosend) {
survey.submitAnswers([{"id_question": show_question, "value": value}], $user.id_user).then((response) => {
console.log("Vos réponses ont bien étés sauvegardées.");
}, (error) => {
value = null;
ToastsStore.addErrorToast({
msg: "Une erreur s'est produite durant l'envoi de vos réponses : " + error + "\nVeuillez réessayer dans quelques instants.",
});
});
}
}
</script>
{#await surveyP then survey}
{#if $user && $user.is_admin}
<a href="surveys/{survey.id}/admin" class="btn btn-primary ms-1 float-end" title="Aller à l'interface d'administration"><i class="bi bi-pencil"></i></a>
<a href="surveys/{survey.id}/responses" class="btn btn-success ms-1 float-end" title="Voir les réponses"><i class="bi bi-files"></i></a>
{/if}
<div class="d-flex align-items-center mb-5">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
</h2>
<div
class="badge rounded-pill ms-2"
class:bg-success={ws_up}
class:bg-danger={!ws_up}
>
{#if ws_up}Connecté{:else}Déconnecté{/if}
</div>
</div>
<form on:submit|preventDefault={sendValue}>
{#if show_question}
{#await req_question}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement d'une nouvelle question &hellip;</span>
</div>
{:then question}
<QuestionForm
qid={show_question}
{question}
bind:value={value}
on:change={sendValue}
>
<!--div class="progress" style="border-radius: 0; height: 4px">
<div class="progress-bar" role="progressbar" style="width: 25%"></div>
</div-->
</QuestionForm>
{#if question.kind != 'mcq' && question.kind != 'ucq'}
<button
class="btn btn-primary"
>
Soumettre la réponse
</button>
{/if}
{/await}
{:else if ws_up}
<h2 class="text-center">
Pas de question actuellement
</h2>
{:else}
<h2 class="text-center">
La session est terminée. <small class="text-muted">On se retrouve une prochaine fois&hellip;</small>
</h2>
{/if}
</form>
{/await}

View file

@ -0,0 +1,156 @@
<script context="module">
import { getSurvey } from '../../../../lib/surveys';
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
rid: params.rid,
},
};
}
</script>
<script lang="ts">
import Correction from '../../../../components/Correction.svelte';
import CorrectionReference from '../../../../components/CorrectionReference.svelte';
import SurveyBadge from '../../../../components/SurveyBadge.svelte';
import QuestionHeader from '../../../../components/QuestionHeader.svelte';
import { getCorrectionTemplates } from '../../../../lib/correctionTemplates';
import { getQuestion } from '../../../../lib/questions';
export let surveyP;
export let rid;
let showResponses = false;
let showStudent = false;
let notCorrected = false;
let nb_responses = 0;
let child;
let waitApply = false;
let ctpls = getCorrectionTemplates(rid);
let filter = "";
let cts = { };
ctpls.then((ctpls) => {
for (const tpl of ctpls) {
cts[tpl.id] = { };
tpl.getCorrections().then((c) => {
if (c) {
for (const d of c) {
cts[tpl.id][d.id_user] = d;
}
}
})
}
cts = cts;
});
</script>
{#await surveyP then survey}
{#await getQuestion(rid)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement de la question&hellip;</span>
</div>
{:then question}
{#await ctpls}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement de la question&hellip;</span>
</div>
{:then correctionTemplates}
<div class="float-end">
<input
class="form-control"
placeholder="filtre"
bind:value={filter}
>
</div>
<div class="d-flex align-items-center">
<h2>
<a href="surveys/{survey.id}/responses" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
<small class="text-muted">Corrections</small>
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
<div class="card sticky-top">
<QuestionHeader
{question}
>
<button
class="btn btn-sm btn-link float-start"
on:click={() => showResponses = !showResponses}
>
<i
class="bi"
class:bi-chevron-right={!showResponses}
class:bi-chevron-down={showResponses}
></i>
</button>
{#if showResponses}
<button
type="button"
class="btn btn-sm btn-success float-end ms-3 me-1"
title="Appliquer les corrections par regexp"
on:click={() => {waitApply = true; child.applyCorrections().then(() => { waitApply = false; })} }
disabled={waitApply}
>
{#if waitApply}
<div class="spinner-border spinner-border-sm" role="status"></div>
{:else}
<i class="bi bi-check-all"></i>
{/if}
</button>
{/if}
<button
type="button"
class="btn btn-sm float-end mx-1"
class:btn-outline-info={!showStudent}
class:btn-info={showStudent}
on:click={() => showStudent = !showStudent}
title="Afficher les étudiants"
>
<i class="bi bi-people"></i>
</button>
<button
type="button"
class="btn btn-sm float-end mx-1"
class:btn-outline-info={!notCorrected}
class:btn-info={notCorrected}
on:click={() => notCorrected = !notCorrected}
title="Afficher les réponses corrigées"
>
<i class="bi bi-files"></i>
</button>
</QuestionHeader>
{#if showResponses}
<CorrectionReference
class="card-body"
{cts}
bind:filter={filter}
{nb_responses}
{question}
templates={correctionTemplates}
/>
{/if}
</div>
<Correction
{cts}
{filter}
{question}
{showStudent}
{notCorrected}
bind:child={child}
templates={correctionTemplates}
on:nb_responses={(v) => { nb_responses = v.detail; } }
/>
{/await}
{/await}
{/await}
<div class="mb-5"></div>

View file

@ -0,0 +1,85 @@
<script context="module">
import { getSurvey } from '../../../../lib/surveys';
export async function load({ params, stuff }) {
return {
props: {
surveyP: stuff.survey,
},
};
}
</script>
<script lang="ts">
import SurveyBadge from '../../../../components/SurveyBadge.svelte';
import SurveyQuestions from '../../../../components/SurveyQuestions.svelte';
import { getSurvey } from '../../../../lib/surveys';
import { getQuestions } from '../../../../lib/questions';
export let surveyP;
</script>
{#await surveyP then survey}
<div class="d-flex align-items-center">
<h2>
<a href="surveys/{survey.id}" class="text-muted" style="text-decoration: none">&lt;</a>
{survey.title}
<small class="text-muted">Corrections</small>
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#await getQuestions(survey.id)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<div class="card mt-3 mb-5">
<table class="table table-hover table-striped mb-0">
<thead>
<tr>
<th>Question</th>
<th>Réponses</th>
<th>Moyenne</th>
</tr>
</thead>
<tbody ng-controller="SurveyGradesController">
{#each questions as question (question.id)}
<tr ng-click="showResponses()" ng-controller="ResponsesController">
<td><a href="surveys/{survey.id}/responses/{question.id}">{question.title}</a></td>
{#await question.getResponses()}
<td colspan="2" class="text-center">
<div class="spinner-border mx-3" role="status"></div>
<span>Chargement &hellip;</span>
</td>
{:then responses}
<td>
{#if responses}
{responses.filter((r) => !r.time_scored).length} /
{responses.length}
{:else}
0
{/if}
</td>
<td>
{#if responses && responses.filter((r) => r.time_scored).length}
{responses.reduce((p, c) => (p + c.score?c.score:0), 0)/responses.filter((r) => r.time_scored).length}
{:else}
--&nbsp;%
{/if}
</td>
{/await}
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<th colspan="2">Moyenne</th>
<th><!--{mean}-->&nbsp;%</th>
</tr>
</tfoot>
</table>
</div>
{/await}
{/await}

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { user } from '../../stores/user';
import SurveyList from '../../components/SurveyList.svelte';
</script>
<div class="card bg-light">
<SurveyList />
</div>

View file

@ -0,0 +1,111 @@
<script context="module">
export async function load({ params }) {
return {
props: {
uid: params.uid,
}
};
}
</script>
<script lang="ts">
import UserSurveys from '../../../components/UserSurveys.svelte';
import { user } from '../../../stores/user';
import { getSurveys } from '../../../lib/surveys';
import { getUser, getUserGrade, getUserScore } from '../../../lib/users';
export let uid;
let allPromos = false;
</script>
{#await getUser(uid)}
<h2>
Étudiant
</h2>
<div class="d-flex justify-content-center">
<div class="spinner-border me-2" role="status"></div>
Chargement des d&eacute;tails&hellip;
</div>
{:then student}
<div class="card mb-5">
<div class="card-header d-flex justify-content-center align-items-center">
<h2 class="card-title text-center">
<i class="bi bi-person-fill"></i>
{#if student.firstname}
{student.firstname} {student.lastname}
{:else}
{student.login}
{/if}
</h2>
{#if student.promo}
<span class="badge bg-success ms-1">{student.promo}</span>
{/if}
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3 mb-3 text-center">
<a href="https://photos.cri.epita.fr/{student.login}" target="_blank">
<img src="https://photos.cri.epita.fr/thumb/{student.login}" alt="avatar" class="img-thumbnail" style="max-height: 250px">
</a>
</div>
<div class="col">
<dl class="row">
<dt class="col-2">ID</dt>
<dd class="col-10">{student.id}</dd>
<dt class="col-2">Login</dt>
<dd class="col-10">
<a href="//cri.epita.fr/users/{student.login}" target="_blank">
{student.login}
</a>
</dd>
<dt class="col-2">E-mail</dt>
<dd class="col-10"><a href="mailto:{student.email}">{student.email}</a></dd>
<dt class="col-2">Nom</dt>
<dd class="col-10">{student.lastname}</dd>
<dt class="col-2">Prénom</dt>
<dd class="col-10">{student.firstname}</dd>
<dt class="col-2">Inscription</dt>
<dd class="col-10">{student.time}</dd>
<dt class="col-2">Groupes</dt>
<dd class="col-10">
<ul ng-if="student.groups">
{#each student.groups.split(',').slice(1) as g, gid (gid)}
<li>
<a href="https://cri.epita.fr/group/{g}/">{g}</a>
</li>
{/each}
</ul>
</dd>
<dt class="col-2">Admin</dt>
<dd class="col-10">{student.is_admin?"Oui":"Non"}</dd>
</dl>
</div>
</div>
</div>
<div class="card-header">
<button
class="btn btn-secondary float-end"
class:active={allPromos}
on:click={e => allPromos = !allPromos}
>
Toutes les promos
</button>
<h3 class="card-title">
Questionnaires
</h3>
</div>
<UserSurveys
{student}
{allPromos}
/>
</div>
{/await}

View file

@ -0,0 +1,64 @@
<script context="module">
export async function load({ params }) {
return {
props: {
sid: params.sid,
uid: params.uid,
}
};
}
</script>
<script lang="ts">
import SurveyBadge from '../../../../components/SurveyBadge.svelte';
import SurveyQuestions from '../../../../components/SurveyQuestions.svelte';
import { getSurvey } from '../../../../lib/surveys';
import { getQuestions } from '../../../../lib/questions';
import { getUser } from '../../../../lib/users';
export let sid;
export let uid;
</script>
{#await getUser(uid)}
<h2>
Étudiant
</h2>
<div class="d-flex justify-content-center">
<div class="spinner-border me-2" role="status"></div>
Chargement des d&eacute;tails&hellip;
</div>
{:then student}
{#await getSurvey(sid)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement du questionnaire &hellip;</span>
</div>
{:then survey}
<div class="d-flex align-items-center">
<h2>
<a href="users/{student.id}/surveys/" class="text-muted" style="text-decoration: none">{student.login}</a> /
{survey.title}
</h2>
<SurveyBadge class="ms-2" {survey} />
</div>
{#await getQuestions(survey.id)}
<div class="text-center">
<div class="spinner-border text-primary mx-3" role="status"></div>
<span>Chargement des questions &hellip;</span>
</div>
{:then questions}
<SurveyQuestions {survey} {questions} id_user={uid} />
{/await}
{:catch error}
<div class="text-center">
<h2>
<a href="surveys/" class="text-muted" style="text-decoration: none">&lt;</a>
Questionnaire introuvable
</h2>
<span>{error}</span>
</div>
{/await}
{/await}

View file

@ -0,0 +1,64 @@
<script context="module">
export async function load({ params }) {
return {
props: {
uid: params.uid,
}
};
}
</script>
<script lang="ts">
import UserSurveys from '../../../../components/UserSurveys.svelte';
import { user } from '../../../../stores/user';
import { getSurveys } from '../../../../lib/surveys';
import { getUser, getUserGrade, getUserScore } from '../../../../lib/users';
export let uid;
let allPromos = false;
</script>
{#await getUser(uid)}
<h2>
Étudiant
</h2>
<div class="d-flex justify-content-center">
<div class="spinner-border me-2" role="status"></div>
Chargement des d&eacute;tails&hellip;
</div>
{:then student}
<div class="card mb-5">
<div class="card-header d-flex justify-content-center align-items-center">
<h2 class="card-title text-center">
<a href="users/{student.id}"><i class="bi bi-chevron-left"></i></a>
<i class="bi bi-person-fill"></i>
{#if student.firstname}
{student.firstname} {student.lastname}
{:else}
{student.login}
{/if}
</h2>
{#if student.promo}
<span class="badge bg-success ms-1">{student.promo}</span>
{/if}
</div>
<div class="card-header">
<button
class="btn btn-secondary float-end"
class:active={allPromos}
on:click={e => allPromos = !allPromos}
>
Toutes les promos
</button>
<h3 class="card-title">
Questionnaires
</h3>
</div>
<UserSurveys
{student}
{allPromos}
/>
</div>
{/await}

View file

@ -0,0 +1,79 @@
<script>
import { goto } from '$app/navigation';
import { user } from '../../stores/user';
import DateFormat from '../../components/DateFormat.svelte';
import { getUsers, getPromos } from '../../lib/users';
function showUser(user) {
goto(`users/${user.id}`)
}
let filterPromo = "";
</script>
{#if $user && $user.is_admin}
<a href="grades" class="btn btn-success ml-1 float-end" title="Notes">
<i class="bi bi-files"></i>
</a>
{#await getPromos() then promos}
<div class="float-end me-2">
<select class="form-select" bind:value={filterPromo}>
<option value="">-</option>
{#each promos as promo, pid (pid)}
<option value={promo}>{promo}</option>
{/each}
</select>
</div>
{/await}
{/if}
<h2>
Étudiants
</h2>
{#await getUsers()}
<div class="text-center">
<div class="spinner-border text-danger mx-3" role="status"></div>
<span>Chargement des &eacute;tudiants &hellip;</span>
</div>
{:then users}
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Login</th>
<th>E-mail</th>
<th>Nom</th>
<th>Prénom</th>
<th>Date d'inscription</th>
<th>Promo</th>
</tr>
</thead>
<tbody>
{#each users.filter((u) => (filterPromo === "" || filterPromo === u.promo)) as u, uid (u.id)}
<tr
class:bg-danger={u.is_admin}
>
<td>
{u.id}
<div class="d-inline-block float-end" style="max-height: 1.5em">
<img src="//photos.cri.epita.fr/square/{u.login}" alt={u.login} style="max-height: 2.5em; margin-top: -0.33em">
</div>
</td>
<td>
<a href="users/{u.login}">{u.login}</a>
</td>
<td>
<a href="mailto:{u.email}">{u.email}</a>
</td>
<td>{u.lastname}</td>
<td>{u.firstname}</td>
<td>
<DateFormat date={u.time} dateStyle="short" timeStyle="medium" />
</td>
<td>{u.promo}</td>
</tr>
{/each}
</tbody>
</table>
{/await}

41
ui/src/stores/toasts.js Normal file
View 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/stores/user.js Normal file
View 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();