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

zestedesavoir / zds-site / 21301029245

23 Jan 2026 09:00PM UTC coverage: 89.343% (+0.003%) from 89.34%
21301029245

Pull #6775

github

web-flow
Merge bf089877e into a92dcf22a
Pull Request #6775: Feat/6633 test que le html qu'on génère est toujours valide

3092 of 4146 branches covered (74.58%)

93 of 103 new or added lines in 16 files covered. (90.29%)

3 existing lines in 2 files now uncovered.

17128 of 19171 relevant lines covered (89.34%)

1.91 hits per line

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

87.39
/zds/tutorialv2/models/versioned.py
1
import codecs
3✔
2
import contextlib
3✔
3
import copy
3✔
4
import os
3✔
5
import shutil
3✔
6
from pathlib import Path
3✔
7

8
from django.conf import settings
3✔
9
from django.core.exceptions import PermissionDenied
3✔
10
from django.template.loader import render_to_string
3✔
11
from django.urls import reverse
3✔
12
from django.utils.translation import gettext_lazy as _
3✔
13
from git import Repo
3✔
14

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

24

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

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

32
    It also has a tree depth.
33

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

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

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

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

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

65
        self.slug_pool = default_slug_pool()
3✔
66

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

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

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

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

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

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

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

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

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

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

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

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

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

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

161
        :return: tree level
162
        :rtype: int
163
        """
164

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

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

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

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

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

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

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

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

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

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

212
        max_slug_size = settings.ZDS_APP["content"]["maximum_slug_size"]
3✔
213

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

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

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

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

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

239
        :param slug: the slug to add
240
        :raise InvalidOperationErrpr: if the slug already exists
241

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

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

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

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

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

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

285
        .. attention::
286

287
            This function will raise an Exception if the publication
288
            is an article
289

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

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

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

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

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

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

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

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

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

374
        if self.has_extracts():
3✔
375
            path += "." + file_ext
3✔
376

377
        return path
3✔
378

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

386
    def get_absolute_url_online(self):
3✔
387
        """
388

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

394
        if self.parent:
2!
395
            base = self.parent.get_absolute_url_online()
2✔
396

397
        base += self.slug + "/"
2✔
398

399
        return base
2✔
400

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

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

412
            return base + self.slug + "/"
1✔
413
        else:
414
            return self.get_absolute_url()
1✔
415

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

430
        return reverse("content:edit-container", args=args)
1✔
431

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

446
        return reverse("content:delete", args=args)
1✔
447

448
    @property
3✔
449
    def add_extract_url(self):
3✔
450
        root_content = self.top_container()
1✔
451
        if self.parent.is_top_container:
1!
452
            return reverse("content:create-extract", args=[root_content.pk, root_content.slug, self.slug])
1✔
NEW
453
        return reverse("content:create-extract", args=[root_content.pk, root_content.slug, self.parent.slug, self.slug])
×
454

455
    @property
3✔
456
    def add_container_url(self):
3✔
457
        root_content = self.top_container()
1✔
458
        if self.parent == root_content:
1!
459
            return reverse("content:create-container", args=[root_content.pk, root_content.slug, self.slug])
1✔
NEW
460
        return reverse(
×
461
            "content:create-container", args=[root_content.pk, root_content.slug, self.parent.slug, self.slug]
462
        )
463

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

479
    def get_conclusion(self):
3✔
480
        """
481
        :return: the conclusion from the file in ``self.conclusion``
482
        :rtype: str
483
        """
484
        if self.conclusion:
3✔
485
            return (
3✔
486
                get_blob(
487
                    self.top_container().repository.commit(self.top_container().current_version).tree,
488
                    self.conclusion.replace("\\", "/"),
489
                )
490
                or ""
491
            )
492
        return ""
1✔
493

494
    def get_introduction_online(self):
3✔
495
        """The introduction 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.introduction:
3!
503
            path = os.path.join(self.top_container().get_prod_path(), self.introduction)
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_conclusion_online(self):
3✔
509
        """The conclusion content for online version.
510

511
        This method should be only used in templates
512

513
        :return: the full text if introduction exists, empty string otherwise
514
        :rtype: str
515
        """
516
        if self.conclusion:
3!
517
            path = os.path.join(self.top_container().get_prod_path(), self.conclusion)
3✔
518
            if os.path.isfile(path):
3!
519
                return codecs.open(path, "r", encoding="utf-8").read()
3✔
520
        return ""
×
521

522
    def get_content_online(self):
3✔
523
        if os.path.isfile(self.get_prod_path()):
2!
524
            return codecs.open(self.get_prod_path(), "r", encoding="utf-8").read()
2✔
525

526
    def compute_hash(self):
3✔
527
        """Compute an MD5 hash from the introduction and conclusion, for comparison purpose
