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

zestedesavoir / zds-site / 11020965627

24 Sep 2024 07:56PM UTC coverage: 88.938% (+0.01%) from 88.928%
11020965627

push

github

web-flow
Ajoute des formulaires dédiés à l'introduction et la conclusion (#6642)

4738 of 5930 branches covered (79.9%)

107 of 110 new or added lines in 4 files covered. (97.27%)

1 existing line in 1 file now uncovered.

16740 of 18822 relevant lines covered (88.94%)

1.89 hits per line

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

87.07
/zds/tutorialv2/models/versioned.py
1
import contextlib
3✔
2
import copy
3✔
3
from pathlib import Path
3✔
4

5
from zds import json_handler
3✔
6
from git import Repo
3✔
7
import os
3✔
8
import shutil
3✔
9
import codecs
3✔
10

11
from django.conf import settings
3✔
12
from django.core.exceptions import PermissionDenied
3✔
13
from django.urls import reverse
3✔
14
from django.utils.translation import gettext_lazy as _
3✔
15
from django.template.loader import render_to_string
3✔
16

17
from zds.tutorialv2.models.mixins import TemplatableContentModelMixin
3✔
18
from zds.tutorialv2.models import CONTENT_TYPES_REQUIRING_VALIDATION
3✔
19
from zds.tutorialv2.utils import default_slug_pool, export_content, get_commit_author, InvalidOperationError
3✔
20
from zds.tutorialv2.utils import get_blob
3✔
21
from zds.utils.validators import InvalidSlugError, check_slug
3✔
22
from zds.utils.misc import compute_hash
3✔
23
from zds.utils.templatetags.emarkdown import emarkdown
3✔
24
from zds.utils.uuslug_wrapper import slugify
3✔
25

26

27
class Container:
3✔
28
    """
29
    A container, which can have sub-Containers or Extracts.
30

31
    A Container has a title, a introduction and a conclusion, a parent (which can be None) and a position into this
32
    parent (which is 1 by default).
33

34
    It also has a tree depth.
35

36
    A container could be either a tutorial/article/opinion, a part or a chapter.
37
    """
38

39
    title = ""
3✔
40
    slug = ""
3✔
41
    introduction = None
3✔
42
    conclusion = None
3✔
43
    parent = None
3✔
44
    ready_to_publish = True  # By default, so that we do not need to migrate "non partial publication ready contents"
3✔
45
    position_in_parent = 1
3✔
46
    children = []
3✔
47
    children_dict = {}
3✔
48
    slug_pool = {}
3✔
49

50
    def __init__(self, title, slug="", parent=None, position_in_parent=1):
3✔
51
        """Initialize the data model that will handle the dialog with raw versionned data at level container.
52

53
        :param title: container title (str)
54
        :param slug: container slug (basicaly slugify(title))
55
        :param parent: container parent (None if this container is the root container)
56
        :param position_in_parent: the display order
57
        :return:
58
        """
59
        self.title = title
3✔
60
        self.slug = slug
3✔
61
        self.parent = parent
3✔
62
        self.position_in_parent = position_in_parent
3✔
63

64
        self.children = []  # even if you want, do NOT remove this line
3✔
65
        self.children_dict = {}
3✔
66

67
        self.slug_pool = default_slug_pool()
3✔
68

69
    def __str__(self):
3✔
70
        return f"<Conteneur '{self.title}'>"
×
71

72
    def __copy__(self):
3✔
73
        cpy = self.__class__(self.title, self.slug, self.parent, self.position_in_parent)
3✔
74
        cpy.children_dict = copy.copy(self.children_dict)
3✔
75
        cpy.children = copy.copy(self.children)
3✔
76
        cpy.slug_pool = copy.deepcopy(self.slug_pool)
3✔
77
        cpy.ready_to_publish = self.ready_to_publish
3✔
78
        cpy.introduction = self.introduction
3✔
79
        cpy.conclusion = self.conclusion
3✔
80
        return cpy
3✔
81

82
    def get_url_path(self, base_url=""):
3✔
83
        """
84
        Return the path to the container for use in URLs.
85
        Preprend with ``base_url`` if specified.
86
        """
87
        if self.is_top_container():
1✔
88
            return base_url
1✔
89
        else:
90
            fragments = []
1✔
91
            current = self
1✔
92
            while current is not None and not current.is_top_container():
1✔
93
                fragments.append(current.slug)
1✔
94
                current = current.parent
1✔
95
            fragments_reversed = reversed(fragments)
1✔
96
            path = f"{'/'.join(fragments_reversed)}/"
1✔
97
            return base_url + path
1✔
98

99
    def get_list_of_containers(self):
3✔
100
        """
101
        Return a flat list of containers following the reading order. Extracts are not included.
102
        Example, if called on the top container: [VersionedContent, Part1, Chapter1, Chapter2, Chapter3, Part2, ...]
103
        """
104
        reading_list = [self]
1✔
105
        if not self.has_extracts():
1✔
106
            for child in self.children:
1✔
107
                reading_list.extend(child.get_list_of_containers())
1✔
108
        return reading_list
1✔
109

110
    def has_extracts(self):
3✔
111
        """Note: This function relies on the fact that every child has the
112
        same type.
113

114
        :return: ``True`` if the container contains extracts, ``False`` otherwise.
115
        :rtype: bool
116
        """
117
        if len(self.children) == 0:
3✔
118
            return False
3✔
119
        return isinstance(self.children[0], Extract)
3✔
120

121
    def has_sub_containers(self):
3✔
122
        """Note: this function relies on the fact that every child has the
123
        same type.
124

125
        :return: ``True`` if the container contains other containers, ``False`` otherwise.
126
        :rtype: bool
127
        """
128
        if len(self.children) == 0:
3✔
129
            return False
3✔
130
        return isinstance(self.children[0], Container)
3✔
131

132
    def get_last_child_position(self):
3✔
133
        """
134
        :return: the position of the last child
135
        :type: int
136
        """
137
        return len(self.children)
3✔
138

139
    def get_tree_depth(self):
3✔
140
        """Return the depth where this container is found.
141

142
        The tree depth of a container is the number of parents of that
143
        container. It is always lower that 3 because a nested
144
        container can have at most two parents.
145

146
        Keep in mind that extracts can reach a depth of 3 in the
147
        document tree since there are leaves. Containers are not
148
        leaves.
149

150
        :return: Tree depth
151
        :rtype: int
152
        """
153
        depth = 0
3✔
154
        current = self
3✔
155
        while current.parent is not None:
3✔
156
            current = current.parent
3✔
157
            depth += 1
3✔
158
        return depth
3✔
159

160
    def get_tree_level(self):
3✔
161
        """Return the level in the tree of this container, i.e the depth of its deepest child.
162

163
        :return: tree level
164
        :rtype: int
165
        """
166

167
        if len(self.children) == 0:
3✔
168
            return 1
3✔
169
        elif isinstance(self.children[0], Extract):
1✔
170
            return 2
1✔
171
        else:
172
            return 1 + max(i.get_tree_level() for i in self.children)
1✔
173

174
    def has_child_with_path(self, child_path):
