Skip to content

Commit

Permalink
N21-2075: sync existing course (#5165)
Browse files Browse the repository at this point in the history
* add sync existing course
* N21-2075 modify course metadata
* update course entity mapping
* update courses /all endpoint
* update get course infos
* update course DO
* N21-2075 permission check
* update course synchronization
* add migration
* update course find all
* update seed data roles
* update pagination handling for courses
* update course do service
* update classes repo spec
* N21-2075 Calendar flag for Nuxt
* update courses info response
* migration: Migration20240823151836
* add course info controller
* update sourse info + course repo
* update classe service tests
---------

Co-authored-by: Mrika Llabani <[email protected]>
Co-authored-by: mrikallab <[email protected]>
  • Loading branch information
3 people authored Sep 3, 2024
1 parent 68a1747 commit 21a5f8e
Show file tree
Hide file tree
Showing 58 changed files with 1,843 additions and 77 deletions.
37 changes: 37 additions & 0 deletions apps/server/src/migrations/mikro-orm/Migration20240823151836.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Migration } from '@mikro-orm/migrations-mongodb';

export class Migration20240823151836 extends Migration {
async up(): Promise<void> {
const adminRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'administrator' },
{
$addToSet: {
permissions: {
$each: ['COURSE_ADMINISTRATION'],
},
},
}
);

if (adminRoleUpdate.modifiedCount > 0) {
console.info('Permission COURSE_ADMINISTRATION added to role administrator.');
}
}

