Merge branch 'dev' into test/e2e

This commit is contained in:
Tibo De Peuter 2025-05-16 14:30:00 +02:00
commit e12c057ebb
Signed by: tdpeuter
GPG key ID: 38297DE43F75FFE2
112 changed files with 5304 additions and 1422 deletions

View file

@ -8,6 +8,7 @@
### Dwengo ### ### Dwengo ###
DWENGO_PORT=3000 DWENGO_PORT=3000
DWENGO_RUN_MODE=test
DWENGO_DB_NAME=":memory:" DWENGO_DB_NAME=":memory:"
DWENGO_DB_UPDATE=true DWENGO_DB_UPDATE=true

View file

@ -37,6 +37,7 @@
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"loki-logger-ts": "^1.0.2", "loki-logger-ts": "^1.0.2",
"marked": "^15.0.7", "marked": "^15.0.7",
"nanoid": "^5.1.5",
"response-time": "^2.3.3", "response-time": "^2.3.3",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",

View file

@ -1,10 +1,11 @@
import { UnauthorizedException } from '../exceptions/unauthorized-exception.js'; import { UnauthorizedException } from '../exceptions/unauthorized-exception.js';
import { getLogger } from '../logging/initalize.js'; import { getLogger } from '../logging/initalize.js';
import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js'; import { AuthenticatedRequest } from '../middleware/auth/authenticated-request.js';
import { createOrUpdateStudent } from '../services/students.js';
import { createOrUpdateTeacher } from '../services/teachers.js';
import { envVars, getEnvVar } from '../util/envVars.js'; import { envVars, getEnvVar } from '../util/envVars.js';
import { Response } from 'express'; import { createOrUpdateStudent } from '../services/students.js';
import { Request, Response } from 'express';
import { createOrUpdateTeacher } from '../services/teachers.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
interface FrontendIdpConfig { interface FrontendIdpConfig {
authority: string; authority: string;
@ -40,6 +41,10 @@ export function getFrontendAuthConfig(): FrontendAuthConfig {
}; };
} }
export function handleGetFrontendAuthConfig(_req: Request, res: Response): void {
res.json(getFrontendAuthConfig());
}
export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> { export async function postHelloHandler(req: AuthenticatedRequest, res: Response): Promise<void> {
const auth = req.auth; const auth = req.auth;
if (!auth) { if (!auth) {
@ -51,7 +56,7 @@ export async function postHelloHandler(req: AuthenticatedRequest, res: Response)
firstName: auth.firstName ?? '', firstName: auth.firstName ?? '',
lastName: auth.lastName ?? '', lastName: auth.lastName ?? '',
}; };
if (auth.accountType === 'student') { if (auth.accountType === AccountType.Student) {
await createOrUpdateStudent(userData); await createOrUpdateStudent(userData);
logger.debug(`Synchronized student ${userData.username} with IDP`); logger.debug(`Synchronized student ${userData.username} with IDP`);
} else { } else {

View file

@ -113,7 +113,7 @@ export async function createStudentRequestHandler(req: Request, res: Response):
const classId = req.body.classId; const classId = req.body.classId;
requireFields({ username, classId }); requireFields({ username, classId });
const request = await createClassJoinRequest(username, classId); const request = await createClassJoinRequest(username, classId.toUpperCase());
res.json({ request }); res.json({ request });
} }

View file

@ -62,6 +62,11 @@ export async function getAllSubmissionsHandler(req: Request, res: Response): Pro
// TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden // TODO: gerald moet nog dingen toevoegen aan de databank voor dat dit gefinaliseerd kan worden
export async function createSubmissionHandler(req: Request, res: Response): Promise<void> { export async function createSubmissionHandler(req: Request, res: Response): Promise<void> {
const submitter = req.body.submitter;
const usernameSubmitter = req.body.submitter.username;
const group = req.body.group;
requireFields({ group, submitter, usernameSubmitter });
const submissionDTO = req.body as SubmissionDTO; const submissionDTO = req.body as SubmissionDTO;
const submission = await createSubmission(submissionDTO); const submission = await createSubmission(submissionDTO);

View file

@ -2,6 +2,7 @@ import { Request, Response } from 'express';
import { requireFields } from './error-helper.js'; import { requireFields } from './error-helper.js';
import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js'; import { createInvitation, deleteInvitation, getAllInvitations, getInvitation, updateInvitation } from '../services/teacher-invitations.js';
import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation'; import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invitation';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> { export async function getAllInvitationsHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username; const username = req.params.username;
@ -30,6 +31,10 @@ export async function createInvitationHandler(req: Request, res: Response): Prom
const classId = req.body.class; const classId = req.body.class;
requireFields({ sender, receiver, classId }); requireFields({ sender, receiver, classId });
if (sender === receiver) {
throw new ConflictException('Cannot send an invitation to yourself');
}
const data = req.body as TeacherInvitationData; const data = req.body as TeacherInvitationData;
const invitation = await createInvitation(data); const invitation = await createInvitation(data);

View file

@ -7,7 +7,6 @@ import {
getJoinRequestsByClass, getJoinRequestsByClass,
getStudentsByTeacher, getStudentsByTeacher,
getTeacher, getTeacher,
getTeacherQuestions,
updateClassJoinRequestStatus, updateClassJoinRequestStatus,
} from '../services/teachers.js'; } from '../services/teachers.js';
import { requireFields } from './error-helper.js'; import { requireFields } from './error-helper.js';
@ -70,16 +69,6 @@ export async function getTeacherStudentHandler(req: Request, res: Response): Pro
res.json({ students }); res.json({ students });
} }
export async function getTeacherQuestionHandler(req: Request, res: Response): Promise<void> {
const username = req.params.username;
const full = req.query.full === 'true';
requireFields({ username });
const questions = await getTeacherQuestions(username, full);
res.json({ questions });
}
export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> { export async function getStudentJoinRequestHandler(req: Request, res: Response): Promise<void> {
const classId = req.params.classId; const classId = req.params.classId;
requireFields({ classId }); requireFields({ classId });

View file

@ -2,7 +2,6 @@ import { DwengoEntityRepository } from '../dwengo-entity-repository.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Language } from '@dwengo-1/common/util/language'; import { Language } from '@dwengo-1/common/util/language';
import { Teacher } from '../../entities/users/teacher.entity.js';
export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> { export class LearningObjectRepository extends DwengoEntityRepository<LearningObject> {
public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> { public async findByIdentifier(identifier: LearningObjectIdentifier): Promise<LearningObject | null> {
@ -32,11 +31,4 @@ export class LearningObjectRepository extends DwengoEntityRepository<LearningObj
} }
); );
} }
public async findAllByTeacher(teacher: Teacher): Promise<LearningObject[]> {
return this.find(
{ admins: teacher },
{ populate: ['admins'] } // Make sure to load admin relations
);
}
} }

View file

@ -3,9 +3,9 @@ import { Question } from '../../entities/questions/question.entity.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { Student } from '../../entities/users/student.entity.js'; import { Student } from '../../entities/users/student.entity.js';
import { LearningObject } from '../../entities/content/learning-object.entity.js'; import { LearningObject } from '../../entities/content/learning-object.entity.js';
import { Group } from '../../entities/assignments/group.entity.js';
import { Assignment } from '../../entities/assignments/assignment.entity.js'; import { Assignment } from '../../entities/assignments/assignment.entity.js';
import { Loaded } from '@mikro-orm/core'; import { Loaded } from '@mikro-orm/core';
import { Group } from '../../entities/assignments/group.entity';
export class QuestionRepository extends DwengoEntityRepository<Question> { export class QuestionRepository extends DwengoEntityRepository<Question> {
public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> { public async createQuestion(question: { loId: LearningObjectIdentifier; author: Student; inGroup: Group; content: string }): Promise<Question> {

View file

@ -26,6 +26,9 @@ export class Assignment {
@Property({ type: 'string' }) @Property({ type: 'string' })
learningPathHruid!: string; learningPathHruid!: string;
@Property({ type: 'datetime', nullable: true })
deadline?: Date;
@Enum({ @Enum({
items: () => Language, items: () => Language,
}) })

View file

@ -1,15 +1,17 @@
import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core';
import { v4 } from 'uuid';
import { Teacher } from '../users/teacher.entity.js'; import { Teacher } from '../users/teacher.entity.js';
import { Student } from '../users/student.entity.js'; import { Student } from '../users/student.entity.js';
import { ClassRepository } from '../../data/classes/class-repository.js'; import { ClassRepository } from '../../data/classes/class-repository.js';
import { customAlphabet } from 'nanoid';
const generateClassId = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@Entity({ @Entity({
repository: () => ClassRepository, repository: () => ClassRepository,
}) })
export class Class { export class Class {
@PrimaryKey() @PrimaryKey()
classId? = v4(); classId? = generateClassId();
@Property({ type: 'string' }) @Property({ type: 'string' })
displayName!: string; displayName!: string;

View file

@ -20,6 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO {
description: assignment.description, description: assignment.description,
learningPath: assignment.learningPathHruid, learningPath: assignment.learningPathHruid,
language: assignment.learningPathLanguage, language: assignment.learningPathLanguage,
deadline: assignment.deadline ?? new Date(),
groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)),
}; };
} }
@ -31,6 +32,7 @@ export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assi
description: assignmentData.description, description: assignmentData.description,
learningPathHruid: assignmentData.learningPath, learningPathHruid: assignmentData.learningPath,
learningPathLanguage: languageMap[assignmentData.language], learningPathLanguage: languageMap[assignmentData.language],
deadline: assignmentData.deadline,
groups: [], groups: [],
}); });
} }

View file

@ -10,6 +10,10 @@ export function mapToUserDTO(user: User): UserDTO {
}; };
} }
export function mapToUsername(user: { username: string }): string {
return user.username;
}
export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T { export function mapToUser<T extends User>(userData: UserDTO, userInstance: T): T {
userInstance.username = userData.username; userInstance.username = userData.username;
userInstance.firstName = userData.firstName; userInstance.firstName = userData.firstName;

View file

@ -7,7 +7,6 @@ import * as express from 'express';
import { AuthenticatedRequest } from './authenticated-request.js'; import { AuthenticatedRequest } from './authenticated-request.js';
import { AuthenticationInfo } from './authentication-info.js'; import { AuthenticationInfo } from './authentication-info.js';
import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js'; import { UnauthorizedException } from '../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../exceptions/forbidden-exception.js';
const JWKS_CACHE = true; const JWKS_CACHE = true;
const JWKS_RATE_LIMIT = true; const JWKS_RATE_LIMIT = true;
@ -108,36 +107,3 @@ function addAuthenticationInfo(req: AuthenticatedRequest, _res: express.Response
} }
export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; export const authenticateUser = [verifyJwtToken, addAuthenticationInfo];
/**
* Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill
* the given access condition.
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
* to true.
*/
export function authorize(accessCondition: (auth: AuthenticationInfo) => boolean) {
return (req: AuthenticatedRequest, _res: express.Response, next: express.NextFunction): void => {
if (!req.auth) {
throw new UnauthorizedException();
} else if (!accessCondition(req.auth)) {
throw new ForbiddenException();
} else {
next();
}
};
}
/**
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
*/
export const authenticatedOnly = authorize((_) => true);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't students.
*/
export const studentsOnly = authorize((auth) => auth.accountType === 'student');
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === 'teacher');

View file

@ -1,8 +1,15 @@
import { Request } from 'express'; import { Request } from 'express';
import { JwtPayload } from 'jsonwebtoken'; import { JwtPayload } from 'jsonwebtoken';
import { AuthenticationInfo } from './authentication-info.js'; import { AuthenticationInfo } from './authentication-info.js';
import * as core from 'express-serve-static-core';
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest<
P = core.ParamsDictionary,
ResBody = unknown,
ReqBody = unknown,
ReqQuery = core.Query,
Locals extends Record<string, unknown> = Record<string, unknown>,
> extends Request<P, ResBody, ReqBody, ReqQuery, Locals> {
// Properties are optional since the user is not necessarily authenticated. // Properties are optional since the user is not necessarily authenticated.
jwtPayload?: JwtPayload; jwtPayload?: JwtPayload;
auth?: AuthenticationInfo; auth?: AuthenticationInfo;

View file

@ -0,0 +1,21 @@
import { authorize } from './auth-checks.js';
import { fetchClass } from '../../../services/classes.js';
import { fetchAllGroups } from '../../../services/groups.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Expects the path to contain the path parameters 'classId' and 'id' (meaning the ID of the assignment).
* Only allows requests from users who are
* - either teachers of the class the assignment was posted in,
* - or students in a group of the assignment.
*/
export const onlyAllowIfHasAccessToAssignment = authorize(async (auth, req) => {
const { classid: classId, id: assignmentId } = req.params as { classid: string; id: number };
if (auth.accountType === AccountType.Teacher) {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
const groups = await fetchAllGroups(classId, assignmentId);
return groups.some((group) => group.members.map((member) => member.username).includes(auth.username));
});

View file

@ -0,0 +1,61 @@
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import * as express from 'express';
import { RequestHandler } from 'express';
import { UnauthorizedException } from '../../../exceptions/unauthorized-exception.js';
import { ForbiddenException } from '../../../exceptions/forbidden-exception.js';
import { envVars, getEnvVar } from '../../../util/envVars.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill
* the given access condition.
* @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates
* to true.
*/
export function authorize<P, ResBody, ReqBody, ReqQuery, Locals extends Record<string, unknown>>(
accessCondition: (auth: AuthenticationInfo, req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>) => boolean | Promise<boolean>
): RequestHandler<P, ResBody, ReqBody, ReqQuery, Locals> {
// Bypass authentication during testing
if (getEnvVar(envVars.RunMode) === 'test') {
return async (
_req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
next();
};
}
return async (
req: AuthenticatedRequest<P, ResBody, ReqBody, ReqQuery, Locals>,
_res: express.Response,
next: express.NextFunction
): Promise<void> => {
if (!req.auth) {
throw new UnauthorizedException();
} else if (!(await accessCondition(req.auth, req))) {
throw new ForbiddenException();
} else {
next();
}
};
}
/**
* Middleware which rejects all unauthenticated users, but accepts all authenticated users.
*/
export const authenticatedOnly = authorize((_) => true);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't students.
*/
export const studentsOnly = authorize((auth) => auth.accountType === AccountType.Student);
/**
* Middleware which rejects requests from unauthenticated users or users that aren't teachers.
*/
export const teachersOnly = authorize((auth) => auth.accountType === AccountType.Teacher);
/**
* Middleware which is to be used on requests no normal user should be able to execute.
* Since there is no concept of administrator accounts yet, currently, those requests will always be blocked.
*/
export const adminOnly = authorize(() => false);

View file

@ -0,0 +1,70 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { fetchClass } from '../../../services/classes.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { getAllInvitations } from '../../../services/teacher-invitations.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
async function teaches(teacherUsername: string, classId: string): Promise<boolean> {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(teacherUsername);
}
/**
* To be used on a request with path parameters username and classId.
* Only allows requests whose username parameter is equal to the username of the user who is logged in and requests
* whose classId parameter references a class the logged-in user is a teacher of.
*/
export const onlyAllowStudentHimselfAndTeachersOfClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
if (req.params.username === auth.username) {
return true;
} else if (auth.accountType === AccountType.Teacher) {
return teaches(auth.username, req.params.classId);
}
return false;
});
/**
* Only let the request pass through if its path parameter "username" is the username of the currently logged-in
* teacher and the path parameter "classId" refers to a class the teacher teaches.
*/
export const onlyAllowTeacherOfClass = authorize(
async (auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username && teaches(auth.username, req.params.classId)
);
/**
* Only let the request pass through if the class id in it refers to a class the current user is in (as a student
* or teacher)
*/
export const onlyAllowIfInClass = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const classId = req.params.classId ?? req.params.classid ?? req.params.id;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});
export const onlyAllowIfInClassOrInvited = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const classId = req.params.classId ?? req.params.classid ?? req.params.id;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
const invitations = await getAllInvitations(auth.username, false);
return clazz.teachers.map(mapToUsername).includes(auth.username) || invitations.some((invitation) => invitation.classId === classId);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});
/**
* Only allows the request to pass if the 'class' property in its body is a class the current user is a member of.
*/
export const onlyAllowOwnClassInBody = authorize(async (auth, req) => {
const classId = (req.body as { class: string })?.class;
const clazz = await fetchClass(classId);
if (auth.accountType === AccountType.Teacher) {
return clazz.teachers.map(mapToUsername).includes(auth.username);
}
return clazz.students.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,26 @@
import { authorize } from './auth-checks.js';
import { fetchClass } from '../../../services/classes.js';
import { fetchGroup } from '../../../services/groups.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Expects the path to contain the path parameters 'classid', 'assignmentid' and 'groupid'.
* Only allows requests from users who are
* - either teachers of the class the assignment for the group was posted in,
* - or students in the group
*/
export const onlyAllowIfHasAccessToGroup = authorize(async (auth, req) => {
const {
classid: classId,
assignmentid: assignmentId,
groupid: groupId,
} = req.params as { classid: string; assignmentid: number; groupid: number };
if (auth.accountType === AccountType.Teacher) {
const clazz = await fetchClass(classId);
return clazz.teachers.map(mapToUsername).includes(auth.username);
} // User is student
const group = await fetchGroup(classId, assignmentId, groupId);
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,21 @@
import { authorize } from './auth-checks';
import { AuthenticationInfo } from '../authentication-info';
import { AuthenticatedRequest } from '../authenticated-request';
import { AccountType } from '@dwengo-1/common/util/account-types';
/**
* Only allows requests whose learning path personalization query parameters ('forGroup' / 'assignmentNo' / 'classId')
* are
* - either not set
* - or set to a group the user is in,
* - or set to anything if the user is a teacher.
*/
export const onlyAllowPersonalizationForOwnGroup = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { forGroup, assignmentNo, classId } = req.params;
if (auth.accountType === AccountType.Student && forGroup && assignmentNo && classId) {
// TODO: groupNumber?
// Const group = await fetchGroup(Number(classId), Number(assignmentNo), )
return false;
}
return true;
});

View file

@ -0,0 +1,66 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { requireFields } from '../../../controllers/error-helper.js';
import { getLearningObjectId, getQuestionId } from '../../../controllers/questions.js';
import { fetchQuestion } from '../../../services/questions.js';
import { FALLBACK_SEQ_NUM } from '../../../config.js';
import { fetchAnswer } from '../../../services/answers.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
export const onlyAllowAuthor = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { author: string }).author === auth.username
);
export const onlyAllowAuthorRequest = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const hruid = req.params.hruid;
const version = req.params.version;
const language = req.query.lang as string;
const seq = req.params.seq;
requireFields({ hruid });
const learningObjectId = getLearningObjectId(hruid, version, language);
const questionId = getQuestionId(learningObjectId, seq);
const question = await fetchQuestion(questionId);
return question.author.username === auth.username;
});
export const onlyAllowAuthorRequestAnswer = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const hruid = req.params.hruid;
const version = req.params.version;
const language = req.query.lang as string;
const seq = req.params.seq;
const seqAnswer = req.params.seqAnswer;
requireFields({ hruid });
const learningObjectId = getLearningObjectId(hruid, version, language);
const questionId = getQuestionId(learningObjectId, seq);
const sequenceNumber = Number(seqAnswer) || FALLBACK_SEQ_NUM;
const answer = await fetchAnswer(questionId, sequenceNumber);
return answer.author.username === auth.username;
});
export const onlyAllowIfHasAccessToQuestion = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const hruid = req.params.hruid;
const version = req.params.version;
const language = req.query.lang as string;
const seq = req.params.seq;
requireFields({ hruid });
const learningObjectId = getLearningObjectId(hruid, version, language);
const questionId = getQuestionId(learningObjectId, seq);
const question = await fetchQuestion(questionId);
const group = question.inGroup;
if (auth.accountType === AccountType.Teacher) {
const cls = group.assignment.within; // TODO check if contains full objects
return cls.teachers.map(mapToUsername).includes(auth.username);
} // User is student
return group.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,28 @@
import { languageMap } from '@dwengo-1/common/util/language';
import { LearningObjectIdentifier } from '../../../entities/content/learning-object-identifier.js';
import { fetchSubmission } from '../../../services/submissions.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { authorize } from './auth-checks.js';
import { FALLBACK_LANG } from '../../../config.js';
import { mapToUsername } from '../../../interfaces/user.js';
import { AccountType } from '@dwengo-1/common/util/account-types';
export const onlyAllowSubmitter = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username
);
export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => {
const { hruid: lohruid, id: submissionNumber } = req.params;
const { language: lang, version: version } = req.query;
const loId = new LearningObjectIdentifier(lohruid, languageMap[lang as string] ?? FALLBACK_LANG, Number(version));
const submission = await fetchSubmission(loId, Number(submissionNumber));
if (auth.accountType === AccountType.Teacher) {
// Dit kan niet werken om dat al deze objecten niet gepopulate zijn.
return submission.onBehalfOf.assignment.within.teachers.map(mapToUsername).includes(auth.username);
}
return submission.onBehalfOf.members.map(mapToUsername).includes(auth.username);
});