3✔
175
        """Return ``True`` if this container has a child matching the given
176
        full path.
177

178
        :param child_path: the full path (/maincontainer/subc1/subc2/childslug) we want to check
179
        :return: ``True`` if the child is found, ``False`` otherwise
180
        :rtype: bool
181

182
        """
183
        if self.get_path(True) not in child_path:
1✔
184
            return False
1✔
185
        return child_path.replace(self.get_path(True), "").replace("/", "") in self.children_dict
1✔
186

187
    def is_top_container(self) -> bool:
3✔
188
        return self.parent is None
3✔
189

190
    def top_container(self):
3✔
191
        current = self
3✔
192
        while not current.is_top_container():
3✔
193
            current = current.parent
3✔
194
        return current
3✔
195

196
    def get_unique_slug(self, title):
3✔
197
        """Generate a slug from the title and check if it is already in slug
198
        pool. If true, add a "-x" to the end, where "x" is a number
199
        starting from 1. When generated, it is added to the slug pool.
200

201
        Note that the slug cannot be larger than
202
        ``settings.ZDS_APP['content']['max_slug_size']``, due to maximum
203
        file size limitation.
204

205
        :param title: title from which the slug is generated (with ``slugify()``)
206
        :return: the unique slug
207
        :rtype: str
208
        """
209
        base = slugify(title)
3✔
210

211
        if not check_slug(base):
3!
212
            raise InvalidSlugError(base, source=title)
×
213

214
        max_slug_size = settings.ZDS_APP["content"]["maximum_slug_size"]
3✔
215

216
        # Slugs may look like `some-article-title-1234`.
217
        max_suffix_digit_count = 4
3✔
218

219
        if len(base) > max_slug_size - 1 - max_suffix_digit_count:
3!
220
            # There is a `- 1` because of the dash between the slug
221
            # itself and the numeric suffix.
222
            base = base[:max_slug_size] - 1 - max_suffix_digit_count
×
223

224
        if base not in self.slug_pool:
3✔
225
            self.slug_pool[base] = 1
3✔
226
            return base
3✔
227

228
        n = self.slug_pool[base]
1✔
229
        while True:
230
            new_slug = base + "-" + str(n)
1✔
231
            self.slug_pool[base] += 1
1✔
232
            if new_slug not in self.slug_pool:
1!
233
                self.slug_pool[new_slug] = 1
1✔
234
                return new_slug
1✔
235
            n = self.slug_pool[base]
×
236

237
    def __add_slug_to_pool(self, slug):
3✔
238
        """Add a slug to the slug pool to be taken into account when
239
        generating a unique slug.
240

241
        :param slug: the slug to add
242
        :raise InvalidOperationErrpr: if the slug already exists
243

244
        """
245
        if slug in self.slug_pool:
3!
246
            raise InvalidOperationError(
×
247
                _("Le slug « {} » est déjà présent dans le conteneur « {} »").format(slug, self.title)
248
            )
249
        self.slug_pool[slug] = 1
3✔
250

251
    def long_slug(self):
3✔
252
        """
253
        :return: a long slug which embeds parent slugs
254
        :rtype: str
255
        """
256
        long_slug = ""
1✔
257
        if self.parent:
1✔
258
            long_slug = self.parent.long_slug() + "__"
1✔
259
        return long_slug + self.slug
1✔
260

261
    def can_add_container(self) -> bool:
3✔
262
        """
263
        Return `True` if adding child containers is allowed.
264
        Adding subcontainers is forbidden:
265
        * if the container already has extracts as children,
266
        * or if the limit of nested containers has been reached.
267
        """
268
        return not self.has_extracts() and self.get_tree_depth() < settings.ZDS_APP["content"]["max_tree_depth"] - 1
3✔
269

270
    def can_add_extract(self):
3✔
271
        """Return ``True`` if this container can contain extracts, i.e doesn't
272
        contain any container (only zero or more extracts) and is not
273
        too deeply nested.
274

275
        :return: ``True`` if this container accept child extract, ``False`` otherwise
276
        :rtype: bool
277
        """
278
        if not self.has_sub_containers():
3✔
279
            if self.get_tree_depth() <= settings.ZDS_APP["content"]["max_tree_depth"]:
3!
280
                return True
3✔
281
        return False
1✔
282

283
    def add_container(self, container, generate_slug=False):
3✔
284
        """Add a child Container, but only if no extract were previously added
285
        and tree depth is lower than 2.
286

287
        .. attention::
288

289
            This function will raise an Exception if the publication
290
            is an article
291

292
        :param container: the new container
293
        :param generate_slug: if ``True``, asks the top container a unique slug for this object
294
        :raises InvalidOperationError: if the new container cannot be added. Please use\
295
        ``can_add_container`` first to make sure you can use ``add_container``.
296
        """
297
        if self.can_add_container():
3!
298
            if generate_slug:
3✔
299
                container.slug = self.get_unique_slug(container.title)
3✔
300
            else:
301
                self.__add_slug_to_pool(container.slug)
3✔
302
            container.parent = self
3✔
303
            container.position_in_parent = self.get_last_child_position() + 1
3✔
304
            self.children.append(container)
3✔
305
            self.children_dict[container.slug] = container
3✔
306
        else:
307
            raise InvalidOperationError(_("Impossible d'ajouter un conteneur au conteneur « {} »").format(self.title))
×
308

309
    def add_extract(self, extract, generate_slug=False):
3✔
310
        """Add a child container, but only if no container were previously added
311

312
        :param extract: the new extract
313
        :param generate_slug: if ``True``, ask the top container a unique slug for this object
314
        :raise InvalidOperationError: if the extract can't be added to this container.
315
        """
316
        if self.can_add_extract():
3!
317
            if generate_slug:
3✔
318
                extract.slug = self.get_unique_slug(extract.title)
3✔
319
            else:
320
                self.__add_slug_to_pool(extract.slug)
3✔
321
            extract.container = self
3✔
322
            extract.position_in_parent = self.get_last_child_position() + 1
3✔
323
            self.children.append(extract)
3✔
324
            self.children_dict[extract.slug] = extract
3✔
325
        else:
326
            raise InvalidOperationError(_("Impossible d'ajouter un extrait au conteneur « {} »").format(self.title))
×
327

328
    def update_children(self):
3✔
329
        """Update the path for introduction and conclusion for the container and all its children. If the children is an
330
        extract, update the path to the text instead. This function is useful when ``self.slug`` has
331
        changed.
332

333
        Note: this function does not account for a different arrangement of the files.
334
        """
335
        # TODO : path comparison instead of pure rewritring ?
336
        self.introduction = os.path.join(self.get_path(relative=True), "introduction.md")
2✔
337
        self.conclusion = os.path.join(self.get_path(relative=True), "conclusion.md")
2✔
338
        for child in self.children:
2✔
339
            if isinstance(child, Container):
1✔
340
                child.update_children()
1✔
341
            elif isinstance(child, Extract):
1!
342
                child.text = child.get_path(relative=True)
1✔
343

344
    def get_path(self, relative=False, os_sensitive=True):
