Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

N21-2075: sync existing course #5165

Merged
merged 90 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from 73 commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
11002da
add sync existing course
sdinkov Aug 2, 2024
b51c574
update imports; cleanup
sdinkov Aug 2, 2024
f39758c
Merge branch 'main' into N21-2111-sync-existing-course
sdinkov Aug 2, 2024
1858e47
N21-2075 modify course metadata
mrikallab Aug 5, 2024
d6c84a1
update tests course entity mapping
sdinkov Aug 5, 2024
a3fc953
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Aug 5, 2024
5baad42
N21-2075 feature flag
mrikallab Aug 6, 2024
7d0d197
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Aug 6, 2024
914a4b3
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Aug 8, 2024
5c9f559
N21-2075 CLEAN UP
mrikallab Aug 9, 2024
be04430
N21-2075 CLEAN UP
mrikallab Aug 9, 2024
97b1ad3
N21-2075 CLEAN UP
mrikallab Aug 9, 2024
eea41b5
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Aug 9, 2024
b122a30
N21-2075 WIP endpoint
mrikallab Aug 9, 2024
591130f
N21-2075 WIP mapper
mrikallab Aug 12, 2024
4d11d79
N21-2075 WIP
mrikallab Aug 12, 2024
37b05f4
update courses /all endpoint
sdinkov Aug 13, 2024
cab5fdd
N21-2075 WIP + Clean up
mrikallab Aug 15, 2024
02d9a39
N21-2075 change to classNames
mrikallab Aug 15, 2024
a18e3a1
N21-2075 fix teacherName
mrikallab Aug 15, 2024
2a19706
update get course infos
sdinkov Aug 15, 2024
1527469
update course DO
sdinkov Aug 16, 2024
d316d7f
N21-2075 fix classNames
mrikallab Aug 16, 2024
38f9bbb
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Aug 16, 2024
19f6860
N21-2075 permission check
mrikallab Aug 16, 2024
51d8801
update course synchronization
sdinkov Aug 19, 2024
cbf29e3
N21-2075 fix permission check
mrikallab Aug 19, 2024
05743f2
update course sync start
sdinkov Aug 20, 2024
44be93e
N21-2075 fix untilDate
mrikallab Aug 20, 2024
27cd924
add migration + cleanup
sdinkov Aug 20, 2024
b6ee615
Merge branch 'N21-2075-sync-existing-course' of https://github.com/hp…
sdinkov Aug 20, 2024
813de58
update course find all
sdinkov Aug 21, 2024
561bde2
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Aug 21, 2024
09237e2
update seed data roles
sdinkov Aug 21, 2024
9109905
update pagination handling for courses
sdinkov Aug 22, 2024
63ef205
update course do service
sdinkov Aug 22, 2024
6f12f96
update classes repo spec
sdinkov Aug 23, 2024
15ae3a5
N21-2075 Calendar flag for Nuxt
mrikallab Aug 23, 2024
7fffc02
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Aug 23, 2024
345e19a
update courses info response and api tests
sdinkov Aug 23, 2024
5131627
Merge branch 'N21-2075-sync-existing-course' of https://github.com/hp…
sdinkov Aug 23, 2024
9c2f73a
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Aug 23, 2024
f1f7ff7
update course api test
sdinkov Aug 23, 2024
70a8a08
update server api test
sdinkov Aug 23, 2024
518941c
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Aug 23, 2024
3fc8d4d
migration: Migration20240823151836
sdinkov Aug 23, 2024
538e736
update course us test
sdinkov Aug 25, 2024
2d84c97
update course uc tests
sdinkov Aug 25, 2024
ccbaff8
update course uc tests
sdinkov Aug 25, 2024
4285ece
update course uc tests
sdinkov Aug 25, 2024
847175a
Merge branch 'N21-2075-sync-existing-course' of https://github.com/hp…
sdinkov Aug 26, 2024
a35f453
N21-2075 refactor response
mrikallab Aug 26, 2024
d1cca42
N21-2075 refactor endpoint + mapper
mrikallab Aug 26, 2024
742d87d
add course info controller + test
sdinkov Aug 26, 2024
80fbfb4
update sourse info + course repo wip
sdinkov Aug 26, 2024
b10bbec
update course info controller + test
sdinkov Aug 26, 2024
5b3558e
update classe service tests
sdinkov Aug 26, 2024
c81fe1f
N21-2075 repo test
mrikallab Aug 27, 2024
6f48e95
update course repo + test
sdinkov Aug 27, 2024
f44458f
Merge branch 'N21-2075-sync-existing-course' of https://github.com/hp…
sdinkov Aug 27, 2024
d4789e9
N21-2075 refactor response
mrikallab Aug 27, 2024
45d8f84
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Aug 27, 2024
831aee1
N21-2075 refactoring response + mapper
mrikallab Aug 27, 2024
0c0c17f
Merge branch 'refs/heads/main' into N21-2075-sync-existing-course
mrikallab Aug 27, 2024
4729c85
N21-2075 nested ternary operation fix
mrikallab Aug 27, 2024
04aaf7d
Merge branch 'refs/heads/main' into N21-2075-sync-existing-course
mrikallab Aug 27, 2024
85dbe0c
N21-2075 fix merge
mrikallab Aug 27, 2024
fbc4006
N21-2075 fix rule
mrikallab Aug 27, 2024
95ee590
upda course info controller; add course-info uc; update tests; cleanup
sdinkov Aug 28, 2024
bf6daed
clean up
sdinkov Aug 28, 2024
04d628b
clean up
sdinkov Aug 28, 2024
11ab084
rename course info controller method
sdinkov Aug 28, 2024
c1df6f4
update course info controller response summary
sdinkov Aug 28, 2024
17a3a07
update review changes
sdinkov Aug 30, 2024
14126ae
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Aug 30, 2024
41da6f3
correct typo
sdinkov Aug 30, 2024
bc7f721
update course info uc
sdinkov Aug 30, 2024
640e299
Merge branch 'refs/heads/main' into N21-2075-sync-existing-course
mrikallab Sep 2, 2024
36ac93b
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Sep 2, 2024
8971d32
Merge branch 'main' into N21-2075-sync-existing-course
mrikallab Sep 3, 2024
ef03b5e
N21-2075 extend delete course
mrikallab Sep 3, 2024
37a9575
update course-info
sdinkov Sep 3, 2024
d371047
N21-2075 extend delete course
mrikallab Sep 3, 2024
2d672d3
Merge remote-tracking branch 'origin/N21-2075-sync-existing-course' i…
mrikallab Sep 3, 2024
174f0ef
update course info tests
sdinkov Sep 3, 2024
c93ccd7
Merge branch 'N21-2075-sync-existing-course' of https://github.com/hp…
sdinkov Sep 3, 2024
77f1d9c
Merge branch 'refs/heads/main' into N21-2075-sync-existing-course
mrikallab Sep 3, 2024
2ffeedf
update course info tests
sdinkov Sep 3, 2024
3d1f948
Merge branch 'N21-2075-sync-existing-course' of https://github.com/hp…
sdinkov Sep 3, 2024
8ca48d2
Merge branch 'main' into N21-2075-sync-existing-course
sdinkov Sep 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.');
}
}
}
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);
sdinkov marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
31 changes: 31 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,35 @@ 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();
console.log(class1.id);
sdinkov marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { EntityManager } from '@mikro-orm/mongodb';
import { ServerTestModule } from '@modules/server/server.module';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Course as CourseEntity } from '@shared/domain/entity';
import { Permission } from '@shared/domain/interface';
import {
cleanupCollections,
courseFactory,
schoolEntityFactory,
TestApiClient,
UserAndAccountTestFactory,
} from '@shared/testing';
import { CourseSortQueryType, CourseStatusQueryType } from '../../domain';
import { CourseInfoListResponse } from '../dto/response';

