• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fiduswriter / fiduswriter / 10941406873

19 Sep 2024 12:55PM UTC coverage: 87.088% (-0.007%) from 87.095%
10941406873

Pull #1294

github

web-flow
Merge 0ca2fd0ed into b2c563a85
Pull Request #1294: remove JSONPATCH setting

6374 of 7319 relevant lines covered (87.09%)

4.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.87
fiduswriter/document/consumers.py
1
import uuid
13✔
2
import atexit
13✔
3
import logging
13✔
4
from time import mktime, time
13✔
5
from copy import deepcopy
13✔
6

7

8
from django.db.utils import DatabaseError
13✔
9
from django.db.models import F, Q
13✔
10
from django.conf import settings
13✔
11

12
from base.helpers.host import get_host
13✔
13

14
from document.helpers.session_user_info import SessionUserInfo
13✔
15
from document.helpers.host import compare_host_with_expected
13✔
16
from document import prosemirror
13✔
17
from document.helpers.serializers import PythonWithURLSerializer
13✔
18
from base.base_consumer import BaseWebsocketConsumer
13✔
19
from document.models import (
13✔
20
    COMMENT_ONLY,
21
    CAN_UPDATE_DOCUMENT,
22
    CAN_COMMUNICATE,
23
    FW_DOCUMENT_VERSION,
24
    DocumentTemplate,
25
    Document,
26
)
27
from usermedia.models import Image, DocumentImage, UserImage
13✔
28
from user.helpers import Avatars
13✔
29

30
logger = logging.getLogger(__name__)
13✔
31

32

33
class WebsocketConsumer(BaseWebsocketConsumer):
13✔
34
    sessions = dict()
13✔
35
    history_length = 1000  # Only keep the last 1000 diffs
13✔
36

37
    def connect(self):
13✔
38
        self.document_id = int(
9✔
39
            self.scope["url_route"]["kwargs"]["document_id"]
40
        )
41
        redirected = self.check_server()
9✔
42
        if redirected:
9✔
43
            return
×
44
        connected = super().connect()
9✔
45
        if not connected:
9✔
46
            return
1✔
47
        logger.debug(
9✔
48
            f"Action:Document socket opened by user. "
49
            f"URL:{self.endpoint} User:{self.user.id} ParticipantID:{self.id}"
50
        )
51

52
    def check_server(self):
13✔
53
        if len(settings.WS_SERVERS) < 2:
9✔
54
            return False
9✔
55
        origin = (
×
56
            dict(self.scope["headers"]).get(b"origin", b"").decode("utf-8")
57
        )
58
        ws_server = settings.WS_SERVERS[
×
59
            self.document_id % len(settings.WS_SERVERS)
60
        ]
61
        expected = get_host(origin, ws_server)
×
62
        host = f"{self.scope['server'][0]}:{self.scope['server'][1]}"
×
63
        if not compare_host_with_expected(host, expected, origin):
×
64
            # Redirect to the correct URL
65
            self.init()
×
66
            logger.debug(f"Redirecting from {host} to {expected}")
×
67
            self.send_message({"type": "redirect", "url": f"{expected}"})
×
68
            self.do_close()
×
69
            return True
×
70
        return False
×
71

72
    def confirm_diff(self, rid):
13✔
73
        response = {"type": "confirm_diff", "rid": rid}
9✔
74
        self.send_message(response)
9✔
75

76
    def subscribe(self, connection_count=0):
13✔
77
        self.user_info = SessionUserInfo(self.user)
9✔
78
        doc_db, can_access = self.user_info.init_access(self.document_id)
9✔
79
        if not can_access or float(doc_db.doc_version) != FW_DOCUMENT_VERSION:
9✔
80
            self.access_denied()
1✔
81
            return
1✔
82
        if (
9✔
83
            doc_db.id in WebsocketConsumer.sessions
84
            and len(WebsocketConsumer.sessions[doc_db.id]["participants"]) > 0
85
        ):
86
            logger.debug(
3✔
87
                f"Action:Serving already opened document. "
88
                f"URL:{self.endpoint} User:{self.user.id} "
89
                f" ParticipantID:{self.id}"
90
            )