3✔
345
        """Get the physical path to the draft version of the container.
346
        Note: this function rely on the fact that the top container is VersionedContainer.
347

348
        :param relative: if ``True``, the path will be relative, absolute otherwise.
349
        :type relative: bool
350
        :param os_sensitive: if ``True`` will use os.path.join to ensure compatibility with all OS, otherwise \\
351
                             will build with ``/``, mainly for urls.
352
        :return: physical path
353
        :rtype: str
354
        """
355
        base = ""
3✔
356
        if self.parent:
3!
357
            base = self.parent.get_path(relative=relative)
3✔
358
        return os.path.join(base, self.slug) if os_sensitive else "/".join([base, self.slug]).strip("/")
3✔
359

360
    def get_prod_path(self, relative=False, file_ext="html"):
3✔
361
        """Get the physical path to the public version of the container. If the container have extracts, then it\
362
        returns the final HTML file.
363

364
        :param file_ext: the dumped file extension
365
        :return:
366
        :param relative: return a relative path instead of an absolute one
367
        :type relative: bool
368
        :return: physical path
369
        :rtype: str
370
        """
371
        base = ""
3✔
372
        if self.parent:
3!
373
            base = self.parent.get_prod_path(relative=relative)
3✔
374
        path = os.path.join(base, self.slug)
3✔
375

376
        if self.has_extracts():
3✔
377
            path += "." + file_ext
3✔
378

379
        return path
3✔
380

381
    def get_absolute_url(self):
3✔
382
        """
383
        :return: url to access the container
384
        :rtype: str
385
        """
386
        return self.top_container().get_absolute_url() + self.get_path(relative=True, os_sensitive=False) + "/"
1✔
387

388
    def get_absolute_url_online(self):
3✔
389
        """
390

391
        :return: the 'online version' of the url
392
        :rtype: str
393
        """
394
        base = ""
2✔
395

396
        if self.parent:
2!
397
            base = self.parent.get_absolute_url_online()
2✔
398

399
        base += self.slug + "/"
2✔
400

401
        return base
2✔
402

403
    def get_absolute_url_beta(self):
3✔
404
        """
405
        :return: url to access the container in beta
406
        :rtype: str
407
        """
408

409
        if self.top_container().sha_beta is not None:
1!
410
            base = ""
1✔
411
            if self.parent:
1!
412
                base = self.parent.get_absolute_url_beta()
1✔
413

414
            return base + self.slug + "/"
1✔
415
        else:
416
            return self.get_absolute_url()
×
417

418
    def get_edit_url(self):
3✔
419
        """
420
        :return: url to edit the container
421
        :rtype: str
422
        """
423
        slugs = [self.slug]
1✔
424
        parent = self.parent
1✔
425
        while parent is not None:
1✔
426
            slugs.append(parent.slug)
1✔
427
            parent = parent.parent
1✔
428
        slugs.reverse()
1✔
429
        args = [self.top_container().pk]
1✔
430
        args.extend(slugs)
1✔
431

432
        return reverse("content:edit-container", args=args)
1✔
433

434
    def get_delete_url(self):
3✔
435
        """
436
        :return: url to edit the container
437
        :rtype: str
438
        """
439
        slugs = [self.slug]
1✔
440
        parent = self.parent
1✔
441
        while parent is not None:
1✔
442
            slugs.append(parent.slug)
1✔
443
            parent = parent.parent
1✔
444
        slugs.reverse()
1✔
445
        args = [self.top_container().pk]
1✔
446
        args.extend(slugs)
1✔
447

448
        return reverse("content:delete", args=args)
1✔
449

450
    def get_introduction(self):
3✔
451
        """
452
        :return: the introduction from the file in ``self.introduction``
453
        :rtype: str
454
        """
455
        if self.introduction:
3✔
456
            return (
3✔
457
                get_blob(
458
                    self.top_container().repository.commit(self.top_container().current_version).tree,
459
                    self.introduction.replace("\\", "/"),
460
                )
461
                or ""
462
            )
463
        return ""
1✔
464

465
    def get_conclusion(self):
3✔
466
        """
467
        :return: the conclusion from the file in ``self.conclusion``
468
        :rtype: str
469
        """
470
        if self.conclusion:
3✔
471
            return (
3✔
472
                get_blob(
473
                    self.top_container().repository.commit(self.top_container().current_version).tree,
474
                    self.conclusion.replace("\\", "/"),
475
                )
476
                or ""
477
            )
478
        return ""
1✔
479

480
    def get_introduction_online(self):
3✔
481
        """The introduction content for online version.
482

483
        This method should be only used in templates
484

485
        :return: the full text if introduction exists, empty string otherwise
486
        :rtype: str
487
        """
488
        if self.introduction:
3!
489
            path = os.path.join(self.top_container().get_prod_path(), self.introduction)
3✔
490
            if os.path.isfile(path):
3!
491
                return codecs.open(path, "r", encoding="utf-8").read()
3✔
492
        return ""
×
493

494
    def get_conclusion_online(self):
3✔
495
        """The conclusion content for online version.
496

497
        This method should be only used in templates
498

499
        :return: the full text if introduction exists, empty string otherwise
500
        :rtype: str
501
        """
502
        if self.conclusion:
3!
503
            path = os.path.join(self.top_container().get_prod_path(), self.conclusion)
3✔
504
            if os.path.isfile(path):
3!
505
                return codecs.open(path, "r", encoding="utf-8").read()
3✔
506
        return ""
×
507

508
    def get_content_online(self):
3✔
509
        if os.path.isfile(self.get_prod_path()):
2!
510
            return codecs.open(self.get_prod_path(), "r", encoding="utf-8").read()
2✔
511

512
    def compute_hash(self):
3✔
513
        """Compute an MD5 hash from the introduction and conclusion, for comparison purpose
514

515
        :return: MD5 hash
516
        :rtype: str
517
        """
518

519
        files = []
1✔
520
        if self.introduction:
1✔
521
            files.append(os.path.join(self.top_container().get_path(), self.introduction))
1✔
522
        if self.conclusion:
1✔
523
            files.append(os.path.join(self.top_container().get_path(), self.conclusion))
1✔
524

525
        return compute_hash(files)
1✔
526

527
    def repo_update(self, title, introduction, conclusion, commit_message="", do_commit=True, update_slug=True):
3✔
528
        """Update the container information and commit them into the repository
529

530
        :param title: the new title
531
        :param introduction: the new introduction text
532
        :param conclusion: the new conclusion text
533
        :param commit_message: commit message that will be used instead of the default one
534
        :param do_commit: perform the commit in repository if ``True``
535
        :return: commit sha
536
        :rtype: str
537
        """
538

539
        if title is None:
3!
540
            raise PermissionDenied
×
541

542
        repo = self.top_container().repository
3✔
543

544
        # update title
545
        if title != self.title:
3✔
546
            self.title = title
2✔
547
            if self.get_tree_depth() > 0:  # if top container, slug is generated from DB, so already changed
2✔
548
                old_path = self.get_path(relative=True)
2✔
549
                old_slug = self.slug
2✔
550

551
                # move things
552
                if update_slug:
2!
553
                    self.slug = self.parent.get_unique_slug(title)
2✔
554
                    new_path = self.get_path(relative=True)
