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

fiduswriter / fiduswriter / 14399382078

11 Apr 2025 08:51AM UTC coverage: 91.255% (-0.05%) from 91.3%
14399382078

push

github

johanneswilm
Add fallback bibliography/reference render when citeproc crashes

6115 of 6701 relevant lines covered (91.26%)

4.31 hits per line

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

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

8

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

13
from base.helpers.ws import get_url_base
13✔
14

15
from document.helpers.session_user_info import SessionUserInfo
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
        # The correct server for the document may have changed since the client
54
        # received its initial connection information. For example, because the
55
        # number of servers has increased (all servers need to restart to have
56
        # the right setting).
57
        if len(settings.PORTS) < 2:
9✔
58
            return False
9✔
59
        # We compare the internal port
60
        actual_port = self.scope["server"][1]
×
61
        expected_conn = settings.PORTS[self.document_id % len(settings.PORTS)]
×
62
        expected_port = (
×
63
            expected_conn["internal"]
64
            if isinstance(expected_conn, dict)
65
            else expected_conn
66
        )
67
        if actual_port != expected_port:
×
68
            # Redirect to the correct URL
69
            self.init()
×
70
            origin = (
×
71
                dict(self.scope["headers"]).get(b"origin", b"").decode("utf-8")
72
            )
73
            expected = get_url_base(origin, expected_conn)
×
74
            logger.debug(f"Redirecting from {actual_port} to {expected}.")
×
75
            self.send_message({"type": "redirect", "base": expected})
×
76
            self.do_close()
×
77
            return True
×
78
        return False
×
79

80
    def confirm_diff(self, rid):
13✔
81
        response = {"type": "confirm_diff", "rid": rid}
9✔
82
        self.send_message(response)
9✔
83

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

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

173
        response["styles"] = {
9✔
174
            "export_templates": [obj["fields"] for obj in export_temps],
175
            "document_styles": [obj["fields"] for obj in document_styles],
176
            "document_templates": document_templates,
177
        }
178
        self.send_message(response)
9✔
179

180
    def unfixable(self):
13✔
181
        self.send_document()
×
182

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

270
    def reject_message(self, message):
13✔
271
        if message["type"] == "diff":
1✔
272
            self.send_message({"type": "reject_diff", "rid": message["rid"]})
1✔
273

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

299
    def update_bibliography(self, bibliography_updates):
13✔
300
        for bu in bibliography_updates:
2✔
301
            if "id" not in bu:
2✔
302
                continue
×
303
            id = bu["id"]
2✔
304
            if bu["type"] == "update":
2✔
305
                self.session["doc"].bibliography[id] = bu["reference"]
2✔
306
            elif bu["type"] == "delete":
×
307
                del self.session["doc"].bibliography[id]
×
308

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

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

402
    def handle_participant_update(self):
13✔
403
        WebsocketConsumer.send_participant_list(self.user_info.document_id)
9✔
404

405
    def handle_chat(self, message):
13✔
406
        chat = {
1✔
407
            "id": str(uuid.uuid4()),
408
            "body": message["body"],
409
            "from": self.user_info.user.id,
410
            "type": "chat",
411
        }
412
        WebsocketConsumer.send_updates(chat, self.user_info.document_id)
1✔
413

414
    def handle_selection_change(self, message):
13✔
415
        if (
7✔
416
            self.user_info.document_id in WebsocketConsumer.sessions
417
            and message["v"] == self.session["doc"].version
418
        ):
419
            WebsocketConsumer.send_updates(
7✔
420
                message,
421
                self.user_info.document_id,
422
                self.id,
423
                self.user_info.user.id,
424
            )
425

426
    def handle_path_change(self, message):
13✔
427
        if (
2✔
428
            self.user_info.document_id in WebsocketConsumer.sessions
429
            and self.user_info.path_object
430
        ):
431
            self.user_info.path_object.path = message["path"]
2✔
432
            self.user_info.path_object.save(
2✔
433
                update_fields=[
434
                    "path",
435
                ]
436
            )
437
            WebsocketConsumer.send_updates(
2✔
438
                message,
439
                self.user_info.document_id,
440
                self.id,
441
                self.user_info.user.id,
442
            )
443

444
    # Checks if the diff only contains changes to comments.
445
    def only_comments(self, message):
13✔
446
        allowed_operations = ["addMark", "removeMark"]
×
447
        only_comment = True
×
448
        if "ds" in message:  # ds = document steps
×
449
            for step in message["ds"]:
×
450
                if not (
×
451
                    step["stepType"] in allowed_operations
452
                    and step["mark"]["type"] == "comment"
453
                ):
454
                    only_comment = False
×
455
        return only_comment
×
456

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

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

583
    def can_update_document(self):
13✔
584
        return self.user_info.access_rights in CAN_UPDATE_DOCUMENT
9✔
585

586
    def can_communicate(self):