91
            self.session = WebsocketConsumer.sessions[doc_db.id]
3✔
92
            self.id = max(self.session["participants"]) + 1
3✔
93
            self.session["participants"][self.id] = self
3✔
94
            template = False
3✔
95
        else:
96
            logger.debug(
9✔
97
                f"Action:Opening document from DB. "
98
                f"URL:{self.endpoint} User:{self.user.id} "
99
                f"ParticipantID:{self.id}"
100
            )
101
            self.id = 0
9✔
102
            if "type" not in doc_db.content:
9✔
103
                doc_db.content = deepcopy(doc_db.template.content)
9✔
104
                if "type" not in doc_db.content:
9✔
105
                    doc_db.content["type"] = "doc"
×
106
                if "content" not in doc_db.content:
9✔
107
                    doc_db.content["content"] = [{type: "title"}]
×
108
                doc_db.save()
9✔
109
            node = prosemirror.from_json(doc_db.content)
9✔
110
            self.session = {
9✔
111
                "doc": doc_db,
112
                "node": node,
113
                "node_updates": False,
114
                "participants": {0: self},
115
                "last_saved_version": doc_db.version,
116
            }
117
            WebsocketConsumer.sessions[doc_db.id] = self.session
9✔
118
            if self.user_info.access_rights == "write":
9✔
119
                template = True
9✔
120
            else:
121
                template = False
1✔
122
        logger.debug(
9✔
123
            f"Action:Participant ID Assigned. URL:{self.endpoint} "
124
            f"User:{self.user.id} ParticipantID:{self.id}"
125
        )
126
        self.send_message({"type": "subscribed"})
9✔
127
        if connection_count < 1:
9✔
128
            self.send_styles()
9✔
129
            self.send_document(False, template)
9✔
130
        if connection_count >= 1:
9✔
131
            # If the user is reconnecting pass along access_rights to
132
            # front end to compare it with previous rights.
133
            self.send_message(
2✔
134
                {
135
                    "type": "access_right",
136
                    "access_right": self.user_info.access_rights,
137
                }
138
            )
139
        if self.can_communicate():
9✔
140
            self.handle_participant_update()
9✔
141

142
    def send_styles(self):
13✔
143
        doc_db = self.session["doc"]
9✔
144
        response = dict()
9✔
145
        response["type"] = "styles"
9✔
146
        serializer = PythonWithURLSerializer()
9✔
147
        export_temps = serializer.serialize(
9✔
148
            doc_db.template.exporttemplate_set.all(),
149
            fields=["file_type", "template_file", "title"],
150
        )
151
        document_styles = serializer.serialize(
9✔
152
            doc_db.template.documentstyle_set.all(),
153
            use_natural_foreign_keys=True,
154
            fields=["title", "slug", "contents", "documentstylefile_set"],
155
        )
156
        document_templates = {}
9✔
157
        for obj in DocumentTemplate.objects.filter(
9✔
158
            Q(user=self.user) | Q(user=None)
159
        ).order_by(F("user").desc(nulls_first=True)):
160
            document_templates[obj.import_id] = {
9✔
161
                "title": obj.title,
162
                "id": obj.id,
163
            }
164

165
        response["styles"] = {
9✔
166
            "export_templates": [obj["fields"] for obj in export_temps],
167
            "document_styles": [obj["fields"] for obj in document_styles],
168
            "document_templates": document_templates,
169
        }
170
        self.send_message(response)
9✔
171

172
    def unfixable(self):
13✔
173
        self.send_document()
×
174

175
    def send_document(self, messages=False, template=False):
13✔
176
        response = dict()
9✔
177
        response["type"] = "doc_data"
9✔
178
        doc_owner = self.session["doc"].owner
9✔
179
        avatars = Avatars()
9✔
180
        response["doc_info"] = {
9✔
181
            "id": self.session["doc"].id,
182
            "is_owner": self.user_info.is_owner,
183
            "access_rights": self.user_info.access_rights,
184
            "path": self.user_info.path,
185
            "owner": {
186
                "id": doc_owner.id,
187
                "name": doc_owner.readable_name,
188
                "username": doc_owner.username,
189
                "avatar": avatars.get_url(doc_owner),
190
                "contacts": [],
191
            },
192
        }
