diff --git a/cms/__main__.py b/cms/__main__.py index 28cd716..fef1124 100644 --- a/cms/__main__.py +++ b/cms/__main__.py @@ -1,15 +1,22 @@ +""" +Main entry point. +""" + import argparse import os import re import shutil import cms -from pip._internal.operations import freeze as pip import example def main(): + """ + Ask the user to confirm, then call create_project(). + """ + parser = argparse.ArgumentParser(description="SimpleCMS") parser.add_argument("project_name", nargs="?", default=".") project_name = parser.parse_args().project_name @@ -31,12 +38,13 @@ def main(): def create_project(project_name, project_dir): + """ + Populate project directory with a minimal, working project. + """ + os.makedirs(project_dir, exist_ok=True) with open(os.path.join(project_dir, "requirements.txt"), "w") as f: - for line in pip.freeze(): - if "django_simplecms" in line: - line = f"django-simplecms=={cms.__version__}" - print(line, file=f) + print(f"django-simplecms=={cms.__version__}", file=f) example_dir = os.path.dirname(example.__file__) app_dir = os.path.join(project_dir, project_name) @@ -49,7 +57,7 @@ def create_project(project_name, project_dir): "w", ) as f: print( - f"""#!/usr/bin/env python + f"""#!/usr/bin/env python3 import os import sys @@ -74,7 +82,7 @@ application = get_wsgi_application()""", print( f""" -Successfully created project "{project_name}" +Successfully created project "{project_name}"! Things to do next: - create a database diff --git a/cms/apps.py b/cms/apps.py index 9a96878..5843777 100644 --- a/cms/apps.py +++ b/cms/apps.py @@ -1,11 +1,21 @@ +""" +Metadata for the application registry. +""" + from django.apps import AppConfig from django.utils.module_loading import autodiscover_modules -from django.utils.translation import gettext_lazy as _ class CmsConfig(AppConfig): + """ + The `cms` app. + """ + name = "cms" - verbose_name = _("Content Management System") def ready(self): + """ + When ready, populate the section type registry. + """ + autodiscover_modules("views") diff --git a/cms/decorators.py b/cms/decorators.py index d787f2c..de4a80e 100644 --- a/cms/decorators.py +++ b/cms/decorators.py @@ -1,20 +1,33 @@ +""" +Decorators. +""" + from cms import registry def page_model(cls): - """Decorator to register the Page model""" + """ + Decorator to register the Page model. + """ + registry.page_class = cls return cls def section_model(cls): - """Decorator to register the Section model""" + """ + Decorator to register the Section model. + """ + registry.section_class = cls return cls def section_view(cls): - """Decorator to register a view for a specific section""" + """ + Decorator to register a view for a specific section. + """ + registry.view_per_type[cls.__name__.lower()] = cls registry.section_types.append((cls.__name__.lower(), cls.verbose_name)) return cls diff --git a/cms/fields.py b/cms/fields.py index ee1f569..4656bdd 100644 --- a/cms/fields.py +++ b/cms/fields.py @@ -1,3 +1,7 @@ +""" +Commonly used Django model fields. +""" + from django.db import models from django.forms import TextInput @@ -5,9 +9,15 @@ from .mixins import EasilyMigratable class CharField(EasilyMigratable, models.TextField): - """Variable width CharField.""" + """ + Variable width CharField. + """ def formfield(self, **kwargs): + """ + Use TextInput instead of the default TextArea. + """ + if not self.choices: kwargs.update({"widget": TextInput}) return super().formfield(**kwargs) @@ -58,6 +68,10 @@ class ImageField(EasilyMigratable, models.ImageField): class ForeignKey(EasilyMigratable, models.ForeignKey): + """ + A foreign key that does not create a reverse relation by default. + """ + def __init__(self, *args, related_name="+", **kwargs): super().__init__( *args, diff --git a/cms/forms.py b/cms/forms.py index 81e715e..b643287 100644 --- a/cms/forms.py +++ b/cms/forms.py @@ -1,3 +1,7 @@ +""" +Some not-so-simple forms. +""" + from urllib.parse import quote from django import forms @@ -8,6 +12,10 @@ from . import registry class PageForm(forms.ModelForm): + """ + Form to edit a page, including all its sections. + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.label_suffix = "" @@ -50,6 +58,10 @@ class PageForm(forms.ModelForm): class SectionForm(forms.ModelForm): + """ + Form to edit a section. + """ + type = forms.ChoiceField() def __init__(self, *args, **kwargs): diff --git a/cms/middleware.py b/cms/middleware.py index e294962..1c3741e 100644 --- a/cms/middleware.py +++ b/cms/middleware.py @@ -1,3 +1,7 @@ +""" +Optional but useful middleware classes. +""" + import os from django.conf import settings @@ -5,18 +9,11 @@ from django.middleware import cache from sass import compile -def locate(filename): - for path, dirs, files in os.walk(os.getcwd(), followlinks=True): - for f in files: - if f == filename: - yield os.path.join(path, filename) - - class FetchFromCacheMiddleware(cache.FetchFromCacheMiddleware): - """Minor change to the original middleware that prevents caching of + """ + Minor change to the original middleware that prevents caching of requests that have a `sessionid` cookie. This should be the Django default, IMHO. - """ def process_request(self, request): @@ -25,9 +22,11 @@ class FetchFromCacheMiddleware(cache.FetchFromCacheMiddleware): class SassMiddleware: - """Simple SASS middleware that intercepts requests for .css files and + """ + SASS middleware that intercepts requests for .css files and tries to compile the corresponding SCSS file. + In production this does nothing, so commit your files! """ def __init__(self, get_response): @@ -53,3 +52,14 @@ class SassMiddleware: response = self.get_response(request) return response + + +def locate(filename): + """ + Locate a file beneath the current directory. + """ + + for path, dirs, files in os.walk(os.getcwd(), followlinks=True): + for f in files: + if f == filename: + yield os.path.join(path, filename) diff --git a/cms/mixins.py b/cms/mixins.py index 11543d1..9c701dd 100644 --- a/cms/mixins.py +++ b/cms/mixins.py @@ -1,3 +1,8 @@ +""" +Some handy mixins. +""" + + class EasilyMigratable: """ Mixin for model fields. Prevents the generation of migrations that @@ -37,7 +42,9 @@ class Numbered: return self.__class__._meta.ordering[-1].lstrip("-") def _renumber(self): - """Renumbers the queryset while preserving the instance's number""" + """ + Renumber the queryset while preserving the instance's number. + """ queryset = self.number_with_respect_to() field_name = self.get_field_name() @@ -48,7 +55,7 @@ class Numbered: # The algorithm: loop over the queryset and set each object's # number to the counter. When an object's number equals the # number of this instance, set this instance's number to the - # counter, increment the counter by 1, and finish the loop + # counter, increment the counter by 1, and finish the loop. counter = 1 inserted = False for other in queryset.exclude(pk=self.pk): diff --git a/cms/models.py b/cms/models.py index 9077ce7..f70edc0 100644 --- a/cms/models.py +++ b/cms/models.py @@ -1,3 +1,7 @@ +""" +Base page and section models. +""" + from django.db import models from django.urls import reverse from django.utils.text import slugify @@ -8,7 +12,9 @@ from . import fields, mixins class BasePage(mixins.Numbered, models.Model): - """Abstract base model for pages.""" + """ + Abstract base model for pages. + """ title = fields.CharField(_("page")) slug = fields.SlugField(_("slug"), blank=True, unique=True) @@ -33,7 +39,9 @@ class BasePage(mixins.Numbered, models.Model): class BaseSection(mixins.Numbered, models.Model): - """Abstract base model for sections""" + """ + Abstract base model for sections. + """ TYPES = [] title = fields.CharField(_("section")) diff --git a/cms/registry.py b/cms/registry.py index b0814fd..acb59c6 100644 --- a/cms/registry.py +++ b/cms/registry.py @@ -1,3 +1,7 @@ +""" +Registry that is populated at startup time by the decorators. +""" + page_class = None section_class = None section_types = [] @@ -5,14 +9,29 @@ view_per_type = {} def get_types(): + """ + Return the available section types as tuples to be used for + form field choices. + """ + return section_types def get_view(section, request): + """ + Given a section instance and a request, return the view class + that is registered to render that section. + """ + return view_per_type[section.type](request) def get_fields_per_type(): + """ + Return a dictionary with the editable fields of each section. + This is used by the JS to show the the relevant form fields. + """ + fields_per_type = {} for name, view in view_per_type.items(): fields_per_type[name] = ["title", "type", "number"] + view.fields diff --git a/cms/urls.py b/cms/urls.py index c2a0930..c53ec13 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -1,3 +1,7 @@ +""" +URLs. +""" + from django.urls import path from .views import CreatePage, CreateSection, PageView, UpdatePage, UpdateSection diff --git a/cms/views.py b/cms/views.py index 59aaedf..fc276f4 100644 --- a/cms/views.py +++ b/cms/views.py @@ -1,3 +1,8 @@ +""" +Some simple section views, as well as the "real" Django views that +make the simple views possible. +""" + import json from django.contrib.auth.mixins import UserPassesTestMixin @@ -17,7 +22,9 @@ from .forms import ContactForm, PageForm, SectionForm class SectionView: - """Generic section view""" + """ + Generic section view. + """ template_name = "cms/sections/section.html" @@ -29,7 +36,9 @@ class SectionView: class SectionFormView(SectionView): - """Generic section with associated form""" + """ + Generic section with associated form. + """ form_class = None success_url = None @@ -60,28 +69,38 @@ class SectionFormView(SectionView): class ContactSectionFormView(SectionFormView): - """Contact section with bogus contact form""" + """ + Generic section with a contact form. + """ form_class = ContactForm def form_valid(self, form): response = HttpResponse(status=302) - response["Location"] = form.save(self.object.href) + response["Location"] = form.save(self.object.href, self.object.subject) return response class PageView(detail.DetailView): - """View of a page with heterogeneous sections""" + """ + View of a page with heterogeneous sections. + """ model = registry.page_class template_name = "cms/page.html" def setup(self, *args, slug="", **kwargs): - """Supply a default argument for slug""" + """ + Supply a default argument for slug. + """ + super().setup(*args, slug=slug, **kwargs) def get(self, request, *args, **kwargs): - """Instantiate section views and render final response""" + """ + Instantiate section views and render final response. + """ + try: page = self.object = self.get_object() except Http404: @@ -98,6 +117,7 @@ class PageView(detail.DetailView): return redirect("cms:updatepage", slug=self.kwargs["slug"]) else: raise + context = self.get_context_data(**kwargs) sections = page.sections.all() context.update( @@ -109,7 +129,10 @@ class PageView(detail.DetailView): return self.render_to_response(context) def post(self, request, **kwargs): - """Call the post() method of the correct section view""" + """ + Call the post() method of the correct section view. + """ + try: pk = int(self.request.POST.get("section")) except Exception: @@ -150,45 +173,65 @@ class PageView(detail.DetailView): class EditPage( UserPassesTestMixin, edit.ModelFormMixin, base.TemplateResponseMixin, base.View ): - """Base view with nested forms for editing the page and all its sections""" + """ + Base view with nested forms for editing the page and all its sections. + """ model = registry.page_class form_class = PageForm template_name = "cms/edit.html" def test_func(self): - """Only allow users with the correct permissions""" + """ + Only allow users with the correct permissions. + """ + app_label = registry.page_class._meta.app_label model_name = registry.page_class._meta.model_name return self.request.user.has_perm(f"{app_label}.change_{model_name}") def get_form_kwargs(self): - """Set the default slug to the current URL for new pages""" + """ + Set the default slug to the current URL for new pages. + """ + kwargs = super().get_form_kwargs() if "slug" in self.kwargs: kwargs.update({"initial": {"slug": self.kwargs["slug"]}}) return kwargs def get_context_data(self, **kwargs): - """Populate the fields_per_type dict for use in javascript""" + """ + Populate the fields_per_type dict for use in JS. + """ + context = super().get_context_data(**kwargs) context["fields_per_type"] = json.dumps(registry.get_fields_per_type()) return context def get_object(self): - """Prevent 404 by serving the new object form""" + """ + Prevent 404 by serving the new object form. + """ + try: return super().get_object() except Http404: return None def get(self, *args, **kwargs): - """Handle GET requests""" + """ + Handle GET requests. + """ + self.object = self.get_object() return self.render_to_response(self.get_context_data(**kwargs)) def post(self, *args, **kwargs): - """Handle POST requests""" + """ + Handle POST requests. + """ + self.object = self.get_object() form = self.get_form() @@ -201,26 +244,37 @@ class EditPage( class CreatePage(EditPage): - """View for creating new pages""" + """ + View for creating new pages. + """ def get_object(self): return registry.page_class() class UpdatePage(EditPage): - """View for editing existing pages""" + """ + View for editing existing pages. + """ @method_decorator(never_cache, name="dispatch") class EditSection( UserPassesTestMixin, edit.ModelFormMixin, base.TemplateResponseMixin, base.View ): + """ + Base view to edit a specific section. + """ + model = registry.section_class form_class = SectionForm template_name = "cms/edit.html" def test_func(self): - """Only allow users with the correct permissions""" + """ + Only allow users with the correct permissions. + """ + app_label = registry.section_class._meta.app_label model_name = registry.section_class._meta.model_name return self.request.user.has_perm(f"{app_label}.change_{model_name}") @@ -273,9 +327,15 @@ class EditSection( class CreateSection(EditSection): + """ + View for creating new sections. + """ + def get_section(self): return registry.section_class(page=self.page) class UpdateSection(EditSection): - pass + """ + View for editing existing sections. + """ diff --git a/example/models.py b/example/models.py index 8613f12..6cf159e 100644 --- a/example/models.py +++ b/example/models.py @@ -1,3 +1,7 @@ +""" +Page and section models. +""" + from cms.decorators import page_model, section_model from cms.models import BasePage, BaseSection from django.db import models diff --git a/example/settings.py b/example/settings.py index e579c85..5b9b98e 100644 --- a/example/settings.py +++ b/example/settings.py @@ -1,3 +1,8 @@ +""" +Django settings file. For the available options, see: +https://docs.djangoproject.com/en/stable/ref/settings/ +""" + import os import random import string @@ -84,6 +89,7 @@ DATABASES = { "NAME": PROJECT_NAME, } } + CACHES = { "default": { "BACKEND": "django.core.cache.backends.memcached.PyLibMCCache", diff --git a/example/urls.py b/example/urls.py index 49d9e51..ee5475b 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,20 +1,19 @@ +""" +URLs. +""" + from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path -from django.views.generic import RedirectView -admin.site.site_header = admin.site.site_title = settings.PROJECT_NAME.replace( - "_", " " -).title() -urlpatterns = staticfiles_urlpatterns() + static( - settings.MEDIA_URL, document_root=settings.MEDIA_ROOT +urlpatterns = ( + staticfiles_urlpatterns() + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + [ + path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), + path("", include("cms.urls", namespace="cms")), + ] ) -urlpatterns += [ - path("admin/", admin.site.urls), - path("accounts/", include("django.contrib.auth.urls")), - path("login/", RedirectView.as_view(url="/accounts/login/")), - path("logout/", RedirectView.as_view(url="/accounts/logout/")), - path("", include("cms.urls", namespace="cms")), -] diff --git a/example/views.py b/example/views.py index a3150da..91931da 100644 --- a/example/views.py +++ b/example/views.py @@ -1,3 +1,7 @@ +""" +View classes for all possible section types. +""" + from cms.decorators import section_view from cms.views import ContactSectionFormView, SectionView from django.utils.translation import gettext_lazy as _ @@ -5,6 +9,10 @@ from django.utils.translation import gettext_lazy as _ @section_view class Text(SectionView): + """ + A section that displays text. + """ + verbose_name = _("Text") template_name = "text.html" fields = ["content"] @@ -12,6 +20,10 @@ class Text(SectionView): @section_view class Images(SectionView): + """ + A section that displays images. + """ + verbose_name = _("Image(s)") template_name = "images.html" fields = ["images"] @@ -19,6 +31,10 @@ class Images(SectionView): @section_view class Video(SectionView): + """ + A section that displays a video. + """ + verbose_name = _("Video") template_name = "video.html" fields = ["video"] @@ -26,6 +42,10 @@ class Video(SectionView): @section_view class Contact(ContactSectionFormView): + """ + A section that displays a contact form. + """ + verbose_name = _("Contact") template_name = "contact.html" fields = ["content", "href"]