528

529
        :return: MD5 hash
530
        :rtype: str
531
        """
532

533
        files = []
1✔
534
        if self.introduction:
1✔
535
            files.append(os.path.join(self.top_container().get_path(), self.introduction))
1✔
536
        if self.conclusion:
1✔
537
            files.append(os.path.join(self.top_container().get_path(), self.conclusion))
1✔
538

539
        return compute_hash(files)
1✔
540

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

544
        :param title: the new title
545
        :param introduction: the new introduction text
546
        :param conclusion: the new conclusion text
547
        :param commit_message: commit message that will be used instead of the default one
548
        :param do_commit: perform the commit in repository if ``True``
549
        :return: commit sha
550
        :rtype: str
551
        """
552

553
        if title is None:
3!
554
            raise PermissionDenied
×
555

556
        repo = self.top_container().repository
3✔
557

558
        # update title
559
        if title != self.title:
3✔
560
            self.title = title
2✔
561
            if self.get_tree_depth() > 0:  # if top container, slug is generated from DB, so already changed
2✔
562
                old_path = self.get_path(relative=True)
2✔
563
                old_slug = self.slug
2✔
564

565
                # move things
566
                if update_slug:
2!
567
                    self.slug = self.parent.get_unique_slug(title)
2✔
568
                    new_path = self.get_path(relative=True)
2✔
569
                    repo.index.move([old_path, new_path])
2✔
570

571
                # update manifest
572
                self.update_children()
2✔
573

574
                # update parent children dict:
575
                self.parent.children_dict.pop(old_slug)
2✔
576
                self.parent.children_dict[self.slug] = self
2✔
577

578
        # update introduction and conclusion (if any)
579
        path = self.top_container().get_path()
3✔
580
        rel_path = self.get_path(relative=True)
3✔
581

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

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

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

596
        if conclusion is not None:
3✔
597
            if self.conclusion is None:
3✔
598
                self.conclusion = os.path.join(rel_path, "conclusion.md")
3✔
599

600
            f = codecs.open(os.path.join(path, self.conclusion), "w", encoding="utf-8")
3✔
601
            f.write(conclusion)
3✔
602
            f.close()
3✔
603
            repo.index.add([self.conclusion])
3✔
604

605
        elif self.conclusion:
1✔
606
            repo.index.remove([self.conclusion])
1✔
607
            os.remove(os.path.join(path, self.conclusion))
1✔
608
            self.conclusion = None
1✔
609

610
        self.top_container().dump_json()
3✔
611
        repo.index.add(["manifest.json"])
3✔
612

613
        if not commit_message:
3✔
614
            commit_message = _("Mise à jour de « {} »").format(self.title)
3✔
615

616
        if do_commit:
3✔
617
            return self.top_container().commit_changes(commit_message)
3✔
618

619
    def repo_add_container(
3✔
620
        self, title, introduction, conclusion, commit_message="", do_commit=True, slug=None, ready_to_publish=None
621
    ):
622
        """
623
        :param title: title of the new container
624
        :param introduction: text of its introduction
625
        :param conclusion: text of its conclusion
626
        :param commit_message: commit message that will be used instead of the default one
627
        :param do_commit: perform the commit in repository if ``True``
628
        :return: commit sha
629
        :rtype: str
630
        """
631
        if slug is None:
3✔
632
            subcontainer = Container(title)
3✔
633
        else:
634
            subcontainer = Container(title, slug=slug)
1✔
635
        # can a subcontainer be added ?
636
        try:
3✔
637
            self.add_container(subcontainer, generate_slug=slug is None)
3✔
638
        except InvalidOperationError:
×
639
            raise PermissionDenied
×
640

641
        # create directory
642
        repo = self.top_container().repository
3✔
643
        path = self.top_container().get_path()
3✔
644
        rel_path = subcontainer.get_path(relative=True)
3✔
645
        Path(path, rel_path).mkdir(parents=True, exist_ok=True)
3✔
646

647
        repo.index.add([rel_path])
3✔
648

649
        # make it
650
        if not commit_message:
3!
651
            commit_message = _("Création du conteneur « {} »").format(title)
3✔
652

653
        if ready_to_publish is not None:
3✔
654
            subcontainer.ready_to_publish = ready_to_publish
1✔
655
        return subcontainer.repo_update(
3✔
656
            title, introduction, conclusion, commit_message=commit_message, do_commit=do_commit
657
        )
658

659
    def repo_add_extract(self, title, text, commit_message="", do_commit=True, slug=None):