193
        WebsocketConsumer.serialize_content(self.session)
9✔
194
        response["doc"] = {
9✔
195
            "v": self.session["doc"].version,
196
            "content": self.session["doc"].content,
197
            "bibliography": self.session["doc"].bibliography,
198
            "images": {},
199
        }
200
        if template:
9✔
201
            response["doc"]["template"] = {
9✔
202
                "id": self.session["doc"].template.id,
203
                "content": self.session["doc"].template.content,
204
            }
205
        if messages:
9✔
206
            response["m"] = messages
2✔
207
        response["time"] = int(time()) * 1000
9✔
208
        for dimage in DocumentImage.objects.filter(
9✔
209
            document_id=self.session["doc"].id
210
        ):
211
            image = dimage.image
1✔
212
            field_obj = {
1✔
213
                "id": image.id,
214
                "title": dimage.title,
215
                "copyright": dimage.copyright,
216
                "image": image.image.url,
217
                "file_type": image.file_type,
218
                "added": mktime(image.added.timetuple()) * 1000,
219
                "checksum": image.checksum,
220
                "cats": [],
221
            }
222
            if image.thumbnail:
1✔
223
                field_obj["thumbnail"] = image.thumbnail.url
1✔
224
                field_obj["height"] = image.height
1✔
225
                field_obj["width"] = image.width
1✔
226
            response["doc"]["images"][image.id] = field_obj
1✔
227
        if self.user_info.access_rights == "read-without-comments":
9✔
228
            response["doc"]["comments"] = []
×
229
        elif self.user_info.access_rights in ["review", "review-tracked"]:
9✔
230
            # Reviewer should only get his/her own comments
231
            filtered_comments = {}
×
232
            for key, value in list(self.session["doc"].comments.items()):
×
233
                if value["user"] == self.user_info.user.id:
×
234
                    filtered_comments[key] = value
×
235
            response["doc"]["comments"] = filtered_comments
×
236
        else:
237
            response["doc"]["comments"] = self.session["doc"].comments
9✔
238
        for contact in doc_owner.contacts.all():
9✔
239
            contact_object = {
2✔
240
                "id": contact.id,
241
                "name": contact.readable_name,
242
                "username": contact.get_username(),
243
                "avatar": avatars.get_url(contact),
244
                "type": "user",
245
            }
246
            response["doc_info"]["owner"]["contacts"].append(contact_object)
2✔
247
        if self.user_info.is_owner:
9✔
248
            for contact in doc_owner.invites_by.all():
9✔
249
                contact_object = {
1✔
250
                    "id": contact.id,
251
                    "name": contact.username,
252
                    "username": contact.username,
253
                    "avatar": None,
254
                    "type": "userinvite",
255
                }
256
                response["doc_info"]["owner"]["contacts"].append(
1✔
257
                    contact_object
258
                )
259
        response["doc_info"]["session_id"] = self.id
9✔
260
        self.send_message(response)
9✔
261

262
    def reject_message(self, message):
13✔
263
        if message["type"] == "diff":
2✔
264
            self.send_message({"type": "reject_diff", "rid": message["rid"]})
2✔
265

266
    def handle_message(self, message):
13✔
267
        if self.user_info.document_id not in WebsocketConsumer.sessions:
9✔
268
            logger.debug(
×
269
                f"Action:Receiving message for closed document. "
270
                f"URL:{self.endpoint} User:{self.user.id} "
271
                f"ParticipantID:{self.id}"
272
            )
273
            return
×
274
        if message["type"] == "get_document":
9✔
275
            self.send_document()
×
276
        elif (
9✔
277
            message["type"] == "participant_update" and self.can_communicate()
278
        ):
279
            self.handle_participant_update()
×
280
        elif message["type"] == "chat" and self.can_communicate():
9✔
281
            self.handle_chat(message)
1✔
282
        elif message["type"] == "check_version":
