Merge branch 'collab-bugfixes' into 'main'
Collab bugfixes See merge request reportcreator/reportcreator!530
This commit is contained in:
commit
12bdd2e761
|
@ -2,6 +2,7 @@
|
|||
|
||||
## Next
|
||||
* Collaborative editing in project findings and sections
|
||||
* Collaborative editing: update notes list when import new notes
|
||||
* Fix slot data items `.length` property undefined `<list-of-figures>`, `<list-of-tables>` and `<table-of-contents>` components
|
||||
|
||||
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
from .import_export import (
|
||||
export_notes,
|
||||
export_project_types,
|
||||
export_projects,
|
||||
export_templates,
|
||||
import_notes,
|
||||
import_project_types,
|
||||
import_projects,
|
||||
import_templates,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'export_project_types', 'export_projects', 'export_templates',
|
||||
'import_project_types', 'import_projects', 'import_templates',
|
||||
'export_project_types', 'export_projects', 'export_templates', 'export_notes',
|
||||
'import_project_types', 'import_projects', 'import_templates', 'import_notes',
|
||||
]
|
||||
|
|
|
@ -19,7 +19,10 @@ from reportcreator_api.archive.import_export.serializers import (
|
|||
PentestProjectExportImportSerializer,
|
||||
ProjectTypeExportImportSerializer,
|
||||
)
|
||||
from reportcreator_api.pentests.consumers import send_collab_event_project, send_collab_event_user
|
||||
from reportcreator_api.pentests.models import (
|
||||
CollabEvent,
|
||||
CollabEventType,
|
||||
FindingTemplate,
|
||||
PentestFinding,
|
||||
PentestProject,
|
||||
|
@ -27,8 +30,14 @@ from reportcreator_api.pentests.models import (
|
|||
ProjectNotebookPage,
|
||||
ProjectType,
|
||||
ReportSection,
|
||||
UserNotebookPage,
|
||||
)
|
||||
from reportcreator_api.pentests.serializers.notes import (
|
||||
ProjectNotebookPageSerializer,
|
||||
ProjectNotebookPageSortListSerializer,
|
||||
UserNotebookPageSerializer,
|
||||
UserNotebookPageSortListSerializer,
|
||||
)
|
||||
from reportcreator_api.pentests.models.notes import UserNotebookPage
|
||||
from reportcreator_api.users.models import PentestUser
|
||||
from reportcreator_api.utils.history import history_context
|
||||
|
||||
|
@ -265,5 +274,41 @@ def import_projects(archive_file):
|
|||
def import_notes(archive_file, context):
|
||||
if not context.get('project') and not context.get('user'):
|
||||
raise ValueError('Either project or user must be provided')
|
||||
return import_archive(archive_file, serializer_classes=[NotesExportImportSerializer], context=context)
|
||||
# Import notes to DB
|
||||
notes = import_archive(archive_file, serializer_classes=[NotesExportImportSerializer], context=context)
|
||||
|
||||
# Send collab events
|
||||
sender_options = {
|
||||
'related_object': context['project'],
|
||||
'serializer': ProjectNotebookPageSerializer,
|
||||
'serializer_sort': ProjectNotebookPageSortListSerializer,
|
||||
'send_collab_event': send_collab_event_project,
|
||||
} if context.get('project') else {
|
||||
'related_object': context['user'],
|
||||
'serializer': UserNotebookPageSerializer,
|
||||
'serializer_sort': UserNotebookPageSortListSerializer,
|
||||
'send_collab_event': send_collab_event_user,
|
||||
}
|
||||
|
||||
# Create events
|
||||
events = CollabEvent.objects.bulk_create(
|
||||
CollabEvent(
|
||||
related_id=sender_options['related_object'].id,
|
||||
path=f'notes.{n.note_id}',
|
||||
type=CollabEventType.CREATE,
|
||||
created=n.created,
|
||||
version=n.created.timestamp(),
|
||||
data={
|
||||
'value': sender_options['serializer'](instance=n).data,
|
||||
},
|
||||
) for n in notes
|
||||
)
|
||||
for e in events:
|
||||
sender_options['send_collab_event'](e)
|
||||
|
||||
# Sort event
|
||||
notes_sorted = sender_options['related_object'].notes.select_related('parent').all()
|
||||
sender_options['serializer_sort'](instance=notes_sorted, context=context).send_collab_event(notes_sorted)
|
||||
|
||||
return notes
|
||||
|
||||
|
|
|
@ -84,8 +84,14 @@ class WebsocketConsumerBase(AsyncJsonWebsocketConsumer):
|
|||
await self.close(code=4443)
|
||||
return
|
||||
|
||||
with history_context(history_user=self.scope.get('user')):
|
||||
return await super().websocket_receive(message)
|
||||
try:
|
||||
with history_context(history_user=self.scope.get('user')):
|
||||
return await super().websocket_receive(message)
|
||||
except ValidationError as ex:
|
||||
await self.send_json({
|
||||
'type': 'error',
|
||||
'message': ex.message,
|
||||
})
|
||||
|
||||
async def websocket_disconnect(self, message):
|
||||
try:
|
||||
|
@ -288,7 +294,7 @@ class NotesConsumerBase(WebsocketConsumerBase):
|
|||
.select_for_update(of=['self'], no_key=True) \
|
||||
.first()
|
||||
if not note:
|
||||
return None, None
|
||||
raise ValidationError('Invalid path: ID not found')
|
||||
return note, path_parts[2]
|
||||
|
||||
@database_sync_to_async
|
||||
|
@ -297,8 +303,6 @@ class NotesConsumerBase(WebsocketConsumerBase):
|
|||
# Validate path and get note
|
||||
valid_paths = {k for k, f in self.get_serializer().fields.items() if not f.read_only} - {'title', 'text'}
|
||||
note, key = self.get_note_for_update(path=content.get('path'), valid_paths=valid_paths)
|
||||
if not note:
|
||||
return None
|
||||
|
||||
# Update in DB
|
||||
serializer = self.get_serializer(instance=note, data={key: content.get('value')}, partial=True)
|
||||
|
@ -325,8 +329,6 @@ class NotesConsumerBase(WebsocketConsumerBase):
|
|||
if not content.get('updates', []):
|
||||
raise ValidationError('No updates')
|
||||
note, key = self.get_note_for_update(path=content.get('path'), valid_paths=['title', 'text'])
|
||||
if not note:
|
||||
return None
|
||||
|
||||
version = content['version']
|
||||
# TODO: reject updates for versions that are too old
|
||||
|
@ -349,7 +351,7 @@ class NotesConsumerBase(WebsocketConsumerBase):
|
|||
for u in e.data.get('updates', [])] for e in over_updates])),
|
||||
)
|
||||
if not updates:
|
||||
return None
|
||||
raise ValidationError('No updates')
|
||||
|
||||
# Update in DB
|
||||
changes = updates[0].changes
|
||||
|
@ -585,7 +587,7 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
|
|||
.select_for_update(of=['self'], no_key=True) \
|
||||
.first()
|
||||
if not obj:
|
||||
return None, None, None
|
||||
raise ValidationError('Invalid path: ID not found')
|
||||
|
||||
# Validate path in top-level or in field definition
|
||||
if path_parts[2] == 'data':
|
||||
|
@ -605,8 +607,8 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
|
|||
def collab_update_key(self, content):
|
||||
# Validate path and get section/finding
|
||||
obj, path, definition = self.get_object_for_update(content.get('path'))
|
||||
if not obj or (definition and definition.type in [FieldDataType.MARKDOWN, FieldDataType.STRING]):
|
||||
return None
|
||||
if definition and definition.type in [FieldDataType.MARKDOWN, FieldDataType.STRING]:
|
||||
raise ValidationError('collab.update_key is not supported for text fields. Use collab.update_text instead.')
|
||||
|
||||
# Update data in DB
|
||||
if definition:
|
||||
|
@ -641,8 +643,8 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
|
|||
@transaction.atomic()
|
||||
def collab_update_text(self, content):
|
||||
obj, path, definition = self.get_object_for_update(content.get('path'))
|
||||
if not obj or not definition or definition.type not in [FieldDataType.MARKDOWN, FieldDataType.STRING]:
|
||||
return None
|
||||
if not definition or definition.type not in [FieldDataType.MARKDOWN, FieldDataType.STRING]:
|
||||
raise ValidationError('collab.update_text is not supported for non-text fields. Use collab.update_key instead.')
|
||||
|
||||
version = content['version']
|
||||
# TODO: reject updates for versions that are too old
|
||||
|
@ -662,7 +664,7 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
|
|||
for u in e.data.get('updates', [])] for e in over_updates])),
|
||||
)
|
||||
if not updates:
|
||||
return None
|
||||
raise ValidationError('No updates')
|
||||
|
||||
# Update in DB
|
||||
changes = updates[0].changes
|
||||
|
@ -697,8 +699,8 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
|
|||
@transaction.atomic()
|
||||
def collab_create(self, content):
|
||||
obj, path, definition = self.get_object_for_update(content.get('path'))
|
||||
if not obj or not definition or definition.type != FieldDataType.LIST:
|
||||
return None
|
||||
if not definition or definition.type != FieldDataType.LIST:
|
||||
raise ValidationError('collab.create is only supported for list fields')
|
||||
|
||||
# Update DB
|
||||
updated_data = obj.data
|
||||
|
@ -726,16 +728,16 @@ class ProjectReportingConsumer(WebsocketConsumerBase):
|
|||
@transaction.atomic()
|
||||
def collab_delete(self, content):
|
||||
obj, path, definition = self.get_object_for_update(content.get('path'))
|
||||
if not obj or not definition:
|
||||
return None
|
||||
if not definition:
|
||||
raise ValidationError('collab.delete is only supported for fields')
|
||||
|
||||
updated_data = obj.data
|
||||
lst = get_value_at_path(updated_data, path[1:-1])
|
||||
if not isinstance(lst, list):
|
||||
return None
|
||||
raise ValidationError('collab.delete is only supported for fields')
|
||||
index = int(path[-1][1:-1] if path[-1].startswith('[') and path[-1].endswith(']') else path[-1])
|
||||
if not (0 <= index < len(lst)):
|
||||
return None
|
||||
raise ValidationError('Invalid list index')
|
||||
lst.pop(index)
|
||||
serializer = (ReportSectionSerializer if isinstance(obj, ReportSection) else PentestFindingSerializer)(instance=obj, data={'data': updated_data}, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
|
|
@ -38,6 +38,7 @@ from reportcreator_api.archive.import_export import (
|
|||
import_templates,
|
||||
)
|
||||
from reportcreator_api.archive.import_export.import_export import export_notes, import_notes
|
||||
from reportcreator_api.pentests.consumers import send_collab_event_project
|
||||
from reportcreator_api.pentests.customfields.predefined_fields import FINDING_FIELDS_PREDEFINED
|
||||
from reportcreator_api.pentests.customfields.types import field_definition_to_dict
|
||||
from reportcreator_api.pentests.models import (
|
||||
|
@ -61,6 +62,7 @@ from reportcreator_api.pentests.models import (
|
|||
UploadedTemplateImage,
|
||||
UserPublicKey,
|
||||
)
|
||||
from reportcreator_api.pentests.models.collab import CollabEvent, CollabEventType
|
||||
from reportcreator_api.pentests.permissions import (
|
||||
ArchivedProjectKeyPartPermissions,
|
||||
IsTemplateEditorOrReadOnly,
|
||||
|
|
|
@ -11,8 +11,12 @@ from django.urls import reverse
|
|||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from reportcreator_api.archive.import_export import export_project_types, export_projects, export_templates
|
||||
from reportcreator_api.archive.import_export.import_export import export_notes
|
||||
from reportcreator_api.archive.import_export import (
|
||||
export_notes,
|
||||
export_project_types,
|
||||
export_projects,
|
||||
export_templates,
|
||||
)
|
||||
from reportcreator_api.notifications.models import NotificationSpec, UserNotification
|
||||
from reportcreator_api.pentests.models import (
|
||||
FindingTemplate,
|
||||
|
|
|
@ -10,10 +10,12 @@ from channels.testing import WebsocketCommunicator
|
|||
from django.conf import settings
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.urls import reverse
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from reportcreator_api.archive.import_export import export_notes
|
||||
from reportcreator_api.conf.asgi import application
|
||||
from reportcreator_api.pentests.customfields.utils import (
|
||||
ensure_defined_structure,
|
||||
|
@ -451,6 +453,20 @@ class TestProjectNotesDbSync:
|
|||
data=[{'id': self.note.note_id, 'order': 1, 'parent': None}])
|
||||
await self.assert_event({'type': CollabEventType.SORT, 'path': 'notes', 'client_id': None, 'sort': res.data})
|
||||
|
||||
async def test_import_sync(self):
|
||||
def import_notes():
|
||||
res = self.api_client1.post(
|
||||
path=reverse('projectnotebookpage-import', kwargs={'project_pk': self.project.id}),
|
||||
data={'file': ContentFile(content=b''.join(export_notes(self.project)), name='export.tar.gz')},
|
||||
format='multipart',
|
||||
)
|
||||
return res.data
|
||||
notes_imported = await sync_to_async(import_notes)()
|
||||
|
||||
for n in notes_imported:
|
||||
await self.assert_event({'type': CollabEventType.CREATE, 'path': f'notes.{n["id"]}', 'value': n, 'client_id': None})
|
||||
await self.assert_event({'type': CollabEventType.SORT, 'path': 'notes', 'client_id': None})
|
||||
|
||||
async def test_member_removed_write(self):
|
||||
await ProjectMemberInfo.objects.filter(project=self.project, user=self.user1).adelete()
|
||||
|
||||
|
|
|
@ -130,7 +130,7 @@ export function useCollab<T = any>(storeState: CollabStoreState<T>) {
|
|||
})
|
||||
storeState.websocket.addEventListener('close', (event) => {
|
||||
// Error handling
|
||||
if (event.code === 4443 || (event.code === 1006 && storeState.connectionState === CollabConnectionState.CONNECTING)) {
|
||||
if (event.code === 4443) {
|
||||
storeState.connectionError = { error: event, message: event.reason || 'Permission denied' };
|
||||
} else if (storeState.connectionState === CollabConnectionState.CONNECTING) {
|
||||
storeState.connectionError = { error: event, message: event.reason || 'Failed to establish connection' };
|
||||
|
@ -232,6 +232,9 @@ export function useCollab<T = any>(storeState: CollabStoreState<T>) {
|
|||
}),
|
||||
};
|
||||
}
|
||||
} else if (msgData.type === 'error') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Received error from websocket:', msgData);
|
||||
} else if (msgData.type === 'ping') {
|
||||
// Do nothing
|
||||
} else {
|
||||
|
|
Loading…
Reference in New Issue