3✔
660
        """
661
        :param title: title of the new extract
662
        :param text: text of the new extract
663
        :param commit_message: commit message that will be used instead of the default one
664
        :param do_commit: perform the commit in repository if ``True``
665
        :param generate_slug: indicates that is must generate slug
666
        :return: commit sha
667
        :rtype: str
668
        """
669
        if not slug:
3✔
670
            extract = Extract(title)
3✔
671
        else:
672
            extract = Extract(title, slug)
1✔
673
        # can an extract be added ?
674
        try:
3✔
675
            self.add_extract(extract, generate_slug=slug is None)
3✔
676
        except InvalidOperationError:
×
677
            raise PermissionDenied
×
678

679
        # make it
680
        if not commit_message:
3!
681
            commit_message = _("Création de l'extrait « {} »").format(title)
3✔
682

683
        return extract.repo_update(title, text, commit_message=commit_message, do_commit=do_commit)
3✔
684

685
    def repo_delete(self, commit_message="", do_commit=True):
3✔
686
        """
687
        :param commit_message: commit message used instead of default one if provided
688
        :param do_commit: tells if we have to commit the change now or let the outer program do it
689
        :return: commit sha
690
        :rtype: str
691
        """
692
        path = self.get_path(relative=True)
2✔
693
        repo = self.top_container().repository
2✔
694
        repo.index.remove([path], r=True)
2✔
695
        shutil.rmtree(self.get_path())  # looks like removing from git is not enough!!
2✔
696

697
        # now, remove from manifest
698
        # work only if slug is correct
699
        top = self.parent
2✔
700
        top.children_dict.pop(self.slug)
2✔
701
        top.children.pop(top.children.index(self))
2✔
702

703
        # commit
704
        top.top_container().dump_json()
2✔
705
        repo.index.add(["manifest.json"])
2✔
706

707
        if not commit_message:
2!
708
            commit_message = _("Suppression du conteneur « {} »").format(self.title)
2✔
709

710
        if do_commit:
2✔
711
            return self.top_container().commit_changes(commit_message)
2✔
712

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

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

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

734
        :param child_slug: the child's slug
735
        :raise ValueError: if the slug does not refer to an existing child
736
        :raise IndexError: if the extract is already the last child
737
        """
738
        if child_slug not in self.children_dict:
1!
739
            raise ValueError(_(child_slug + " n'existe pas."))
×
740
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
741
        if child_pos == len(self.children) - 1:
1!
742
            raise IndexError(_(child_slug + " est le dernier élément."))
×
743
        self.children[child_pos], self.children[child_pos + 1] = self.children[child_pos + 1], self.children[child_pos]
1✔
744
        self.children[child_pos].position_in_parent = child_pos
1✔
745
        self.children[child_pos + 1].position_in_parent = child_pos + 1
1✔
746

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

751
        :param child_slug: the child's slug
752
        :param refer_slug: the referent child's slug.
753
        :raise ValueError: if one slug does not refer to an existing child
754
        """
755
        if child_slug not in self.children_dict:
1!
756
            raise ValueError(_(child_slug + " n'existe pas."))
×
757
        if refer_slug not in self.children_dict:
1!
758
            raise ValueError(_(refer_slug + " n'existe pas."))
×
759
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
760
        refer_pos = self.children.index(self.children_dict[refer_slug])
1✔
761

762
        # if we want our child to get down (reference is an lower child)
763
        if child_pos < refer_pos:
1✔
764
            for i in range(child_pos, refer_pos):
1✔
765
                self.move_child_down(child_slug)
1✔
766
        elif child_pos > refer_pos:
1!
767
            # if we want our child to get up (reference is an upper child)
768
            for i in range(child_pos, refer_pos + 1, -1):
1!
769
                self.move_child_up(child_slug)
×
770

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

775
        :param child_slug: the child's slug
776
        :param refer_slug: the referent child's slug.
777
        :raise ValueError: if one slug does not refer to an existing child
778
        """
779
        if child_slug not in self.children_dict:
1!
780
            raise ValueError(_(child_slug + " n'existe pas."))
×
781
        if refer_slug not in self.children_dict:
1!
782
            raise ValueError(_(refer_slug + " n'existe pas."))
×
783
        child_pos = self.children.index(self.children_dict[child_slug])
1✔
784
        refer_pos = self.children.index(self.children_dict[refer_slug])
1✔
785

786
        # if we want our child to get down (reference is an lower child)
787
        if child_pos < refer_pos:
1✔
788
            for i in range(child_pos, refer_pos - 1):
1✔
789
                self.move_child_down(child_slug)
1✔
790
        elif child_pos > refer_pos:
1!
791
            # if we want our child to get up (reference is an upper child)
792
            for i in range(child_pos, refer_pos, -1):
1✔
793
                self.move_child_up(child_slug)
1✔
794

795
    def traverse(self, only_container=True):
3✔
796
        """Traverse the container.