9✔
283
            self.check_version(message)
2✔
284
        elif message["type"] == "selection_change":
9✔
285
            self.handle_selection_change(message)
6✔
286
        elif message["type"] == "diff" and self.can_update_document():
9✔
287
            self.handle_diff(message)
9✔
288
        elif message["type"] == "path_change":
2✔
289
            self.handle_path_change(message)
2✔
290

291
    def update_bibliography(self, bibliography_updates):
13✔
292
        for bu in bibliography_updates:
2✔
293
            if "id" not in bu:
2✔
294
                continue
×
295
            id = bu["id"]
2✔
296
            if bu["type"] == "update":
2✔
297
                self.session["doc"].bibliography[id] = bu["reference"]
2✔
298
            elif bu["type"] == "delete":
×
299
                del self.session["doc"].bibliography[id]
×
300

301
    def update_images(self, image_updates):
13✔
302
        for iu in image_updates:
4✔
303
            if "id" not in iu:
4✔
304
                continue
×
305
            id = iu["id"]
4✔
306
            if iu["type"] == "update":
4✔
307
                # Ensure that access rights exist
308
                if not UserImage.objects.filter(
4✔
309
                    image__id=id, owner=self.user_info.user
310
                ).exists():
311
                    continue
×
312
                doc_image = DocumentImage.objects.filter(
4✔
313
                    document_id=self.session["doc"].id, image_id=id
314
                ).first()
315
                if doc_image:
4✔
316
                    doc_image.title = iu["image"]["title"]
×
317
                    doc_image.copyright = iu["image"]["copyright"]
×
318
                    doc_image.save()
×
319
                else:
320
                    DocumentImage.objects.create(
4✔
321
                        document_id=self.session["doc"].id,
322
                        image_id=id,
323
                        title=iu["image"]["title"],
324
                        copyright=iu["image"]["copyright"],
325
                    )
326
            elif iu["type"] == "delete":
×
327
                DocumentImage.objects.filter(
×
328
                    document_id=self.session["doc"].id, image_id=id
329
                ).delete()
330
                for image in Image.objects.filter(id=id):
×
331
                    if image.is_deletable():
×
332
                        image.delete()
×
333

334
    def update_comments(self, comments_updates):
13✔
335
        comments_updates = deepcopy(comments_updates)
2✔
336
        for cd in comments_updates:
2✔
337
            if "id" not in cd:
2✔
338
                # ignore
339
                continue
×
340
            id = cd["id"]
2✔
341
            if cd["type"] == "create":
2✔
342
                self.session["doc"].comments[id] = {
2✔
343
                    "user": cd["user"],
344
                    "username": cd["username"],
345
                    "assignedUser": cd["assignedUser"],
346
                    "assignedUsername": cd["assignedUsername"],
347
                    "date": cd["date"],
348
                    "comment": cd["comment"],
349
                    "isMajor": cd["isMajor"],
350
                    "resolved": cd["resolved"],
351
                }
352
            elif cd["type"] == "delete":
1✔
353
                del self.session["doc"].comments[id]
1✔
354
            elif cd["type"] == "update":
1✔
355
                self.session["doc"].comments[id]["comment"] = cd["comment"]
1✔
356
                if "isMajor" in cd:
1✔
357
                    self.session["doc"].comments[id]["isMajor"] = cd["isMajor"]
1✔
358
                if "assignedUser" in cd and "assignedUsername" in cd:
1✔
359
                    self.session["doc"].comments[id]["assignedUser"] = cd[
1✔
360
                        "assignedUser"
361
                    ]
362
                    self.session["doc"].comments[id]["assignedUsername"] = cd[
1✔
363
                        "assignedUsername"
364
                    ]
365
                if "resolved" in cd:
1✔
366
                    self.session["doc"].comments[id]["resolved"] = cd[
1✔
367
                        "resolved"
368
                    ]
369
            elif cd["type"] == "add_answer":
1✔
370
                if "answers" not in self.session["doc"].comments[id]:
1✔
371
                    self.session["doc"].comments[id]["answers"] = []
