diff --git a/cookiecutter.json b/cookiecutter.json index e2cd66d..399f56d 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -5,7 +5,7 @@ "version": "0.1.0", - "_bireli_version": "0.2.0-pre.13", + "_bireli_version": "0.2.0-pre.14", "_python_version": ">=3.8", "_django_version": ">=4.0,<4.1", "_project_composer_version": ">=0.6.0,<0.7.0", diff --git a/{{ cookiecutter.project_name }}/Makefile b/{{ cookiecutter.project_name }}/Makefile index 75a7290..2240995 100644 --- a/{{ cookiecutter.project_name }}/Makefile +++ b/{{ cookiecutter.project_name }}/Makefile @@ -41,6 +41,7 @@ help: @echo " check-migrations -- to check for pending application migrations (do not write anything)" @echo " migrate -- to apply demo database migrations" @echo " superuser -- to create a superuser for Django admin" + @echo " initial-data -- to load initial data" @echo @echo " css -- to build CSS with default environnement" @echo " watch-css -- to launch watcher CSS with default environnement" @@ -174,6 +175,13 @@ superuser: $(DJANGO_MANAGE) createsuperuser .PHONY: superuser +initial-data: + @echo "" + @printf "$(FORMATBLUE)$(FORMATBOLD)---> Loading initial data <---$(FORMATRESET)\n" + @echo "" + $(DJANGO_MANAGE) emencia_initial django-apps/project_utils/initial.json +.PHONY: initial-data + run: @echo "" @printf "$(FORMATBLUE)$(FORMATBOLD)---> Running development server <---$(FORMATRESET)\n" diff --git a/{{ cookiecutter.project_name }}/django-apps/project_utils/commands/management/emencia_initial.py b/{{ cookiecutter.project_name }}/django-apps/project_utils/commands/management/emencia_initial.py deleted file mode 100644 index 86bd5e7..0000000 --- a/{{ cookiecutter.project_name }}/django-apps/project_utils/commands/management/emencia_initial.py +++ /dev/null @@ -1,67 +0,0 @@ -import argparse -import json - -from django.conf import settings -from django.core.management.base import CommandError, BaseCommand - -from cms.api import Page - - -class Command(BaseCommand): - """ - Demo data loader. - """ - help = ( - "Populate site with demo data. It will populate CMS pages from required JSON " - "tree file. You MUST use Django command 'flush' before using this command. " - "Pages are only created for the default language." - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.created_pages = {} - - def add_arguments(self, parser): - parser.add_argument( - "dump", - metavar="FILEPATH", - type=argparse.FileType("r", encoding="UTF-8"), - help="Filepath to the JSON tree file to load." - ) - - def handle(self, *args, **options): - self.stdout.write( - self.style.SUCCESS("=== Starting demo maker ===") - ) - - raise NotImplementedError("TODO") - - # Validate available demo page templates - available_cms_templates = [item[0] for item in settings.CMS_TEMPLATES] - for k, v in settings.DEMO_PAGE_TEMPLATES.items(): - if v not in available_cms_templates: - raise CommandError(( - "Page template '{}' must be enabled in 'settings.CMS_TEMPLATES'." - ).format(v)) - - if ( - "homepage" not in settings.DEMO_PAGE_TEMPLATES.keys() or - "page" not in settings.DEMO_PAGE_TEMPLATES.keys() - ): - raise CommandError( - "Page template 'homepage' and 'page' must be defined in " - "'settings.DEMO_PAGE_TEMPLATES'." - ) - - # Validate database is empty - existing = Page.objects.all() - if existing.count(): - raise CommandError( - "Demo maker will only work if there is not any existing CMS " - "page. Did you forgot to use Django command 'flush' before ?" - ) - - self.stdout.write("* Opened JSON tree: {}".format(options["dump"].name)) - tree = json.load(options["dump"]) - - self.create_cms_pages(tree) diff --git a/{{ cookiecutter.project_name }}/django-apps/project_utils/exceptions.py b/{{ cookiecutter.project_name }}/django-apps/project_utils/exceptions.py index d3a4ce8..5768bfb 100644 --- a/{{ cookiecutter.project_name }}/django-apps/project_utils/exceptions.py +++ b/{{ cookiecutter.project_name }}/django-apps/project_utils/exceptions.py @@ -6,8 +6,8 @@ class ProjectUtilsException(Exception): pass -class DemoMakerException(ProjectUtilsException): +class InitialDataLoaderException(ProjectUtilsException): """ - Custom explicit exception from a DemoMaker process. + Custom explicit exception from a InitialDataLoader process. """ pass diff --git a/{{ cookiecutter.project_name }}/django-apps/project_utils/initial.json b/{{ cookiecutter.project_name }}/django-apps/project_utils/initial.json index c1b9b8a..9943814 100644 --- a/{{ cookiecutter.project_name }}/django-apps/project_utils/initial.json +++ b/{{ cookiecutter.project_name }}/django-apps/project_utils/initial.json @@ -14,28 +14,29 @@ "password": "django324" } }, - "pages": { - "key": "homepage", - "name": "Demo homepage", - "children": [ - { - "key": "annexes", - "name": "Annexes", - "children": [ - { - "key": "contact", - "name": "Contact" - }, - { - "key": "legal-notice", - "name": "Legal notice" - }, - { - "key": "privacy_policy", - "name": "Privacy policy" - } - ] - } - ] - } + "pages": [ + { + "key": "homepage", + "name": "Demo homepage", + "is_homepage": true + }, + { + "key": "annexes", + "name": "Annexes", + "children": [ + { + "key": "contact", + "name": "Contact" + }, + { + "key": "legal-notice", + "name": "Legal notice" + }, + { + "key": "privacy_policy", + "name": "Privacy policy" + } + ] + } + ] } diff --git a/{{ cookiecutter.project_name }}/django-apps/project_utils/helpers.py b/{{ cookiecutter.project_name }}/django-apps/project_utils/initial_loader.py similarity index 76% rename from {{ cookiecutter.project_name }}/django-apps/project_utils/helpers.py rename to {{ cookiecutter.project_name }}/django-apps/project_utils/initial_loader.py index 31baa71..6f6a67d 100644 --- a/{{ cookiecutter.project_name }}/django-apps/project_utils/helpers.py +++ b/{{ cookiecutter.project_name }}/django-apps/project_utils/initial_loader.py @@ -86,7 +86,7 @@ Currently this is hardly coupled to Django site, Django auth and CMS. This is not really flexible and will be broken if CMS is not enabled. -This can be improved by splitting DemoMaker into application parts managed by +This could be improved by splitting InitialDataLoader into application parts managed by project-composer. """ @@ -98,12 +98,12 @@ from cms.utils import get_current_site -from .exceptions import DemoMakerException +from .exceptions import InitialDataLoaderException from .factories import PageFactory, UserFactory from .logging import BaseOutput -class DemoMaker: +class InitialDataLoader: """ Helper class that will create implemented objects from a given structure. @@ -127,11 +127,68 @@ def validate_global_author(self): """ if not self.global_author: msg = "No 'global_author' have been given despite it is required." - raise DemoMakerException(msg) + raise InitialDataLoaderException(msg) if isinstance(self.global_author, str): msg = "Unable to find created user for global author username '{}'." - raise DemoMakerException(msg.format(self.global_author)) + raise InitialDataLoaderException(msg.format(self.global_author)) + + return True + + def _recursive_get_pages(self, pages, collected=[]): + """ + Collect a flat list of page tree. + + Note than all page items won't have the children item since the collection + is definitively flat. Obviously the collection drop all the tree hierarchy + informations (the 'children' key). + + Arguments: + pages (list): The tree list of page to recursively dig for pages. + + Keyword Arguments: + collected (list): + """ + for page in pages: + collected.append({k: v for k, v in page.items() if k != "children"}) + + if page.get("children"): + self._recursive_get_pages(page.get("children"), collected=collected) + + def validate_page_tree(self, pages): + """ + Recursively validate page tree structure. + + Arguments: + pages (list): The tree list of page to recursively dig for pages. + + Returns: + boolean: + """ + + flatten_tree = [] + self._recursive_get_pages(pages, collected=flatten_tree) + + homepages = [ + item + for item in flatten_tree + if item.get("is_homepage") is True + ] + + if len(homepages) == 0: + msg = ( + "At least one homepage is required, given structure got none." + ) + raise InitialDataLoaderException(msg.format(len(homepages))) + elif len(homepages) > 1: + msg = ( + "Only a single page with 'is_homepage' is allowed but given structure " + "got {} pages with this option enabled." + ) + raise InitialDataLoaderException(msg.format(len(homepages))) + + for item in flatten_tree: + self.validate_page_data(item) return True @@ -144,18 +201,18 @@ def validate_page_data(self, data): """ if not data.get("key"): msg = "A page must define a non empty 'key' item." - raise DemoMakerException(msg) + raise InitialDataLoaderException(msg) if not slug_re.match(data["key"]): msg = ( "The 'key' item must be a valid identifier consisting of letters, " "numbers, underscores or hyphens. Given one is invalid: {}" ) - raise DemoMakerException(msg.format(data["key"])) + raise InitialDataLoaderException(msg.format(data["key"])) if not data.get("name"): msg = "A page must define a non empty 'name' item." - raise DemoMakerException(msg) + raise InitialDataLoaderException(msg) return True @@ -172,14 +229,14 @@ def validate_page_template(self, template, can_be_empty=False): """ if not can_be_empty and not template: msg = "A template path cannot be empty." - raise DemoMakerException(msg) + raise InitialDataLoaderException(msg) if template not in [k for k, v in settings.CMS_TEMPLATES]: msg = ( "Given template path is not registered from " "'settings.CMS_TEMPLATES': {}" ) - raise DemoMakerException(msg.format(template)) + raise InitialDataLoaderException(msg.format(template)) return True @@ -241,8 +298,6 @@ def page_creation(self, data, parent=None, level=0, created=[]): root page. created (list): List where to append all created page objects. """ - self.validate_page_data(data) - key = data["key"] self.log.info("- Creating page: {name}".format( name=key, @@ -255,7 +310,7 @@ def page_creation(self, data, parent=None, level=0, created=[]): "user": self.global_author, "parent": parent, "reverse_id": key, - "set_homepage": True if level == 0 else False, + "set_homepage": True if data.get("is_homepage") else False, "should_publish": True, "in_navigation": True, "title__language": settings.LANGUAGE_CODE, @@ -270,7 +325,7 @@ def page_creation(self, data, parent=None, level=0, created=[]): level += 1 for child in data.get("children", []): - self.page_creation(child, parent=page, level=level) + self.page_creation(child, parent=page, level=level, created=created) return created @@ -302,8 +357,16 @@ def create(self, structure): self.validate_global_author() if structure.get("pages"): + # Validate the default template self.validate_page_template(self.default_template) - pages = self.page_creation(structure["pages"]) + + # Validate the whole tree + self.validate_page_tree(structure["pages"]) + + # Create pages + pages = [] + for item in structure["pages"]: + self.page_creation(item, created=pages) self.log.info("* Created {} page(s)".format(len(pages))) return { @@ -321,7 +384,8 @@ def load(self, path): path (string or pathlib.Path): Path to the JSON file to load. Returns: - dict: Dictionnary of created objets as returned by ``DemoMaker.create()``. + dict: Dictionnary of created objets as returned + by ``InitialDataLoader.create()``. """ return self.create( json.loads(Path(path).read_text()) diff --git a/{{ cookiecutter.project_name }}/django-apps/project_utils/management/commands/emencia_initial.py b/{{ cookiecutter.project_name }}/django-apps/project_utils/management/commands/emencia_initial.py new file mode 100644 index 0000000..7101692 --- /dev/null +++ b/{{ cookiecutter.project_name }}/django-apps/project_utils/management/commands/emencia_initial.py @@ -0,0 +1,39 @@ +from django.core.management.base import CommandError, BaseCommand + +from cms.api import Page + +from ...initial_loader import InitialDataLoader + + +class Command(BaseCommand): + """ + Initial data loader. + """ + help = ( + "Populate site with initial data loaded from a JSON file." + ) + + def add_arguments(self, parser): + parser.add_argument( + "dump", + metavar="FILEPATH", + help="Filepath to the JSON file with structure to load." + ) + + def handle(self, *args, **options): + self.stdout.write( + self.style.SUCCESS("=== Loading initial data ===") + ) + + # Validate database is empty + existing = Page.objects.all() + if existing.count(): + raise CommandError( + "Initial data can only be loaded when the database is empty from any " + "objects. You should (carefuly) use Django command 'flush' before." + ) + + self.stdout.write("* Opened JSON source: {}".format(options["dump"])) + + maker = InitialDataLoader() + maker.load(options["dump"]) diff --git a/{{ cookiecutter.project_name }}/tests/0010_base/0050_initial.py b/{{ cookiecutter.project_name }}/tests/0010_base/0050_initial.py index cdf1254..6b42875 100644 --- a/{{ cookiecutter.project_name }}/tests/0010_base/0050_initial.py +++ b/{{ cookiecutter.project_name }}/tests/0010_base/0050_initial.py @@ -9,8 +9,8 @@ from cms.api import Page from cms.utils import get_current_site -from project_utils.exceptions import DemoMakerException -from project_utils.helpers import DemoMaker +from project_utils.exceptions import InitialDataLoaderException +from project_utils.initial_loader import InitialDataLoader from project_utils.user import safe_get_user_model @@ -20,7 +20,7 @@ def test_cms_demomaker_blank(caplog, db): """ caplog.set_level(logging.DEBUG, logger="project-utils") - maker = DemoMaker() + maker = InitialDataLoader() maker.create({}) User = safe_get_user_model() @@ -37,10 +37,11 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "homepage", "name": "Demo homepage", - }, + "is_homepage": True, + }], }, "A template path cannot be empty.", ), @@ -52,10 +53,11 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "homepage", "name": "Demo homepage", - }, + "is_homepage": True, + }], }, ( "Given template path is not registered from 'settings.CMS_TEMPLATES': " @@ -69,10 +71,11 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "homepage", "name": "Demo homepage", - }, + "is_homepage": True, + }], }, "No 'global_author' have been given despite it is required." ), @@ -84,10 +87,11 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "homepage", "name": "Demo homepage", - }, + "is_homepage": True, + }], }, "Unable to find created user for global author username 'nope'." ), @@ -99,9 +103,10 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "name": "Demo homepage", - }, + "is_homepage": True, + }], }, "A page must define a non empty 'key' item." ), @@ -113,10 +118,11 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "L'été", "name": "Demo homepage", - }, + "is_homepage": True, + }], }, ( "The 'key' item must be a valid identifier consisting of letters, " @@ -131,9 +137,10 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "homepage", - }, + "is_homepage": True, + }], }, "A page must define a non empty 'name' item." ), @@ -145,28 +152,72 @@ def test_cms_demomaker_blank(caplog, db): "users": { "guest": {}, }, - "pages": { + "pages": [{ "key": "homepage", "name": "Demo homepage", "template": "foo.html", - }, + "is_homepage": True, + }], }, ( "Given template path is not registered from 'settings.CMS_TEMPLATES': " "foo.html" ), ), + # Missing homepage declaration + ( + { + "global_author": "guest", + "default_template": "pages/single_column.html", + "users": { + "guest": {}, + }, + "pages": [{ + "key": "homepage", + "name": "Demo homepage", + }], + }, + ( + "At least one homepage is required, given structure got none." + ), + ), + # Multiple homepage declarations + ( + { + "global_author": "guest", + "default_template": "pages/single_column.html", + "users": { + "guest": {}, + }, + "pages": [ + { + "key": "homepage", + "name": "Demo homepage", + "is_homepage": True, + "children": [{ + "key": "foo", + "name": "Foo", + "is_homepage": True, + }] + } + ], + }, + ( + "Only a single page with 'is_homepage' is allowed but given structure " + "got 2 pages with this option enabled." + ), + ), ]) def test_cms_demomaker_validatons(caplog, db, structure, expected): """ - DemoMake should raise an exception 'DemoMakerException' for invalid structure - values. + DemoMake should raise an exception 'InitialDataLoaderException' for invalid + structure values. """ caplog.set_level(logging.DEBUG, logger="project-utils") - maker = DemoMaker() + maker = InitialDataLoader() - with pytest.raises(DemoMakerException) as exc_info: + with pytest.raises(InitialDataLoaderException) as exc_info: maker.create(structure) assert exc_info.value.args[0] == expected @@ -174,11 +225,11 @@ def test_cms_demomaker_validatons(caplog, db, structure, expected): def test_cms_demomaker_success(caplog, db): """ - DemoMaker should correctly create all objects from given structure. + InitialDataLoader should correctly create all objects from given structure. """ caplog.set_level(logging.DEBUG, logger="project-utils") - maker = DemoMaker() + maker = InitialDataLoader() maker.create({ "global_author": "admin", "default_template": "pages/single_column.html", @@ -199,28 +250,31 @@ def test_cms_demomaker_success(caplog, db): "password": "ok", }, }, - "pages": { - "key": "homepage", - "name": "Demo homepage", - "template": None, - "children": [ - { - "key": "foo", - "name": "Foo", - "children": [ - { - "key": "bar", - "name": "Bar", - "children": [] - }, - ] - }, - { - "key": "plop_plop-plop", - "name": "Plop", - }, - ], - }, + "pages": [ + { + "key": "homepage", + "name": "Demo homepage", + "is_homepage": True, + "template": None, + "children": [ + { + "key": "foo", + "name": "Foo", + "children": [ + { + "key": "bar", + "name": "Bar", + "children": [] + }, + ] + }, + ], + }, + { + "key": "plop_plop-plop", + "name": "Plop", + }, + ] }) site = get_current_site() diff --git a/{{ cookiecutter.project_name }}/tests/conftest.py b/{{ cookiecutter.project_name }}/tests/conftest.py index a09467d..4cab87c 100644 --- a/{{ cookiecutter.project_name }}/tests/conftest.py +++ b/{{ cookiecutter.project_name }}/tests/conftest.py @@ -12,7 +12,7 @@ import pytest -from project_utils import helpers +from project_utils import initial_loader class FixturesSettingsTestMixin(object): @@ -90,8 +90,8 @@ def load_initials(db): """ Create objects from project initial structure data. """ - maker = helpers.DemoMaker() + maker = initial_loader.InitialDataLoader() return maker.load( - Path(helpers.__file__).parent / "initial.json" + Path(initial_loader.__file__).parent / "initial.json" )