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;
+}