1✔
372
                self.session["doc"].comments[id]["answers"].append(
1✔
373
                    {
374
                        "id": cd["answerId"],
375
                        "user": cd["user"],
376
                        "username": cd["username"],
377
                        "date": cd["date"],
378
                        "answer": cd["answer"],
379
                    }
380
                )
381
            elif cd["type"] == "delete_answer":
1✔
382
                answer_id = cd["answerId"]
1✔
383
                for answer in self.session["doc"].comments[id]["answers"]:
1✔
384
                    if answer["id"] == answer_id:
1✔
385
                        self.session["doc"].comments[id]["answers"].remove(
1✔
386
                            answer
387
                        )
388
            elif cd["type"] == "update_answer":
1✔
389
                answer_id = cd["answerId"]
1✔
390
                for answer in self.session["doc"].comments[id]["answers"]:
1✔
391
                    if answer["id"] == answer_id:
1✔
392
                        answer["answer"] = cd["answer"]
1✔
393

394
    def handle_participant_update(self):
13✔
395
        WebsocketConsumer.send_participant_list(self.user_info.document_id)
9✔
396

397
    def handle_chat(self, message):
13✔
398
        chat = {
1✔
399
            "id": str(uuid.uuid4()),
400
            "body": message["body"],
401
            "from": self.user_info.user.id,
402
            "type": "chat",
403
        }
404
        WebsocketConsumer.send_updates(chat, self.user_info.document_id)
1✔
405

406
    def handle_selection_change(self, message):
13✔
407
        if (
6✔
408
            self.user_info.document_id in WebsocketConsumer.sessions
409
            and message["v"] == self.session["doc"].version
410
        ):
411
            WebsocketConsumer.send_updates(
6✔
412
                message,
413
                self.user_info.document_id,
414
                self.id,
415
                self.user_info.user.id,
416
            )
417

418
    def handle_path_change(self, message):
13✔
419
        if (
2✔
420
            self.user_info.document_id in WebsocketConsumer.sessions
421
            and self.user_info.path_object
422
        ):
423
            self.user_info.path_object.path = message["path"]
2✔
424
            self.user_info.path_object.save(
2✔
425
                update_fields=[
426
                    "path",
427
                ]
428
            )
429
            WebsocketConsumer.send_updates(
2✔
430
                message,
431
                self.user_info.document_id,
432
                self.id,
433
                self.user_info.user.id,
434
            )
435

436
    # Checks if the diff only contains changes to comments.
437
    def only_comments(self, message):
13✔
438
        allowed_operations = ["addMark", "removeMark"]
×
439
        only_comment = True
×
440
        if "ds" in message:  # ds = document steps
×
441
            for step in message["ds"]:
×
442
                if not (
×
443
                    step["stepType"] in allowed_operations
444
                    and step["mark"]["type"] == "comment"
445
                ):
446
                    only_comment = False
×
447
        return only_comment
×
448

449
    def handle_diff(self, message):
13✔
450
        pv = message["v"]
9✔
451
        dv = self.session["doc"].version
9✔
452
        logger.debug(
9✔
453
            f"Action:Handling Diff. URL:{self.endpoint} User:{self.user.id} "
454
            f"ParticipantID:{self.id} Client version:{pv} "
455
            f"Server version:{dv} Message:{message}"
456
        )
457
        if (
9✔
458
            self.user_info.access_rights in COMMENT_ONLY
459
            and not self.only_comments(message)
460
        ):
461
            logger.error(
×
462
                f"Action:Received non-comment diff from comment-only "
463
                f"collaborator.Discarding URL:{self.endpoint} "
464
                f"User:{self.user.id} ParticipantID:{self.id}"
465
            )
466
            return
×
467
        if pv == dv:
9✔
468
            if "ds" in message:  # ds = document steps
9✔
469
                updated_node = prosemirror.apply(
9✔
470
                    message["ds"], self.session["node"]
471
                )
472
                if updated_node:
9✔
473
                    self.session["node"] = updated_node
9✔
474
                    self.session["node_updates"] = True
9✔
475
                else:
476
                    self.unfixable()