View file

@ -0,0 +1,17 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
export const onlyAllowSenderOrReceiver = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username || req.params.receiver === auth.username
);
export const onlyAllowSender = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.sender === auth.username);
export const onlyAllowSenderBody = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { sender: string }).sender === auth.username
);
export const onlyAllowReceiverBody = authorize(
(auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { receiver: string }).receiver === auth.username
);

View file

@ -0,0 +1,8 @@
import { authorize } from './auth-checks.js';
import { AuthenticationInfo } from '../authentication-info.js';
import { AuthenticatedRequest } from '../authenticated-request.js';
/**
* Only allow the user whose username is in the path parameter "username" to access the endpoint.
*/
export const preventImpersonation = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => req.params.username === auth.username);

View file

@ -1,16 +1,18 @@
import express from 'express'; import express from 'express';
import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js'; import { createAnswerHandler, deleteAnswerHandler, getAnswerHandler, getAllAnswersHandler, updateAnswerHandler } from '../controllers/answers.js';
import { authenticatedOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowAuthor, onlyAllowAuthorRequestAnswer, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', getAllAnswersHandler); router.get('/', authenticatedOnly, getAllAnswersHandler);
router.post('/', createAnswerHandler); router.post('/', teachersOnly, onlyAllowAuthor, createAnswerHandler);
router.get('/:seqAnswer', getAnswerHandler); router.get('/:seqAnswer', onlyAllowIfHasAccessToQuestion, getAnswerHandler);
router.delete('/:seqAnswer', deleteAnswerHandler); router.delete('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, deleteAnswerHandler);
router.put('/:seqAnswer', updateAnswerHandler); router.put('/:seqAnswer', teachersOnly, onlyAllowAuthorRequestAnswer, updateAnswerHandler);
export default router; export default router;

View file

@ -9,22 +9,25 @@ import {
putAssignmentHandler, putAssignmentHandler,
} from '../controllers/assignments.js'; } from '../controllers/assignments.js';
import groupRouter from './groups.js'; import groupRouter from './groups.js';
import { teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfInClass } from '../middleware/auth/checks/class-auth-checks.js';
import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', getAllAssignmentsHandler); router.get('/', teachersOnly, onlyAllowIfInClass, getAllAssignmentsHandler);
router.post('/', createAssignmentHandler); router.post('/', teachersOnly, onlyAllowIfInClass, createAssignmentHandler);
router.get('/:id', getAssignmentHandler); router.get('/:id', onlyAllowIfHasAccessToAssignment, getAssignmentHandler);
router.put('/:id', putAssignmentHandler); router.put('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, putAssignmentHandler);
router.delete('/:id', deleteAssignmentHandler); router.delete('/:id', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteAssignmentHandler);
router.get('/:id/submissions', getAssignmentsSubmissionsHandler); router.get('/:id/submissions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentsSubmissionsHandler);
router.get('/:id/questions', getAssignmentQuestionsHandler); router.get('/:id/questions', teachersOnly, onlyAllowIfHasAccessToAssignment, getAssignmentQuestionsHandler);
router.use('/:assignmentid/groups', groupRouter); router.use('/:assignmentid/groups', groupRouter);

View file

@ -1,28 +1,35 @@
import express from 'express'; import express from 'express';
import { getFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js'; import { handleGetFrontendAuthConfig, postHelloHandler } from '../controllers/auth.js';
import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Returns auth configuration for frontend // Returns auth configuration for frontend
router.get('/config', (_req, res) => { router.get('/config', handleGetFrontendAuthConfig);
res.json(getFrontendAuthConfig());
});
router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => { router.get('/testAuthenticatedOnly', authenticatedOnly, (_req, res) => {
/* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ /* #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
res.json({ message: 'If you see this, you should be authenticated!' }); res.json({ message: 'If you see this, you should be authenticated!' });
}); });
router.get('/testStudentsOnly', studentsOnly, (_req, res) => { router.get('/testStudentsOnly', studentsOnly, (_req, res) => {
/* #swagger.security = [{ "student": [ ] }] */ /* #swagger.security = [{ "studentProduction": [ ] }, { "studentStaging": [ ] }, { "studentDev": [ ] }] */
res.json({ message: 'If you see this, you should be a student!' }); res.json({ message: 'If you see this, you should be a student!' });
}); });
router.get('/testTeachersOnly', teachersOnly, (_req, res) => { router.get('/testTeachersOnly', teachersOnly, (_req, res) => {
/* #swagger.security = [{ "teacher": [ ] }] */ /* #swagger.security = [{ "teacherProduction": [ ] }, { "teacherStaging": [ ] }, { "teacherDev": [ ] }] */
res.json({ message: 'If you see this, you should be a teacher!' }); res.json({ message: 'If you see this, you should be a teacher!' });
}); });
router.post('/hello', authenticatedOnly, postHelloHandler); // This endpoint is called by the client when the user has just logged in.
// It creates or updates the user entity based on the authentication data the endpoint was called with.
router.post(
'/hello',
authenticatedOnly,
/*
#swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }]
*/ postHelloHandler
);
export default router; export default router;

View file

@ -14,33 +14,35 @@ import {
putClassHandler, putClassHandler,
} from '../controllers/classes.js'; } from '../controllers/classes.js';
import assignmentRouter from './assignments.js'; import assignmentRouter from './assignments.js';
import { adminOnly, teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfInClass, onlyAllowIfInClassOrInvited } from '../middleware/auth/checks/class-auth-checks.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects router.get('/', adminOnly, getAllClassesHandler);
router.get('/', getAllClassesHandler);
router.post('/', createClassHandler); router.post('/', teachersOnly, createClassHandler);
router.get('/:id', getClassHandler); router.get('/:id', onlyAllowIfInClassOrInvited, getClassHandler);
router.put('/:id', putClassHandler); router.put('/:id', teachersOnly, onlyAllowIfInClass, putClassHandler);
router.delete('/:id', deleteClassHandler); router.delete('/:id', teachersOnly, onlyAllowIfInClass, deleteClassHandler);
router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); router.get('/:id/teacher-invitations', teachersOnly, onlyAllowIfInClass, getTeacherInvitationsHandler);
router.get('/:id/students', getClassStudentsHandler); router.get('/:id/students', onlyAllowIfInClass, getClassStudentsHandler);
router.post('/:id/students', addClassStudentHandler); router.post('/:id/students', teachersOnly, onlyAllowIfInClass, addClassStudentHandler);
router.delete('/:id/students/:username', deleteClassStudentHandler); router.delete('/:id/students/:username', teachersOnly, onlyAllowIfInClass, deleteClassStudentHandler);
router.get('/:id/teachers', getClassTeachersHandler); router.get('/:id/teachers', onlyAllowIfInClass, getClassTeachersHandler);
router.post('/:id/teachers', addClassTeacherHandler); // De combinatie van deze POST en DELETE endpoints kan lethal zijn
router.post('/:id/teachers', teachersOnly, onlyAllowIfInClass, addClassTeacherHandler);
router.delete('/:id/teachers/:username', deleteClassTeacherHandler); router.delete('/:id/teachers/:username', teachersOnly, onlyAllowIfInClass, deleteClassTeacherHandler);
router.use('/:classid/assignments', assignmentRouter); router.use('/:classid/assignments', assignmentRouter);

View file

@ -8,22 +8,24 @@ import {
getGroupSubmissionsHandler, getGroupSubmissionsHandler,
putGroupHandler, putGroupHandler,
} from '../controllers/groups.js'; } from '../controllers/groups.js';
import { onlyAllowIfHasAccessToGroup } from '../middleware/auth/checks/group-auth-checker.js';
import { teachersOnly } from '../middleware/auth/checks/auth-checks.js';
import { onlyAllowIfHasAccessToAssignment } from '../middleware/auth/checks/assignment-auth-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects router.get('/', onlyAllowIfHasAccessToAssignment, getAllGroupsHandler);
router.get('/', getAllGroupsHandler);
router.post('/', createGroupHandler); router.post('/', teachersOnly, onlyAllowIfHasAccessToAssignment, createGroupHandler);
router.get('/:groupid', getGroupHandler); router.get('/:groupid', onlyAllowIfHasAccessToAssignment, getGroupHandler);
router.put('/:groupid', putGroupHandler); router.put('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, putGroupHandler);
router.delete('/:groupid', deleteGroupHandler); router.delete('/:groupid', teachersOnly, onlyAllowIfHasAccessToAssignment, deleteGroupHandler);
router.get('/:groupid/submissions', getGroupSubmissionsHandler); router.get('/:groupid/submissions', onlyAllowIfHasAccessToGroup, getGroupSubmissionsHandler);
router.get('/:groupid/questions', getGroupQuestionsHandler); router.get('/:groupid/questions', onlyAllowIfHasAccessToGroup, getGroupQuestionsHandler);
export default router; export default router;

View file

@ -1,8 +1,8 @@
import express from 'express'; import express from 'express';
import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js';
import submissionRoutes from './submissions.js'; import submissionRoutes from './submissions.js';
import questionRoutes from './questions.js'; import questionRoutes from './questions.js';
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
@ -16,13 +16,13 @@ const router = express.Router();
// Route 2: list of object data // Route 2: list of object data
// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie // Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie
router.get('/', getAllLearningObjects); router.get('/', authenticatedOnly, getAllLearningObjects);
// Parameter: hruid of learning object // Parameter: hruid of learning object
// Query: language // Query: language
// Route to fetch data of one learning object based on its hruid // Route to fetch data of one learning object based on its hruid
// Example: http://localhost:3000/learningObject/un_ai7 // Example: http://localhost:3000/learningObject/un_ai7
router.get('/:hruid', getLearningObject); router.get('/:hruid', authenticatedOnly, getLearningObject);
router.use('/:hruid/submissions', submissionRoutes); router.use('/:hruid/submissions', submissionRoutes);
@ -32,12 +32,12 @@ router.use('/:hruid/:version/questions', questionRoutes);
// Query: language, version (optional) // Query: language, version (optional)
// Route to fetch the HTML rendering of one learning object based on its hruid. // Route to fetch the HTML rendering of one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/un_ai7/html // Example: http://localhost:3000/learningObject/un_ai7/html
router.get('/:hruid/html', getLearningObjectHTML); router.get('/:hruid/html', authenticatedOnly, getLearningObjectHTML);
// Parameter: hruid of learning object, name of attachment. // Parameter: hruid of learning object, name of attachment.
// Query: language, version (optional). // Query: language, version (optional).
// Route to get the raw data of the attachment for one learning object based on its hruid. // Route to get the raw data of the attachment for one learning object based on its hruid.
// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png // Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png
router.get('/:hruid/html/:attachmentName', getAttachment); router.get('/:hruid/html/:attachmentName', authenticatedOnly, getAttachment);
export default router; export default router;

View file

@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import { getLearningPaths } from '../controllers/learning-paths.js'; import { getLearningPaths } from '../controllers/learning-paths.js';
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
@ -22,6 +23,6 @@ const router = express.Router();
// Route to fetch learning paths based on a theme // Route to fetch learning paths based on a theme
// Example: http://localhost:3000/learningPath?theme=kiks // Example: http://localhost:3000/learningPath?theme=kiks
router.get('/', getLearningPaths); router.get('/', authenticatedOnly, getLearningPaths);
export default router; export default router;

View file

@ -1,20 +1,25 @@
import express from 'express'; import express from 'express';
import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js'; import { createQuestionHandler, deleteQuestionHandler, getAllQuestionsHandler, getQuestionHandler } from '../controllers/questions.js';
import answerRoutes from './answers.js'; import answerRoutes from './answers.js';
import { authenticatedOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
import { updateAnswerHandler } from '../controllers/answers.js';
import { onlyAllowAuthor, onlyAllowAuthorRequest, onlyAllowIfHasAccessToQuestion } from '../middleware/auth/checks/question-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
// Query language // Query language
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllQuestionsHandler); router.get('/', authenticatedOnly, getAllQuestionsHandler);
router.post('/', createQuestionHandler); router.post('/', studentsOnly, onlyAllowAuthor, createQuestionHandler);
router.delete('/:seq', deleteQuestionHandler);
// Information about a question with id // Information about a question with id
router.get('/:seq', getQuestionHandler); router.get('/:seq', onlyAllowIfHasAccessToQuestion, getQuestionHandler);
router.delete('/:seq', studentsOnly, onlyAllowAuthorRequest, deleteQuestionHandler);
router.put('/:seq', studentsOnly, onlyAllowAuthorRequest, updateAnswerHandler);
router.use('/:seq/answers', answerRoutes); router.use('/:seq/answers', answerRoutes);

View file

@ -18,12 +18,30 @@ router.get('/', (_, res: Response) => {
}); });
}); });
router.use('/student', studentRouter /* #swagger.tags = ['Student'] */);
router.use('/teacher', teacherRouter /* #swagger.tags = ['Teacher'] */);
router.use('/class', classRouter /* #swagger.tags = ['Class'] */);
router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */);
router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); router.use(
router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); '/class',
router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); classRouter /* #swagger.tags = ['Class'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/learningObject',
learningObjectRoutes /* #swagger.tags = ['Learning Object'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/learningPath',
learningPathRoutes /* #swagger.tags = ['Learning Path'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/student',
studentRouter /* #swagger.tags = ['Student'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/teacher',
teacherRouter /* #swagger.tags = ['Teacher'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
router.use(
'/theme',
themeRoutes /* #swagger.tags = ['Theme'], #swagger.security = [{ "studentProduction": [ ] }, { "teacherProduction": [ ] }, { "studentStaging": [ ] }, { "teacherStaging": [ ] }, { "studentDev": [ ] }, { "teacherDev": [ ] }] */
);
export default router; export default router;

View file

