From e40451debed569a435f05f804086948a16e12cd6 Mon Sep 17 00:00:00 2001 From: alfredpichard Date: Thu, 20 Apr 2023 18:12:26 +0200 Subject: [PATCH] WIP --- src/backend/marsha/bbb/api.py | 6 ++ .../migrations/0014_classroomsharednote.py | 83 +++++++++++++++++++ src/backend/marsha/bbb/models.py | 29 +++++++ src/backend/marsha/bbb/serializers.py | 37 ++++++++- src/backend/marsha/bbb/utils/bbb_utils.py | 33 +++++++- .../ClassroomWidgetProvider/index.tsx | 7 ++ .../widgets/Recordings/index.spec.tsx | 12 +-- .../widgets/SharedNotes/index.spec.tsx | 66 +++++++++++++++ .../widgets/SharedNotes/index.tsx | 78 +++++++++++++++++ .../src/utils/tests/factories.ts | 14 ++++ .../src/types/apps/classroom/models.ts | 7 ++ 11 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 src/backend/marsha/bbb/migrations/0014_classroomsharednote.py create mode 100644 src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.spec.tsx create mode 100644 src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.tsx diff --git a/src/backend/marsha/bbb/api.py b/src/backend/marsha/bbb/api.py index bf5e268e0c..f2900666ba 100644 --- a/src/backend/marsha/bbb/api.py +++ b/src/backend/marsha/bbb/api.py @@ -19,6 +19,7 @@ get_recordings, join, process_recordings, + get_session_shared_note, ) from marsha.core import defaults, permissions as core_permissions from marsha.core.api import APIViewMixin, ObjectPkMixin, ObjectRelatedMixin @@ -365,6 +366,11 @@ def service_end(self, request, *args, **kwargs): Type[rest_framework.response.Response] HttpResponse with the serialized classroom. """ + try: + get_session_shared_note(classroom=self.get_object()) + except ApiMeetingException as exception: + response = {"message": str(exception)} + status = 400 try: response = end(classroom=self.get_object()) status = 200 diff --git a/src/backend/marsha/bbb/migrations/0014_classroomsharednote.py b/src/backend/marsha/bbb/migrations/0014_classroomsharednote.py new file mode 100644 index 0000000000..eb6d6bf3d0 --- /dev/null +++ b/src/backend/marsha/bbb/migrations/0014_classroomsharednote.py @@ -0,0 +1,83 @@ +# Generated by Django 4.1.7 on 2023-04-20 15:49 + +import uuid + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("bbb", "0013_classroom_tools_parameters"), + ] + + operations = [ + migrations.CreateModel( + name="ClassroomSharedNote", + fields=[ + ( + "deleted", + models.DateTimeField(db_index=True, editable=False, null=True), + ), + ( + "deleted_by_cascade", + models.BooleanField(default=False, editable=False), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the shared note as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_on", + models.DateTimeField( + default=django.utils.timezone.now, + editable=False, + help_text="date and time at which a shared note was created", + verbose_name="created on", + ), + ), + ( + "updated_on", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a shared note was last updated", + verbose_name="updated on", + ), + ), + ( + "shared_note_url", + models.CharField( + blank=True, + help_text="url of the classroom shared note", + max_length=255, + null=True, + verbose_name="shared note url", + ), + ), + ( + "classroom", + models.ForeignKey( + help_text="classroom to which this shared note belongs", + on_delete=django.db.models.deletion.PROTECT, + related_name="shared notes", + to="bbb.classroom", + verbose_name="classroom shared note", + ), + ), + ], + options={ + "verbose_name": "Classroom shared note", + "verbose_name_plural": "Classroom shared notes", + "db_table": "classroom_shared_note", + "ordering": ["-updated_on"], + }, + ), + ] diff --git a/src/backend/marsha/bbb/models.py b/src/backend/marsha/bbb/models.py index e093329183..e021778066 100644 --- a/src/backend/marsha/bbb/models.py +++ b/src/backend/marsha/bbb/models.py @@ -285,3 +285,32 @@ class Meta: ordering = ["-created_on"] verbose_name = _("Classroom recording") verbose_name_plural = _("Classroom recordings") + + +class ClassroomSharedNote(BaseModel): + """Model representing a shared note in a classroom.""" + + classroom = models.ForeignKey( + to=Classroom, + related_name="shared_notes", + verbose_name=_("classroom shared notes"), + help_text=_("classroom to which this shared note belongs"), + # don't allow hard deleting a classroom if it still contains a recording + on_delete=models.PROTECT, + ) + + shared_note_url = models.CharField( + max_length=255, + verbose_name=_("shared note url"), + help_text=_("url of the classroom shared note"), + null=True, + blank=True, + ) + + class Meta: + """Options for the ``ClassroomSharedNote`` model.""" + + db_table = "classroom_shared_note" + ordering = ["-updated_on"] + verbose_name = _("Classroom shared note") + verbose_name_plural = _("Classroom shared notes") diff --git a/src/backend/marsha/bbb/serializers.py b/src/backend/marsha/bbb/serializers.py index 1c1483493d..436fc4e7d3 100644 --- a/src/backend/marsha/bbb/serializers.py +++ b/src/backend/marsha/bbb/serializers.py @@ -12,7 +12,7 @@ from rest_framework import serializers -from marsha.bbb.models import Classroom, ClassroomDocument, ClassroomRecording +from marsha.bbb.models import Classroom, ClassroomDocument, ClassroomRecording, ClassroomSharedNote from marsha.bbb.utils.bbb_utils import ( ApiMeetingException, get_meeting_infos, @@ -54,6 +54,28 @@ class Meta: # noqa ) +class ClassroomSharedNoteSerializer(ReadOnlyModelSerializer): + """A serializer to display a ClassroomRecording resource.""" + + class Meta: # noqa + model = ClassroomSharedNote + fields = ( + "id", + "classroom", + "shared_note_url", + ) + read_only_fields = ( + "id", + "classroom", + "shared_note_url", + ) + + # Make sure classroom UUID is converted to a string during serialization + classroom = serializers.PrimaryKeyRelatedField( + read_only=True, pk_field=serializers.CharField() + ) + + class ClassroomSerializer(serializers.ModelSerializer): """A serializer to display a Classroom resource.""" @@ -72,6 +94,7 @@ class Meta: # noqa "starting_at", "estimated_duration", "recordings", + "shared_notes", # specific generated fields "infos", "invite_token", @@ -100,6 +123,7 @@ class Meta: # noqa invite_token = serializers.SerializerMethodField() instructor_token = serializers.SerializerMethodField() recordings = serializers.SerializerMethodField() + shared_notes = serializers.SerializerMethodField() def get_infos(self, obj): """Meeting infos from BBB server.""" @@ -137,6 +161,17 @@ def get_recordings(self, obj): ).data return [] + def get_shared_notes(self, obj): + """Get the shared notes for the classroom. + + Only available for admins. + """ + if self.context.get("is_admin", True): + return ClassroomSharedNoteSerializer( + obj.shared_notes.all(), many=True, context=self.context + ).data + return [] + def update(self, instance, validated_data): if any( attribute in validated_data diff --git a/src/backend/marsha/bbb/utils/bbb_utils.py b/src/backend/marsha/bbb/utils/bbb_utils.py index ced77360cc..ab19e31f5a 100644 --- a/src/backend/marsha/bbb/utils/bbb_utils.py +++ b/src/backend/marsha/bbb/utils/bbb_utils.py @@ -10,7 +10,7 @@ import requests import xmltodict -from marsha.bbb.models import Classroom, ClassroomRecording +from marsha.bbb.models import Classroom, ClassroomRecording, ClassroomSharedNote from marsha.core.utils import time_utils @@ -194,6 +194,37 @@ def get_meeting_infos(classroom: Classroom): raise exception +def get_session_shared_note(classroom: Classroom): + """Call BBB API to retrieve shared notes.""" + + try: + meeting_infos = get_meeting_infos(classroom=classroom) + session_id = meeting_infos["id"] + except ApiMeetingException as exception: + raise exception + + url = f"{settings.BBB_API_ENDPOINT}/{session_id}/notes.html" + request = requests.request( + "get", + url, + verify=not settings.DEBUG, + timeout=settings.BBB_API_TIMEOUT, + ) + if request.status_code == 200: + classroom_shared_note, created = ClassroomSharedNote.objects.get_or_create( + classroom=classroom, shared_note_url=url + ) + logger.info( + "%s shared note uploaded on %s with url %s", + "Created" if created else "Updated", + classroom_shared_note.updated_on.isoformat(), + classroom_shared_note.shared_note_url, + ) + return classroom_shared_note + + raise ApiMeetingException(request) + + def get_recordings(meeting_id: str = None, record_id: str = None): """Call BBB API to retrieve recordings.""" parameters = {} diff --git a/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/index.tsx b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/index.tsx index 752e86530c..1713df3dda 100644 --- a/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/index.tsx +++ b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/index.tsx @@ -11,6 +11,7 @@ import { Description } from './widgets/Description'; import { Invite } from './widgets/Invite'; import { Recordings } from './widgets/Recordings'; import { Scheduling } from './widgets/Scheduling'; +import { SharedNotes } from './widgets/SharedNotes'; import { SupportSharing } from './widgets/SupportSharing'; import { ToolsAndApplications } from './widgets/ToolsAndApplications'; @@ -21,6 +22,7 @@ enum WidgetType { INVITE = 'INVITE', SUPPORT_SHARING = 'SUPPORT_SHARING', RECORDINGS = 'RECORDINGS', + SHARED_NOTES = 'SHARED_NOTES', } const widgetLoader: { [key in WidgetType]: WidgetProps } = { @@ -48,6 +50,10 @@ const widgetLoader: { [key in WidgetType]: WidgetProps } = { component: , size: WidgetSize.DEFAULT, }, + [WidgetType.SHARED_NOTES]: { + component: , + size: WidgetSize.DEFAULT, + }, }; const classroomWidgets: WidgetType[] = [ @@ -57,6 +63,7 @@ const classroomWidgets: WidgetType[] = [ WidgetType.SCHEDULING, WidgetType.SUPPORT_SHARING, WidgetType.RECORDINGS, + WidgetType.SHARED_NOTES, ]; export const ClassroomWidgetProvider = () => { diff --git a/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/Recordings/index.spec.tsx b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/Recordings/index.spec.tsx index 3c6c855e54..65ce6a5bef 100644 --- a/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/Recordings/index.spec.tsx +++ b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/Recordings/index.spec.tsx @@ -17,14 +17,14 @@ describe('', () => { let classroom = classroomMockFactory({ id: '1', started: false }); const classroomRecordings = [ classroomRecordingMockFactory({ - started_at: DateTime.fromJSDate( - new Date(2022, 1, 29, 11, 0, 0), - ).toISO() as string, + started_at: + DateTime.fromJSDate(new Date(2022, 1, 29, 11, 0, 0)).toISO() || + undefined, }), classroomRecordingMockFactory({ - started_at: DateTime.fromJSDate( - new Date(2022, 1, 15, 11, 0, 0), - ).toISO() as string, + started_at: + DateTime.fromJSDate(new Date(2022, 1, 15, 11, 0, 0)).toISO() || + undefined, }), ]; diff --git a/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.spec.tsx b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.spec.tsx new file mode 100644 index 0000000000..446087ec82 --- /dev/null +++ b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.spec.tsx @@ -0,0 +1,66 @@ +import { screen } from '@testing-library/react'; +import { InfoWidgetModalProvider } from 'lib-components'; +import { render } from 'lib-tests'; +import { DateTime } from 'luxon'; +import React from 'react'; + +import { + classroomMockFactory, + classroomSharedNoteMockFactory, +} from '@lib-classroom/utils/tests/factories'; +import { wrapInClassroom } from '@lib-classroom/utils/wrapInClassroom'; + +import { SharedNotes } from '.'; + +describe('', () => { + it('displays a list of available shared notes', () => { + let classroom = classroomMockFactory({ id: '1', started: false }); + const classroomSharedNotes = [ + classroomSharedNoteMockFactory({ + updated_on: + DateTime.fromJSDate(new Date(2022, 1, 29, 11, 0, 0)).toISO() || + undefined, + }), + classroomSharedNoteMockFactory({ + updated_on: + DateTime.fromJSDate(new Date(2022, 1, 15, 11, 0, 0)).toISO() || + undefined, + }), + ]; + + const { rerender } = render( + wrapInClassroom( + + , + , + classroom, + ), + ); + + expect(screen.getByText('Shared notes')).toBeInTheDocument(); + expect(screen.getByText('No shared note available')).toBeInTheDocument(); + + // simulate updated classroom + classroom = { + ...classroom, + shared_notes: classroomSharedNotes, + }; + rerender( + wrapInClassroom( + + , + , + classroom, + ), + ); + expect( + screen.queryByText('No shared note available'), + ).not.toBeInTheDocument(); + expect( + screen.getByText('Tuesday, March 1, 2022 - 11:00 AM'), + ).toBeInTheDocument(); + expect( + screen.getByText('Tuesday, February 15, 2022 - 11:00 AM'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.tsx b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.tsx new file mode 100644 index 0000000000..476395407c --- /dev/null +++ b/src/frontend/packages/lib_classroom/src/components/ClassroomWidgetProvider/widgets/SharedNotes/index.tsx @@ -0,0 +1,78 @@ +import { Box } from 'grommet'; +import { ClassroomSharedNote, FoldableItem, ItemList } from 'lib-components'; +import React from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import { useCurrentClassroom } from '@lib-classroom/hooks/useCurrentClassroom'; + +const messages = defineMessages({ + title: { + defaultMessage: 'Shared notes', + description: 'Label for shared notes download in classroom form.', + id: 'component.SharedNotes.title', + }, + info: { + defaultMessage: `All available shared notes can be downloaded here.`, + description: 'Helptext for the widget.', + id: 'component.SharedNotes.info', + }, + noSharedNoteAvailable: { + defaultMessage: 'No shared note available', + description: 'Message when no recordings are available.', + id: 'component.SharedNotes.noSharedNoteAvailable', + }, + downloadSharedNoteLabel: { + defaultMessage: 'Download shared note', + description: 'Label for download recording button.', + id: 'component.SharedNotes.downloadSharedNoteLabel', + }, +}); + +export const SharedNotes = () => { + const classroom = useCurrentClassroom(); + const intl = useIntl(); + + return ( + + + {(sharedNote: ClassroomSharedNote) => ( + + + {intl.formatDate(sharedNote.updated_on, { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + }) + + ' - ' + + intl.formatDate(sharedNote.updated_on, { + hour: 'numeric', + minute: 'numeric', + })} + + + )} + + + ); +}; diff --git a/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts b/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts index 7f4eaa46ae..111168875a 100644 --- a/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts +++ b/src/frontend/packages/lib_classroom/src/utils/tests/factories.ts @@ -6,6 +6,7 @@ import { ClassroomDocument, ClassroomInfos, ClassroomRecording, + ClassroomSharedNote, } from 'lib-components'; const { READY } = uploadState; @@ -29,6 +30,7 @@ export const classroomMockFactory = >( invite_token: null, instructor_token: null, recordings: [], + shared_notes: [], enable_waiting_room: false, enable_shared_notes: true, enable_chat: true, @@ -98,3 +100,15 @@ export const classroomRecordingMockFactory = ( ...classroomRecording, }; }; + +export const classroomSharedNoteMockFactory = ( + classroomSharedNote: Partial = {}, +): ClassroomSharedNote => { + return { + classroom: faker.datatype.uuid(), + id: faker.datatype.uuid(), + updated_on: faker.date.recent().toISOString(), + shared_note_url: faker.internet.url(), + ...classroomSharedNote, + }; +}; diff --git a/src/frontend/packages/lib_components/src/types/apps/classroom/models.ts b/src/frontend/packages/lib_components/src/types/apps/classroom/models.ts index e1386a181c..90e28777ea 100644 --- a/src/frontend/packages/lib_components/src/types/apps/classroom/models.ts +++ b/src/frontend/packages/lib_components/src/types/apps/classroom/models.ts @@ -17,6 +17,7 @@ export interface Classroom extends Resource { invite_token: Nullable; instructor_token: Nullable; recordings: ClassroomRecording[]; + shared_notes: ClassroomSharedNote[]; enable_waiting_room: boolean; enable_shared_notes: boolean; enable_chat: boolean; @@ -132,3 +133,9 @@ export interface ClassroomRecording extends Resource { video_file_url: string; started_at: string; } + +export interface ClassroomSharedNote extends Resource { + classroom: string; + shared_note_url: string; + updated_on: string; +}