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

fiduswriter / fiduswriter / 20318081346

17 Dec 2025 09:38PM UTC coverage: 87.002% (-0.03%) from 87.033%
20318081346

push

github

johanneswilm
Make code Python 3.11+ compatible, fixes #1335

6928 of 7963 relevant lines covered (87.0%)

4.73 hits per line

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

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

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

12
FW_DOCUMENT_VERSION = 3.5
14✔
13

14

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

32
    def __str__(self):
14✔
33
        return self.title
1✔
34

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

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

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

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

79

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

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

120
    class Meta:
14✔
121
        ordering = ["-id"]
14✔
122

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

129
    def save(self, *args, **kwargs):
14✔
130
        self.clean()
10✔
131
        return super().save(*args, **kwargs)
10✔
132

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

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

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

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

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

186

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

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

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

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

237

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

253
    class Meta:
14✔
254
        unique_together = (("document", "holder_type", "holder_id"),)
14✔
255

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

263

264
def revision_filename(instance, filename):
14✔
265
    return f"document-revisions/{instance.pk}.fidus"
1✔
266

267

268
class DocumentRevision(models.Model):
14✔
269
    document = models.ForeignKey(Document, on_delete=models.deletion.CASCADE)
14✔
270
    doc_version = models.DecimalField(
14✔
271
        max_digits=3, decimal_places=1, default=FW_DOCUMENT_VERSION
272
    )
273
    note = models.CharField(max_length=255, default="", blank=True)
14✔
274
    date = models.DateTimeField(auto_now=True)
14✔
275
    file_object = models.FileField(upload_to=revision_filename)
14✔
276
    file_name = models.CharField(max_length=255, default="", blank=True)
14✔
277

278
    def save(self, *args, **kwargs):
14✔
279
        if self.pk is None:
1✔
280
            # We remove the file_object the first time so that we can use the
281
            # pk as the name of the saved revision file.
282
            file_object = self.file_object
1✔
283
            self.file_object = None
1✔
284
            super().save(*args, **kwargs)
1✔
285
            self.file_object = file_object
1✔
286
            kwargs.pop("force_insert", None)
1✔
287
        super().save(*args, **kwargs)
1✔
288

289
    def __str__(self):
14✔
290
        if len(self.note) > 0:
×
291
            return "{note} ({id}) of {doc_id}".format(
×
292
                note=self.note,
293
                id=self.id,
294
                doc_id=self.document.id,
295
            )
296
        else:
297
            return "{id} of {doc_id}".format(
×
298
                id=self.id,
299
                doc_id=self.document.id,
300
            )
301

302
    @classmethod
14✔
303
    def check(cls, **kwargs):
14✔
304
        errors = super().check(**kwargs)
14✔
305
        errors.extend(cls._check_doc_versions(**kwargs))
14✔
306
        return errors
14✔
307

308
    @classmethod
14✔
309
    def _check_doc_versions(cls, **kwargs):
14✔
310
        try:
14✔
311
            if len(
14✔
312
                cls.objects.filter(doc_version__lt=str(FW_DOCUMENT_VERSION))
313
            ):
314
                return [
×
315
                    checks.Warning(
316
                        "Document revisions need to be upgraded. Please "
317
                        "navigate to /admin/document/document/maintenance/ "
318
                        "with a browser as a superuser and upgrade all "
319
                        "document revisions on this server.",
320
                        obj=cls,
321
                    )
322
                ]
323
            else:
324
                return []
14✔
325
        except (ProgrammingError, OperationalError):
14✔
326
            # Database has not yet been initialized, so don't throw any error.
327
            return []
14✔
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