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

fiduswriter / fiduswriter / 25827314658

13 May 2026 09:25PM UTC coverage: 89.125% (+0.03%) from 89.092%
25827314658

push

github

johanneswilm
allow deletion of encrypted documents anmd display their title correctly

10597 of 11890 relevant lines covered (89.13%)

5.44 hits per line

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

92.07
fiduswriter/document/models.py
1
import uuid
20✔
2
from django.db import models
20✔
3
from django.db.utils import OperationalError, ProgrammingError
20✔
4
from django.core import checks
20✔
5
from django.conf import settings
20✔
6
from django.contrib.contenttypes.fields import GenericForeignKey
20✔
7
from django.contrib.contenttypes.models import ContentType
20✔
8

9
# FW_DOCUMENT_VERSION:
10
# Also defined in frontend
11
# document/static/js/modules/schema/index.js
12

13
FW_DOCUMENT_VERSION = 3.5
20✔
14

15

16
class DocumentTemplate(models.Model):
20✔
17
    title = models.TextField(default="", blank=True)
20✔
18
    import_id = models.CharField(max_length=255, default="", blank=True)
20✔
19
    content = models.JSONField(default=dict)
20✔
20
    doc_version = models.DecimalField(
20✔
21
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
22
    )
23
    user = models.ForeignKey(
20✔
24
        settings.AUTH_USER_MODEL,
25
        null=True,
26
        blank=True,
27
        on_delete=models.deletion.CASCADE,
28
    )
29
    added = models.DateTimeField(auto_now_add=True)
20✔
30
    updated = models.DateTimeField(auto_now=True)
20✔
31
    auto_delete = True
20✔
32

33
    def __str__(self):
20✔
34
        return self.title
1✔
35

36
    def is_deletable(self):
20✔
37
        reverse_relations = [
1✔
38
            f
39
            for f in self._meta.model._meta.get_fields()
40
            if (f.one_to_many or f.one_to_one)
41
            and f.auto_created
42
            and not f.concrete
43
            and f.name not in ["documentstyle", "exporttemplate"]
44
        ]
45

46
        for r in reverse_relations:
1✔
47
            if r.remote_field.model.objects.filter(
1✔
48
                **{r.field.name: self}
49
            ).exists():
50
                return False
1✔
51
        return True
1✔
52

53
    @classmethod
20✔
54
    def check(cls, **kwargs):
20✔
55
        errors = super().check(**kwargs)
20✔
56
        errors.extend(cls._check_doc_versions(**kwargs))
20✔
57
        return errors
20✔
58

59
    @classmethod
20✔
60
    def _check_doc_versions(cls, **kwargs):
20✔
61
        try:
20✔
62
            if cls.objects.filter(
20✔
63
                doc_version__lt=str(FW_DOCUMENT_VERSION)
64
            ).exists():
65
                return [
×
66
                    checks.Warning(
67
                        "Document templates need to be upgraded. Please "
68
                        "navigate to /admin/document/document/maintenance/ "
69
                        "with a browser as a superuser and upgrade all "
70
                        "document templates on this server.",
71
                        obj=cls,
72
                    )
73
                ]
74
            else:
75
                return []
20✔
76
        except (ProgrammingError, OperationalError):
20✔
77
            # Database has not yet been initialized, so don't throw any error.
78
            return []
20✔
79

80

81
class Document(models.Model):
20✔
82
    title = models.TextField(default="", blank=True)
20✔
83
    path = models.TextField(default="", blank=True)
20✔
84
    content = models.JSONField(default=dict)
20✔
85
    doc_version = models.DecimalField(
20✔
86
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
87
    )
88
    # The doc_version is the version of the data format in the content field.
89
    # We upgrade the content field in JavaScript and not migrations so that
90
    # the same code can be used for migrations and for importing old fidus
91
    # files that are being uploaded. This field is only used for upgrading data
92
    # and is therefore not handed to the editor or document overview page.
