Premières évolutions liées à la nouvelle version, dont création du formulaire d'édition des groupes de quizs.

This commit is contained in:
Fabrice PENHOËT 2020-10-12 17:51:35 +02:00
parent 7a23a76f8e
commit 9a6bb5d882
40 changed files with 3912 additions and 820 deletions

2
app.js
View File

@ -11,6 +11,7 @@ const userRoutes = require("./routes/user");
const userPausesRoutes = require("./routes/pause");
const userPaymentsRoutes = require("./routes/payment");
const questionnairesRoutes = require("./routes/questionnaire");
const groupsRoutes = require("./routes/group");
const questionsRoutes = require("./routes/question");
const choicesRoutes = require("./routes/choice");
const illustrationRoutes = require("./routes/illustration");
@ -39,6 +40,7 @@ app.use("/api/user", userRoutes);
app.use("/api/pause", userPausesRoutes);
app.use("/api/payment", userPaymentsRoutes);
app.use("/api/questionnaire", questionnairesRoutes);
app.use("/api/group", groupsRoutes);
app.use("/api/questionnaire", tagRoutes);
app.use("/api/question", questionsRoutes);
app.use("/api/question", choicesRoutes);

View File

@ -40,7 +40,10 @@ module.exports =
hourGiveNewQuestionnaireEnd: 8, // idem
numberNewQuestionnaireAtSameTime: 50, // for mass mailing sending new quiz
minSearchQuestionnaires: 3,
fieldNewQuestionnaires : "publishingAt", // field to be used to create the list of the last questionnaires, can be "createdAt", "updatedAt" or "publishingAt"
fieldNewQuestionnaires : "publishingAt", // field to be used to create the list of the last questionnaires, can be "createdAt", "updatedAt" or "publishingAt"
// Groups :
nbQuestionnairesByGroupMin: 2,
nbQuestionnairesByGroupMax: 0,
// Illustrations:
nbIllustrationsMin: 0,
nbIllustrationsMax: 1,
@ -63,6 +66,6 @@ module.exports =
dirCacheQuestionnaires: questionnaires.dirCacheQuestionnaires,
dirCacheQuestions: questionnaires.dirCacheQuestions,
dirCacheUsersQuestionnaires: questionnaires.dirCacheUsersQuestionnaires,
dirHTMLQuestionnaire: questionnaires.dirHTMLQuestionnaire,
dirWebQuestionnaire: questionnaires.dirWebQuestionnaire
dirHTMLQuestionnaires: questionnaires.dirHTMLQuestionnaires,
dirWebQuestionnaires: questionnaires.dirWebQuestionnaires
};

View File

@ -1,34 +1,43 @@
module.exports =
{
// API'routes (after "apiUrl" defined in instance.js)
questionnaireRoutes: "/questionnaire",
questionnaireRoutes: "/questionnaire",// la base à laquelle s'ajoute les routes suivantes
getListNextQuestionnaires: "/getlistnextquestionnaires/",
getQuestionnaireRoutes: "/get",
getRandomQuestionnairesRoute : "/getrandom",
getStatsQuestionnaires : "/stats/",
previewQuestionnaireRoutes: "/preview",
publishedQuestionnaireRoutes: "/quiz/",
saveAnswersRoute: "/answer/",
getStatsQuestionnaires : "/stats/",
searchQuestionnairesRoute : "/search",
getRandomQuestionnairesRoute : "/getrandom",
searchAdminQuestionnairesRoute : "/searchadmin",
getListNextQuestionnaires: "/getlistnextquestionnaires/",
regenerateHTML: "/htmlregenerated",
saveAnswersRoute: "/answer/",
searchAdminQuestionnairesRoute : "/searchadmin",
searchQuestionnairesRoute : "/search",
// -- groupes :
groupRoutes: "/group",
getGroupRoute: "/get/",
searchGroupsRoute : "/search",
// -- questions & choices :
questionsRoute: "/question/",
// -- tags :
tagsSearchRoute: "/tags/search/",
// -- answers :
getQuestionnairesWithoutAnswer: "/withoutanswer/user/",
getPreviousAnswers: "/user/answers/",
getStatsAnswers : "/user/anwswers/stats/",
getAdminStats: "/getadminstats/",
getPreviousAnswers: "/user/answers/",
getQuestionnairesWithoutAnswer: "/withoutanswer/user/",
getStatsAnswers : "/user/anwswers/stats/",
// forms : à compléter avec valeurs par défaut, etc. cf modèle
Questionnaire :
{
title: { maxlength: 255, required: true },
slug: { maxlength: 150 }, // champ requis mais calculé à partir du titre qd vide
slug: { maxlength: 150 }, // champ requis mais calculé à partir du titre qd laissé vide dans le formulaire
introduction: { required: true }
},
searchQuestionnaires : { minlength: 3, required: true },
Group :
{
title: { maxlength: 255, required: true },
slug: { maxlength: 150 }, // champ requis mais calculé à partir du titre qd laissé vide dans le formulaire
},
Question :
{
text: { maxlength: 255, required: true },
@ -39,20 +48,27 @@ module.exports =
text: { maxlength: 255, required: true }
},
search: { minlength: 3, required: true },
/* valeurs en fait définies dans instance.js donc à supprimer quand plus utilisées ailleurs */
searchGroups: { minlength: 3, required: true },
// Emplacement des fichiers JSON générés :
dirCacheGroups : "datas/questionnaires/groups",
dirCacheQuestionnaires : "datas/questionnaires",
dirCacheQuestions : "datas/questionnaires/questions",
dirCacheTags : "datas/questionnaires/tags",
dirCacheUsersQuestionnaires : "datas/users/questionnaires",
// Emplacement des fichiers HTML générés :
dirHTMLGroups : "front/public/quizs/gp",
dirHTMLQuestionnaires : "front/public/quiz",
dirHTMLTags : "front/public/quizs",
// Idem mais pour urls :
dirWebGroups : "quizs/gp",
dirWebQuestionnaires : "quiz",
dirWebTags : "quizs/",
// limite des résultat du moteur de recherche, quand demande de résultats au hasard :
nbRandomResults : 3,
/* Valeurs en fait définies dans instance.js donc à supprimer quand plus utilisées ailleurs : */
nbQuestionsMin: 1,
nbQuestionsMax: 0,
nbChoicesMax: 10,
nbTagsMin: 0,
nbTagsMax: 0, // 0 = not max
// JSON and HTML dir
dirCacheQuestionnaires : "datas/questionnaires",
dirCacheQuestions : "datas/questionnaires/questions",
dirCacheUsersQuestionnaires : "datas/users/questionnaires",
dirCacheTags : "datas/questionnaires/tags",
dirHTMLQuestionnaire : "front/public/quiz",
dirHTMLTags : "front/public/quizs",
dirWebQuestionnaire : "quiz",//pour url page
dirWebTags : "quizs/",// idem
nbRandomResults : 3// limite les résultat du moteur de recherche quand demande de résultats au hasard
nbTagsMax: 0, // 0 = not max
};

385
controllers/group.js Normal file
View File