797

798
        :param only_container: if we only want container's paths, not extract
799
        :return: a generator that traverse all the container recursively (depth traversal)
800
        :rtype: collections.Iterable[Container|Extract]
801
        """
802
        yield self
1✔
803
        for child in self.children:
1✔
804
            if isinstance(child, Container):
1✔
805
                yield from child.traverse(only_container)
1✔
806
            elif not only_container:
1✔
807
                yield child
1✔
808

809
    def get_level_as_string(self):
3✔
810
        """Get a word (Part/Chapter/Section) for the container level
811

812
        .. attention::
813

814
            this deals with internationalized string
815

816
        :return: The string representation of this level
817
        :rtype: str
818
        """
819
        if self.get_tree_depth() == 0:
1✔
820
            return self.type
1✔
821
        elif self.get_tree_depth() == 1:
1✔
822
            return _("Partie")
1✔
823
        elif self.get_tree_depth() == 2:
1!
824
            return _("Chapitre")
1✔
825
        return _("Sous-chapitre")
×
826

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

830
        :return: The string representation of next level (upper)
831
        :rtype: str
832
        """
833
        if self.get_tree_depth() == 0 and self.can_add_container():
×
834
            return _("Partie")
×
835
        elif self.get_tree_depth() == 1 and self.can_add_container():
×
836
            return _("Chapitre")
×
837
        else:
838
            return _("Section")
×
839

840
    def is_chapter(self):
3✔
841
        """
842
        Check if the container level is Chapter
843

844
        :return: True if the container level is Chapter
845
        :rtype: bool
846
        """
847
        if self.get_tree_depth() == 2:
1✔
848
            return True
1✔
849
        return False
1✔
850

851
    def next_level_is_chapter(self):
3✔
852
        """Same as ``self.is_chapter()`` but check the container's children
853

854
        :return: True if the container next level is Chapter
855
        :rtype: bool
856
        """
857
        if self.get_tree_depth() == 1 and self.can_add_container():
×
858
            return True
×
859
        return False
×
860

861
    def remove_children(self, children_slugs):
3✔
862
        for slug in children_slugs:
×
863
            if slug not in self.children_dict:
×
864
                continue
×
865
            to_be_remove = self.children_dict[slug]
×
866
            self.children.remove(to_be_remove)
×
867
            del self.children_dict[slug]
×
868

869
    def _dump_html(self, file_path, content, db_object):
3✔
870
        try:
×
871
            with file_path.open("w", encoding="utf-8") as f:
×
872
                f.write(emarkdown(content, db_object.js_support))
×
873
        except (UnicodeError, UnicodeEncodeError):
×
874
            from zds.tutorialv2.publication_utils import FailureDuringPublication
×
875

876
            raise FailureDuringPublication(
×
877
                _(
878
                    "Une erreur est survenue durant la publication de l'introduction de « {} »,"
879
                    " vérifiez le code markdown"
880
                ).format(self.title)
881
            )
882

883
    def publish_introduction_and_conclusion(self, base_dir, db_object):
3✔
884
        if self.has_extracts():
×
885
            return
×
886
        current_dir_path = Path(base_dir, self.get_prod_path(relative=True))  # create subdirectory
×
887
        current_dir_path.mkdir(parents=True, exist_ok=True)
×
888

889
        if self.introduction:
×
890
            path = current_dir_path / "introduction.html"
×
891
            self._dump_html(path, self.get_introduction(), db_object)
×
892
            self.introduction = Path(self.get_path(relative=True), "introduction.html")
×
893

894
        if self.conclusion:
×
895
            path = current_dir_path / "conclusion.html"
×
896
            self._dump_html(path, self.get_conclusion(), db_object)
×
897
            self.conclusion = Path(self.get_path(relative=True), "conclusion.html")
×
898

899
    def publish_extracts(self, base_dir, is_js, template, failure_exception):
3✔
900
        """
901
        Publish the current element thanks to a templated view.
902

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

923
    def is_validable(self):
3✔
924
        """Return ``True`` if the container would be in the public version if the content is validated."""
925
        if self.parent is not None and not self.parent.is_validable():
1!
926
            return False
×
927
        return self.ready_to_publish
1✔
928

929

930
class Extract:
3✔
931
    """
932
    A content extract from a Container.
933

934
    It has a title, a position in the parent container and a text.