93
    version = models.PositiveIntegerField(default=0)
20✔
94
    diffs = models.JSONField(default=list, blank=True)
20✔
95
    # The last few diffs that were received and approved. The number of stored
96
    # diffs should always be equivalent to or more than all the diffs since the
97
    # last full save of the document.
98
    owner = models.ForeignKey(
20✔
99
        settings.AUTH_USER_MODEL,
100
        related_name="owner",
101
        on_delete=models.deletion.CASCADE,
102
    )
103
    added = models.DateTimeField(auto_now_add=True)
20✔
104
    updated = models.DateTimeField(auto_now=True)
20✔
105
    comments = models.JSONField(default=dict, blank=True)
20✔
106
    bibliography = models.JSONField(default=dict, blank=True)
20✔
107
    # Whether or not document is listed on document overview list page.
108
    # True by default and for all normal documents. Can be set to False when
109
    # documents are added in plugins that list these documents somewhere else.
110
    listed = models.BooleanField(default=True)
20✔
111
    template = models.ForeignKey(
20✔
112
        DocumentTemplate, on_delete=models.deletion.CASCADE
113
    )
114
    e2ee = models.BooleanField(default=False)
20✔
115
    e2ee_salt = models.BinaryField(max_length=16, null=True, blank=True)
20✔
116
    e2ee_iterations = models.PositiveIntegerField(default=600000)
20✔
117
    # For E2EE documents: the document version at which the last encrypted
118
    # snapshot was saved. Diffs stored in `diffs` cover snapshot_version →
119
    # version. Null for non-E2EE documents.
120
    e2ee_snapshot_version = models.PositiveIntegerField(null=True, blank=True)
20✔
121

122
    def __str__(self):
20✔
123
        if len(self.title) > 0:
1✔
124
            return f"{self.title} ({self.id})"
×
125
        else:
126
            return str(self.id)
1✔
127

128
    class Meta:
20✔
129
        ordering = ["-id"]
20✔
130

131
    def clean(self, *args, **kwargs):
20✔
132
        if self.comments is None:
16✔
133
            self.comments = "{}"
×
134
        if self.bibliography is None:
16✔
135
            self.bibliography = "{}"
×
136

137
    def save(self, *args, **kwargs):
20✔
138
        self.clean()
16✔
139
        return super().save(*args, **kwargs)
16✔
140

141
    def get_absolute_url(self):
20✔
142
        return "/document/%i/" % self.id
8✔
143

144
    def is_deletable(self):
20✔
145
        reverse_relations = [
3✔
146
            f
147
            for f in self._meta.model._meta.get_fields()
148
            if (f.one_to_many or f.one_to_one)
149
            and f.auto_created
150
            and not f.concrete
151
            and f.name
152
            not in [
153
                "accessright",
154
                "accessrightinvite",
155
                "documentrevision",
156
                "documentimage",
157
                "encryption_keys",
158
                "sharetoken",
159
                "encrypted_images",
160
            ]
161
        ]
162

163
        for r in reverse_relations:
3✔
164
            if r.remote_field.model.objects.filter(
×
165
                **{r.field.name: self}
166
            ).exists():
167
                return False
×
168
        return True
3✔
169

170
    @classmethod
20✔
171
    def check(cls, **kwargs):
20✔
172
        errors = super().check(**kwargs)
20✔
173
        errors.extend(cls._check_doc_versions(**kwargs))
20✔
174
        return errors
20✔
175

176
    @classmethod
20✔
177
    def _check_doc_versions(cls, **kwargs):
20✔
178
        try:
20✔
179
            if len(
20✔
180
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
181
            ):
182
                return [
×
183
                    checks.Warning(
184
                        "Documents need to be upgraded. Please navigate to "
185
                        "/admin/document/document/maintenance/ with a browser "
186
                        "as a superuser and upgrade all documents on this "
187
                        "server.",
188
                        obj=cls,
189
                    )
190
                ]