×
477
                    patch_msg = {
×
478
                        "type": "patch_error",
479
                        "user_id": self.user.id,
480
                    }
481
                    self.send_message(patch_msg)
×
482
                    # Reset collaboration to avoid any data loss issues.
483
                    self.reset_collaboration(
×
484
                        patch_msg, self.user_info.document_id, self.id
485
                    )
486
                    return
×
487
            self.session["doc"].diffs.append(message)
9✔
488
            self.session["doc"].diffs = self.session["doc"].diffs[
9✔
489
                -self.history_length :
490
            ]
491
            self.session["doc"].version += 1
9✔
492
            if "ti" in message:  # ti = title
9✔
493
                self.session["doc"].title = message["ti"][-255:]
7✔
494
            if "cu" in message:  # cu = comment updates
9✔
495
                self.update_comments(message["cu"])
2✔
496
            if "bu" in message:  # bu = bibliography updates
9✔
497
                self.update_bibliography(message["bu"])
2✔
498
            if "iu" in message:  # iu = image updates
9✔
499
                self.update_images(message["iu"])
4✔
500
            if self.session["doc"].version % settings.DOC_SAVE_INTERVAL == 0:
9✔
501
                WebsocketConsumer.save_document(self.user_info.document_id)
3✔
502
            self.confirm_diff(message["rid"])
9✔
503
            WebsocketConsumer.send_updates(
9✔
504
                message,
505
                self.user_info.document_id,
506
                self.id,
507
                self.user_info.user.id,
508
            )
509
        elif pv < dv:
×
510
            if pv + len(self.session["doc"].diffs) >= dv:
×
511
                # We have enough diffs stored to fix it.
512
                number_diffs = pv - dv
×
513
                logger.debug(
×
514
                    f"Action:Resending document diffs. URL:{self.endpoint} "
515
                    f"User:{self.user.id} ParticipantID:{self.id} "
516
                    f"number of messages to be resent:{number_diffs}"
517
                )
518
                messages = self.session["doc"].diffs[number_diffs:]
×
519
                for message in messages:
×
520
                    new_message = message.copy()
×
521
                    new_message["server_fix"] = True
×
522
                    self.send_message(new_message)
×
523
            else:
524
                logger.debug(
×
525
                    f"Action:User is on a very old version of the document. "
526
                    f"URL:{self.endpoint} User:{self.user.id} "
527
                    f"ParticipantID:{self.id}"
528
                )
529
                # Client has a version that is too old to be fixed
530
                self.unfixable()
×
531
                return
×
532
        else:
533
            # Client has a higher version than server. Something is fishy!
534
            logger.debug(
×
535
                f"Action:User has higher document version than server.Fishy! "
536
                f"URL:{self.endpoint} User:{self.user.id} "
537
                f"ParticipantID:{self.id}"
538
            )
539

540
    def check_version(self, message):
13✔
541
        pv = message["v"]
2✔
542
        dv = self.session["doc"].version
2✔
543
        logger.debug(
2✔
544
            f"Action:Checking version of document. URL:{self.endpoint} "
545
            f"User:{self.user.id} ParticipantID:{self.id} "
546
            f"Client document version:{pv} Server document version:{dv}"
547
        )
548
        if pv == dv:
2✔
549
            response = {
1✔
550
                "type": "confirm_version",
551
                "v": pv,
552
            }
553
            self.send_message(response)
1✔
554
            return
1✔
555
        elif pv + len(self.session["doc"].diffs) >= dv:
2✔
556
            number_diffs = pv - dv
2✔
557
            logger.debug(
2✔
558
                f"Action:Resending document diffs. URL:{self.endpoint} "
559
                f"User:{self.user.id} ParticipantID:{self.id}"
560
                f"number of messages to be resent:{number_diffs}"
561
            )
562
            messages = self.session["doc"].diffs[number_diffs:]
2✔
563
            self.send_document(messages)
2✔
564
            return
2✔
565
        else:
566
            logger.debug(
×
567
                f"Action:User is on a very old version of the document. "
568
                f"URL:{self.endpoint} User:{self.user.id} "
569
                f"ParticipantID:{self.id}"
570
            )