935
    """
936

937
    title = ""
3✔
938
    slug = ""
3✔
939
    container = None
3✔
940
    position_in_parent = 1
3✔
941
    text = None
3✔
942

943
    def __init__(self, title, slug="", container=None, position_in_parent=1):
3✔
944
        self.title = title
3✔
945
        self.slug = slug
3✔
946
        self.container = container
3✔
947
        self.position_in_parent = position_in_parent
3✔
948

949
    def __str__(self):
3✔
950
        return f"<Extrait '{self.title}'>"
×
951

952
    def get_url_path(self, base_url=""):
3✔
953
        return f"{base_url}{self.container.get_url_path()}#{self.position_in_parent}-{self.slug}"
1✔
954

955
    def get_absolute_url(self):
3✔
956
        """Find the url that point to the offline version of this extract
957

958
        :return: the url to access the tutorial offline
959
        :rtype: str
960
        """
961
        return f"{self.container.get_absolute_url()}#{self.position_in_parent}-{self.slug}"
1✔
962

963
    def get_absolute_url_online(self):
3✔
964
        """
965
        :return: the url to access the tutorial when online
966
        :rtype: str
967
        """
968
        return f"{self.container.get_absolute_url_online()}#{self.position_in_parent}-{self.slug}"
×
969

970
    def get_absolute_url_beta(self):
3✔
971
        """
972
        :return: the url to access the tutorial when in beta
973
        :rtype: str
974
        """
975
        return f"{self.container.get_absolute_url_beta()}#{self.position_in_parent}-{self.slug}"
×
976

977
    def get_edit_url(self):
3✔
978
        """
979
        :return: url to edit the extract
980
        :rtype: str
981
        """
982
        slugs = [self.slug]
1✔
983
        parent = self.container
1✔
984
        while parent is not None:
1✔
985
            slugs.append(parent.slug)
1✔
986
            parent = parent.parent
1✔
987
        slugs.reverse()
1✔
988
        args = [self.container.top_container().pk]
1✔
989
        args.extend(slugs)
1✔
990

991
        return reverse("content:edit-extract", args=args)
1✔
992

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

996
        This method is an alias to ``extract.get_path(True)[:-3]``
997
        (removes the ``.md`` file extension).
998

999
        :rtype: str
1000

1001
        """
1002
        return self.get_path(True)[:-3]
1✔
1003

1004
    def get_first_level_slug(self):
3✔
1005
        """
1006
        :return: the ``first_level_slug``, if (and only if) the parent container is a chapter
1007
        :rtype: str
1008
        """
1009
        if self.container.get_tree_depth() == 2:
1✔
1010
            return self.container.parent.slug
1✔
1011

1012
        return ""
1✔
1013

1014
    def get_delete_url(self):
3✔
1015
        """
1016
        :return: URL to delete the extract
1017
        :rtype: str
1018
        """
1019
        slugs = [self.slug]
1✔
1020
        parent = self.container
1✔
1021
        while parent is not None:
1✔
1022
            slugs.append(parent.slug)
1✔
1023
            parent = parent.parent
1✔
1024
        slugs.reverse()
1✔
1025
        args = [self.container.top_container().pk]
1✔
1026
        args.extend(slugs)
1✔
1027

1028
        return reverse("content:delete", args=args)
1✔
1029

1030
    def get_path(self, relative=False):
3✔
1031
        """
1032
        Get the physical path to the draft version of the extract.
1033
        :param relative: if ``True``, the path will be relative, absolute otherwise.
1034
        :return: physical path
1035
        :rtype: str
1036
        """
1037
        return os.path.join(self.container.get_path(relative=relative), self.slug) + ".md"
3✔
1038

1039
    def get_text(self):
3✔
1040
        """
1041
        :return: versioned text
1042
        :rtype: str
1043
        """
1044
        if self.text:
3!
1045
            return get_blob(
3✔
1046
                self.container.top_container().repository.commit(self.container.top_container().current_version).tree,
1047
                self.text.replace("\\", "/"),
1048
            )
1049
        return ""
×
1050

1051
    def compute_hash(self):
3✔
1052
        """Compute an MD5 hash from the text, for comparison purpose
1053

1054
        :return: MD5 hash of the text
1055
        :rtype: str
1056
        """
1057

1058
        if self.text:
1!
1059
            return compute_hash([self.get_path()])
1✔
1060

1061
        else:
1062
            return compute_hash([])
×
1063

1064
    def repo_update(self, title, text, commit_message="", do_commit=True):
3✔
1065
        """
1066
        :param title: new title of the extract
1067
        :param text: new text of the extract
1068
        :param commit_message: commit message that will be used instead of the default one
1069
        :return: commit sha
1070
        :rtype: str
1071
        """
1072

1073
        if title is None:
3!
1074
            raise PermissionDenied
×
1075

1076
        repo = self.container.top_container().repository
3✔
1077

1078
        if title != self.title:
3✔
1079
            # get a new slug
1080
            old_path = self.get_path(relative=True)
2✔
1081
            old_slug = self.slug