@ -0,0 +1,385 @@
const { Op, QueryTypes } = require("sequelize");
const pug = require("pug");
const striptags = require("striptags");
const config = require("../config/main.js");
const configQuestionnaires = require("../config/questionnaires.js");
const tool = require("../tools/main");
const toolError = require("../tools/error");
const toolFile = require("../tools/file");
const questionnaireCtrl = require("./questionnaire");
const userCtrl = require("./user");
const txtGeneral = require("../lang/"+config.adminLang+"/general");
const txtGroups = require("../lang/"+config.adminLang+"/group");
exports.create = async (req, res, next) =>
{
try
{
const db = require("../models/index");
req.body.CreatorId=req.connectedUser.User.id;
const group=await db["Group"].create({ ...req.body }, { fields: ["title", "slug", "introduction", "publishingAt", "language", "CreatorId"] });
creaGroupJson(group.id);
res.status(201).json({ message: txtGeneral.addedOkMessage, id: group.id });
next();
}
catch(e)
{
const returnAPI=toolError.returnSequelize(e);
if(returnAPI.messages)
{
res.status(returnAPI.status).json({ errors : returnAPI.messages });
next();
}
else
next(e);
}
}
exports.modify = async (req, res, next) =>
{
try
{
const db = require("../models/index");
const group=await searchGroupById(req.params.id);
if(!group)
{
const Err=new Error;
error.status=404;
error.message=txtGroups.notFound.replace("#SEARCH", req.params.id);
throw Err;
}
else if(req.connectedUser.User.status==="creator" && req.connectedUser.User.id!==group.CreatorId)
res.status(401).json({ errors: txtGeneral.notAllowed });
else
await db["Group"].update({ ...req.body }, { where: { id : req.params.id } , fields: ["title", "slug", "introduction", "publishingAt", "language"], limit:1 });
await creaGroupJson(req.params.id);
res.status(200).json({ message: txtGeneral.updateOkMessage });
next();
}
catch(e)
{
const returnAPI=toolError.returnSequelize(e);
if(returnAPI.messages)
{
res.status(returnAPI.status).json({ errors : returnAPI.messages });
next();
}
else
next(e);
}
}
exports.delete = async (req, res, next) =>
{
try
{
const db = require("../models/index");
const group=await searchGroupById(req.params.id);
if(!group)
{
const Err=new Error;
error.status=404;
error.message=txtGroups.notFound.replace("#SEARCH", req.params.id);
throw Err;
}
else if(req.connectedUser.User.status==="creator" && req.connectedUser.User.id!==group.CreatorId)
res.status(401).json({ errors: txtGeneral.notAllowed });
else
{
// La suppression sera bloquée par SQL si des quizs dépendent de ce groupe
// Donc il faut d'abord supprimer tous les quizs du groupe :
for(let i in group.Questionnaires)
await questionnaireCtrl.deleteQuestionnaireById(group.Questionnaires[i].id);
const nb=await db["Group"].destroy( { where: { id : req.params.id }, limit:1 });
if(nb===1)
{
toolFile.deleteJSON(configQuestionnaires.dirCacheGroups, req.params.id);
toolFile.deleteFile(configQuestionnaires.dirHTMLGroups, group.Group.slug+".html");
creaStatsGroupsJson();
res.status(200).json({ message: txtGeneral.deleteOkMessage });
}
else
{
const Err=new Error;
error.status=404;
error.message=txtGroups.deleteFailMessage.replace("#ID", req.params.id);
throw Err;
}
}
next();
}
catch(e)
{
next(e);
}
}
// Recherche par mots-clés parmis tous les groupes (y compris ceux non publiés).
exports.searchGroups = async (req, res, next) =>
{
try
{
let search=tool.trimIfNotNull(req.body.searchGroups);
if(search === null || search === "" || search.length < configQuestionnaires.searchGroups.minlength)
res.status(400).json(txtGroups.searchIsNotLongEnough.replace("#MIN", configQuestionnaires.searchGroups.minlength));
else
{
const db = require("../models/index");
const getGroups=await db.sequelize.query("SELECT `id`,`title` FROM `Groups` WHERE (`title` LIKE :search OR `introduction` LIKE :search) ORDER BY `title` ASC", { replacements: { search: "%"+search+"%" }, type: QueryTypes.SELECT });
res.status(200).json(getGroups);
}
next();
}
catch(e)
{
next(e);
}
}
exports.getOneById = async (req, res, next) =>
{
try
{
const datas=await searchGroupById(req.params.id);
if(datas)
res.status(200).json(datas);
else
res.status(404).json({ message:txtGroups.notFound.replace("#SEARCH", req.params.id) });
next();
}
catch(e)
{
next(e);
}
}
// Retourne les statistiques concernant les groupes de questionnaires
exports.getStats = async(req, res, next) =>
{
try
{
const stats=await getStatsGroups();
res.status(200).json(stats);
}
catch(e)
{
next(e);
}
}
// CRONS
// Supprime fichiers json de groupes n'existant plus.
exports.deleteJsonFiles = async (req, res, next) =>
{
try
{
const db = require("../models/index");
const groups=await db["Group"].findAll({ attributes: ["id"] });
for(let i in groups)
saveFiles.push(groups[i].id+".json");
const deleteFiles = await toolFile.deleteFilesInDirectory(configQuestionnaires.dirCacheGroups, saveFiles);
res.status(200).json(deleteFiles);
next();
}
catch(e)
{
next(e);
}
}
// Teste si des groupes doivent être publiés
exports.checkGroupsNeedToBePublished = async (req, res, next) =>
{
try
{
const nb=await checkGroupsNeedToBePublished();
res.status(200).json(txtGroups.haveBeenPublished.replace("#NB", nb));
next();
}
catch(e)
{
next(e);
}
}
// FONCTIONS PARTAGÉES
const creaGroupJson = async (id) =>
{
const db = require("../models/index");
const Group=await db["Group"].findByPk(id);
if(Group)
{
let datas={ Group };
const Questionnaires=await db["Questionnaire"].findAll({ where: { GroupId: Group.id }, order: [["rankInGroup", "ASC"], ["createdAt", "ASC"]], attributes: ["id"] });
if(Questionnaires)
datas.Questionnaires=Questionnaires;
await toolFile.createJSON(configQuestionnaires.dirCacheGroups, id, datas);
// si le groupe est publiable on génère/actualise la page HTML :
if(checkGroupIsPublishable(datas))
creaGroupHTML(id);
else // dans le cas contraire, on supprime l'éventuel fichier préexistant
toolFile.deleteFile(config.dirHTMLGroups, Group.slug+".html");
// + mise à jour des statistiques :
creaStatsGroupsJson();
return datas;
}
else
return false;
}
exports.creaGroupJson = creaGroupJson;
const checkGroupIsPublishable = (datas, checkDate=true) =>
{
if(checkDate)
{
if(datas.Group.publishingAt === null)
return false;
else
{
const today=new Date();
today.setHours(0,0,0,0);// !! attention au décalage horaire du fait de l'enregistrement en UTC dans mysql
const publishingAt=new Date(datas.Group.publishingAt);
if (publishingAt.getTime() > today.getTime())
return false;
}
}
if(datas.Questionnaires === undefined || datas.Questionnaires.length < config.nbQuestionnairesByGroupMin)
return false;
return true;
}
const creaGroupHTML = async (id, preview = false) =>
{
// J'ai besoin de toutes les infos concernant le groupe et ses éléments pour les transmettre à la vue
const group=await searchGroupById(id, true);
if(!group)
return false;
if(checkGroupIsPublishable(group)===false && preview===false)
return false;
const compiledFunction = pug.compileFile("./views/"+config.theme+"/quiz-group.pug");
const configTpl = require("../views/"+config.theme+"/config/"+config.availableLangs[0]+".js");
const pageDatas = // à revoir en regardant les besoins de la vue
{
config: config,
configQuestionnaires: configQuestionnaires,
configTpl: configTpl,
tool: tool,
txtGeneral: txtGeneral,
txtGroups: txtGroups,
pageLang: group.language,
metaDescription: tool.shortenIfLongerThan(config.siteName+" : "+striptags(groupe.introduction.replace("<br>", " ").replace("</p>", " ")), 200),
author: group.CreatorName,
pageTitle: txtGroups.groupsName+" "+group.title,
contentTitle: group.title+"("+txtGroups.groupsName+")",
group: group,
linkCanonical: config.siteUrl+"/"+config.dirWebGroups+"/"+group.slug+".html"
}
const html=await compiledFunction(pageDatas);
if(preview === false)
{
await toolFile.createHTML(configQuestionnaires.dirHTMLGroups, group.slug, html);
return true;
}
else
return html;
}
// Remonte toutes les données du groupe + les données des questionnaires y étant classés si reassemble=true
const searchGroupById = async (id, reassemble=false) =>
{
let group=await toolFile.readJSON(configQuestionnaires.dirCacheGroups, id);
if(!group)
group=await creaGroupJson(id);
if(!group)
return false;
if(reassemble)
{
let questionnaire; Questionnaires=[];
const author=await userCtrl.searchUserById(group.Group.CreatorId);
if(author)
group.Group.CreatorName=author.User.name;
for(let i in group.Questionnaires)
{
questionnaire=await questionnaireCtrl.searchQuestionnaireById(questionnaire.Questions[i].id, true);
if(questionnaire)
Questionnaires.push(questionnaire);
}
group.Questionnaires=Questionnaires;
}
return group;
}
exports.searchGroupById = searchGroupById;
// Cherche si il y a des groupes de questionnaires dont la date de publication est passée mais qui ne sont pas publiés
// Vérifie si ils sont publiables et si oui génère le HTML
// Si regenerate=true, tous les fichiers sont (ré)générés, même s'ils existent déjà (évolution template...)
// Retourne le nombre de fichiers ayant été (ré)générés
const checkGroupsNeedToBePublished = async (regenerate=false) =>
{
const db = require("../models/index");
const groups = await db.sequelize.query("SELECT `id`,`slug` FROM `groups` WHERE `publishingAt` < NOW()", { type: QueryTypes.SELECT });
let publishedOk=false, nb=0;
for(let i in groups)
{
if(regenerate===false)
{
if(await toolFile.checkIfFileExist(configQuestionnaires.dirHTMLGroups, group.Group.slug+".html")===false)
{
publishedOk=await creaGroupHTML(groups[i].id);// creaGroupHTLM contrôle que le groupe est publiable
if(publishedOk)
nb++;
}
}
else
{
publishedOk=await creaGroupHTML(groups[i].id);
if(publishedOk)
nb++;
}
}
return nb;
}
// Compte le nombre total de groupes et le stocke
const creaStatsGroupsJson = async () =>
{
const db = require("../models/index");
const Groups=await db["Group"].findAll({ attributes: ["id"] });
const GroupsPublished=await db.sequelize.query("SELECT `id` FROM `Groups` WHERE `publishingAt` < NOW()", { type: QueryTypes.SELECT });
const QuestionnairesInGroups=await db.sequelize.query("SELECT DISTINCT `id` FROM `Questionnaires` WHERE `GroupId` IS NOT NULL", { type: QueryTypes.SELECT });
if(Groups && GroupsPublished && QuestionnairesInGroups)
{
const stats =
{
nbTot : Groups.length,
nbPublished : GroupsPublished.length, // ! en fait, peuvent être passé de date et non publié : À REVOIR QUAND DES EXEMPLES EN BD!
nbQuestionnaires : QuestionnairesInGroups.length
}
await toolFile.createJSON(configQuestionnaires.dirCacheGroups, "stats", stats);
return stats;
}
else
return false;
}
exports.creaStatsGroupsJson = creaStatsGroupsJson;
// Retourne les données créées par la fonction précédente
const getStatsGroups = async () =>
{
let stats=await toolFile.readJSON(configQuestionnaires.dirCacheGroups, "stats");
if(!stats)
stats=await creaStatsGroupsJson();
if(!stats)
return false;
else
return stats;
}
exports.getStatsGroups = getStatsGroups;

View File

@ -91,50 +91,11 @@ exports.delete = async (req, res, next) =>
{
try
{
const db = require("../models/index");
const questionnaire=await searchQuestionnaireById(req.params.id);
if(!questionnaire)
{
const Err=new Error;
error.status=404;
error.message=txtQuestionnaire.notFound+" ("+req.params.id+")";
const del=deleteQuestionnaire(req.params.id, req);
if(typeof del===Error)
throw Err;
}
else if(req.connectedUser.User.status==="creator" && req.connectedUser.User.id!==questionnaire.CreatorId)
res.status(401).json({ errors: txtGeneral.notAllowed });
else
{
// Permet de supprimer les fichiers associés en plus du sql. Inutile pour link qui n'a pas de fichier.
// À faire avant la suppression SQL du questionnaire entraînant la suppression en cascade du reste.
for(i in questionnaire.Questions)
await questionCtrl.deleteQuestionById(questionnaire.Questions[i].id);
for(i in questionnaire.Illustrations)
await illustrationCtrl.deleteIllustrationById(questionnaire.Illustrations[i].id);
const nb=await db["Questionnaire"].destroy( { where: { id : req.params.id }, limit:1 });
if(nb===1)
{
await toolFile.deleteJSON(config.dirCacheQuestionnaires, req.params.id);
res.status(200).json({ message: txtGeneral.deleteOkMessage });
// actualisation de liste des questionnaires pour les tags concernés.
// Ici au contraire, les enregistrements doivent être supprimés avant.
for(let i in questionnaire.Tags)
tagCtrl.creaQuestionnairesTagJson(questionnaire.Tags[i].TagId);
// La suppression peut éventuellement concerner un des derniers questionnaires, donc :
creaNewQuestionnairesJson();
creaStatsQuestionnairesJson();
// Éventuellement regénérer les caches listant les réponses/quizs des users ayant accès à ce questionnaire ?
// ++ HTML
}
else
{
const Err=new Error;
error.status=404;
error.message=txtQuestionnaire.notFound+" ("+req.params.id+")";
throw Err;
}
}
res.status(200).json({ message: txtGeneral.deleteOkMessage });
next();
}
catch(e)
@ -403,7 +364,7 @@ exports.checkQuestionnairesNeedToBePublished= async (req, res, next) =>
}
// FONCTIONS UTILITAIRES
// FONCTIONS PARTAGÉES
const creaQuestionnaireJson = async (id) =>
{
@ -435,7 +396,7 @@ const creaQuestionnaireJson = async (id) =>
tagCtrl.creaUsedTagsJson();
// si le quiz était publié jusqu'ici, il me faut supprimer son fichier HTML (revenir pour réactiver)
if(wasPublished)
toolFile.deleteFile(config.dirHTMLQuestionnaire, Questionnaire.slug+".html");
toolFile.deleteFile(config.dirHTMLQuestionnaires, Questionnaire.slug+".html");
}
// peut impacter la liste des derniers si des informations affichées ont changé
creaNewQuestionnairesJson();// peut avoir été impacté
@ -451,6 +412,52 @@ const creaQuestionnaireJson = async (id) =>
}
exports.creaQuestionnaireJson = creaQuestionnaireJson;
// Supprime un questionnaire et toutes ses dépendances
const deleteQuestionnaire = async (id, req) =>
{
const db = require("../models/index");
const questionnaire=await searchQuestionnaireById(id);
if(!questionnaire)
{
const Err=new Error;
error.status=404;
error.message=txtQuestionnaire.notFound+" ("+req.params.id+")";
return Err;
}
else if(req.connectedUser.User.status==="creator" && req.connectedUser.User.id!==questionnaire.CreatorId)
{
const Err=new Error;
error.status=401;
error.message=txtGeneral.notAllowed;
return Err;
}
else
{
// Suppression des fichiers associés en plus du sql. Inutile pour link qui n'a pas de fichier.
// À faire avant la suppression SQL du questionnaire qui entraîne des suppressions en cascade dans la base de données.
for(let i in questionnaire.Questions)
await questionCtrl.deleteQuestionById(questionnaire.Questions[i].id);
for(let i in questionnaire.Illustrations)
await illustrationCtrl.deleteIllustrationById(questionnaire.Illustrations[i].id);
const nb=await db["Questionnaire"].destroy( { where: { id : req.params.id }, limit:1 });
if(nb===1)// = json existant, bien que sql déjà supprimé
{
toolFile.deleteJSON(configQuestionnaires.dirCacheQuestionnaires, req.params.id);
// + HTML :
toolFile.deleteFile(configQuestionnaires.dirHTMLQuestionnaires, questionnaire.Questionnaire.slug+".html");
// Actualisation de liste des questionnaires pour les tags concernés.
// Ici au contraire, les enregistrements doivent être supprimés avant.
for(let i in questionnaire.Tags)
tagCtrl.creaQuestionnairesTagJson(questionnaire.Tags[i].TagId);
// La suppression peut éventuellement concerner un des derniers questionnaires, donc :
creaNewQuestionnairesJson();
creaStatsQuestionnairesJson();
return true;
}
}
}
exports.deleteQuestionnaire = deleteQuestionnaire;
const checkQuestionnaireIsPublishable = (datas, checkDate=true) =>
{
if(checkDate)
@ -502,12 +509,12 @@ const creaQuestionnaireHTML = async (id, preview=false) =>
pageTitle: questionnaire.Questionnaire.title+" ("+txtQuestionnaire.questionnairesName+")",
contentTitle: questionnaire.Questionnaire.title,
questionnaire: questionnaire,
linkCanonical: config.siteUrl+"/"+config.dirWebQuestionnaire+"/"+questionnaire.Questionnaire.slug+".html"
linkCanonical: config.siteUrl+"/"+config.dirWebQuestionnaires+"/"+questionnaire.Questionnaire.slug+".html"
}
const html=await compiledFunction(pageDatas);
if(preview===false)
{
await toolFile.createHTML(config.dirHTMLQuestionnaire, questionnaire.Questionnaire.slug, html);
await toolFile.createHTML(config.dirHTMLQuestionnaires, questionnaire.Questionnaire.slug, html);
return true;
}
else
@ -575,7 +582,7 @@ const checkQuestionnairesPublishedHaveHTML = async (regenerate=false) =>
await creaQuestionnaireHTML(questionnaires[i].id);
nb++;
}
else if(await toolFile.checkIfFileExist(config.dirHTMLQuestionnaire, questionnaires[i].slug+".html")===false)
else if(await toolFile.checkIfFileExist(config.dirHTMLQuestionnaires, questionnaires[i].slug+".html")===false)
{
await creaQuestionnaireHTML(questionnaires[i].id);
nb++;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1555
front/public/JS/group.app.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Gestion des groupes de quizs</title>
<!-- Version lisible des scripts : https://gitlab.com/lefablab/wikilerni/-/tree/master/front/src -->
<script src="/JS/polyfill.app.js" defer></script>
<script src="/JS/manageGroups.app.js" defer></script>
<link rel="shortcut icon" href="/img/favicon.ico">
<link rel="stylesheet" href="/themes/wikilerni/css/style.css">
</head>
<body class="cardboard">
<!-- En tête -->
<header class="cardboard">
<a href="/" title="Page d'accueil WikLerni"><img src="/themes/wikilerni/img/wikilerni-purple-2-128.png" alt="WikiLerni (logo)" title="Accéder à la page d'accueil de WikiLerni" /></a>
<ul id="headLinks">
<li><a href="/contact.html" rel="nofollow">Contact</a></li>
<li><a href="/quizs/" id="indexHeadLink" title="Les derniers quizs">Parcourir</a></li>
<li><a href="/connexion.html" id="accountHeadLink">Mon compte</a></li>
<li><a href="/a-propos.html">À propos</a></li>
<li><a href="/" title="Page d'accueil de WikiLerni">Accueil</a></li>
</ul>
</header>
<div id="crash"></div>
<section id="main-content" class="needJS">
<ul id="menu" class="cardboard">
<li><a href="/gestion.html">Gestion WikiLerni</a></li>
<li><a href="/gestion-quizs.html" title="Publication des quizs">Les quizs</a></li>
<li><a href="/gestion-utilisateurs.html" title="Les comptes utilisateurs">Les abonné(e)s</a></li>
<li><a href="/sortie.html">Me déconnecter</a></li>
</ul>
<div id="account" class="cardboard">
<h1 class="cardboard" id="infos">Les groupes de quizs</h1>
<h2>Chercher un groupe</h2>
<form id="search" method="POST">
<input id="searchGroups" type="text" name="searchGroups" placeholder="Votre recherche" class="cardboard" />
<div class="input_wrapper"><input type="submit" value="Chercher" class="cardboard" /></div>
<div id="searchResult"></div>
</form>
<h2 id="infos">Informations du groupe</h2>
<div id="groupIntro"></div>
<div id="message"></div>
<form id="groups" method="POST">
<fieldset><label for="title">Titre</label><input id="title" type="text" name="title" class="cardboard"></fieldset>
<fieldset><label for="slug">Slug</label><input id="slug" type="text" name="slug" class="cardboard"></fieldset>
<fieldset><label for="introduction">Introduction</label><textarea id="introduction" name="introduction" rows="10" class="cardboard"></textarea></fieldset>
<fieldset><label for="publishingAt">Date de publication</label><input id="publishingAt" type="date" name="publishingAt" class="cardboard"></fieldset>
<ul class="checkbox_li">
<li class="checkbox_li">
<label for="deleteOk" class="check" id="deleteOkLabel"><input type="checkbox" id="deleteOk" name="deleteOk" value="true" /><div class="checkbox_override"></div> <span class="error">Je souhaite supprimer ce groupe.</span></label>
</li>
</ul>
<input type="hidden" name="id" id="id" value="">
<div class="input_wrapper"><input type="submit" value="Valider" class="cardboard" id="submitDatas" /></div>
<div class="input_wrapper"><a href="#groups" class="button cardboard" id="wantNewGroup">Vider</a></div>
<div id="response"></div>
</form>
<div id="response"></div>
</section>
<footer class="cardboard">
<ul id="footLinks">
<li><a href="https://framasphere.org/people/7e54b7a0b53201389eef2a0000053625" title="Blog WikiLerni sur diaspora*">Blog</a></li>
<li><a href="/credits.html">Crédits</a></li>
<li><a href="/mentions-legales.html" rel="nofollow">Mentions légales</a></li>
<li><a href="/donnees.html">Données personnelles</a></li>
<li><a href="/CGV-CGU.html" rel="nofollow">CGV &amp; CGU</a></li>
</ul>
</footer>
</body>
</html>

View File

@ -45,11 +45,11 @@
<div class="input_wrapper"><input type="submit" value="Chercher" class="cardboard" /></div>
<div id="searchResult"></div>
</form>
<div id="message"></div>
<form id="questionnaires" method="POST">
<h2>Informations du quiz</h2>
<div class="input_wrapper"><a class="button cardboard" href="/gestion-groups.html">Gérer les groupes de quizs.</a></div>
<fieldset><label for="title">Titre</label><input id="title" type="text" name="title" class="cardboard"></fieldset>
<fieldset><label for="slug">Slug</label><input id="slug" type="text" name="slug" class="cardboard"></fieldset>
<fieldset><label for="introduction">Introduction</label><textarea id="introduction" name="introduction" rows="10" class="cardboard"></textarea></fieldset>

View File

@ -46,19 +46,16 @@
<div id="emailMessage"></div>
</fieldset>
<fieldset>
<label for="password">Mot de passe : </label><input id="password" type="password" name="password" placeholder="Mot de passe de votre choix" class="cardboard">
<div id="passwordMessage"><span class="info">Au moins 8 caractères. <a href="#password" id="getPassword">Générer un mot de passe</a>.</span></div>
</fieldset>
<fieldset>
<label for="codeGodfather">Code ou e-mail parrain : </label><input id="codeGodfather" type="text" name="codeGodfather" placeholder="Code ou email de votre parrain." class="cardboard">
<div id="codeGodfatherMessage"><span class="info">Facultatif.</span></div>
</fieldset>
<ul class="checkbox_li">
<li class="checkbox_li">
<label for="cguOk" class="check"><input type="checkbox" id="cguOk" name="cguOk" value="true" /><div class="checkbox_override"></div> J'accepte <a href="/CGV-CGU.html" target="_blank" rel="noopener" title="À lire :)">les Conditions Générale d'Utilisation</a> du site (requis).</label>

View File

@ -417,7 +417,7 @@ margin:3em 0;
}
#questionsList p a
{
font-size:1.5em;
font-size:1.5em;
}
@ -597,9 +597,10 @@ font-size:1em;
{
padding:0.2em 1em;
}
#searchQuestionnaires
#searchQuestionnaires, #searchUsers, #searchGroups
{
width:65%;
margin:auto;
}
#manageQuestionnaires #questionnairesList li
{
@ -1399,7 +1400,7 @@ font-size:0.9em;
}
#signup h2, #login h2, #account h2, #manageQuestionnaires h2
{
margin-bottom: 0;
margin-bottom: 0.5em;
}
#signup p, #login p, #account p, #manageQuestionnaires p
{

View File

@ -119,7 +119,10 @@ myForm.addEventListener("submit", function(e)
}
else if (response.errors)
{
response.errors = response.errors.join("<br>");
if(Array.isArray(response.errors))
response.errors = response.errors.join("<br>");
else
response.errors = serverError;
addElement(divResponse, "p", response.errors, "", ["error"]);
}
else

181
front/src/group.js Normal file
View File

@ -0,0 +1,181 @@
// -- GESTION DU FORMULAIRE PERMETTANT DE CRÉER SON COMPTE
/// L'utilisateur peut avoir répondu à un quiz avant d'arriver sur la page d'inscription
/// Des ce cas il faut enregistrer son résultat en même temps que les informations de son compte
// Fichier de configuration tirés du backend :
import { apiUrl, availableLangs, theme } from "../../config/instance.js";
const lang=availableLangs[0];
const configTemplate = require("../../views/"+theme+"/config/"+lang+".js");// besoin de toutes les déclarations, car appel dynamique : configTemplate[homePage]
const configUsers = require("../../config/users");// idem pour configurer formulaire
// Importation des fonctions utiles au script :
import { getLocaly, removeLocaly, saveLocaly } from "./tools/clientstorage.js";
import { addElement } from "./tools/dom.js";
import { helloDev } from "./tools/everywhere.js";
import { getDatasFromInputs, setAttributesToInputs } from "./tools/forms.js";
import { loadMatomo } from "./tools/matomo.js";
import { checkAnswerDatas, checkSession, getPassword, getTimeDifference } from "./tools/users.js";
// Dictionnaires :
const { notRequired, serverError } = require("../../lang/"+lang+"/general");
const { alreadyConnected, godfatherFound, godfatherNotFound, needUniqueEmail, passwordCopied } = require("../../lang/"+lang+"/user");
// Principaux éléments du DOM manipulés :
const myForm=document.getElementById("subscription");
const divResponse=document.getElementById("response");
const passwordInput=document.getElementById("password");
const passwordLink=document.getElementById("getPassword");
const passwordHelp=document.getElementById("passwordMessage");
const emailInput=document.getElementById("email");
const btnSubmit=document.getElementById("submitDatas");
const codeGodfatherInput=document.getElementById("codeGodfather");
helloDev();
// Test de connexion de l'utilisateur + affichage formulaire d'inscription.
const initialise = async () =>
{
try
{
const isConnected=await checkSession();
if(isConnected)
{
saveLocaly("message", { message: alreadyConnected, color:"info" });// pour l'afficher sur la page suivante
const user=getLocaly("user", true);
const homePage=user.status+"HomePage";
window.location.assign("/"+configTemplate[homePage]);
}
else
{
loadMatomo();
setAttributesToInputs(configUsers, myForm);
myForm.style.display="block";
}
}
catch(e)
{
addElement(divResponse, "p", serverError, "", ["error"]);
console.error(e);
}
}
initialise();
// Générateur de mot de passe "aléatoire"
passwordLink.addEventListener("click", function(e)
{
e.preventDefault();
passwordInput.type="text";
passwordInput.value=getPassword(8, 12);
// Copie du mot de passe généré dans le "presse-papier" de l'ordinateur :
passwordInput.select();
document.execCommand("copy");
addElement(passwordHelp, "div", passwordCopied, "", ["success"]);
});
// Test si l'e-mail saisi est déjà utilisé par un autre compte.
// Si c'est le cas, la validation du formulaire est bloquée.
emailInput.addEventListener("focus", function(e)
{
document.getElementById("emailMessage").innerHTML="";// pour supprimer l'éventuel message d'erreur déjà affiché
});
emailInput.addEventListener("blur", function(e)
{
const emailValue=emailInput.value.trim();
if(emailValue!=="")
{
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl+configUsers.userRoutes+configUsers.checkIfIsEmailfreeRoute);
xhr.onreadystatechange = function()
{
if (this.readyState == XMLHttpRequest.DONE)
{
let response=JSON.parse(this.responseText);
if (this.status === 200 && response.free!==undefined && response.free === false)
{
addElement(document.getElementById("emailMessage"), "div", needUniqueEmail.replace("#URL", configTemplate.connectionPage), "", ["error"]);
btnSubmit.setAttribute("disabled", true);
}
else
btnSubmit.removeAttribute("disabled");
}
}
xhr.setRequestHeader("Content-Type", "application/json");
const datas={ emailTest:emailValue };
xhr.send(JSON.stringify(datas));
}
});
// Vérification que le code/e-mail de parrainage saisi est valide.
codeGodfatherInput.addEventListener("focus", function(e)
{ // on efface l'éventuel message d'erreur si on revient sur le champ pour tester un autre code.
addElement(document.getElementById("codeGodfatherMessage"), "i", notRequired);
});
codeGodfatherInput.addEventListener("blur", function(e)
{
const codeValue=codeGodfatherInput.value.trim();
if(codeValue!=="")
{
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl+configUsers.userRoutes+configUsers.getGodfatherRoute);
xhr.onreadystatechange = function()
{
if (this.readyState == XMLHttpRequest.DONE)
{
if (this.status === 204)
addElement(document.getElementById("codeGodfatherMessage"), "div", godfatherNotFound, "", ["error"]);
else
addElement(document.getElementById("codeGodfatherMessage"), "div", godfatherFound, "", ["success"]);
}
}
xhr.setRequestHeader("Content-Type", "application/json");
const datas={ codeTest:codeValue };
xhr.send(JSON.stringify(datas));
}
});
// Traitement de l'envoi des données d'inscription :
myForm.addEventListener("submit", function(e)
{
try
{
e.preventDefault();
const xhr = new XMLHttpRequest();
xhr.open("POST", apiUrl+configUsers.userRoutes+configUsers.subscribeRoute);
xhr.onreadystatechange = function()
{
if (this.readyState == XMLHttpRequest.DONE)
{
let response=JSON.parse(this.responseText);
if (this.status === 201)
{
myForm.style.display="none";
addElement(divResponse, "p", response.message, "", ["success"]);
removeLocaly("lastAnswer");// ! important pour ne pas enregister plusieurs fois le résultat.
}
else if (response.errors)
{
response.errors = response.errors.join("<br>");
addElement(divResponse, "p", response.errors, "", ["error"]);
}
else
addElement(divResponse, "p", serverError, "", ["error"]);
}
}
xhr.setRequestHeader("Content-Type", "application/json");
let datas=getDatasFromInputs(myForm);
if(datas)
{
datas.timeDifference=getTimeDifference(configUsers);
// si l'utilisateur a précédement répondu à un quiz, j'ajoute les infos de son résultat :
datas=checkAnswerDatas(datas);
xhr.send(JSON.stringify(datas));
}
}
catch(e)
{
addElement(divResponse, "p", serverError, "", ["error"]);
console.error(e);
}
});

235
front/src/manageGroups.js Normal file
View File

@ -0,0 +1,235 @@
// -- GESTION DU FORMULAIRE PERMETTANT DE SAISIR / ÉDITER LES INFOS DES GROUPES DE QUIZS
/// Vérifie que l'utilisateur est bien connecté, a le bon statut et le redirige vers le formulaire d'inscription si ce n'est pas le cas.
/// Si c'est ok, propose un moteur de recherche permettant de chercher un groupe.
/// Si un id est passé par l'url on affiche les informations du groupe dans un formulaire permettant de l'éditer/supprimer.
/// Si pas d'id passé par l'url, on affiche un formulaire vide permettant d'en saisir un nouveau.
// Fichier de configuration côté client :
import { apiUrl, availableLangs, theme } from "../../config/instance.js";
const lang=availableLangs[0];
const configQuestionnaires = require("../../config/questionnaires.js");
const configTemplate = require("../../views/"+theme+"/config/"+lang+".js");
// Fonctions utiles au script :
import { getLocaly, removeLocaly } from "./tools/clientstorage.js";
import { addElement } from "./tools/dom.js";
import { helloDev, updateAccountLink } from "./tools/everywhere.js";
import { empyForm, getDatasFromInputs, setAttributesToInputs } from "./tools/forms.js";
import { dateFormat, isEmpty, replaceAll } from "../../tools/main";
import { getUrlParams } from "./tools/url.js";
import { checkSession } from "./tools/users.js";
// Dictionnaires :
const { addOkMessage, serverError } = require("../../lang/"+lang+"/general");
const { infosGroupForAdmin, searchWithoutResult } = require("../../lang/"+lang+"/group");
const { needBeConnected } = require("../../lang/"+lang+"/user");
// Principaux éléments du DOM manipulés :
const divMain = document.getElementById("main-content");
const divMessage = document.getElementById("message");
const divResponse = document.getElementById("response");
const divCrash = document.getElementById("crash");
const divGroupIntro = document.getElementById("groupIntro");
const formGroup = document.getElementById("groups");
const deleteCheckBox = document.getElementById("deleteOkLabel");
const btnNewGroup = document.getElementById("wantNewGroup");
const formSearch = document.getElementById("search");
const divSearchResult = document.getElementById("searchResult");
helloDev();
const initialise = async () =>
{
try
{
const isConnected=await checkSession(["manager", "admin"], "/"+configTemplate.connectionPage, { message: needBeConnected, color:"error" }, window.location);
if(isConnected)
{
const user=getLocaly("user", true);
updateAccountLink(user.status, configTemplate);
divMain.style.display="block";
if(!isEmpty(getLocaly("message")))
{
addElement(divMessage, "p", getLocaly("message", true).message, "", [getLocaly("message", true).color], "", false);
removeLocaly("message");
}
// Initialisation du formulaire de recherche :
setAttributesToInputs(configQuestionnaires, formSearch);
// Fonction utile pour vider le formulaire, y compris les champs hidden, etc.
// Cache aussi certains champs en mode création
const emptyGroupForm = () =>
{
empyForm(formGroup);
// Case de suppression cachée par défaut, car inutile pour formulaire de création
deleteCheckBox.style.display="none";
}
emptyGroupForm();
// Initialise les contraintes du formulaire :
setAttributesToInputs(configQuestionnaires.Group, formGroup);
// Fonction affichant les infos connues concernant un utilisateur et son abonnement
const showFormGroupInfos = (id) =>
{
// on commence par tout vider, des fois que... :
emptyGroupForm();
const xhrGetInfos = new XMLHttpRequest();
xhrGetInfos.open("GET", apiUrl+configQuestionnaires.groupRoutes+configQuestionnaires.getGroupRoute+id);
xhrGetInfos.onreadystatechange = function()
{
if (this.readyState == XMLHttpRequest.DONE)
{
let response=JSON.parse(this.responseText);
if (this.status === 200 && response.Group != undefined)
{
const mapText =
{
GROUP_ID : response.Group.id,
DATE_CREA : dateFormat(response.Group.createdAt),
DATE_UPDATE : dateFormat(response.Group.updatedAt),
NB_ELEMENTS : (response.Group.Questionnaires!==undefined) ? response.Group.Questionnaires.length : 0
};
const groupIntro=replaceAll(infosGroupForAdmin, mapText);
addElement(divGroupIntro, "p", groupIntro, "", ["info"]);
for(let data in response.Group)
{
if(formGroup.elements[data]!==undefined)
{
if(data==="publishingAt" && response.Group[data]!==null)
formGroup.elements[data].value=dateFormat(response.Group[data], "form");// !! revoir car format pouvant poser soucis si navigateur ne gère pas les champs de type "date"
else
formGroup.elements[data].value=response.Group[data];
}
}
deleteCheckBox.style.display="block";
}
}
}
xhrGetInfos.setRequestHeader("Authorization", "Bearer "+user.token);
xhrGetInfos.send();
}
// Si un id est passé par l'url, on essaye d'afficher les infos :
let urlDatas=getUrlParams();
if(urlDatas && urlDatas.id!==undefined)
showFormGroupInfos(urlDatas.id);
// Besoin d'un coup de Kärcher ?
btnNewGroup.addEventListener("click", function(e)
{
emptyGroupForm();
});
// Envoi du formulaire des infos du groupe
formGroup.addEventListener("submit", function(e)
{
e.preventDefault();
divResponse.innerHTML="";
let datas=getDatasFromInputs(formGroup);
const xhrGroupDatas = new XMLHttpRequest();
if(!isEmpty(datas.id) && (datas.deleteOk!==undefined))
xhrGroupDatas.open("DELETE", apiUrl+configQuestionnaires.groupRoutes+"/"+datas.id);
else if(!isEmpty(datas.id))
xhrGroupDatas.open("PUT", apiUrl+configQuestionnaires.groupRoutes+"/"+datas.id);
else
xhrGroupDatas.open("POST", apiUrl+configQuestionnaires.groupRoutes);
xhrGroupDatas.onreadystatechange = function()
{
if (this.readyState == XMLHttpRequest.DONE)
{
let response=JSON.parse(this.responseText);
if (this.status === 201 && response.id!=undefined)
{
addElement(divResponse, "p", addOkMessage, "", ["success"]);
datas.id=response.id;
}
else if (this.status === 200 && response.message!=undefined)
{
if(Array.isArray(response.message))
response.message = response.message.join("<br>");
else
response.message = response.message;
addElement(divResponse, "p", response.message, "", ["success"]);
}
else if (response.errors)
{
if(Array.isArray(response.errors))
response.errors = response.errors.join("<br>");
else
response.errors = serverError;
addElement(divResponse, "p", response.errors, "", ["error"]);
}
else
addElement(divResponse, "p", serverError, "", ["error"]);
if(isEmpty(response.errors))
{
if(datas.deleteOk===undefined)
showFormGroupInfos(datas.id);
else
emptyGroupForm();
}
}
}
xhrGroupDatas.setRequestHeader("Content-Type", "application/json");
xhrGroupDatas.setRequestHeader("Authorization", "Bearer "+user.token);
if(datas)
xhrGroupDatas.send(JSON.stringify(datas));
});
// Traitement du lancement d'une recherche
formSearch.addEventListener("submit", function(e)
{
e.preventDefault();
let datas=getDatasFromInputs(formSearch);
const xhrSearch = new XMLHttpRequest();
xhrSearch.open("POST", apiUrl+configQuestionnaires.groupRoutes+configQuestionnaires.searchGroupsRoute);
xhrSearch.onreadystatechange = function()
{
if (this.readyState == XMLHttpRequest.DONE)
{
let response=JSON.parse(this.responseText);
if (this.status === 200 && Array.isArray(response))
{
if(response.length===0)
addElement(divSearchResult, "p", searchWithoutResult, "", ["info"]);// vérifier et importer texte
else
{
let selectHTML="<option value=''></option>";
for(let i in response)
selectHTML+="<option value='"+response[i].id+"'>"+response[i].title+"</option>";
addElement(divSearchResult, "select", selectHTML, "selectSearch");
const searchSelect=document.getElementById("selectSearch");
searchSelect.addEventListener("change", function()
{
if(searchSelect.value!=="")
showFormGroupInfos(searchSelect.value);
});
}
}
else if (response.errors)
{
if(Array.isArray(response.errors))
response.errors = response.errors.join("<br>");
else
response.errors = serverError;
addElement(divSearchResult, "p", response.errors, "", ["error"]);
}
else
addElement(divSearchResult, "p", serverError, "", ["error"]);
}
}
xhrSearch.setRequestHeader("Content-Type", "application/json");
xhrSearch.setRequestHeader("Authorization", "Bearer "+user.token);
if(datas)
xhrSearch.send(JSON.stringify(datas));
});
}
}
catch(e)
{
addElement(divCrash, "p", serverError, "", ["error"]);
console.error(e);
}
}
initialise();

View File

@ -10,10 +10,12 @@ module.exports =
connection: "./src/connection.js",
deconnection: "./src/deconnection.js",
deleteValidation: "./src/deleteValidation.js",
group: "./src/group.js",
homeManager: "./src/homeManager.js",
homeUser: "./src/homeUser.js",
index: "./src/index.js",
loginLink: "./src/loginLink.js",
manageGroups: "./src/manageGroups.js",
manageQuestionnaires: "./src/manageQuestionnaires.js",
manageUsers: "./src/manageUsers.js",
newLoginValidation: "./src/newLoginValidation.js",

View File

@ -5,7 +5,10 @@ module.exports =
alertNewWindow: "nouvelle fenêtre",
badUrl : "Tentative d'accès à une page n'existant pas :",
btnLinkToQuestionnaire : "Aller au quiz !",
btnProposeConnection: "Je me connecte.",
btnProposeSubscribe: "Je crée mon compte.",
deleteBtnTxt: "Supprimer",
deleteFailMessage : "La suppression de l'enregistrement #ID a échoué.",
deleteOkMessage : "La suppression a bien été enregistrée.",
failAuth : "Erreur d'authentification.",
failAuthCron : "Tentative de lancement d'un cron sans le bon token.",
@ -25,6 +28,7 @@ module.exports =
scriptTimingAlert : "*** Script lent : SCRIPT_TIMING millisecondes, route : SCRIPT_URL",
scriptTimingInfo : "Durée de la réponse : SCRIPT_TIMING millisecondes, route : SCRIPT_URL",
statsAdmin : "Durant les dernières 24h : NB_USERS_24H comptes ont été créés, NB_SUBSCRIPTIONS_24H validés et NB_USERS_DELETED_24H supprimés. NB_ANSWERS_24H réponses aux quizs ont été enregistrées.<br>En tout, il y a : NB_USERS_TOT comptes, dont NB_SUBSCRIPTIONS_TOT validés et NB_SUBSCRIPTIONS_PREMIUM comptes prémium. NB_ANSWERS_TOT réponses aux quizs ont été enregistrées.<br>Parmi les NB_USERS_DELETED_TOT comptes supprimés, NB_USERS_DELETED_VALIDED avaient validé leur compte et NB_USERS_DELETED_PREMIUM avaient souscrit un compte prémium.",
subscriptionCall: "Inscrivez-vous !",
updateBtnTxt: "Modifier",
updateOkMessage : "La mise à jour à jour a bien été enregistrée."
};

View File

@ -1,14 +1,24 @@
module.exports =
{
btnSendResponse: "Testez vos réponses.",
btnShareQuizTxt: "Partager ce quiz sur ",
commonIntroTxt: "Ce quiz vous permet de tester ce que vous avez retenu des textes qui vous ont été proposés à la lecture. Au besoin, cliquez sur le bouton précédent pour les relire.",
correctAnswerTxt: "Bonne réponse",
groupsName: "Quiz",// nom d'un groupe pour l'affichage dans les vues
haveBeenPublished: "#NB nouveaux groupes de quizs ont été publiés.",
infosGroupForAdmin: "Ce groupe de quizs a été créé le DATE_CREA, mise à jour la dernière fois le DATE_UPDATE.<br>Son identifiant est <b>GROUP_ID</b>. Il regroupe actuellement les questions de NB_ELEMENTS quizs.",
linkFirstElementGroup: "Retour à la première page.",
lastUpdated: "Dernière mise à jour, le ",
needCorrectPublishingDate: "La date de publication fournie n'a pas un format valide.",
needLanguage: "Vous devez sélectionner la langue de ce quiz.",
needNotTooLongTitle: "Le titre du quiz ne doit pas compter plus de 255 caractères.",
needTitle: "Merci de fournir un titre à votre quiz.",
needUniqueUrl: "L'url du quiz doit être unique.",
needUrl: "Merci de fournir l'url à votre quiz.",
/*
questionnairesName: "quiz",
questionnaireNeedBeCompleted: "Quiz incomplet",
needLanguage: "Vous devez sélectionner la langue de ce groupe de quizs.",
needNotTooLongTitle: "Le titre du groupe de quizs ne doit pas compter plus de 255 caractères.",
needTitle: "Merci de fournir un titre à ce groupe de quizs.",
needUniqueUrl: "L'url du groupe de quizs doit être unique.",
needUrl: "Merci de fournir l'url à ce groupe de quizs.",
notFound: "Le groupe de quizs (#SEARCH) n'a pas été trouvé.",
publishedAt: ", le",
publishedBy: "Quiz publié par"*/
publishedBy: "Quiz publié par",
searchIsNotLongEnough: "Vous devez saisir au moins #MIN caractères pour votre recherche.",
searchWithoutResult: "Aucun groupe n'a été trouvé pour votre recherche.",
wrongAnswerTxt: "Mauvaise réponse"
};

View File

@ -1,7 +1,7 @@
module.exports =
{
btnProposeConnection: "Je me connecte.",
btnProposeSubscribe: "Je crée mon compte.",
btnProposeConnection: "Je me connecte.",// déplacé dans general.js
btnProposeSubscribe: "Je crée mon compte.",//idem
btnSendResponse: "Testez vos réponses.",
btnShareQuizTxt: "Partager ce quiz sur ",
btnShowQuestionnaire: "Afficher le quiz !",

View File

@ -11,6 +11,10 @@ module.exports =
deleteInactiveUsersMessage: " comptes utilisateurs inactifs ont été supprimés.",
deleteOkMessage: "L'utilisateur a bien été supprimé.",
emailNotFound: "Aucun compte utilisateur n'a été trouvé pour cette adresse e-mail.",
formsEmailLabel: "E-mail :",
formsEmailPlaceholder: "Votre adresse e-mail",
formsCGUOkLabel: "J'accepte <a href=#link target=\"_blank\" rel=\"noopener\" title=\"À lire :)\">les Conditions Générale d'Utilisation</a> du site (requis).",
formsSubmitTxt: "Je m'abonne !",
godfatherFound: "Votre \"parrain\" a bien été trouvé :)",
godfatherNotFound: "Désolé mais aucun utilisateur n'a été trouvé pour ce code/e-mail parrain :(",
infosAdminGodfather: "Cet utilisateur a été parrainé par ",

View File

@ -12,7 +12,7 @@ const txtGeneral = require("../lang/"+config.adminLang+"/general");
module.exports = (sequelize, DataTypes) =>
{
const Group = sequelize.define("Groupe",
const Group = sequelize.define("Group",
{
title:
{
@ -28,7 +28,7 @@ module.exports = (sequelize, DataTypes) =>
{
args: [1, 255],
msg: txt.needNotTooLongTitle
}
}
}
},
slug:
@ -50,7 +50,7 @@ module.exports = (sequelize, DataTypes) =>
validate:
{
notNull: { msg: txt.needUrl }
}
}
},
introduction:
{
@ -85,10 +85,10 @@ module.exports = (sequelize, DataTypes) =>
collate: "utf8mb4_unicode_ci"
}
);
Questionnaire.associate = function(models)
Group.associate = function(models)
{
Questionnaire.hasMany(models.Questionnaire);
Questionnaire.belongsTo(models.User, { as: "Creator", foreignKey: { name: "CreatorId", allowNull: false } });
Group.hasMany(models.Questionnaire);
Group.belongsTo(models.User, { as: "Creator", foreignKey: { name: "CreatorId", allowNull: false } });
};
return Questionnaire;
return Group;
};

View File