2✔
555
                    repo.index.move([old_path, new_path])
2✔
556

557
                # update manifest
558
                self.update_children()
2✔
559

560
                # update parent children dict:
561
                self.parent.children_dict.pop(old_slug)
2✔
562
                self.parent.children_dict[self.slug] = self
2✔
563

564
        # update introduction and conclusion (if any)
565
        path = self.top_container().get_path()
3✔
566
        rel_path = self.get_path(relative=True)
3✔
567

568
        if introduction is not None:
3✔
569
            if self.introduction is None:
3✔
570
                self.introduction = os.path.join(rel_path, "introduction.md")
3✔
571

572
            f = codecs.open(os.path.join(path, self.introduction), "w", encoding="utf-8")
3✔
573
            f.write(introduction)
3✔
574
            f.close()
3✔
575
            repo.index.add([self.introduction])
3✔
576

577
        elif self.introduction:
1✔
578
            repo.index.remove([self.introduction])
1✔
579
            os.remove(os.path.join(path, self.introduction))
1✔
580
            self.introduction = None
1✔
581

582
        if conclusion is not None:
3✔
583
            if self.conclusion is None:
3✔
584
                self.conclusion = os.path.join(rel_path, "conclusion.md")
3✔
585

586
            f = codecs.open(os.path.join(path, self.conclusion), "w", encoding="utf-8")
3✔
587
            f.write(conclusion)
3✔
588
            f.close()
3✔
589
            repo.index.add([self.conclusion])
3✔
590

591
        elif self.conclusion:
1✔
592
            repo.index.remove([self.conclusion])
1✔
593
            os.remove(os.path.join(path, self.conclusion))
1✔
594
            self.conclusion = None
1✔
595

596
        self.top_container().dump_json()
3✔
597
        repo.index.add(["manifest.json"])
3✔
598

599
        if not commit_message:
3✔
600
            commit_message = _("Mise à jour de « {} »").format(self.title)
3✔
601

602
        if do_commit:
3✔
603
            return self.top_container().commit_changes(commit_message)
3✔
604

605
    def repo_add_container(
3✔
606
        self, title, introduction, conclusion, commit_message="", do_commit=True, slug=None, ready_to_publish=None
607
    ):
608
        """
609
        :param title: title of the new container
610
        :param introduction: text of its introduction
611
        :param conclusion: text of its conclusion
612
        :param commit_message: commit message that will be used instead of the default one
613
        :param do_commit: perform the commit in repository if ``True``
614
        :return: commit sha
615
        :rtype: str
616
        """
617
        if slug is None:
3✔
618
            subcontainer = Container(title)
3✔
619
        else:
620
            subcontainer = Container(title, slug=slug)
1✔
621
        # can a subcontainer be added ?
622
        try:
3✔
623
            self.add_container(subcontainer, generate_slug=slug is None)
3✔
624
        except InvalidOperationError:
×
625
            raise PermissionDenied
×
626

627
        # create directory
628
        repo = self.top_container().repository
3✔
629
        path = self.top_container().get_path()
3✔
630
        rel_path = subcontainer.get_path(relative=True)
3✔
631
        with contextlib.suppress(FileExistsError):
3✔
632
            Path(path, rel_path).mkdir(parents=True)
3✔
633

634
        repo.index.add([rel_path])
3✔
635

636
        # make it
637
        if not commit_message:
3!
638
            commit_message = _("Création du conteneur « {} »").format(title)
3✔
639

640
        if ready_to_publish is not None:
3✔
641
            subcontainer.ready_to_publish = ready_to_publish
1✔
642
        return subcontainer.repo_update(
3✔
643
            title, introduction, conclusion, commit_message=commit_message, do_commit=do_commit
644
        )
645

646
    def repo_add_extract(self, title, text, commit_message="", do_commit=True, slug=None):
3✔
647
        """
648
        :param title: title of the new extract
649
        :param text: text of the new extract
650
        :param commit_message: commit message that will be used instead of the default one
651
        :param do_commit: perform the commit in repository if ``True``
652
        :param generate_slug: indicates that is must generate slug
653
        :return: commit sha
654
        :rtype: str
655
        """
656
        if not slug:
3✔
657
            extract = Extract(title)
3✔
658
        else:
659
            extract = Extract(title, slug)
1✔
660
        # can an extract be added ?
661
        try:
3✔
662
            self.add_extract(extract, generate_slug=slug is None)
3✔
663
        except InvalidOperationError:
×
664
            raise PermissionDenied
×
665

666
        # make it
667
        if not commit_message:
3!
668
            commit_message = _("Création de l'extrait « {} »").format(title)
3✔
669

670
        return extract.repo_update(title, text, commit_message=commit_message, do_commit=do_commit)
3✔
671

672
    def repo_delete(self, commit_message="", do_commit=True):
3✔
673
        """
674
        :param commit_message: commit message used instead of default one if provided
675
        :param do_commit: tells if we have to commit the change now or let the outer program do it
676
        :return: commit sha
677
        :rtype: str
678
        """
679
        path = self.get_path(relative=True)
2✔
680
        repo = self.top_container().repository
2✔
681
        repo.index.remove([path], r=True)
2✔
682
        shutil.rmtree(self.get_path())  # looks like removing from git is not enough!!
2✔
683

684
        # now, remove from manifest
685
        # work only if slug is correct
686
        top = self.parent
2✔
687
        top.children_dict.pop(self.slug)
2✔
688
        top.children.pop(top.children.index(self))
2✔
689

690
        # commit
691
        top.top_container().dump_json()
2✔
692
        repo.index.add(["manifest.json"])
2✔
693

694
        if not commit_message:
2!
695
            commit_message = _("Suppression du conteneur « {} »").format(self.title)
2✔
696

697
        if do_commit:
2✔
698
            return self.top_container().commit_changes(commit_message)
2✔
699

700
    def move_child_up(self, child_slug):
3✔
701
        """Change the child's ordering by moving up the child whose slug equals child_slug.
702
        This method **does not** automaticaly update the repo.
703

704
        :param child_slug: the child's slug
705
        :raise ValueError: if the slug does not refer to an existing child
706
        :raise IndexError: if the extract is already the first child
707
        """
708
        if child_slug not in self.children_dict:
1!
709
            raise ValueError(_(child_slug + " n'existe pas."))
×
710
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
711
        if child_pos == 0:
1✔
712
            raise IndexError(_(child_slug + " est le premier élément."))
1✔
713
        self.children[child_pos], self.children[child_pos - 1] = self.children[child_pos - 1], self.children[child_pos]
1✔
714
        self.children[child_pos].position_in_parent = child_pos + 1
1✔
715
        self.children[child_pos - 1].position_in_parent = child_pos
1✔
716

717
    def move_child_down(self, child_slug):
3✔
718
        """Change the child's ordering by moving down the child whose slug equals child_slug.
719
        This method **does not** automaticaly update the repo.
720

721
        :param child_slug: the child's slug
722
        :raise ValueError: if the slug does not refer to an existing child
723
        :raise IndexError: if the extract is already the last child
724
        """
725
        if child_slug not in self.children_dict:
1!
726
            raise ValueError(_(child_slug + " n'existe pas."))
