diff --git a/app/controllers/ExamSectionController.java b/app/controllers/ExamSectionController.java index e6bf58836f..d052980036 100644 --- a/app/controllers/ExamSectionController.java +++ b/app/controllers/ExamSectionController.java @@ -616,4 +616,35 @@ public Result getQuestionDistribution(Long id) { node.put("distributed", isDistributed); return ok(Json.toJson(node)); } + + @Authenticated + @Restrict({ @Group("TEACHER"), @Group("ADMIN") }) + public Result listSections( + Optional filter, + Optional> courseIds, + Optional> examIds, + Optional> tagIds, + Http.Request request + ) { + User user = request.attrs().get(Attrs.AUTHENTICATED_USER); + ExpressionList query = DB.find(ExamSection.class).where(); + if (!user.hasRole(Role.Name.ADMIN)) { + query = query.where().eq("creator.id", user.getId()); + } + if (filter.isPresent()) { + String condition = String.format("%%%s%%", filter.get()); + query = query.ilike("name", condition); + } + if (examIds.isPresent() && !examIds.get().isEmpty()) { + query = query.in("exam.id", examIds.get()); + } + if (courseIds.isPresent() && !courseIds.get().isEmpty()) { + query = query.in("exam.course.id", courseIds.get()); + } + if (tagIds.isPresent() && !tagIds.get().isEmpty()) { + query = query.in("examSectionQuestions.question.tags.id", tagIds.get()); + } + Set sections = query.findSet(); + return ok(sections, PathProperties.parse("(*, creator(id))")); + } } diff --git a/app/controllers/QuestionController.java b/app/controllers/QuestionController.java index cbee79ae96..7a20a2878f 100644 --- a/app/controllers/QuestionController.java +++ b/app/controllers/QuestionController.java @@ -90,8 +90,8 @@ public Result getQuestions( return ok(Collections.emptySet()); } PathProperties pp = PathProperties.parse( - "*, modifier(firstName, lastName) questionOwners(id, firstName, lastName, userIdentifier, email), " + - "attachment(id, fileName), options(defaultScore, correctOption, claimChoiceType), tags(name), examSectionQuestions(examSection(exam(state, examActiveEndDate, course(code)))))" + "*, modifier(firstName, lastName), questionOwners(id, firstName, lastName, userIdentifier, email), " + + "attachment(id, fileName), options(defaultScore, correctOption, claimChoiceType), tags(id, name), examSectionQuestions(examSection(exam(state, examActiveEndDate, course(code)))))" ); Query query = DB.find(Question.class); pp.apply(query); diff --git a/app/controllers/TagController.java b/app/controllers/TagController.java index 8b3fd7d210..5a46989cdc 100644 --- a/app/controllers/TagController.java +++ b/app/controllers/TagController.java @@ -17,6 +17,7 @@ import be.objectify.deadbolt.java.actions.Group; import be.objectify.deadbolt.java.actions.Restrict; +import com.fasterxml.jackson.databind.JsonNode; import controllers.base.BaseController; import io.ebean.DB; import io.ebean.ExpressionList; @@ -24,9 +25,11 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.StreamSupport; import models.Role; import models.Tag; import models.User; +import models.questions.Question; import play.mvc.Http; import play.mvc.Result; import sanitizers.Attrs; @@ -38,8 +41,8 @@ public class TagController extends BaseController { @Restrict({ @Group("ADMIN"), @Group("TEACHER") }) public Result listTags( Optional filter, - Optional> examIds, Optional> courseIds, + Optional> examIds, Optional> sectionIds, Http.Request request ) { @@ -62,6 +65,25 @@ public Result listTags( query = query.in("questions.examSectionQuestions.examSection.id", sectionIds.get()); } Set tags = query.findSet(); - return ok(tags, PathProperties.parse("(*, creator(id))")); + return ok(tags, PathProperties.parse("(*, creator(id), questions(id))")); + } + + @Restrict({ @Group("ADMIN"), @Group("TEACHER") }) + public Result addTagToQuestions(Http.Request request) { + JsonNode body = request.body().asJson(); + List questionIds = StreamSupport + .stream(body.get("questionIds").spliterator(), false) + .map(JsonNode::asLong) + .toList(); + Long tagId = body.get("tagId").asLong(); + List questions = DB.find(Question.class).where().idIn(questionIds).findList(); + Tag tag = DB.find(Tag.class, tagId); + questions.forEach(question -> { + if (!question.getTags().contains(tag)) { + question.getTags().add(tag); + question.update(); + } + }); + return ok(); } } diff --git a/conf/routes b/conf/routes index f2ad8db502..87ed09d0db 100644 --- a/conf/routes +++ b/conf/routes @@ -121,6 +121,7 @@ DELETE /app/exams/:eid/sections/:sid/questions/:qid controlle PUT /app/exams/:eid/sections/:sid/reorder controllers.ExamSectionController.reorderSectionQuestions(eid: Long, sid: Long, request: Request) PUT /app/exams/:eid/reorder controllers.ExamSectionController.reorderSections(eid: Long, request: Request) GET /app/exams/question/:id/distribution controllers.ExamSectionController.getQuestionDistribution(id: Long) +GET /app/sections controllers.ExamSectionController.listSections(filter: java.util.Optional[String], courseIds: java.util.Optional[LongList], examIds: java.util.Optional[LongList], tagIds: java.util.Optional[LongList], request: Request) ############### Section material interface ############### GET /app/materials controllers.ExamMaterialController.listMaterials(request: Request) @@ -406,7 +407,8 @@ GET /app/availability/:roomId/:date controlle GET /app/languages controllers.LanguageController.getSupportedLanguages ################# Tag interface ################## -GET /app/tags controllers.TagController.listTags(filter: java.util.Optional[String], examIds: java.util.Optional[LongList], courseIds: java.util.Optional[LongList], sectionIds: java.util.Optional[LongList], request: Request) +GET /app/tags controllers.TagController.listTags(filter: java.util.Optional[String], courseIds: java.util.Optional[LongList], examIds: java.util.Optional[LongList], sectionIds: java.util.Optional[LongList], request: Request) +POST /app/tags/questions controllers.TagController.addTagToQuestions(request: Request) ################# General Settings interface ################## diff --git a/project/plugins.sbt b/project/plugins.sbt index 61c06703c5..5d068dcad8 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,6 +2,6 @@ resolvers += "Typesafe repository" at "https://repo.typesafe.com/typesafe/releas addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.0") -addSbtPlugin("org.playframework" % "sbt-play-ebean" % "8.0.0-M1") +addSbtPlugin("org.playframework" % "sbt-play-ebean" % "8.0.0") // addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.10.0-RC1") diff --git a/ui/src/app/exam/exam.model.ts b/ui/src/app/exam/exam.model.ts index 759804aa0a..c74b5782b5 100644 --- a/ui/src/app/exam/exam.model.ts +++ b/ui/src/app/exam/exam.model.ts @@ -96,6 +96,7 @@ export interface ReverseQuestion extends Question { export interface Tag { id?: number; name: string; + questions: Question[]; } export interface Question { diff --git a/ui/src/app/question/library/export/library-file-export.component.ts b/ui/src/app/question/library/export/library-file-export.component.ts deleted file mode 100644 index a819b71a73..0000000000 --- a/ui/src/app/question/library/export/library-file-export.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2020 Exam Consortium - * - * Licensed under the EUPL, Version 1.1 or - as soon they will be approved by the European Commission - subsequent - * versions of the EUPL (the "Licence"); - * You may not use this work except in compliance with the Licence. - * You may obtain a copy of the Licence at: - * - * https://joinup.ec.europa.eu/software/page/eupl/licence-eupl - * - * Unless required by applicable law or agreed to in writing, software distributed under the Licence is distributed - * on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Licence for the specific language governing permissions and limitations under the Licence. - */ -import { Component, Input } from '@angular/core'; -import { TranslateService } from '@ngx-translate/core'; -import { ToastrService } from 'ngx-toastr'; -import { FileService } from '../../../shared/file/file.service'; - -@Component({ - selector: 'xm-library-file-export', - template: ` `, -}) -export class LibraryFileExportComponent { - @Input() selections: number[] = []; - - constructor(private Files: FileService, private translate: TranslateService, private toast: ToastrService) {} - - export() { - if (this.selections.length === 0) { - this.toast.warning(this.translate.instant('sitnet_choose_atleast_one')); - } else { - this.Files.download( - '/app/questions/export', - 'moodle-export.xml', - { ids: this.selections.map((s) => s.toString()) }, - true, - ); - } - } -} diff --git a/ui/src/app/question/library/export/library-transfer-dialog.component.html b/ui/src/app/question/library/export/library-transfer-dialog.component.html new file mode 100644 index 0000000000..b48c08ca52 --- /dev/null +++ b/ui/src/app/question/library/export/library-transfer-dialog.component.html @@ -0,0 +1,47 @@ + diff --git a/ui/src/app/question/library/export/library-transfer.component.ts b/ui/src/app/question/library/export/library-transfer-dialog.component.ts similarity index 84% rename from ui/src/app/question/library/export/library-transfer.component.ts rename to ui/src/app/question/library/export/library-transfer-dialog.component.ts index 91966fd573..0a351389be 100644 --- a/ui/src/app/question/library/export/library-transfer.component.ts +++ b/ui/src/app/question/library/export/library-transfer-dialog.component.ts @@ -15,6 +15,7 @@ import { HttpClient } from '@angular/common/http'; import type { OnInit } from '@angular/core'; import { Component, Input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; @@ -27,15 +28,20 @@ type Organisation = { @Component({ selector: 'xm-library-transfer', - templateUrl: './library-transfer.component.html', + templateUrl: './library-transfer-dialog.component.html', }) -export class LibraryTransferComponent implements OnInit { +export class LibraryTransferDialogComponent implements OnInit { @Input() selections: number[] = []; organisations: Organisation[] = []; organisation?: Organisation; showOrganisationSelection = false; - constructor(private http: HttpClient, private translate: TranslateService, private toast: ToastrService) {} + constructor( + public activeModal: NgbActiveModal, + private http: HttpClient, + private translate: TranslateService, + private toast: ToastrService, + ) {} ngOnInit() { this.http.get('/app/iop/organisations').subscribe((resp) => { diff --git a/ui/src/app/question/library/export/library-transfer.component.html b/ui/src/app/question/library/export/library-transfer.component.html deleted file mode 100644 index b78ff50bd5..0000000000 --- a/ui/src/app/question/library/export/library-transfer.component.html +++ /dev/null @@ -1,39 +0,0 @@ -
-
- -   - - {{ 'sitnet_transfer_questions' | translate }} - - -
- -
- - -
- - - ({{ organisation.code }}) - -
-
-
diff --git a/ui/src/app/question/library/library.component.ts b/ui/src/app/question/library/library.component.ts index 0d1f75bcf4..231d8f4e6a 100644 --- a/ui/src/app/question/library/library.component.ts +++ b/ui/src/app/question/library/library.component.ts @@ -14,12 +14,17 @@ */ import { Component } from '@angular/core'; import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; +import { from, tap } from 'rxjs'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; import { FileService } from 'src/app/shared/file/file.service'; -import type { Question } from '../../exam/exam.model'; +import type { Question, Tag } from '../../exam/exam.model'; import type { User } from '../../session/session.service'; +import { LibraryTransferDialogComponent } from './export/library-transfer-dialog.component'; +import { LibraryOwnersDialogComponent } from './owners/library-owners-dialog.component'; +import { LibraryTagsDialogComponent } from './tags/library-tags-dialog.component'; @Component({ selector: 'xm-library', @@ -51,23 +56,74 @@ import type { User } from '../../session/session.service';
+
+
+ {{ 'sitnet_search' | translate }}: +
+
-
-
-
-
- {{ selections.length }} {{ 'sitnet_questions_selected' | translate }} -
- - - -
+
+
+ {{ 'sitnet_actions' | translate }}: +
+
+
+
+ + + + + {{ + 'sitnet_choose_atleast_one' | translate + }} + + {{ selections.length }} {{ 'sitnet_questions_selected' | translate }} +
@@ -89,6 +145,7 @@ export class LibraryComponent { constructor( private router: Router, private translate: TranslateService, + private modal: NgbModal, private toast: ToastrService, private Attachment: AttachmentService, private Files: FileService, @@ -107,11 +164,6 @@ export class LibraryComponent { this.router.navigate(['/staff/questions', copy.id, 'edit']); } - ownerSelected(event: { user: User; selections: number[] }) { - const questions = this.questions.filter((q) => event.selections.indexOf(q.id) > -1); - questions.forEach((q) => q.questionOwners.push(event.user)); - } - import() { this.Attachment.selectFile(false, {}, 'sitnet_import_questions_detail') .then((result) => { @@ -123,6 +175,68 @@ export class LibraryComponent { .catch((err) => this.toast.error(err)); } + export() { + if (this.selections.length === 0) { + this.toast.warning(this.translate.instant('sitnet_choose_atleast_one')); + } else { + this.Files.download( + '/app/questions/export', + 'moodle-export.xml', + { ids: this.selections.map((s) => s.toString()) }, + true, + ); + } + } + + openOwnerSelection() { + const modalRef = this.modal.open(LibraryOwnersDialogComponent, { + backdrop: 'static', + keyboard: true, + size: 'lg', + }); + modalRef.componentInstance.selections = this.selections; + from(modalRef.result) + .pipe( + tap((result: { questions: number[]; users: User[] }) => { + const questions = this.questions.filter((q) => result.questions.includes(q.id)); + questions.forEach((q) => q.questionOwners.push(...result.users)); + }), + ) + .subscribe(); + } + + openTagSelection() { + const modalRef = this.modal.open(LibraryTagsDialogComponent, { + backdrop: 'static', + keyboard: true, + size: 'lg', + }); + modalRef.componentInstance.selections = this.selections; + from(modalRef.result) + .pipe( + tap((result: { questions: number[]; tags: Tag[] }) => { + const questions = this.questions.filter((q) => result.questions.includes(q.id)); + questions.forEach((q) => result.tags.forEach((t) => this.addTagIfNotExists(q, t))); + }), + ) + .subscribe(); + } + + openFileTransfer() { + const modalRef = this.modal.open(LibraryTransferDialogComponent, { + backdrop: 'static', + keyboard: true, + size: 'lg', + }); + modalRef.componentInstance.selections = this.selections; + } + + private addTagIfNotExists(q: Question, t: Tag) { + if (!q.tags.map((qt) => qt.id).includes(t.id)) { + q.tags.push(t); + } + } + private reload = () => this.router .navigateByUrl('/', { skipLocationChange: true }) diff --git a/ui/src/app/question/library/library.service.ts b/ui/src/app/question/library/library.service.ts index 4a55bb2a3c..db668f420d 100644 --- a/ui/src/app/question/library/library.service.ts +++ b/ui/src/app/question/library/library.service.ts @@ -17,7 +17,7 @@ import { Inject, Injectable } from '@angular/core'; import { SESSION_STORAGE, WebStorageService } from 'ngx-webstorage-service'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import type { Course, Exam, ReverseQuestion, Tag } from '../../exam/exam.model'; +import type { Course, Exam, ExamSection, ReverseQuestion, Tag } from '../../exam/exam.model'; import { QuestionService } from '../question.service'; export interface LibraryQuestion extends ReverseQuestion { @@ -36,14 +36,22 @@ export class LibraryService { private Question: QuestionService, ) {} - listExams = (courseIds: number[], sectionIds: number[], tagIds: number[]): Observable => - this.http.get('/app/examsearch', { params: this.getQueryParams(courseIds, sectionIds, tagIds) }); + listExams$ = (courseIds: number[], sectionIds: number[], tagIds: number[]): Observable => + this.http.get('/app/examsearch', { params: this.getQueryParams(courseIds, [], sectionIds, tagIds) }); - listCourses = (courseIds: number[], sectionIds: number[], tagIds: number[]): Observable => - this.http.get('/app/courses/user', { params: this.getQueryParams(courseIds, sectionIds, tagIds) }); + listCourses$ = (examIds: number[], sectionIds: number[], tagIds: number[]): Observable => + this.http.get('/app/courses/user', { params: this.getQueryParams([], examIds, sectionIds, tagIds) }); - listTags = (courseIds: number[], sectionIds: number[], tagIds: number[]): Observable => - this.http.get('/app/tags', { params: this.getQueryParams(courseIds, sectionIds, tagIds) }); + listSections$ = (courseIds: number[], examIds: number[], tagIds: number[]): Observable => + this.http.get('/app/sections', { params: this.getQueryParams(courseIds, examIds, [], tagIds) }); + + listTags$ = (courseIds: number[], examIds: number[], sectionIds: number[]): Observable => + this.http.get('/app/tags', { params: this.getQueryParams(courseIds, sectionIds, examIds, []) }); + + listAllTags$ = (): Observable => this.http.get('/app/tags'); + + addTagForQuestions$ = (tagId: number, questionIds: number[]) => + this.http.post('/app/tags/questions', { questionIds: questionIds, tagId: tagId }); loadFilters = (category: string) => { const entry = this.webStorageService.get('questionFilters'); @@ -94,14 +102,14 @@ export class LibraryService { }; search = ( - examIds: number[], courseIds: number[], - tagIds: number[], + examIds: number[], sectionIds: number[], + tagIds: number[], ): Observable => this.http .get('/app/questions', { - params: this.getQueryParams(courseIds, sectionIds, tagIds, examIds), + params: this.getQueryParams(courseIds, examIds, sectionIds, tagIds), }) .pipe( map((questions) => { @@ -129,24 +137,24 @@ export class LibraryService { }), ); - private getQueryParams = (courseIds: number[], sectionIds: number[], tagIds: number[], examIds?: number[]) => { + private getQueryParams = (courseIds: number[], examIds: number[], sectionIds: number[], tagIds: number[]) => { let params = new HttpParams(); - const returnAppendedHttpParams = (key: string, idArray: number[], paramsObj: HttpParams) => { + const append = (key: string, idArray: number[], paramsObj: HttpParams) => { return idArray.reduce((paramObj, currentId) => paramObj.append(key, currentId.toString()), paramsObj); }; if (courseIds.length > 0) { - params = returnAppendedHttpParams('course', courseIds, params); + params = append('course', courseIds, params); } if (sectionIds.length > 0) { - params = returnAppendedHttpParams('section', sectionIds, params); + params = append('section', sectionIds, params); } if (tagIds.length > 0) { - params = returnAppendedHttpParams('tag', tagIds, params); + params = append('tag', tagIds, params); } - if (examIds && examIds.length > 0) { - params = returnAppendedHttpParams('exam', examIds, params); + if (examIds.length > 0) { + params = append('exam', examIds, params); } return params; diff --git a/ui/src/app/question/library/owners/library-owners.component.ts b/ui/src/app/question/library/owners/library-owners-dialog.component.ts similarity index 66% rename from ui/src/app/question/library/owners/library-owners.component.ts rename to ui/src/app/question/library/owners/library-owners-dialog.component.ts index d0aeb1e6e8..8e7c90b61b 100644 --- a/ui/src/app/question/library/owners/library-owners.component.ts +++ b/ui/src/app/question/library/owners/library-owners-dialog.component.ts @@ -13,8 +13,8 @@ * See the Licence for the specific language governing permissions and limitations under the Licence. */ import type { OnInit } from '@angular/core'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import type { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; +import { Component, Input } from '@angular/core'; +import { NgbActiveModal, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; import type { Observable } from 'rxjs'; @@ -25,47 +25,48 @@ import { UserService } from '../../../shared/user/user.service'; import { QuestionService } from '../../question.service'; @Component({ - selector: 'xm-library-owner-selection', template: ` -
- -   - - {{ 'sitnet_add_question_owner' | translate }} - -
-
+