diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3f043f52..cd83d4da58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [4.0.0-beta.20] - 2023-04-20 +### Added + +- Add a widget to download the available shared notes on the classroom dashboard + ### Fixed - downgrade python social auth to version 4.3.0 (#2197) diff --git a/env.d/development.dist b/env.d/development.dist index 88370a9bbf..9164f23a7d 100644 --- a/env.d/development.dist +++ b/env.d/development.dist @@ -31,6 +31,7 @@ DJANGO_LTI_CONFIG_CONTACT_EMAIL=fun.dev@fun-mooc.fr # BBB server credentials DJANGO_BBB_ENABLED=False DJANGO_BBB_API_ENDPOINT=https://example.com/bbb/api +DJANGO_BBB_SHARED_NOTES_RETRIEVE_LINK=https://example.com/bbb DJANGO_BBB_API_SECRET=BbbSecret # BBB callback through scalelite may use a different secret to sign the sent token DJANGO_BBB_API_CALLBACK_SECRET=BbbOtherSecret diff --git a/src/backend/marsha/bbb/admin.py b/src/backend/marsha/bbb/admin.py index a4384a259e..ff6747b7ff 100644 --- a/src/backend/marsha/bbb/admin.py +++ b/src/backend/marsha/bbb/admin.py @@ -5,7 +5,7 @@ from marsha.core.admin import link_field -from .models import Classroom, ClassroomDocument +from .models import Classroom, ClassroomDocument, ClassroomSharedNote class ClassroomDocumentInline(admin.TabularInline): @@ -93,3 +93,12 @@ class ClassroomDocumentAdmin(admin.ModelAdmin): "classroom__playlist__organization__name", "filename", ) + + +@admin.register(ClassroomSharedNote) +class ClassroomSharedNoteAdmin(admin.ModelAdmin): + """Admin class for the ClassroomSharedNote model""" + + verbose_name = _("Classroom shared note") + list_display = ("id", link_field("classroom"), "updated_on") + readonly_fields = ["id", "updated_on"] diff --git a/src/backend/marsha/bbb/api.py b/src/backend/marsha/bbb/api.py index bf5e268e0c..6f7ffba981 100644 --- a/src/backend/marsha/bbb/api.py +++ b/src/backend/marsha/bbb/api.py @@ -17,6 +17,7 @@ create, end, get_recordings, + get_session_shared_note, join, process_recordings, ) @@ -365,6 +366,7 @@ def service_end(self, request, *args, **kwargs): Type[rest_framework.response.Response] HttpResponse with the serialized classroom. """ + get_session_shared_note(classroom=self.get_object()) 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..ff62c8bf95 100644 --- a/src/backend/marsha/bbb/serializers.py +++ b/src/backend/marsha/bbb/serializers.py @@ -12,7 +12,12 @@ 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 +59,30 @@ class Meta: # noqa ) +class ClassroomSharedNoteSerializer(ReadOnlyModelSerializer): + """A serializer to display a ClassroomRecording resource.""" + + class Meta: # noqa + model = ClassroomSharedNote + fields = ( + "id", + "classroom", + "shared_note_url", + "updated_on", + ) + read_only_fields = ( + "id", + "classroom", + "shared_note_url", + "updated_on", + ) + + # 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 +101,7 @@ class Meta: # noqa "starting_at", "estimated_duration", "recordings", + "shared_notes", # specific generated fields "infos", "invite_token", @@ -100,6 +130,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 +168,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/tests/bbb_utils/test_get_session_shared_note.py b/src/backend/marsha/bbb/tests/bbb_utils/test_get_session_shared_note.py new file mode 100644 index 0000000000..8e783e7c3f --- /dev/null +++ b/src/backend/marsha/bbb/tests/bbb_utils/test_get_session_shared_note.py @@ -0,0 +1,106 @@ +"""Tests for the get_recordings service in the ``bbb`` app of the Marsha project.""" +from django.test import TestCase, override_settings + +import responses + +from marsha.bbb.factories import ClassroomFactory, ClassroomRecordingFactory +from marsha.bbb.utils.bbb_utils import get_session_shared_note +from marsha.core.tests.testing_utils import reload_urlconf + + +@override_settings( + BBB_SHARED_NOTES_RETRIEVE_LINK="https://10.7.7.1/bigbluebutton/sharednotes" +) +@override_settings(BBB_API_ENDPOINT="https://10.7.7.1/bigbluebutton/api") +@override_settings(BBB_API_SECRET="SuperSecret") +@override_settings(BBB_ENABLED=True) +class ClassroomServiceTestCase(TestCase): + """Test our intentions about the Classroom get_recordings service.""" + + maxDiff = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Force URLs reload to use BBB_ENABLED + reload_urlconf() + + @responses.activate + def test_get_shared_notes(self): + """Validate response when multiple recordings exists.""" + classroom = ClassroomFactory( + meeting_id="881e8986-9673-11ed-a1eb-0242ac120002", started=True + ) + + responses.add( + responses.GET, + "https://10.7.7.1/bigbluebutton/api/getMeetingInfo", + match=[ + responses.matchers.query_param_matcher( + { + "meetingID": "7a567d67-29d3-4547-96f3-035733a4dfaa", + "checksum": "7f13332ec54e7df0a02d07904746cb5b8b330498", + } + ) + ], + body=""" + + SUCCESS + random-6256545 + random-6256545 + ab0da0b4a1f283e94cfefdf32dd761eebd5461ce-1635514947533 + 1635514947533 + Fri Oct 29 13:42:27 UTC 2021 + 77581 + 613-555-1234 + trac + trusti + true + 0 + true + false + false + 1635514947596 + 0 + 1 + 0 + 0 + 0 + 0 + 0 + + + w_2xox6leao03w + User 1907834 + MODERATOR + true + false + false + false + HTML5 + + + w_bau7cr7aefju + User 1907834 + VIEWER + false + false + false + false + HTML5 + + + + + false + + """, + status=200, + ) + + shared_note_object = get_session_shared_note(classroom.meeting_id) + assert ( + shared_note_object.shared_note_url + == "https://10.7.7.1/bigbluebutton/sharednotes/ab0da0b4a1f283e94cfefdf32dd761eebd5461ce-1635514947533/notes.html" + ) diff --git a/src/backend/marsha/bbb/utils/bbb_utils.py b/src/backend/marsha/bbb/utils/bbb_utils.py index ced77360cc..6d5d7c9e18 100644 --- a/src/backend/marsha/bbb/utils/bbb_utils.py +++ b/src/backend/marsha/bbb/utils/bbb_utils.py @@ -10,8 +10,9 @@ 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 +from django.http.response import Http404 logger = logging.getLogger(__name__) @@ -194,6 +195,30 @@ def get_meeting_infos(classroom: Classroom): raise exception +def get_session_shared_note(classroom: Classroom): + """Call BBB API to retrieve shared notes.""" + if not classroom.enable_shared_notes: + return + + try: + meeting_infos = get_meeting_infos(classroom=classroom) + session_id = meeting_infos["internalMeetingID"] + except ApiMeetingException as exception: + raise exception + + url = f"{settings.BBB_SHARED_NOTES_RETRIEVE_LINK}/{session_id}/notes.html" + 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 + + def get_recordings(meeting_id: str = None, record_id: str = None): """Call BBB API to retrieve recordings.""" parameters = {} diff --git a/src/backend/marsha/settings.py b/src/backend/marsha/settings.py index 29ab81c1fd..8cbf1cb180 100644 --- a/src/backend/marsha/settings.py +++ b/src/backend/marsha/settings.py @@ -375,6 +375,7 @@ class Base(Configuration): # BBB BBB_ENABLED = values.BooleanValue(False) BBB_API_ENDPOINT = values.Value() + BBB_SHARED_NOTES_RETRIEVE_LINK = values.Value() BBB_API_SECRET = values.Value(None) BBB_API_CALLBACK_SECRET = values.Value(None) BBB_API_TIMEOUT = values.PositiveIntegerValue(10) 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; +}