×
727
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
728
        if child_pos == len(self.children) - 1:
1!
729
            raise IndexError(_(child_slug + " est le dernier élément."))
×
730
        self.children[child_pos], self.children[child_pos + 1] = self.children[child_pos + 1], self.children[child_pos]
1✔
731
        self.children[child_pos].position_in_parent = child_pos
1✔
732
        self.children[child_pos + 1].position_in_parent = child_pos + 1
1✔
733

734
    def move_child_after(self, child_slug, refer_slug):
3✔
735
        """Change the child's ordering by moving the child to be below the reference child.
736
        This method **does not** automaticaly update the repo
737

738
        :param child_slug: the child's slug
739
        :param refer_slug: the referent child's slug.
740
        :raise ValueError: if one slug does not refer to an existing child
741
        """
742
        if child_slug not in self.children_dict:
1!
743
            raise ValueError(_(child_slug + " n'existe pas."))
×
744
        if refer_slug not in self.children_dict:
1!
745
            raise ValueError(_(refer_slug + " n'existe pas."))
×
746
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
747
        refer_pos = self.children.index(self.children_dict[refer_slug])
1✔
748

749
        # if we want our child to get down (reference is an lower child)
750
        if child_pos < refer_pos:
1✔
751
            for i in range(child_pos, refer_pos):
1✔
752
                self.move_child_down(child_slug)
1✔
753
        elif child_pos > refer_pos:
1!
754
            # if we want our child to get up (reference is an upper child)
755
            for i in range(child_pos, refer_pos + 1, -1):
1!
756
                self.move_child_up(child_slug)
×
757

758
    def move_child_before(self, child_slug, refer_slug):
3✔
759
        """Change the child's ordering by moving the child to be just above the reference child.
760
        This method **does not** automaticaly update the repo.
761

762
        :param child_slug: the child's slug
763
        :param refer_slug: the referent child's slug.
764
        :raise ValueError: if one slug does not refer to an existing child
765
        """
766
        if child_slug not in self.children_dict:
1!
767
            raise ValueError(_(child_slug + " n'existe pas."))
×
768
        if refer_slug not in self.children_dict:
1!
769
            raise ValueError(_(refer_slug + " n'existe pas."))
×
770
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
771
        refer_pos = self.children.index(self.children_dict[refer_slug])
1✔
772

773
        # if we want our child to get down (reference is an lower child)
774
        if child_pos < refer_pos:
1✔
775
            for i in range(child_pos, refer_pos - 1):
1✔
776
                self.move_child_down(child_slug)
1✔
777
        elif child_pos > refer_pos:
1!
778
            # if we want our child to get up (reference is an upper child)
779
            for i in range(child_pos, refer_pos, -1):
1✔
780
                self.move_child_up(child_slug)
1✔
781

782
    def traverse(self, only_container=True):
3✔
783
        """Traverse the container.
784

785
        :param only_container: if we only want container's paths, not extract
786
        :return: a generator that traverse all the container recursively (depth traversal)
787
        :rtype: collections.Iterable[Container|Extract]
788
        """
789
        yield self
1✔
790
        for child in self.children:
1✔
791
            if isinstance(child, Container):
1✔
792
                yield from child.traverse(only_container)
1✔
793
            elif not only_container:
1✔
794
                yield child
1✔
795

796
    def get_level_as_string(self):
3✔
797
        """Get a word (Part/Chapter/Section) for the container level
798

799
        .. attention::
800

801
            this deals with internationalized string
802

803
        :return: The string representation of this level
804
        :rtype: str
805
        """
806
        if self.get_tree_depth() == 0:
1✔
807
            return self.type
1✔
808
        elif self.get_tree_depth() == 1:
1✔
809
            return _("Partie")
1✔
810
        elif self.get_tree_depth() == 2:
1!
811
            return _("Chapitre")
1✔
812
        return _("Sous-chapitre")
×
813

814
    def get_next_level_as_string(self):
3✔
815
        """Same as ``self.get_level_as_string()`` but try to guess the level of this container's children
816

817
        :return: The string representation of next level (upper)
818
        :rtype: str
819
        """
820
        if self.get_tree_depth() == 0 and self.can_add_container():
×
821
            return _("Partie")
×
822
        elif self.get_tree_depth() == 1 and self.can_add_container():
×
823
            return _("Chapitre")
×
824
        else:
825
            return _("Section")
×
826

827
    def is_chapter(self):
3✔
828
        """
829
        Check if the container level is Chapter
830

831
        :return: True if the container level is Chapter
832
        :rtype: bool
833
        """
834
        if self.get_tree_depth() == 2:
1✔
835
            return True
1✔
836
        return False
1✔
837

838
    def next_level_is_chapter(self):
3✔
839
        """Same as ``self.is_chapter()`` but check the container's children
840

841
        :return: True if the container next level is Chapter
842
        :rtype: bool
843
        """
844
        if self.get_tree_depth() == 1 and self.can_add_container():
×
845
            return True
×
846
        return False
×
847

848
    def remove_children(self, children_slugs):
3✔
849
        for slug in children_slugs:
×
850
            if slug not in self.children_dict:
×
851
                continue
×
852
            to_be_remove = self.children_dict[slug]
×
853
            self.children.remove(to_be_remove)
×
854
            del self.children_dict[slug]
×
855

856
    def _dump_html(self, file_path, content, db_object):
3✔
857
        try:
×
858
            with file_path.open("w", encoding="utf-8") as f:
×
859
                f.write(emarkdown(content, db_object.js_support))
×
860
        except (UnicodeError, UnicodeEncodeError):
×
861
            from zds.tutorialv2.publication_utils import FailureDuringPublication
×
862

863
            raise FailureDuringPublication(
×
864
                _(
865
                    "Une erreur est survenue durant la publication de l'introduction de « {} »,"
866
                    " vérifiez le code markdown"
867
                ).format(self.title)
868
            )
869

870
    def publish_introduction_and_conclusion(self, base_dir, db_object):
3✔
871
        if self.has_extracts():
×
872
            return
×
873
        current_dir_path = Path(base_dir, self.get_prod_path(relative=True))  # create subdirectory
×
874
        with contextlib.suppress(FileExistsError):
×
875
            current_dir_path.mkdir(parents=True)
×
876

877
        if self.introduction:
×
878
            path = current_dir_path / "introduction.html"
×
879
            self._dump_html(path, self.get_introduction(), db_object)
×
880
            self.introduction = Path(self.get_path(relative=True), "introduction.html")
×
881

882
        if self.conclusion:
×
883
            path = current_dir_path / "conclusion.html"
×
884
            self._dump_html(path, self.get_conclusion(), db_object)
×
885
            self.conclusion = Path(self.get_path(relative=True), "conclusion.html")
×
886

887
    def publish_extracts(self, base_dir, is_js, template, failure_exception):
3✔
888
        """
889
        Publish the current element thanks to a templated view.
890

891
        :param base_dir: directory into which we will put the result
892
        :param is_js: jsfiddle activation flag
893
        :param template: templated view name
894
        :param failure_exception: the exception to throw when we fail
895
        :return:
896
        """