const createStudent = () => {
const { studentUser, studentAccount } = UserAndAccountTestFactory.buildStudent({}, [Permission.COURSE_VIEW]);
return { account: studentAccount, user: studentUser };
};
const createTeacher = () => {
const { teacherAccount, teacherUser } = UserAndAccountTestFactory.buildTeacher({}, [
Permission.COURSE_VIEW,
Permission.COURSE_EDIT,
]);
return { account: teacherAccount, user: teacherUser };
};

const createAdmin = () => {
const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({}, [Permission.COURSE_ADMINISTRATION]);
return { account: adminAccount, user: adminUser };
};

describe('Course Info Controller (API)', () => {
let app: INestApplication;
let em: EntityManager;
let testApiClient: TestApiClient;

beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ServerTestModule],
}).compile();

app = module.createNestApplication();
await app.init();
em = module.get(EntityManager);
testApiClient = new TestApiClient(app, 'course-info');
});

afterAll(async () => {
await cleanupCollections(em);
await app.close();
});

describe('[GET] /course-info', () => {
describe('when logged in as admin', () => {
const setup = async () => {
const student = createStudent();
const teacher = createTeacher();
const admin = createAdmin();
const school = schoolEntityFactory.buildWithId({});

const currentCourses: CourseEntity[] = courseFactory.buildList(5, {
school,
untilDate: new Date('2045-07-31T23:59:59'),
});
const archivedCourses: CourseEntity[] = courseFactory.buildList(10, {
school,
untilDate: new Date('2024-07-31T23:59:59'),
});

admin.user.school = school;
await em.persistAndFlush(school);
await em.persistAndFlush(currentCourses);
await em.persistAndFlush(archivedCourses);
await em.persistAndFlush([admin.account, admin.user]);
em.clear();

return {
student,
currentCourses,
archivedCourses,
teacher,
admin,
school,
};
};

it('should return the correct response structure', async () => {
const { admin } = await setup();
const query = {};

const loggedInClient = await testApiClient.login(admin.account);
const response = await loggedInClient.get().query(query);

expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('skip');
expect(response.body).toHaveProperty('limit');
expect(response.body).toHaveProperty('total');
});

it('should return archived courses in pages', async () => {
const { admin } = await setup();
const query = { skip: 0, limit: 10, sortBy: CourseSortQueryType.NAME, type: CourseStatusQueryType.ARCHIVE };

const loggedInClient = await testApiClient.login(admin.account);
const response = await loggedInClient.get().query(query);

const { total, skip, limit, data } = response.body as CourseInfoListResponse;
expect(response.statusCode).toBe(200);
expect(skip).toBe(0);
expect(limit).toBe(10);
expect(total).toBe(10);
expect(data.length).toBe(10);
});

it('should return current courses in pages', async () => {
const { admin, currentCourses } = await setup();
const query = { skip: 4, limit: 2, sortBy: CourseSortQueryType.NAME, type: CourseStatusQueryType.CURRENT };

const loggedInClient = await testApiClient.login(admin.account);
const response = await loggedInClient.get().query(query);

const { total, skip, limit, data } = response.body as CourseInfoListResponse;
expect(response.statusCode).toBe(200);
expect(skip).toBe(4);
expect(limit).toBe(2);
expect(total).toBe(5);
expect(data.length).toBe(1);
expect(data[0].id).toBe(currentCourses[4].id);
});
});

describe('when logged in not authenticated/authorized', () => {
sdinkov marked this conversation as resolved.
Show resolved Hide resolved
const setup = async () => {
const teacher = createTeacher();

await em.persistAndFlush([teacher.account, teacher.user]);
em.clear();

return {
teacher,
};
};

it('should return unauthorized', async () => {
const query = { skip: 4, limit: 2, sortBy: CourseSortQueryType.NAME, type: CourseStatusQueryType.CURRENT };

const response = await testApiClient.get().query(query);

expect(response.status).toEqual(HttpStatus.UNAUTHORIZED);
expect(response.body).toEqual({
code: HttpStatus.UNAUTHORIZED,
message: 'Unauthorized',
title: 'Unauthorized',
type: 'UNAUTHORIZED',
});
});

it('should return forbidden', async () => {
const { teacher } = await setup();
const query = { skip: 4, limit: 2, sortBy: CourseSortQueryType.NAME, type: CourseStatusQueryType.CURRENT };

const loggedInClient = await testApiClient.login(teacher.account);
const response = await loggedInClient.get().query(query);

expect(response.status).toEqual(HttpStatus.FORBIDDEN);
expect(response.body).toEqual({
code: HttpStatus.FORBIDDEN,
message: 'Forbidden',
title: 'Forbidden',
type: 'FORBIDDEN',
});
});
});
});
});
Loading
Loading