571
            # Client has a version that is too old
572
            self.unfixable()
×
573
            return
×
574

575
    def can_update_document(self):
13✔
576
        return self.user_info.access_rights in CAN_UPDATE_DOCUMENT
9✔
577

578
    def can_communicate(self):
13✔
579
        return self.user_info.access_rights in CAN_COMMUNICATE
9✔
580

581
    def disconnect(self, close_code):
13✔
582
        if (
9✔
583
            hasattr(self, "endpoint")
584
            and hasattr(self, "user")
585
            and hasattr(self, "id")
586
        ):
587
            logger.debug(
9✔
588
                f"Action:Closing websocket. URL:{self.endpoint} "
589
                f"User:{self.user.id} ParticipantID:{self.id}"
590
            )
591
        if (
9✔
592
            hasattr(self, "session")
593
            and hasattr(self, "user_info")
594
            and hasattr(self.user_info, "document_id")
595
            and self.user_info.document_id in WebsocketConsumer.sessions
596
            and hasattr(self, "id")
597
            and self.id
598
            in WebsocketConsumer.sessions[self.user_info.document_id][
599
                "participants"
600
            ]
601
        ):
602
            del self.session["participants"][self.id]
9✔
603
            if len(self.session["participants"]) == 0:
9✔
604
                WebsocketConsumer.save_document(self.user_info.document_id)
9✔
605
                del WebsocketConsumer.sessions[self.user_info.document_id]
9✔
606
                logger.debug(
9✔
607
                    f"Action:No participants for the document. "
608
                    f"URL:{self.endpoint} User:{self.user.id}"
609
                )
610
            else:
611
                WebsocketConsumer.send_participant_list(
3✔
612
                    self.user_info.document_id
613
                )
614
        self.close()
9✔
615

616
    @classmethod
13✔
617
    def send_participant_list(cls, document_id):
13✔
618
        if document_id in WebsocketConsumer.sessions:
9✔
619
            avatars = Avatars()
9✔
620
            participant_list = []
9✔
621
            for session_id, waiter in list(
9✔
622
                cls.sessions[document_id]["participants"].items()
623
            ):
624
                access_rights = waiter.user_info.access_rights
9✔
625
                if access_rights not in CAN_COMMUNICATE:
9✔
626
                    continue
×
627
                participant_list.append(
9✔
628
                    {
629
                        "session_id": session_id,
630
                        "id": waiter.user_info.user.id,
631
                        "name": waiter.user_info.user.readable_name,
632
                        "avatar": avatars.get_url(waiter.user_info.user),
633
                    }
634
                )
635
            message = {
9✔
636
                "participant_list": participant_list,
637
                "type": "connections",
638
            }
639
            WebsocketConsumer.send_updates(message, document_id)
9✔
640

641
    @classmethod
13✔
642
    def reset_collaboration(cls, patch_exception_msg, document_id, sender_id):
13✔
643
        logger.debug(
×
644
            f"Action:Resetting collaboration. DocumentID:{document_id} "
645
            f"Patch conflict triggered. ParticipantID:{sender_id} "
646
            f"waiters:{len(cls.sessions[document_id]['participants'])}"
647
        )
648
        for waiter in list(cls.sessions[document_id]["participants"].values()):
×
649
            if waiter.id != sender_id:
×
650
                waiter.unfixable()
×
651
                waiter.send_message(patch_exception_msg)
×
652

653
    @classmethod
13✔
654
    def send_updates(cls, message, document_id, sender_id=None, user_id=None):
13✔
655
        logger.debug(
9✔
656
            f"Action:Sending message to waiters. DocumentID:{document_id} "
657
            f"waiters:{len(cls.sessions[document_id]['participants'])}"
658
        )
659
        for waiter in list(cls.sessions[document_id]["participants"].values()):
9✔
660
            if waiter.id != sender_id:
9✔
661
                access_rights = waiter.user_info.access_rights
9✔
662
                if "comments" in message and len(message["comments"]) > 0:
9✔
663
                    # Filter comments if needed
664
                    if access_rights == "read-without-comments":