897
        parsed = render_to_string(template, {"container": self, "is_js": is_js})
×
898
        with Path(base_dir, self.get_prod_path(relative=True)).open("w", encoding="utf-8") as f:
×
899
            try:
×
900
                f.write(parsed)
×
901
            except (UnicodeError, UnicodeEncodeError):
×
902
                raise failure_exception(
×
903
                    _("Une erreur est survenue durant la publication de « {} »," " vérifiez le code markdown").format(
904
                        self.title
905
                    )
906
                )
907
        # as introduction and conclusion are published in the full file, we remove reference to them
908
        self.introduction = None
×
909
        self.conclusion = None
×
910

911
    def is_validable(self):
3✔
912
        """Return ``True`` if the container would be in the public version if the content is validated."""
913
        if self.parent is not None and not self.parent.is_validable():
1!
914
            return False
×
915
        return self.ready_to_publish
1✔
916

917

918
class Extract:
3✔
919
    """
920
    A content extract from a Container.
921

922
    It has a title, a position in the parent container and a text.
923
    """
924

925
    title = ""
3✔
926
    slug = ""
3✔
927
    container = None
3✔
928
    position_in_parent = 1
3✔
929
    text = None
3✔
930

931
    def __init__(self, title, slug="", container=None, position_in_parent=1):
3✔
932
        self.title = title
3✔
933
        self.slug = slug
3✔
934
        self.container = container
3✔
935
        self.position_in_parent = position_in_parent
3✔
936

937
    def __str__(self):
3✔
938
        return f"<Extrait '{self.title}'>"
×
939

940
    def get_url_path(self, base_url=""):
3✔
941
        return f"{base_url}{self.container.get_url_path()}#{self.position_in_parent}-{self.slug}"
1✔
942

943
    def get_absolute_url(self):
3✔
944
        """Find the url that point to the offline version of this extract
945

946
        :return: the url to access the tutorial offline
947
        :rtype: str
948
        """
949
        return f"{self.container.get_absolute_url()}#{self.position_in_parent}-{self.slug}"
1✔
950

951
    def get_absolute_url_online(self):
3✔
952
        """
953
        :return: the url to access the tutorial when online
954
        :rtype: str
955
        """
956
        return f"{self.container.get_absolute_url_online()}#{self.position_in_parent}-{self.slug}"
×
957

958
    def get_absolute_url_beta(self):
3✔
959
        """
960
        :return: the url to access the tutorial when in beta
961
        :rtype: str
962
        """
963
        return f"{self.container.get_absolute_url_beta()}#{self.position_in_parent}-{self.slug}"
×
964

965
    def get_edit_url(self):
3✔
966
        """
967
        :return: url to edit the extract
968
        :rtype: str
969
        """
970
        slugs = [self.slug]
1✔
971
        parent = self.container
1✔
972
        while parent is not None:
1✔
973
            slugs.append(parent.slug)
1✔
974
            parent = parent.parent
1✔
975
        slugs.reverse()
1✔
976
        args = [self.container.top_container().pk]
1✔
977
        args.extend(slugs)
1✔
978

979
        return reverse("content:edit-extract", args=args)
1✔
980

981
    def get_full_slug(self):
3✔
982
        """Get the slug of curent extract with its full path (part1/chapter1/slug_of_extract).
983

984
        This method is an alias to ``extract.get_path(True)[:-3]``
985
        (removes the ``.md`` file extension).
986

987
        :rtype: str
988

989
        """
990
        return self.get_path(True)[:-3]
1✔
991

992
    def get_first_level_slug(self):
3✔
993
        """
994
        :return: the ``first_level_slug``, if (and only if) the parent container is a chapter
995
        :rtype: str
996
        """
997
        if self.container.get_tree_depth() == 2:
1✔
998
            return self.container.parent.slug
1✔
999

1000
        return ""
1✔
1001

1002
    def get_delete_url(self):
3✔
1003
        """
1004
        :return: URL to delete the extract
1005
        :rtype: str
1006
        """
1007
        slugs = [self.slug]
1✔
1008
        parent = self.container
1✔
1009
        while parent is not None:
1✔
1010
            slugs.append(parent.slug)
1✔
1011
            parent = parent.parent
1✔
1012
        slugs.reverse()
1✔
1013
        args = [self.container.top_container().pk]
1✔
1014
        args.extend(slugs)
1✔
1015

1016
        return reverse("content:delete", args=args)
1✔
1017

1018
    def get_path(self, relative=False):
3✔
1019
        """
1020
        Get the physical path to the draft version of the extract.
1021
        :param relative: if ``True``, the path will be relative, absolute otherwise.
1022
        :return: physical path
1023
        :rtype: str
1024
        """
1025
        return os.path.join(self.container.get_path(relative=relative), self.slug) + ".md"
3✔
1026

1027
    def get_text(self):
3✔
1028
        """
1029
        :return: versioned text
1030
        :rtype: str
1031
        """
1032
        if self.text:
3!
1033
            return get_blob(
3✔
1034
                self.container.top_container().repository.commit(self.container.top_container().current_version).tree,
1035
                self.text.replace("\\", "/"),
1036
            )
1037
        return ""
×
1038

1039
    def compute_hash(self):
3✔
1040
        """Compute an MD5 hash from the text, for comparison purpose
1041

1042
        :return: MD5 hash of the text
1043
        :rtype: str
1044
        """
1045

1046
        if self.text:
1!
1047
            return compute_hash([self.get_path()])
1✔
1048

1049
        else:
1050
            return compute_hash([])
×
1051

1052
    def repo_update(self, title, text, commit_message="", do_commit=True):
3✔
1053
        """
1054
        :param title: new title of the extract
1055
        :param text: new text of the extract
1056
        :param commit_message: commit message that will be used instead of the default one
1057
        :return: commit sha
1058
        :rtype: str
1059
        """
1060

1061
        if title is None:
3!
1062
            raise PermissionDenied
×
1063

1064
        repo = self.container.top_container().repository
3✔
1065

1066
        if title != self.title:
3✔
1067
            # get a new slug
1068
            old_path = self.get_path(relative=True)
2✔
1069
            old_slug = self.slug
2✔
1070
            self.title = title
2✔
1071
            self.slug = self.container.get_unique_slug(title)
2✔
1072

1073
            # move file
1074
            new_path = self.get_path(relative=True)
2✔
1075
            repo.index.move([old_path, new_path])
2✔
1076

1077
            # update parent children dict:
1078
            self.container.children_dict.pop(old_slug)
2✔
1079
            self.container.children_dict[self.slug] = self
2✔
1080

1081
        # edit text
1082
        path = self.container.top_container().get_path()
3✔
1083

1084
        if text is not None:
3✔
1085
            self.text = self.get_path(relative=True)
3✔
1086
            f = codecs.open(os.path.join(path, self.text), "w", encoding="utf-8")
3✔
1087
            f.write(text)
3✔
1088
            f.close()
3✔
1089

1090
            repo.index.add([self.text])
3✔
1091

1092
        elif self.text:
1✔
1093
            if os.path.exists(os.path.join(path, self.text)):
1!
1094
                repo.index.remove([self.text])