2✔
1082
            self.title = title
2✔
1083
            self.slug = self.container.get_unique_slug(title)
2✔
1084

1085
            # move file
1086
            new_path = self.get_path(relative=True)
2✔
1087
            repo.index.move([old_path, new_path])
2✔
1088

1089
            # update parent children dict:
1090
            self.container.children_dict.pop(old_slug)
2✔
1091
            self.container.children_dict[self.slug] = self
2✔
1092

1093
        # edit text
1094
        path = self.container.top_container().get_path()
3✔
1095

1096
        if text is not None:
3✔
1097
            self.text = self.get_path(relative=True)
3✔
1098
            f = codecs.open(os.path.join(path, self.text), "w", encoding="utf-8")
3✔
1099
            f.write(text)
3✔
1100
            f.close()
3✔
1101

1102
            repo.index.add([self.text])
3✔
1103

1104
        elif self.text:
1✔
1105
            if os.path.exists(os.path.join(path, self.text)):
1!
1106
                repo.index.remove([self.text])
1✔
1107
                os.remove(os.path.join(path, self.text))
1✔
1108

1109
            self.text = None
1✔
1110

1111
        # make it
1112
        self.container.top_container().dump_json()
3✔
1113
        repo.index.add(["manifest.json"])
3✔
1114

1115
        if not commit_message:
3✔
1116
            commit_message = _("Modification de l'extrait « {} », situé dans le conteneur « {} »").format(
2✔
1117
                self.title, self.container.title
1118
            )
1119

1120
        if do_commit:
3✔
1121
            return self.container.top_container().commit_changes(commit_message)
3✔
1122

1123
    def repo_delete(self, commit_message="", do_commit=True):
3✔
1124
        """
1125
        :param commit_message: commit message used instead of default one if provided
1126
        :param do_commit: tells if we have to commit the change now or let the outer program do it
1127
        :return: commit sha, None if no commit is done
1128
        :rtype: str
1129
        """
1130
        path = self.text
1✔
1131
        repo = self.container.top_container().repository
1✔
1132

1133
        repo.index.remove([path])
1✔
1134
        os.remove(self.get_path())  # looks like removing from git is not enough
1✔
1135

1136
        # now, remove from manifest
1137
        # work only if slug is correct!!
1138
        top = self.container
1✔
1139
        top.children_dict.pop(self.slug, None)
1✔
1140
        top.children.pop(top.children.index(self))
1✔
1141

1142
        # commit
1143
        top.top_container().dump_json()
1✔
1144
        repo.index.add(["manifest.json"])
1✔
1145

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

1149
        if do_commit:
1✔
1150
            return self.container.top_container().commit_changes(commit_message)
1✔
1151

1152
    def get_tree_depth(self):
3✔
1153
        """Return the depth where this extract is found.
1154

1155
        The tree depth of an extract is the number of parents of that
1156
        extract. It is always lower that 4 because an extract can have
1157
        at most three parents.
1158

1159
        Keep in mind that containers can't reach a depth of 3 in the
1160
        document tree since there are not leaves.
1161

1162
        :return: Tree depth
1163
        :rtype: int
1164
        """
1165
        depth = 1
1✔
1166
        current = self.container
1✔
1167
        while current.parent is not None:
1✔
1168
            current = current.parent
1✔
1169
            depth += 1
1✔
1170
        return depth
1✔
1171

1172
    def is_validable(self):
3✔
1173
        return self.container.is_validable()
1✔
1174

1175

1176
class VersionedContent(Container, TemplatableContentModelMixin):
3✔
1177
    """
1178
    This class is used to handle a specific version of a content.
1179

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

1182
    For simplicity, it also contains read-only DB information filled at the creation.
1183
    """
1184

1185
    current_version = None
3✔
1186
    slug_repository = ""
3✔
1187
    repository = None
3✔
1188

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

1191
    # Metadata from json :
1192
    description = ""
3✔
1193
    type = ""
3✔
1194
    licence = None
3✔
1195

1196
    # Metadata from DB :
1197
    pk = 0
3✔
1198
    sha_draft = None
3✔
1199
    sha_beta = None
3✔
1200
    sha_public = None
3✔
1201
    sha_validation = None
3✔
1202
    sha_picked = None
3✔
1203
    is_beta = False
3✔
1204
    is_validation = False
3✔
1205
    is_public = False
3✔
1206
    in_beta = False
3✔
1207
    in_validation = False
3✔
1208
    in_public = False
3✔
1209

1210
    authors = None
3✔
1211
    subcategory = None
3✔
1212
    image = None
3✔
1213
    creation_date = None
3✔
1214
    pubdate = None
3✔
1215
    update_date = None
3✔
1216
    source = None
