diff --git a/packages/common/index.ts b/packages/common/index.ts index a577bb8fb..52b8a0425 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1940,6 +1940,8 @@ export const InsightCategories = [ 'Queues', 'Chatbot', 'Staff', + 'Other_Sections', + 'Other_Semesters', ] export enum InsightType { diff --git a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx index 9956b5362..880b9d3c4 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourseForm.tsx @@ -30,6 +30,8 @@ const EditCourseForm: React.FC = ({ const [formGeneral] = Form.useForm() const [professors, setProfessors] = useState() + // PAT TODO: add a popconfirm that tells the user that changing the name will detach the course from courses of the same name + const isAdmin = user && user.organization?.organizationRole === OrganizationRole.ADMIN diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(insights)/components/InsightsPageMenu.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(insights)/components/InsightsPageMenu.tsx index 5e509e976..3df9e8627 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(insights)/components/InsightsPageMenu.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(insights)/components/InsightsPageMenu.tsx @@ -41,6 +41,12 @@ const InsightsMenu: React.FC = ({ case 'Staff': icon = break + case 'Other_Sections': + icon = //PAT TODO: replace with a better icon + break + case 'Other_Semesters': + icon = //PAT TODO: replace with a better icon + break } return { diff --git a/packages/server/ormconfig.ts b/packages/server/ormconfig.ts index 8ef6de1b7..f1156a8b9 100644 --- a/packages/server/ormconfig.ts +++ b/packages/server/ormconfig.ts @@ -42,8 +42,8 @@ import { LMSAssignmentModel } from './src/lmsIntegration/lmsAssignment.entity'; import { LMSAnnouncementModel } from './src/lmsIntegration/lmsAnnouncement.entity'; import { UnreadAsyncQuestionModel } from './src/asyncQuestion/unread-async-question.entity'; import { AsyncQuestionCommentModel } from './src/asyncQuestion/asyncQuestionComment.entity'; -import { ChatbotDocPdfModel } from './src/chatbot/chatbot-doc-pdf.entity'; import { SuperCourseModel } from './src/course/super-course.entity'; +import { ChatbotDocPdfModel } from 'chatbot/chatbot-doc-pdf.entity'; // set .envs to their default values if the developer hasn't yet set them if (fs.existsSync('.env')) { config(); @@ -106,6 +106,7 @@ const typeorm: DataSourceOptions = { ApplicationConfigModel, QueueInviteModel, QueueChatsModel, + SuperCourseModel, InsightDashboardModel, LMSOrganizationIntegrationModel, LMSCourseIntegrationModel, diff --git a/packages/server/src/course/course.entity.ts b/packages/server/src/course/course.entity.ts index 1be3f3ed1..4fd826f0e 100644 --- a/packages/server/src/course/course.entity.ts +++ b/packages/server/src/course/course.entity.ts @@ -137,17 +137,17 @@ export class CourseModel extends BaseEntity { @Exclude() unreadAsyncQuestions: UnreadAsyncQuestionModel[]; - @OneToMany( - (type) => ChatbotDocPdfModel, - (chatbotDocPdf) => chatbotDocPdf.course, - ) - @Exclude() - chatbot_doc_pdfs: ChatbotDocPdfModel[]; - @ManyToOne(() => SuperCourseModel, (course) => course.courses) @JoinColumn({ name: 'superCourseId' }) superCourse: SuperCourseModel; @Column({ nullable: true }) superCourseId: number; + + @OneToMany( + (type) => ChatbotDocPdfModel, + (chatbotDocPdf) => chatbotDocPdf.course, + ) + @Exclude() + chatbot_doc_pdfs: ChatbotDocPdfModel[]; } diff --git a/packages/server/src/course/course.service.ts b/packages/server/src/course/course.service.ts index 720f1c4c1..4abac1581 100644 --- a/packages/server/src/course/course.service.ts +++ b/packages/server/src/course/course.service.ts @@ -39,6 +39,7 @@ import { CourseSettingsModel } from './course_settings.entity'; import { OrganizationUserModel } from 'organization/organization-user.entity'; import { OrganizationCourseModel } from 'organization/organization-course.entity'; import { SemesterModel } from 'semester/semester.entity'; +import { SuperCourseModel } from './super-course.entity'; import { MailService } from 'mail/mail.service'; import { ChatbotApiService } from 'chatbot/chatbot-api.service'; import { InternalServerErrorException } from '@nestjs/common'; @@ -551,6 +552,22 @@ export class CourseService { 'Either a new semester or new section must be provided for your course clone.', ); } + + // SuperCourses are used to group courses together for insights that span multiple semesters + // They are generated here based solely on the course's name for now + const standardizedCourseName = clonedCourse.name.trim().toLowerCase(); + const superCourse = await manager.findOne(SuperCourseModel, { + where: { name: standardizedCourseName }, + }); + if (!superCourse) { + const newSuperCourse = manager.create(SuperCourseModel, { + name: standardizedCourseName, + organizationId: organizationUser.organizationId, + }); + await manager.save(newSuperCourse); + } + clonedCourse.superCourse = superCourse; + await manager.save(clonedCourse); if (originalCourse.courseSettings) { @@ -594,10 +611,10 @@ export class CourseService { await this.redisProfileService.deleteProfile(`u:${userId}`); } - const organizationCourse = new OrganizationCourseModel(); - - organizationCourse.courseId = clonedCourse.id; - organizationCourse.organizationId = organizationUser.organizationId; + const organizationCourse = await manager.create(OrganizationCourseModel, { + courseId: clonedCourse.id, + organizationId: organizationUser.organizationId, + }); await manager.save(organizationCourse); // -------------- For Queues -------------- diff --git a/packages/server/src/insights/insight-objects.ts b/packages/server/src/insights/insight-objects.ts index 0ac209000..70c0dc211 100644 --- a/packages/server/src/insights/insight-objects.ts +++ b/packages/server/src/insights/insight-objects.ts @@ -1,11 +1,13 @@ import { ChartOutputType, GanttChartOutputType, + InsightCategories, InsightFilterOption, InsightObject, InsightType, MultipleGanttChartOutputType, numToWeekday, + PossibleOutputTypes, Role, StringMap, TableOutputType, @@ -1276,6 +1278,44 @@ export const StaffQuestionTimesByDay: InsightObject = { }, }; +//------------------- Insights Across Semesters (and of different sections with the same semester) -------------------// +// TODO: utilize the new supercourse entity to identify the same course across different semesters and sections and +// generate relevant summary statistics to show trends in system usage and adoption over time + +export const TotalQuestionsAcrossSemesters: InsightObject = { + displayName: 'Total Questions Asked Acress Semesters', + description: '', + roles: [], + insightType: InsightType.Chart, + insightCategory: 'Other_Semesters', + compute: function ({ + insightFilters, + cacheManager, + }: { + insightFilters: any; + cacheManager: Cache; + }): Promise { + throw new Error('Function not implemented.'); + }, +}; + +export const TotalQuestionsAcrossSections: InsightObject = { + displayName: 'Total Questions Asked Across Sections', + description: '', + roles: [], + insightType: InsightType.Chart, + insightCategory: 'Other_Sections', + compute: function ({ + insightFilters, + cacheManager, + }: { + insightFilters: any; + cacheManager: Cache; + }): Promise { + throw new Error('Function not implemented.'); + }, +}; + export const INSIGHTS_MAP = { TotalStudents, TotalQuestionsAsked, diff --git a/packages/server/src/insights/insights.command.ts b/packages/server/src/insights/insights.command.ts index 40a37ad17..8f54cc5a7 100644 --- a/packages/server/src/insights/insights.command.ts +++ b/packages/server/src/insights/insights.command.ts @@ -6,7 +6,6 @@ import { INSIGHTS_MAP } from './insight-objects'; /** * Initial Product: Create a system to aggregate certain data within a certain time period for one course - * or accross all courses * * Support courseIds and time intervals (no longer than a semester) */ diff --git a/packages/server/src/organization/organization.controller.ts b/packages/server/src/organization/organization.controller.ts index e38e71f68..9ed7d19dd 100644 --- a/packages/server/src/organization/organization.controller.ts +++ b/packages/server/src/organization/organization.controller.ts @@ -69,6 +69,7 @@ import { RedisProfileService } from '../redisProfile/redis-profile.service'; import { OrgOrCourseRolesGuard } from 'guards/org-or-course-roles.guard'; import { OrgRoles } from 'decorators/org-roles.decorator'; import { CourseRoles } from 'decorators/course-roles.decorator'; +import { SuperCourseModel } from 'course/super-course.entity'; import { CourseService } from 'course/course.service'; // TODO: put the error messages in ERROR_MESSAGES object @@ -377,17 +378,32 @@ export class OrganizationController { `Semester ID is invalid`, HttpStatus.BAD_REQUEST, ); - } else if (courseDetails.semesterId && courseDetails.semesterId !== -1) { - const semester = await manager.findOne(SemesterModel, { - where: { id: courseDetails.semesterId }, - relations: ['courses'], + } else if (courseDetails.semesterId && courseDetails.semesterId == -1) { + throw new HttpException(`Semester must be set`, HttpStatus.BAD_REQUEST); + } + + const semester = await manager.findOne(SemesterModel, { + where: { id: courseDetails.semesterId }, + relations: ['courses'], + }); + if (!semester) { + throw new HttpException(`Semester not found`, HttpStatus.NOT_FOUND); + } + newCourse.semester = semester; + + const superCourse = await manager.findOne(SuperCourseModel, { + where: { name: newCourse.name }, + }); + if (!superCourse) { + const newSuperCourse = manager.create(SuperCourseModel, { + name: newCourse.name, + organizationId: oid, }); - if (!semester) { - throw new HttpException(`Semester not found`, HttpStatus.NOT_FOUND); - } - newCourse.semester = semester; - await manager.save(newCourse); + await manager.save(newSuperCourse); } + newCourse.superCourse = superCourse; + + await manager.save(newCourse); // Create default settings const newCourseSettings = manager.create(CourseSettingsModel, { @@ -505,18 +521,18 @@ export class OrganizationController { ); } else if (courseDetails.semesterId && courseDetails.semesterId == -1) { throw new HttpException(`Semester must be set`, HttpStatus.BAD_REQUEST); - } else if (courseDetails.semesterId && courseDetails.semesterId !== -1) { - const semester = await manager.findOne(SemesterModel, { - where: { id: courseDetails.semesterId }, - relations: ['courses'], - }); - if (!semester) { - throw new HttpException(`Semester not found`, HttpStatus.NOT_FOUND); - } + } - courseInfo.course.semester = semester; + const semester = await manager.findOne(SemesterModel, { + where: { id: courseDetails.semesterId }, + relations: ['courses'], + }); + if (!semester) { + throw new HttpException(`Semester not found`, HttpStatus.NOT_FOUND); } + courseInfo.course.semester = semester; + courseInfo.course.name = courseDetails.name; if (courseDetails.coordinator_email) { @@ -530,6 +546,23 @@ export class OrganizationController { courseInfo.course.zoomLink = courseDetails.zoomLink; courseInfo.course.timezone = courseDetails.timezone; + // To update production courses with the new super course feature when they update their course + // TODO: remove once all existing production courses have non-null super courses + const standardizedCourseName = courseInfo.course.name + .trim() + .toLowerCase(); + const superCourse = await manager.findOne(SuperCourseModel, { + where: { name: standardizedCourseName }, + }); + if (!superCourse) { + const newSuperCourse = manager.create(SuperCourseModel, { + name: standardizedCourseName, + organizationId: oid, + }); + await manager.save(newSuperCourse); + } + courseInfo.course.superCourse = superCourse; + await manager.save(courseInfo.course); // Remove current professors await manager.delete(UserCourseModel, { diff --git a/packages/server/src/organization/organization.entity.ts b/packages/server/src/organization/organization.entity.ts index c26f26c95..d1a68f0dc 100644 --- a/packages/server/src/organization/organization.entity.ts +++ b/packages/server/src/organization/organization.entity.ts @@ -53,7 +53,7 @@ export class OrganizationModel extends BaseEntity { @Exclude() @JoinColumn({ name: 'organizationId' }) @OneToMany( - (type) => OrganizationUserModel, + () => OrganizationUserModel, (organizationUser) => organizationUser.organization, ) organizationUsers: OrganizationUserModel[]; @@ -61,7 +61,7 @@ export class OrganizationModel extends BaseEntity { @Exclude() @JoinColumn({ name: 'organizationId' }) @OneToMany( - (type) => OrganizationCourseModel, + () => OrganizationCourseModel, (organizationCourse) => organizationCourse.organization, ) organizationCourses: OrganizationCourseModel[]; @@ -69,7 +69,7 @@ export class OrganizationModel extends BaseEntity { @Exclude() @JoinColumn({ name: 'organizationId' }) @OneToMany( - (type) => LMSOrganizationIntegrationModel, + () => LMSOrganizationIntegrationModel, (integration) => integration.organization, ) organizationIntegrations: LMSOrganizationIntegrationModel[];