async down(): Promise<void> {
const adminRoleUpdate = await this.getCollection('roles').updateOne(
{ name: 'administrator' },
{
$pull: {
permissions: {
$in: ['COURSE_ADMINISTRATION'],
},
},
}
);

if (adminRoleUpdate.modifiedCount > 0) {
console.info('Rollback: Permission COURSE_ADMINISTRATION added to role administrator.');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Action } from '../type';
import { CourseRule } from './course.rule';

describe('CourseRule', () => {
let module: TestingModule;
let service: CourseRule;
let authorizationHelper: AuthorizationHelper;
let user: User;
Expand All @@ -19,7 +20,7 @@ describe('CourseRule', () => {
beforeAll(async () => {
await setupEntities();

const module: TestingModule = await Test.createTestingModule({
module = await Test.createTestingModule({
providers: [AuthorizationHelper, CourseRule],
}).compile();

Expand All @@ -32,12 +33,20 @@ describe('CourseRule', () => {
user = userFactory.build({ roles: [role] });
});

afterEach(() => {
jest.clearAllMocks();
});

afterAll(async () => {
await module.close();
});

describe('when validating an entity', () => {
it('should call hasAllPermissions on AuthorizationHelper', () => {
entity = courseEntityFactory.build({ teachers: [user] });
const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions');
service.hasPermission(user, entity, { action: Action.read, requiredPermissions: [] });
expect(spy).toBeCalledWith(user, []);
expect(spy).toHaveBeenCalledWith(user, []);
});

it('should call hasAccessToEntity on AuthorizationHelper if action = "read"', () => {
Expand Down Expand Up @@ -73,6 +82,35 @@ describe('CourseRule', () => {
});
});

describe('when validating an entity and the user has COURSE_ADMINISTRATION permission', () => {
const setup = () => {
const permissionD = Permission.COURSE_ADMINISTRATION;
const adminRole = roleFactory.build({ permissions: [permissionD] });
const adminUser = userFactory.build({ roles: [adminRole] });

return {
adminUser,
permissionD,
};
};

it('should call hasAllPermissions with admin permissions on AuthorizationHelper', () => {
const { permissionD, adminUser } = setup();
entity = courseEntityFactory.build();
const spy = jest.spyOn(authorizationHelper, 'hasAllPermissions');
service.hasPermission(adminUser, entity, { action: Action.read, requiredPermissions: [] });
expect(spy).toHaveBeenNthCalledWith(2, adminUser, [permissionD]);
});

it('should not call hasAccessToEntity on AuthorizationHelper', () => {
const { adminUser } = setup();
entity = courseEntityFactory.build();
const spy = jest.spyOn(authorizationHelper, 'hasAccessToEntity');
service.hasPermission(adminUser, entity, { action: Action.read, requiredPermissions: [] });
expect(spy).toHaveBeenCalledTimes(0);
});
});

describe('when validating a domain object', () => {
describe('when the user is authorized', () => {
const setup = () => {
Expand Down
32 changes: 23 additions & 9 deletions apps/server/src/modules/authorization/domain/rules/course.rule.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Course } from '@modules/learnroom/domain';
import { Injectable } from '@nestjs/common';
import { Course as CourseEntity, User } from '@shared/domain/entity';
import { Permission } from '@shared/domain/interface';
import { AuthorizationHelper } from '../service/authorization.helper';
import { Action, AuthorizationContext, Rule } from '../type';

Expand All @@ -16,14 +17,27 @@ export class CourseRule implements Rule<CourseEntity | Course> {

public hasPermission(user: User, object: CourseEntity | Course, context: AuthorizationContext): boolean {
const { action, requiredPermissions } = context;
const hasPermission =
this.authorizationHelper.hasAllPermissions(user, requiredPermissions) &&
this.authorizationHelper.hasAccessToEntity(
user,
object,
action === Action.read ? ['teachers', 'substitutionTeachers', 'students'] : ['teachers', 'substitutionTeachers']
);

return hasPermission;

const hasRequiredPermission = this.authorizationHelper.hasAllPermissions(user, requiredPermissions);
const hasAdminPermission = this.authorizationHelper.hasAllPermissions(user, [Permission.COURSE_ADMINISTRATION]);

const hasAccessToEntity = hasAdminPermission
? true
: this.authorizationHelper.hasAccessToEntity(
user,
object,
this.isReadAction(action)
? ['teachers', 'substitutionTeachers', 'students']
: ['teachers', 'substitutionTeachers']
);

return hasAccessToEntity && hasRequiredPermission;
}

isReadAction(action: Action) {
if (action === Action.read) {
return true;
}
return false;
}
}
8 changes: 8 additions & 0 deletions apps/server/src/modules/class/domain/class.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,12 @@ export class Class extends DomainObject<ClassProps> {
public removeUser(userId: string) {
this.props.userIds = this.props.userIds?.filter((userId1) => userId1 !== userId);
}

public getClassFullName(): string {
const classFullName = this.props.gradeLevel
? this.props.gradeLevel.toString().concat(this.props.name)
: this.props.name;

return classFullName;
}
}
24 changes: 24 additions & 0 deletions apps/server/src/modules/class/domain/testing/class.do.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,28 @@ describe(Class.name, () => {
});
});
});

describe('getClassFullName', () => {
describe('When function is called', () => {
it('should return full class name consisting of grade level and class name', () => {
const gradeLevel = 1;
const name = 'A';
const domainObject = classFactory.build({ name, gradeLevel });

const result = domainObject.getClassFullName();

expect(result).toEqual('1A');
});

it('should return full class name consisting of class name only', () => {
const gradeLevel = undefined;
const name = 'A';
const domainObject = classFactory.build({ name, gradeLevel });

const result = domainObject.getClassFullName();

expect(result).toEqual('A');
});
});
});
});
30 changes: 30 additions & 0 deletions apps/server/src/modules/class/repo/classes.repo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,34 @@ describe(ClassesRepo.name, () => {
});
});
});

describe('findClassById', () => {
describe('when class is not found in classes', () => {
it('should return null', async () => {
const result = await repo.findClassById(new ObjectId().toHexString());

expect(result).toEqual(null);
});
});

describe('when class is in classes', () => {
const setup = async () => {
const class1: ClassEntity = classEntityFactory.buildWithId();
await em.persistAndFlush([class1]);
em.clear();

return {
class1,
};
};

it('should find class with particular classId', async () => {
const { class1 } = await setup();

const result = await repo.findClassById(class1.id);

expect(result?.id).toEqual(class1.id);
});
});
});
});
12 changes: 12 additions & 0 deletions apps/server/src/modules/class/repo/classes.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,16 @@ export class ClassesRepo {

await this.em.persistAndFlush(existingEntities);
}

public async findClassById(id: EntityId): Promise<Class | null> {
const clazz = await this.em.findOne(ClassEntity, { id });

if (!clazz) {
return null;
}

const domainObject: Class = ClassMapper.mapToDO(clazz);

return domainObject;
}
}
2 changes: 1 addition & 1 deletion apps/server/src/modules/class/repo/mapper/class.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ClassSourceOptions } from '../../domain/class-source-options.do';
import { ClassEntity } from '../../entity';

export class ClassMapper {
private static mapToDO(entity: ClassEntity): Class {
static mapToDO(entity: ClassEntity): Class {
return new Class({
id: entity.id,
name: entity.name,
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/modules/class/service/class.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { deletionRequestFactory } from '@modules/deletion/domain/testing';
import { InternalServerErrorException } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs';
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundLoggableException } from '@shared/common/loggable-exception';
import { EntityId } from '@shared/domain/types';
import { setupEntities } from '@shared/testing';
import { Logger } from '@src/core/logger';
Expand Down Expand Up @@ -126,6 +127,34 @@ describe(ClassService.name, () => {
});
});

describe('findById', () => {
describe('when the user has classes', () => {
const setup = () => {
const clazz: Class = classFactory.build();

classesRepo.findClassById.mockResolvedValueOnce(clazz);

return {
clazz,
};
};

it('should return the class', async () => {
const { clazz } = setup();

const result: Class = await service.findById(clazz.id);

expect(result).toEqual(clazz);
});

it('should throw error', async () => {
classesRepo.findClassById.mockResolvedValueOnce(null);

await expect(service.findById('someId')).rejects.toThrowError(NotFoundLoggableException);
});
});
});

describe('deleteUserDataFromClasses', () => {
describe('when user is missing', () => {
const setup = () => {
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/modules/class/service/class.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@modules/deletion';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { EventBus, EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { NotFoundLoggableException } from '@shared/common/loggable-exception';
import { EntityId } from '@shared/domain/types';
import { Logger } from '@src/core/logger';
import { Class } from '../domain';
Expand Down Expand Up @@ -100,4 +101,12 @@ export class ClassService implements DeletionService, IEventHandler<UserDeletedE
private getClassesId(classes: Class[]): EntityId[] {
return classes.map((item) => item.id);
}

public async findById(id: EntityId): Promise<Class> {
const clazz: Class | null = await this.classesRepo.findClassById(id);
if (!clazz) {
throw new NotFoundLoggableException(Class.name, { id });
}
return clazz;
}
}
Loading

0 comments on commit 21a5f8e

Please sign in to comment.