3✔
1217
    antispam = True
3✔
1218
    tags = None
3✔
1219
    converted_to = None
3✔
1220
    content_type_attribute = "type"
3✔
1221

1222
    def __copy__(self):
3✔
1223
        cpy = self.__class__(self.current_version, self.type, self.title, self.slug, self.slug_repository)
×
1224
        cpy.children_dict = copy.copy(self.children_dict)
×
1225
        cpy.children = copy.copy(self.children)
×
1226
        cpy.slug_pool = copy.deepcopy(self.slug_pool)
×
1227
        cpy.ready_to_publish = self.ready_to_publish
×
1228
        cpy.introduction = self.introduction
×
1229
        cpy.conclusion = self.conclusion
×
1230
        for attr in dir(cpy):
×
1231
            value = getattr(cpy, attr)
×
1232
            if not value and not callable(value):
×
1233
                setattr(cpy, attr, getattr(self, attr))
×
1234
        return cpy
×
1235

1236
    def __init__(self, current_version, _type, title, slug, slug_repository=""):
3✔
1237
        """
1238
        :param current_version: version of the content
1239
        :param _type: either "TUTORIAL", "ARTICLE" or "OPINION"
1240
        :param title: title of the content
1241
        :param slug: slug of the content
1242
        :param slug_repository: slug of the directory that contains the repository, named after database slug.
1243
            if not provided, use ``self.slug`` instead.
1244
        """
1245

1246
        Container.__init__(self, title, slug)
3✔
1247
        self.current_version = current_version
3✔
1248
        self.type = _type
3✔
1249

1250
        if slug_repository != "":
3✔
1251
            self.slug_repository = slug_repository
3✔
1252
        else:
1253
            self.slug_repository = slug
3✔
1254

1255
        if self.slug != "" and os.path.exists(self.get_path()):
3✔
1256
            self.repository = Repo(self.get_path())
3✔
1257

1258
    def __str__(self):
3✔
1259
        return self.title
1✔
1260

1261
    def requires_validation(self) -> bool:
3✔
1262
        return self.type in CONTENT_TYPES_REQUIRING_VALIDATION
2✔
1263

1264
    def get_absolute_url(self, version=None):
3✔
1265
        return TemplatableContentModelMixin.get_absolute_url(self, version)
3✔
1266

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

1270
        :return: internationalized string
1271
        :rtype: str
1272
        """
1273
        if self.is_article:
1!
1274
            return _("L’Article")
×
1275
        elif self.is_tutorial:
1!
1276
            return _("Le Tutoriel")
1✔
1277
        elif self.is_opinion:
×
1278
            return _("Le Billet")
×
1279
        else:
1280
            return _("Le Contenu")
×
1281

1282
    def get_absolute_url_online(self):
3✔
1283
        """
1284

1285
        :return: the url to access the content when online
1286
        :rtype: str
1287
        """
1288
        _reversed = ""
3✔
1289

1290
        if self.is_article:
3✔
1291
            _reversed = "article"
3✔
1292
        elif self.is_tutorial:
3✔
1293
            _reversed = "tutorial"
3✔
1294
        elif self.is_opinion:
2!
1295
            _reversed = "opinion"
2✔
1296
        return reverse(_reversed + ":view", kwargs={"pk": self.pk, "slug": self.slug})
3✔
1297

1298
    def get_absolute_url_beta(self):
3✔
1299
        """
1300
        :return: the url to access the tutorial when in beta
1301
        :rtype: str
1302
        """
1303
        if self.in_beta:
3✔
1304
            return reverse("content:beta-view", args=[self.pk, self.slug])
1✔
1305
        else:
1306
            return self.get_absolute_url()
3✔
1307

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

1311
        :param relative: if ``True``, the path will be relative, absolute otherwise.
1312
        :param use_current_slug: if ``True``, use ``self.slug`` instead of ``self.slug_last_draft``
1313
        :return: physical path
1314
        :rtype: str
1315
        """
1316
        if relative:
3✔
1317
            return ""
3✔
1318
        else:
1319
            slug = self.slug_repository
3✔
1320
            if use_current_slug:
3✔
1321
                slug = self.slug
2✔
1322
            return os.path.join(settings.ZDS_APP["content"]["repo_private_path"], slug)
3✔
1323

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

1329
        :param relative: return the relative path instead of the absolute one
1330
        :return: physical path
1331
        :rtype: str
1332