191
            else:
192
                return []
20✔
193
        except (ProgrammingError, OperationalError):
20✔
194
            # Database has not yet been initialized, so don't throw any error.
195
            return []
20✔
196

197

198
RIGHTS_CHOICES = (
20✔
199
    ("write", "Writer"),
200
    # Can write content and can read+write comments.
201
    # Can chat with collaborators.
202
    # Has read access to revisions.
203
    ("write-tracked", "Write with tracked changes"),
204
    # Can write tracked content and can read/write comments.
205
    # Cannot turn off tracked changes.
206
    # Can chat with collaborators.
207
    # Has read access to revisions.
208
    ("comment", "Commentator"),
209
    # Can read content and can read+write comments.
210
    # Can chat with collaborators.
211
    # Has read access to revisions.
212
    ("review-tracked", "Reviewer who can write with tracked changes"),
213
    # Can write tracked content and can read/write his own comments.
214
    # Cannot turn off tracked changes.
215
    # Cannot chat with collaborators.
216
    # Has no access to revisions.
217
    ("review", "Reviewer"),
218
    # Can read the content and can read/write his own comments.
219
    # Comments by users with this access right only show the user's
220
    # numeric ID, not their username.
221
    # Cannot chat with collaborators nor see that they are connected.
222
    # Has no access to revisions.
223
    ("read", "Reader"),
224
    # Can read content, including comments
225
    # Can chat with collaborators.
226
    # Has read access to revisions.
227
    ("read-without-comments", "Reader without comment access"),
228
    # Can read content, but not the comments.
229
    # Cannot chat with collaborators.
230
    # Has no access to revisions.
231
)
232

233
E2EE_ALLOWED_RIGHTS = ["write", "read-without-comments", "read"]
20✔
234

235
# Editor and Reviewer can only comment and not edit document
236
COMMENT_ONLY = ("review", "comment")
20✔
237

238
CAN_UPDATE_DOCUMENT = [
20✔
239
    "write",
240
    "write-tracked",
241
    "review",
242
    "review-tracked",
243
    "comment",
244
]
245

246
# Whether the collaborator is allowed to know about other collaborators
247
# and communicate with them.
248
CAN_COMMUNICATE = ["read", "write", "comment", "write-tracked"]
20✔
249

250

251
class AccessRight(models.Model):
20✔
252
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
20✔
253
    path = models.TextField(default="", blank=True)
20✔
254
    holder_choices = models.Q(app_label="user", model="user") | models.Q(
20✔
255
        app_label="user", model="userinvite"
256
    )
257
    holder_type = models.ForeignKey(
20✔
258
        ContentType, on_delete=models.CASCADE, limit_choices_to=holder_choices
259
    )
260
    holder_id = models.PositiveIntegerField()
20✔
261
    holder_obj = GenericForeignKey("holder_type", "holder_id")
20✔
262
    rights = models.CharField(
20✔
263
        max_length=21, choices=RIGHTS_CHOICES, blank=False
264
    )
265

266
    class Meta:
20✔
267
        unique_together = (("document", "holder_type", "holder_id"),)
20✔
268

269
    def __str__(self):
20✔
270
        return "%(name)s %(rights)s on %(doc_id)d" % {
×
271
            "name": self.holder_obj.readable_name,
272
            "rights": self.rights,
273
            "doc_id": self.document.id,
274
        }
275

276

277
class ShareToken(models.Model):
20✔
278
    token = models.UUIDField(unique=True, default=uuid.uuid4)
20✔
279
    document = models.ForeignKey(Document, on_delete=models.CASCADE)
20✔
280
    rights = models.CharField(choices=RIGHTS_CHOICES, max_length=21)
20✔
281
    created_by = models.ForeignKey(
20✔
282
        settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
283
    )
284
    expires_at = models.DateTimeField(null=True, blank=True)
20✔
285
    note = models.CharField(max_length=255, blank=True, default="")