13✔
587
        return self.user_info.access_rights in CAN_COMMUNICATE
9✔
588

589
    def disconnect(self, code):
13✔
590
        if (
9✔
591
            hasattr(self, "endpoint")
592
            and hasattr(self, "user")
593
            and hasattr(self, "id")
594
        ):
595
            logger.debug(
9✔
596
                f"Action:Closing websocket. URL:{self.endpoint} "
597
                f"User:{self.user.id} ParticipantID:{self.id}"
598
            )
599
        if (
9✔
600
            hasattr(self, "session")
601
            and hasattr(self, "user_info")
602
            and hasattr(self.user_info, "document_id")
603
            and self.user_info.document_id in WebsocketConsumer.sessions
604
            and hasattr(self, "id")
605
            and self.id
606
            in WebsocketConsumer.sessions[self.user_info.document_id][
607
                "participants"
608
            ]
609
        ):
610
            del self.session["participants"][self.id]
9✔
611
            if len(self.session["participants"]) == 0:
9✔
612
                WebsocketConsumer.save_document(self.user_info.document_id)
9✔
613
                del WebsocketConsumer.sessions[self.user_info.document_id]
9✔
614
                logger.debug(
9✔
615
                    f"Action:No participants for the document. "
616
                    f"URL:{self.endpoint} User:{self.user.id}"
617
                )
618
            else:
619
                try:
3✔
620
                    WebsocketConsumer.send_participant_list(
3✔
621
                        self.user_info.document_id
622
                    )
623
                except autobahn.exception.Disconnected:
×
624
                    pass
×
625
        self.close()
9✔
626

627
    @classmethod
13✔
628
    def send_participant_list(cls, document_id):
13✔
629
        if document_id in WebsocketConsumer.sessions:
9✔
630
            avatars = Avatars()
9✔
631
            participant_list = []
9✔
632
            for session_id, waiter in list(
9✔
633
                cls.sessions[document_id]["participants"].items()
634
            ):
635
                access_rights = waiter.user_info.access_rights
9✔
636
                if access_rights not in CAN_COMMUNICATE:
9✔
637
                    continue
×
638
                participant_list.append(
9✔
639
                    {
640
                        "session_id": session_id,
641
                        "id": waiter.user_info.user.id,
642
                        "name": waiter.user_info.user.readable_name,
643
                        "avatar": avatars.get_url(waiter.user_info.user),
644
                    }
645
                )
646
            message = {
9✔
647
                "participant_list": participant_list,
648
                "type": "connections",
649
            }
650
            WebsocketConsumer.send_updates(message, document_id)
9✔
651

652
    @classmethod
13✔
653
    def reset_collaboration(cls, patch_exception_msg, document_id, sender_id):
13✔
654
        logger.debug(
×
655
            f"Action:Resetting collaboration. DocumentID:{document_id} "
656
            f"Patch conflict triggered. ParticipantID:{sender_id} "
657
            f"waiters:{len(cls.sessions[document_id]['participants'])}"
658
        )
659
        for waiter in list(cls.sessions[document_id]["participants"].values()):
×
660
            if waiter.id != sender_id:
×
661
                waiter.unfixable()
×
662
                waiter.send_message(patch_exception_msg)
×
663

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

713
    @classmethod
13✔
714
    def serialize_content(cls, session):
13✔
715
        if "node_updates" in session and session["node_updates"]:
9✔
716
            session["doc"].content = prosemirror.to_mini_json(session["node"])
9✔
717
            session["node_updates"] = False
9✔
718

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

746
        except DatabaseError as e:
×
747
            expected_msg = "Save with update_fields did not affect any rows."
×
748
            if str(e) == expected_msg:
×
749
                cls.__insert_document(doc=session["doc"])
×
750
            else:
751
                raise e
×
752
        session["last_saved_version"] = session["doc"].version
9✔
753

754
    @classmethod
13✔
755
    def __insert_document(cls, doc: Document) -> None:
13✔
756
        """
757
        Purpose:
758
        during plugin tests we experienced Integrity errors
759
         at the end of tests.
760
        This exception occurs while handling another exception,
761
         so in order to have a clean tests output
762
          we raise the exception in a way we don't output
763
           misleading error messages related to different exceptions
764

765
        :param doc: socket document model instance
766
        :return: None
767
        """
768
        from django.db.utils import IntegrityError
×
769

770
        try:
×
771
            doc.save()
×
772
        except IntegrityError as e:
×
773
            if settings.TESTING:
×
774
                pass
×
775
            else:
776
                raise IntegrityError(
×
777
                    "plugin test error when we try to save a doc already deleted "
778
                    "along with the rest of db data so it "
779
                    "raises an Integrity error: {}".format(e)
780
                ) from None
781

782
    @classmethod
13✔
783
    def save_all_docs(cls):
13✔
784
        for document_id in cls.sessions:
×
785
            cls.save_document(document_id)
×
786

787

788
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