@ -5,15 +5,19 @@ import {
getStudentRequestHandler, getStudentRequestHandler,
getStudentRequestsHandler, getStudentRequestsHandler,
} from '../controllers/students.js'; } from '../controllers/students.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { onlyAllowStudentHimselfAndTeachersOfClass } from '../middleware/auth/checks/class-auth-checks.js';
// Under /:username/joinRequests/
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/', getStudentRequestsHandler); router.get('/', preventImpersonation, getStudentRequestsHandler);
router.post('/', createStudentRequestHandler); router.post('/', preventImpersonation, createStudentRequestHandler);
router.get('/:classId', getStudentRequestHandler); router.get('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, getStudentRequestHandler);
router.delete('/:classId', deleteClassJoinRequestHandler); router.delete('/:classId', onlyAllowStudentHimselfAndTeachersOfClass, deleteClassJoinRequestHandler);
export default router; export default router;

View file

@ -11,33 +11,37 @@ import {
getStudentSubmissionsHandler, getStudentSubmissionsHandler,
} from '../controllers/students.js'; } from '../controllers/students.js';
import joinRequestRouter from './student-join-requests.js'; import joinRequestRouter from './student-join-requests.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { adminOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllStudentsHandler); router.get('/', adminOnly, getAllStudentsHandler);
router.post('/', createStudentHandler); // Users will be created automatically when some resource is created for them. Therefore, this endpoint
// Can only be used by an administrator.
router.post('/', adminOnly, createStudentHandler);
router.delete('/:username', deleteStudentHandler); router.delete('/:username', preventImpersonation, deleteStudentHandler);
// Information about a student's profile // Information about a student's profile
router.get('/:username', getStudentHandler); router.get('/:username', preventImpersonation, getStudentHandler);
// The list of classes a student is in // The list of classes a student is in
router.get('/:username/classes', getStudentClassesHandler); router.get('/:username/classes', preventImpersonation, getStudentClassesHandler);
// The list of submissions a student has made // The list of submissions a student has made
router.get('/:username/submissions', getStudentSubmissionsHandler); router.get('/:username/submissions', preventImpersonation, getStudentSubmissionsHandler);
// The list of assignments a student has // The list of assignments a student has
router.get('/:username/assignments', getStudentAssignmentsHandler); router.get('/:username/assignments', preventImpersonation, getStudentAssignmentsHandler);
// The list of groups a student is in // The list of groups a student is in
router.get('/:username/groups', getStudentGroupsHandler); router.get('/:username/groups', preventImpersonation, getStudentGroupsHandler);
// A list of questions a user has created // A list of questions a user has created
router.get('/:username/questions', getStudentQuestionsHandler); router.get('/:username/questions', preventImpersonation, getStudentQuestionsHandler);
router.use('/:username/joinRequests', joinRequestRouter); router.use('/:username/joinRequests', joinRequestRouter);

View file

@ -1,15 +1,15 @@
import express from 'express'; import express from 'express';
import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js'; import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler, getSubmissionsHandler } from '../controllers/submissions.js';
import { onlyAllowIfHasAccessToSubmission, onlyAllowSubmitter } from '../middleware/auth/checks/submission-checks.js';
import { adminOnly, studentsOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
// Root endpoint used to search objects router.get('/', adminOnly, getSubmissionsHandler);
router.get('/', getSubmissionsHandler);
router.post('/', createSubmissionHandler); router.post('/', studentsOnly, onlyAllowSubmitter, createSubmissionHandler);
// Information about an submission with id 'id' router.get('/:id', onlyAllowIfHasAccessToSubmission, getSubmissionHandler);
router.get('/:id', getSubmissionHandler);
router.delete('/:id', deleteSubmissionHandler); router.delete('/:id', onlyAllowIfHasAccessToSubmission, deleteSubmissionHandler);
export default router; export default router;

View file

@ -6,17 +6,24 @@ import {
getInvitationHandler, getInvitationHandler,
updateInvitationHandler, updateInvitationHandler,
} from '../controllers/teacher-invitations.js'; } from '../controllers/teacher-invitations.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import {
onlyAllowReceiverBody,
onlyAllowSender,
onlyAllowSenderBody,
onlyAllowSenderOrReceiver,
} from '../middleware/auth/checks/teacher-invitation-checks.js';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
router.get('/:username', getAllInvitationsHandler); router.get('/:username', preventImpersonation, getAllInvitationsHandler);
router.get('/:sender/:receiver/:classId', getInvitationHandler); router.get('/:sender/:receiver/:classId', onlyAllowSenderOrReceiver, getInvitationHandler);
router.post('/', createInvitationHandler); router.post('/', onlyAllowSenderBody, createInvitationHandler);
router.put('/', updateInvitationHandler); router.put('/', onlyAllowReceiverBody, updateInvitationHandler);
router.delete('/:sender/:receiver/:classId', deleteInvitationHandler); router.delete('/:sender/:receiver/:classId', onlyAllowSender, deleteInvitationHandler);
export default router; export default router;

View file

@ -6,32 +6,31 @@ import {
getStudentJoinRequestHandler, getStudentJoinRequestHandler,
getTeacherClassHandler, getTeacherClassHandler,
getTeacherHandler, getTeacherHandler,
getTeacherQuestionHandler,
getTeacherStudentHandler, getTeacherStudentHandler,
updateStudentJoinRequestHandler, updateStudentJoinRequestHandler,
} from '../controllers/teachers.js'; } from '../controllers/teachers.js';
import invitationRouter from './teacher-invitations.js'; import invitationRouter from './teacher-invitations.js';
import { adminOnly } from '../middleware/auth/checks/auth-checks.js';
import { preventImpersonation } from '../middleware/auth/checks/user-auth-checks.js';
import { onlyAllowTeacherOfClass } from '../middleware/auth/checks/class-auth-checks.js';
const router = express.Router(); const router = express.Router();
// Root endpoint used to search objects // Root endpoint used to search objects
router.get('/', getAllTeachersHandler); router.get('/', adminOnly, getAllTeachersHandler);
router.post('/', createTeacherHandler); router.post('/', adminOnly, createTeacherHandler);
router.get('/:username', getTeacherHandler); router.get('/:username', preventImpersonation, getTeacherHandler);
router.delete('/:username', deleteTeacherHandler); router.delete('/:username', preventImpersonation, deleteTeacherHandler);
router.get('/:username/classes', getTeacherClassHandler); router.get('/:username/classes', preventImpersonation, getTeacherClassHandler);
router.get('/:username/students', getTeacherStudentHandler); router.get('/:username/students', preventImpersonation, getTeacherStudentHandler);
router.get('/:username/questions', getTeacherQuestionHandler); router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler);
router.get('/:username/joinRequests/:classId', getStudentJoinRequestHandler); router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler);
router.put('/:username/joinRequests/:classId/:studentUsername', updateStudentJoinRequestHandler);
// Invitations to other classes a teacher received // Invitations to other classes a teacher received
router.use('/invitations', invitationRouter); router.use('/invitations', invitationRouter);

View file

@ -1,14 +1,15 @@
import express from 'express'; import express from 'express';
import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js'; import { getThemesHandler, getHruidsByThemeHandler } from '../controllers/themes.js';
import { authenticatedOnly } from '../middleware/auth/checks/auth-checks.js';
const router = express.Router(); const router = express.Router();
// Query: language // Query: language
// Route to fetch list of {key, title, description, image} themes in their respective language // Route to fetch list of {key, title, description, image} themes in their respective language
router.get('/', getThemesHandler); router.get('/', authenticatedOnly, getThemesHandler);
// Arg: theme (key) // Arg: theme (key)
// Route to fetch list of hruids based on theme // Route to fetch list of hruids based on theme
router.get('/:theme', getHruidsByThemeHandler); router.get('/:theme', authenticatedOnly, getHruidsByThemeHandler);
export default router; export default router;

View file

