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

fiduswriter / fiduswriter / 24113311951

08 Apr 2026 01:47AM UTC coverage: 86.61% (-0.8%) from 87.363%
24113311951

push

github

web-flow
Merge pull request #1375 from fiduswriter/feature/anonymous-editing

Feature/open editing access, #1359

7775 of 8977 relevant lines covered (86.61%)

5.16 hits per line

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

90.54
fiduswriter/document/models.py
1
import uuid
16✔
2
from django.db import models
16✔
3
from django.db.utils import OperationalError, ProgrammingError
16✔
4
from django.core import checks
16✔
5
from django.conf import settings
16✔
6
from django.contrib.contenttypes.fields import GenericForeignKey
16✔
7
from django.contrib.contenttypes.models import ContentType
16✔
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
16✔
14

15

16
class DocumentTemplate(models.Model):
16✔
17
    title = models.CharField(max_length=255, default="", blank=True)
16✔
18
    import_id = models.CharField(max_length=255, default="", blank=True)
16✔
19
    content = models.JSONField(default=dict)
16✔
20
    doc_version = models.DecimalField(
16✔
21
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
22
    )
23
    user = models.ForeignKey(
16✔
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)
16✔
30
    updated = models.DateTimeField(auto_now=True)
16✔
31
    auto_delete = True
16✔
32

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

36
    def is_deletable(self):
16✔
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
×
51
        return True
1✔
52

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

59
    @classmethod
16✔
60
    def _check_doc_versions(cls, **kwargs):
16✔
61
        try:
16✔
62
            if cls.objects.filter(
16✔
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 []
16✔
76
        except (ProgrammingError, OperationalError):
16✔
77
            # Database has not yet been initialized, so don't throw any error.
78
            return []
16✔
79

80

81
class Document(models.Model):
16✔
82
    title = models.CharField(max_length=255, default="", blank=True)
16✔
83
    path = models.TextField(default="", blank=True)
16✔
84
    content = models.JSONField(default=dict)
16✔
85
    doc_version = models.DecimalField(
16✔
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)
16✔
94
    diffs = models.JSONField(default=list, blank=True)
16✔
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(
16✔
99
        settings.AUTH_USER_MODEL,
100
        related_name="owner",
101
        on_delete=models.deletion.CASCADE,
102
    )
103
    added = models.DateTimeField(auto_now_add=True)
16✔
104
    updated = models.DateTimeField(auto_now=True)
16✔
105
    comments = models.JSONField(default=dict, blank=True)
16✔
106
    bibliography = models.JSONField(default=dict, blank=True)
16✔
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)
16✔
111
    template = models.ForeignKey(
16✔
112
        DocumentTemplate, on_delete=models.deletion.CASCADE
113
    )
114

115
    def __str__(self):
16✔
116
        if len(self.title) > 0:
×
117
            return f"{self.title} ({self.id})"
×
118
        else:
119
            return str(self.id)
×
120

121
    class Meta:
16✔
122
        ordering = ["-id"]
16✔
123

124
    def clean(self, *args, **kwargs):
16✔
125
        if self.comments is None:
12✔
126
            self.comments = "{}"
×
127
        if self.bibliography is None:
12✔
128
            self.bibliography = "{}"
×
129

130
    def save(self, *args, **kwargs):
16✔
131
        self.clean()
12✔
132
        return super().save(*args, **kwargs)
12✔
133

134
    def get_absolute_url(self):
16✔
135
        return "/document/%i/" % self.id
7✔
136

137
    def is_deletable(self):
16✔
138
        reverse_relations = [
2✔
139
            f
140
            for f in self._meta.model._meta.get_fields()
141
            if (f.one_to_many or f.one_to_one)
142
            and f.auto_created
143
            and not f.concrete
144
            and f.name
145
            not in [
146
                "accessright",
147
                "accessrightinvite",
148
                "documentrevision",
149
                "documentimage",
150
            ]
151
        ]
152

153
        for r in reverse_relations:
2✔
154
            if r.remote_field.model.objects.filter(
2✔
155
                **{r.field.name: self}
156
            ).exists():
157
                return False
×
158
        return True
2✔
159

160
    @classmethod
16✔
161
    def check(cls, **kwargs):
16✔
162
        errors = super().check(**kwargs)
16✔
163
        errors.extend(cls._check_doc_versions(**kwargs))
16✔
164
        return errors
16✔
165

166
    @classmethod
16✔
167
    def _check_doc_versions(cls, **kwargs):
16✔
168
        try:
16✔
169
            if len(
16✔
170
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
171
            ):
172
                return [
×
173
                    checks.Warning(
174
                        "Documents need to be upgraded. Please navigate to "
175
                        "/admin/document/document/maintenance/ with a browser "
176
                        "as a superuser and upgrade all documents on this "
177
                        "server.",
178
                        obj=cls,
179
                    )
180
                ]
181
            else:
182
                return []
16✔
183
        except (ProgrammingError, OperationalError):