1✔
1095
                os.remove(os.path.join(path, self.text))
1✔
1096

1097
            self.text = None
1✔
1098

1099
        # make it
1100
        self.container.top_container().dump_json()
3✔
1101
        repo.index.add(["manifest.json"])
3✔
1102

1103
        if not commit_message:
3✔
1104
            commit_message = _("Modification de l'extrait « {} », situé dans le conteneur « {} »").format(
2✔
1105
                self.title, self.container.title
1106
            )
1107

1108
        if do_commit:
3✔
1109
            return self.container.top_container().commit_changes(commit_message)
3✔
1110

1111
    def repo_delete(self, commit_message="", do_commit=True):
3✔
1112
        """
1113
        :param commit_message: commit message used instead of default one if provided
1114
        :param do_commit: tells if we have to commit the change now or let the outer program do it
1115
        :return: commit sha, None if no commit is done
1116
        :rtype: str
1117
        """
1118
        path = self.text
1✔
1119
        repo = self.container.top_container().repository
1✔
1120

1121
        repo.index.remove([path])
1✔
1122
        os.remove(self.get_path())  # looks like removing from git is not enough
1✔
1123

1124
        # now, remove from manifest
1125
        # work only if slug is correct!!
1126
        top = self.container
1✔
1127
        top.children_dict.pop(self.slug, None)
1✔
1128
        top.children.pop(top.children.index(self))
1✔
1129

1130
        # commit
1131
        top.top_container().dump_json()
1✔
1132
        repo.index.add(["manifest.json"])
1✔
1133

1134
        if not commit_message:
1!
1135
            commit_message = _("Suppression de l'extrait « {} »").format(self.title)
1✔
1136

1137
        if do_commit:
1✔
1138
            return self.container.top_container().commit_changes(commit_message)
1✔
1139

1140
    def get_tree_depth(self):
3✔
1141
        """Return the depth where this extract is found.
1142

1143
        The tree depth of an extract is the number of parents of that
1144
        extract. It is always lower that 4 because an extract can have
1145
        at most three parents.
1146

1147
        Keep in mind that containers can't reach a depth of 3 in the
1148
        document tree since there are not leaves.
1149

1150
        :return: Tree depth
1151
        :rtype: int
1152
        """
1153
        depth = 1
1✔
1154
        current = self.container
1✔
1155
        while current.parent is not None:
1✔
1156
            current = current.parent
1✔
1157
            depth += 1
1✔
1158
        return depth
1✔
1159

1160
    def is_validable(self):
3✔
1161
        return self.container.is_validable()
1✔
1162

1163

1164
class VersionedContent(Container, TemplatableContentModelMixin):
3✔
1165
    """
1166
    This class is used to handle a specific version of a content.
1167

1168
    It is created from the 'manifest.json' file, and could dump information in it.
1169

1170
    For simplicity, it also contains read-only DB information filled at the creation.
1171
    """
1172

1173
    current_version = None
3✔
1174
    slug_repository = ""
3✔
1175
    repository = None
3✔
1176

1177
    PUBLIC = False  # this variable is set to true when the VersionedContent is created from the public repository
3✔
1178

1179
    # Metadata from json :
1180
    description = ""
3✔
1181
    type = ""
3✔
1182
    licence = None
3✔
1183

1184
    # Metadata from DB :
1185
    pk = 0
3✔
1186
    sha_draft = None
3✔
1187
    sha_beta = None
3✔
1188
    sha_public = None
3✔
1189
    sha_validation = None
3✔
1190
    sha_picked = None
3✔
1191
    is_beta = False
3✔
1192
    is_validation = False
3✔
1193
    is_public = False
3✔
1194
    in_beta = False
3✔
1195
    in_validation = False
3✔
1196
    in_public = False
3✔
1197

1198
    authors = None
3✔
1199
    subcategory = None
3✔
1200
    image = None
3✔
1201
    creation_date = None
3✔
1202
    pubdate = None
3✔
1203
    update_date = None
3✔
1204
    source = None
3✔
1205
    antispam = True
3✔
1206
    tags = None
3✔
1207
    converted_to = None
3✔
1208
    content_type_attribute = "type"
3✔
1209

1210
    def __copy__(self):
3✔
1211
        cpy = self.__class__(self.current_version, self.type, self.title, self.slug, self.slug_repository)
×
1212
        cpy.children_dict = copy.copy(self.children_dict)
×
1213
        cpy.children = copy.copy(self.children)
×
1214
        cpy.slug_pool = copy.deepcopy(self.slug_pool)
×
1215
        cpy.ready_to_publish = self.ready_to_publish
×
1216
        cpy.introduction = self.introduction
×
1217
        cpy.conclusion = self.conclusion
×
1218
        for attr in dir(cpy):
×
1219
            value = getattr(cpy, attr)
×
1220
            if not value and not callable(value):
×
1221
                setattr(cpy, attr, getattr(self, attr))
×
1222
        return cpy
×
1223

1224
    def __init__(self, current_version, _type, title, slug, slug_repository=""):
3✔
1225
        """
1226
        :param current_version: version of the content
1227
        :param _type: either "TUTORIAL", "ARTICLE" or "OPINION"
1228
        :param title: title of the content
1229
        :param slug: slug of the content
1230
        :param slug_repository: slug of the directory that contains the repository, named after database slug.
1231
            if not provided, use ``self.slug`` instead.
1232
        """
1233

1234
        Container.__init__(self, title, slug)
3✔
1235
        self.current_version = current_version
3✔
1236
        self.type = _type
3✔
1237

1238
        if slug_repository != "":
3✔
1239
            self.slug_repository = slug_repository
3✔
1240
        else:
1241
            self.slug_repository = slug
3✔
1242

1243
        if self.slug != "" and os.path.exists(self.get_path()):
3✔
1244
            self.repository = Repo(self.get_path())
3✔
1245

1246
    def __str__(self):
3✔
1247
        return self.title
×
1248

1249
    def requires_validation(self) -> bool:
3✔
1250
        return self.type in CONTENT_TYPES_REQUIRING_VALIDATION
2✔
1251

1252
    def get_absolute_url(self, version=None):
3✔
1253
        return TemplatableContentModelMixin.get_absolute_url(self, version)
3✔
1254

1255
    def textual_type(self):
3✔
1256
        """Create a internationalized string with the human readable type of this content e.g “The Article”
1257

1258
        :return: internationalized string
1259
        :rtype: str
1260
        """
1261
        if self.is_article:
1!
UNCOV
1262
            return _("L’Article")
×
1263
        elif self.is_tutorial:
1!
1264
            return _("Le Tutoriel")
1✔
1265
        elif self.is_opinion:
×
1266
            return _("Le Billet")
×
1267
        else:
1268
            return _("Le Contenu")
×
1269

1270
    def get_absolute_url_online(self):
3✔
1271
        """
1272

1273
        :return: the url to access the content when online
1274
        :rtype: str
1275
        """
1276
        _reversed = ""
3✔
1277

1278
        if self.is_article:
3✔
1279
            _reversed = "article"
3✔
1280
        elif self.is_tutorial:
3✔
1281
            _reversed = "tutorial"
3✔
1282
        elif self.is_opinion:
2!
1283
            _reversed = "opinion"
2✔
1284
        return reverse(_reversed + ":view", kwargs={"pk": self.pk, "slug": self.slug})
3✔
1285

1286
    def get_absolute_url_beta(self):
3✔
1287
        """
1288
        :return: the url to access the tutorial when in beta
1289
        :rtype: str
1290
        """
1291
        if self.in_beta:
3✔
1292
            return reverse("content:beta-view", args=[self.pk, self.slug])
1✔
1293
        else:
1294
            return self.get_absolute_url()
3✔
1295

1296
    def get_path(self, relative=False, use_current_slug=False):
3✔
1297
        """Get the physical path to the draft version of the Content.
1298

1299
        :param relative: if ``True``, the path will be relative, absolute otherwise.
1300
        :param use_current_slug: if ``True``, use ``self.slug`` instead of ``self.slug_last_draft``
1301
        :return: physical path
1302
        :rtype: str
1303
        """
1304
        if relative:
3✔
1305
            return ""
3✔
1306
        else:
1307
            slug = self.slug_repository
3✔
1308
            if use_current_slug:
3✔
1309
                slug = self.slug
2✔
1310
            return os.path.join(settings.ZDS_APP["content"]["repo_private_path"], slug)
3✔
1311

1312
    def get_prod_path(self, relative=False, file_ext="html"):
3✔
1313
        """Get the physical path to the public version of the content. If it
1314
        has one or more extracts (if it is a mini-tutorial or an
1315
        article), return the path of the HTML file.
1316

1317
        :param relative: return the relative path instead of the absolute one
1318
        :return: physical path
1319
        :rtype: str
1320

1321
        """
1322
        path = ""
3✔
1323

1324
        if not relative:
3✔
1325
            path = os.path.join(settings.ZDS_APP["content"]["repo_public_path"], self.slug)
3✔
1326

1327
        if self.has_extracts():
3✔
1328
            path = os.path.join(path, self.slug + "." + file_ext)
2✔
1329

1330
        return path
3✔
1331

1332
    def get_list_of_chapters(self) -> list[Container]:
3✔
1333
        continuous_list = []
1✔
1334
        if len(self.children) != 0 and isinstance(self.children[0], Container):  # children must be Containers!
1!
1335
            for child in self.children:
1✔
1336
                if len(child.children) != 0:
1!
1337
                    if isinstance(child.children[0], Extract):
1!
1338
                        continuous_list.append(child)  # it contains Extract, this is a chapter, so paginated
1✔
1339
                    else:  # Container is a part
1340
                        for sub_child in child.children:
×
1341
                            continuous_list.append(sub_child)  # even if `sub_child.children` is empty, it's a chapter
×
1342
        return continuous_list
1✔
1343

1344
    def get_json(self):
3✔
1345
        """
1346
        :return: raw JSON file
1347
        :rtype: str
1348
        """
1349
        dct = export_content(self)
3✔
1350
        data = json_handler.dumps(dct, indent=4, ensure_ascii=False)
3✔
1351
        return data
3✔
1352

1353
    def dump_json(self, path=None):
3✔
1354
        """Write the JSON into file
1355

1356
        :param path: path to the file. If ``None``, write in 'manifest.json'
1357
        """
1358
        if path is None:
3✔
1359
            man_path = os.path.join(self.get_path(), "manifest.json")
3✔
1360
        else:
1361
            man_path = path
3✔
1362
        json_data = codecs.open(man_path, "w", encoding="utf-8")
3✔
1363
        json_data.write(self.get_json())
3✔
1364
        json_data.close()
3✔
1365

1366
    def repo_update_top_container(self, title, slug, introduction, conclusion, commit_message="", do_commit=True):
3✔
1367
        """Update the top container information and commit them into the repository.
1368
        Note that this is slightly different from the ``repo_update()`` function, because slug is generated using DB
1369

1370
        :param title: the new title
1371
        :param slug: the new slug, according to title (choose using DB!!)
1372
        :param introduction: the new introduction text
1373
        :param conclusion: the new conclusion text
1374
        :param commit_message: commit message that will be used instead of the default one
1375
        :param do_commit: if ``True``, also commit change
1376
        :return: commit sha
1377
        :rtype: str
1378
        """
1379

1380
        if slug != self.slug:
2✔
1381
            # move repository
1382
            old_path = self.get_path(use_current_slug=True)
2✔
1383
            self.slug = slug
2✔
1384
            new_path = self.get_path(use_current_slug=True)
2✔
1385
            shutil.move(old_path, new_path)
2✔
1386
            self.repository = Repo(new_path)
2✔
1387
            self.slug_repository = slug
2✔
1388

1389
        return self.repo_update(title, introduction, conclusion, commit_message=commit_message, do_commit=do_commit)
2✔
1390

1391
    def commit_changes(self, commit_message):
3✔
1392
        """Commit change made to the repository
1393

1394
        :param commit_message: The message that will appear in content history
1395
        :return: commit sha
1396
        :rtype: str
1397
        """
1398
        cm = self.repository.index.commit(commit_message, **get_commit_author())
3✔
1399

1400
        self.sha_draft = cm.hexsha
3✔
1401
        self.current_version = cm.hexsha
3✔
1402

1403
        return cm.hexsha
3✔
1404

1405
    def change_child_directory(self, child, adoptive_parent):
3✔
1406
        """Move an element of this content to a new location.
1407
        This method changes the repository index and stage every change but does **not** commit.
1408

1409
        :param child: the child we want to move, can be either an Extract or a Container object
1410
        :param adoptive_parent: the container where the child *will be* moved, must be a Container object
1411
        """
1412

1413
        old_path = child.get_path(False)  # absolute path because we want to access the address
1✔
1414
        if isinstance(child, Extract):
1✔
1415
            old_parent = child.container
1✔
1416
            old_parent.children = [c for c in old_parent.children if c.slug != child.slug]
1✔
1417
            adoptive_parent.add_extract(child, True)
1✔
1418
        else:
1419
            old_parent = child.parent
1✔
1420
            old_parent.children = [c for c in old_parent.children if c.slug != child.slug]
1✔
1421
            adoptive_parent.add_container(child, True)
1✔
1422
        self.repository.index.move([old_path, child.get_path(False)])
1✔
1423
        old_parent.update_children()
1✔
1424
        adoptive_parent.update_children()
1✔
1425
        self.dump_json()
1✔
1426

1427

1428
class PublicContent(VersionedContent):
3✔
1429
    """This is the public version of a VersionedContent, created from public repository"""
1430

1431
    def __init__(self, current_version, _type, title, slug):
3✔
1432
        """This initialisation function avoid the loading of the Git repository
1433

1434
        :param current_version: version of the content
1435
        :param _type: either "TUTORIAL", "ARTICLE" or "OPINION"
1436
        :param title: title of the content
1437
        :param slug: slug of the content
1438
        """
1439

1440
        super().__init__(current_version, _type, title, slug)
3✔
1441
        self.PUBLIC = True
3✔
1442

1443

1444
class NotAPublicVersion(Exception):
3✔
1445
    """Exception raised when a given version is not a public version as it should be"""
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