1333
        """
1334
        path = ""
3✔
1335

1336
        if not relative:
3✔
1337
            path = os.path.join(settings.ZDS_APP["content"]["repo_public_path"], self.slug)
3✔
1338

1339
        if self.has_extracts():
3✔
1340
            path = os.path.join(path, self.slug + "." + file_ext)
2✔
1341

1342
        return path
3✔
1343

1344
    def get_list_of_chapters(self) -> list[Container]:
3✔
1345
        continuous_list = []
1✔
1346
        if len(self.children) != 0 and isinstance(self.children[0], Container):  # children must be Containers!
1!
1347
            for child in self.children:
1✔
1348
                if len(child.children) != 0:
1!
1349
                    if isinstance(child.children[0], Extract):
1!
1350
                        continuous_list.append(child)  # it contains Extract, this is a chapter, so paginated
1✔
1351
                    else:  # Container is a part
1352
                        for sub_child in child.children:
×
1353
                            continuous_list.append(sub_child)  # even if `sub_child.children` is empty, it's a chapter
×
1354
        return continuous_list
1✔
1355

1356
    def get_json(self):
3✔
1357
        """
1358
        :return: raw JSON file
1359
        :rtype: str
1360
        """
1361
        dct = export_content(self)
3✔
1362
        data = json_handler.dumps(dct, indent=4, ensure_ascii=False)
3✔
1363
        return data
3✔
1364

1365
    def dump_json(self, path=None):
3✔
1366
        """Write the JSON into file
1367

1368
        :param path: path to the file. If ``None``, write in 'manifest.json'
1369
        """
1370
        if path is None:
3✔
1371
            man_path = os.path.join(self.get_path(), "manifest.json")
3✔
1372
        else:
1373
            man_path = path
3✔
1374
        json_data = codecs.open(man_path, "w", encoding="utf-8")
3✔
1375
        json_data.write(self.get_json())
3✔
1376
        json_data.close()
3✔
1377

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

1382
        :param title: the new title
1383
        :param slug: the new slug, according to title (choose using DB!!)
1384
        :param introduction: the new introduction text
1385
        :param conclusion: the new conclusion text
1386
        :param commit_message: commit message that will be used instead of the default one
1387
        :param do_commit: if ``True``, also commit change
1388
        :return: commit sha
1389
        :rtype: str
1390
        """
1391

1392
        if slug != self.slug:
2✔
1393
            # move repository
1394
            old_path = self.get_path(use_current_slug=True)
2✔
1395
            self.slug = slug
2✔
1396
            new_path = self.get_path(use_current_slug=True)
2✔
1397
            shutil.move(old_path, new_path)
2✔
1398
            self.repository = Repo(new_path)
2✔
1399
            self.slug_repository = slug
2✔
1400

1401
        return self.repo_update(title, introduction, conclusion, commit_message=commit_message, do_commit=do_commit)
2✔
1402

1403
    def commit_changes(self, commit_message):
3✔
1404
        """Commit change made to the repository
1405

1406
        :param commit_message: The message that will appear in content history
1407
        :return: commit sha
1408
        :rtype: str
1409
        """
1410
        cm = self.repository.index.commit(commit_message, **get_commit_author())
3✔
1411

1412
        self.sha_draft = cm.hexsha
3✔
1413
        self.current_version = cm.hexsha
3✔
1414

1415
        return cm.hexsha
3✔
1416

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

1421
        :param child: the child we want to move, can be either an Extract or a Container object
1422
        :param adoptive_parent: the container where the child *will be* moved, must be a Container object
1423
        """
1424

1425
        old_path = child.get_path(False)  # absolute path because we want to access the address
1✔
1426
        if isinstance(child, Extract):
1✔
1427
            old_parent = child.container
1✔
1428
            old_parent.children = [c for c in old_parent.children if c.slug != child.slug]
1✔
1429
            adoptive_parent.add_extract(child, True)
1✔
1430
        else:
1431
            old_parent = child.parent
1✔
1432
            old_parent.children = [c for c in old_parent.children if c.slug != child.slug]
1✔
1433
            adoptive_parent.add_container(child, True)
1✔
1434
        self.repository.index.move([old_path, child.get_path(False)])
1✔
1435
        old_parent.update_children()
1✔
1436
        adoptive_parent.update_children()
1✔
1437
        self.dump_json()
1✔
1438

1439

1440
class PublicContent(VersionedContent):
3✔
1441
    """This is the public version of a VersionedContent, created from public repository"""
1442

1443
    def __init__(self, current_version, _type, title, slug):
3✔
1444
        """This initialisation function avoid the loading of the Git repository
1445

1446
        :param current_version: version of the content
1447
        :param _type: either "TUTORIAL", "ARTICLE" or "OPINION"
1448
        :param title: title of the content
1449
        :param slug: slug of the content
1450
        """
1451

1452
        super().__init__(current_version, _type, title, slug)
3✔
1453
        self.PUBLIC = True
3✔
1454

1455

1456
class NotAPublicVersion(Exception):
3✔
1457
    """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