×
665
                        # The reader should not receive the comments update, so
666
                        # we remove the comments from the copy of the message
667
                        # sent to the reviewer. We still need to send the rest
668
                        # of the message as it may contain other diff
669
                        # information.
670
                        message = deepcopy(message)
×
671
                        message["comments"] = []
×
672
                    elif (
×
673
                        access_rights in ["review", "review-tracked"]
674
                        and user_id != waiter.user_info.user.id
675
                    ):
676
                        # The reviewer should not receive comments updates from
677
                        # others than themselves, so we remove the comments
678
                        # from the copy of the message sent to the reviewer
679
                        # that are not from them. We still need to send the
680
                        # rest of the message as it may contain other diff
681
                        # information.
682
                        message = deepcopy(message)
×
683
                        message["comments"] = []
×
684
                elif (
9✔
685
                    message["type"] in ["chat", "connections"]
686
                    and access_rights not in CAN_COMMUNICATE
687
                ):
688
                    continue
×
689
                elif (
9✔
690
                    message["type"] == "selection_change"
691
                    and access_rights not in CAN_COMMUNICATE
692
                    and user_id != waiter.user_info.user.id
693
                ):
694
                    continue
×
695
                elif (
9✔
696
                    message["type"] == "path_change"
697
                    and user_id != waiter.user_info.user.id
698
                ):
699
                    continue
×
700
                waiter.send_message(message)
9✔
701

702
    @classmethod
13✔
703
    def serialize_content(cls, session):
13✔
704
        if "node_updates" in session and session["node_updates"]:
9✔
705
            session["doc"].content = prosemirror.to_mini_json(session["node"])
9✔
706
            session["node_updates"] = False
9✔
707

708
    @classmethod
13✔
709
    def save_document(cls, document_id):
13✔
710
        session = cls.sessions[document_id]
9✔
711
        if session["doc"].version == session["last_saved_version"]:
9✔
712
            return
3✔
713
        logger.debug(
9✔
714
            f"Action:Saving document to DB. DocumentID:{session['doc'].id} "
715
            f"Doc version:{session['doc'].version}"
716
        )
717
        cls.serialize_content(session)
9✔
718
        try:
9✔
719
            # this try block is to avoid a db exception
720
            # in case the doc has been deleted from the db
721
            # in fiduswriter the owner of a doc could delete a doc
722
            # while an invited writer is editing the same doc
723
            session["doc"].save(
9✔
724
                update_fields=[
725
                    "title",
726
                    "version",
727
                    "content",
728
                    "diffs",
729
                    "comments",
730
                    "bibliography",
731
                    "updated",
732
                ]
733
            )
734

735
        except DatabaseError as e:
×
736
            expected_msg = "Save with update_fields did not affect any rows."
×
737
            if str(e) == expected_msg:
×
738
                cls.__insert_document(doc=session["doc"])
×
739
            else:
740
                raise e
×
741
        session["last_saved_version"] = session["doc"].version
9✔
742

743
    @classmethod
13✔
744
    def __insert_document(cls, doc: Document) -> None:
13✔
745
        """
746
        Purpose:
747
        during plugin tests we experienced Integrity errors
748
         at the end of tests.
749
        This exception occurs while handling another exception,
750
         so in order to have a clean tests output
751
          we raise the exception in a way we don't output
752
           misleading error messages related to different exceptions
753

754
        :param doc: socket document model instance
755
        :return: None
756
        """
757
        from django.db.utils import IntegrityError
×
758

759
        try:
×
760
            doc.save()
×
761
        except IntegrityError as e:
×
762
            if settings.TESTING:
×
763
                pass
×
764
            else:
765
                raise IntegrityError(
×
766
                    "plugin test error when we try to save a doc already deleted "
767
                    "along with the rest of db data so it "
768
                    "raises an Integrity error: {}".format(e)
769
                ) from None
770

771
    @classmethod
13✔
772
    def save_all_docs(cls):
13✔
773
        for document_id in cls.sessions:
×
774
            cls.save_document(document_id)
×
775

776

777
atexit.register(WebsocketConsumer.save_all_docs)
13✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc