extended admin tool for finished lessons
All checks were successful
Deploy to production / deploy (push) Successful in 2m54s
All checks were successful
Deploy to production / deploy (push) Successful in 2m54s
This commit is contained in:
@@ -34,6 +34,7 @@ class AdminController {
|
||||
this.getUsers = this.getUsers.bind(this);
|
||||
this.updateUser = this.updateUser.bind(this);
|
||||
this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this);
|
||||
this.markUserVocabLessonsCompleteThrough = this.markUserVocabLessonsCompleteThrough.bind(this);
|
||||
this.getUserVocabCourses = this.getUserVocabCourses.bind(this);
|
||||
this.getVocabCourseForAdmin = this.getVocabCourseForAdmin.bind(this);
|
||||
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
|
||||
@@ -151,6 +152,34 @@ class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
async markUserVocabLessonsCompleteThrough(req, res) {
|
||||
const schema = Joi.object({
|
||||
courseId: Joi.number().integer().positive().required(),
|
||||
throughLessonNumber: Joi.number().integer().positive().required()
|
||||
});
|
||||
const { error, value } = schema.validate(req.body || {});
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
try {
|
||||
const { userid: requester } = req.headers;
|
||||
const { id } = req.params;
|
||||
const result = await AdminService.adminMarkUserVocabLessonsCompleteThrough(
|
||||
requester,
|
||||
id,
|
||||
value.courseId,
|
||||
value.throughLessonNumber
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
let status = 500;
|
||||
if (err.message === 'noaccess') status = 403;
|
||||
else if (err.message === 'notenrolled') status = 403;
|
||||
else if (err.message === 'badrequest') status = 400;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getUserVocabCourses(req, res) {
|
||||
try {
|
||||
const { userid: requester } = req.headers;
|
||||
|
||||
@@ -27,6 +27,11 @@ router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adm
|
||||
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
|
||||
router.get('/users/:id/vocab-courses', authenticate, adminController.getUserVocabCourses);
|
||||
router.post('/users/:id/vocab-lesson-progress/reset', authenticate, adminController.resetUserVocabLessonProgress);
|
||||
router.post(
|
||||
'/users/:id/vocab-lesson-progress/mark-complete-through',
|
||||
authenticate,
|
||||
adminController.markUserVocabLessonsCompleteThrough
|
||||
);
|
||||
router.get('/vocab/courses/:courseId', authenticate, adminController.getVocabCourseForAdmin);
|
||||
router.get('/users/:id', authenticate, adminController.getUser);
|
||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||
|
||||
@@ -1974,6 +1974,28 @@ class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
async adminMarkUserVocabLessonsCompleteThrough(requesterHashedId, targetHashedId, courseId, throughLessonNumber) {
|
||||
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
const vocab = new VocabService();
|
||||
try {
|
||||
return await vocab.adminMarkLessonsCompleteThrough(
|
||||
targetHashedId,
|
||||
Number(courseId),
|
||||
Number(throughLessonNumber)
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.status === 403) {
|
||||
throw new Error('notenrolled');
|
||||
}
|
||||
if (e.status === 400) {
|
||||
throw new Error('badrequest');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async adminListUserEnrolledVocabCourses(requesterHashedId, targetHashedId) {
|
||||
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
|
||||
@@ -2609,6 +2609,101 @@ export default class VocabService {
|
||||
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin: Alle Lektionen eines Kurses bis einschließlich lesson_number als abgeschlossen setzen
|
||||
* (nur Zeilen, die noch nicht completed sind). Nur bei eingeschriebenem Nutzer.
|
||||
*/
|
||||
async adminMarkLessonsCompleteThrough(targetHashedUserId, courseId, throughLessonNumber) {
|
||||
const user = await this._getUserByHashedId(targetHashedUserId);
|
||||
const cid = Number(courseId);
|
||||
const maxNum = Number(throughLessonNumber);
|
||||
if (!Number.isFinite(cid) || cid < 1) {
|
||||
const err = new Error('Invalid courseId');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!Number.isFinite(maxNum) || maxNum < 1) {
|
||||
const err = new Error('Invalid throughLessonNumber');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const enrollment = await VocabCourseEnrollment.findOne({
|
||||
where: { userId: user.id, courseId: cid }
|
||||
});
|
||||
if (!enrollment) {
|
||||
const err = new Error('Not enrolled in this course');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lessons = await VocabCourseLesson.findAll({
|
||||
where: { courseId: cid, lessonNumber: { [Op.lte]: maxNum } },
|
||||
order: [['lessonNumber', 'ASC']]
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const details = [];
|
||||
|
||||
for (const lesson of lessons) {
|
||||
const lessonData = lesson.get({ plain: true });
|
||||
const targetScore = lessonData.targetScorePercent || 80;
|
||||
|
||||
const [progress] = await VocabCourseProgress.findOrCreate({
|
||||
where: { userId: user.id, lessonId: lesson.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
courseId: cid,
|
||||
lessonId: lesson.id,
|
||||
completed: false,
|
||||
score: 0,
|
||||
lessonState: {}
|
||||
}
|
||||
});
|
||||
|
||||
if (progress.completed) {
|
||||
details.push({
|
||||
lessonNumber: lesson.lessonNumber,
|
||||
lessonId: lesson.id,
|
||||
status: 'unchanged'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const mergedState = this._applyScheduledReviewState(
|
||||
this._sanitizeLessonState(progress.lessonState),
|
||||
{
|
||||
previousCompleted: false,
|
||||
nextCompleted: true,
|
||||
shouldAdvanceReview: true,
|
||||
lessonData,
|
||||
now
|
||||
}
|
||||
);
|
||||
|
||||
await progress.update({
|
||||
completed: true,
|
||||
completedAt: now,
|
||||
score: Math.max(Number(progress.score) || 0, targetScore),
|
||||
lastAccessedAt: now,
|
||||
lessonState: mergedState
|
||||
});
|
||||
|
||||
details.push({
|
||||
lessonNumber: lesson.lessonNumber,
|
||||
lessonId: lesson.id,
|
||||
status: 'marked_complete'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
courseId: cid,
|
||||
throughLessonNumber: maxNum,
|
||||
lessonsConsidered: lessons.length,
|
||||
details
|
||||
};
|
||||
}
|
||||
|
||||
// ========== GRAMMAR EXERCISE METHODS ==========
|
||||
|
||||
async getExerciseTypes() {
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
"loadCoursesError": "Dili makarga ang lista sa mga kurso.",
|
||||
"loadingLessons": "Nagkarga sa mga leksiyon …"
|
||||
},
|
||||
"vocabLessonMarkComplete": {
|
||||
"divider": "Ayuhon ang pag-uswag (dili paghimo og peke nga resulta sa ehersisyo)",
|
||||
"throughLabel": "Tanang leksiyon hangtod sa numero (lakip)",
|
||||
"hint": "I-mark ang kulang o abli nga mga row nga nahuman, lakip ang target score ug unang review wave. Ang nahuman na dili usbon.",
|
||||
"submit": "I-mark nga nahuman hangtod dinhi",
|
||||
"confirm": "I-mark nga nahuman ang tanang leksiyon nga numero ≤ {n} ni {username} niining kurso?",
|
||||
"success": "{marked} ka leksiyon nga bag-ong gi-mark nga nahuman ({unchanged} klaro nang nahuman).",
|
||||
"successNone": "Walay pagbag-o: tanang leksiyon nga naapektuhan ({unchanged}) klaro nang nahuman.",
|
||||
"error": "Dili ma-mark nga nahuman."
|
||||
},
|
||||
"rights": {
|
||||
"add": "Idugang ang katungod",
|
||||
"select": "Palihog pagpili",
|
||||
|
||||
@@ -45,6 +45,16 @@
|
||||
"loadCoursesError": "Die Kursliste konnte nicht geladen werden.",
|
||||
"loadingLessons": "Lektionen werden geladen …"
|
||||
},
|
||||
"vocabLessonMarkComplete": {
|
||||
"divider": "Fortschritt reparieren (ohne Übungsergebnisse zu fälschen)",
|
||||
"throughLabel": "Alle Lektionen bis Lektionsnummer (einschließlich)",
|
||||
"hint": "Setzt fehlende oder offene Einträge auf „abgeschlossen“, inkl. Ziel-Score und erster Review-Welle. Bereits abgeschlossene Lektionen bleiben unverändert.",
|
||||
"submit": "Bis hier als abgeschlossen markieren",
|
||||
"confirm": "Alle Lektionen mit Nummer ≤ {n} für {username} in diesem Kurs als abgeschlossen markieren?",
|
||||
"success": "{marked} Lektion(en) neu als abgeschlossen gesetzt ({unchanged} waren bereits erledigt).",
|
||||
"successNone": "Keine Änderung: alle betroffenen Lektionen ({unchanged}) waren bereits abgeschlossen.",
|
||||
"error": "Markieren fehlgeschlagen."
|
||||
},
|
||||
"adultVerification": {
|
||||
"title": "[Admin] - Erotik-Freigaben",
|
||||
"intro": "Volljährige Nutzer können den Erotikbereich beantragen. Hier werden Anfragen geprüft und freigegeben oder abgelehnt.",
|
||||
|
||||
@@ -45,6 +45,16 @@
|
||||
"loadCoursesError": "Could not load the course list.",
|
||||
"loadingLessons": "Loading lessons…"
|
||||
},
|
||||
"vocabLessonMarkComplete": {
|
||||
"divider": "Repair progress (does not fabricate exercise answers)",
|
||||
"throughLabel": "All lessons up to and including lesson number",
|
||||
"hint": "Marks missing or open rows as completed, including target score and first review wave. Already completed lessons are left unchanged.",
|
||||
"submit": "Mark through here as completed",
|
||||
"confirm": "Mark every lesson with number ≤ {n} for {username} in this course as completed?",
|
||||
"success": "{marked} lesson(s) newly marked complete ({unchanged} were already done).",
|
||||
"successNone": "No change: all affected lessons ({unchanged}) were already completed.",
|
||||
"error": "Could not mark lessons complete."
|
||||
},
|
||||
"adultVerification": {
|
||||
"title": "[Admin] - Erotic approvals",
|
||||
"intro": "Adult users can request access to the erotic area. Requests can be reviewed, approved or rejected here.",
|
||||
|
||||
@@ -45,6 +45,16 @@
|
||||
"loadCoursesError": "No se pudo cargar la lista de cursos.",
|
||||
"loadingLessons": "Cargando lecciones…"
|
||||
},
|
||||
"vocabLessonMarkComplete": {
|
||||
"divider": "Reparar progreso (no inventa resultados de ejercicios)",
|
||||
"throughLabel": "Todas las lecciones hasta el número (incluido)",
|
||||
"hint": "Marca filas faltantes o abiertas como completadas, con puntuación objetivo y primera ola de repaso. Las ya completadas no se cambian.",
|
||||
"submit": "Marcar hasta aquí como completadas",
|
||||
"confirm": "¿Marcar todas las lecciones con número ≤ {n} para {username} en este curso como completadas?",
|
||||
"success": "{marked} lección(es) marcadas como completadas ({unchanged} ya estaban hechas).",
|
||||
"successNone": "Sin cambios: todas las lecciones afectadas ({unchanged}) ya estaban completadas.",
|
||||
"error": "No se pudo marcar como completadas."
|
||||
},
|
||||
"adultVerification": {
|
||||
"title": "[Admin] - Aprobaciones eróticas",
|
||||
"intro": "Los usuarios adultos pueden solicitar acceso al área erótica. Aquí se revisan, aprueban o rechazan las solicitudes.",
|
||||
|
||||
@@ -82,6 +82,31 @@
|
||||
{{ vocabResetSubmitting ? $t('general.loading') : $t('admin.vocabLessonReset.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="vocabCourseLessons.length">
|
||||
<p class="vocab-reset__divider">{{ $t('admin.vocabLessonMarkComplete.divider') }}</p>
|
||||
<label class="edit__field">
|
||||
<span>{{ $t('admin.vocabLessonMarkComplete.throughLabel') }}</span>
|
||||
<input
|
||||
v-model.number="vocabMarkThroughNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="vocabMaxLessonNumber"
|
||||
:disabled="loadingVocabCourseDetail || vocabMarkSubmitting"
|
||||
class="vocab-reset__number"
|
||||
/>
|
||||
</label>
|
||||
<p class="vocab-reset__hint">{{ $t('admin.vocabLessonMarkComplete.hint') }}</p>
|
||||
<div class="vocab-reset__row">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!canMarkVocabThrough || vocabMarkSubmitting || loadingVocabCourseDetail"
|
||||
@click="adminMarkVocabLessonsThrough"
|
||||
>
|
||||
{{ vocabMarkSubmitting ? $t('general.loading') : $t('admin.vocabLessonMarkComplete.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -105,6 +130,8 @@ export default {
|
||||
loadingVocabCourses: false,
|
||||
loadingVocabCourseDetail: false,
|
||||
vocabResetSubmitting: false,
|
||||
vocabMarkThroughNumber: null,
|
||||
vocabMarkSubmitting: false,
|
||||
vocabLoadError: false
|
||||
};
|
||||
},
|
||||
@@ -125,6 +152,15 @@ export default {
|
||||
selectLabel: dup ? `${c.title} (#${c.id})` : (c.title || `#${c.id}`)
|
||||
};
|
||||
});
|
||||
},
|
||||
vocabMaxLessonNumber() {
|
||||
const list = this.vocabCourseLessons;
|
||||
if (!list.length) return 1;
|
||||
return Math.max(...list.map((l) => Number(l.lessonNumber) || 0));
|
||||
},
|
||||
canMarkVocabThrough() {
|
||||
const n = Number(this.vocabMarkThroughNumber);
|
||||
return Number.isFinite(n) && n >= 1 && n <= this.vocabMaxLessonNumber;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -135,6 +171,7 @@ export default {
|
||||
this.selectedVocabCourseId = '';
|
||||
this.vocabCourseLessons = [];
|
||||
this.selectedVocabLessonId = '';
|
||||
this.vocabMarkThroughNumber = null;
|
||||
},
|
||||
async select(u) {
|
||||
const res = await apiClient.get(`/api/admin/users/${u.id}`);
|
||||
@@ -168,6 +205,7 @@ export default {
|
||||
this.selectedVocabCourseId = '';
|
||||
this.vocabCourseLessons = [];
|
||||
this.selectedVocabLessonId = '';
|
||||
this.vocabMarkThroughNumber = null;
|
||||
} catch (e) {
|
||||
console.error('[UsersView] vocab courses:', e);
|
||||
this.vocabCourses = [];
|
||||
@@ -179,6 +217,7 @@ export default {
|
||||
},
|
||||
async onVocabCourseChange() {
|
||||
this.selectedVocabLessonId = '';
|
||||
this.vocabMarkThroughNumber = null;
|
||||
this.vocabCourseLessons = [];
|
||||
if (!this.selectedVocabCourseId) {
|
||||
return;
|
||||
@@ -232,6 +271,45 @@ export default {
|
||||
} finally {
|
||||
this.vocabResetSubmitting = false;
|
||||
}
|
||||
},
|
||||
adminMarkVocabLessonsThrough() {
|
||||
if (!this.selected || !this.selectedVocabCourseId || !this.canMarkVocabThrough || this.vocabMarkSubmitting) {
|
||||
return;
|
||||
}
|
||||
const n = Number(this.vocabMarkThroughNumber);
|
||||
const msg = this.$t('admin.vocabLessonMarkComplete.confirm', {
|
||||
n,
|
||||
username: this.selected.username
|
||||
});
|
||||
if (!window.confirm(msg)) {
|
||||
return;
|
||||
}
|
||||
this.runAdminVocabMarkThrough();
|
||||
},
|
||||
async runAdminVocabMarkThrough() {
|
||||
this.vocabMarkSubmitting = true;
|
||||
try {
|
||||
const { data } = await apiClient.post(
|
||||
`/api/admin/users/${this.selected.id}/vocab-lesson-progress/mark-complete-through`,
|
||||
{
|
||||
courseId: Number(this.selectedVocabCourseId),
|
||||
throughLessonNumber: Number(this.vocabMarkThroughNumber)
|
||||
}
|
||||
);
|
||||
const details = Array.isArray(data?.details) ? data.details : [];
|
||||
const marked = details.filter((d) => d.status === 'marked_complete').length;
|
||||
const unchanged = details.filter((d) => d.status === 'unchanged').length;
|
||||
const msgKey =
|
||||
marked === 0 && unchanged > 0
|
||||
? 'admin.vocabLessonMarkComplete.successNone'
|
||||
: 'admin.vocabLessonMarkComplete.success';
|
||||
this.$root?.$refs?.messageDialog?.open?.(this.$t(msgKey, { marked, unchanged }));
|
||||
} catch (e) {
|
||||
console.error('[UsersView] admin vocab mark complete:', e);
|
||||
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonMarkComplete.error'));
|
||||
} finally {
|
||||
this.vocabMarkSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -303,6 +381,18 @@ export default {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vocab-reset__divider {
|
||||
margin: 8px 0 0;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.vocab-reset__number {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.vocab-reset__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user