20✔
286
    created_at = models.DateTimeField(auto_now_add=True)
20✔
287
    is_active = models.BooleanField(default=True)
20✔
288

289
    class Meta:
20✔
290
        unique_together = ("document", "rights", "created_by")
20✔
291

292

293
def revision_filename(instance, filename):
20✔
294
    return f"document-revisions/{instance.pk}.fidus"
1✔
295

296

297
class DocumentEncryptionKey(models.Model):
20✔
298
    document = models.ForeignKey(
20✔
299
        Document,
300
        on_delete=models.CASCADE,
301
        related_name="encryption_keys",
302
    )
303
    holder = models.ForeignKey(
20✔
304
        settings.AUTH_USER_MODEL,
305
        on_delete=models.CASCADE,
306
        related_name="document_encryption_keys",
307
    )
308
    # The document encryption key, encrypted. Which key encrypts it
309
    # depends on `encrypted_with_master_key`:
310
    # - True:  encrypted with the owner's master key (MK)
311
    # - False: encrypted with the recipient's public key (PK) via ECDH
312
    encrypted_key = models.TextField()  # Base64
20✔
313
    encrypted_with_master_key = models.BooleanField(default=True)
20✔
314
    created_at = models.DateTimeField(auto_now_add=True)
20✔
315
    updated_at = models.DateTimeField(auto_now=True)
20✔
316

317
    class Meta:
20✔
318
        unique_together = (("document", "holder"),)
20✔
319

320
    def __str__(self):
20✔
321
        return f"DEK for doc {self.document_id} / {self.holder.readable_name}"
×
322

323

324
class DocumentRevision(models.Model):
20✔
325
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
20✔
326
    doc_version = models.DecimalField(
20✔
327
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
328
    )
329
    note = models.CharField(max_length=255, default="", blank=True)
20✔
330
    date = models.DateTimeField(auto_now=True)
20✔
331
    file_object = models.FileField(upload_to=revision_filename)
20✔
332
    file_name = models.CharField(max_length=255, default="", blank=True)
20✔
333

334
    def save(self, *args, **kwargs):
20✔
335
        if self.pk is None:
1✔
336
            # We remove the file_object the first time so that we can use the
337
            # pk as the name of the saved revision file.
338
            file_object = self.file_object
1✔
339
            self.file_object = None
1✔
340
            super().save(*args, **kwargs)
1✔
341
            self.file_object = file_object
1✔
342
            kwargs.pop("force_insert", None)
1✔
343
        super().save(*args, **kwargs)
1✔
344

345
    def __str__(self):
20✔
346
        if len(self.note) > 0:
×
347
            return "{note} ({id}) of {doc_id}".format(
×
348
                note=self.note,
349
                id=self.id,
350
                doc_id=self.document.id,
351
            )
352
        else:
353
            return "{id} of {doc_id}".format(
×
354
                id=self.id,
355
                doc_id=self.document.id,
356
            )
357

358
    @classmethod
20✔
359
    def check(cls, **kwargs):
20✔
360
        errors = super().check(**kwargs)
20✔
361
        errors.extend(cls._check_doc_versions(**kwargs))
20✔
362
        return errors
20✔
363

364
    @classmethod
20✔
365
    def _check_doc_versions(cls, **kwargs):
20✔
366
        try:
20✔
367
            if len(
20✔
368
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
369
            ):
370
                return [
×
371
                    checks.Warning(
372
                        "Document revisions need to be upgraded. Please "
373
                        "navigate to /admin/document/document/maintenance/ "
374
                        "with a browser as a superuser and upgrade all "
375
                        "document revisions on this server.",
376
                        obj=cls,
377
                    )
378
                ]
379
            else:
380
                return []
20✔
381
        except (ProgrammingError, OperationalError):
20✔
382
            # Database has not yet been initialized, so don't throw any error.
383
            return []
20✔
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

© 2026 Coveralls, Inc