diff --git a/api/urls.py b/api/urls.py index fc71b98e..a2716880 100644 --- a/api/urls.py +++ b/api/urls.py @@ -16,6 +16,7 @@ path("classes/<int:class_id>/add_students", views.add_student_to_class), path("subject/<subject_abbr>", views.subject_list), path("subjects/all", views.subjects_all), + path("teachers/all", views.teachers_all), path("reevaluate_task/<int:task_id>", views.reevaluate_task), path("search", views.search), path("transfer_students", views.transfer_students), diff --git a/api/views.py b/api/views.py index 100dae70..80d77d7b 100644 --- a/api/views.py +++ b/api/views.py @@ -3,7 +3,7 @@ import django.http from django.shortcuts import get_object_or_404, resolve_url from django.http import HttpRequest, HttpResponseBadRequest -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_GET, require_POST from django.contrib.auth.models import User from django.urls import reverse from common.models import ( @@ -284,6 +284,19 @@ def subjects_all(request) -> JsonResponse: return JsonResponse(resp) +@user_passes_test(is_teacher) +@require_GET +def teachers_all(request) -> JsonResponse: + teachers = User.objects.filter(groups__name="teachers") + items = tuple( + {"username": t.username, "full_name": t.get_full_name(), "last_name": t.last_name} + for t in teachers + ) + + resp = {"teachers": items} + return JsonResponse(resp) + + @login_required def info(request): res = {} @@ -745,8 +758,16 @@ def import_activities(request): post = json.loads(request.body.decode("utf-8")) semester_id = post["semester_id"] - subject = post["subject"] + subject_abbr = post["subject_abbr"] activities_id = post["activities"] + activities_to_teacher = post[ + "activities_to_teacher" + ] # activities_to_teacher: when INBUS doesn't provide one, UI user selects one + + activities_to_teacher = { + int(activity_id): teacher_username + for activity_id, teacher_username in activities_to_teacher.items() + } activities = [inbus.concrete_activity(activity_id) for activity_id in activities_id] activities = [ @@ -755,10 +776,16 @@ def import_activities(request): semester = Semester.objects.get(pk=semester_id) try: - res["users"] = list(common.bulk_import.run(activities, subject, semester, request.user)) + res["users"] = list( + common.bulk_import.run( + activities, subject_abbr, semester, request.user, activities_to_teacher + ) + ) res["count"] = len(res["users"]) except (ImportException, UnicodeDecodeError) as e: - res["error"] = "".join(traceback.TracebackException.from_exception(e).format()) + # msg = traceback.TracebackException.from_exception(e).format() + msg = e.args[0] + res["error"] = msg except BaseException: res["error"] = traceback.format_exc() diff --git a/common/bulk_import.py b/common/bulk_import.py index 9e5b37b5..dd470e64 100644 --- a/common/bulk_import.py +++ b/common/bulk_import.py @@ -8,7 +8,7 @@ from typing import List, Dict, Generator import traceback -from .inbus.dto import ConcreteActivity +from .inbus.dto import ConcreteActivity, ConcreteActivityId class ImportException(Exception): @@ -26,15 +26,17 @@ class ImportResult: def run( concrete_activities: List[ConcreteActivity], - subj: Dict[str, str], + subject_abbr: str, semester: Semester, user: User, + activities_to_teacher: Dict[int, str], ) -> Generator[ImportResult, None, None]: """ - `subj`: subject from selected subject in UI as dictionary with k:abbr, v: name + `subject_addr`: subject abbreviation from selected subject in UI + `user`: importing user (the that uses import UI) + `activities_to_teacher`: dictionary of activities and manually assigned teachers (username) in the UI """ - subject_abbr = subj["abbr"] try: subject = Subject.objects.get(abbr=subject_abbr) except Subject.DoesNotExist: @@ -75,8 +77,13 @@ def run( f"Cannot create user {ca.teacherLogins.upper()}.\n\nTraceback\n\n{traceback.format_exc()}" ) else: - # TODO: We assign all activities without teacher to one special user :-) - teacher = User.objects.get(username="GAU01") + # We assign all activities without teacher in INBUS to the one selected by importing user + try: + teacher_username = activities_to_teacher[ca.concreteActivityId] + teacher = User.objects.get(username=teacher_username) + except KeyError: + msg = f"There's no assigned teacher to activity {ca.code()}. Please, make sure you selected one." + raise ImportException(msg) if not is_teacher(teacher): teachers_group = Group.objects.get_by_natural_key("teachers") @@ -87,7 +94,9 @@ def run( class_in_db[c].save() # Students - students_in_class = inbus.students_in_concrete_activity(ca.concreteActivityId) + students_in_class = inbus.students_in_concrete_activity( + ConcreteActivityId(ca.concreteActivityId) + ) for student in students_in_class: login = student.login.upper() diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f2bba4b0..6b4d51e6 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,14 +3,12 @@ import EditTask from './EditTask.svelte'; import ClassList from './ClassList.svelte'; import Router from 'svelte-spa-router'; import StudentTransfer from './StudentTransfer.svelte'; -import ImportFromInbus from './ImportFromInbus.svelte'; import { user } from './global.js'; const routes = { '/task/edit/:id': EditTask, '/task/add/:subject': EditTask, '/student_transfer': StudentTransfer, - '/import': ImportFromInbus, '/': ClassList }; </script> diff --git a/frontend/src/ClassList.svelte b/frontend/src/ClassList.svelte index f11ed12a..94d30edb 100644 --- a/frontend/src/ClassList.svelte +++ b/frontend/src/ClassList.svelte @@ -63,7 +63,7 @@ $: { teacher={filter.teacher} clazz={filter.class} /> - <a class="btn btn-sm p-1" href="/#/import" title="Bulk import students from EDISON"> + <a class="btn btn-sm p-1" href="/import/inbus" title="Bulk import students from EDISON"> <span class="iconify" data-icon="mdi:calendar-import"></span> </a> diff --git a/frontend/src/ImportFromInbus.svelte b/frontend/src/ImportFromInbus.svelte deleted file mode 100644 index 077efa10..00000000 --- a/frontend/src/ImportFromInbus.svelte +++ /dev/null @@ -1,209 +0,0 @@ -<script> -import { fetch } from './api.js'; - -let subject_inbus_selected = null; -let subject_kelvin_selected = null; - -let semester = null; -let busy = false; - -let semesters = null; -let subjects_inbus = null; -let subjects_inbus_filtered = null; -let subjects_kelvin = null; -let subject_inbus_schedule = null; - -let classes_to_import = []; -let result = null; - -$: canImport = classes_to_import.length && !busy; - -function svcc2num(svcc) { - let [dept_code, version] = svcc.split('/'); - let [dept, code] = dept_code.split('-'); - - let svcc_str = dept + code + version; - let svcc_num = Number(svcc_str); - - return svcc_num; -} - -async function loadInbusAndKelvinSubjects() { - const res1 = await fetch('/api/inbus/subject_versions'); - const res2 = await fetch('/api/subjects/all'); - - subjects_inbus = await res1.json(); - subjects_kelvin = await res2.json(); - - subjects_kelvin = subjects_kelvin.subjects; - subjects_kelvin.sort((a, b) => { - const name_a = a.name.toUpperCase(); - const name_b = b.name.toUpperCase(); - if (name_a < name_b) { - return -1; - } - if (name_a > name_b) { - return 1; - } - return 0; - }); - - const subject_kelvin_abbrs = subjects_kelvin.map((s) => s.abbr); - - subjects_inbus_filtered = subjects_inbus.filter((subject_inbus) => - subject_kelvin_abbrs.includes(subject_inbus.subject.abbrev) - ); - subjects_inbus_filtered = subjects_inbus_filtered.sort( - (a, b) => svcc2num(a.subjectVersionCompleteCode) - svcc2num(b.subjectVersionCompleteCode) - ); -} - -function parseSemesters(semesters_data) { - semesters = semesters_data.map((sm) => ({ - pk: sm.pk, - year: sm.year, - winter: sm.winter, - display: new String(sm.year) + (sm.winter ? 'W' : 'S') - })); -} - -async function loadSemesters() { - const res = await fetch('/api/semesters'); - const semesters_data = await res.json(); - parseSemesters(semesters_data['semesters']); -} - -async function loadScheduleForSubjectVersionId() { - let res = await fetch( - `/api/inbus/schedule/subject/version/${subject_inbus_selected.subjectVersionId}` - ); - subject_inbus_schedule = await res.json(); -} - -$: if (subject_inbus_selected) { - loadScheduleForSubjectVersionId(); -} - -async function import_activities() { - busy = true; - const req = { - semester_id: semester, - subject: subject_kelvin_selected, - activities: classes_to_import - }; - - const res = await fetch('/api/import/activities', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(req) - }); - - result = await res.json(); - busy = false; -} - -loadSemesters(); -loadInbusAndKelvinSubjects(); -</script> - -{#if semesters} - <select bind:value={semester}> - {#each semesters as item} - <option value={item.pk}>{item.display}</option> - {/each} - </select> -{/if} - -{#if subjects_kelvin} - <select bind:value={subject_kelvin_selected}> - {#each subjects_kelvin as item} - <option value={item}>{item.abbr} - {item.name}</option> - {/each} - </select> -{/if} - -{#if subjects_inbus_filtered} - <select bind:value={subject_inbus_selected}> - {#each subjects_inbus_filtered as item} - <option value={item} - >{item.subjectVersionCompleteCode} - {item.subject.abbrev} - {item.subject.title}</option> - {/each} - </select> -{/if} - -{#if subject_inbus_schedule} - <table class="table table-hover table-stripped table-sm"> - <tbody> - {#each subject_inbus_schedule as ca} - <!-- `ca` stands for concrete_activity --> - <tr> - <td> - <label> - <input - type="checkbox" - bind:group={classes_to_import} - name="classesToImport" - value={ca.concreteActivityId} /> - {ca.educationTypeAbbrev} - </label> - </td> - - <td> - {ca.educationTypeAbbrev}/{ca.order}, {ca.subjectVersionCompleteCode} - </td> - <td> - {ca.teacherFullNames} - </td> - - <td> - {ca.weekDayAbbrev} - </td> - - <td> - {ca.beginTime} - </td> - - <td> - {ca.endTime} - </td> - </tr> - {/each} - </tbody> - </table> -{/if} - -<button - class="btn btn-success" - on:click={import_activities} - disabled={!canImport} - class:btn-danger={!canImport}> - {#if busy} - Importing... - {:else} - Import - {/if} -</button> - -{#if result} - {#if result.error} - <div class="alert alert-danger" role="alert"> - {result.error} - </div> - {:else} - <table class="table table-sm table-hover table-striped"> - <tbody> - {#each result.users as item} - <tr> - <td>{item.login}</td> - <td>{item.firstname}</td> - <td>{item.lastname}</td> - <td>{item.created}</td> - </tr> - {/each} - </tbody> - </table> - {/if} -{/if} diff --git a/frontend/src/Teacher/InbusImport.vue b/frontend/src/Teacher/InbusImport.vue new file mode 100644 index 00000000..9bff1e82 --- /dev/null +++ b/frontend/src/Teacher/InbusImport.vue @@ -0,0 +1,391 @@ +<script setup lang="ts"> +/** + * This component provides import of classes of students from Edison + * into Kelvin using the INBUS API. + * + * It is only available to teachers, and it is accessible from the main page + * next to class selection drop down menu. + */ +import { computed, ref } from 'vue'; + +import { csrfToken } from '../api.js'; +import { ConcreteActivity, InbusSubjectVersion } from './inbusdto'; + +interface KelvinSubject { + name: string; + abbr: string; +} + +interface Semester { + pk: number; + year: number; + winter: boolean; +} + +interface ImportResult { + login: string; + firstname: string; + lastname: string; + created: boolean; +} + +interface Result { + users: ImportResult[]; + count: number; + error?: string; +} + +interface Teacher { + username: string; + full_name: string; + last_name: string; +} + +interface ImportRequest { + semester_id: number; + subject_abbr: string; + activities: number[]; + activities_to_teacher: { [key: number]: string }; +} + +const busy = ref<boolean>(false); + +const semesters = await loadSemesters(); +// Semester ID +const semester = ref<number | null>( + semesters.length > 0 ? semesters[semesters.length - 1].pk : null +); + +const subjects_kelvin = await loadKelvinSubjects(); + +// Subject abbreviation +const subject_kelvin_selected = ref<string | null>( + subjects_kelvin.length > 0 ? subjects_kelvin[0].abbr : null +); + +const subjects_inbus_filtered = await loadInbusSubjects(subjects_kelvin); +const subject_inbus_schedule = ref<ConcreteActivity[] | null>(null); + +const teachers = await loadTeachers(); +const activities_to_teacher_selected = ref({}); + +const classes_to_import = ref([]); +const result = ref<Result | null>(null); + +const canImport = computed(() => { + return ( + classes_to_import.value.length && !busy.value && subject_kelvin_selected.value && semester.value + ); +}); + +function assembleRequest(url: string): Request { + const headers: Headers = new Headers(); + + headers.set('Content-Type', 'application/json'); + headers.set('Accept', 'application/json'); + + const request: Request = new Request(url, { + method: 'GET', + headers: headers + }); + + return request; +} + +function svcc2num(svcc: string): number { + const [dept_code, version] = svcc.split('/'); + const [dept, code] = dept_code.split('-'); + + const svcc_str = dept + code + version; + const svcc_num = Number(svcc_str); + + return svcc_num; +} + +function sortCollection<T>(collection: T[], key: string) { + collection.sort((a, b) => { + const name_a = a[key].toUpperCase(); + const name_b = b[key].toUpperCase(); + if (name_a < name_b) { + return -1; + } + if (name_a > name_b) { + return 1; + } + return 0; + }); +} + +async function loadKelvinSubjects(): Promise<KelvinSubject[]> { + const res = await fetch('/api/subjects/all', {}); + const subjects_kelvin_resp = await res.json(); + + const subjects_kelvin: KelvinSubject[] = subjects_kelvin_resp.subjects; + sortCollection(subjects_kelvin, 'name'); + + return subjects_kelvin; +} + +async function loadInbusSubjects(kelvin_subjects: KelvinSubject[]): Promise<InbusSubjectVersion[]> { + const res = await fetch('/api/inbus/subject_versions', {}); + + const subjects_inbus: InbusSubjectVersion[] = await res.json(); + const subject_kelvin_abbrs = kelvin_subjects.map((s) => s.abbr); + + const subjects_inbus_filtered = subjects_inbus.filter((subject_inbus) => + subject_kelvin_abbrs.includes(subject_inbus.subject.abbrev) + ); + subjects_inbus_filtered.sort( + (a, b) => svcc2num(a.subjectVersionCompleteCode) - svcc2num(b.subjectVersionCompleteCode) + ); + + return subjects_inbus_filtered; +} + +function parseSemesters(semesters_data: Semester[]) { + const semesters = semesters_data.map((sm) => ({ + pk: sm.pk, + year: sm.year, + winter: sm.winter, + display: String(sm.year) + (sm.winter ? 'W' : 'S') + })); + + return semesters; +} + +async function loadSemesters() { + const request = assembleRequest('/api/semesters'); + const res = await fetch(request, {}); + + const semesters_data = await res.json(); + return parseSemesters(semesters_data['semesters']); +} + +async function loadScheduleForSubjectVersionId(subject_index: number) { + subject_inbus_schedule.value = null; + activities_to_teacher_selected.value = {}; + + const versionId = subjects_inbus_filtered[subject_index].subjectVersionId; + const request = assembleRequest(`/api/inbus/schedule/subject/version/${versionId}`); + const res = await fetch(request, {}); + subject_inbus_schedule.value = await res.json(); +} + +async function loadTeachers(): Promise<Teacher[]> { + const request = assembleRequest('/api/teachers/all'); + const res = await fetch(request, {}); + const teachers_data = await res.json(); + const teachers: Teacher[] = teachers_data['teachers']; + + sortCollection(teachers, 'last_name'); + + return teachers; +} + +function classesWithoutTeacher() { + const classes_without_teacher: number[] = []; + for (const clazz of subject_inbus_schedule.value) { + if (!clazz.teacherFullNames) { + classes_without_teacher.push(clazz.concreteActivityId); + } + } + + return classes_without_teacher; +} + +function isRequestValid(req) { + const classes_without_teacher = classesWithoutTeacher(); + + for (const activity of req.activities) { + if (classes_without_teacher.includes(activity)) { + if (!req.activities_to_teacher.hasOwnProperty(activity)) { + return false; + } + } + } + + return true; +} + +function getCorrespondingActivityRepr(activity_id: number) { + for (const ca of subject_inbus_schedule.value) { + if (ca.concreteActivityId === activity_id) { + return `${ca.educationTypeAbbrev}/${ca.order}, ${ca.subjectVersionCompleteCode}`; + } + } +} + +function classesWithoutSelectedTeacher(req: ImportRequest) { + const classes_without_selected_teacher: string[] = []; + const classes_without_teacher = classesWithoutTeacher(); + //console.log('classes_without_teacher', classes_without_teacher); + + for (const activity_id of req.activities) { + if (classes_without_teacher.includes(activity_id)) { + if (!req.activities_to_teacher.hasOwnProperty(activity_id)) { + classes_without_selected_teacher.push(getCorrespondingActivityRepr(activity_id)); + } + } + } + + return classes_without_selected_teacher; +} + +async function importActivities() { + busy.value = true; + const req: ImportRequest = { + semester_id: semester.value, + subject_abbr: subject_kelvin_selected.value, + activities: classes_to_import.value, + activities_to_teacher: activities_to_teacher_selected.value + }; + + if (!isRequestValid(req)) { + const classes_without_selected_teacher = classesWithoutSelectedTeacher(req); + const err_msg = + classes_without_selected_teacher.length > 1 + ? `Selected classes to import (${classes_without_selected_teacher.join(', ')}) don't have assigned teacher. Please, select ones.` + : `Selected class to import (${classes_without_selected_teacher}) doesn't have assigned teacher. Please, select one.`; + result.value = { users: [], count: 0, error: err_msg }; + busy.value = false; + + return; + } + + const res = await fetch('/api/import/activities', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken() + }, + body: JSON.stringify(req) + }); + + result.value = await res.json(); + busy.value = false; +} + +function onInbusSubjectSelected(event) { + const value: string = event.target.value; + if (value !== '') { + loadScheduleForSubjectVersionId(Number.parseInt(value)); + } else { + subject_inbus_schedule.value = null; + } +} + +function onTeacherSelected(event) { + const value: string = event.target.value; + const [activity_id, teacher_username] = value.split(','); + + activities_to_teacher_selected.value[parseInt(activity_id)] = teacher_username; +} +</script> + +<template> + <select v-model="semester"> + <option v-for="item in semesters" :key="item.pk" :value="item.pk">{{ item.display }}</option> + </select> + + <select v-model="subject_kelvin_selected"> + <option v-for="item in subjects_kelvin" :key="item.abbr" :value="item.abbr"> + {{ item.abbr }} - {{ item.name }} + </option> + </select> + + <select @change="onInbusSubjectSelected"> + <option value="">Select Edison subject</option> + <option v-for="(item, index) in subjects_inbus_filtered" :key="index" :value="index"> + {{ item.subjectVersionCompleteCode }} - {{ item.subject.abbrev }} - {{ item.subject.title }} + </option> + </select> + + <table v-if="subject_inbus_schedule" class="table table-hover table-stripped table-sm"> + <tbody> + <tr v-for="ca in subject_inbus_schedule" :key="ca.concreteActivityId"> + <td> + <label> + <input + v-model="classes_to_import" + type="checkbox" + :value="ca.concreteActivityId" + name="classesToImport" + /> + {{ ca.educationTypeAbbrev }} + </label> + </td> + + <td>{{ ca.educationTypeAbbrev }}/{{ ca.order }}, {{ ca.subjectVersionCompleteCode }}</td> + + <td> + <span v-if="ca.teacherFullNames">{{ ca.teacherFullNames }}</span> + <span v-else> + <select @change="onTeacherSelected"> + <option value="">Select teacher</option> + <option + v-for="teacher in teachers" + :key="teacher.username" + :value="`${ca.concreteActivityId},${teacher.username}`" + > + {{ teacher.username }} - {{ teacher.full_name }} + </option> + </select> + </span> + </td> + + <td> + {{ ca.weekDayAbbrev }} + </td> + + <td> + {{ ca.beginTime }} + </td> + + <td> + {{ ca.endTime }} + </td> + </tr> + </tbody> + </table> + + <button + class="btn" + :class="{ 'btn-success': canImport, 'btn-danger': !canImport }" + :disabled="!canImport" + @click="importActivities" + > + <span v-if="busy">Importing...</span> + <span v-else>Import</span> + </button> + + <div> + <div v-if="result"> + <div v-if="result.error" class="alert alert-danger" role="alert"> + {{ result.error }} + </div> + <div v-else> + <table class="table table-sm table-hover table-striped"> + <thead> + <tr> + <th>Login</th> + <th>First name</th> + <th>Last name</th> + <th>User created</th> + </tr> + </thead> + <tbody> + <tr v-for="item in result.users" :key="item.login"> + <td>{{ item.login }}</td> + <td>{{ item.firstname }}</td> + <td>{{ item.lastname }}</td> + <td>{{ item.created }}</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> +</template> + +<style scoped></style> diff --git a/frontend/src/Teacher/inbusdto.ts b/frontend/src/Teacher/inbusdto.ts new file mode 100644 index 00000000..ac23d97b --- /dev/null +++ b/frontend/src/Teacher/inbusdto.ts @@ -0,0 +1,27 @@ +export interface ConcreteActivity { + /** + Concrete activity in schedule. + */ + concreteActivityId: number; + order: number; + subjectVersionId: number; + subjectVersionCompleteCode: string; + educationTypeAbbrev: string; + beginTime: string; + endTime: string; + weekDayAbbrev: string; + teacherFullNames?: string; +} + +interface InbusSubject { + subjectId: number; + code: string; + abbrev: string; + title: string; +} + +export interface InbusSubjectVersion { + subjectVersionId: number; + subject: InbusSubject; + subjectVersionCompleteCode: string; +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 3b233df2..77144ba0 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -146,6 +146,7 @@ window.addEventListener('DOMContentLoaded', focusTab); import { defineCustomElement, h } from 'vue'; import SuspensionWrapper from './components/SuspensionWrapper.vue'; import AllTasks from './Teacher/AllTasks.vue'; +import InbusImport from './Teacher/InbusImport.vue'; /** * Register new Vue component as a custom element. @@ -182,3 +183,4 @@ const registerSuspendedVueComponent = (name, component, configureApp = undefined }; registerSuspendedVueComponent('tasks-all', AllTasks); +registerSuspendedVueComponent('inbus-import', InbusImport); diff --git a/templates/web/inbusimport.html b/templates/web/inbusimport.html new file mode 100644 index 00000000..def17b63 --- /dev/null +++ b/templates/web/inbusimport.html @@ -0,0 +1,5 @@ +{% extends 'web/layout.html' %} + +{% block fullcontent %} +<kelvin-inbus-import></kelvin-inbus-import> +{% endblock %} diff --git a/web/urls.py b/web/urls.py index c44a9f40..9fadbe8b 100644 --- a/web/urls.py +++ b/web/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path("", common_view.index, name="index"), + path("import/inbus", common_view.import_inbus, name="import_inbus"), path("student-view", student_view.student_index, name="student_index"), path( "find-task/<int:task_id>/<str:login>/", diff --git a/web/views/common.py b/web/views/common.py index b55c6237..4569c3a7 100644 --- a/web/views/common.py +++ b/web/views/common.py @@ -1,4 +1,4 @@ -from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import login_required, user_passes_test from django.shortcuts import render from django.utils.crypto import get_random_string from django.conf import settings @@ -17,6 +17,11 @@ def index(request): return student_index(request) +@user_passes_test(is_teacher) +def import_inbus(request): + return render(request, "web/inbusimport.html", {}) + + @login_required() def api_token(request): data = {