@ -6,6 +6,7 @@ const cronAuth = require("../middleware/cronAuth");
const ctrlUser = require("../controllers/user");
const ctrlPause = require("../controllers/pause");
const ctrlQuestionnaire = require("../controllers/questionnaire");
const ctrlGroup = require("../controllers/group");
const ctrlQuestion = require("../controllers/question");
const ctrlIllustration = require("../controllers/illustration");
const ctrlSubscription = require("../controllers/subscription");
@ -28,6 +29,9 @@ router.get("/addquestionnairetouser/:token", cronAuth, ctrlSubscription.addNewQu
router.get("/deletequestionnairesfiles/:token", cronAuth, ctrlQuestionnaire.deleteJsonFiles);
router.get("/deletequestionsfiles/:token", cronAuth, ctrlQuestion.deleteJsonFiles);
router.get("/publishquestionnaires/:token", cronAuth, ctrlQuestionnaire.checkQuestionnairesNeedToBePublished);
// + Groupes
router.get("/deletegroupsfiles/:token", cronAuth, ctrlGroup.deleteJsonFiles);
router.get("/publishgroups/:token", cronAuth, ctrlGroup.checkGroupsNeedToBePublished);
// Illustrations des questionnaires
router.get("/deleteoldillustrations/:token", cronAuth, ctrlIllustration.deleteOldFiles);

15
routes/group.js Normal file
View File

@ -0,0 +1,15 @@
const express = require("express");
const router = express.Router();
const auth = require("../middleware/authAdmin");
const groupCtrl = require("../controllers/group");
router.post("/search", auth, groupCtrl.searchGroups);
router.post("/", auth, groupCtrl.create);
router.get("/stats", auth, groupCtrl.getStatsGroups);
router.put("/:id", auth, groupCtrl.modify);
router.delete("/:id", auth, groupCtrl.delete);
router.get("/get/:id", auth, groupCtrl.getOneById);
module.exports = router;

View File

@ -1,5 +1,6 @@
module.exports =
{
// liens de l'interface
headLinks:
[
{ anchor: "Contact", attributes: { href:"/contact.html", rel: "nofollow" } },
@ -16,27 +17,28 @@ module.exports =
{ anchor: "Données personnelles", attributes: { href:"/donnees.html", title:"Vos données personnelles sur WikiLerni" } },
{ anchor: "CGV & CGU", attributes: { href:"/CGV-CGU.html", rel: "nofollow" } }
],
maxQuestionnairesSiteHomePage: 3,
maxQuestionnairesByPage: 12,
nbQuestionnairesUserHomePage : 3,
accountPage: "compte.html",
aboutPage: "a-propos.html",
adminHomePage: "admin.html",
cguPage: "CGV-CGU.html",
connectionPage : "connexion.html",
deleteLinkPage : "aurevoir.html?t=",
loginLinkPage : "login.html?t=",
managerHomePage : "gestion.html",
newLoginLinkPage : "newlogin.html?t=",
questionnairesManagementPage: "gestion-quizs.html",
stopMailPage : "stop-mail.html?t=",
subscribePage : "inscription.html",
updateAccountPage: "compte.html",
userHomePage : "accueil.html",
userHomePageTxt : "Ma page d'accueil.",
adminHomePage : "admin.html",
managerHomePage : "gestion.html",
subscribePage : "inscription.html",
connectionPage : "connexion.html",
validationLinkPage : "validation.html?t=",
loginLinkPage : "login.html?t=",
newLoginLinkPage : "newlogin.html?t=",
deleteLinkPage : "aurevoir.html?t=",
stopMailPage : "stop-mail.html?t=",
accountPage: "compte.html",
updateAccountPage: "compte.html",
questionnairesManagementPage: "gestion-quizs.html",
usersManagementPage: "gestion-utilisateurs.html",
aboutPage: "a-propos.html",
illustrationDir : "/img/quizs/",
validationLinkPage : "validation.html?t=",
/* Textes (général) */
siteSlogan: "Cultivons notre jardin !",
noJSNotification: "Désolé, mais pour l'instant, l'utilisation de WikiLerni nécessite l'activation du JavaScript.",
mailRecipientTxt: "Message envoyé à :",
/* Page d'accueil */
homePageTxt: "Page d'accueil",
homeTitle1: "De nature curieuse ?",
homeP1: "Avec WikiLerni, vous apprenez chaque jour de nouvelles choses.<br>Des articles de Wikipédia sont sélectionnés pour vous et sont suivis d'un quiz vous permettant de tester ce que vous en avez retenu.<br>De jour en jour de nouvelles graines de savoir sont ainsi semées dans votre \"jardin\".",
@ -44,13 +46,20 @@ module.exports =
homeP2: "Tout comme sur Wikipédia <span class='postscriptum'>(*)</span>, le logiciel et le contenu partagé sur WikiLerni <a href=\"/credits.html\" title=\"En savoir plus\">sont libres</a>.<br>Sur WikiLerni, pas de publicité, ni de commercialisation de vos données.<br>Vous pouvez venir y \"cultiver votre jardin\" en toute tranquillité.",
homeBtnAboutTxt: "En savoir plus sur WikiLerni ?",
homeBtnSubscribeTxt: "Tester WikiLerni gratuitement.",
/* Page dernières publications... */
newQuestionnairesTitle: "Culture générale - apprenez de nouvelles choses avec WikiLerni",
newQuestionnairesIntro: "WikiLerni : testez vos connaissances et apprenez de nouvelles choses avec les quizs WikiLerni.",
newsListTitle: "<h3>1 article Wikipédia + 1 quiz = 1 WikiLerni</h3><p>WikiLerni, ce sont plusieurs quizs publiés chaque semaine, chacun associé à un article Wikipédia.<br>Sans publicité, ni commerce de vos données, <b>vous apprenez de nouvelles choses en toute liberté</b>.</p><blockquote>Aristote: «Lhomme a naturellement la passion de connaître…»</blockquote>",
/* Page quizs */
quizElementLinksIntro: "Source(s)",
quizElementSubcriptionFormTitle: "Recevez les prochains WikiLerni",
explanationTitle: "Vous découvrez WikiLerni ?",
explanationTxt: "<p>Le principe est simple : <b>vous commencez par lire l'article Wikipédia dont le lien vous est proposé</b>.<br>Puis vous <b>afficher le quiz pour vérifier ce que vous avez retenu de votre lecture</b>.</p><p>Suivant les questions, <b>une ou plusieurs réponses peuvent être correctes</b> et doivent donc être cochées.<br>C'est toujours <b>le contenu de l'article Wikipédia qui fait foi</b> concernant les \"bonnes\" réponses.<br>Mais les articles de Wikipédia peuvent évoluer, donc n'hésitez pas <a href='/contact.html'>à me signaler une erreur</a>.</p><h3>Pas le temps de lire l'article Wikipédia ?</h3><p>Il est vrai que certains sont longs ! :-)<br>Dans ce cas, <b>essayez simplement de répondre avec vos connaissances actuelles</b>.<br>Il n'est pas nécessaire de répondre à toutes les questions pour obtenir les réponses.<br>Après validation, vous verrez apparaître les bonnes réponses + un extrait de l'article Wikipédia.<br>Vous pouvez ainsi <b>apprendre de nouvelles choses en quelques minutes</b>.</p><p>Une autre possibilité est d'afficher le quiz avant d'aller chercher les réponses dans l'article Wikipédia... Elles y sont toutes !</p><p><b>Il n'y a pas de bonne façon de faire</b>, et dans tous les cas <b>vous apprenez des choses sur des sujets très variés, ce qui est le but de WikiLerni</b>.</p><p>Quand le sujet s'y prête, ne vous étonnez pas si certaines des réponses proposées peuvent être un peu décalées, absurdes... On peut apprendre avec le sourire, non ? :)</p><p>Une fois votre résultat obtenu, il vous sera proposé de créer un compte pour le sauvegarder. Ce compte vous permettra de <b>tester de nouveau ce quiz</b> pour vérifier ce que vous en avez retenu après plusieurs jours, semaines, mois... Grâce à ce compte, vous pourrez aussi <b>recevoir régulièrement de nouveaux quizs</b> pour continuer à \"cultiver votre jardin\".</p>",
questionnaireLicenceTxt: "Ce quiz <a href=\"/credits.html\">est libre</a>, mais il n'est pas gratuit. Vous pouvez <a href=\"/participer-financement.html\">participer à son financement en cliquant ici</a>.",
noJSNotification: "Désolé, mais pour l'instant, l'utilisation de WikiLerni nécessite l'activation du JavaScript.",
newsListTitle: "<h3>1 article Wikipédia + 1 quiz = 1 WikiLerni</h3><p>WikiLerni, ce sont plusieurs quizs publiés chaque semaine, chacun associé à un article Wikipédia.<br>Sans publicité, ni commerce de vos données, <b>vous apprenez de nouvelles choses en toute liberté</b>.</p><blockquote>Aristote: «Lhomme a naturellement la passion de connaître…»</blockquote>",
mailRecipientTxt: "Message envoyé à :",
twitterAccount: "@WikiLerni",
/* Autres */
illustrationDir : "/img/quizs/",
twitterAccount: "WikiLerni",
maxQuestionnairesByPage: 12,
maxQuestionnairesSiteHomePage: 3,
nbQuestionnairesUserHomePage : 3,
};

View File

@ -0,0 +1,99 @@
extends layout.pug
block append scripts
script(src="/JS/polyfill.app.js" defer)
script(src="/JS/groupElement.app.js" defer)
block content
div(id="tags" class="cardboard")
ul
li
a(href="/") #{config.siteName}
for tag in questionnaire.Tags
li
a(href="/quizs/"+tag.slug+".html") #{tag.name}
-
const imgAttributes = { alt: txtIllustration.defaultAlt, style: "opacity: 0.0;" };
if(questionnaire.Illustrations!=undefined && questionnaire.Illustrations.length!==0)
{
if (tool.isEmpty(questionnaire.Illustrations[0].alt)===false)
imgAttributes.alt=questionnaire.Illustrations[0].alt;
if(tool.isEmpty(questionnaire.Illustrations[0].title)===false)
imgAttributes.title=questionnaire.Illustrations[0].title;
}
const publishedAtTxt=tool.dateFormat(questionnaire.Questionnaire.publishingAt, questionnaire.Questionnaire.language);
const updatedAtTxt=tool.dateFormat(questionnaire.Questionnaire.updatedAt, questionnaire.Questionnaire.language);
if(questionnaire.Illustrations!=undefined && questionnaire.Illustrations.length!==0)
div(id="content-picture" class="cardboard")
div(style="background-image: url('/img/quizs/"+questionnaire.Illustrations[0].url+"');")
img(src="/img/quizs/"+questionnaire.Illustrations[0].url)&attributes(imgAttributes)
//- Important : ici, on garde volontairement le html saisi car lien possible vers auteur de l'illustration :
if(questionnaire.Illustrations[0].caption)
p !{questionnaire.Illustrations[0].caption}
div(id="content-side")
div(id="content-title")
h1(class="cardboard")
img(id="required-time-icon" src="/themes/wikilerni/img/time-required-"+questionnaire.Questionnaire.estimatedTime+".png" title=txtQuestionnaire.estimatedTime+" "+txtQuestionnaire.estimatedTimeOption[questionnaire.Questionnaire.estimatedTime])
span #{questionnaire.Questionnaire.title}
if(questionnaire.Illustrations!=undefined && questionnaire.Illustrations.length!==0)
a(href="/img/quizs/"+questionnaire.Illustrations[0].url target="_blank" rel="noopener")
img(src="/img/quizs/min/"+questionnaire.Illustrations[0].url class="thumb")&attributes(imgAttributes)
div(id="content-title-corner")
div(id="content" class="cardboard")
p(id="author-date") #{txtQuestionnaire.publishedBy} #{author}#{txtQuestionnaire.publishedAt} #{publishedAtTxt}. #{txtQuestionnaire.lastUpdated}#{updatedAtTxt}.
//- Important : ici, on garde volontairement le html !
if(questionnaire.Questionnaire.introduction)
div#introduction !{questionnaire.Questionnaire.introduction}
div#links
h4 #{configTpl.quizElementLinksIntro}
ul#quizElementLinks
for link in questionnaire.Links
li
a(href=link.url target="_blank" rel="noopener" title=link.anchor+" ("+txtGeneral.alertNewWindow+")") #{link.anchor}
// - lien vers élément suivant ou quiz si dernier ?
//div#links
// a(href=link.url class="button cardboard" target="_blank" rel="noopener" title=link.anchor+" ("+txtGeneral.alertNewWindow+")") #{link.anchor}
div#licence
p !{configTpl.questionnaireLicenceTxt}
noscript
div
strong #{configTpl.noJSNotification}
-
const cguOkLabel = txtUsers.formsEmailLabel.replace("#link", "/"+configTpl.cguPage);
div#signup
form(id="subscription" method="POST" class="needJS")
h3 #{configTpl.quizElementSubcriptionFormTitle}
fieldset
label(for="email") #{txtUsers.formsEmailLabel}
input(id="email" type="email" name="email" placeholder=txtUsers.formsEmailPlaceholder class="cardboard")
div#emailMessage
ul(class="checkbox_li")
li(class="checkbox_li")
label(for="cguOk" class="check")
input(type="checkbox" id="cguOk" name="cguOk" value="true")
div(class="checkbox_override")
span #{cguOkLabel}
div(class="input_wrapper")
input(id="submitDatas" type="submit" value=txtUsers.formsSubmitTxt class="cardboard")
div(id="response")
div#zerozozio
a(href="http://sharetodiaspora.github.io/?url="+linkCanonical+"&title="+questionnaire.Questionnaire.title rel="nofollow noopener" title=txtQuestionnaire.btnShareQuizTxt+" diaspora* ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/diaspora.png" alt=txtQuestionnaire.btnShareQuizTxt+" diaspora*")
a(href="https://www.facebook.com/sharer.php?u="+linkCanonical rel="nofollow noopener" title=txtQuestionnaire.btnShareQuizTxt+" facebook ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/facebook.png" alt=txtQuestionnaire.btnShareQuizTxt+" facebook")
a(href="https://twitter.com/intent/tweet?url="+linkCanonical+"&text="+questionnaire.Questionnaire.title+" via @"+configTpl.twitterAccount rel="nofollow noopener" title=txtQuestionnaire.btnShareQuizTxt+" twitter ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/twitter.png" alt=txtQuestionnaire.btnShareQuizTxt+" twitter")
div#explanations(class="engraved framed")
h3#explanationsTitle #{configTpl.explanationTitle}
div#explanationsContent
p !{configTpl.explanationTxt}

View File

@ -0,0 +1,72 @@
extends layout.pug
block append scripts
script(src="/JS/polyfill.app.js" defer)
script(src="/JS/group.app.js" defer)
block content
div(id="content-side")
div(id="content-title")
h1(class="cardboard")
span #{group.title}
div(id="content-title-corner")
div(id="content" class="cardboard")
p(id="author-date") #{txtGroups.publishedBy} #{author}#{txtGroups.publishedAt} #{publishedAtTxt}. #{txtGroups.lastUpdated}#{updatedAtTxt}.
div#introduction
if(group.introduction)
div !{group.introduction}//- Important : ici, on garde volontairement le html, car cela est accepté pour l'introduction.
div txtGroups.commonIntroTxt
// - lien vers premier élément du groupe
div#links
a(href=="/"+configQuestionnaires.dirdirWebQuestionnaires+group.Questionnaires[0].slug+".html" class="button cardboard" title=txtGroups.linkFirstElementGroup) #{txtGroups.linkFirstElementGroup}
div#licence
p !{configTpl.groupLicenceTxt}
noscript
div
strong #{configTpl.noJSNotification}
form(id="group" method="POST" class="needJS")
h2 #{group.title}
div#response
div(class="subscribeBtns")
p
a(class="button cardboard" href=configTpl.subscribePage) #{txtGeneral.btnProposeSubscribe}
p
a(class="button cardboard" href=configTpl.connectionPage) #{txtGeneral.btnProposeConnection}
for questionnaire in group.Questionnaires
for question in questionnaire.Questions
p(id="question_"+question.Question.id) #{question.Question.text}
if(question.Question.explanation)
blockquote(class="help" id="help_"+question.Question.id cite=group.Links[0].url) #{txtexplanationBeforeTxt} #{question.Question.explanation}
ul(class="checkbox_li")
for response in question.Choices
li(class="checkbox_li")
label(class="check" for="response_"+response.id)
input(type="checkbox" name="response_"+response.id id="response_"+response.id)
div(class="checkbox_override")
span(class="wrongResponse")
img(src="/themes/wikilerni/img/wrong-min.png" title=txtGroups.wrongAnswerTxt)
span(class="rightResponse")
img(src="/themes/wikilerni/img/correct-min.png" title=txtGroups.correctAnswerTxt)
em #{response.text}
input(type="hidden" name="isCorrect_response_"+response.id id="isCorrect_response_"+response.id value=""+response.isCorrect)
input(type="hidden" name="question_id_response_"+response.id id="question_id_response_"+response.id value=question.Question.id)
input(name="groupId" id="groupId" value=group.id type="hidden")
p
span(class="input_wrapper")
input(id="checkResponses" type="submit" value=txtGroups.btnSendResponse class="cardboard" title=txtGroups.btnSendResponse)
div#zerozozio
a(href="http://sharetodiaspora.github.io/?url="+linkCanonical+"&title="+group.title rel="nofollow noopener" title=txtGroups.btnShareQuizTxt+" diaspora* ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/diaspora.png" alt=txtGroups.btnShareQuizTxt+" diaspora*")
a(href="https://www.facebook.com/sharer.php?u="+linkCanonical rel="nofollow noopener" title=txtGroups.btnShareQuizTxt+" facebook ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/facebook.png" alt=txtGroups.btnShareQuizTxt+" facebook")
a(href="https://twitter.com/intent/tweet?url="+linkCanonical+"&text="+group.title+" via @"+configTpl.twitterAccount rel="nofollow noopener" title=txtGroups.btnShareQuizTxt+" twitter ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/twitter.png" alt=txtGroups.btnShareQuizTxt+" twitter")
div#explanations(class="engraved framed")
h3#explanationsTitle #{configTpl.explanationTitle}
div#explanationsContent
p !{configTpl.explanationTxt}

View File

@ -99,7 +99,7 @@ block content
img(src="/themes/wikilerni/img/diaspora.png" alt=txtQuestionnaire.btnShareQuizTxt+" diaspora*")
a(href="https://www.facebook.com/sharer.php?u="+linkCanonical rel="nofollow noopener" title=txtQuestionnaire.btnShareQuizTxt+" facebook ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/facebook.png" alt=txtQuestionnaire.btnShareQuizTxt+" facebook")
a(href="https://twitter.com/intent/tweet?url="+linkCanonical+"&text="+questionnaire.Questionnaire.title+" via "+configTpl.twitterAccount rel="nofollow noopener" title=txtQuestionnaire.btnShareQuizTxt+" twitter ("+txtGeneral.alertNewWindow+")" target="_blank")
a(href="https://twitter.com/intent/tweet?url="+linkCanonical+"&text="+questionnaire.Questionnaire.title+" via @"+configTpl.twitterAccount rel="nofollow noopener" title=txtQuestionnaire.btnShareQuizTxt+" twitter ("+txtGeneral.alertNewWindow+")" target="_blank")
img(src="/themes/wikilerni/img/twitter.png" alt=txtQuestionnaire.btnShareQuizTxt+" twitter")
div#explanations(class="engraved framed")