16✔
184
            # Database has not yet been initialized, so don't throw any error.
185
            return []
16✔
186

187

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

223
# Editor and Reviewer can only comment and not edit document
224
COMMENT_ONLY = ("review", "comment")
16✔
225

226
CAN_UPDATE_DOCUMENT = [
16✔
227
    "write",
228
    "write-tracked",
229
    "review",
230
    "review-tracked",
231
    "comment",
232
]
233

234
# Whether the collaborator is allowed to know about other collaborators
235
# and communicate with them.
236
CAN_COMMUNICATE = ["read", "write", "comment", "write-tracked"]
16✔
237

238

239
class AccessRight(models.Model):
16✔
240
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
16✔
241
    path = models.TextField(default="", blank=True)
16✔
242
    holder_choices = models.Q(app_label="user", model="user") | models.Q(
16✔
243
        app_label="user", model="userinvite"
244
    )
245
    holder_type = models.ForeignKey(
16✔
246
        ContentType, on_delete=models.CASCADE, limit_choices_to=holder_choices
247
    )
248
    holder_id = models.PositiveIntegerField()
16✔
249
    holder_obj = GenericForeignKey("holder_type", "holder_id")
16✔
250
    rights = models.CharField(
16✔
251
        max_length=21, choices=RIGHTS_CHOICES, blank=False
252
    )
253

254
    class Meta:
16✔
255
        unique_together = (("document", "holder_type", "holder_id"),)
16✔
256

257
    def __str__(self):
16✔
258
        return "%(name)s %(rights)s on %(doc_id)d" % {
×
259
            "name": self.holder_obj.readable_name,
260
            "rights": self.rights,
261
            "doc_id": self.document.id,
262
        }
263

264

265
class ShareToken(models.Model):
16✔
266
    token = models.UUIDField(unique=True, default=uuid.uuid4)
16✔
267
    document = models.ForeignKey(Document, on_delete=models.CASCADE)
16✔
268
    rights = models.CharField(choices=RIGHTS_CHOICES, max_length=21)
16✔
269
    created_by = models.ForeignKey(
16✔
270
        settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
271
    )
272
    expires_at = models.DateTimeField(null=True, blank=True)
16✔
273
    note = models.CharField(max_length=255, blank=True, default="")
16✔
274
    created_at = models.DateTimeField(auto_now_add=True)
16✔
275
    is_active = models.BooleanField(default=True)
16✔
276

277
    class Meta:
16✔
278
        unique_together = ("document", "rights", "created_by")
16✔
279

280

281
def revision_filename(instance, filename):
16✔
282
    return f"document-revisions/{instance.pk}.fidus"
1✔
283

284

285
class DocumentRevision(models.Model):
16✔
286
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
16✔
287
    doc_version = models.DecimalField(
16✔
288
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
289
    )
290
    note = models.CharField(max_length=255, default="", blank=True)
16✔
291
    date = models.DateTimeField(auto_now=True)
16✔
292
    file_object = models.FileField(upload_to=revision_filename)
16✔
293
    file_name = models.CharField(max_length=255, default="", blank=True)
16✔
294

295
    def save(self, *args, **kwargs):
16✔
296
        if self.pk is None:
1✔
297
            # We remove the file_object the first time so that we can use the
298
            # pk as the name of the saved revision file.
299
            file_object = self.file_object
1✔
300
            self.file_object = None
1✔
301
            super().save(*args, **kwargs)
1✔
302
            self.file_object = file_object
1✔
303
            kwargs.pop("force_insert", None)
1✔
304
        super().save(*args, **kwargs)
1✔
305

306
    def __str__(self):
16✔
307
        if len(self.note) > 0:
×
308
            return "{note} ({id}) of {doc_id}".format(
×
309
                note=self.note,
310
                id=self.id,
311
                doc_id=self.document.id,
312
            )
313
        else:
314
            return "{id} of {doc_id}".format(
×
315
                id=self.id,
316
                doc_id=self.document.id,
317
            )
318

319
    @classmethod
16✔
320
    def check(cls, **kwargs):
16✔
321
        errors = super().check(**kwargs)
16✔
322
        errors.extend(cls._check_doc_versions(**kwargs))
16✔
323
        return errors
16✔
324

325
    @classmethod
16✔
326
    def _check_doc_versions(cls, **kwargs):
16✔
327
        try:
16✔
328
            if len(
16✔
329
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
330
            ):
331
                return [
×
332
                    checks.Warning(
333
                        "Document revisions need to be upgraded. Please "
334
                        "navigate to /admin/document/document/maintenance/ "
335
                        "with a browser as a superuser and upgrade all "
336
                        "document revisions on this server.",
337
                        obj=cls,
338
                    )
339
                ]
340
            else:
341
                return []
16✔
342
        except (ProgrammingError, OperationalError):
16✔
343
            # Database has not yet been initialized, so don't throw any error.
344
            return []
16✔
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