@ -34,7 +34,7 @@ export async function createAnswer(questionId: QuestionId, answerData: AnswerDat
return mapToAnswerDTO(answer); return mapToAnswerDTO(answer);
} }
async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> { export async function fetchAnswer(questionId: QuestionId, sequenceNumber: number): Promise<Answer> {
const answerRepository = getAnswerRepository(); const answerRepository = getAnswerRepository();
const question = await fetchQuestion(questionId); const question = await fetchQuestion(questionId);
const answer = await answerRepository.findAnswer(question, sequenceNumber); const answer = await answerRepository.findAnswer(question, sequenceNumber);

View file

@ -34,6 +34,15 @@ export async function fetchGroup(classId: string, assignmentNumber: number, grou
return group; return group;
} }
export async function fetchAllGroups(classId: string, assignmentNumber: number): Promise<Group[]> {
const assignment = await fetchAssignment(classId, assignmentNumber);
const groupRepository = getGroupRepository();
const groups = await groupRepository.findAllGroupsForAssignment(assignment);
return groups;
}
export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> { export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise<GroupDTO> {
const group = await fetchGroup(classId, assignmentNumber, groupNumber); const group = await fetchGroup(classId, assignmentNumber, groupNumber);
return mapToGroupDTO(group, group.assignment.within); return mapToGroupDTO(group, group.assignment.within);

View file

@ -4,7 +4,7 @@ import { getLearningPathRepository } from '../../data/repositories.js';
import learningObjectService from '../learning-objects/learning-object-service.js'; import learningObjectService from '../learning-objects/learning-object-service.js';
import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { getLastSubmissionForGroup, isTransitionPossible } from './learning-path-personalization-util.js'; import { getLastSubmissionForGroup, idFromLearningPathNode, isTransitionPossible } from './learning-path-personalization-util.js';
import { import {
FilteredLearningObject, FilteredLearningObject,
LearningObjectNode, LearningObjectNode,
@ -95,7 +95,7 @@ async function convertNode(
personalizedFor: Group | undefined, personalizedFor: Group | undefined,
nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject> nodesToLearningObjects: Map<LearningPathNode, FilteredLearningObject>
): Promise<LearningObjectNode> { ): Promise<LearningObjectNode> {
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(node, personalizedFor) : null; const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningPathNode(node), personalizedFor) : null;
const transitions = node.transitions const transitions = node.transitions
.filter( .filter(
(trans) => (trans) =>

View file

@ -3,11 +3,33 @@ import { DWENGO_API_BASE } from '../../config.js';
import { LearningPathProvider } from './learning-path-provider.js'; import { LearningPathProvider } from './learning-path-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js'; import { getLogger, Logger } from '../../logging/initalize.js';
import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content'; import { LearningPath, LearningPathResponse } from '@dwengo-1/common/interfaces/learning-content';
import { Group } from '../../entities/assignments/group.entity.js';
import { getLastSubmissionForGroup, idFromLearningObjectNode } from './learning-path-personalization-util.js';
const logger: Logger = getLogger(); const logger: Logger = getLogger();
/**
* Adds progress information to the learning path. Modifies the learning path in-place.
* @param learningPath The learning path to add progress to.
* @param personalizedFor The group whose progress should be shown.
* @returns the modified learning path.
*/
async function addProgressToLearningPath(learningPath: LearningPath, personalizedFor: Group): Promise<LearningPath> {
await Promise.all(
learningPath.nodes.map(async (node) => {
const lastSubmission = personalizedFor ? await getLastSubmissionForGroup(idFromLearningObjectNode(node), personalizedFor) : null;
node.done = Boolean(lastSubmission);
})
);
learningPath.num_nodes = learningPath.nodes.length;
learningPath.num_nodes_left = learningPath.nodes.filter((it) => !it.done).length;
return learningPath;
}
const dwengoApiLearningPathProvider: LearningPathProvider = { const dwengoApiLearningPathProvider: LearningPathProvider = {
async fetchLearningPaths(hruids: string[], language: string, source: string): Promise<LearningPathResponse> { async fetchLearningPaths(hruids: string[], language: string, source: string, personalizedFor: Group): Promise<LearningPathResponse> {
if (hruids.length === 0) { if (hruids.length === 0) {
return { return {
success: false, success: false,
@ -32,17 +54,24 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
}; };
} }
await Promise.all(learningPaths?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
return { return {
success: true, success: true,
source, source,
data: learningPaths, data: learningPaths,
}; };
}, },
async searchLearningPaths(query: string, language: string): Promise<LearningPath[]> { async searchLearningPaths(query: string, language: string, personalizedFor: Group): Promise<LearningPath[]> {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language }; const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
if (searchResults) {
await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
}
return searchResults ?? []; return searchResults ?? [];
}, },
}; };

View file

@ -5,18 +5,36 @@ import { getSubmissionRepository } from '../../data/repositories.js';
import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js';
import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js';
import { JSONPath } from 'jsonpath-plus'; import { JSONPath } from 'jsonpath-plus';
import { LearningObjectNode } from '@dwengo-1/common/interfaces/learning-content';
/** /**
* Returns the last submission for the learning object associated with the given node and for the group * Returns the last submission for the learning object associated with the given node and for the group
*/ */
export async function getLastSubmissionForGroup(node: LearningPathNode, pathFor: Group): Promise<Submission | null> { export async function getLastSubmissionForGroup(learningObjectId: LearningObjectIdentifier, pathFor: Group): Promise<Submission | null> {
const submissionRepo = getSubmissionRepository(); const submissionRepo = getSubmissionRepository();
const learningObjectId: LearningObjectIdentifier = { return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
}
/**
* Creates a LearningObjectIdentifier describing the specified node.
*/
export function idFromLearningObjectNode(node: LearningObjectNode): LearningObjectIdentifier {
return {
hruid: node.learningobject_hruid,
language: node.language,
version: node.version,
};
}
/**
* Creates a LearningObjectIdentifier describing the specified node.
*/
export function idFromLearningPathNode(node: LearningPathNode): LearningObjectIdentifier {
return {
hruid: node.learningObjectHruid, hruid: node.learningObjectHruid,
language: node.language, language: node.language,
version: node.version, version: node.version,
}; };
return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor);
} }
/** /**

View file

@ -13,6 +13,7 @@ import { fetchStudent } from './students.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
import { FALLBACK_VERSION_NUM } from '../config.js'; import { FALLBACK_VERSION_NUM } from '../config.js';
import { fetchAssignment } from './assignments.js'; import { fetchAssignment } from './assignments.js';
import { ConflictException } from '../exceptions/conflict-exception.js';
export async function getQuestionsAboutLearningObjectInAssignment( export async function getQuestionsAboutLearningObjectInAssignment(
loId: LearningObjectIdentifier, loId: LearningObjectIdentifier,
@ -99,10 +100,18 @@ export async function createQuestion(loId: LearningObjectIdentifier, questionDat
const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber); const inGroup = await getGroupRepository().findByAssignmentAndGroupNumber(assignment, questionData.inGroup.groupNumber);
if (!inGroup) {
throw new NotFoundException('Group with id and assignment not found');
}
if (!inGroup.members.contains(author)) {
throw new ConflictException('Author is not part of this group');
}
const question = await questionRepository.createQuestion({ const question = await questionRepository.createQuestion({
loId, loId,
author, author,
inGroup: inGroup!, inGroup: inGroup,
content, content,
}); });

View file

@ -24,7 +24,8 @@ import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/subm
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; import { ConflictException } from '../exceptions/conflict-exception.js';
import { Submission } from '../entities/assignments/submission.entity'; import { Submission } from '../entities/assignments/submission.entity.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> {
const studentRepository = getStudentRepository(); const studentRepository = getStudentRepository();
@ -34,7 +35,7 @@ export async function getAllStudents(full: boolean): Promise<StudentDTO[] | stri
return users.map(mapToStudentDTO); return users.map(mapToStudentDTO);
} }
return users.map((user) => user.username); return users.map(mapToUsername);
} }
export async function fetchStudent(username: string): Promise<Student> { export async function fetchStudent(username: string): Promise<Student> {
@ -42,7 +43,7 @@ export async function fetchStudent(username: string): Promise<Student> {
const user = await studentRepository.findByUsername(username); const user = await studentRepository.findByUsername(username);
if (!user) { if (!user) {
throw new NotFoundException('Student with username not found'); throw new NotFoundException(`Student with username ${username} not found`);
} }
return user; return user;
@ -64,7 +65,7 @@ export async function createStudent(userData: StudentDTO): Promise<StudentDTO> {
const newStudent = mapToStudent(userData); const newStudent = mapToStudent(userData);
await studentRepository.save(newStudent, { preventOverwrite: true }); await studentRepository.save(newStudent, { preventOverwrite: true });
return userData; return mapToStudentDTO(newStudent);
} }
export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> { export async function createOrUpdateStudent(userData: StudentDTO): Promise<StudentDTO> {

View file

@ -32,6 +32,10 @@ export async function createInvitation(data: TeacherInvitationData): Promise<Tea
throw new ConflictException('The teacher sending the invite is not part of the class'); throw new ConflictException('The teacher sending the invite is not part of the class');
} }
if (cls.teachers.contains(receiver)) {
throw new ConflictException('The teacher receiving the invite is already part of the class');
}
const newInvitation = mapToInvitation(sender, receiver, cls); const newInvitation = mapToInvitation(sender, receiver, cls);
await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true }); await teacherInvitationRepository.save(newInvitation, { preventOverwrite: true });

View file

@ -1,12 +1,5 @@
import { import { getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js';
getClassJoinRequestRepository,
getClassRepository,
getLearningObjectRepository,
getQuestionRepository,
getTeacherRepository,
} from '../data/repositories.js';
import { mapToClassDTO } from '../interfaces/class.js'; import { mapToClassDTO } from '../interfaces/class.js';
import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js';
import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js';
import { Teacher } from '../entities/users/teacher.entity.js'; import { Teacher } from '../entities/users/teacher.entity.js';
import { fetchStudent } from './students.js'; import { fetchStudent } from './students.js';
@ -15,10 +8,6 @@ import { mapToStudentRequestDTO } from '../interfaces/student-request.js';
import { TeacherRepository } from '../data/users/teacher-repository.js'; import { TeacherRepository } from '../data/users/teacher-repository.js';
import { ClassRepository } from '../data/classes/class-repository.js'; import { ClassRepository } from '../data/classes/class-repository.js';
import { Class } from '../entities/classes/class.entity.js'; import { Class } from '../entities/classes/class.entity.js';
import { LearningObjectRepository } from '../data/content/learning-object-repository.js';
import { LearningObject } from '../entities/content/learning-object.entity.js';
import { QuestionRepository } from '../data/questions/question-repository.js';
import { Question } from '../entities/questions/question.entity.js';
import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js'; import { ClassJoinRequestRepository } from '../data/classes/class-join-request-repository.js';
import { Student } from '../entities/users/student.entity.js'; import { Student } from '../entities/users/student.entity.js';
import { NotFoundException } from '../exceptions/not-found-exception.js'; import { NotFoundException } from '../exceptions/not-found-exception.js';
@ -26,10 +15,10 @@ import { addClassStudent, fetchClass, getClassStudentsDTO } from './classes.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher'; import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { ClassDTO } from '@dwengo-1/common/interfaces/class'; import { ClassDTO } from '@dwengo-1/common/interfaces/class';
import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question';
import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { ConflictException } from '../exceptions/conflict-exception.js'; import { ConflictException } from '../exceptions/conflict-exception.js';
import { mapToUsername } from '../interfaces/user.js';
export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
@ -38,7 +27,7 @@ export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | stri
if (full) { if (full) {
return users.map(mapToTeacherDTO); return users.map(mapToTeacherDTO);
} }
return users.map((user) => user.username); return users.map(mapToUsername);
} }
export async function fetchTeacher(username: string): Promise<Teacher> { export async function fetchTeacher(username: string): Promise<Teacher> {
@ -57,7 +46,8 @@ export async function getTeacher(username: string): Promise<TeacherDTO> {
return mapToTeacherDTO(user); return mapToTeacherDTO(user);
} }
export async function createTeacher(userData: TeacherDTO): Promise<TeacherDTO> { // TODO update parameter
export async function createTeacher(userData: TeacherDTO, _update?: boolean): Promise<TeacherDTO> {
const teacherRepository: TeacherRepository = getTeacherRepository(); const teacherRepository: TeacherRepository = getTeacherRepository();
const newTeacher = mapToTeacher(userData); const newTeacher = mapToTeacher(userData);
@ -110,7 +100,9 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro
const classIds: string[] = classes.map((cls) => cls.id); const classIds: string[] = classes.map((cls) => cls.id);
const students: StudentDTO[] = (await Promise.all(classIds.map(async (username) => await getClassStudentsDTO(username)))).flat(); const students: StudentDTO[] = (await Promise.all(classIds.map(async (classId) => await getClassStudentsDTO(classId))))
.flat()
.filter((student, index, self) => self.findIndex((s) => s.username === student.username) === index);
if (full) { if (full) {
return students; return students;
@ -119,28 +111,6 @@ export async function getStudentsByTeacher(username: string, full: boolean): Pro
return students.map((student) => student.username); return students.map((student) => student.username);
} }
export async function getTeacherQuestions(username: string, full: boolean): Promise<QuestionDTO[] | QuestionId[]> {
const teacher: Teacher = await fetchTeacher(username);
// Find all learning objects that this teacher manages
const learningObjectRepository: LearningObjectRepository = getLearningObjectRepository();
const learningObjects: LearningObject[] = await learningObjectRepository.findAllByTeacher(teacher);
if (!learningObjects || learningObjects.length === 0) {
return [];
}
// Fetch all questions related to these learning objects
const questionRepository: QuestionRepository = getQuestionRepository();
const questions: Question[] = await questionRepository.findAllByLearningObjects(learningObjects);
if (full) {
return questions.map(mapToQuestionDTO);
}
return questions.map(mapToQuestionDTOId);
}
export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> { export async function getJoinRequestsByClass(classId: string): Promise<ClassJoinRequestDTO[]> {
const classRepository: ClassRepository = getClassRepository(); const classRepository: ClassRepository = getClassRepository();
const cls: Class | null = await classRepository.findById(classId); const cls: Class | null = await classRepository.findById(classId);

View file

@ -0,0 +1,76 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { Request, Response } from 'express';
import { getAssignmentHandler, getAllAssignmentsHandler, getAssignmentsSubmissionsHandler } from '../../src/controllers/assignments.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
import { getAssignment01 } from '../test_assets/assignments/assignments.testdata';
function createRequestObject(
classid: string,
assignmentid: string
): {
query: { full: string };
params: { classid: string; id: string };
} {
return {
params: {
classid: classid,
id: assignmentid,
},
query: {
full: 'true',
},
};
}
describe('Assignment controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('return error non-existing assignment', async () => {
req = createRequestObject('doesnotexist', '43000'); // Should not exist
await expect(async () => getAssignmentHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return an assignment', async () => {
const assignment = getAssignment01();
req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString());
await getAssignmentHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignment: expect.anything() }));
});
it('should return a list of assignments', async () => {
req = createRequestObject(getClass01().classId as string, 'irrelevant');
await getAllAssignmentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() }));
});
it('should return a list of submissions for an assignment', async () => {
const assignment = getAssignment01();
req = createRequestObject(assignment.within.classId as string, (assignment.id ?? 1).toString());
await getAssignmentsSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
});

View file

@ -0,0 +1,123 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import {
createClassHandler,
deleteClassHandler,
getAllClassesHandler,
getClassHandler,
getClassStudentsHandler,
getTeacherInvitationsHandler,
} from '../../src/controllers/classes.js';
import { Request, Response } from 'express';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
describe('Class controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('create and delete class', async () => {
req = {
body: { displayName: 'coole_nieuwe_klas' },
};
await createClassHandler(req as Request, res as Response);
const result = jsonMock.mock.lastCall?.[0];
// Console.log('class', result.class);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() }));
req = {
params: { id: result.class.id },
};
await deleteClassHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ class: expect.anything() }));
});
it('Error class not found', async () => {
req = {
params: { id: 'doesnotexist' },
};
await expect(async () => getClassHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('Error create a class without name', async () => {
req = {
body: {},
};
await expect(async () => createClassHandler(req as Request, res as Response)).rejects.toThrow(BadRequestException);
});
it('return list of students', async () => {
req = {
params: { id: getClass01().classId as string },
query: {},
};
await getClassStudentsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ students: expect.anything() }));
});
it('Error students on a non-existent class', async () => {
req = {
params: { id: 'doesnotexist' },
query: {},
};
await expect(async () => getClassStudentsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 200 and a list of teacher-invitations', async () => {
const classId = getClass01().classId as string;
req = {
params: { id: classId },
query: {},
};
await getTeacherInvitationsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ invitations: expect.anything() }));
});
it('Error teacher-invitations on a non-existent class', async () => {
req = {
params: { id: 'doesnotexist' },
query: {},
};
await expect(async () => getTeacherInvitationsHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return a list of classes', async () => {
req = {
query: {},
};
await getAllClassesHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ classes: expect.anything() }));
});
});

View file

@ -0,0 +1,140 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { Request, Response } from 'express';
import {
createGroupHandler,
deleteGroupHandler,
getAllGroupsHandler,
getGroupHandler,
getGroupSubmissionsHandler,
} from '../../src/controllers/groups.js';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass01 } from '../test_assets/classes/classes.testdata';
import { getAssignment01, getAssignment02 } from '../test_assets/assignments/assignments.testdata';
import { getTestGroup01 } from '../test_assets/assignments/groups.testdata';
function createRequestObject(
classid: string,
assignmentid: string,
groupNumber: string
): {
query: { full: string };
params: { classid: string; groupid: string; assignmentid: string };
} {
return {
params: {
classid: classid,
assignmentid: assignmentid,
groupid: groupNumber,
},
query: {
full: 'true',
},
};
}
describe('Group controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('Error not found on a non-existing group', async () => {
req = {
params: {
classid: 'id01',
assignmentid: '1',
groupid: '154981', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 404 not found on a non-existing assignment', async () => {
req = {
params: {
classid: 'id01',
assignmentid: '1000', // Should not exist
groupid: '42000', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return 404 not found ont a non-existing class', async () => {
req = {
params: {
classid: 'doesnotexist', // Should not exist
assignmentid: '1000', // Should not exist
groupid: '42000', // Should not exist
},
query: {},
};
await expect(async () => getGroupHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return an existing group', async () => {
const group = getTestGroup01();
const classId = getClass01().classId as string;
req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString());
await getGroupHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() }));
});
it('Create and delete', async () => {
const assignment = getAssignment02();
const classId = assignment.within.classId as string;
req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1');
req.body = {
members: ['Noordkaap', 'DireStraits'],
};
await createGroupHandler(req as Request, res as Response);
await deleteGroupHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ group: expect.anything() }));
});
it('should return the submissions for a group', async () => {
const group = getTestGroup01();
const classId = getClass01().classId as string;
req = createRequestObject(classId, (group.assignment.id ?? 1).toString(), (group.groupNumber ?? 1).toString());
await getGroupSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
it('should return a list of groups for an assignment', async () => {
const assignment = getAssignment01();
const classId = assignment.within.classId as string;
req = createRequestObject(classId, (assignment.id ?? 1).toString(), '1');
await getAllGroupsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ groups: expect.anything() }));
});
});

View file

@ -21,6 +21,7 @@ import { BadRequestException } from '../../src/exceptions/bad-request-exception.
import { ConflictException } from '../../src/exceptions/conflict-exception.js'; import { ConflictException } from '../../src/exceptions/conflict-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { StudentDTO } from '@dwengo-1/common/interfaces/student'; import { StudentDTO } from '@dwengo-1/common/interfaces/student';
import { getClass02 } from '../test_assets/classes/classes.testdata';
describe('Student controllers', () => { describe('Student controllers', () => {
let req: Partial<Request>; let req: Partial<Request>;
@ -186,7 +187,7 @@ describe('Student controllers', () => {
it('Get join request by student and class', async () => { it('Get join request by student and class', async () => {
req = { req = {
params: { username: 'PinkFloyd', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { username: 'PinkFloyd', classId: getClass02().classId },
}; };
await getStudentRequestHandler(req as Request, res as Response); await getStudentRequestHandler(req as Request, res as Response);
@ -201,7 +202,7 @@ describe('Student controllers', () => {
it('Create and delete join request', async () => { it('Create and delete join request', async () => {
req = { req = {
params: { username: 'TheDoors' }, params: { username: 'TheDoors' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { classId: getClass02().classId },
}; };
await createStudentRequestHandler(req as Request, res as Response); await createStudentRequestHandler(req as Request, res as Response);
@ -209,7 +210,7 @@ describe('Student controllers', () => {
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() })); expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ request: expect.anything() }));
req = { req = {
params: { username: 'TheDoors', classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { username: 'TheDoors', classId: getClass02().classId },
}; };
await deleteClassJoinRequestHandler(req as Request, res as Response); await deleteClassJoinRequestHandler(req as Request, res as Response);
@ -222,7 +223,7 @@ describe('Student controllers', () => {
it('Create join request student already in class error', async () => { it('Create join request student already in class error', async () => {
req = { req = {
params: { username: 'Noordkaap' }, params: { username: 'Noordkaap' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { classId: getClass02().classId },
}; };
await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException);
@ -231,7 +232,7 @@ describe('Student controllers', () => {
it('Create join request duplicate', async () => { it('Create join request duplicate', async () => {
req = { req = {
params: { username: 'Tool' }, params: { username: 'Tool' },
body: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, body: { classId: getClass02().classId },
}; };
await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException); await expect(async () => createStudentRequestHandler(req as Request, res as Response)).rejects.toThrow(ConflictException);

View file

@ -0,0 +1,61 @@
import { setupTestApp } from '../setup-tests.js';
import { describe, it, expect, beforeAll, beforeEach, vi, Mock } from 'vitest';
import { getSubmissionHandler, getAllSubmissionsHandler } from '../../src/controllers/submissions.js';
import { Request, Response } from 'express';
import { NotFoundException } from '../../src/exceptions/not-found-exception';
import { getClass02 } from '../test_assets/classes/classes.testdata';
function createRequestObject(
hruid: string,
submissionNumber: string
): {
query: { language: string; version: string };
params: { hruid: string; id: string };
} {
return {
params: {
hruid: hruid,
id: submissionNumber,
},
query: {
language: 'en',
version: '1',
},
};
}
describe('Submission controllers', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let jsonMock: Mock;
let statusMock: Mock;
beforeAll(async () => {
await setupTestApp();
});
beforeEach(async () => {
jsonMock = vi.fn();
statusMock = vi.fn().mockReturnThis();
res = {
json: jsonMock,
status: statusMock,
};
});
it('error submission is not found', async () => {
req = createRequestObject('id01', '1000000');
await expect(async () => getSubmissionHandler(req as Request, res as Response)).rejects.toThrow(NotFoundException);
});
it('should return a list of submissions for a learning object', async () => {
req = createRequestObject(getClass02().classId as string, 'irrelevant');
await getAllSubmissionsHandler(req as Request, res as Response);
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ submissions: expect.anything() }));
});
});

View file

@ -12,6 +12,7 @@ import { TeacherInvitationData } from '@dwengo-1/common/interfaces/teacher-invit
import { getClassHandler } from '../../src/controllers/classes'; import { getClassHandler } from '../../src/controllers/classes';
import { BadRequestException } from '../../src/exceptions/bad-request-exception'; import { BadRequestException } from '../../src/exceptions/bad-request-exception';
import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; import { ClassStatus } from '@dwengo-1/common/util/class-join-request';
import { getClass02 } from '../test_assets/classes/classes.testdata';
describe('Teacher controllers', () => { describe('Teacher controllers', () => {
let req: Partial<Request>; let req: Partial<Request>;
@ -57,7 +58,7 @@ describe('Teacher controllers', () => {
const body = { const body = {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'testleerkracht1', receiver: 'testleerkracht1',
class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', class: getClass02().classId,
} as TeacherInvitationData; } as TeacherInvitationData;
req = { body }; req = { body };
@ -67,7 +68,7 @@ describe('Teacher controllers', () => {
params: { params: {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'testleerkracht1', receiver: 'testleerkracht1',
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: getClass02().classId,
}, },
body: { accepted: 'false' }, body: { accepted: 'false' },
}; };
@ -80,7 +81,7 @@ describe('Teacher controllers', () => {
params: { params: {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'FooFighters', receiver: 'FooFighters',
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: getClass02().classId,
}, },
}; };
await getInvitationHandler(req as Request, res as Response); await getInvitationHandler(req as Request, res as Response);
@ -100,7 +101,7 @@ describe('Teacher controllers', () => {
const body = { const body = {
sender: 'LimpBizkit', sender: 'LimpBizkit',
receiver: 'FooFighters', receiver: 'FooFighters',
class: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', class: getClass02().classId,
} as TeacherInvitationData; } as TeacherInvitationData;
req = { body }; req = { body };
@ -111,7 +112,7 @@ describe('Teacher controllers', () => {
req = { req = {
params: { params: {
id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', id: getClass02().classId,
}, },
}; };

View file

@ -15,8 +15,8 @@ import {
import { BadRequestException } from '../../src/exceptions/bad-request-exception.js'; import { BadRequestException } from '../../src/exceptions/bad-request-exception.js';
import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js'; import { EntityAlreadyExistsException } from '../../src/exceptions/entity-already-exists-exception.js';
import { getStudentRequestsHandler } from '../../src/controllers/students.js'; import { getStudentRequestsHandler } from '../../src/controllers/students.js';
import { TeacherDTO } from '@dwengo-1/common/interfaces/teacher';
import { getClassHandler } from '../../src/controllers/classes'; import { getClassHandler } from '../../src/controllers/classes';
import { getClass02 } from '../test_assets/classes/classes.testdata';
describe('Teacher controllers', () => { describe('Teacher controllers', () => {
let req: Partial<Request>; let req: Partial<Request>;
@ -96,7 +96,7 @@ describe('Teacher controllers', () => {
}); });
it('Teacher list', async () => { it('Teacher list', async () => {
req = { query: { full: 'true' } }; req = { query: { full: 'false' } };
await getAllTeachersHandler(req as Request, res as Response); await getAllTeachersHandler(req as Request, res as Response);
@ -104,8 +104,7 @@ describe('Teacher controllers', () => {
const result = jsonMock.mock.lastCall?.[0]; const result = jsonMock.mock.lastCall?.[0];
const teacherUsernames = result.teachers.map((s: TeacherDTO) => s.username); expect(result.teachers).toContain('testleerkracht1');
expect(teacherUsernames).toContain('testleerkracht1');
expect(result.teachers).toHaveLength(5); expect(result.teachers).toHaveLength(5);
}); });
@ -169,7 +168,7 @@ describe('Teacher controllers', () => {
it('Get join requests by class', async () => { it('Get join requests by class', async () => {
req = { req = {
params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { classId: getClass02().classId },
}; };
await getStudentJoinRequestHandler(req as Request, res as Response); await getStudentJoinRequestHandler(req as Request, res as Response);
@ -183,7 +182,7 @@ describe('Teacher controllers', () => {
it('Update join request status', async () => { it('Update join request status', async () => {
req = { req = {
params: { classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', studentUsername: 'PinkFloyd' }, params: { classId: getClass02().classId, studentUsername: 'PinkFloyd' },
body: { accepted: 'true' }, body: { accepted: 'true' },
}; };
@ -201,7 +200,7 @@ describe('Teacher controllers', () => {
expect(status).toBeTruthy(); expect(status).toBeTruthy();
req = { req = {
params: { id: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89' }, params: { id: getClass02().classId },
}; };
await getClassHandler(req as Request, res as Response); await getClassHandler(req as Request, res as Response);

View file

@ -3,6 +3,7 @@ import { setupTestApp } from '../../setup-tests';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass02 } from '../../test_assets/classes/classes.testdata';
describe('AssignmentRepository', () => { describe('AssignmentRepository', () => {
let assignmentRepository: AssignmentRepository; let assignmentRepository: AssignmentRepository;
@ -15,7 +16,7 @@ describe('AssignmentRepository', () => {
}); });
it('should return the requested assignment', async () => { it('should return the requested assignment', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); const assignment = await assignmentRepository.findByClassAndId(class_!, 21001);
expect(assignment).toBeTruthy(); expect(assignment).toBeTruthy();
@ -23,7 +24,7 @@ describe('AssignmentRepository', () => {
}); });
it('should return all assignments for a class', async () => { it('should return all assignments for a class', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!);
expect(assignments).toBeTruthy(); expect(assignments).toBeTruthy();

View file

@ -4,6 +4,7 @@ import { GroupRepository } from '../../../src/data/assignments/group-repository'
import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories';
import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata';
describe('GroupRepository', () => { describe('GroupRepository', () => {
let groupRepository: GroupRepository; let groupRepository: GroupRepository;
@ -18,7 +19,8 @@ describe('GroupRepository', () => {
}); });
it('should return the requested group', async () => { it('should return the requested group', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const id = getClass01().classId;
const class_ = await classRepository.findById(id);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001);
@ -27,7 +29,7 @@ describe('GroupRepository', () => {
}); });
it('should return all groups for assignment', async () => { it('should return all groups for assignment', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const class_ = await classRepository.findById(getClass01().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const groups = await groupRepository.findAllGroupsForAssignment(assignment!); const groups = await groupRepository.findAllGroupsForAssignment(assignment!);
@ -37,7 +39,7 @@ describe('GroupRepository', () => {
}); });
it('should not find removed group', async () => { it('should not find removed group', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21001); const assignment = await assignmentRepository.findByClassAndId(class_!, 21001);
await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001); await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 21001);

View file

@ -18,6 +18,7 @@ import { Submission } from '../../../src/entities/assignments/submission.entity'
import { Class } from '../../../src/entities/classes/class.entity'; import { Class } from '../../../src/entities/classes/class.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata'; import { testLearningObject01 } from '../../test_assets/content/learning-objects.testdata';
import { getClass01 } from '../../test_assets/classes/classes.testdata';
describe('SubmissionRepository', () => { describe('SubmissionRepository', () => {
let submissionRepository: SubmissionRepository; let submissionRepository: SubmissionRepository;
@ -54,7 +55,7 @@ describe('SubmissionRepository', () => {
it('should find the most recent submission for a group', async () => { it('should find the most recent submission for a group', async () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1); const id = new LearningObjectIdentifier('id03', Language.English, 1);
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const class_ = await classRepository.findById(getClass01().classId);
const assignment = await assignmentRepository.findByClassAndId(class_!, 21000); const assignment = await assignmentRepository.findByClassAndId(class_!, 21000);
const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001); const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 21001);
const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!);
@ -67,7 +68,7 @@ describe('SubmissionRepository', () => {
let assignment: Assignment | null; let assignment: Assignment | null;
let loId: LearningObjectIdentifier; let loId: LearningObjectIdentifier;
it('should find all submissions for a certain learning object and assignment', async () => { it('should find all submissions for a certain learning object and assignment', async () => {
clazz = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); clazz = await classRepository.findById(getClass01().classId);
assignment = await assignmentRepository.findByClassAndId(clazz!, 21000); assignment = await assignmentRepository.findByClassAndId(clazz!, 21000);
loId = { loId = {
hruid: 'id02', hruid: 'id02',

View file

@ -4,6 +4,7 @@ import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join
import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories';
import { StudentRepository } from '../../../src/data/users/student-repository'; import { StudentRepository } from '../../../src/data/users/student-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass02, getClass03 } from '../../test_assets/classes/classes.testdata';
describe('ClassJoinRequestRepository', () => { describe('ClassJoinRequestRepository', () => {
let classJoinRequestRepository: ClassJoinRequestRepository; let classJoinRequestRepository: ClassJoinRequestRepository;
@ -26,7 +27,7 @@ describe('ClassJoinRequestRepository', () => {
}); });
it('should list all requests to a single class', async () => { it('should list all requests to a single class', async () => {
const class_ = await cassRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await cassRepository.findById(getClass02().classId);
const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!);
expect(requests).toBeTruthy(); expect(requests).toBeTruthy();
@ -35,7 +36,7 @@ describe('ClassJoinRequestRepository', () => {
it('should not find a removed request', async () => { it('should not find a removed request', async () => {
const student = await studentRepository.findByUsername('SmashingPumpkins'); const student = await studentRepository.findByUsername('SmashingPumpkins');
const class_ = await cassRepository.findById('80dcc3e0-1811-4091-9361-42c0eee91cfa'); const class_ = await cassRepository.findById(getClass03().classId);
await classJoinRequestRepository.deleteBy(student!, class_!); await classJoinRequestRepository.deleteBy(student!, class_!);
const request = await classJoinRequestRepository.findAllRequestsBy(student!); const request = await classJoinRequestRepository.findAllRequestsBy(student!);

View file

@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { setupTestApp } from '../../setup-tests'; import { setupTestApp } from '../../setup-tests';
import { getClassRepository } from '../../../src/data/repositories'; import { getClassRepository } from '../../../src/data/repositories';
import { getClass01, getClass04 } from '../../test_assets/classes/classes.testdata';
describe('ClassRepository', () => { describe('ClassRepository', () => {
let classRepository: ClassRepository; let classRepository: ClassRepository;
@ -18,16 +19,16 @@ describe('ClassRepository', () => {
}); });
it('should return requested class', async () => { it('should return requested class', async () => {
const classVar = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const classVar = await classRepository.findById(getClass01().classId);
expect(classVar).toBeTruthy(); expect(classVar).toBeTruthy();
expect(classVar?.displayName).toBe('class01'); expect(classVar?.displayName).toBe('class01');
}); });
it('class should be gone after deletion', async () => { it('class should be gone after deletion', async () => {
await classRepository.deleteById('33d03536-83b8-4880-9982-9bbf2f908ddf'); await classRepository.deleteById(getClass04().classId);
const classVar = await classRepository.findById('33d03536-83b8-4880-9982-9bbf2f908ddf'); const classVar = await classRepository.findById(getClass04().classId);
expect(classVar).toBeNull(); expect(classVar).toBeNull();
}); });

View file

@ -4,6 +4,7 @@ import { getClassRepository, getTeacherInvitationRepository, getTeacherRepositor
import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository';
import { TeacherRepository } from '../../../src/data/users/teacher-repository'; import { TeacherRepository } from '../../../src/data/users/teacher-repository';
import { ClassRepository } from '../../../src/data/classes/class-repository'; import { ClassRepository } from '../../../src/data/classes/class-repository';
import { getClass01, getClass02 } from '../../test_assets/classes/classes.testdata';
describe('ClassRepository', () => { describe('ClassRepository', () => {
let teacherInvitationRepository: TeacherInvitationRepository; let teacherInvitationRepository: TeacherInvitationRepository;
@ -34,7 +35,7 @@ describe('ClassRepository', () => {
}); });
it('should return all invitations for a class', async () => { it('should return all invitations for a class', async () => {
const class_ = await classRepository.findById('34d484a1-295f-4e9f-bfdc-3e7a23d86a89'); const class_ = await classRepository.findById(getClass02().classId);
const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!);
expect(invitations).toBeTruthy(); expect(invitations).toBeTruthy();
@ -42,7 +43,7 @@ describe('ClassRepository', () => {
}); });
it('should not find a removed invitation', async () => { it('should not find a removed invitation', async () => {
const class_ = await classRepository.findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const class_ = await classRepository.findById(getClass01().classId);
const sender = await teacherRepository.findByUsername('FooFighters'); const sender = await teacherRepository.findByUsername('FooFighters');
const receiver = await teacherRepository.findByUsername('LimpBizkit'); const receiver = await teacherRepository.findByUsername('LimpBizkit');
await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!);

View file

@ -14,6 +14,7 @@ import { Language } from '@dwengo-1/common/util/language';
import { Question } from '../../../src/entities/questions/question.entity'; import { Question } from '../../../src/entities/questions/question.entity';
import { Class } from '../../../src/entities/classes/class.entity'; import { Class } from '../../../src/entities/classes/class.entity';
import { Assignment } from '../../../src/entities/assignments/assignment.entity'; import { Assignment } from '../../../src/entities/assignments/assignment.entity';
import { getClass01 } from '../../test_assets/classes/classes.testdata';
describe('QuestionRepository', () => { describe('QuestionRepository', () => {
let questionRepository: QuestionRepository; let questionRepository: QuestionRepository;
@ -37,7 +38,7 @@ describe('QuestionRepository', () => {
const id = new LearningObjectIdentifier('id03', Language.English, 1); const id = new LearningObjectIdentifier('id03', Language.English, 1);
const student = await studentRepository.findByUsername('Noordkaap'); const student = await studentRepository.findByUsername('Noordkaap');
const clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); const clazz = await getClassRepository().findById(getClass01().classId);
const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); const assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000);
const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001); const group = await getGroupRepository().findByAssignmentAndGroupNumber(assignment!, 21001);
await questionRepository.createQuestion({ await questionRepository.createQuestion({
@ -56,7 +57,7 @@ describe('QuestionRepository', () => {
let assignment: Assignment | null; let assignment: Assignment | null;
let loId: LearningObjectIdentifier; let loId: LearningObjectIdentifier;
it('should find all questions for a certain learning object and assignment', async () => { it('should find all questions for a certain learning object and assignment', async () => {
clazz = await getClassRepository().findById('8764b861-90a6-42e5-9732-c0d9eb2f55f9'); clazz = await getClassRepository().findById(getClass01().classId);
assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000); assignment = await getAssignmentRepository().findByClassAndId(clazz!, 21000);
loId = { loId = {
hruid: 'id05', hruid: 'id05',

View file

@ -6,13 +6,20 @@ import { testLearningPathWithConditions } from '../content/learning-paths.testda
import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata'; import { getClassWithTestleerlingAndTestleerkracht } from '../classes/classes.testdata';
export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] { export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assignment[] {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 7);
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 7);
const today = new Date();
today.setHours(23, 59);
assignment01 = em.create(Assignment, { assignment01 = em.create(Assignment, {
id: 21000, id: 21000,
within: classes[0], within: classes[0],
title: 'dire straits', title: 'dire straits',
description: 'reading', description: 'reading',
learningPathHruid: 'id02', learningPathHruid: 'un_ai',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: today,
groups: [], groups: [],
}); });
@ -23,6 +30,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'reading', description: 'reading',
learningPathHruid: 'id01', learningPathHruid: 'id01',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: futureDate,
groups: [], groups: [],
}); });
@ -33,6 +41,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'will be deleted', description: 'will be deleted',
learningPathHruid: 'id02', learningPathHruid: 'id02',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: pastDate,
groups: [], groups: [],
}); });
@ -43,6 +52,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'with a description', description: 'with a description',
learningPathHruid: 'id01', learningPathHruid: 'id01',
learningPathLanguage: Language.English, learningPathLanguage: Language.English,
deadline: pastDate,
groups: [], groups: [],
}); });
@ -53,6 +63,7 @@ export function makeTestAssignemnts(em: EntityManager, classes: Class[]): Assign
description: 'You have to do the testing learning path with a condition.', description: 'You have to do the testing learning path with a condition.',
learningPathHruid: testLearningPathWithConditions.hruid, learningPathHruid: testLearningPathWithConditions.hruid,
learningPathLanguage: testLearningPathWithConditions.language as Language, learningPathLanguage: testLearningPathWithConditions.language as Language,
deadline: futureDate,
groups: [], groups: [],
}); });

View file

@ -10,7 +10,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass01: Teacher[] = teachers.slice(4, 5); const teacherClass01: Teacher[] = teachers.slice(4, 5);
class01 = em.create(Class, { class01 = em.create(Class, {
classId: '8764b861-90a6-42e5-9732-c0d9eb2f55f9', classId: 'X2J9QT', // 8764b861-90a6-42e5-9732-c0d9eb2f55f9
displayName: 'class01', displayName: 'class01',
teachers: teacherClass01, teachers: teacherClass01,
students: studentsClass01, students: studentsClass01,
@ -20,7 +20,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass02: Teacher[] = teachers.slice(1, 2); const teacherClass02: Teacher[] = teachers.slice(1, 2);
class02 = em.create(Class, { class02 = em.create(Class, {
classId: '34d484a1-295f-4e9f-bfdc-3e7a23d86a89', classId: '7KLPMA', // 34d484a1-295f-4e9f-bfdc-3e7a23d86a89
displayName: 'class02', displayName: 'class02',
teachers: teacherClass02, teachers: teacherClass02,
students: studentsClass02, students: studentsClass02,
@ -30,7 +30,7 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass03: Teacher[] = teachers.slice(2, 3); const teacherClass03: Teacher[] = teachers.slice(2, 3);
class03 = em.create(Class, { class03 = em.create(Class, {
classId: '80dcc3e0-1811-4091-9361-42c0eee91cfa', classId: 'R0D3UZ', // 80dcc3e0-1811-4091-9361-42c0eee91cfa
displayName: 'class03', displayName: 'class03',
teachers: teacherClass03, teachers: teacherClass03,
students: studentsClass03, students: studentsClass03,
@ -40,14 +40,14 @@ export function makeTestClasses(em: EntityManager, students: Student[], teachers
const teacherClass04: Teacher[] = teachers.slice(2, 3); const teacherClass04: Teacher[] = teachers.slice(2, 3);
class04 = em.create(Class, { class04 = em.create(Class, {
classId: '33d03536-83b8-4880-9982-9bbf2f908ddf', classId: 'Q8N5YC', // 33d03536-83b8-4880-9982-9bbf2f908ddf
displayName: 'class04', displayName: 'class04',
teachers: teacherClass04, teachers: teacherClass04,
students: studentsClass04, students: studentsClass04,
}); });
classWithTestleerlingAndTestleerkracht = em.create(Class, { classWithTestleerlingAndTestleerkracht = em.create(Class, {
classId: 'a75298b5-18aa-471d-8eeb-5d77eb989393', classId: 'ZAV71B', // Was a75298b5-18aa-471d-8eeb-5d77eb989393
displayName: 'Testklasse', displayName: 'Testklasse',
teachers: [getTestleerkracht1()], teachers: [getTestleerkracht1()],
students: [getTestleerling1()], students: [getTestleerling1()],

View file

@ -7,6 +7,7 @@ export interface AssignmentDTO {
description: string; description: string;
learningPath: string; learningPath: string;
language: string; language: string;
deadline: Date;
groups: GroupDTO[] | string[][]; groups: GroupDTO[] | string[][];
} }

View file

@ -13,8 +13,8 @@ export interface QuestionDTO {
export interface QuestionData { export interface QuestionData {
author?: string; author?: string;
content: string;
inGroup: GroupDTO; inGroup: GroupDTO;
content: string;
} }
export interface QuestionId { export interface QuestionId {

View file

@ -0,0 +1,4 @@
export enum AccountType {
Student = 'student',
Teacher = 'teacher',
}

View file

@ -26,7 +26,59 @@ const doc = {
], ],
components: { components: {
securitySchemes: { securitySchemes: {
student: { studentDev: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
teacherDev: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
studentStaging: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost/idp/realms/student/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
teacherStaging: {
type: 'oauth2',
flows: {
implicit: {
authorizationUrl: 'http://localhost/idp/realms/teacher/protocol/openid-connect/auth',
scopes: {
openid: 'openid',
profile: 'profile',
email: 'email',
},
},
},
},
studentProduction: {
type: 'oauth2', type: 'oauth2',
flows: { flows: {
implicit: { implicit: {
@ -39,7 +91,7 @@ const doc = {
}, },
}, },
}, },
teacher: { teacherProduction: {
type: 'oauth2', type: 'oauth2',
flows: { flows: {
implicit: { implicit: {

View file

@ -17,6 +17,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@dwengo-1/common": "^0.2.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
"@tanstack/vue-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0",
"@vueuse/core": "^13.1.0", "@vueuse/core": "^13.1.0",

View file

@ -0,0 +1,54 @@
.h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
font-size: 50px;
padding-left: 1%;
}
.empty-message {
text-align: center;
font-size: 18px;
}
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
.table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
.table td,
.table th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
@media screen and (max-width: 850px) {
.h1 {
text-align: center;
padding-left: 0;
}
}

View file

@ -57,6 +57,22 @@
</div> </div>
<v-row v-else> <v-row v-else>
<v-col
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/learningPath/search"
:is-absolute-path="true"
:title="t('searchAllLearningPathsTitle')"
:description="t('searchAllLearningPathsDescription')"
icon="mdi-magnify"
class="fill-height grey-bg-card"
/>
</v-col>
<v-col <v-col
v-for="card in cards" v-for="card in cards"
:key="card.key" :key="card.key"
@ -74,24 +90,13 @@
class="fill-height" class="fill-height"
/> />
</v-col> </v-col>
<v-col
cols="12"
sm="6"
md="4"
lg="4"
class="d-flex"
>
<ThemeCard
path="/learningPath/search"
:is-absolute-path="true"
:title="t('searchAllLearningPathsTitle')"
:description="t('searchAllLearningPathsDescription')"
icon="mdi-magnify"
class="fill-height"
/>
</v-col>
</v-row> </v-row>
</v-container> </v-container>
</template> </template>
<style scoped></style> <style scoped>
.grey-bg-card {
background-color: #f6faf2;
border: 2px solid #0e6942;
}
</style>

View file

@ -31,4 +31,9 @@
></v-text-field> ></v-text-field>
</template> </template>
<style scoped></style> <style scoped>
.search-field {
width: 25%;
min-width: 300px;
}
</style>

View file

@ -53,9 +53,9 @@
white-space: normal; white-space: normal;
} }
.results-grid { .results-grid {
margin: 20px; margin: 20px auto;
display: flex; display: flex;
align-items: stretch; justify-content: center;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }

View file

@ -14,6 +14,8 @@
const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable
const name: string = auth.authState.user!.profile.name!; const name: string = auth.authState.user!.profile.name!;
const username = auth.authState.user!.profile.preferred_username!;
const email = auth.authState.user!.profile.email;
const initials: string = name const initials: string = name
.split(" ") .split(" ")
.map((n) => n[0]) .map((n) => n[0])
@ -90,7 +92,11 @@
<!-- >--> <!-- >-->
<!-- {{ t("discussions") }}--> <!-- {{ t("discussions") }}-->
<!-- </v-btn>--> <!-- </v-btn>-->
<v-menu open-on-hover> </v-toolbar-items>
<v-menu
open-on-hover
open-on-click
>
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn <v-btn
v-bind="props" v-bind="props"
@ -114,7 +120,6 @@
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</v-toolbar-items>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-dialog max-width="500"> <v-dialog max-width="500">
<template v-slot:activator="{ props: activatorProps }"> <template v-slot:activator="{ props: activatorProps }">
@ -158,12 +163,48 @@
</v-card> </v-card>
</template> </template>
</v-dialog> </v-dialog>
<v-avatar <v-menu min-width="200px">
size="large" <template v-slot:activator="{ props }">
color="#0e6942" <v-btn
class="user-button" icon
>{{ initials }}</v-avatar v-bind="props"
> >
<v-avatar
color="#0e6942"
size="large"
class="user-button"
>
<span>{{ initials }}</span>
</v-avatar>
</v-btn>
</template>
<v-card>
<v-card-text>
<div class="mx-auto text-center">
<v-avatar
color="#0e6942"
size="large"
class="user-button mb-3"
>
<span>{{ initials }}</span>
</v-avatar>
<h3>{{ name }}</h3>
<p class="text-caption mt-1">{{ username }}</p>
<p class="text-caption mt-1">{{ email }}</p>
<v-divider class="my-3"></v-divider>
<v-btn
variant="text"
rounded
append-icon="mdi-logout"
@click="performLogout"
to="/login"
>{{ t("logout") }}</v-btn
>
<v-divider class="my-3"></v-divider>
</div>
</v-card-text>
</v-card>
</v-menu>
</v-app-bar> </v-app-bar>
<v-navigation-drawer <v-navigation-drawer
v-model="drawer" v-model="drawer"
@ -248,6 +289,12 @@
text-transform: none; text-transform: none;
} }
.translate-button {
z-index: 1;
position: relative;
margin-left: 10px;
}
@media (max-width: 700px) { @media (max-width: 700px) {
.menu { .menu {
display: none; display: none;

View file

@ -6,6 +6,7 @@
import type { AnswersResponse } from "@/controllers/answers"; import type { AnswersResponse } from "@/controllers/answers";
import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer"; import type { AnswerData, AnswerDTO } from "@dwengo-1/common/interfaces/answer";
import authService from "@/services/auth/auth-service"; import authService from "@/services/auth/auth-service";
import { AccountType } from "@dwengo-1/common/util/account-types";
const props = defineProps<{ const props = defineProps<{
question: QuestionDTO; question: QuestionDTO;
@ -80,7 +81,7 @@
{{ question.content }} {{ question.content }}
</div> </div>
<div <div
v-if="authService.authState.activeRole === 'teacher'" v-if="authService.authState.activeRole === AccountType.Teacher"
class="answer-input-container" class="answer-input-container"
> >
<input <input

View file

@ -1,49 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, watch } from "vue";
import { deadlineRules } from "@/utils/assignment-rules.ts"; import { deadlineRules } from "@/utils/assignment-rules.ts";
const date = ref(""); const emit = defineEmits<(e: "update:deadline", value: Date) => void>();
const time = ref("23:59");
const emit = defineEmits(["update:deadline"]);
const formattedDeadline = computed(() => { const datetime = ref("");
if (!date.value || !time.value) return "";
return `${date.value} ${time.value}`; // Watch the datetime value and emit the update
watch(datetime, (val) => {
const newDate = new Date(val);
if (!isNaN(newDate.getTime())) {
emit("update:deadline", newDate);
}
}); });
function updateDeadline(): void {
if (date.value && time.value) {
emit("update:deadline", formattedDeadline.value);
}
}
</script> </script>
<template> <template>
<div>
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="date" v-model="datetime"
label="Select Deadline Date" type="datetime-local"
type="date" label="Select Deadline"
variant="outlined" variant="outlined"
density="compact" density="compact"
:rules="deadlineRules" :rules="deadlineRules"
required required
@update:modelValue="updateDeadline" />
></v-text-field>
</v-card-text> </v-card-text>
<v-card-text>
<v-text-field
v-model="time"
label="Select Deadline Time"
type="time"
variant="outlined"
density="compact"
@update:modelValue="updateDeadline"
></v-text-field>
</v-card-text>
</div>
</template> </template>
<style scoped></style>

View file

@ -26,8 +26,8 @@ export class LearningPathController extends BaseController {
}); });
return LearningPath.fromDTO(single(dtos)); return LearningPath.fromDTO(single(dtos));
} }
async getAllByTheme(theme: string): Promise<LearningPath[]> { async getAllByThemeAndLanguage(theme: string, language: Language): Promise<LearningPath[]> {
const dtos = await this.get<LearningPathDTO[]>("/", { theme }); const dtos = await this.get<LearningPathDTO[]>("/", { theme, language });
return dtos.map((dto) => LearningPath.fromDTO(dto)); return dtos.map((dto) => LearningPath.fromDTO(dto));
} }

View file

@ -1,6 +1,5 @@
import { BaseController } from "@/controllers/base-controller.ts"; import { BaseController } from "@/controllers/base-controller.ts";
import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { QuestionsResponse } from "@/controllers/questions.ts";
import type { ClassesResponse } from "@/controllers/classes.ts"; import type { ClassesResponse } from "@/controllers/classes.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
@ -40,10 +39,6 @@ export class TeacherController extends BaseController {
return this.get<StudentsResponse>(`/${username}/students`, { full }); return this.get<StudentsResponse>(`/${username}/students`, { full });
} }
async getQuestions(username: string, full = false): Promise<QuestionsResponse> {
return this.get<QuestionsResponse>(`/${username}/questions`, { full });
}
async getStudentJoinRequests(username: string, classId: string): Promise<JoinRequestsResponse> { async getStudentJoinRequests(username: string, classId: string): Promise<JoinRequestsResponse> {
return this.get<JoinRequestsResponse>(`/${username}/joinRequests/${classId}`); return this.get<JoinRequestsResponse>(`/${username}/joinRequests/${classId}`);
} }

View file

@ -21,6 +21,7 @@
"JoinClassExplanation": "Geben Sie den Code ein, den Ihnen die Lehrkraft mitgeteilt hat, um der Klasse beizutreten.", "JoinClassExplanation": "Geben Sie den Code ein, den Ihnen die Lehrkraft mitgeteilt hat, um der Klasse beizutreten.",
"invalidFormat": "Ungültiges Format", "invalidFormat": "Ungültiges Format",
"submitCode": "senden", "submitCode": "senden",
"submit": "senden",
"members": "Mitglieder", "members": "Mitglieder",
"themes": "Themen", "themes": "Themen",
"choose-theme": "Wählen Sie ein Thema", "choose-theme": "Wählen Sie ein Thema",
@ -68,10 +69,10 @@
"pick-class": "Wählen Sie eine klasse", "pick-class": "Wählen Sie eine klasse",
"choose-students": "Studenten auswählen", "choose-students": "Studenten auswählen",
"create-group": "Gruppe erstellen", "create-group": "Gruppe erstellen",
"class": "klasse", "class": "Klasse",
"delete": "löschen", "delete": "löschen",
"view-assignment": "Auftrag anzeigen", "view-assignment": "Auftrag anzeigen",
"code": "code", "code": "Code",
"invitations": "Einladungen", "invitations": "Einladungen",
"createClass": "Klasse erstellen", "createClass": "Klasse erstellen",
"createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.", "createClassInstructions": "Geben Sie einen Namen für Ihre Klasse ein und klicken Sie auf „Erstellen“. Es erscheint ein Fenster mit einem Code, den Sie kopieren können. Geben Sie diesen Code an Ihre Schüler weiter und sie können Ihrer Klasse beitreten.",
@ -83,7 +84,7 @@
"onlyUse": "nur Buchstaben, Zahlen, Bindestriche (-) und Unterstriche (_) verwenden", "onlyUse": "nur Buchstaben, Zahlen, Bindestriche (-) und Unterstriche (_) verwenden",
"close": "schließen", "close": "schließen",
"copied": "kopiert!", "copied": "kopiert!",
"accept": "akzeptieren", "accept": "Akzeptieren",
"deny": "ablehnen", "deny": "ablehnen",
"sent": "sent", "sent": "sent",
"failed": "fehlgeschlagen", "failed": "fehlgeschlagen",
@ -110,7 +111,7 @@
"remove": "entfernen", "remove": "entfernen",
"students": "Studenten", "students": "Studenten",
"classJoinRequests": "Beitrittsanfragen", "classJoinRequests": "Beitrittsanfragen",
"reject": "ablehnen", "reject": "Ablehnen",
"areusure": "Sind Sie sicher?", "areusure": "Sind Sie sicher?",
"yes": "ja", "yes": "ja",
"teachers": "Lehrer", "teachers": "Lehrer",
@ -121,5 +122,18 @@
"invite": "einladen", "invite": "einladen",
"assignmentIndicator": "AUFGABE", "assignmentIndicator": "AUFGABE",
"searchAllLearningPathsTitle": "Alle Lernpfade durchsuchen", "searchAllLearningPathsTitle": "Alle Lernpfade durchsuchen",
"searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen." "searchAllLearningPathsDescription": "Nicht gefunden, was Sie gesucht haben? Klicken Sie hier, um unsere gesamte Lernpfad-Datenbank zu durchsuchen.",
"no-students-found": "Diese Klasse hat keine Schüler.",
"no-invitations-found": "Sie haben keine ausstehenden Einladungen.",
"no-join-requests-found": "Es gibt keine ausstehenden Beitrittsanfragen für diese Klasse.",
"no-classes-found": "Sie sind noch keinem Kurs beigetreten.",
"classCreated": "Klasse erstellt!",
"success": "Erfolg",
"submitted": "eingereicht",
"see-submission": "Einsendung anzeigen",
"view-submissions": "Einsendungen anzeigen",
"valid-username": "Bitte geben Sie einen gültigen Benutzernamen ein",
"creationFailed": "Erstellung fehlgeschlagen, bitte versuchen Sie es erneut",
"no-assignments": "Derzeit gibt es keine Zuweisungen.",
"deadline": "deadline"
} }

View file

@ -33,6 +33,7 @@
"JoinClassExplanation": "Enter the code the teacher has given you to join the class.", "JoinClassExplanation": "Enter the code the teacher has given you to join the class.",
"invalidFormat": "Invalid format.", "invalidFormat": "Invalid format.",
"submitCode": "submit", "submitCode": "submit",
"submit": "submit",
"members": "Members", "members": "Members",
"themes": "Themes", "themes": "Themes",
"choose-theme": "Select a theme", "choose-theme": "Select a theme",
@ -68,21 +69,21 @@
"pick-class": "Pick a class", "pick-class": "Pick a class",
"choose-students": "Select students", "choose-students": "Select students",
"create-group": "Create group", "create-group": "Create group",
"class": "class", "class": "Class",
"delete": "delete", "delete": "delete",
"view-assignment": "View assignment", "view-assignment": "View assignment",
"code": "code", "code": "Code",
"invitations": "invitations", "invitations": "Invitations",
"createClass": "create class", "createClass": "Create class",
"classname": "classname", "classname": "classname",
"EnterNameOfClass": "Enter a classname.", "EnterNameOfClass": "Enter a classname.",
"create": "create", "create": "create",
"sender": "sender", "sender": "Sender",
"nameIsMandatory": "classname is mandatory", "nameIsMandatory": "classname is mandatory",
"onlyUse": "only use letters, numbers, dashes (-) and underscores (_)", "onlyUse": "only use letters, numbers, dashes (-) and underscores (_)",
"close": "close", "close": "close",
"copied": "copied!", "copied": "copied!",
"accept": "accept", "accept": "Accept",
"deny": "deny", "deny": "deny",
"createClassInstructions": "Enter a name for your class and click on create. A window will appear with a code that you can copy. Give this code to your students and they will be able to join.", "createClassInstructions": "Enter a name for your class and click on create. A window will appear with a code that you can copy. Give this code to your students and they will be able to join.",
"sent": "sent", "sent": "sent",
@ -108,12 +109,12 @@
"progress": "Progress", "progress": "Progress",
"created": "created", "created": "created",
"remove": "remove", "remove": "remove",
"students": "students", "students": "Students",
"classJoinRequests": "join requests", "classJoinRequests": "Join requests",
"reject": "reject", "reject": "Reject",
"areusure": "Are you sure?", "areusure": "Are you sure?",
"yes": "yes", "yes": "yes",
"teachers": "teachers", "teachers": "Teachers",
"accepted": "accepted", "accepted": "accepted",
"rejected": "rejected", "rejected": "rejected",
"enterUsername": "enter the username of the teacher you would like to invite", "enterUsername": "enter the username of the teacher you would like to invite",
@ -121,5 +122,18 @@
"invite": "invite", "invite": "invite",
"assignmentIndicator": "ASSIGNMENT", "assignmentIndicator": "ASSIGNMENT",
"searchAllLearningPathsTitle": "Search all learning paths", "searchAllLearningPathsTitle": "Search all learning paths",
"searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths." "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.",
"no-students-found": "This class has no students.",
"no-invitations-found": "You have no pending invitations.",
"no-join-requests-found": "There are no pending join requests for this class.",
"no-classes-found": "You are not yet part of a class.",
"classCreated": "class created!",
"success": "success",
"submitted": "submitted",
"see-submission": "view submission",
"view-submissions": "view submissions",
"valid-username": "please enter a valid username",
"creationFailed": "creation failed, please try again",
"no-assignments": "There are currently no assignments.",
"deadline": "deadline"
} }

View file

@ -33,6 +33,7 @@
"JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.", "JoinClassExplanation": "Entrez le code que l'enseignant vous a donné pour rejoindre la classe.",
"invalidFormat": "Format non valide.", "invalidFormat": "Format non valide.",
"submitCode": "envoyer", "submitCode": "envoyer",
"submit": "envoyer",
"members": "Membres", "members": "Membres",
"themes": "Thèmes", "themes": "Thèmes",
"choose-theme": "Choisis un thème", "choose-theme": "Choisis un thème",
@ -68,22 +69,22 @@
"pick-class": "Choisissez une classe", "pick-class": "Choisissez une classe",
"choose-students": "Sélectionnez des élèves", "choose-students": "Sélectionnez des élèves",
"create-group": "Créer un groupe", "create-group": "Créer un groupe",
"class": "classe", "class": "Classe",
"delete": "supprimer", "delete": "supprimer",
"view-assignment": "Voir le travail", "view-assignment": "Voir le travail",
"code": "code", "code": "Code",
"invitations": "invitations", "invitations": "Invitations",
"createClass": "créer une classe", "createClass": "Créer une classe",
"createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.", "createClassInstructions": "Entrez un nom pour votre classe et cliquez sur créer. Une fenêtre apparaît avec un code que vous pouvez copier. Donnez ce code à vos élèves et ils pourront rejoindre votre classe.",
"classname": "nom de classe", "classname": "nom de classe",
"EnterNameOfClass": "saisir un nom de classe.", "EnterNameOfClass": "saisir un nom de classe.",
"create": "créer", "create": "créer",
"sender": "expéditeur", "sender": "Expéditeur",
"nameIsMandatory": "le nom de classe est obligatoire", "nameIsMandatory": "le nom de classe est obligatoire",
"onlyUse": "n'utiliser que des lettres, des chiffres, des tirets (-) et des traits de soulignement (_)", "onlyUse": "n'utiliser que des lettres, des chiffres, des tirets (-) et des traits de soulignement (_)",
"close": "fermer", "close": "fermer",
"copied": "copié!", "copied": "copié!",
"accept": "accepter", "accept": "Accepter",
"deny": "refuser", "deny": "refuser",
"sent": "envoyé", "sent": "envoyé",
"failed": "échoué", "failed": "échoué",
@ -108,12 +109,13 @@
"submission": "Soumission", "submission": "Soumission",
"progress": "Progrès", "progress": "Progrès",
"remove": "supprimer", "remove": "supprimer",
"students": "étudiants", "students": "Étudiants",
"classJoinRequests": "demandes d'adhésion",
"reject": "rejeter", "classJoinRequests": "Demandes d'adhésion",
"reject": "Rejeter",
"areusure": "Êtes-vous sûr?", "areusure": "Êtes-vous sûr?",
"yes": "oui", "yes": "oui",
"teachers": "enseignants", "teachers": "Enseignants",
"accepted": "acceptée", "accepted": "acceptée",
"rejected": "rejetée", "rejected": "rejetée",
"enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter", "enterUsername": "entrez le nom d'utilisateur de l'enseignant que vous souhaitez inviter",
@ -121,5 +123,18 @@
"invite": "inviter", "invite": "inviter",
"assignmentIndicator": "DEVOIR", "assignmentIndicator": "DEVOIR",
"searchAllLearningPathsTitle": "Rechercher tous les parcours d'apprentissage", "searchAllLearningPathsTitle": "Rechercher tous les parcours d'apprentissage",
"searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles." "searchAllLearningPathsDescription": "Vous n'avez pas trouvé ce que vous cherchiez ? Cliquez ici pour rechercher dans toute notre base de données de parcours d'apprentissage disponibles.",
"no-students-found": "Cette classe n'a pas d'élèves.",
"no-invitations-found": "Vous n'avez aucune invitation en attente.",
"no-join-requests-found": "Il n'y a aucune demande d'adhésion en attente pour cette classe.",
"no-classes-found": "Vous ne faites pas encore partie d'une classe.",
"classCreated": "Classe créée !",
"success": "succès",
"submitted": "soumis",
"see-submission": "voir la soumission",
"view-submissions": "voir les soumissions",
"valid-username": "veuillez entrer un nom d'utilisateur valide",
"creationFailed": "échec de la création, veuillez réessayer",
"no-assignments": "Il n'y a actuellement aucun travail.",
"deadline": "délai"
} }

View file

@ -33,6 +33,7 @@
"JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.", "JoinClassExplanation": "Voer de code in die je van de docent hebt gekregen om lid te worden van de klas.",
"invalidFormat": "Ongeldig formaat.", "invalidFormat": "Ongeldig formaat.",
"submitCode": "verzenden", "submitCode": "verzenden",
"submit": "verzenden",
"members": "Leden", "members": "Leden",
"themes": "Lesthema's", "themes": "Lesthema's",
"choose-theme": "Kies een thema", "choose-theme": "Kies een thema",
@ -68,22 +69,22 @@
"pick-class": "Kies een klas", "pick-class": "Kies een klas",
"choose-students": "Studenten selecteren", "choose-students": "Studenten selecteren",
"create-group": "Groep aanmaken", "create-group": "Groep aanmaken",
"class": "klas", "class": "Klas",
"delete": "verwijderen", "delete": "verwijderen",
"view-assignment": "Opdracht bekijken", "view-assignment": "Opdracht bekijken",
"code": "code", "code": "Code",
"invitations": "uitnodigingen", "invitations": "Uitnodigingen",
"createClass": "klas aanmaken", "createClass": "Klas aanmaken",
"createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.", "createClassInstructions": "Voer een naam in voor je klas en klik op create. Er verschijnt een venster met een code die je kunt kopiëren. Geef deze code aan je leerlingen en ze kunnen deelnemen aan je klas.",
"classname": "klasnaam", "classname": "klasnaam",
"EnterNameOfClass": "Geef een klasnaam op.", "EnterNameOfClass": "Geef een klasnaam op.",
"create": "aanmaken", "create": "aanmaken",
"sender": "afzender", "sender": "Afzender",
"nameIsMandatory": "klasnaam is verplicht", "nameIsMandatory": "klasnaam is verplicht",
"onlyUse": "gebruik enkel letters, cijfers, dashes (-) en underscores (_)", "onlyUse": "gebruik enkel letters, cijfers, dashes (-) en underscores (_)",
"close": "sluiten", "close": "sluiten",
"copied": "gekopieerd!", "copied": "gekopieerd!",
"accept": "accepteren", "accept": "Accepteren",
"deny": "weigeren", "deny": "weigeren",
"sent": "verzonden", "sent": "verzonden",
"failed": "mislukt", "failed": "mislukt",
@ -108,12 +109,12 @@
"submission": "Indiening", "submission": "Indiening",
"progress": "Vooruitgang", "progress": "Vooruitgang",
"remove": "verwijder", "remove": "verwijder",
"students": "studenten", "students": "Studenten",
"classJoinRequests": "deelname verzoeken", "classJoinRequests": "Deelname verzoeken",
"reject": "weiger", "reject": "Weiger",
"areusure": "Bent u zeker?", "areusure": "Bent u zeker?",
"yes": "ja", "yes": "ja",
"teachers": "leerkrachten", "teachers": "Leerkrachten",
"accepted": "geaccepteerd", "accepted": "geaccepteerd",
"rejected": "geweigerd", "rejected": "geweigerd",
"enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in", "enterUsername": "vul de gebruikersnaam van de leerkracht die je wilt uitnodigen in",
@ -121,5 +122,18 @@
"invite": "uitnodigen", "invite": "uitnodigen",
"assignmentIndicator": "OPDRACHT", "assignmentIndicator": "OPDRACHT",
"searchAllLearningPathsTitle": "Alle leerpaden doorzoeken", "searchAllLearningPathsTitle": "Alle leerpaden doorzoeken",
"searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken." "searchAllLearningPathsDescription": "Niet gevonden waar je naar op zoek was? Klik hier om onze volledige databank van beschikbare leerpaden te doorzoeken.",
"no-students-found": "Deze klas heeft geen leerlingen.",
"no-invitations-found": "U heeft geen openstaande uitnodigingen.",
"no-join-requests-found": "Er zijn geen openstaande verzoeken om lid te worden van deze klas.",
"no-classes-found": "U maakt nog geen deel uit van een klas.",
"classCreated": "Klas aangemaakt!",
"success": "succes",
"submitted": "ingediend",
"see-submission": "inzending bekijken",
"view-submissions": "inzendingen bekijken",
"valid-username": "voer een geldige gebruikersnaam in",
"creationFailed": "aanmaak mislukt, probeer het opnieuw",
"no-assignments": "Er zijn momenteel geen opdrachten.",
"deadline": "deadline"
} }

View file

@ -15,6 +15,7 @@ import { invalidateAllGroupKeys } from "./groups";
import { invalidateAllSubmissionKeys } from "./submissions"; import { invalidateAllSubmissionKeys } from "./submissions";
import type { TeachersResponse } from "@/controllers/teachers"; import type { TeachersResponse } from "@/controllers/teachers";
import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations"; import type { TeacherInvitationsResponse } from "@/controllers/teacher-invitations";
import { studentClassesQueryKey } from "@/queries/students.ts";
const classController = new ClassController(); const classController = new ClassController();
@ -171,6 +172,8 @@ export function useClassDeleteStudentMutation(): UseMutationReturnType<
await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) }); await queryClient.invalidateQueries({ queryKey: classQueryKey(data.class.id) });
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, true) });
await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) }); await queryClient.invalidateQueries({ queryKey: classStudentsKey(data.class.id, false) });
await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, false) });
await queryClient.invalidateQueries({ queryKey: studentClassesQueryKey(data.class.id, true) });
}, },
}); });
} }

View file

@ -22,12 +22,13 @@ export function useGetLearningPathQuery(
}); });
} }
export function useGetAllLearningPathsByThemeQuery( export function useGetAllLearningPathsByThemeAndLanguageQuery(
theme: MaybeRefOrGetter<string>, theme: MaybeRefOrGetter<string>,
language: MaybeRefOrGetter<Language>,
): UseQueryReturnType<LearningPath[], Error> { ): UseQueryReturnType<LearningPath[], Error> {
return useQuery({ return useQuery({
queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme], queryKey: [LEARNING_PATH_KEY, "getAllByTheme", theme, language],
queryFn: async () => learningPathController.getAllByTheme(toValue(theme)), queryFn: async () => learningPathController.getAllByThemeAndLanguage(toValue(theme), toValue(language)),
enabled: () => Boolean(toValue(theme)), enabled: () => Boolean(toValue(theme)),
}); });
} }

View file

@ -33,7 +33,7 @@ function studentsQueryKey(full: boolean): [string, boolean] {
function studentQueryKey(username: string): [string, string] { function studentQueryKey(username: string): [string, string] {
return ["student", username]; return ["student", username];
} }
function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] { export function studentClassesQueryKey(username: string, full: boolean): [string, string, boolean] {
return ["student-classes", username, full]; return ["student-classes", username, full];
} }
function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { function studentAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] {

View file

@ -10,7 +10,6 @@ import {
import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts"; import { TeacherController, type TeacherResponse, type TeachersResponse } from "@/controllers/teachers.ts";
import type { ClassesResponse } from "@/controllers/classes.ts"; import type { ClassesResponse } from "@/controllers/classes.ts";
import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts";
import type { QuestionsResponse } from "@/controllers/questions.ts";
import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher";
import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts";
@ -33,10 +32,6 @@ function teacherStudentsQueryKey(username: string, full: boolean): [string, stri
return ["teacher-students", username, full]; return ["teacher-students", username, full];
} }
function teacherQuestionsQueryKey(username: string, full: boolean): [string, string, boolean] {
return ["teacher-questions", username, full];
}
export function teacherClassJoinRequests(classId: string): [string, string] { export function teacherClassJoinRequests(classId: string): [string, string] {
return ["teacher-class-join-requests", classId]; return ["teacher-class-join-requests", classId];
} }
@ -80,17 +75,6 @@ export function useTeacherStudentsQuery(
}); });
} }
export function useTeacherQuestionsQuery(
username: MaybeRefOrGetter<string | undefined>,
full: MaybeRefOrGetter<boolean> = false,
): UseQueryReturnType<QuestionsResponse, Error> {
return useQuery({
queryKey: computed(() => teacherQuestionsQueryKey(toValue(username)!, toValue(full))),
queryFn: async () => teacherController.getQuestions(toValue(username)!, toValue(full)),
enabled: () => Boolean(toValue(username)),
});
}
export function useTeacherJoinRequestsQuery( export function useTeacherJoinRequestsQuery(
username: MaybeRefOrGetter<string | undefined>, username: MaybeRefOrGetter<string | undefined>,
classId: MaybeRefOrGetter<string | undefined>, classId: MaybeRefOrGetter<string | undefined>,

View file

@ -14,6 +14,7 @@ import UserHomePage from "@/views/homepage/UserHomePage.vue";
import SingleTheme from "@/views/SingleTheme.vue"; import SingleTheme from "@/views/SingleTheme.vue";
import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue"; import LearningObjectView from "@/views/learning-paths/learning-object/LearningObjectView.vue";
import authService from "@/services/auth/auth-service"; import authService from "@/services/auth/auth-service";
import { allowRedirect, Redirect } from "@/utils/redirect.ts";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -143,7 +144,11 @@ router.beforeEach(async (to, _from, next) => {
// Verify if user is logged in before accessing certain routes // Verify if user is logged in before accessing certain routes
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
if (!authService.isLoggedIn.value && !(await authService.loadUser())) { if (!authService.isLoggedIn.value && !(await authService.loadUser())) {
next("/login"); const path = to.fullPath;
if (allowRedirect(path)) {
localStorage.setItem(Redirect.AFTER_LOGIN_KEY, path);
}
next(Redirect.LOGIN);
} else { } else {
next(); next();
} }

View file

@ -0,0 +1,12 @@
export enum Redirect {
AFTER_LOGIN_KEY = "redirectAfterLogin",
HOME = "/user",
LOGIN = "/login",
ROOT = "/",
}
const NOT_ALLOWED_REDIRECTS = new Set<Redirect>([Redirect.HOME, Redirect.ROOT, Redirect.LOGIN]);
export function allowRedirect(path: string): boolean {
return !NOT_ALLOWED_REDIRECTS.has(path as Redirect);
}

View file

@ -3,6 +3,7 @@
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { onMounted, ref, type Ref } from "vue"; import { onMounted, ref, type Ref } from "vue";
import auth from "../services/auth/auth-service.ts"; import auth from "../services/auth/auth-service.ts";
import { Redirect } from "@/utils/redirect.ts";
const { t } = useI18n(); const { t } = useI18n();
@ -10,10 +11,20 @@
const errorMessage: Ref<string | null> = ref(null); const errorMessage: Ref<string | null> = ref(null);
async function redirectPage(): Promise<void> {
const redirectUrl = localStorage.getItem(Redirect.AFTER_LOGIN_KEY);
if (redirectUrl) {
localStorage.removeItem(Redirect.AFTER_LOGIN_KEY);
await router.replace(redirectUrl);
} else {
await router.replace(Redirect.HOME);
}
}
onMounted(async () => { onMounted(async () => {
try { try {
await auth.handleLoginCallback(); await auth.handleLoginCallback();
await router.replace("/user"); // Redirect to theme page await redirectPage();
} catch (error) { } catch (error) {
errorMessage.value = `${t("loginUnexpectedError")}: ${error}`; errorMessage.value = `${t("loginUnexpectedError")}: ${error}`;
} }

View file

@ -28,7 +28,7 @@
alt="Dwengo logo" alt="Dwengo logo"
style="align-self: center" style="align-self: center"
/> />
<h1>{{ t("homeTitle") }}</h1> <h1 class="h1">{{ t("homeTitle") }}</h1>
<p class="info"> <p class="info">
{{ t("homeIntroduction1") }} {{ t("homeIntroduction1") }}
</p> </p>
@ -84,7 +84,10 @@
</div> </div>
</div> </div>
<div class="container_right"> <div class="container_right">
<v-menu open-on-hover> <v-menu
open-on-hover
open-on-click
>
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn <v-btn
v-bind="props" v-bind="props"

View file

@ -1,17 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from "vue-router";
import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg"; import dwengoLogo from "../../../assets/img/dwengo-groen-zwart.svg";
import auth from "@/services/auth/auth-service.ts"; import auth from "@/services/auth/auth-service.ts";
import { watch } from "vue";
import { AccountType } from "@dwengo-1/common/util/account-types";
const router = useRouter();
watch(
() => auth.isLoggedIn.value,
async (newVal) => {
if (newVal) {
await router.push("/user");
}
},
{ immediate: true },
);
async function loginAsStudent(): Promise<void> { async function loginAsStudent(): Promise<void> {
await auth.loginAs("student"); await auth.loginAs(AccountType.Student);
} }
async function loginAsTeacher(): Promise<void> { async function loginAsTeacher(): Promise<void> {
await auth.loginAs("teacher"); await auth.loginAs(AccountType.Teacher);
}
async function performLogout(): Promise<void> {
await auth.logout();
} }
</script> </script>
@ -65,13 +76,6 @@
</div> </div>
</ul> </ul>
</div> </div>
<div v-if="auth.isLoggedIn.value">
<p>
You are currently logged in as {{ auth.authState.user!.profile.name }} ({{ auth.authState.activeRole }})
</p>
<v-btn @click="performLogout">Logout</v-btn>
<v-btn to="/user">home</v-btn>
</div>
</main> </main>
</template> </template>

View file

@ -2,10 +2,11 @@
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import LearningPathsGrid from "@/components/LearningPathsGrid.vue"; import LearningPathsGrid from "@/components/LearningPathsGrid.vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue"; import UsingQueryResult from "@/components/UsingQueryResult.vue";
import { useGetAllLearningPathsByThemeQuery } from "@/queries/learning-paths.ts"; import { useGetAllLearningPathsByThemeAndLanguageQuery } from "@/queries/learning-paths.ts";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useThemeQuery } from "@/queries/themes.ts"; import { useThemeQuery } from "@/queries/themes.ts";
import type { Language } from "@/data-objects/language";
const props = defineProps<{ theme: string }>(); const props = defineProps<{ theme: string }>();
@ -16,7 +17,10 @@
const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme)); const currentThemeInfo = computed(() => themeQueryResult.data.value?.find((it) => it.key === props.theme));
const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeQuery(() => props.theme); const learningPathsForThemeQueryResult = useGetAllLearningPathsByThemeAndLanguageQuery(
() => props.theme,
() => locale.value as Language,
);
const { t } = useI18n(); const { t } = useI18n();
const searchFilter = ref(""); const searchFilter = ref("");
@ -31,13 +35,14 @@
</script> </script>
<template> <template>
<div class="container"> <div class="container d-flex flex-column align-items-center justify-center">
<using-query-result :query-result="themeQueryResult"> <using-query-result :query-result="themeQueryResult">
<h1>{{ currentThemeInfo!!.title }}</h1> <h1>{{ currentThemeInfo!!.title }}</h1>
<p>{{ currentThemeInfo!!.description }}</p> <p>{{ currentThemeInfo!!.description }}</p>
<div class="search-field-container"> <br />
<div class="search-field-container mt-sm-6">
<v-text-field <v-text-field
class="search-field" class="search-field mx-auto"
:label="t('search')" :label="t('search')"
append-inner-icon="mdi-magnify" append-inner-icon="mdi-magnify"
v-model="searchFilter" v-model="searchFilter"
@ -56,13 +61,15 @@
<style scoped> <style scoped>
.search-field-container { .search-field-container {
display: block; justify-content: center !important;
margin: 20px;
} }
.search-field { .search-field {
max-width: 300px; width: 25%;
min-width: 300px;
} }
.container { .container {
padding: 20px; padding: 20px;
justify-content: center;
justify-items: center;
} }
</style> </style>

View file

@ -14,6 +14,7 @@
import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment";
import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; import { useCreateAssignmentMutation } from "@/queries/assignments.ts";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { AccountType } from "@dwengo-1/common/util/account-types";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -23,7 +24,7 @@
onMounted(async () => { onMounted(async () => {
// Redirect student // Redirect student
if (role.value === "student") { if (role.value === AccountType.Student) {
await router.push("/user"); await router.push("/user");
} }
@ -48,7 +49,7 @@
// Disable combobox when learningPath prop is passed // Disable combobox when learningPath prop is passed
const lpIsSelected = route.query.hruid !== undefined; const lpIsSelected = route.query.hruid !== undefined;
const deadline = ref(null); const deadline = ref(new Date());
const description = ref(""); const description = ref("");
const groups = ref<string[][]>([]); const groups = ref<string[][]>([]);
@ -86,6 +87,7 @@
title: assignmentTitle.value, title: assignmentTitle.value,
description: description.value, description: description.value,
learningPath: lp || "", learningPath: lp || "",
deadline: deadline.value,
language: language.value, language: language.value,
groups: groups.value, groups: groups.value,
}; };
@ -96,7 +98,7 @@
<template> <template>
<div class="main-container"> <div class="main-container">
<h1 class="title">{{ t("new-assignment") }}</h1> <h1 class="h1">{{ t("new-assignment") }}</h1>
<v-card class="form-card"> <v-card class="form-card">
<v-form <v-form
ref="form" ref="form"

View file

@ -8,9 +8,10 @@
import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; import { useGetLearningPathQuery } from "@/queries/learning-paths.ts";
import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts";
import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; import type { GroupDTO } from "@dwengo-1/common/interfaces/group";
import { AccountType } from "@dwengo-1/common/util/account-types";
const role = auth.authState.activeRole; const role = auth.authState.activeRole;
const isTeacher = computed(() => role === "teacher"); const isTeacher = computed(() => role === AccountType.Teacher);
const route = useRoute(); const route = useRoute();
const classId = ref<string>(route.params.classId as string); const classId = ref<string>(route.params.classId as string);

View file

@ -22,8 +22,7 @@
) => { groupProgressMap: Map<number, number> }; ) => { groupProgressMap: Map<number, number> };
}>(); }>();
const { t, locale } = useI18n(); const { t } = useI18n();
const language = ref<Language>(locale.value as Language);
const learningPath = ref(); const learningPath = ref();
// Get the user's username/id // Get the user's username/id
const username = asyncComputed(async () => { const username = asyncComputed(async () => {
@ -38,7 +37,7 @@
const lpQueryResult = useGetLearningPathQuery( const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
computed(() => language.value), computed(() => assignmentQueryResult.data.value?.assignment.language as Language),
); );
const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true);
@ -100,7 +99,7 @@ language
> >
<v-btn <v-btn
v-if="lpData" v-if="lpData"
:to="`/learningPath/${lpData.hruid}/${language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`" :to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`"
variant="tonal" variant="tonal"
color="primary" color="primary"
> >

View file

@ -19,8 +19,7 @@
) => { groupProgressMap: Map<number, number> }; ) => { groupProgressMap: Map<number, number> };
}>(); }>();
const { t, locale } = useI18n(); const { t } = useI18n();
const language = computed(() => locale.value);
const groups = ref(); const groups = ref();
const learningPath = ref(); const learningPath = ref();
@ -29,7 +28,7 @@
// Get learning path object // Get learning path object
const lpQueryResult = useGetLearningPathQuery( const lpQueryResult = useGetLearningPathQuery(
computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""),
computed(() => language.value as Language), computed(() => assignmentQueryResult.data.value?.assignment.language as Language),
); );
// Get all the groups withing the assignment // Get all the groups withing the assignment
@ -121,7 +120,7 @@ Const {groupProgressMap} = props.useGroupsWithProgress(
> >
<v-btn <v-btn
v-if="lpData" v-if="lpData"
:to="`/learningPath/${lpData.hruid}/${language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`" :to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`"
variant="tonal" variant="tonal"
color="primary" color="primary"
> >
@ -203,8 +202,8 @@ Const {groupProgressMap} = props.useGroupsWithProgress(
<v-btn <v-btn
color="primary" color="primary"
@click="dialog = false" @click="dialog = false"
>Close</v-btn >Close
> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>

View file

@ -9,14 +9,16 @@
import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { asyncComputed } from "@vueuse/core"; import { asyncComputed } from "@vueuse/core";
import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; import { useDeleteAssignmentMutation } from "@/queries/assignments.ts";
import { AccountType } from "@dwengo-1/common/util/account-types";
import "../../assets/common.css";
const { t } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const role = ref(auth.authState.activeRole); const role = ref(auth.authState.activeRole);
const username = ref<string>(""); const username = ref<string>("");
const isTeacher = computed(() => role.value === "teacher"); const isTeacher = computed(() => role.value === AccountType.Teacher);
// Fetch and store all the teacher's classes // Fetch and store all the teacher's classes
let classesQueryResults = undefined; let classesQueryResults = undefined;
@ -27,13 +29,13 @@
classesQueryResults = useStudentClassesQuery(username, true); classesQueryResults = useStudentClassesQuery(username, true);
} }
//TODO: remove later
const classController = new ClassController(); const classController = new ClassController();
//TODO: replace by query that fetches all user's assignment const assignments = asyncComputed(
const assignments = asyncComputed(async () => { async () => {
const classes = classesQueryResults?.data?.value?.classes; const classes = classesQueryResults?.data?.value?.classes;
if (!classes) return []; if (!classes) return [];
const result = await Promise.all( const result = await Promise.all(
(classes as ClassDTO[]).map(async (cls) => { (classes as ClassDTO[]).map(async (cls) => {
const { assignments } = await classController.getAssignments(cls.id); const { assignments } = await classController.getAssignments(cls.id);
@ -44,13 +46,30 @@
description: a.description, description: a.description,
learningPath: a.learningPath, learningPath: a.learningPath,
language: a.language, language: a.language,
deadline: a.deadline,
groups: a.groups, groups: a.groups,
})); }));
}), }),
); );
return result.flat(); // Order the assignments by deadline
}, []); return result.flat().sort((a, b) => {
const now = Date.now();
const aTime = new Date(a.deadline).getTime();
const bTime = new Date(b.deadline).getTime();
const aIsPast = aTime < now;
const bIsPast = bTime < now;
if (aIsPast && !bIsPast) return 1;
if (!aIsPast && bIsPast) return -1;
return aTime - bTime;
});
},
[],
{ evaluating: true },
);
async function goToCreateAssignment(): Promise<void> { async function goToCreateAssignment(): Promise<void> {
await router.push("/assignment/create"); await router.push("/assignment/create");
@ -72,6 +91,35 @@
mutate({ cid: clsId, an: num }); mutate({ cid: clsId, an: num });
} }
function formatDate(date?: string | Date): string {
if (!date) return "";
const d = new Date(date);
// Choose locale based on selected language
const currentLocale = locale.value;
return d.toLocaleDateString(currentLocale, {
weekday: "short",
day: "2-digit",
month: "long",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function getDeadlineClass(deadline?: string | Date): string {
if (!deadline) return "";
const date = new Date(deadline);
const now = new Date();
const in24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000);
if (date.getTime() < now.getTime()) return "deadline-passed";
if (date.getTime() <= in24Hours.getTime()) return "deadline-in24hours";
return "deadline-upcoming";
}
onMounted(async () => { onMounted(async () => {
const user = await auth.loadUser(); const user = await auth.loadUser();
username.value = user?.profile?.preferred_username ?? ""; username.value = user?.profile?.preferred_username ?? "";
@ -80,7 +128,7 @@
<template> <template>
<div class="assignments-container"> <div class="assignments-container">
<h1>{{ t("assignments") }}</h1> <h1 class="h1">{{ t("assignments") }}</h1>
<v-btn <v-btn
v-if="isTeacher" v-if="isTeacher"
@ -107,6 +155,13 @@
{{ assignment.class.displayName }} {{ assignment.class.displayName }}
</span> </span>
</div> </div>
<div
class="assignment-deadline"
:class="getDeadlineClass(assignment.deadline)"
>
{{ t("deadline") }}:
<span>{{ formatDate(assignment.deadline) }}</span>
</div>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
@ -131,6 +186,13 @@
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="assignments.length === 0">
<v-col cols="12">
<div class="no-assignments">
{{ t("no-assignments") }}
</div>
</v-col>
</v-row>
</v-container> </v-container>
</div> </div>
</template> </template>
@ -139,18 +201,32 @@
.assignments-container { .assignments-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 2% 4%;
box-sizing: border-box; box-sizing: border-box;
} }
.center-btn { .center-btn {
display: block; display: block;
margin-left: auto; margin: 0 auto 2rem auto;
margin-right: auto; font-weight: 600;
background-color: #10ad61;
color: white;
transition: background-color 0.2s;
}
.center-btn:hover {
background-color: #0e6942;
} }
.assignment-card { .assignment-card {
padding: 1rem; padding: 1.25rem;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background-color: white;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.assignment-card:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
} }
.top-content { .top-content {
@ -158,6 +234,35 @@
word-break: break-word; word-break: break-word;
} }
.assignment-title {
font-weight: 700;
font-size: 1.4rem;
color: #0e6942;
margin-bottom: 0.3rem;
}
.assignment-class,
.assignment-deadline {
font-size: 0.95rem;
color: #444;
margin-bottom: 0.2rem;
}
.class-name {
font-weight: 600;
color: #097180;
}
.assignment-deadline.deadline-passed {
color: #d32f2f;
font-weight: bold;
}
.assignment-deadline.deadline-in24hours {
color: #f57c00;
font-weight: bold;
}
.spacer { .spacer {
flex: 1; flex: 1;
} }
@ -165,24 +270,14 @@
.button-row { .button-row {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.5rem; gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.assignment-title { .no-assignments {
font-weight: bold; text-align: center;
font-size: 1.5rem; font-size: 1.2rem;
margin-bottom: 0.1rem; color: #777;
word-break: break-word; padding: 3rem 0;
}
.assignment-class {
color: #666;
font-size: 0.95rem;
}
.class-name {
font-weight: 500;
color: #333;
} }
</style> </style>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
import { useClassQuery } from "@/queries/classes";
import { defineProps } from "vue";
import UsingQueryResult from "@/components/UsingQueryResult.vue";
const props = defineProps({
classId: String,
});
const classQuery = useClassQuery(props.classId);
</script>
<template>
<using-query-result
:query-result="classQuery"
v-slot="{ data: classResponse }"
>
<span>{{ classResponse?.class.displayName }}</span>
</using-query-result>
</template>

View file

@ -13,6 +13,7 @@
import { useCreateTeacherInvitationMutation } from "@/queries/teacher-invitations"; import { useCreateTeacherInvitationMutation } from "@/queries/teacher-invitations";
import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation"; import type { TeacherInvitationData } from "@dwengo-1/common/interfaces/teacher-invitation";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import "../../assets/common.css";
const { t } = useI18n(); const { t } = useI18n();
@ -76,7 +77,7 @@
}, },
onError: (e) => { onError: (e) => {
dialog.value = false; dialog.value = false;
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}, },
); );
@ -104,7 +105,7 @@
} }
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}, },
); );
@ -112,7 +113,7 @@
function sentInvite(): void { function sentInvite(): void {
if (!usernameTeacher.value) { if (!usernameTeacher.value) {
showSnackbar(t("please enter a valid username"), "error"); showSnackbar(t("valid-username"), "error");
return; return;
} }
const data: TeacherInvitationData = { const data: TeacherInvitationData = {
@ -125,7 +126,7 @@
usernameTeacher.value = ""; usernameTeacher.value = "";
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}); });
} }
@ -186,7 +187,7 @@
v-slot="classResponse: { data: ClassResponse }" v-slot="classResponse: { data: ClassResponse }"
> >
<div> <div>
<h1 class="title">{{ classResponse.data.class.displayName }}</h1> <h1 class="h1">{{ classResponse.data.class.displayName }}</h1>
<using-query-result <using-query-result
:query-result="getStudents" :query-result="getStudents"
v-slot="studentsResponse: { data: StudentsResponse }" v-slot="studentsResponse: { data: StudentsResponse }"
@ -211,19 +212,34 @@
<th class="header"></th> <th class="header"></th>
</tr> </tr>
</thead> </thead>
<tbody>
<tbody v-if="studentsResponse.data.students.length">
<tr <tr
v-for="s in studentsResponse.data.students as StudentDTO[]" v-for="s in studentsResponse.data.students as StudentDTO[]"
:key="s.id" :key="s.id"
> >
<td> <td>{{ s.firstName + " " + s.lastName }}</td>
{{ s.firstName + " " + s.lastName }}
</td>
<td> <td>
<v-btn @click="showPopup(s)">{{ t("remove") }}</v-btn> <v-btn @click="showPopup(s)">{{ t("remove") }}</v-btn>
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tbody v-else>
<tr>
<td
colspan="2"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-students-found") }}
</td>
</tr>
</tbody>
</v-table> </v-table>
</v-col> </v-col>
<using-query-result <using-query-result
@ -242,7 +258,7 @@
<th class="header">{{ t("accept") + "/" + t("reject") }}</th> <th class="header">{{ t("accept") + "/" + t("reject") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody v-if="joinRequests.data.joinRequests.length">
<tr <tr
v-for="jr in joinRequests.data.joinRequests as ClassJoinRequestDTO[]" v-for="jr in joinRequests.data.joinRequests as ClassJoinRequestDTO[]"
:key="(jr.class, jr.requester, jr.status)" :key="(jr.class, jr.requester, jr.status)"
@ -287,6 +303,21 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tbody v-else>
<tr>
<td
colspan="2"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-join-requests-found") }}
</td>
</tr>
</tbody>
</v-table> </v-table>
</v-col> </v-col>
</using-query-result> </using-query-result>
@ -356,49 +387,6 @@
</main> </main>
</template> </template>
<style scoped> <style scoped>
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
td,
th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 50px;
}
h2 { h2 {
color: #0e6942; color: #0e6942;
font-size: 30px; font-size: 30px;
@ -407,6 +395,7 @@
.join { .join {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-left: 1%;
gap: 20px; gap: 20px;
margin-top: 50px; margin-top: 50px;
} }
@ -416,16 +405,7 @@
text-decoration: underline; text-decoration: underline;
} }
main {
margin-left: 30px;
}
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
h1 {
text-align: center;
padding-left: 0;
}
.join { .join {
text-align: center; text-align: center;
align-items: center; align-items: center;

View file

@ -2,7 +2,7 @@
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import authState from "@/services/auth/auth-service.ts"; import authState from "@/services/auth/auth-service.ts";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { validate, version } from "uuid"; import { useRoute } from "vue-router";
import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; import type { ClassDTO } from "@dwengo-1/common/interfaces/class";
import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students"; import { useCreateJoinRequestMutation, useStudentClassesQuery } from "@/queries/students";
import type { StudentDTO } from "@dwengo-1/common/interfaces/student"; import type { StudentDTO } from "@dwengo-1/common/interfaces/student";
@ -12,8 +12,10 @@
import { useClassStudentsQuery, useClassTeachersQuery } from "@/queries/classes"; import { useClassStudentsQuery, useClassTeachersQuery } from "@/queries/classes";
import type { StudentsResponse } from "@/controllers/students"; import type { StudentsResponse } from "@/controllers/students";
import type { TeachersResponse } from "@/controllers/teachers"; import type { TeachersResponse } from "@/controllers/teachers";
import "../../assets/common.css";
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
// Username of logged in student // Username of logged in student
const username = ref<string | undefined>(undefined); const username = ref<string | undefined>(undefined);
@ -37,6 +39,11 @@
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
const queryCode = route.query.code as string | undefined;
if (queryCode) {
code.value = queryCode;
}
}); });
// Fetch all classes of the logged in student // Fetch all classes of the logged in student
@ -74,11 +81,15 @@
// The code a student sends in to join a class needs to be formatted as v4 to be valid // The code a student sends in to join a class needs to be formatted as v4 to be valid
// These rules are used to display a message to the user if they use a code that has an invalid format // These rules are used to display a message to the user if they use a code that has an invalid format
function codeRegex(value: string): boolean {
return /^[a-zA-Z0-9]{6}$/.test(value);
}
const codeRules = [ const codeRules = [
(value: string | undefined): string | boolean => { (value: string | undefined): string | boolean => {
if (value === undefined || value === "") { if (value === undefined || value === "") {
return true; return true;
} else if (value !== undefined && validate(value) && version(value) === 4) { } else if (codeRegex(value)) {
return true; return true;
} }
return t("invalidFormat"); return t("invalidFormat");
@ -91,7 +102,7 @@
// Function called when a student submits a code to join a class // Function called when a student submits a code to join a class
function submitCode(): void { function submitCode(): void {
// Check if the code is valid // Check if the code is valid
if (code.value !== undefined && validate(code.value) && version(code.value) === 4) { if (code.value !== undefined && codeRegex(code.value)) {
mutate( mutate(
{ username: username.value!, classId: code.value }, { username: username.value!, classId: code.value },
{ {
@ -99,7 +110,7 @@
showSnackbar(t("sent"), "success"); showSnackbar(t("sent"), "success");
}, },
onError: (e) => { onError: (e) => {
showSnackbar(t("failed") + ": " + e.message, "error"); showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error");
}, },
}, },
); );
@ -135,7 +146,7 @@
></v-empty-state> ></v-empty-state>
</div> </div>
<div v-else> <div v-else>
<h1 class="title">{{ t("classes") }}</h1> <h1 class="h1">{{ t("classes") }}</h1>
<using-query-result <using-query-result
:query-result="classesQuery" :query-result="classesQuery"
v-slot="classResponse: { data: ClassesResponse }" v-slot="classResponse: { data: ClassesResponse }"
@ -161,7 +172,7 @@
<th class="header">{{ t("members") }}</th> <th class="header">{{ t("members") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody v-if="classResponse.data.classes.length">
<tr <tr
v-for="c in classResponse.data.classes as ClassDTO[]" v-for="c in classResponse.data.classes as ClassDTO[]"
:key="c.id" :key="c.id"
@ -181,6 +192,21 @@
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tbody v-else>
<tr>
<td
colspan="3"
class="empty-message"
>
<v-icon
icon="mdi-information-outline"
size="small"
>
</v-icon>
{{ t("no-classes-found") }}
</td>
</tr>
</tbody>
</v-table> </v-table>
</v-col> </v-col>
</v-row> </v-row>
@ -244,7 +270,7 @@
<v-text-field <v-text-field
label="CODE" label="CODE"
v-model="code" v-model="code"
placeholder="XXXXXXXX-XXXX-4XXX-XXXX-XXXXXXXXXXXX" placeholder="XXXXXX"
:rules="codeRules" :rules="codeRules"
variant="outlined" variant="outlined"
></v-text-field> ></v-text-field>
@ -271,49 +297,6 @@
</main> </main>
</template> </template>
<style scoped> <style scoped>
.header {
font-weight: bold !important;
background-color: #0e6942;
color: white;
padding: 10px;
}
table thead th:first-child {
border-top-left-radius: 10px;
}
.table thead th:last-child {
border-top-right-radius: 10px;
}
.table tbody tr:nth-child(odd) {
background-color: white;
}
.table tbody tr:nth-child(even) {
background-color: #f6faf2;
}
td,
th {
border-bottom: 1px solid #0e6942;
border-top: 1px solid #0e6942;
}
.table {
width: 90%;
padding-top: 10px;
border-collapse: collapse;
}
h1 {
color: #0e6942;
text-transform: uppercase;
font-weight: bolder;
padding-top: 2%;
font-size: 50px;
}
h2 { h2 {
color: #0e6942; color: #0e6942;
font-size: 30px; font-size: 30px;
@ -321,6 +304,7 @@
.join { .join {
display: flex; display: flex;
margin-left: 1%;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
margin-top: 50px; margin-top: 50px;
@ -331,16 +315,7 @@
text-decoration: underline; text-decoration: underline;
} }
main {
margin-left: 30px;
}
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
h1 {
text-align: center;
padding-left: 0;
}
.join { .join {
text-align: center; text-align: center;
align-items: center; align-items: center;

Some files were not shown because too many files have changed in this diff Show more