├── .python-version ├── headless_cms ├── __init__.py ├── contrib │ ├── __init__.py │ └── astrowind │ │ ├── __init__.py │ │ ├── astrowind_pages │ │ ├── __init__.py │ │ ├── management │ │ │ ├── __init__.py │ │ │ └── commands │ │ │ │ ├── __init__.py │ │ │ │ └── populate_aw_data.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0003_awaboutpage_add_skip_translation.py │ │ ├── admin.py │ │ └── views.py │ │ ├── astrowind_posts │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0004_rename_awpost_publish_date_created_date_awpost_publish_created_idx.py │ │ │ ├── 0003_alter_awpostimage_src_url.py │ │ │ └── 0002_awcategory_skip_translation_awpost_add_skip_translation.py │ │ ├── admin.py │ │ ├── urls.py │ │ ├── serializers.py │ │ ├── views.py │ │ └── models.py │ │ ├── astrowind_metadata │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0002_awmetadata_add_skip_translation.py │ │ ├── admin.py │ │ └── models.py │ │ ├── astrowind_widgets │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0003_alter_awimage_src_url.py │ │ │ └── 0002_awaction_add_skip_translation.py │ │ └── admin.py │ │ └── urls.py ├── core │ ├── __init__.py │ └── management │ │ ├── __init__.py │ │ └── commands │ │ ├── __init__.py │ │ ├── clean_outdated_drafts.py │ │ └── export_cms_data.py ├── enhances │ ├── __init__.py │ ├── templates │ │ ├── custom_localized_fields │ │ │ └── admin │ │ │ │ └── widget.html │ │ ├── reversion │ │ │ ├── revision_form.html │ │ │ └── object_history.html │ │ └── admin │ │ │ └── change_form.html │ └── static │ │ └── localized_fields │ │ └── localized-fields-admin.js ├── schema │ ├── __init__.py │ ├── views.py │ ├── preprocessing_hooks.py │ ├── urls.py │ └── auto_schema.py ├── utils │ ├── __init__.py │ ├── markdown.py │ ├── custom_import_export.py │ ├── martor_custom_upload.py │ ├── relations.py │ └── hash_utils.py ├── auto_translate │ └── __init__.py ├── fields │ ├── __init__.py │ ├── url_field.py │ ├── martor_field.py │ ├── boolean_field.py │ └── slug_field.py ├── forms.py ├── filters.py ├── serializer_fields.py ├── settings.py ├── mixins.py └── widgets.py ├── tests ├── helpers │ ├── __init__.py │ ├── base.py │ └── schema_utils.py ├── test_app │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_views.py │ │ └── test_models.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_alter_articleimage_src_url.py │ │ └── 0002_article_skip_translation_and_more.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── views.py │ ├── factories.py │ └── models.py ├── test_utils │ ├── __init__.py │ └── test_relations.py ├── test_commands │ ├── __init__.py │ ├── data │ │ ├── .gitignore │ │ ├── invalid_data.txt │ │ ├── expected_data.zip │ │ ├── expected_dir │ │ │ └── test_app │ │ │ │ ├── Note.json │ │ │ │ ├── Blog_posts.json │ │ │ │ ├── Post_tags.json │ │ │ │ ├── Blog_articles.json │ │ │ │ ├── ArticleImageThrough.json │ │ │ │ ├── PostTag.json │ │ │ │ ├── Category.json │ │ │ │ ├── Domain.json │ │ │ │ ├── ArticleImage.json │ │ │ │ ├── Blog.json │ │ │ │ ├── Article.json │ │ │ │ ├── Post.json │ │ │ │ └── Item.json │ │ └── corrupted_dir │ │ │ └── test_app │ │ │ ├── Category.json │ │ │ └── Post.json │ ├── test_clean_outdated_drafts.py │ └── test_import_export_cms_data.py ├── test_project │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── test_schema │ ├── __init__.py │ ├── test_cms_schema.py │ └── test_cms_api_schema.yml ├── test_translation │ ├── __init__.py │ └── test_openai_translation.py ├── pytest.ini └── manage.py ├── docs ├── faq.rst ├── images │ ├── quick-start │ │ ├── demo.png │ │ ├── home-page.png │ │ ├── zh-pricing.png │ │ ├── ar-post-detail.png │ │ ├── cookiecutter.png │ │ ├── index_request.png │ │ ├── index_response.png │ │ └── vn-post-list.png │ └── how-to-use │ │ ├── history.png │ │ ├── publish.png │ │ ├── markdown-1.png │ │ ├── translation.png │ │ ├── admin-actions.png │ │ ├── export-single.png │ │ ├── import-button.png │ │ ├── markdown-preview.png │ │ └── markdown-full-screen.png ├── reference │ ├── filters.rst │ ├── mixins.rst │ ├── auto_translate.rst │ ├── schema.rst │ ├── serializers.rst │ ├── models.rst │ ├── commands.rst │ ├── admin.rst │ └── fields.rst ├── Makefile ├── make.bat ├── installation.rst ├── index.rst ├── conf.py ├── configuration.rst └── introduction.rst ├── .env.test ├── scripts └── lint.sh ├── docker-compose.yml ├── .gitignore ├── .readthedocs.yaml ├── CONTRIBUTING.md ├── .pre-commit-config.yaml ├── LICENSE ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── CHANGELOG.md ├── pyproject.toml └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /headless_cms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/enhances/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/schema/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_schema/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_translation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/core/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_commands/data/.gitignore: -------------------------------------------------------------------------------- 1 | results/ 2 | -------------------------------------------------------------------------------- /headless_cms/core/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_metadata/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_metadata/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_widgets/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_commands/data/invalid_data.txt: -------------------------------------------------------------------------------- 1 | This is invalid data for import 2 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | FAQ 2 | === 3 | 4 | Some most common problem and the fix will be described here. 5 | -------------------------------------------------------------------------------- /headless_cms/auto_translate/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_translate import BaseTranslate 2 | 3 | __all__ = ["BaseTranslate"] 4 | -------------------------------------------------------------------------------- /docs/images/quick-start/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/demo.png -------------------------------------------------------------------------------- /docs/images/how-to-use/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/history.png -------------------------------------------------------------------------------- /docs/images/how-to-use/publish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/publish.png -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = test_project.settings 3 | filterwarnings = 4 | ignore::DeprecationWarning 5 | -------------------------------------------------------------------------------- /docs/images/how-to-use/markdown-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/markdown-1.png -------------------------------------------------------------------------------- /docs/images/quick-start/home-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/home-page.png -------------------------------------------------------------------------------- /docs/images/how-to-use/translation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/translation.png -------------------------------------------------------------------------------- /docs/images/quick-start/zh-pricing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/zh-pricing.png -------------------------------------------------------------------------------- /docs/images/how-to-use/admin-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/admin-actions.png -------------------------------------------------------------------------------- /docs/images/how-to-use/export-single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/export-single.png -------------------------------------------------------------------------------- /docs/images/how-to-use/import-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/import-button.png -------------------------------------------------------------------------------- /docs/images/quick-start/ar-post-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/ar-post-detail.png -------------------------------------------------------------------------------- /docs/images/quick-start/cookiecutter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/cookiecutter.png -------------------------------------------------------------------------------- /docs/images/quick-start/index_request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/index_request.png -------------------------------------------------------------------------------- /docs/images/quick-start/index_response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/index_response.png -------------------------------------------------------------------------------- /docs/images/quick-start/vn-post-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/quick-start/vn-post-list.png -------------------------------------------------------------------------------- /tests/test_commands/data/expected_data.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/tests/test_commands/data/expected_data.zip -------------------------------------------------------------------------------- /docs/images/how-to-use/markdown-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/markdown-preview.png -------------------------------------------------------------------------------- /docs/images/how-to-use/markdown-full-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huynguyengl99/django-headless-cms/HEAD/docs/images/how-to-use/markdown-full-screen.png -------------------------------------------------------------------------------- /tests/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "test_app" 7 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=cms_test_db 2 | POSTGRES_USER=cms_test_user 3 | POSTGRES_PASSWORD=cms_test_pass 4 | POSTGRES_HOST=localhost 5 | POSTGRES_PORT=5454 6 | 7 | OPENAI_API_KEY="test-api-key" 8 | -------------------------------------------------------------------------------- /tests/test_app/admin.py: -------------------------------------------------------------------------------- 1 | from headless_cms.admin import auto_admins 2 | 3 | from test_app.models import Article, ArticleImage, Category, Item, Post, PostTag 4 | 5 | auto_admins([Article, ArticleImage, Category, Item, Post, PostTag]) 6 | -------------------------------------------------------------------------------- /tests/test_app/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | from test_app.views import PostCMSViewSet 4 | 5 | router = routers.SimpleRouter() 6 | router.register(r"", PostCMSViewSet, basename="posts") 7 | 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$1" == "--fix" ]; then 4 | ruff check . --fix && black ./headless_cms && toml-sort pyproject.toml 5 | else 6 | ruff check . && black ./headless_cms --check && toml-sort pyproject.toml --check 7 | fi 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db_pg: 3 | image: postgres:15 4 | volumes: 5 | - "dbdata:/var/lib/postgresql/data" 6 | env_file: 7 | - .env.test 8 | ports: 9 | - "5454:5432" 10 | 11 | volumes: 12 | dbdata: 13 | -------------------------------------------------------------------------------- /docs/reference/filters.rst: -------------------------------------------------------------------------------- 1 | Filters 2 | ======= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | CMSFilterBackend 9 | ---------------- 10 | 11 | .. autoclass:: headless_cms.filters.CMSFilterBackend 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | :noindex: 16 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | admin.autodiscover() 5 | 6 | urlpatterns = [ 7 | path("admin/", admin.site.urls), 8 | path("", include("martor.urls")), 9 | path("", include("headless_cms.schema.urls")), 10 | path("test-app/", include("test_app.urls")), 11 | ] 12 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/admin.py: -------------------------------------------------------------------------------- 1 | from headless_cms.admin import auto_admins 2 | from headless_cms.contrib.astrowind.astrowind_posts.models import ( 3 | AWCategory, 4 | AWPost, 5 | AWPostImage, 6 | AWPostTag, 7 | ) 8 | 9 | auto_admins( 10 | [ 11 | AWPostTag, 12 | AWCategory, 13 | AWPostImage, 14 | AWPost, 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / Optimized / DLL Files 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | __pycache__ 6 | 7 | # Distribution / Packaging 8 | .venv/ 9 | venv/ 10 | .venv-*/ 11 | dist 12 | docs/_build/ 13 | media-test/ 14 | 15 | 16 | # Unit Test & coverage 17 | .pytest_cache/ 18 | htmlcov 19 | .coverage 20 | coverage.xml 21 | 22 | # Ignore IDE, Editor Files 23 | .idea/ 24 | .vscode/ 25 | 26 | # macOS 27 | .DS_Store 28 | *_out.yml 29 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Note.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "text": { 6 | "en": "Note for en", 7 | "ro": "Note for ro", 8 | "vi": "Note for vi" 9 | } 10 | }, 11 | { 12 | "id": "2", 13 | "skip_translation": "0", 14 | "text": { 15 | "en": "Note 2 for en", 16 | "ro": "Note 2 for ro", 17 | "vi": "Note 2 for vi" 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from rest_framework.routers import SimpleRouter 3 | 4 | from .views import AWCategoryViewSet, AWPostCMSViewSet, AWPostTagViewSet 5 | 6 | router = SimpleRouter() 7 | router.register(r"posts", AWPostCMSViewSet) 8 | router.register(r"tags", AWPostTagViewSet) 9 | router.register(r"categories", AWCategoryViewSet) 10 | urlpatterns = [path("", include(router.urls))] 11 | -------------------------------------------------------------------------------- /headless_cms/fields/__init__.py: -------------------------------------------------------------------------------- 1 | from .boolean_field import LocalizedBooleanField 2 | from .martor_field import LocalizedMartorField 3 | from .slug_field import LocalizedUniqueNormalizedSlugField 4 | from .url_field import AutoLanguageUrlField, LocalizedUrlField 5 | 6 | __all__ = [ 7 | "LocalizedBooleanField", 8 | "LocalizedMartorField", 9 | "LocalizedUniqueNormalizedSlugField", 10 | "AutoLanguageUrlField", 11 | "LocalizedUrlField", 12 | ] 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | jobs: 8 | create_environment: 9 | - asdf plugin add uv 10 | - asdf install uv latest 11 | - asdf global uv latest 12 | - uv venv 13 | install: 14 | - uv sync --group docs 15 | build: 16 | html: 17 | - uv run sphinx-build -T -b html docs $READTHEDOCS_OUTPUT/html 18 | sphinx: 19 | configuration: docs/conf.py 20 | -------------------------------------------------------------------------------- /tests/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docs/reference/mixins.rst: -------------------------------------------------------------------------------- 1 | Mixins 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | CMSSchemaMixin 9 | -------------- 10 | 11 | .. autoclass:: headless_cms.mixins.CMSSchemaMixin 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | :noindex: 16 | 17 | 18 | HashModelMixin 19 | -------------- 20 | 21 | .. autoclass:: headless_cms.mixins.HashModelMixin 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | :noindex: 26 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/admin.py: -------------------------------------------------------------------------------- 1 | from headless_cms.admin import auto_admins 2 | from headless_cms.contrib.astrowind.astrowind_pages.models import ( 3 | AWAboutPage, 4 | AWContactPage, 5 | AWIndexPage, 6 | AWPostPage, 7 | AWPricingPage, 8 | AWSite, 9 | ) 10 | 11 | auto_admins( 12 | [ 13 | AWAboutPage, 14 | AWContactPage, 15 | AWIndexPage, 16 | AWPricingPage, 17 | AWPostPage, 18 | AWSite, 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_metadata/admin.py: -------------------------------------------------------------------------------- 1 | from headless_cms.admin import auto_admins 2 | from headless_cms.contrib.astrowind.astrowind_metadata.models import ( 3 | AWMetadata, 4 | AWMetadataImage, 5 | AWMetaDataOpenGraph, 6 | AWMetadataRobot, 7 | AWMetaDataTwitter, 8 | ) 9 | 10 | auto_admins( 11 | [ 12 | AWMetadataRobot, 13 | AWMetadataImage, 14 | AWMetaDataOpenGraph, 15 | AWMetaDataTwitter, 16 | AWMetadata, 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /headless_cms/forms.py: -------------------------------------------------------------------------------- 1 | from localized_fields.forms import LocalizedFieldForm 2 | from localized_fields.value import LocalizedStringValue 3 | from martor.fields import MartorFormField 4 | 5 | from .widgets import LocalizedMartorWidget 6 | 7 | 8 | class LocalizedMartorForm(LocalizedFieldForm): 9 | """Form for a localized integer field, allows editing the field in multiple 10 | languages.""" 11 | 12 | widget = LocalizedMartorWidget 13 | field_class = MartorFormField 14 | value_class = LocalizedStringValue 15 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Blog_posts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "blog": 1, 5 | "post": 1 6 | }, 7 | { 8 | "id": "2", 9 | "blog": 1, 10 | "post": 2 11 | }, 12 | { 13 | "id": "3", 14 | "blog": 2, 15 | "post": 2 16 | }, 17 | { 18 | "id": "4", 19 | "blog": 2, 20 | "post": 3 21 | }, 22 | { 23 | "id": "5", 24 | "blog": 3, 25 | "post": 1 26 | }, 27 | { 28 | "id": "6", 29 | "blog": 3, 30 | "post": 3 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /tests/test_schema/test_cms_schema.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.urls import reverse 4 | 5 | from helpers.base import BaseAPITestCase 6 | from helpers.schema_utils import assert_api_schema 7 | 8 | 9 | @mock.patch("drf_spectacular.settings.spectacular_settings.SCHEMA_PATH_PREFIX", "") 10 | class TestCMSSpectacularAPIView(BaseAPITestCase): 11 | def test_schema(self): 12 | res = self.client.get(reverse("cms-schema")) 13 | 14 | assert_api_schema(res.content, "test_schema/test_cms_api_schema.yml") 15 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Post_tags.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "post": 1, 5 | "posttag": 1 6 | }, 7 | { 8 | "id": "2", 9 | "post": 1, 10 | "posttag": 2 11 | }, 12 | { 13 | "id": "3", 14 | "post": 2, 15 | "posttag": 2 16 | }, 17 | { 18 | "id": "4", 19 | "post": 2, 20 | "posttag": 3 21 | }, 22 | { 23 | "id": "5", 24 | "post": 3, 25 | "posttag": 1 26 | }, 27 | { 28 | "id": "6", 29 | "post": 3, 30 | "posttag": 3 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /headless_cms/fields/url_field.py: -------------------------------------------------------------------------------- 1 | from django.db.models import CharField 2 | from localized_fields.fields import LocalizedCharField 3 | 4 | 5 | class AutoLanguageUrlField(CharField): 6 | """This field will automatically add language prefix path for relative url (/about => /en/about) 7 | but will keep the full url as it is.""" 8 | 9 | pass 10 | 11 | 12 | class LocalizedUrlField(LocalizedCharField): 13 | """This field is used to prevent automatic translation for this field (the link should remain stable).""" 14 | 15 | pass 16 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Blog_articles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "blog": 1, 5 | "article": 1 6 | }, 7 | { 8 | "id": "2", 9 | "blog": 1, 10 | "article": 2 11 | }, 12 | { 13 | "id": "3", 14 | "blog": 2, 15 | "article": 2 16 | }, 17 | { 18 | "id": "4", 19 | "blog": 2, 20 | "article": 3 21 | }, 22 | { 23 | "id": "5", 24 | "blog": 3, 25 | "article": 1 26 | }, 27 | { 28 | "id": "6", 29 | "blog": 3, 30 | "article": 3 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /docs/reference/auto_translate.rst: -------------------------------------------------------------------------------- 1 | Auto Translate 2 | ============== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | BaseTranslate 9 | ------------- 10 | 11 | .. autoclass:: headless_cms.auto_translate.base_translate.BaseTranslate 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | :noindex: 16 | 17 | OpenAITranslate 18 | --------------- 19 | 20 | .. autoclass:: headless_cms.auto_translate.openai_translate.OpenAITranslate 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | :noindex: 25 | -------------------------------------------------------------------------------- /tests/test_commands/data/corrupted_dir/test_app/Category.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "4", 4 | "title": { 5 | "en": "Environment", 6 | "ro": "Mediu", 7 | "vi": "Môi trường" 8 | }, 9 | "slug": { 10 | "en": "environment", 11 | "ro": "mediu", 12 | "vi": "moi-truong" 13 | } 14 | }, 15 | { 16 | "id": "5", 17 | "title": { 18 | "en": "Sport", 19 | "ro": "Sport", 20 | "vi": "Thể thao" 21 | }, 22 | "slug": { 23 | "en": "sport", 24 | "ro": "sport", 25 | "vi": "the-thao" 26 | } 27 | } 28 | ] 29 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError as exc: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) from exc 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/migrations/0004_rename_awpost_publish_date_created_date_awpost_publish_created_idx.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.20 on 2025-11-18 13:07 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("astrowind_posts", "0003_alter_awpostimage_src_url"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameIndex( 14 | model_name="awpost", 15 | new_name="awpost_publish_created_idx", 16 | old_fields=("publish_date", "created_date"), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tests/test_app/views.py: -------------------------------------------------------------------------------- 1 | from headless_cms.mixins import CMSSchemaMixin 2 | from headless_cms.serializers import auto_serializer 3 | from rest_framework import viewsets 4 | 5 | from test_app.models import Article, Post 6 | 7 | 8 | class PostCMSViewSet(CMSSchemaMixin, viewsets.ReadOnlyModelViewSet): 9 | queryset = Post.published_objects.published(auto_prefetch=True) 10 | serializer_class = auto_serializer(Post) 11 | 12 | 13 | class ArticleCMSViewSet(CMSSchemaMixin, viewsets.ReadOnlyModelViewSet): 14 | queryset = Article.published_objects.published(auto_prefetch=True) 15 | serializer_class = auto_serializer(Article) 16 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0003_alter_articleimage_src_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-06-20 17:22 2 | 3 | import headless_cms.fields.url_field 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("test_app", "0002_article_skip_translation_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="articleimage", 16 | name="src_url", 17 | field=headless_cms.fields.url_field.LocalizedUrlField( 18 | blank=True, default=dict, null=True, required=[] 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_widgets/migrations/0003_alter_awimage_src_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-06-20 17:22 2 | 3 | from django.db import migrations 4 | 5 | import headless_cms.fields.url_field 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("astrowind_widgets", "0002_awaction_add_skip_translation"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="awimage", 17 | name="src_url", 18 | field=headless_cms.fields.url_field.LocalizedUrlField( 19 | blank=True, default=dict, null=True, required=[] 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/reference/schema.rst: -------------------------------------------------------------------------------- 1 | Schema 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | CustomAutoSchema 9 | ---------------- 10 | 11 | .. autoclass:: headless_cms.schema.auto_schema.CustomAutoSchema 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | :noindex: 16 | 17 | preprocessing_filter_spec 18 | ------------------------- 19 | 20 | .. autofunction:: headless_cms.schema.preprocessing_hooks.preprocessing_filter_spec 21 | :noindex: 22 | 23 | 24 | CMSSpectacularAPIView 25 | --------------------- 26 | 27 | .. autoclass:: headless_cms.schema.views.CMSSpectacularAPIView 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | :noindex: 32 | -------------------------------------------------------------------------------- /headless_cms/schema/views.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.views import SpectacularAPIView 2 | 3 | from headless_cms.settings import headless_cms_settings 4 | 5 | 6 | class CMSSpectacularAPIView(SpectacularAPIView): 7 | """ 8 | Custom Spectacular API view for CMS. 9 | 10 | This view extends the default Spectacular API view to use custom settings 11 | for preprocessing hooks, as defined in the headless CMS settings. 12 | 13 | Attributes: 14 | custom_settings (dict): A dictionary of custom settings for the Spectacular API view. 15 | """ 16 | 17 | custom_settings = { 18 | "PREPROCESSING_HOOKS": ( 19 | headless_cms_settings.CMS_DRF_SPECTACULAR_PREPROCESSING_HOOKS 20 | ), 21 | } 22 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/ArticleImageThrough.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "position": "1", 5 | "article": 1, 6 | "article_image": 1 7 | }, 8 | { 9 | "id": "3", 10 | "position": "1", 11 | "article": 2, 12 | "article_image": 2 13 | }, 14 | { 15 | "id": "5", 16 | "position": "1", 17 | "article": 3, 18 | "article_image": 1 19 | }, 20 | { 21 | "id": "2", 22 | "position": "2", 23 | "article": 1, 24 | "article_image": 2 25 | }, 26 | { 27 | "id": "4", 28 | "position": "2", 29 | "article": 2, 30 | "article_image": 3 31 | }, 32 | { 33 | "id": "6", 34 | "position": "2", 35 | "article": 3, 36 | "article_image": 3 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/serializers.py: -------------------------------------------------------------------------------- 1 | from headless_cms.contrib.astrowind.astrowind_posts.models import AWPost 2 | from headless_cms.contrib.astrowind.astrowind_widgets.models import AWImage 3 | from headless_cms.serializers import ( 4 | LocalizedBaseSerializer, 5 | LocalizedDynamicFileSerializer, 6 | ) 7 | 8 | 9 | class RelatedPostImageSerializer(LocalizedDynamicFileSerializer): 10 | class Meta(LocalizedDynamicFileSerializer.Meta): 11 | model = AWImage 12 | 13 | 14 | class RelatedPostSerializer(LocalizedBaseSerializer): 15 | image = RelatedPostImageSerializer() 16 | 17 | class Meta(LocalizedBaseSerializer.Meta): 18 | model = AWPost 19 | fields = ["id", "title", "excerpt", "author", "slug", "image"] 20 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/migrations/0003_alter_awpostimage_src_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-06-20 17:22 2 | 3 | from django.db import migrations 4 | 5 | import headless_cms.fields.url_field 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ( 12 | "astrowind_posts", 13 | "0002_awcategory_skip_translation_awpost_add_skip_translation", 14 | ), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterField( 19 | model_name="awpostimage", 20 | name="src_url", 21 | field=headless_cms.fields.url_field.LocalizedUrlField( 22 | blank=True, default=dict, null=True, required=[] 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /docs/reference/serializers.rst: -------------------------------------------------------------------------------- 1 | Serializers 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | auto_serializer 9 | --------------- 10 | 11 | .. autofunction:: headless_cms.serializers.auto_serializer 12 | :noindex: 13 | 14 | LocalizedModelSerializer 15 | ------------------------ 16 | 17 | .. autoclass:: headless_cms.serializers.LocalizedModelSerializer 18 | :noindex: 19 | 20 | LocalizedBaseSerializer 21 | ----------------------- 22 | 23 | .. autoclass:: headless_cms.serializers.LocalizedBaseSerializer 24 | :members: 25 | :noindex: 26 | 27 | LocalizedDynamicFileSerializer 28 | ------------------------------ 29 | 30 | .. autoclass:: headless_cms.serializers.LocalizedDynamicFileSerializer 31 | :exclude-members: src, get_src 32 | :noindex: 33 | -------------------------------------------------------------------------------- /headless_cms/enhances/templates/custom_localized_fields/admin/widget.html: -------------------------------------------------------------------------------- 1 | {% with widget_id=widget.attrs.id %} 2 |
3 | 10 | {{ widget.md }} 11 | {% for widget in widget.subwidgets %} 12 |
13 |
14 | {% include widget.template_name %} 15 |
16 |
17 | {% endfor %} 18 |
19 | {% endwith %} 20 | -------------------------------------------------------------------------------- /headless_cms/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django_filters import rest_framework as filters 3 | 4 | 5 | class CMSFilterBackend(filters.DjangoFilterBackend): 6 | """ 7 | A custom filter backend that extends DjangoFilterBackend to provide filtering by multiple IDs when no 8 | custom filterset class is defined for the view. 9 | """ 10 | 11 | def get_filterset_class(self, view, queryset=None): 12 | filterset_class = super().get_filterset_class(view, queryset) 13 | if filterset_class: 14 | return filterset_class 15 | 16 | from headless_cms.mixins import HashModelMixin 17 | 18 | if not isinstance(view, HashModelMixin): 19 | return None 20 | 21 | class IdFilter(django_filters.FilterSet): 22 | id = django_filters.AllValuesMultipleFilter() 23 | 24 | return IdFilter 25 | -------------------------------------------------------------------------------- /headless_cms/schema/preprocessing_hooks.py: -------------------------------------------------------------------------------- 1 | from headless_cms.mixins import CMSSchemaMixin 2 | 3 | 4 | def preprocessing_filter_spec(endpoints): 5 | """ 6 | Preprocessing hook to filter endpoints for the OpenAPI schema. 7 | 8 | This function filters the endpoints to include only those views that extend the 9 | `CMSSchemaMixin`. It is used as a preprocessing hook for DRF Spectacular to 10 | dynamically generate the OpenAPI schema for CMS views. 11 | 12 | Args: 13 | endpoints (list): A list of endpoints to be processed. 14 | 15 | Returns: 16 | list: A filtered list of endpoints that include only CMS views. 17 | """ 18 | filtered = [] 19 | for path, path_regex, method, view in endpoints: 20 | if not issubclass(view.cls, CMSSchemaMixin): 21 | continue 22 | filtered.append((path, path_regex, method, view)) 23 | return filtered 24 | -------------------------------------------------------------------------------- /tests/test_commands/test_clean_outdated_drafts.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.core.management import call_command 3 | from reversion.models import Version 4 | 5 | from helpers.base import BaseTestCase 6 | from test_app.factories import PostFactory 7 | 8 | 9 | class CleanOutdatedDraftsTests(BaseTestCase): 10 | def test_clean_outdated_drafts(self): 11 | with reversion.create_revision(): 12 | post = PostFactory() 13 | 14 | with reversion.create_revision(): 15 | post.title.en = "Other title" 16 | post.save() 17 | 18 | post.publish() 19 | 20 | with reversion.create_revision(): 21 | post.title.en = "Another title" 22 | post.save() 23 | 24 | assert Version.objects.get_for_object(post).count() == 4 25 | 26 | call_command("clean_outdated_drafts") 27 | 28 | assert Version.objects.get_for_object(post).count() == 2 29 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /headless_cms/schema/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from drf_spectacular.settings import spectacular_settings 3 | from drf_spectacular.views import SpectacularRedocView, SpectacularSwaggerView 4 | 5 | from .views import CMSSpectacularAPIView 6 | 7 | 8 | class CustomSpectacularSwaggerView(SpectacularSwaggerView): 9 | def _get_schema_auth_names(self): 10 | return super()._get_schema_auth_names() + [ 11 | list(auth.keys())[0] for auth in spectacular_settings.SECURITY 12 | ] 13 | 14 | 15 | urlpatterns = [ 16 | path("api/cms-schema/", CMSSpectacularAPIView.as_view(), name="cms-schema"), 17 | path( 18 | "api/cms-schema/swg/", 19 | CustomSpectacularSwaggerView.as_view(url_name="cms-schema"), 20 | name="cms-swagger-ui", 21 | ), 22 | path( 23 | "api/cms-schema/redoc/", 24 | SpectacularRedocView.as_view(url_name="cms-schema"), 25 | name="cms-redoc", 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/PostTag.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "title": { 6 | "en": "Django", 7 | "ro": "Django", 8 | "vi": "Django" 9 | }, 10 | "slug": { 11 | "en": "django", 12 | "ro": "django", 13 | "vi": "django" 14 | } 15 | }, 16 | { 17 | "id": "2", 18 | "skip_translation": "0", 19 | "title": { 20 | "en": "Python", 21 | "ro": "Python", 22 | "vi": "Python" 23 | }, 24 | "slug": { 25 | "en": "python", 26 | "ro": "python", 27 | "vi": "python" 28 | } 29 | }, 30 | { 31 | "id": "3", 32 | "skip_translation": "0", 33 | "title": { 34 | "en": "Web Development", 35 | "ro": "Dezvoltare web", 36 | "vi": "Ph\u00e1t tri\u1ec3n web" 37 | }, 38 | "slug": { 39 | "en": "web-development", 40 | "ro": "dezvoltare-web", 41 | "vi": "phat-trien-web" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============================== 2 | Installation 3 | ============================== 4 | 5 | django-headless-cms is available on the Python Package Index (PyPI), so it 6 | can be installed with standard Python tools like ``pip``. 7 | 8 | Installation Methods 9 | -------------------- 10 | 11 | .. contents:: 12 | :local: 13 | :depth: 1 14 | 15 | Using pip 16 | ~~~~~~~~~ 17 | 18 | You can install django-headless-cms using pip: 19 | 20 | .. code-block:: shell 21 | 22 | pip install django-headless-cms 23 | 24 | Optional dependencies for OpenAI support can be installed with: 25 | 26 | .. code-block:: shell 27 | 28 | pip install django-headless-cms[openai] 29 | 30 | 31 | Development Version 32 | ~~~~~~~~~~~~~~~~~~~ 33 | 34 | Alternatively, you can install the git repository directly to obtain the development version: 35 | 36 | .. code-block:: shell 37 | 38 | pip install -e git+https://github.com/huynguyengl99/django-headless-cms.git#egg=django-headless-cms 39 | -------------------------------------------------------------------------------- /headless_cms/fields/martor_field.py: -------------------------------------------------------------------------------- 1 | from localized_fields.fields import LocalizedField 2 | from localized_fields.value import LocalizedStringValue 3 | 4 | from ..forms import LocalizedMartorForm 5 | from ..widgets import AdminLocalizedMartorWidget 6 | 7 | 8 | class LocalizedMartorField(LocalizedField): 9 | """ 10 | A custom field that provides a localized Markdown editor with multi-language support. 11 | It extends the LocalizedField and uses LocalizedStringValue for storing values. 12 | 13 | The field utilizes the LocalizedMartorForm and AdminLocalizedMartorWidget to render 14 | the Markdown editor in the admin interface. 15 | """ 16 | 17 | attr_class = LocalizedStringValue 18 | 19 | def formfield(self, **kwargs): 20 | kwargs.pop("widget", None) 21 | 22 | defaults = { 23 | "form_class": LocalizedMartorForm, 24 | "widget": AdminLocalizedMartorWidget, 25 | } 26 | 27 | defaults.update(kwargs) 28 | return super().formfield(**defaults) 29 | -------------------------------------------------------------------------------- /headless_cms/serializer_fields.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils import translation 3 | from rest_framework.fields import ( 4 | CharField, 5 | ) 6 | 7 | from headless_cms.utils.markdown import replace_placeholder 8 | 9 | LANGUAGE_PREFIXES = tuple(f"/{lang}/" for lang in settings.LANGUAGES) 10 | 11 | 12 | class UrlField(CharField): 13 | """This field will automatically add language prefix path for relative url (/about => /en/about) 14 | but will keep the full url as it is.""" 15 | 16 | def to_representation(self, value): 17 | value: str = super().to_representation(value) 18 | if not value.startswith("/") or value.startswith(LANGUAGE_PREFIXES): 19 | return value 20 | 21 | language_code = translation.get_language() or settings.LANGUAGE_CODE 22 | 23 | return f"/{language_code}" + value 24 | 25 | 26 | class LocalizedMartorFieldSerializer(CharField): 27 | def to_representation(self, value): 28 | return replace_placeholder(str(value), False) 29 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Category.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "title": { 6 | "en": "Technology", 7 | "ro": "Tehnologie", 8 | "vi": "C\u00f4ng ngh\u1ec7" 9 | }, 10 | "slug": { 11 | "en": "technology", 12 | "ro": "tehnologie", 13 | "vi": "cong-nghe" 14 | } 15 | }, 16 | { 17 | "id": "2", 18 | "skip_translation": "0", 19 | "title": { 20 | "en": "Health", 21 | "ro": "S\u0103n\u0103tate", 22 | "vi": "S\u1ee9c kh\u1ecfe" 23 | }, 24 | "slug": { 25 | "en": "health", 26 | "ro": "sanatate", 27 | "vi": "suc-khoe" 28 | } 29 | }, 30 | { 31 | "id": "3", 32 | "skip_translation": "0", 33 | "title": { 34 | "en": "Lifestyle", 35 | "ro": "Stil de via\u021b\u0103", 36 | "vi": "Phong c\u00e1ch s\u1ed1ng" 37 | }, 38 | "slug": { 39 | "en": "lifestyle", 40 | "ro": "stil-de-viata", 41 | "vi": "phong-cach-song" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/migrations/0002_awcategory_skip_translation_awpost_add_skip_translation.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ("astrowind_posts", "0001_initial"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="awcategory", 13 | name="skip_translation", 14 | field=models.BooleanField(default=False), 15 | ), 16 | migrations.AddField( 17 | model_name="awpost", 18 | name="skip_translation", 19 | field=models.BooleanField(default=False), 20 | ), 21 | migrations.AddField( 22 | model_name="awpostimage", 23 | name="skip_translation", 24 | field=models.BooleanField(default=False), 25 | ), 26 | migrations.AddField( 27 | model_name="awposttag", 28 | name="skip_translation", 29 | field=models.BooleanField(default=False), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Domain.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "title": { 6 | "en": "Tech Blog", 7 | "ro": "Blog Tehnologic", 8 | "vi": "Blog C\u00f4ng Ngh\u1ec7" 9 | }, 10 | "slug": { 11 | "en": "tech-blog", 12 | "ro": "blog-tehnologic", 13 | "vi": "blog-cong-nghe" 14 | } 15 | }, 16 | { 17 | "id": "2", 18 | "skip_translation": "0", 19 | "title": { 20 | "en": "Health Blog", 21 | "ro": "Blog de S\u0103n\u0103tate", 22 | "vi": "Blog S\u1ee9c Kh\u1ecfe" 23 | }, 24 | "slug": { 25 | "en": "health-blog", 26 | "ro": "blog-de-sanatate", 27 | "vi": "blog-suc-khoe" 28 | } 29 | }, 30 | { 31 | "id": "3", 32 | "skip_translation": "0", 33 | "title": { 34 | "en": "Lifestyle Blog", 35 | "ro": "Blog de Stil de Via\u021b\u0103", 36 | "vi": "Blog Phong C\u00e1ch S\u1ed1ng" 37 | }, 38 | "slug": { 39 | "en": "lifestyle-blog", 40 | "ro": "blog-de-stil-de-viata", 41 | "vi": "blog-phong-cach-song" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /headless_cms/utils/markdown.py: -------------------------------------------------------------------------------- 1 | import re 2 | from ast import literal_eval 3 | 4 | from django.apps import apps 5 | from django.utils.translation import gettext_lazy as _ 6 | from martor.utils import markdownify 7 | 8 | 9 | def replace_placeholder(markdown_text, show_invalid=True): 10 | res = markdown_text 11 | pattern = re.compile(r"(.*?)", re.DOTALL) 12 | groups = set(pattern.findall(markdown_text)) 13 | for match in groups: 14 | try: 15 | app_model, id, attr = literal_eval(match) 16 | app_str, model_str = app_model.split(".") 17 | 18 | app = apps.get_app_config(app_str) 19 | model = app.get_model(model_str) 20 | obj = model.objects.get(pk=id) 21 | dt = getattr(obj, attr) 22 | except Exception: 23 | dt = str(_("Invalid placeholder")) if show_invalid else None 24 | res = res.replace(f"{match}", dt) if dt else res 25 | 26 | return res 27 | 28 | 29 | def custom_markdownify(markdown_text): 30 | markdown_text = replace_placeholder(markdown_text) 31 | return markdownify(markdown_text) 32 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to django-headless-cms 2 | 3 | Contributions are welcome! Here are some pointers to help you install the library for development and validate your changes before submitting a pull request. 4 | 5 | ## Install the library for development 6 | 7 | 8 | First install [uv](https://docs.astral.sh/uv/getting-started/installation/), create your own `.venv` 9 | and activate it: 10 | 11 | ```bash 12 | uv venv # or pythin -m venv .venv 13 | source .venv/bin/activate 14 | ``` 15 | 16 | Then use uv install all dev package: 17 | ```bash 18 | uv sync 19 | ``` 20 | 21 | ## Validate the changes before creating a pull request 22 | 0. Prepare for test: 23 | - Docker running 24 | - Run `docker-compose up` to create testing postgresql database. 25 | 26 | 1. Make sure the existing tests are still passing (and consider adding new tests as well!): 27 | 28 | ```bash 29 | pytest --cov-report term-missing --cov=headless_cms tests 30 | ``` 31 | 32 | 2. Reformat and validate the code with the following tools: 33 | 34 | ```bash 35 | bash scripts/lint.sh [--fix] 36 | ``` 37 | 38 | These steps are also run automatically in the CI when you open the pull request. 39 | -------------------------------------------------------------------------------- /headless_cms/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.conf import settings 4 | from rest_framework.settings import APISettings 5 | 6 | HEADLESS_CMS_DEFAULTS: dict[str, Any] = { 7 | "AUTO_TRANSLATE_CLASS": "headless_cms.auto_translate.BaseTranslate", 8 | "CMS_DRF_SPECTACULAR_PREPROCESSING_HOOKS": [ 9 | "headless_cms.schema.preprocessing_hooks.preprocessing_filter_spec" 10 | ], 11 | "AUTO_TRANSLATE_IGNORES": [], 12 | "GLOBAL_EXCLUDED_SERIALIZED_FIELDS": [], 13 | "OPENAI_CHAT_MODEL": "gpt-4-turbo", 14 | "OPENAI_CLIENT": "openai.OpenAI", 15 | "DEFAULT_CMS_PERMISSION_CLASS": "rest_framework.permissions.AllowAny", 16 | "CMS_HOST": "http://localhost:8000", 17 | } 18 | 19 | IMPORT_STRINGS = [ 20 | "AUTO_TRANSLATE_CLASS", 21 | "OPENAI_CLIENT", 22 | "DEFAULT_CMS_PERMISSION_CLASS", 23 | ] 24 | 25 | 26 | class HeadlessCMSSettings(APISettings): 27 | _original_settings: dict[str, Any] = {} 28 | 29 | 30 | headless_cms_settings = HeadlessCMSSettings( 31 | user_settings=getattr(settings, "HEADLESS_CMS_SETTINGS", {}), # type: ignore 32 | defaults=HEADLESS_CMS_DEFAULTS, # type: ignore 33 | import_strings=IMPORT_STRINGS, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/helpers/base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User 3 | from django.test import SimpleTestCase, TransactionTestCase 4 | from django.test.utils import override_settings 5 | from rest_framework.test import APITransactionTestCase 6 | 7 | # Test helpers. 8 | 9 | 10 | class TestBaseTransaction(TransactionTestCase): 11 | def _should_reload_connections(self): 12 | return False 13 | 14 | 15 | @override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) 16 | class UserMixin(SimpleTestCase): 17 | def setUp(self): 18 | super().setUp() 19 | self.user = User(username="test", is_staff=True, is_superuser=True) 20 | self.user.set_password("password") 21 | self.user.save() 22 | 23 | 24 | class LoginMixin(UserMixin): 25 | 26 | def setUp(self): 27 | super().setUp() 28 | self.client.login(username="test", password="password") 29 | 30 | 31 | class BaseTestCase(LoginMixin, TestBaseTransaction): 32 | @pytest.fixture(autouse=True) 33 | def __inject_fixtures(self, mocker): 34 | self.mocker = mocker 35 | 36 | 37 | class BaseAPITestCase(LoginMixin, TestBaseTransaction, APITransactionTestCase): 38 | pass 39 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/ArticleImage.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "1", 5 | "src_file": { 6 | "en": "", 7 | "ro": "", 8 | "vi": "" 9 | }, 10 | "src_url": { 11 | "en": "https://dummyimage.com/258x543", 12 | "ro": "", 13 | "vi": "" 14 | }, 15 | "alt": { 16 | "en": "Likely result card.", 17 | "ro": "", 18 | "vi": "" 19 | } 20 | }, 21 | { 22 | "id": "2", 23 | "skip_translation": "0", 24 | "src_file": { 25 | "en": "", 26 | "ro": "", 27 | "vi": "" 28 | }, 29 | "src_url": { 30 | "en": "https://dummyimage.com/112x477", 31 | "ro": "", 32 | "vi": "" 33 | }, 34 | "alt": { 35 | "en": "Why join.", 36 | "ro": "", 37 | "vi": "" 38 | } 39 | }, 40 | { 41 | "id": "3", 42 | "skip_translation": "0", 43 | "src_file": { 44 | "en": "", 45 | "ro": "", 46 | "vi": "" 47 | }, 48 | "src_url": { 49 | "en": "https://dummyimage.com/511x380", 50 | "ro": "", 51 | "vi": "" 52 | }, 53 | "alt": { 54 | "en": "Computer want.", 55 | "ro": "", 56 | "vi": "" 57 | } 58 | } 59 | ] 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_language_version: 4 | python: python3.11 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-toml 11 | - id: check-yaml 12 | args: 13 | - --unsafe 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v3.15.2 18 | hooks: 19 | - id: pyupgrade 20 | args: 21 | - --py3-plus 22 | - --keep-runtime-typing 23 | - repo: https://github.com/astral-sh/ruff-pre-commit 24 | rev: v0.3.4 25 | hooks: 26 | - id: ruff 27 | args: 28 | - --fix 29 | - repo: https://github.com/psf/black-pre-commit-mirror 30 | rev: 24.3.0 31 | hooks: 32 | - id: black 33 | - repo: https://github.com/pappasam/toml-sort 34 | rev: v0.23.1 35 | hooks: 36 | - id: toml-sort-fix 37 | files: 'pyproject.toml' 38 | ci: 39 | autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks 40 | autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate 41 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_metadata/migrations/0002_awmetadata_add_skip_translation.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ("astrowind_metadata", "0001_initial"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="awmetadata", 13 | name="skip_translation", 14 | field=models.BooleanField(default=False), 15 | ), 16 | migrations.AddField( 17 | model_name="awmetadataimage", 18 | name="skip_translation", 19 | field=models.BooleanField(default=False), 20 | ), 21 | migrations.AddField( 22 | model_name="awmetadataopengraph", 23 | name="skip_translation", 24 | field=models.BooleanField(default=False), 25 | ), 26 | migrations.AddField( 27 | model_name="awmetadatarobot", 28 | name="skip_translation", 29 | field=models.BooleanField(default=False), 30 | ), 31 | migrations.AddField( 32 | model_name="awmetadatatwitter", 33 | name="skip_translation", 34 | field=models.BooleanField(default=False), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /headless_cms/enhances/templates/reversion/revision_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | 5 | {% block breadcrumbs %} 6 | 14 | {% endblock %} 15 | 16 | 17 | {% block object-tools %}{% endblock %} 18 | 19 | 20 | {% block form_top %} 21 |

This version is {% if is_published %}Published{% else %}Drafted{% endif %} 22 |

23 |

{% blocktrans %}Press the save button below to revert to this version of the object.{% endblocktrans %}

24 | {% endblock %} 25 | 26 | 27 | {% block submit_buttons_top %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} 28 | {% block submit_buttons_bottom %}{% with is_popup=1 %}{{block.super}}{% endwith %}{% endblock %} 29 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Blog.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "title": { 6 | "en": "", 7 | "ro": "", 8 | "vi": "" 9 | }, 10 | "slug": { 11 | "en": null, 12 | "ro": null, 13 | "vi": null 14 | }, 15 | "name": { 16 | "en": "Tech Insights", 17 | "ro": "Perspective Tehnologice", 18 | "vi": "Nh\u1eadn \u0110\u1ecbnh C\u00f4ng Ngh\u1ec7" 19 | }, 20 | "domain": 1 21 | }, 22 | { 23 | "id": "2", 24 | "skip_translation": "0", 25 | "title": { 26 | "en": "", 27 | "ro": "", 28 | "vi": "" 29 | }, 30 | "slug": { 31 | "en": null, 32 | "ro": null, 33 | "vi": null 34 | }, 35 | "name": { 36 | "en": "Health Matters", 37 | "ro": "Probleme de S\u0103n\u0103tate", 38 | "vi": "Nh\u1eefng V\u1ea5n \u0110\u1ec1 S\u1ee9c Kh\u1ecfe" 39 | }, 40 | "domain": 2 41 | }, 42 | { 43 | "id": "3", 44 | "skip_translation": "0", 45 | "title": { 46 | "en": "", 47 | "ro": "", 48 | "vi": "" 49 | }, 50 | "slug": { 51 | "en": null, 52 | "ro": null, 53 | "vi": null 54 | }, 55 | "name": { 56 | "en": "Lifestyle Choices", 57 | "ro": "Alegerea Stilului de Via\u021b\u0103", 58 | "vi": "L\u1ef1a Ch\u1ecdn Phong C\u00e1ch S\u1ed1ng" 59 | }, 60 | "domain": 3 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/management/commands/populate_aw_data.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core.management import call_command 3 | from reversion.management.commands import BaseRevisionCommand 4 | 5 | from headless_cms.models import LocalizedPublicationModel 6 | 7 | 8 | class Command(BaseRevisionCommand): 9 | """ 10 | Populates Astrowind data. 11 | 12 | Usage: 13 | python manage.py populate_aw_data 14 | """ 15 | 16 | help = "Populate astrowind data." 17 | 18 | def handle(self, *app_labels, **options): 19 | populate_apps = ( 20 | "astrowind_posts", 21 | "astrowind_pages", 22 | ) 23 | call_command( 24 | "import_cms_data", 25 | *populate_apps, 26 | input="https://raw.githubusercontent.com/huynguyengl99/dj-hcms-data/main/data/astrowind/astrowind.zip", 27 | ) 28 | page_models = [] 29 | for app_name in populate_apps: 30 | page_models.extend( 31 | app for app in apps.get_app_config(app_name).get_models() 32 | ) 33 | print("Publish Astrowind pages and posts.") 34 | for page_model in page_models: 35 | for page in page_model.objects.all(): 36 | if isinstance(page, LocalizedPublicationModel): 37 | page: LocalizedPublicationModel 38 | page.recursively_publish() 39 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/migrations/0003_awaboutpage_add_skip_translation.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ("astrowind_pages", "0002_initial"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="awaboutpage", 13 | name="skip_translation", 14 | field=models.BooleanField(default=False), 15 | ), 16 | migrations.AddField( 17 | model_name="awcontactpage", 18 | name="skip_translation", 19 | field=models.BooleanField(default=False), 20 | ), 21 | migrations.AddField( 22 | model_name="awindexpage", 23 | name="skip_translation", 24 | field=models.BooleanField(default=False), 25 | ), 26 | migrations.AddField( 27 | model_name="awpostpage", 28 | name="skip_translation", 29 | field=models.BooleanField(default=False), 30 | ), 31 | migrations.AddField( 32 | model_name="awpricingpage", 33 | name="skip_translation", 34 | field=models.BooleanField(default=False), 35 | ), 36 | migrations.AddField( 37 | model_name="awsite", 38 | name="skip_translation", 39 | field=models.BooleanField(default=False), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from headless_cms.contrib.astrowind.astrowind_pages.views import ( 4 | AWAboutPageHashView, 5 | AWAboutPageView, 6 | AWContactPageHashView, 7 | AWContactPageView, 8 | AWIndexPageHashView, 9 | AWIndexPageView, 10 | AWPostPageHashView, 11 | AWPostPageView, 12 | AWPricingPageHashView, 13 | AWPricingPageView, 14 | AWSiteHashView, 15 | AWSiteView, 16 | ) 17 | 18 | urlpatterns = [ 19 | path("index/", AWIndexPageView.as_view(), name="index"), 20 | path("index/hash/", AWIndexPageHashView.as_view(), name="index"), 21 | path("about/", AWAboutPageView.as_view(), name="about"), 22 | path("about/hash/", AWAboutPageHashView.as_view(), name="about"), 23 | path("site/", AWSiteView.as_view(), name="site"), 24 | path("site/hash/", AWSiteHashView.as_view(), name="site"), 25 | path("post-page/", AWPostPageView.as_view(), name="post-page"), 26 | path("post-page/hash/", AWPostPageHashView.as_view(), name="post-page"), 27 | path("pricing/", AWPricingPageView.as_view(), name="pricing"), 28 | path("pricing/hash/", AWPricingPageHashView.as_view(), name="pricing"), 29 | path("contact/", AWContactPageView.as_view(), name="contact"), 30 | path("contact/hash/", AWContactPageHashView.as_view(), name="contact"), 31 | path( 32 | "posts/", 33 | include("headless_cms.contrib.astrowind.astrowind_posts.urls"), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /tests/test_utils/test_relations.py: -------------------------------------------------------------------------------- 1 | from headless_cms.utils.relations import calculate_prefetch_relation 2 | 3 | from helpers.base import BaseTestCase 4 | from test_app.models import Blog 5 | 6 | 7 | class RelationTests(BaseTestCase): 8 | def test_calculate_prefetch_relation(self): 9 | prefetch_relations, select_relation = calculate_prefetch_relation(Blog) 10 | prefetches = { 11 | item if isinstance(item, str) else item.prefetch_to 12 | for item in prefetch_relations 13 | } 14 | assert prefetches == { 15 | "posts", 16 | "posts__tags", 17 | "posts__tags__published_version", 18 | "posts__published_version", 19 | "posts__category", 20 | "posts__category__published_version", 21 | "posts__items", 22 | "posts__items__published_version", 23 | "posts__note", 24 | "posts__note__published_version", 25 | "articles", 26 | "articles__images", 27 | "articles__images__published_version", 28 | "articles__published_version", 29 | "articles__items", 30 | "articles__items__published_version", 31 | "articles__note", 32 | "articles__note__published_version", 33 | } 34 | assert set(select_relation) == { 35 | "published_version", 36 | "domain", 37 | "domain__published_version", 38 | } 39 | -------------------------------------------------------------------------------- /tests/test_app/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | from helpers.base import BaseAPITestCase 6 | from test_app.factories import ( # assuming you have a factory for Post model 7 | CategoryFactory, 8 | PostFactory, 9 | ) 10 | from test_app.models import Post 11 | 12 | 13 | class PostCMSViewSetTest(BaseAPITestCase): 14 | def setUp(self): 15 | super().setUp() 16 | with reversion.create_revision(): 17 | self.category = CategoryFactory.create() 18 | 19 | with reversion.create_revision(): 20 | self.published_post: Post = PostFactory.create(category=self.category) 21 | self.published_post.publish() 22 | with reversion.create_revision(): 23 | self.unpublished_post = PostFactory.create() 24 | self.url = reverse("posts-list") 25 | 26 | def test_list_published_posts(self): 27 | response = self.client.get(self.url) 28 | 29 | self.assertEqual(response.status_code, status.HTTP_200_OK) 30 | self.assertEqual(len(response.data), 1) 31 | 32 | self.assertEqual(response.data[0]["id"], self.published_post.id) 33 | 34 | def test_unpublished_posts_not_returned(self): 35 | response = self.client.get(self.url) 36 | 37 | # Check that the unpublished post is not in the returned data 38 | self.assertNotIn( 39 | self.unpublished_post.id, [post["id"] for post in response.data] 40 | ) 41 | -------------------------------------------------------------------------------- /headless_cms/fields/boolean_field.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from localized_fields.fields import LocalizedBooleanField as BaseLocalizedBooleanField 3 | from localized_fields.value import LocalizedBooleanValue, LocalizedValue 4 | 5 | 6 | class LocalizedBooleanField(BaseLocalizedBooleanField): 7 | @staticmethod 8 | def _convert_localized_value( 9 | value: LocalizedValue, 10 | ) -> LocalizedBooleanValue: 11 | """Converts from :see:LocalizedValue to :see:LocalizedBooleanValue.""" 12 | 13 | integer_values = {} 14 | for lang_code, _ in settings.LANGUAGES: 15 | local_value = value.get(lang_code, None) 16 | 17 | if isinstance(local_value, bool): 18 | integer_values[lang_code] = local_value 19 | elif isinstance(local_value, str): 20 | if local_value.lower() == "false": 21 | local_value = False 22 | elif local_value.lower() == "true": 23 | local_value = True 24 | else: 25 | raise ValueError( 26 | f"Could not convert value {local_value} to boolean." 27 | ) 28 | 29 | integer_values[lang_code] = local_value 30 | elif local_value is not None: 31 | raise TypeError( 32 | f"Expected value of type str instead of {type(local_value)}." 33 | ) 34 | 35 | return LocalizedBooleanValue(integer_values) 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Django Headless CMS documentation! 2 | ============================================= 3 | 4 | **Django-headless-cms** is a powerful, flexible, and open-source content management system designed to simplify the 5 | process of managing and delivering content across multiple platforms. Built on top of Django, it leverages the sturdy 6 | foundation of the Django framework while providing a headless approach, making it ideal for modern web applications. 7 | With features such as multi-language support, versioning, auto-translation with ChatGPT, and a responsive admin 8 | interface, Django-headless-cms aims to streamline content management. It integrates seamlessly with existing Python 9 | and Django libraries, offers robust API generation, and supports schema migrations to ensure smooth synchronization 10 | between development and production environments. Whether you are building a blog, an e-commerce site, or a corporate 11 | website, Django-headless-cms provides the tools you need to manage your content efficiently and effectively. 12 | 13 | 14 | Contents 15 | -------- 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: User guide 20 | 21 | introduction 22 | quick-start 23 | installation 24 | configuration 25 | how-to-use 26 | faq 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | :caption: Reference 31 | 32 | reference/models 33 | reference/fields 34 | reference/admin 35 | reference/serializers 36 | reference/mixins 37 | reference/filters 38 | reference/schema 39 | reference/auto_translate 40 | reference/commands 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2024-present, Huy Nguyen . 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /headless_cms/schema/auto_schema.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.openapi import AutoSchema 2 | from drf_spectacular.utils import OpenApiParameter 3 | 4 | 5 | class CustomAutoSchema(AutoSchema): 6 | """ 7 | Custom AutoSchema for DRF Spectacular. 8 | 9 | This class extends the default AutoSchema provided by DRF Spectacular to add 10 | global parameters to the OpenAPI schema. Specifically, it adds an "accept-language" 11 | header parameter to all endpoints. 12 | 13 | Attributes: 14 | global_params (list): A list of global OpenAPI parameters to be added to all endpoints. 15 | 16 | Configuration: 17 | To use this custom schema in Django REST Framework, update your settings: 18 | 19 | .. code-block:: python 20 | 21 | # Rest framework 22 | REST_FRAMEWORK = { 23 | "DEFAULT_RENDERER_CLASSES": [ 24 | "rest_framework.renderers.JSONRenderer", 25 | ], 26 | "DEFAULT_SCHEMA_CLASS": "headless_cms.schema.auto_schema.CustomAutoSchema", 27 | } 28 | """ 29 | 30 | global_params = [ 31 | OpenApiParameter( 32 | name="accept-language", 33 | type=str, 34 | location=OpenApiParameter.HEADER, 35 | description="Language code parameter.", 36 | ) 37 | ] 38 | 39 | def get_override_parameters(self): 40 | """ 41 | Get the override parameters for the schema. 42 | 43 | This method extends the default parameters with the global parameters defined in 44 | the `global_params` attribute. 45 | 46 | Returns: 47 | list: A list of OpenAPI parameters. 48 | """ 49 | params = super().get_override_parameters() 50 | return params + self.global_params 51 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | import os 6 | 7 | # -- Project information ----------------------------------------------------- 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 9 | import sys 10 | from importlib.metadata import version 11 | from pathlib import Path 12 | 13 | import django 14 | 15 | sys.path.append(os.path.abspath(".")) 16 | sys.path.append(os.path.abspath("..")) 17 | sys.path.append(os.path.abspath("../tests")) 18 | os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 19 | 20 | django.setup() 21 | 22 | project = "Django Headless CMS" 23 | copyright = "2024, Huy Nguyen" 24 | author = "Huy Nguyen" 25 | 26 | release = version("django-headless-cms") 27 | version = release 28 | 29 | 30 | sys.path.insert(0, str(Path(__file__).parent.parent)) 31 | 32 | # -- General configuration --------------------------------------------------- 33 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 34 | 35 | extensions = [ 36 | "sphinx.ext.duration", 37 | "sphinx.ext.doctest", 38 | "sphinx.ext.autodoc", 39 | "sphinx.ext.autosummary", 40 | "sphinx.ext.intersphinx", 41 | "sphinx.ext.napoleon", 42 | "sphinx.ext.autosectionlabel", 43 | ] 44 | 45 | templates_path = ["_templates"] 46 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "images"] 47 | 48 | 49 | # -- Options for HTML output ------------------------------------------------- 50 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 51 | html_theme = "sphinx_rtd_theme" 52 | html_static_path = ["_static"] 53 | -------------------------------------------------------------------------------- /headless_cms/enhances/templates/admin/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_form.html' %} 2 | {% load i18n %} 3 | 4 | {% block object-tools %} 5 | {{ block.super }} 6 | {% if published_state %} 7 |
Item {{ published_state }}.
8 | {% endif %} 9 | {% endblock %} 10 | 11 | {% block field_sets %} 12 | {% if published_state %} 13 |
14 | {% if show_publish %} 15 | {% endif %} 16 | {% if show_recursively_publish %} 17 | {% endif %} 19 | {% if show_unpublish %}{% endif %} 21 |
22 | {% endif %} 23 | {{ block.super }} 24 | {% endblock %} 25 | 26 | {% block after_related_objects %} 27 | {{ block.super }} 28 | {% if show_translate %} 29 |
30 | 31 | 32 | 33 | 34 |
35 | {% endif %} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | release: 10 | if: "startsWith(github.event.head_commit.message, 'bump:')" 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | name: "Release to github" 15 | steps: 16 | - uses: softprops/action-gh-release@v2 17 | with: 18 | tag_name: ${{ github.ref_name }} 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 21 | 22 | publish: 23 | if: "startsWith(github.event.head_commit.message, 'bump:')" 24 | runs-on: ubuntu-latest 25 | permissions: 26 | id-token: write 27 | steps: 28 | - name: Dump GitHub context 29 | env: 30 | GITHUB_CONTEXT: ${{ toJson(github) }} 31 | run: echo "$GITHUB_CONTEXT" 32 | - uses: actions/checkout@v4 33 | - name: Set up Python 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: "3.11" 37 | cache-dependency-path: pyproject.toml 38 | - uses: actions/cache@v4 39 | id: cache 40 | with: 41 | path: ${{ env.pythonLocation }} 42 | key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-publish 43 | - name: Install build dependencies 44 | if: steps.cache.outputs.cache-hit != 'true' 45 | run: pip install build 46 | - name: Build distribution 47 | run: python -m build 48 | - name: Publish 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | with: 51 | password: ${{ secrets.PYPI_API_TOKEN }} 52 | - name: Dump GitHub context 53 | env: 54 | GITHUB_CONTEXT: ${{ toJson(github) }} 55 | run: echo "$GITHUB_CONTEXT" 56 | -------------------------------------------------------------------------------- /docs/reference/models.rst: -------------------------------------------------------------------------------- 1 | Models 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | 9 | PublishedQuerySet 10 | ----------------- 11 | 12 | .. autoclass:: headless_cms.models.PublishedQuerySet 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | :noindex: 17 | 18 | PublishedManager 19 | ---------------- 20 | 21 | .. autoclass:: headless_cms.models.PublishedManager 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | :noindex: 26 | 27 | LocalizedPublicationModel 28 | ------------------------- 29 | 30 | .. autoclass:: headless_cms.models.LocalizedPublicationModel 31 | :members: 32 | :undoc-members: 33 | :show-inheritance: 34 | :noindex: 35 | 36 | LocalizedSingletonModel 37 | ----------------------- 38 | 39 | .. autoclass:: headless_cms.models.LocalizedSingletonModel 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | :noindex: 44 | 45 | LocalizedTitleSlugModel 46 | ----------------------- 47 | 48 | .. autoclass:: headless_cms.models.LocalizedTitleSlugModel 49 | :members: 50 | :undoc-members: 51 | :show-inheritance: 52 | :noindex: 53 | 54 | LocalizedDynamicFileModel 55 | ------------------------- 56 | 57 | .. autoclass:: headless_cms.models.LocalizedDynamicFileModel 58 | :members: 59 | :undoc-members: 60 | :show-inheritance: 61 | :noindex: 62 | 63 | M2MSortedOrderThrough 64 | ---------------------- 65 | 66 | .. autoclass:: headless_cms.models.M2MSortedOrderThrough 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | :noindex: 71 | 72 | SortableGenericBaseModel 73 | ------------------------ 74 | 75 | .. autoclass:: headless_cms.models.SortableGenericBaseModel 76 | :members: 77 | :undoc-members: 78 | :show-inheritance: 79 | :noindex: 80 | -------------------------------------------------------------------------------- /docs/reference/commands.rst: -------------------------------------------------------------------------------- 1 | Commands 2 | ======== 3 | 4 | This section provides documentation on the various management commands available in the headless CMS. 5 | 6 | Clean Outdated Drafts 7 | --------------------- 8 | 9 | Deletes outdated drafts. 10 | 11 | Usage: 12 | 13 | .. code-block:: shell 14 | 15 | python manage.py clean_outdated_drafts --days 16 | 17 | Options: 18 | --days: Delete only revisions older than the specified number of days. 19 | 20 | .. autofunction:: headless_cms.core.management.commands.clean_outdated_drafts.Command 21 | 22 | Export CMS Data 23 | --------------- 24 | 25 | Exports data recursively of a Django app into JSON files. 26 | 27 | Usage: 28 | 29 | .. code-block:: shell 30 | 31 | python manage.py export_cms_data --output [--compress] [--cf ] 32 | 33 | Options: 34 | --output: Export data to this directory. 35 | --compress: Compress data. 36 | --cf, --compress-format: Compression format (default is zip). 37 | 38 | .. autofunction:: headless_cms.core.management.commands.export_cms_data.Command 39 | 40 | Import CMS Data 41 | --------------- 42 | 43 | Imports data recursively of a Django app from JSON files. 44 | 45 | Usage: 46 | 47 | .. code-block:: shell 48 | 49 | python manage.py import_cms_data --input [--cf ] 50 | 51 | Options: 52 | --input: Directory or compression file to import data from. 53 | --cf, --compress-format: Compression format (default is zip). 54 | 55 | .. autofunction:: headless_cms.core.management.commands.import_cms_data.Command 56 | 57 | Populate Astrowind Data 58 | ----------------------- 59 | 60 | Populates Astrowind data. 61 | 62 | Usage: 63 | 64 | .. code-block:: shell 65 | 66 | python manage.py populate_aw_data 67 | 68 | .. autofunction:: headless_cms.contrib.astrowind.astrowind_pages.management.commands.populate_aw_data.Command 69 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0002_article_skip_translation_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-06-17 08:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("test_app", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="article", 15 | name="skip_translation", 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name="articleimage", 20 | name="skip_translation", 21 | field=models.BooleanField(default=False), 22 | ), 23 | migrations.AddField( 24 | model_name="blog", 25 | name="skip_translation", 26 | field=models.BooleanField(default=False), 27 | ), 28 | migrations.AddField( 29 | model_name="category", 30 | name="skip_translation", 31 | field=models.BooleanField(default=False), 32 | ), 33 | migrations.AddField( 34 | model_name="domain", 35 | name="skip_translation", 36 | field=models.BooleanField(default=False), 37 | ), 38 | migrations.AddField( 39 | model_name="item", 40 | name="skip_translation", 41 | field=models.BooleanField(default=False), 42 | ), 43 | migrations.AddField( 44 | model_name="note", 45 | name="skip_translation", 46 | field=models.BooleanField(default=False), 47 | ), 48 | migrations.AddField( 49 | model_name="post", 50 | name="skip_translation", 51 | field=models.BooleanField(default=False), 52 | ), 53 | migrations.AddField( 54 | model_name="posttag", 55 | name="skip_translation", 56 | field=models.BooleanField(default=False), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /docs/reference/admin.rst: -------------------------------------------------------------------------------- 1 | Admin 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | 9 | auto_admins 10 | ----------- 11 | 12 | .. autofunction:: headless_cms.admin.auto_admins 13 | :noindex: 14 | 15 | 16 | 17 | EnhancedLocalizedVersionAdmin 18 | ----------------------------- 19 | 20 | .. autoclass:: headless_cms.admin.EnhancedLocalizedVersionAdmin 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | :noindex: 25 | :exclude-members: actions, change_view, changelist_view, formfield_overrides, get_list_display, get_resource_classes, media, render_change_form, response_change, revision_view 26 | 27 | PublishStatusInlineMixin 28 | ------------------------ 29 | 30 | .. autoclass:: headless_cms.admin.PublishStatusInlineMixin 31 | :members: 32 | :undoc-members: 33 | :show-inheritance: 34 | :noindex: 35 | 36 | BaseGenericAdmin 37 | ---------------- 38 | 39 | .. autoclass:: headless_cms.admin.BaseGenericAdmin 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | :noindex: 44 | 45 | BaseSortableGenericAdmin 46 | ------------------------ 47 | 48 | .. autoclass:: headless_cms.admin.BaseSortableGenericAdmin 49 | :members: 50 | :undoc-members: 51 | :show-inheritance: 52 | :noindex: 53 | 54 | 55 | publish 56 | ------- 57 | 58 | .. autofunction:: headless_cms.admin.publish 59 | :noindex: 60 | 61 | unpublish 62 | --------- 63 | 64 | .. autofunction:: headless_cms.admin.unpublish 65 | :noindex: 66 | 67 | translate_missing 68 | ----------------- 69 | 70 | .. autofunction:: headless_cms.admin.translate_missing 71 | :noindex: 72 | 73 | force_translate 74 | --------------- 75 | 76 | .. autofunction:: headless_cms.admin.force_translate 77 | :noindex: 78 | 79 | create_m2m_inline_admin 80 | ----------------------- 81 | 82 | .. autofunction:: headless_cms.admin.create_m2m_inline_admin 83 | :noindex: 84 | 85 | create_generic_inline_admin 86 | --------------------------- 87 | 88 | .. autofunction:: headless_cms.admin.create_generic_inline_admin 89 | :noindex: 90 | -------------------------------------------------------------------------------- /tests/test_commands/data/corrupted_dir/test_app/Post.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "4", 4 | "title": { 5 | "en": "The Importance of Environmental Conservation", 6 | "ro": "Importanța conservării mediului", 7 | "vi": "Tầm quan trọng của bảo tồn môi trường" 8 | }, 9 | "subtitle": { 10 | "en": "", 11 | "ro": "", 12 | "vi": "" 13 | }, 14 | "description": { 15 | "en": "This post discusses the importance of environmental conservation.", 16 | "ro": "Această postare discută importanța conservării mediului.", 17 | "vi": "Bài đăng này thảo luận về tầm quan trọng của bảo tồn môi trường." 18 | }, 19 | "body": { 20 | "en": "Environmental conservation is crucial for the sustainability of our planet.", 21 | "ro": "Conservarea mediului este crucială pentru sustenabilitatea planetei noastre.", 22 | "vi": "Bảo tồn môi trường là rất quan trọng cho sự bền vững của hành tinh chúng ta." 23 | }, 24 | "href": "{'en': 'https://example.com/en/environmental-conservation', 'ro': 'https://example.com/ro/conservarea-mediului', 'vi': 'https://example.com/vi/bao-ton-moi-truong'}", 25 | }, 26 | { 27 | "id": "abc", 28 | "title": "hello", 29 | "subtitle": { 30 | "en": "", 31 | "ro": "", 32 | "vi": "" 33 | }, 34 | "description": { 35 | "en": "This post highlights the top sports to stay fit.", 36 | "ro": "Această postare evidențiază cele mai bune sporturi pentru a rămâne în formă.", 37 | "vi": "Bài đăng này nêu bật những môn thể thao hàng đầu để giữ dáng." 38 | }, 39 | "body": { 40 | "en": "Engaging in sports is a great way to maintain physical health and fitness.", 41 | "ro": "Participarea la sporturi este o modalitate excelentă de a menține sănătatea fizică și forma fizică.", 42 | "vi": "Tham gia vào các môn thể thao là một cách tuyệt vời để duy trì sức khỏe thể chất và hình thể." 43 | }, 44 | "href": "{'en': 'https://example.com/en/top-sports', 'ro': 'https://example.com/ro/sporturi-pentru-forma', 'vi': 'https://example.com/vi/mon-the-thao-giu-dang'}", 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Article.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "title": { 6 | "en": "First Article", 7 | "ro": "Primul articol", 8 | "vi": "B\u00e0i vi\u1ebft \u0111\u1ea7u ti\u00ean" 9 | }, 10 | "subtitle": { 11 | "en": "Introduction to Django", 12 | "ro": "Introducere \u00een Django", 13 | "vi": "Gi\u1edbi thi\u1ec7u v\u1ec1 Django" 14 | }, 15 | "story": { 16 | "en": "This is the story of the first article.", 17 | "ro": "Aceasta este povestea primului articol.", 18 | "vi": "\u0110\u00e2y l\u00e0 c\u00e2u chuy\u1ec7n c\u1ee7a b\u00e0i vi\u1ebft \u0111\u1ea7u ti\u00ean." 19 | }, 20 | "note": 2 21 | }, 22 | { 23 | "id": "2", 24 | "skip_translation": "0", 25 | "title": { 26 | "en": "Second Article", 27 | "ro": "Al doilea articol", 28 | "vi": "B\u00e0i vi\u1ebft th\u1ee9 hai" 29 | }, 30 | "subtitle": { 31 | "en": "Health Benefits of Yoga", 32 | "ro": "Beneficiile s\u0103n\u0103t\u0103\u021bii ale yoga", 33 | "vi": "L\u1ee3i \u00edch s\u1ee9c kh\u1ecfe c\u1ee7a Yoga" 34 | }, 35 | "story": { 36 | "en": "This is the story of the second article.", 37 | "ro": "Aceasta este povestea al doilea articol.", 38 | "vi": "\u0110\u00e2y l\u00e0 c\u00e2u chuy\u1ec7n c\u1ee7a b\u00e0i vi\u1ebft th\u1ee9 hai." 39 | }, 40 | "note": 1 41 | }, 42 | { 43 | "id": "3", 44 | "skip_translation": "0", 45 | "title": { 46 | "en": "Third Article", 47 | "ro": "Al treilea articol", 48 | "vi": "B\u00e0i vi\u1ebft th\u1ee9 ba" 49 | }, 50 | "subtitle": { 51 | "en": "Lifestyle Trends in 2023", 52 | "ro": "Tendin\u021bele stilului de via\u021b\u0103 \u00een 2023", 53 | "vi": "Xu h\u01b0\u1edbng phong c\u00e1ch s\u1ed1ng n\u0103m 2023" 54 | }, 55 | "story": { 56 | "en": "This is the story of the third article.", 57 | "ro": "Aceasta este povestea al treilea articol.", 58 | "vi": "\u0110\u00e2y l\u00e0 c\u00e2u chuy\u1ec7n c\u1ee7a b\u00e0i vi\u1ebft th\u1ee9 ba." 59 | }, 60 | "note": 2 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /headless_cms/utils/custom_import_export.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.db.models import ManyToManyField 3 | from django.utils.translation import gettext_lazy as _ 4 | from import_export import widgets 5 | from import_export.resources import ModelDeclarativeMetaclass, ModelResource 6 | from import_export.widgets import Widget 7 | from localized_fields.fields import LocalizedField, LocalizedFileField 8 | 9 | 10 | class LocalizedWidget(Widget): 11 | def render(self, value, obj=None): 12 | return value 13 | 14 | 15 | class LocalizedFileWidget(LocalizedWidget): 16 | def render(self, value, obj=None): 17 | return {k: "" for k, _v in value.__dict__.items()} 18 | 19 | 20 | class LocalizedModelResource(ModelResource): 21 | @classmethod 22 | def widget_from_django_field(cls, f, default=widgets.Widget): 23 | if isinstance(f, LocalizedFileField): 24 | return LocalizedFileWidget 25 | elif isinstance(f, LocalizedField): 26 | return LocalizedWidget 27 | return super().widget_from_django_field(f, default) 28 | 29 | def save_instance(self, *args, **kwargs): 30 | with reversion.create_revision(): 31 | reversion.set_comment(_("Import data")) 32 | super().save_instance(*args, **kwargs) 33 | 34 | 35 | def override_modelresource_factory( 36 | model, resource_class=LocalizedModelResource, exclude_m2m=False 37 | ): 38 | """ 39 | Factory for creating ``ModelResource`` class for given Django model. 40 | """ 41 | exclude = ["published_version"] 42 | if exclude_m2m: 43 | model_fields = model._meta.get_fields() 44 | for field in model_fields: 45 | if isinstance(field, ManyToManyField): 46 | exclude.append(field.name) 47 | 48 | attrs = { 49 | "model": model, 50 | "exclude": exclude, 51 | "use_natural_foreign_keys": True, 52 | "skip_unchanged": True, 53 | } 54 | Meta = type("Meta", (object,), attrs) # noqa 55 | 56 | class_name = model.__name__ + "Resource" 57 | 58 | class_attrs = { 59 | "Meta": Meta, 60 | } 61 | 62 | metaclass = ModelDeclarativeMetaclass 63 | return metaclass(class_name, (resource_class,), class_attrs) 64 | -------------------------------------------------------------------------------- /docs/reference/fields.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ====== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | LocalizedCharField 9 | ------------------ 10 | 11 | .. autoclass:: localized_fields.fields.LocalizedCharField 12 | :members: 13 | :undoc-members: 14 | :show-inheritance: 15 | :noindex: 16 | 17 | LocalizedTextField 18 | ------------------ 19 | 20 | .. autoclass:: localized_fields.fields.LocalizedTextField 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | :noindex: 25 | 26 | LocalizedMartorField 27 | -------------------- 28 | 29 | .. autoclass:: headless_cms.fields.LocalizedMartorField 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | :noindex: 34 | 35 | LocalizedUniqueNormalizedSlugField 36 | ---------------------------------- 37 | 38 | .. autoclass:: headless_cms.fields.LocalizedUniqueNormalizedSlugField 39 | :members: 40 | :undoc-members: 41 | :show-inheritance: 42 | :noindex: 43 | 44 | LocalizedUrlField 45 | ---------------------------------- 46 | 47 | .. autoclass:: headless_cms.fields.LocalizedUrlField 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | :noindex: 52 | 53 | AutoLanguageUrlField 54 | -------------------- 55 | 56 | .. autoclass:: headless_cms.fields.AutoLanguageUrlField 57 | :members: 58 | :undoc-members: 59 | :show-inheritance: 60 | :noindex: 61 | 62 | LocalizedIntegerField 63 | --------------------- 64 | 65 | .. autoclass:: localized_fields.fields.LocalizedIntegerField 66 | :members: 67 | :undoc-members: 68 | :show-inheritance: 69 | :noindex: 70 | 71 | LocalizedFloatField 72 | ------------------- 73 | 74 | .. autoclass:: localized_fields.fields.LocalizedFloatField 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | :noindex: 79 | 80 | LocalizedBooleanField 81 | --------------------- 82 | 83 | .. autoclass:: headless_cms.fields.LocalizedBooleanField 84 | :members: 85 | :undoc-members: 86 | :show-inheritance: 87 | :noindex: 88 | 89 | LocalizedFileField 90 | ------------------ 91 | 92 | .. autoclass:: localized_fields.fields.LocalizedFileField 93 | :members: 94 | :undoc-members: 95 | :show-inheritance: 96 | :noindex: 97 | -------------------------------------------------------------------------------- /headless_cms/utils/martor_custom_upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | from django.conf import settings 5 | from django.contrib.admin.views.decorators import staff_member_required 6 | from django.core.files.base import ContentFile 7 | from django.core.files.storage import default_storage 8 | from django.http import HttpResponse, JsonResponse 9 | from django.utils.translation import gettext_lazy as _ 10 | from django.views.decorators.http import require_http_methods 11 | from martor.utils import LazyEncoder 12 | 13 | 14 | @staff_member_required 15 | @require_http_methods(["POST"]) 16 | def markdown_uploader(request): 17 | """ 18 | Markdown image upload for locale storage 19 | and represent as json to markdown editor. 20 | 21 | Taken from https://github.com/agusmakmun/django-markdown-editor/wiki 22 | """ 23 | if "markdown-image-upload" in request.FILES: 24 | image = request.FILES["markdown-image-upload"] 25 | image_types = [ 26 | "image/png", 27 | "image/jpg", 28 | "image/jpeg", 29 | "image/pjpeg", 30 | "image/gif", 31 | ] 32 | if image.content_type not in image_types: 33 | return JsonResponse( 34 | {"status": 405, "error": _("Bad image format.")}, 35 | encoder=LazyEncoder, 36 | status=405, 37 | ) 38 | 39 | if image.size > settings.FILE_UPLOAD_MAX_MEMORY_SIZE: 40 | to_mb = settings.FILE_UPLOAD_MAX_MEMORY_SIZE / (1024 * 1024) 41 | return JsonResponse( 42 | { 43 | "status": 405, 44 | "error": _("Maximum image file is %(size)s MB.") % {"size": to_mb}, 45 | }, 46 | encoder=LazyEncoder, 47 | status=405, 48 | ) 49 | 50 | img_uuid = "{}-{}".format(uuid.uuid4().hex[:10], image.name.replace(" ", "-")) 51 | tmp_file = os.path.join(settings.MARTOR_UPLOAD_PATH, img_uuid) 52 | def_path = default_storage.save(tmp_file, ContentFile(image.read())) 53 | img_url = os.path.join(settings.MEDIA_URL, def_path) 54 | 55 | return JsonResponse({"status": 200, "link": img_url, "name": image.name}) 56 | return HttpResponse(_("Invalid request!")) 57 | -------------------------------------------------------------------------------- /headless_cms/enhances/templates/reversion/object_history.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/object_history.html" %} 2 | {% load i18n %} 3 | 4 | 5 | {% block content %} 6 |
7 | 8 |

{% blocktrans %}Choose a date from the list below to revert to a previous version of this object.{% endblocktrans %}

9 | 10 |
11 | {% if action_list %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for action in action_list %} 23 | 24 | 25 | 33 | 34 | 35 | 36 | {% endfor %} 37 | 38 |
{% trans 'Date/time' %}{% trans 'User' %}{% trans 'Action' %}{% trans 'Published' %}
{{action.revision.date_created|date:"DATETIME_FORMAT"}} 26 | {% if action.revision.user %} 27 | {{action.revision.user.get_username}} 28 | {% if action.revision.user.get_full_name %} ({{action.revision.user.get_full_name}}){% endif %} 29 | {% else %} 30 | — 31 | {% endif %} 32 | {{action.revision.get_comment|linebreaksbr|default:""}}{% if action.revision.id == object.published_version.revision_id %}✅{% else %}-{% endif %}
39 | {% else %} 40 |

{% trans "This object doesn't have a change history. It probably wasn't added via this admin site." %}

41 | {% endif %} 42 |
43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_widgets/admin.py: -------------------------------------------------------------------------------- 1 | from adminsortable2.admin import ( 2 | SortableAdminBase, 3 | SortableInlineAdminMixin, 4 | ) 5 | from django.contrib import admin 6 | from django.contrib.admin import StackedInline 7 | 8 | from headless_cms.admin import ( 9 | EnhancedLocalizedVersionAdmin, 10 | PublishStatusInlineMixin, 11 | auto_admins, 12 | ) 13 | from headless_cms.contrib.astrowind.astrowind_widgets.models import ( 14 | AWAction, 15 | AWBlogHighlightedPost, 16 | AWBlogLatestPost, 17 | AWBrand, 18 | AWCallToAction, 19 | AWContact, 20 | AWContent, 21 | AWDisclaimer, 22 | AWFaq, 23 | AWFeature, 24 | AWFeature2, 25 | AWFeature3, 26 | AWFooter, 27 | AWFooterLink, 28 | AWFooterLinkItem, 29 | AWHeader, 30 | AWHeaderLink, 31 | AWHero, 32 | AWHeroText, 33 | AWImage, 34 | AWInput, 35 | AWItem, 36 | AWPriceItem, 37 | AWPricing, 38 | AWStat, 39 | AWStatItem, 40 | AWStep, 41 | AWStep2, 42 | AWTestimonial, 43 | AWTestimonialItem, 44 | AWTextArea, 45 | ) 46 | 47 | 48 | class AWHeaderLinkSelfInline( 49 | PublishStatusInlineMixin, 50 | SortableInlineAdminMixin, 51 | StackedInline, 52 | ): 53 | model = AWHeaderLink.links.through 54 | fk_name = "parent_link" 55 | extra = 0 56 | 57 | 58 | @admin.register(AWHeaderLink) 59 | class AWHeaderLinkAdmin(SortableAdminBase, EnhancedLocalizedVersionAdmin): 60 | history_latest_first = True 61 | 62 | inlines = [AWHeaderLinkSelfInline] 63 | 64 | 65 | auto_admins( 66 | [ 67 | AWAction, 68 | AWBlogHighlightedPost, 69 | AWBlogLatestPost, 70 | AWBrand, 71 | AWCallToAction, 72 | AWContact, 73 | AWContent, 74 | AWDisclaimer, 75 | AWFaq, 76 | AWFeature, 77 | AWFeature2, 78 | AWFeature3, 79 | AWFooter, 80 | AWFooterLink, 81 | AWFooterLinkItem, 82 | AWHeader, 83 | AWHero, 84 | AWHeroText, 85 | AWImage, 86 | AWInput, 87 | AWItem, 88 | AWPriceItem, 89 | AWPricing, 90 | AWStat, 91 | AWStatItem, 92 | AWStep, 93 | AWStep2, 94 | AWTestimonial, 95 | AWTestimonialItem, 96 | AWTextArea, 97 | ] 98 | ) 99 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_pages/views.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | from rest_framework.generics import RetrieveAPIView 3 | 4 | from headless_cms.contrib.astrowind.astrowind_pages.models import ( 5 | AWAboutPage, 6 | AWContactPage, 7 | AWIndexPage, 8 | AWPostPage, 9 | AWPricingPage, 10 | AWSite, 11 | ) 12 | from headless_cms.mixins import CMSSchemaMixin 13 | from headless_cms.serializers import HashModelSerializer, auto_serializer 14 | 15 | 16 | class AWPageView(CMSSchemaMixin, RetrieveAPIView): 17 | model = None 18 | 19 | def get_object(self): 20 | obj = self.model.published_objects.published(auto_prefetch=True).first() 21 | if not obj: 22 | raise Http404 23 | self.check_object_permissions(self.request, obj) 24 | return obj 25 | 26 | 27 | class AWIndexPageView(AWPageView): 28 | serializer_class = auto_serializer(AWIndexPage) 29 | model = AWIndexPage 30 | 31 | 32 | class AWSiteView(AWPageView): 33 | serializer_class = auto_serializer(AWSite) 34 | model = AWSite 35 | 36 | 37 | class AWAboutPageView(AWPageView): 38 | serializer_class = auto_serializer(AWAboutPage) 39 | model = AWAboutPage 40 | 41 | 42 | class AWPricingPageView(AWPageView): 43 | serializer_class = auto_serializer(AWPricingPage) 44 | model = AWPricingPage 45 | 46 | 47 | class AWContactPageView(AWPageView): 48 | serializer_class = auto_serializer(AWContactPage) 49 | model = AWContactPage 50 | 51 | 52 | class AWPostPageView(AWPageView): 53 | serializer_class = auto_serializer(AWPostPage) 54 | model = AWPostPage 55 | 56 | 57 | class AWIndexPageHashView(AWPageView): 58 | serializer_class = HashModelSerializer 59 | model = AWIndexPage 60 | 61 | 62 | class AWSiteHashView(AWPageView): 63 | serializer_class = HashModelSerializer 64 | model = AWSite 65 | 66 | 67 | class AWAboutPageHashView(AWPageView): 68 | serializer_class = HashModelSerializer 69 | model = AWAboutPage 70 | 71 | 72 | class AWPricingPageHashView(AWPageView): 73 | serializer_class = HashModelSerializer 74 | model = AWPricingPage 75 | 76 | 77 | class AWContactPageHashView(AWPageView): 78 | serializer_class = HashModelSerializer 79 | model = AWContactPage 80 | 81 | 82 | class AWPostPageHashView(AWPageView): 83 | serializer_class = HashModelSerializer 84 | model = AWPostPage 85 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/views.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from django.db.models import Q 3 | from drf_spectacular.types import OpenApiTypes 4 | from drf_spectacular.utils import extend_schema_field 5 | from rest_framework.pagination import PageNumberPagination 6 | from rest_framework.viewsets import ReadOnlyModelViewSet 7 | 8 | from headless_cms.contrib.astrowind.astrowind_posts.models import ( 9 | AWCategory, 10 | AWPost, 11 | AWPostTag, 12 | ) 13 | from headless_cms.contrib.astrowind.astrowind_posts.serializers import ( 14 | RelatedPostSerializer, 15 | ) 16 | from headless_cms.mixins import CMSSchemaMixin, HashModelMixin 17 | from headless_cms.serializers import auto_serializer 18 | 19 | 20 | class AWPostPaginator(PageNumberPagination): 21 | page_size = 10 22 | page_size_query_param = "size" 23 | 24 | 25 | class PostFilter(django_filters.FilterSet): 26 | id = django_filters.AllValuesMultipleFilter() 27 | category = extend_schema_field(OpenApiTypes.STR)( 28 | django_filters.AllValuesFilter( 29 | field_name="category__slug", 30 | method="filter_slug", 31 | ) 32 | ) 33 | tag = extend_schema_field(OpenApiTypes.STR)( 34 | django_filters.AllValuesFilter( 35 | field_name="tags__slug", 36 | method="filter_slug", 37 | ) 38 | ) 39 | 40 | def filter_slug(self, queryset, name, value): 41 | return queryset.filter(Q(**{name + "__iexact": value})) 42 | 43 | class Meta: 44 | model = AWPost 45 | fields = ["id", "category", "tag"] 46 | 47 | 48 | class AWPostCMSViewSet(CMSSchemaMixin, HashModelMixin, ReadOnlyModelViewSet): 49 | queryset = AWPost.published_objects.published(auto_prefetch=True) 50 | serializer_class = auto_serializer( 51 | AWPost, 52 | override_model_serializer_fields={ 53 | AWPost: {"related_posts": RelatedPostSerializer(read_only=True, many=True)} 54 | }, 55 | ) 56 | filterset_class = PostFilter 57 | pagination_class = AWPostPaginator 58 | 59 | 60 | class AWPostTagViewSet(CMSSchemaMixin, HashModelMixin, ReadOnlyModelViewSet): 61 | queryset = AWPostTag.published_objects.published() 62 | serializer_class = auto_serializer(AWPostTag) 63 | pagination_class = None 64 | 65 | 66 | class AWCategoryViewSet(CMSSchemaMixin, HashModelMixin, ReadOnlyModelViewSet): 67 | queryset = AWCategory.published_objects.published() 68 | serializer_class = auto_serializer(AWCategory) 69 | pagination_class = None 70 | -------------------------------------------------------------------------------- /headless_cms/mixins.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.utils import extend_schema 2 | from rest_framework.decorators import action 3 | from rest_framework.response import Response 4 | from rest_framework.settings import api_settings as rest_framework_settings 5 | 6 | from headless_cms.serializers import HashModelSerializer 7 | from headless_cms.settings import headless_cms_settings 8 | 9 | 10 | class CMSSchemaMixin: 11 | """ 12 | Mixin to include views in the OpenAPI schema documentation for the CMS. 13 | 14 | This mixin is used to dynamically generate documentation for the OpenAPI schema, 15 | ensuring that only views extending this mixin will be included in the generated 16 | schema. It also provides default CMS permission classes for Django REST Framework 17 | views. 18 | 19 | Attributes: 20 | permission_classes (list): A list of permission classes that includes the default 21 | CMS permission class followed by the default permission classes from Django 22 | REST Framework settings. 23 | 24 | Example: 25 | This mixin can be used in a Django REST Framework view to include the view in 26 | the OpenAPI schema documentation and enforce CMS-specific permission policies. 27 | 28 | .. code-block:: python 29 | 30 | from rest_framework.views import APIView 31 | from headless_cms.mixins import CMSSchemaMixin 32 | 33 | class MyCMSView(CMSSchemaMixin, APIView): 34 | # Your view implementation here 35 | pass 36 | """ 37 | 38 | permission_classes = [ 39 | headless_cms_settings.DEFAULT_CMS_PERMISSION_CLASS 40 | ] + rest_framework_settings.DEFAULT_PERMISSION_CLASSES 41 | 42 | def __init_subclass__(cls, **kwargs): 43 | cls.__doc__ = cls.__doc__ or " " 44 | 45 | 46 | class HashModelMixin: 47 | """ 48 | A mixin that adds hash-related actions to a Django REST Framework viewset. 49 | 50 | This mixin provides actions to retrieve hash data for a list of model instances. 51 | """ 52 | 53 | @extend_schema( 54 | responses=HashModelSerializer(many=True), 55 | ) 56 | @action( 57 | detail=False, 58 | methods=["GET"], 59 | pagination_class=None, 60 | serializer_class=HashModelSerializer, 61 | ) 62 | def hash(self, request, *args, **kwargs): 63 | queryset = self.filter_queryset(self.get_queryset()) 64 | 65 | serializer = HashModelSerializer(queryset, many=True) 66 | return Response(serializer.data) 67 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Dump GitHub context 17 | env: 18 | GITHUB_CONTEXT: ${{ toJson(github) }} 19 | run: echo "$GITHUB_CONTEXT" 20 | - uses: actions/checkout@v4 21 | - name: Install the latest version of uv and set the python version to 3.13t 22 | uses: astral-sh/setup-uv@v5 23 | with: 24 | enable-cache: true 25 | python-version: 3.11 26 | - name: Lint 27 | run: uv run bash scripts/lint.sh 28 | 29 | test: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | python-version: ["3.10", "3.11", "3.12"] 34 | django-version: ["4.2", "5.0", "5.1", "5.2"] 35 | fail-fast: false 36 | services: 37 | postgres: 38 | image: postgres 39 | env: 40 | POSTGRES_PASSWORD: cms_test_pass 41 | POSTGRES_DB: cms_test_db 42 | POSTGRES_USER: cms_test_user 43 | options: >- 44 | --health-cmd pg_isready 45 | --health-interval 10s 46 | --health-timeout 5s 47 | --health-retries 5 48 | ports: 49 | - 5432:5432 50 | env: 51 | POSTGRES_DB: cms_test_db 52 | POSTGRES_USER: cms_test_user 53 | POSTGRES_PASSWORD: cms_test_pass 54 | POSTGRES_HOST: localhost 55 | POSTGRES_PORT: 5432 56 | steps: 57 | - name: Dump GitHub context 58 | env: 59 | GITHUB_CONTEXT: ${{ toJson(github) }} 60 | run: echo "$GITHUB_CONTEXT" 61 | - uses: actions/checkout@v4 62 | - name: Install the latest version of uv and set the python version 63 | uses: astral-sh/setup-uv@v5 64 | with: 65 | enable-cache: true 66 | python-version: ${{ matrix.python-version }} 67 | - run: mkdir coverage 68 | - name: Install Django 69 | run: uv add "Django~=${{ matrix.django-version }}.0" 70 | - name: Test 71 | run: uv run --group test coverage run -m pytest tests 72 | env: 73 | COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-django${{ matrix.django-version }} 74 | CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-django${{ matrix.django-version }} 75 | - name: Store coverage files 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: coverage 79 | path: coverage 80 | -------------------------------------------------------------------------------- /headless_cms/utils/relations.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | 3 | from headless_cms.models import LocalizedPublicationModel, M2MSortedOrderThrough 4 | 5 | calculated_models = {} 6 | 7 | 8 | class CumulativePrefetch(Prefetch): 9 | def __radd__(self, other: str): 10 | self.add_prefix(other.replace("__", "")) 11 | return self 12 | 13 | 14 | def calculate_prefetch_relation( 15 | model: type[LocalizedPublicationModel], fetched_models: set | None = None 16 | ): 17 | if fetched_models is None: 18 | fetched_models = {model} 19 | prefetch_relations = [] 20 | select_relations = ["published_version"] 21 | fields = model._meta.get_fields() 22 | fetched_models = fetched_models | {model} 23 | for f in fields: 24 | rel_model = f.related_model 25 | if ( 26 | not rel_model 27 | or rel_model in fetched_models 28 | or not issubclass(rel_model, LocalizedPublicationModel) 29 | or f.auto_created 30 | ): 31 | continue 32 | f_name = f.name 33 | if f.many_to_many or f.one_to_many: 34 | if f.many_to_many and issubclass( 35 | f.remote_field.through, M2MSortedOrderThrough 36 | ): 37 | thr = f.remote_field.through 38 | tfs = thr._meta.get_fields() 39 | rlt_field = next( 40 | obj.remote_field 41 | for obj in tfs 42 | if obj.is_relation and obj.related_model != model 43 | ) 44 | rlt_name = rlt_field.related_name or rlt_field.name 45 | queryset = rlt_field.model.objects.order_by(rlt_name + "__position") 46 | 47 | prefetch_relations.append(CumulativePrefetch(f_name, queryset)) 48 | else: 49 | prefetch_relations.append(f_name) 50 | prefetches, selects = calculate_prefetch_relation(rel_model, fetched_models) 51 | prefetch_relations.extend( 52 | [ 53 | f_name + "__" + relation_name 54 | for relation_name in prefetches + selects 55 | ] 56 | ) 57 | else: 58 | select_relations.append(f_name) 59 | prefetches, selects = calculate_prefetch_relation(rel_model, fetched_models) 60 | prefetch_relations.extend( 61 | [f_name + "__" + relation_name for relation_name in prefetches] 62 | ) 63 | select_relations.extend( 64 | [f_name + "__" + relation_name for relation_name in selects] 65 | ) 66 | 67 | return prefetch_relations, select_relations 68 | -------------------------------------------------------------------------------- /headless_cms/fields/slug_field.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | from django.conf import settings 4 | from django.utils.functional import keep_lazy_text 5 | from django.utils.text import slugify 6 | from localized_fields.fields import LocalizedUniqueSlugField 7 | from unidecode import unidecode 8 | 9 | 10 | @contextlib.contextmanager 11 | def normalized_slugify(): 12 | """ 13 | Context manager to temporarily override the slugify function to use a custom slugify function 14 | that normalizes the value using unidecode. 15 | """ 16 | from localized_fields.fields import uniqueslug_field 17 | 18 | @keep_lazy_text 19 | def custom_slugify(value, allow_unicode=False): 20 | return slugify(unidecode(value), allow_unicode=allow_unicode) 21 | 22 | uniqueslug_field.slugify = custom_slugify 23 | yield 24 | uniqueslug_field.slugify = slugify 25 | 26 | 27 | def _get_lazy_language_codes(): 28 | """ 29 | Generator function to yield language codes from the settings.LANGUAGES configuration. 30 | 31 | :return: A generator for language codes. 32 | """ 33 | return (lang_code for lang_code, _ in settings.LANGUAGES) 34 | 35 | 36 | class LocalizedUniqueNormalizedSlugField(LocalizedUniqueSlugField): 37 | """ 38 | A custom field that extends LocalizedUniqueSlugField to provide a localized unique slug field 39 | with normalized slugs for multi-language support. 40 | """ 41 | 42 | def pre_save(self, instance, add: bool): 43 | """ 44 | Overrides the pre_save method to use the normalized slugify function within the context manager. 45 | 46 | :param instance: The model instance being saved. 47 | :param add: A boolean indicating whether this is a new instance being added. 48 | :return: The value to be saved to the database. 49 | """ 50 | with normalized_slugify(): 51 | return super().pre_save(instance, add) 52 | 53 | def __init__(self, *args, **kwargs): 54 | """ 55 | Initializes the field with the specified arguments and sets the uniqueness attribute. 56 | 57 | :param args: Positional arguments. 58 | :param kwargs: Keyword arguments. 59 | """ 60 | kwargs["uniqueness"] = kwargs.pop("uniqueness", Uniqueness) 61 | super().__init__(*args, **kwargs) 62 | 63 | 64 | class LazyUniqueness(type): 65 | """ 66 | Metaclass for Uniqueness that allows it to be an iterable, providing language codes lazily. 67 | """ 68 | 69 | def __iter__(cls): 70 | return iter(_get_lazy_language_codes()) 71 | 72 | 73 | class Uniqueness(metaclass=LazyUniqueness): 74 | """ 75 | A proxy class that acts as a lazy iterator for current language codes. 76 | """ 77 | 78 | pass 79 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | ===================== 2 | Configuration 3 | ===================== 4 | 5 | Headless CMS Settings 6 | ===================== 7 | 8 | The `django-headless-cms` package can be customized using various settings. These settings should be added to your Django project's settings file under the `HEADLESS_CMS_SETTINGS` dictionary. 9 | 10 | Here is the structure of the configuration with detailed comments explaining each setting: 11 | 12 | .. code-block:: python 13 | 14 | # settings.py 15 | 16 | HEADLESS_CMS_SETTINGS = { 17 | # The class used for automatic translation of content. 18 | # Alternative, using ChatGPT: "headless_cms.auto_translate.openai_translate.OpenAITranslate" 19 | # You can create a translation class yourself by inheriting from BaseTranslate. 20 | "AUTO_TRANSLATE_CLASS": "headless_cms.auto_translate.BaseTranslate", 21 | 22 | # Preprocessing hooks for DRF Spectacular. 23 | "CMS_DRF_SPECTACULAR_PREPROCESSING_HOOKS": [ 24 | "headless_cms.schema.preprocessing_hooks.preprocessing_filter_spec" 25 | ], 26 | 27 | # List of terms to ignore during auto-translation. 28 | "AUTO_TRANSLATE_IGNORES": [], 29 | 30 | # List of fields to exclude from serialization. 31 | "GLOBAL_EXCLUDED_SERIALIZED_FIELDS": [], 32 | 33 | # The OpenAI model to use for chat-based translation. 34 | # The default model is gpt-4-turbo because we find it slightly better than gpt-4. 35 | # But you can choose any model you want here. 36 | "OPENAI_CHAT_MODEL": "gpt-4-turbo", 37 | 38 | # The OpenAI client to use for translation. 39 | "OPENAI_CLIENT": "openai.OpenAI", 40 | 41 | # The default permission class for the CMS. 42 | "DEFAULT_CMS_PERMISSION_CLASS": "rest_framework.permissions.AllowAny", 43 | 44 | # The host URL of the CMS. 45 | # Normally, this is applied for localhost. 46 | # For production, if you use django-storage, you don't need to configure this. 47 | "CMS_HOST": "http://localhost:8000", 48 | } 49 | 50 | Example Configuration 51 | ===================== 52 | 53 | Below is an example configuration that demonstrates how to customize the settings for `django-headless-cms`: 54 | 55 | .. code-block:: python 56 | 57 | # settings.py 58 | 59 | HEADLESS_CMS_SETTINGS = { 60 | "AUTO_TRANSLATE_CLASS": ( 61 | "headless_cms.auto_translate.openai_translate.OpenAITranslate" 62 | ), 63 | "AUTO_TRANSLATE_IGNORES": [ 64 | "Astro", 65 | "Astrowind", 66 | "Tailwind CSS", 67 | ], 68 | "OPENAI_CHAT_MODEL": "gpt-4", 69 | "DEFAULT_CMS_PERMISSION_CLASS": "rest_framework_api_key.permissions.HasAPIKey", 70 | } 71 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_posts/models.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.db import models 3 | from django.db.models import DateTimeField, F 4 | from localized_fields.fields import ( 5 | LocalizedCharField, 6 | LocalizedTextField, 7 | ) 8 | 9 | from headless_cms.contrib.astrowind.astrowind_metadata.models import AWMetadata 10 | from headless_cms.fields import LocalizedMartorField 11 | from headless_cms.models import ( 12 | LocalizedDynamicFileModel, 13 | LocalizedTitleSlugModel, 14 | M2MSortedOrderThrough, 15 | ) 16 | 17 | 18 | @reversion.register(exclude=("published_version",)) 19 | class AWPostImage(LocalizedDynamicFileModel): 20 | pass 21 | 22 | 23 | @reversion.register(exclude=("published_version",)) 24 | class AWPost(LocalizedTitleSlugModel): 25 | excerpt = LocalizedTextField(blank=True, null=True, required=False) 26 | image = models.ForeignKey( 27 | AWPostImage, 28 | blank=True, 29 | null=True, 30 | on_delete=models.SET_NULL, 31 | related_name="posts", 32 | ) 33 | draft = models.BooleanField(default=False) 34 | category = models.ForeignKey( 35 | "AWCategory", 36 | blank=True, 37 | null=True, 38 | on_delete=models.SET_NULL, 39 | related_name="posts", 40 | ) 41 | tags = models.ManyToManyField("AWPostTag", blank=True, related_name="posts") 42 | author = LocalizedCharField(blank=True, null=True, required=False) 43 | 44 | content = LocalizedMartorField(default=dict, blank=True, null=True, required=False) 45 | 46 | metadata = models.ForeignKey( 47 | AWMetadata, blank=True, null=True, on_delete=models.SET_NULL 48 | ) 49 | 50 | related_posts = models.ManyToManyField( 51 | "self", 52 | blank=True, 53 | through="AWRelatedPost", 54 | symmetrical=False, 55 | ) 56 | 57 | publish_date = DateTimeField(blank=True, null=True) 58 | created_date = DateTimeField(auto_now_add=True) 59 | 60 | class Meta: 61 | ordering = [F("publish_date").desc(nulls_first=True), "-created_date"] 62 | indexes = [ 63 | models.Index( 64 | fields=["publish_date", "created_date"], 65 | name="awpost_publish_created_idx", 66 | ) 67 | ] 68 | 69 | 70 | class AWRelatedPost(M2MSortedOrderThrough): 71 | fk_name = "source_post" 72 | 73 | source_post = models.ForeignKey( 74 | AWPost, on_delete=models.CASCADE, related_name="source_through" 75 | ) 76 | related_post = models.ForeignKey( 77 | AWPost, on_delete=models.CASCADE, related_name="related_through" 78 | ) 79 | 80 | 81 | @reversion.register(exclude=("published_version",)) 82 | class AWCategory(LocalizedTitleSlugModel): 83 | pass 84 | 85 | 86 | @reversion.register(exclude=("published_version",)) 87 | class AWPostTag(LocalizedTitleSlugModel): 88 | pass 89 | -------------------------------------------------------------------------------- /headless_cms/utils/hash_utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | class HashTracker: 5 | def __init__(self, initial_data: str | None = None, algo: str = "md5") -> None: 6 | """ 7 | Initialize the HashTracker instance with an optional initial data and a hashing algorithm. 8 | 9 | Args: 10 | initial_data (Optional[str]): Initial data to start the hashing process. 11 | If None, the hash is not initialized. 12 | algo (str): The hashing algorithm to use (e.g., 'md5', 'sha256'). Defaults to 'md5'. 13 | """ 14 | self.algo: str = algo 15 | self.hasher = hashlib.new( 16 | self.algo 17 | ) # Initialize hasher at the creation of an instance 18 | self._current_hash: bytes | None = None 19 | if initial_data is not None: 20 | self.update_hash(initial_data) 21 | 22 | def _hash_function(self, data: str) -> bytes: 23 | """Compute the hash of the given data using the initialized hashing algorithm. 24 | 25 | Args: 26 | data (str): The data to hash. 27 | 28 | Returns: 29 | bytes: The hash digest of the data. 30 | """ 31 | self.hasher.update(data.encode("utf-8")) 32 | return self.hasher.digest() 33 | 34 | def _combine_hashes(self, hash1: bytes, hash2: bytes) -> bytes: 35 | """Combines two hash bytes using XOR, intended as a private method. 36 | 37 | Args: 38 | hash1 (bytes): The first hash. 39 | hash2 (bytes): The second hash to combine with the first. 40 | 41 | Returns: 42 | bytes: The result of the XOR combination of the two hashes. 43 | """ 44 | return bytes(a ^ b for a, b in zip(hash1, hash2, strict=False)) 45 | 46 | def update_hash(self, new_data: str | type[int] | None) -> None: 47 | """ 48 | Update the current hash with new data by combining it with the existing hash. 49 | If new_data is None, the method does nothing. 50 | 51 | Args: 52 | new_data (Optional[str]): The new data to add to the current hash. If None, the hash remains unchanged. 53 | """ 54 | if new_data is None: 55 | return # Do nothing if new_data is None 56 | 57 | new_hash: bytes = self._hash_function(str(new_data)) 58 | if self._current_hash is None: 59 | self._current_hash = new_hash 60 | else: 61 | self._current_hash = self._combine_hashes(self._current_hash, new_hash) 62 | 63 | @property 64 | def current_hash(self) -> str | None: 65 | """ 66 | Get the current hash state as a hexadecimal string. 67 | 68 | Returns: 69 | Optional[str]: The current hash in hexadecimal format, or None if no hash has been computed yet. 70 | """ 71 | return self._current_hash.hex() if self._current_hash else None 72 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_metadata/models.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.db import models 3 | from django.db.models import BooleanField, IntegerField 4 | from django.utils.translation import gettext_lazy as _ 5 | from localized_fields.fields import ( 6 | LocalizedCharField, 7 | LocalizedTextField, 8 | ) 9 | 10 | from headless_cms.models import ( 11 | LocalizedPublicationModel, 12 | ) 13 | 14 | 15 | @reversion.register(exclude=("published_version",)) 16 | class AWMetadataRobot(LocalizedPublicationModel): 17 | index = BooleanField(default=False) 18 | follow = BooleanField(default=False) 19 | 20 | 21 | @reversion.register(exclude=("published_version",)) 22 | class AWMetadataImage(LocalizedPublicationModel): 23 | url = LocalizedCharField(blank=True, null=True, required=False) 24 | width = IntegerField(default=0) 25 | height = IntegerField(default=0) 26 | 27 | 28 | @reversion.register(exclude=("published_version",)) 29 | class AWMetaDataOpenGraph(LocalizedPublicationModel): 30 | url = LocalizedCharField(blank=True, null=True, required=False) 31 | site_name = LocalizedCharField(blank=True, null=True, required=False) 32 | images = models.ManyToManyField( 33 | AWMetadataImage, related_name="metadata_open_graphs" 34 | ) 35 | locale = LocalizedCharField(blank=True, null=True, required=False) 36 | type = models.CharField(default="", blank=True) 37 | 38 | 39 | @reversion.register(exclude=("published_version",)) 40 | class AWMetaDataTwitter(LocalizedPublicationModel): 41 | handle = LocalizedCharField(blank=True, null=True, required=False) 42 | site = LocalizedCharField(blank=True, null=True, required=False) 43 | card_type = LocalizedCharField(blank=True, null=True, required=False) 44 | 45 | 46 | @reversion.register(exclude=("published_version",)) 47 | class AWMetadata(LocalizedPublicationModel): 48 | title = LocalizedTextField(blank=True, null=True, required=False) 49 | title_template = LocalizedTextField( 50 | default=dict, 51 | blank=True, 52 | null=True, 53 | required=False, 54 | help_text=_( 55 | "Title template (default should be %s - {title}), used for default site metadata." 56 | ), 57 | ) 58 | description = LocalizedTextField(blank=True, null=True, required=False) 59 | canonical = LocalizedCharField(blank=True, null=True, required=False) 60 | ignore_title_template = BooleanField(default=False) 61 | robots = models.ForeignKey( 62 | AWMetadataRobot, 63 | blank=True, 64 | null=True, 65 | related_name="metadata", 66 | on_delete=models.SET_NULL, 67 | ) 68 | open_graph = models.ForeignKey( 69 | AWMetaDataOpenGraph, 70 | blank=True, 71 | null=True, 72 | related_name="metadata", 73 | on_delete=models.SET_NULL, 74 | ) 75 | twitter = models.ForeignKey( 76 | AWMetaDataTwitter, 77 | blank=True, 78 | null=True, 79 | related_name="metadata", 80 | on_delete=models.SET_NULL, 81 | ) 82 | -------------------------------------------------------------------------------- /headless_cms/enhances/static/localized_fields/localized-fields-admin.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | var syncTabs = function(lang) { 3 | $('.localized-fields-widget.tab label:contains("'+lang+'")').each(function(){ 4 | $(this).parents('.localized-fields-widget[role="tabs"]').find('.localized-fields-widget.tab').removeClass('active'); 5 | $(this).parents('.localized-fields-widget.tab').addClass('active'); 6 | $(this).parents('.localized-fields-widget[role="tabs"]').children('.localized-fields-widget>[role="tabpanel"]').hide(); 7 | $('#'+$(this).attr('for')).show(); 8 | 9 | function handle(i, obj){ 10 | var mainMartor = $(obj); 11 | var editorId = 'martor-' + mainMartor.data('field-name'); 12 | var editor = ace.edit(editorId); 13 | var currentTextVal = $(handle.focus_text_area_id).find('textarea').val() 14 | 15 | editor.setValue(currentTextVal) 16 | } 17 | handle.focus_text_area_id = '#'+$(this).attr('for') 18 | 19 | $(this).parents('.localized-fields-widget[role="tabs"]').find('.main-martor').each(handle) 20 | }); 21 | } 22 | 23 | $(window).on("load", function () { 24 | $('.localized-fields-widget[role="tabs"]').each(function () { 25 | function handle_martor_edit(i, obj){ 26 | var mainMartor = $(obj); 27 | var field_name = mainMartor.data('field-name'); 28 | var editorId = 'martor-' + field_name; 29 | var editor = ace.edit(editorId); 30 | editor.on('change', function (evt){ 31 | if (editor.curOp && editor.curOp.command.name){ 32 | var value = editor.getValue(); 33 | var curText = mainMartor.parents( 34 | '.localized-fields-widget[role="tabs"]' 35 | ).find('.localized-panel').not('[style*="display: none"]').find('textarea') 36 | curText.val(value) 37 | } 38 | }) 39 | } 40 | 41 | $(this).find('.main-martor').each(handle_martor_edit) 42 | }); 43 | 44 | 45 | $('.localized-fields-widget.tab label').click(function(event) { 46 | event.preventDefault(); 47 | syncTabs(this.innerText); 48 | if (window.sessionStorage) { 49 | window.sessionStorage.setItem('localized-field-lang', this.innerText); 50 | } 51 | return false; 52 | }); 53 | 54 | if (window.sessionStorage) { 55 | var lang = window.sessionStorage.getItem('localized-field-lang'); 56 | 57 | if (lang) { 58 | syncTabs(lang); 59 | return 60 | } 61 | } 62 | $('.localized-fields-widget>[role="tabpanel"]').hide(); 63 | 64 | $('.localized-fields-widget[role="tabs"]').each(function () { 65 | var label = $(this).find('.localized-fields-widget.tab:first label').text(); 66 | syncTabs(label) 67 | }); 68 | 69 | }); 70 | 71 | })(django.jQuery) 72 | -------------------------------------------------------------------------------- /headless_cms/core/management/commands/clean_outdated_drafts.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.db import models, router, transaction 4 | from django.utils import timezone 5 | from reversion.management.commands import BaseRevisionCommand 6 | from reversion.models import Revision 7 | 8 | from headless_cms.models import LocalizedPublicationModel 9 | 10 | 11 | class Command(BaseRevisionCommand): 12 | """ 13 | Deletes outdated drafts. 14 | 15 | Usage: 16 | python manage.py clean_outdated_drafts [app_label ...] [--using DATABASE] [--model-db DATABASE] [--days DAYS] 17 | 18 | Options: 19 | app_label: Optional app_label or app_label.model_name list. 20 | --using: The database to query for revision data. 21 | --model-db: The database to query for model data. 22 | --days: Delete only revisions older than the specified number of days. 23 | """ 24 | 25 | help = "Deletes outdated drafts." 26 | 27 | def add_arguments(self, parser): 28 | super().add_arguments(parser) 29 | parser.add_argument( 30 | "--days", 31 | default=0, 32 | type=int, 33 | help="Delete only revisions older than the specified number of days.", 34 | ) 35 | 36 | def handle(self, *app_labels, **options): 37 | verbosity = options["verbosity"] 38 | using = options["using"] 39 | days = options["days"] 40 | using = using or router.db_for_write(Revision) 41 | with transaction.atomic(using=using): 42 | revision_query = models.Q() 43 | remove_revision_ids = set() 44 | can_delete = False 45 | for model in self.get_models(options): 46 | if not issubclass(model, LocalizedPublicationModel): 47 | continue 48 | if verbosity >= 1: 49 | self.stdout.write( 50 | f"Finding outdated draft revisions for {model._meta.verbose_name}" 51 | ) 52 | 53 | published_objs = ( 54 | model.objects.using(using) 55 | .prefetch_related("versions") 56 | .filter(published_version_id__isnull=False) 57 | ) 58 | for obj in published_objs: 59 | remove_revision_ids.update( 60 | obj.versions.filter(id__lt=obj.published_version_id) 61 | .values_list("revision_id", flat=True) 62 | .iterator() 63 | ) 64 | 65 | revision_query |= models.Q(pk__in=remove_revision_ids) 66 | can_delete = True 67 | if can_delete: 68 | revisions_to_delete = ( 69 | Revision.objects.using(using) 70 | .filter( 71 | revision_query, 72 | date_created__lt=timezone.now() - timedelta(days=days), 73 | ) 74 | .order_by() 75 | ) 76 | else: 77 | revisions_to_delete = Revision.objects.using(using).none() 78 | if verbosity >= 1: 79 | self.stdout.write( 80 | f"Deleting {revisions_to_delete.count()} revisions..." 81 | ) 82 | revisions_to_delete.delete() 83 | -------------------------------------------------------------------------------- /tests/test_app/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, call 2 | 3 | import reversion 4 | from django.utils import translation 5 | from reversion.models import Version 6 | 7 | from helpers.base import BaseTestCase 8 | from test_app.factories import ( 9 | ArticleFactory, 10 | BlogFactory, 11 | NoteFactory, 12 | PostFactory, 13 | ) 14 | from test_app.models import Article, Blog, Post 15 | 16 | 17 | class TestPublicationModel(BaseTestCase): 18 | def test_not_published_model(self): 19 | with reversion.create_revision(): 20 | obj = PostFactory.create() 21 | assert Version.objects.get_for_model(obj.__class__).count() == 1 22 | 23 | assert not obj.published_version 24 | assert not obj.published_data 25 | 26 | assert obj.published_state() == Post.AdminPublishedStateHtml.UNPUBLISHED 27 | 28 | def test_latest_published_model(self): 29 | translation.activate("en") 30 | 31 | with reversion.create_revision(): 32 | obj: Post = PostFactory.create(title="Init title") 33 | 34 | obj.publish() 35 | 36 | assert obj.published_version 37 | assert obj.published_data 38 | assert obj.published_data["title"] == "Init title" 39 | assert obj.published_state() == Post.AdminPublishedStateHtml.PUBLISHED_LATEST 40 | translation.deactivate() 41 | 42 | def test_outdated_published_model(self): 43 | translation.activate("en") 44 | 45 | with reversion.create_revision(): 46 | obj: Post = PostFactory.create(title="Init title") 47 | 48 | obj.publish(self.user) 49 | 50 | with reversion.create_revision(): 51 | obj.title = "Outdated title" 52 | obj.save() 53 | 54 | assert obj.published_version 55 | assert obj.published_data 56 | assert obj.published_data["title"] == "Init title" 57 | assert obj.published_state() == Post.AdminPublishedStateHtml.PUBLISHED_OUTDATED 58 | translation.deactivate() 59 | 60 | def test_published_queryset(self): 61 | with reversion.create_revision(): 62 | PostFactory.create() 63 | with reversion.create_revision(): 64 | obj2 = PostFactory.create() 65 | obj2.publish() 66 | 67 | assert Post.objects.all().count() == 2 68 | assert Post.published_objects.published().count() == 1 69 | assert Post.published_objects.published().first() == obj2 70 | 71 | def test_recursive_action(self): 72 | with reversion.create_revision(): 73 | self.note = NoteFactory.create() 74 | 75 | with reversion.create_revision(): 76 | self.post: Post = PostFactory.create(note=self.note) 77 | 78 | with reversion.create_revision(): 79 | self.article: Article = ArticleFactory.create(note=self.note) 80 | 81 | with reversion.create_revision(): 82 | self.blog: Blog = BlogFactory.create() 83 | 84 | self.blog.articles.add(self.article) 85 | self.blog.posts.add(self.post) 86 | 87 | action = Mock() 88 | 89 | self.blog.recursive_action(action) 90 | 91 | action.assert_has_calls( 92 | [ 93 | call(self.blog), 94 | call(self.post), 95 | call(self.note), 96 | call(self.article), 97 | ], 98 | any_order=True, 99 | ) # note only call once because we have deduplicated 100 | -------------------------------------------------------------------------------- /tests/test_app/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.conf import settings 3 | from django.contrib.contenttypes.models import ContentType 4 | from factory.django import DjangoModelFactory 5 | from faker import Faker 6 | 7 | from test_app.models import ( 8 | Article, 9 | ArticleImage, 10 | Blog, 11 | Category, 12 | Domain, 13 | Item, 14 | Note, 15 | Post, 16 | PostTag, 17 | ) 18 | 19 | f = Faker() 20 | 21 | 22 | class NoteFactory(DjangoModelFactory): 23 | class Meta: 24 | model = Note 25 | 26 | text = factory.Faker("paragraph", nb_sentences=3) 27 | 28 | 29 | class CategoryFactory(DjangoModelFactory): 30 | class Meta: 31 | model = Category 32 | 33 | title = factory.Faker("sentence", nb_words=3) 34 | 35 | 36 | class PostTagFactory(DjangoModelFactory): 37 | class Meta: 38 | model = PostTag 39 | 40 | title = factory.Faker("sentence", nb_words=3) 41 | 42 | 43 | class PostFactory(DjangoModelFactory): 44 | class Meta: 45 | model = Post 46 | 47 | title = factory.Faker("sentence", nb_words=3) 48 | subtitle = factory.Faker("sentence", nb_words=3) 49 | description = factory.Faker("paragraph", nb_sentences=3) 50 | body = factory.Faker("paragraph", nb_sentences=5) 51 | 52 | href = factory.LazyAttribute(lambda o: f"/{f.slug()}") 53 | 54 | 55 | class ArticleFactory(DjangoModelFactory): 56 | class Meta: 57 | model = Article 58 | 59 | title = factory.Faker("sentence", nb_words=3) 60 | subtitle = factory.Faker("sentence", nb_words=5) 61 | story = factory.Faker("paragraph", nb_sentences=5) 62 | 63 | 64 | class ArticleImageFactory(DjangoModelFactory): 65 | class Meta: 66 | model = ArticleImage 67 | 68 | src_file = { 69 | settings.LANGUAGE_CODE: factory.django.ImageField(color="blue").evaluate( 70 | None, None, {} 71 | ) 72 | } 73 | src_url = factory.Faker("image_url") 74 | alt = factory.Faker("sentence", nb_words=3) 75 | 76 | 77 | class ItemFactory(DjangoModelFactory): 78 | class Meta: 79 | model = Item 80 | 81 | title = factory.Faker("sentence", nb_words=3) 82 | description = factory.Faker("paragraph", nb_sentences=3) 83 | icon = factory.Faker("word") 84 | 85 | 86 | class GenericItemFactory(ItemFactory): 87 | title = factory.Faker("sentence", nb_words=3) 88 | description = factory.Faker("paragraph", nb_sentences=3) 89 | icon = factory.Faker("word") 90 | 91 | object_id = factory.SelfAttribute("content_object.id") 92 | content_type = factory.LazyAttribute( 93 | lambda o: ContentType.objects.get_for_model(o.content_object) 94 | ) 95 | 96 | class Meta: 97 | exclude = ["content_object"] 98 | abstract = True 99 | 100 | 101 | class PostItemFactory(GenericItemFactory): 102 | content_object = factory.SubFactory(PostFactory) 103 | 104 | class Meta: 105 | model = Item 106 | 107 | 108 | class ArticleItemFactory(GenericItemFactory): 109 | content_object = factory.SubFactory(ArticleFactory) 110 | 111 | class Meta: 112 | model = Item 113 | 114 | 115 | class DomainFactory(DjangoModelFactory): 116 | class Meta: 117 | model = Domain 118 | 119 | title = factory.Faker("sentence", nb_words=3) 120 | slug = factory.Faker("slug") 121 | 122 | 123 | class BlogFactory(DjangoModelFactory): 124 | class Meta: 125 | model = Blog 126 | 127 | name = factory.Faker("sentence", nb_words=3) 128 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Post.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "1", 5 | "title": { 6 | "en": "Understanding Django ORM", 7 | "ro": "\u00cen\u021belegerea ORM-ului Django", 8 | "vi": "Hi\u1ec3u v\u1ec1 Django ORM" 9 | }, 10 | "subtitle": { 11 | "en": "", 12 | "ro": "", 13 | "vi": "" 14 | }, 15 | "description": { 16 | "en": "This post explains how Django ORM works.", 17 | "ro": "Aceast\u0103 postare explic\u0103 cum func\u021bioneaz\u0103 ORM-ul Django.", 18 | "vi": "B\u00e0i \u0111\u0103ng n\u00e0y gi\u1ea3i th\u00edch c\u00e1ch ho\u1ea1t \u0111\u1ed9ng c\u1ee7a Django ORM." 19 | }, 20 | "body": { 21 | "en": "Django ORM is a powerful tool for interacting with databases.", 22 | "ro": "ORM-ul Django este un instrument puternic pentru interac\u021biunea cu bazele de date.", 23 | "vi": "Django ORM l\u00e0 m\u1ed9t c\u00f4ng c\u1ee5 m\u1ea1nh m\u1ebd \u0111\u1ec3 t\u01b0\u01a1ng t\u00e1c v\u1edbi c\u01a1 s\u1edf d\u1eef li\u1ec7u." 24 | }, 25 | "href": "{'en': 'https://example.com/en/understanding-django-orm', 'ro': 'https://example.com/ro/in\u021belegerea-django-orm', 'vi': 'https://example.com/vi/hi\u1ec3u-v\u1ec1-django-orm'}", 26 | "note": 1, 27 | "category": 1 28 | }, 29 | { 30 | "id": "2", 31 | "skip_translation": "0", 32 | "title": { 33 | "en": "Health Tips for 2023", 34 | "ro": "Sfaturi pentru s\u0103n\u0103tate pentru 2023", 35 | "vi": "M\u1eb9o s\u1ee9c kh\u1ecfe cho n\u0103m 2023" 36 | }, 37 | "subtitle": { 38 | "en": "", 39 | "ro": "", 40 | "vi": "" 41 | }, 42 | "description": { 43 | "en": "This post provides health tips for the new year.", 44 | "ro": "Aceast\u0103 postare ofer\u0103 sfaturi pentru s\u0103n\u0103tate pentru noul an.", 45 | "vi": "B\u00e0i \u0111\u0103ng n\u00e0y cung c\u1ea5p c\u00e1c m\u1eb9o s\u1ee9c kh\u1ecfe cho n\u0103m m\u1edbi." 46 | }, 47 | "body": { 48 | "en": "Following these tips will help you stay healthy.", 49 | "ro": "Urm\u00e2nd aceste sfaturi te va ajuta s\u0103 r\u0103m\u00e2i s\u0103n\u0103tos.", 50 | "vi": "L\u00e0m theo nh\u1eefng m\u1eb9o n\u00e0y s\u1ebd gi\u00fap b\u1ea1n duy tr\u00ec s\u1ee9c kh\u1ecfe." 51 | }, 52 | "href": "{'en': 'https://example.com/en/health-tips-2023', 'ro': 'https://example.com/ro/sfaturi-pentru-s\u0103n\u0103tate-pentru-2023', 'vi': 'https://example.com/vi/m\u1eb9o-s\u1ee9c-kh\u1ecfe-cho-n\u0103m-2023'}", 53 | "note": 2, 54 | "category": 2 55 | }, 56 | { 57 | "id": "3", 58 | "skip_translation": "0", 59 | "title": { 60 | "en": "Modern Lifestyle Hacks", 61 | "ro": "Trucuri pentru un stil de via\u021b\u0103 modern", 62 | "vi": "M\u1eb9o s\u1ed1ng hi\u1ec7n \u0111\u1ea1i" 63 | }, 64 | "subtitle": { 65 | "en": "", 66 | "ro": "", 67 | "vi": "" 68 | }, 69 | "description": { 70 | "en": "This post shares lifestyle hacks for a modern life.", 71 | "ro": "Aceast\u0103 postare \u00eemp\u0103rt\u0103\u0219e\u0219te trucuri pentru un stil de via\u021b\u0103 modern.", 72 | "vi": "B\u00e0i \u0111\u0103ng n\u00e0y chia s\u1ebb nh\u1eefng m\u1eb9o s\u1ed1ng cho cu\u1ed9c s\u1ed1ng hi\u1ec7n \u0111\u1ea1i." 73 | }, 74 | "body": { 75 | "en": "Use these hacks to improve your daily routine.", 76 | "ro": "Folose\u0219te aceste trucuri pentru a-\u021bi \u00eembun\u0103t\u0103\u021bi rutina zilnic\u0103.", 77 | "vi": "S\u1eed d\u1ee5ng nh\u1eefng m\u1eb9o n\u00e0y \u0111\u1ec3 c\u1ea3i thi\u1ec7n th\u00f3i quen h\u00e0ng ng\u00e0y c\u1ee7a b\u1ea1n." 78 | }, 79 | "href": "{'en': 'https://example.com/en/modern-lifestyle-hacks', 'ro': 'https://example.com/ro/trucuri-pentru-un-stil-de-via\u021b\u0103-modern', 'vi': 'https://example.com/vi/m\u1eb9o-s\u1ed1ng-hi\u1ec7n-\u0111\u1ea1i'}", 80 | "note": 1, 81 | "category": 3 82 | } 83 | ] 84 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | import reversion 2 | from django.contrib.contenttypes.fields import GenericRelation 3 | from django.db import models 4 | from headless_cms.fields import AutoLanguageUrlField, LocalizedMartorField 5 | from headless_cms.models import ( 6 | LocalizedDynamicFileModel, 7 | LocalizedPublicationModel, 8 | LocalizedTitleSlugModel, 9 | M2MSortedOrderThrough, 10 | SortableGenericBaseModel, 11 | ) 12 | from localized_fields.fields import ( 13 | LocalizedTextField, 14 | ) 15 | from localized_fields.fields.char_field import LocalizedCharField 16 | 17 | 18 | @reversion.register(exclude=("published_version",)) 19 | class Item(SortableGenericBaseModel): 20 | title = LocalizedTextField(blank=True, null=True, required=False) 21 | description = LocalizedTextField(blank=True, null=True, required=False) 22 | icon = models.CharField(blank=True, default="") 23 | 24 | 25 | @reversion.register(exclude=("published_version",)) 26 | class Note(LocalizedPublicationModel): 27 | text = LocalizedTextField(blank=True, null=True, required=False) 28 | 29 | 30 | class News(LocalizedPublicationModel): 31 | title = LocalizedTextField(blank=True, null=True, required=False) 32 | subtitle = LocalizedTextField(blank=True, null=True, required=False) 33 | items = GenericRelation(Item, blank=True) 34 | 35 | class Meta: 36 | abstract = True 37 | 38 | 39 | @reversion.register(exclude=("published_version",)) 40 | class Post(News): 41 | description = LocalizedTextField(blank=True, null=True, required=False) 42 | body = LocalizedMartorField(blank=False, null=False, required=False) 43 | 44 | href = AutoLanguageUrlField(blank=True, null=True) 45 | 46 | note = models.ForeignKey( 47 | "Note", 48 | blank=True, 49 | null=True, 50 | on_delete=models.SET_NULL, 51 | related_name="note_posts", 52 | ) 53 | 54 | tags = models.ManyToManyField("PostTag", blank=True, related_name="posts") 55 | category = models.ForeignKey( 56 | "Category", 57 | blank=True, 58 | null=True, 59 | on_delete=models.SET_NULL, 60 | related_name="posts", 61 | ) 62 | 63 | 64 | @reversion.register(exclude=("published_version",)) 65 | class Category(LocalizedTitleSlugModel): 66 | pass 67 | 68 | 69 | @reversion.register(exclude=("published_version",)) 70 | class PostTag(LocalizedTitleSlugModel): 71 | pass 72 | 73 | 74 | @reversion.register(exclude=("published_version",)) 75 | class Article(News): 76 | story = LocalizedMartorField(blank=False, null=False, required=False) 77 | images = models.ManyToManyField("ArticleImage", through="ArticleImageThrough") 78 | 79 | note = models.ForeignKey( 80 | "Note", 81 | on_delete=models.SET_NULL, 82 | related_name="note_articles", 83 | blank=True, 84 | null=True, 85 | ) 86 | 87 | 88 | @reversion.register(exclude=("published_version",)) 89 | class ArticleImage(LocalizedDynamicFileModel): 90 | pass 91 | 92 | 93 | class ArticleImageThrough(M2MSortedOrderThrough): 94 | article = models.ForeignKey("Article", on_delete=models.CASCADE) 95 | article_image = models.ForeignKey("ArticleImage", on_delete=models.CASCADE) 96 | 97 | 98 | @reversion.register(exclude=("published_version",)) 99 | class Blog(LocalizedTitleSlugModel): 100 | name = LocalizedCharField() 101 | posts = models.ManyToManyField( 102 | Post, 103 | blank=True, 104 | ) 105 | articles = models.ManyToManyField( 106 | Article, 107 | blank=True, 108 | ) 109 | domain = models.ForeignKey( 110 | "Domain", 111 | blank=True, 112 | null=True, 113 | on_delete=models.SET_NULL, 114 | related_name="blog", 115 | ) 116 | 117 | 118 | @reversion.register(exclude=("published_version",)) 119 | class Domain(LocalizedTitleSlugModel): 120 | pass 121 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.3.0 (2025-11-19) 2 | 3 | ### Fix 4 | 5 | - update type syntax to satisfy linter 6 | 7 | ## v1.2.0 (2025-04-08) 8 | 9 | ### Feat 10 | 11 | - **uv**: migrate package management from poetry to uv 12 | 13 | ## v1.1.7 (2025-04-03) 14 | 15 | ### Fix 16 | 17 | - fix readthedocs install 18 | 19 | ## v1.1.6 (2025-04-03) 20 | 21 | ### Fix 22 | 23 | - **virtualenv-readthedocs**: fix virtualenv version for readthedocs install 24 | 25 | ## v1.1.5 (2025-04-03) 26 | 27 | ### Fix 28 | 29 | - **readthedocs**: fix readthedocs poetry 30 | 31 | ## v1.1.4 (2025-04-03) 32 | 33 | ### Fix 34 | 35 | - **readthedocs**: fix install poetry 36 | 37 | ## v1.1.3 (2025-04-03) 38 | 39 | ### Fix 40 | 41 | - **readthedocs**: increase poetry version for readthedocs build 42 | 43 | ## v1.1.2 (2025-04-03) 44 | 45 | ### Fix 46 | 47 | - **.github/workflows**: update github workflow 48 | 49 | ## v1.1.1 (2025-04-03) 50 | 51 | ### Fix 52 | 53 | - **poetry**: upgrade poetry to the official version 2 54 | 55 | ## v1.1.0 (2024-06-21) 56 | 57 | ### Feat 58 | 59 | - add localized url field to prevent translation on url 60 | 61 | ## v1.0.0 (2024-06-20) 62 | 63 | ### Feat 64 | 65 | - Initial stable release version 1.0.0 66 | 67 | ## v0.8.2 (2024-06-19) 68 | 69 | ### Fix 70 | 71 | - do some small fixes and update docs 72 | 73 | ## v0.8.1 (2024-06-18) 74 | 75 | ### Fix 76 | 77 | - update test and handle incorrect change_view behavior 78 | 79 | ## v0.8.0 (2024-06-17) 80 | 81 | ### Feat 82 | 83 | - add skip translation field and improve recursive action time using asyncio 84 | 85 | ## v0.7.4 (2024-06-15) 86 | 87 | ### Fix 88 | 89 | - fix languages tab change and add type annotation for get hash 90 | 91 | ## v0.7.3 (2024-06-15) 92 | 93 | ### Fix 94 | 95 | - correct test 96 | 97 | ## v0.7.2 (2024-06-15) 98 | 99 | ### Fix 100 | 101 | - update readonly fields 102 | 103 | ## v0.7.1 (2024-06-15) 104 | 105 | ### Fix 106 | 107 | - improve hash and create auto generate id query param when inhance hash model mixin 108 | 109 | ## v0.7.0 (2024-06-13) 110 | 111 | ### Feat 112 | 113 | - add hash field to track change of the object 114 | 115 | ## v0.6.5 (2024-06-12) 116 | 117 | ### Fix 118 | 119 | - fix localized tab click 120 | 121 | ## v0.6.4 (2024-06-12) 122 | 123 | ### Fix 124 | 125 | - fix localized toggle lang and admin recursively publish msg 126 | 127 | ## v0.6.3 (2024-06-10) 128 | 129 | ### Fix 130 | 131 | - improve long object translation 132 | 133 | ## v0.6.2 (2024-06-09) 134 | 135 | ### Fix 136 | 137 | - add docs for missing components 138 | 139 | ## v0.6.1 (2024-06-08) 140 | 141 | ### Fix 142 | 143 | - update recursive_action param pos 144 | 145 | ## v0.6.0 (2024-06-07) 146 | 147 | ### Feat 148 | 149 | - deduplicated for recursive translation 150 | 151 | ## v0.5.2 (2024-06-07) 152 | 153 | ### Fix 154 | 155 | - Add language name to openai translation prompt 156 | 157 | ## v0.5.1 (2024-06-05) 158 | 159 | ### Fix 160 | 161 | - clear all language field data when primary lang is clear 162 | 163 | ## v0.5.0 (2024-06-05) 164 | 165 | ### Feat 166 | 167 | - update localized unique normalized slug field to use lazy generator 168 | 169 | ## v0.4.0 (2024-06-04) 170 | 171 | ### Feat 172 | 173 | - add python 12 174 | 175 | ## v0.3.2 (2024-06-04) 176 | 177 | ### Fix 178 | 179 | - update readme 180 | 181 | ## v0.3.1 (2024-06-04) 182 | 183 | ### Fix 184 | 185 | - Update docs 186 | 187 | ## v0.3.0 (2024-06-03) 188 | 189 | ### Feat 190 | 191 | - add project documentation and do some minor refactor 192 | 193 | ## v0.2.2 (2024-05-31) 194 | 195 | ### Fix 196 | 197 | - add docs for main components and refactor some minor things 198 | 199 | ## v0.2.1 (2024-05-28) 200 | 201 | ### Fix 202 | 203 | - trigger a new build to update pypi package 204 | 205 | ## v0.2.0 (2024-05-28) 206 | 207 | ### Feat 208 | 209 | - populate astrowind data 210 | 211 | ## v0.1.1 (2024-05-28) 212 | 213 | ### Refactor 214 | 215 | - update pyproject toml for commitizen bump pattern 216 | 217 | ## v0.1.0 (2024-05-27) 218 | 219 | ### Feat 220 | 221 | - setup cz commit 222 | 223 | ## v0.0.4 (2024-05-27) 224 | 225 | ## v0.0.3 (2024-03-31) 226 | 227 | ## v0.0.2 (2024-03-31) 228 | 229 | ## v0.0.1 (2024-03-31) 230 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling"] 4 | 5 | [dependency-groups] 6 | dev = [ 7 | "commitizen>=3.27.0,<4", 8 | "setuptools>=78.1.0" 9 | ] 10 | docs = [ 11 | "Sphinx>=7.0.0,<8", 12 | "sphinx_rtd_theme" 13 | ] 14 | lint = [ 15 | "black", 16 | "pre-commit>=3.4.0,<4.0.0", 17 | "ruff", 18 | "toml-sort" 19 | ] 20 | test = [ 21 | "coverage[toml]", 22 | "django-environ", 23 | "django-extensions", 24 | "factory-boy", 25 | "freezegun", 26 | "openai>=1", 27 | "pytest", 28 | "pytest-cov", 29 | "pytest-django", 30 | "pytest-mock" 31 | ] 32 | 33 | [project] 34 | authors = [{email = "danghuy1999@gmail.com", name = "Huy Nguyen"}] 35 | classifiers = [ 36 | "Environment :: Web Environment", 37 | "Framework :: Django :: 4.0", 38 | "Framework :: Django :: 4.1", 39 | "Framework :: Django :: 4.2", 40 | "Framework :: Django :: 5.0", 41 | "Framework :: Django :: 5.1", 42 | "Framework :: Django :: 5.2", 43 | "Framework :: Django", 44 | "Intended Audience :: Developers", 45 | "Intended Audience :: Information Technology", 46 | "License :: OSI Approved :: BSD License", 47 | "Operating System :: OS Independent", 48 | "Programming Language :: Python :: 3 :: Only", 49 | "Programming Language :: Python :: 3", 50 | "Programming Language :: Python :: 3.10", 51 | "Programming Language :: Python :: 3.11", 52 | "Programming Language :: Python :: 3.12", 53 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 54 | "Topic :: Internet :: WWW/HTTP", 55 | "Topic :: Software Development :: Libraries :: Application Frameworks", 56 | "Topic :: Software Development :: Libraries", 57 | "Topic :: Software Development" 58 | ] 59 | dependencies = [ 60 | "Django>=4,<6", 61 | "Unidecode", 62 | "django-admin-interface", 63 | "django-admin-sortable2>=2.2.8", 64 | "django-filter", 65 | "django-import-export", 66 | "django-localized-fields", 67 | "django-reversion", 68 | "django-solo", 69 | "djangorestframework>=3", 70 | "drf_spectacular", 71 | "martor", 72 | "psycopg" 73 | ] 74 | description = "A simple django-based headless CMS." 75 | license = {file = "LICENSE"} 76 | name = "django-headless-cms" 77 | readme = "README.md" 78 | requires-python = ">=3.10,<4.0" 79 | version = "1.3.0" 80 | 81 | [project.optional-dependencies] 82 | openai = ["openai>=1"] 83 | 84 | [project.urls] 85 | Documentation = "https://django-headless-cms.readthedocs.io/" 86 | Homepage = "https://github.com/huynguyengl99/django-headless-cms" 87 | Repository = "https://github.com/huynguyengl99/django-headless-cms" 88 | 89 | [tool.black] 90 | exclude = ''' 91 | /( 92 | \.git 93 | | \.pytest_cache 94 | | \.vscode 95 | | __pycache__ 96 | | .venv 97 | | build 98 | | coverage 99 | )/ 100 | ''' 101 | line-length = 88 102 | preview = true 103 | 104 | [tool.commitizen] 105 | name = "cz_conventional_commits" 106 | tag_format = "v$version" 107 | update_changelog_on_bump = true 108 | version_provider = "pep621" 109 | version_scheme = "pep440" 110 | 111 | [tool.commitizen.customize] 112 | bump_map = {build = "PATCH", ci = "PATCH", docs = "PATCH", feat = "MINOR", fix = "PATCH", perf = "PATCH", refactor = "PATCH"} 113 | bump_pattern = '^(feat|fix|ci|build|perf|refactor|docs)' 114 | schema_pattern = '^(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)(\(\S+\))?\:?\s.*' 115 | 116 | [tool.coverage.run] 117 | omit = [ 118 | "headless_cms/contrib/*", 119 | "headless_cms/fields/boolean_field.py", # Just a small fix 120 | "headless_cms/utils/martor_custom_upload.py", 121 | "tests/*" 122 | ] 123 | 124 | [tool.hatch.build.targets.sdist] 125 | include = ["headless_cms"] 126 | 127 | [tool.hatch.build.targets.wheel] 128 | include = ["headless_cms"] 129 | 130 | [tool.ruff] 131 | src = ["headless_cms", 'tests'] 132 | 133 | [tool.ruff.lint] 134 | ignore = [ 135 | "E501" # line too long, handled by black 136 | ] 137 | select = [ 138 | "B", # flake8-bugbear 139 | "C", # flake8-comprehensions 140 | "E", # pycodestyle errors 141 | "F", # pyflakes 142 | "I", # isort 143 | "N", # pep8-naming 144 | "PL", # pylint 145 | "Q", # flake8-quotes 146 | "UP", # pyupgrade 147 | "W" # pycodestyle warnings 148 | ] 149 | 150 | [tool.ruff.lint.per-file-ignores] 151 | "*" = ['C901'] 152 | "tests/*" = ["PLR0913", "PLR2004"] 153 | "tests/test_utils/base.py" = ["N802"] 154 | 155 | [tool.tomlsort] 156 | all = true 157 | in_place = true 158 | spaces_before_inline_comment = 2 159 | 160 | [tool.uv] 161 | default-groups = [ 162 | "dev", 163 | "docs", 164 | "lint", 165 | "test" 166 | ] 167 | -------------------------------------------------------------------------------- /tests/test_commands/data/expected_dir/test_app/Item.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "skip_translation": "0", 5 | "content_type": "", 6 | "object_id": "", 7 | "position": "0", 8 | "title": { 9 | "en": "Item 1", 10 | "ro": "Articol 1", 11 | "vi": "M\u1ee5c 1" 12 | }, 13 | "description": { 14 | "en": "Description for item 1", 15 | "ro": "Descriere pentru articolul 1", 16 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 1" 17 | }, 18 | "icon": "icon-header-item1" 19 | }, 20 | { 21 | "id": "2", 22 | "skip_translation": "0", 23 | "content_type": "", 24 | "object_id": "", 25 | "position": "0", 26 | "title": { 27 | "en": "Item 2", 28 | "ro": "Articol 2", 29 | "vi": "M\u1ee5c 2" 30 | }, 31 | "description": { 32 | "en": "Description for item 2", 33 | "ro": "Descriere pentru articolul 2", 34 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 2" 35 | }, 36 | "icon": "icon-header-item2" 37 | }, 38 | { 39 | "id": "3", 40 | "skip_translation": "0", 41 | "content_type": "", 42 | "object_id": "", 43 | "position": "0", 44 | "title": { 45 | "en": "Item 3", 46 | "ro": "Articol 3", 47 | "vi": "M\u1ee5c 3" 48 | }, 49 | "description": { 50 | "en": "Description for item 3", 51 | "ro": "Descriere pentru articolul 3", 52 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 3" 53 | }, 54 | "icon": "icon-header-item3" 55 | }, 56 | { 57 | "id": "4", 58 | "skip_translation": "0", 59 | "content_type": "[\"test_app\", \"post\"]", 60 | "object_id": "1", 61 | "position": "0", 62 | "title": { 63 | "en": "Item 1", 64 | "ro": "Articol 1", 65 | "vi": "M\u1ee5c 1" 66 | }, 67 | "description": { 68 | "en": "Description for item 1", 69 | "ro": "Descriere pentru articolul 1", 70 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 1" 71 | }, 72 | "icon": "icon-header-item1" 73 | }, 74 | { 75 | "id": "5", 76 | "skip_translation": "0", 77 | "content_type": "[\"test_app\", \"post\"]", 78 | "object_id": "2", 79 | "position": "0", 80 | "title": { 81 | "en": "Item 2", 82 | "ro": "Articol 2", 83 | "vi": "M\u1ee5c 2" 84 | }, 85 | "description": { 86 | "en": "Description for item 2", 87 | "ro": "Descriere pentru articolul 2", 88 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 2" 89 | }, 90 | "icon": "icon-header-item2" 91 | }, 92 | { 93 | "id": "6", 94 | "skip_translation": "0", 95 | "content_type": "[\"test_app\", \"post\"]", 96 | "object_id": "3", 97 | "position": "0", 98 | "title": { 99 | "en": "Item 3", 100 | "ro": "Articol 3", 101 | "vi": "M\u1ee5c 3" 102 | }, 103 | "description": { 104 | "en": "Description for item 3", 105 | "ro": "Descriere pentru articolul 3", 106 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 3" 107 | }, 108 | "icon": "icon-header-item3" 109 | }, 110 | { 111 | "id": "7", 112 | "skip_translation": "0", 113 | "content_type": "[\"test_app\", \"article\"]", 114 | "object_id": "1", 115 | "position": "0", 116 | "title": { 117 | "en": "Item 1", 118 | "ro": "Articol 1", 119 | "vi": "M\u1ee5c 1" 120 | }, 121 | "description": { 122 | "en": "Description for item 1", 123 | "ro": "Descriere pentru articolul 1", 124 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 1" 125 | }, 126 | "icon": "icon-header-item1" 127 | }, 128 | { 129 | "id": "8", 130 | "skip_translation": "0", 131 | "content_type": "[\"test_app\", \"article\"]", 132 | "object_id": "2", 133 | "position": "0", 134 | "title": { 135 | "en": "Item 2", 136 | "ro": "Articol 2", 137 | "vi": "M\u1ee5c 2" 138 | }, 139 | "description": { 140 | "en": "Description for item 2", 141 | "ro": "Descriere pentru articolul 2", 142 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 2" 143 | }, 144 | "icon": "icon-header-item2" 145 | }, 146 | { 147 | "id": "9", 148 | "skip_translation": "0", 149 | "content_type": "[\"test_app\", \"article\"]", 150 | "object_id": "3", 151 | "position": "0", 152 | "title": { 153 | "en": "Item 3", 154 | "ro": "Articol 3", 155 | "vi": "M\u1ee5c 3" 156 | }, 157 | "description": { 158 | "en": "Description for item 3", 159 | "ro": "Descriere pentru articolul 3", 160 | "vi": "M\u00f4 t\u1ea3 cho m\u1ee5c 3" 161 | }, 162 | "icon": "icon-header-item3" 163 | } 164 | ] 165 | -------------------------------------------------------------------------------- /tests/test_schema/test_cms_api_schema.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: '' 4 | version: 0.0.0 5 | paths: 6 | /test-app/: 7 | get: 8 | operationId: test_app_list 9 | parameters: 10 | - in: header 11 | name: accept-language 12 | schema: 13 | type: string 14 | description: Language code parameter. 15 | tags: 16 | - test-app 17 | security: 18 | - cookieAuth: [] 19 | - basicAuth: [] 20 | - {} 21 | responses: 22 | '200': 23 | content: 24 | application/json: 25 | schema: 26 | type: array 27 | items: 28 | $ref: '#/components/schemas/Post' 29 | description: '' 30 | /test-app/{id}/: 31 | get: 32 | operationId: test_app_retrieve 33 | parameters: 34 | - in: header 35 | name: accept-language 36 | schema: 37 | type: string 38 | description: Language code parameter. 39 | - in: path 40 | name: id 41 | schema: 42 | type: integer 43 | description: A unique integer value identifying this post. 44 | required: true 45 | tags: 46 | - test-app 47 | security: 48 | - cookieAuth: [] 49 | - basicAuth: [] 50 | - {} 51 | responses: 52 | '200': 53 | content: 54 | application/json: 55 | schema: 56 | $ref: '#/components/schemas/Post' 57 | description: '' 58 | components: 59 | schemas: 60 | Category: 61 | type: object 62 | description: Serializer for Category 63 | properties: 64 | id: 65 | type: integer 66 | readOnly: true 67 | title: 68 | type: string 69 | nullable: true 70 | slug: 71 | type: string 72 | nullable: true 73 | pattern: ^[-a-zA-Z0-9_]+$ 74 | required: 75 | - id 76 | Item: 77 | type: object 78 | description: Serializer for Item 79 | properties: 80 | id: 81 | type: integer 82 | readOnly: true 83 | title: 84 | type: string 85 | nullable: true 86 | description: 87 | type: string 88 | nullable: true 89 | icon: 90 | type: string 91 | required: 92 | - id 93 | Note: 94 | type: object 95 | description: Serializer for Note 96 | properties: 97 | id: 98 | type: integer 99 | readOnly: true 100 | text: 101 | type: string 102 | nullable: true 103 | required: 104 | - id 105 | Post: 106 | type: object 107 | description: Serializer for Post 108 | properties: 109 | id: 110 | type: integer 111 | readOnly: true 112 | note: 113 | allOf: 114 | - $ref: '#/components/schemas/Note' 115 | readOnly: true 116 | nullable: true 117 | category: 118 | allOf: 119 | - $ref: '#/components/schemas/Category' 120 | readOnly: true 121 | nullable: true 122 | tags: 123 | type: array 124 | items: 125 | $ref: '#/components/schemas/PostTag' 126 | readOnly: true 127 | nullable: true 128 | items: 129 | type: array 130 | items: 131 | $ref: '#/components/schemas/Item' 132 | readOnly: true 133 | nullable: true 134 | hash: 135 | type: string 136 | readOnly: true 137 | nullable: true 138 | title: 139 | type: string 140 | nullable: true 141 | subtitle: 142 | type: string 143 | nullable: true 144 | description: 145 | type: string 146 | nullable: true 147 | body: 148 | type: string 149 | href: 150 | type: string 151 | nullable: true 152 | required: 153 | - body 154 | - category 155 | - hash 156 | - id 157 | - items 158 | - note 159 | - tags 160 | PostTag: 161 | type: object 162 | description: Serializer for PostTag 163 | properties: 164 | id: 165 | type: integer 166 | readOnly: true 167 | title: 168 | type: string 169 | nullable: true 170 | slug: 171 | type: string 172 | nullable: true 173 | pattern: ^[-a-zA-Z0-9_]+$ 174 | required: 175 | - id 176 | securitySchemes: 177 | basicAuth: 178 | type: http 179 | scheme: basic 180 | cookieAuth: 181 | type: apiKey 182 | in: cookie 183 | name: sessionid 184 | -------------------------------------------------------------------------------- /tests/test_commands/test_import_export_cms_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | import pytest 8 | from django.conf import settings 9 | from django.core.management import call_command 10 | from freezegun import freeze_time 11 | from import_export.exceptions import ImportError as IEImportError 12 | 13 | from helpers.base import BaseTestCase 14 | from test_app.models import Article, Category, Item, Post 15 | 16 | 17 | class ImportExportCMSDataTests(BaseTestCase): 18 | reset_sequences = True 19 | 20 | def setUp(self): 21 | self.data_dir: Path = settings.BASE_DIR / "test_commands/data" 22 | self.expected_data_dir = self.data_dir / "expected_dir" 23 | self.expected_data_zip = self.data_dir / "expected_data.zip" 24 | 25 | self.corrupted_data_dir = self.data_dir / "corrupted_dir" 26 | 27 | self.result_dir = self.data_dir / "results" 28 | 29 | def tearDown(self): 30 | """Comment out this tear down to keep the results for comparison.""" 31 | # if self.result_dir.exists(): 32 | # shutil.rmtree(self.result_dir) 33 | 34 | def get_folder_contents(self, folder_path): 35 | res = {} 36 | for path, _sub_dirs, files in os.walk(folder_path): 37 | for name in files: 38 | rel_dir = os.path.relpath(path, folder_path) 39 | rel_file = os.path.join(rel_dir, name) 40 | ful_path = os.path.join(path, name) 41 | res[rel_file] = json.loads(self.get_file_data(ful_path)) 42 | 43 | return res 44 | 45 | def get_file_data(self, file_path): 46 | with open(file_path) as f: 47 | return f.read() 48 | 49 | def compare_folder(self, folder1, folder2): 50 | folder_1_contents = self.get_folder_contents(folder1) 51 | folder_2_contents = self.get_folder_contents(folder2) 52 | assert folder_1_contents == folder_2_contents 53 | 54 | def test_import_export_cms_data(self): 55 | with freeze_time("2012-01-14 12:00:01"): 56 | now = datetime.now().strftime("%Y%m%d-%H%M%S") 57 | call_command("import_cms_data", "test_app", input=self.expected_data_dir) 58 | 59 | assert Post.objects.count() == 3 60 | assert Article.objects.count() == 3 61 | assert Item.objects.count() == 9 62 | 63 | call_command("export_cms_data", "test_app", output=self.result_dir) 64 | # comment out next line to create zip file 65 | # call_command( 66 | # "export_cms_data", "test_app", output=self.result_dir, compress=True 67 | # ) 68 | result = self.result_dir / now 69 | 70 | self.compare_folder(self.expected_data_dir, result) 71 | 72 | def test_import_export_cms_zip_data(self): 73 | with freeze_time("2012-01-14 12:00:01"): 74 | now = datetime.now().strftime("%Y%m%d-%H%M%S") 75 | call_command("import_cms_data", "test_app", input=self.expected_data_zip) 76 | 77 | assert Post.objects.count() == 3 78 | assert Article.objects.count() == 3 79 | assert Item.objects.count() == 9 80 | 81 | call_command( 82 | "export_cms_data", "test_app", output=self.result_dir, compress=True 83 | ) 84 | result = self.result_dir / f"{now}.zip" 85 | 86 | temp_extracted_result_zip = self.result_dir / "extracted" / "result" 87 | temp_extracted_expected_zip = self.result_dir / "extracted" / "expected" 88 | 89 | shutil.unpack_archive(result, temp_extracted_result_zip) 90 | shutil.unpack_archive(self.expected_data_zip, temp_extracted_expected_zip) 91 | 92 | self.compare_folder(temp_extracted_result_zip, temp_extracted_expected_zip) 93 | 94 | def test_invalid_import_data(self): 95 | with pytest.raises(shutil.ReadError, match=r"is not a zip file"): 96 | call_command( 97 | "import_cms_data", "test_app", input=self.data_dir / "invalid_data.txt" 98 | ) 99 | 100 | def test_atomic_import(self): 101 | with freeze_time("2012-01-14 12:00:01"): 102 | call_command("import_cms_data", "test_app", input=self.expected_data_dir) 103 | 104 | assert Post.objects.count() == 3 105 | assert Category.objects.count() == 3 106 | assert Article.objects.count() == 3 107 | assert Item.objects.count() == 9 108 | 109 | with pytest.raises(IEImportError): 110 | call_command( 111 | "import_cms_data", "test_app", input=self.corrupted_data_dir 112 | ) 113 | 114 | assert Post.objects.count() == 3 115 | assert Category.objects.count() == 3 116 | -------------------------------------------------------------------------------- /headless_cms/core/management/commands/export_cms_data.py: -------------------------------------------------------------------------------- 1 | import json 2 | import shutil 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | from django.contrib.contenttypes.fields import GenericRelation 7 | from django.db.models import ForeignKey, ManyToManyField 8 | from reversion.management.commands import BaseRevisionCommand 9 | 10 | from headless_cms.models import LocalizedPublicationModel 11 | from headless_cms.utils.custom_import_export import override_modelresource_factory 12 | 13 | 14 | class Command(BaseRevisionCommand): 15 | """ 16 | Exports data recursively of a Django app into JSON files. 17 | 18 | Usage: 19 | python manage.py export_cms_data [app_label ...] [--using DATABASE] [--model-db DATABASE] [--output DIRECTORY] [--compress] [--cf FORMAT] 20 | 21 | Options: 22 | app_label: Optional app_label or app_label.model_name list. 23 | --using: The database to query for revision data. 24 | --model-db: The database to query for model data. 25 | --output: Export data to this directory. 26 | --compress: Compress data. 27 | --cf, --compress-format: Compression format (default is zip). 28 | """ 29 | 30 | help = "Export data recursively of a Django app into JSON files." 31 | 32 | def add_arguments(self, parser): 33 | super().add_arguments(parser) 34 | parser.add_argument( 35 | "--output", 36 | default="exported_data", 37 | type=str, 38 | help="Export data to this directory.", 39 | ) 40 | parser.add_argument( 41 | "--cf", 42 | "--compress-format", 43 | default="zip", 44 | type=str, 45 | help="Compression format.", 46 | ) 47 | parser.add_argument( 48 | "--compress", 49 | default=False, 50 | action="store_true", 51 | help="Compress data", 52 | ) 53 | 54 | def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False): 55 | super().__init__(stdout, stderr, no_color, force_color) 56 | self.exported_models = set() 57 | self.base_output_dir = None 58 | self.data_output_dir = None 59 | self.current_time = None 60 | self.should_compress = False 61 | self.verbosity = 0 62 | 63 | def export_model(self, model): 64 | if model in self.exported_models: 65 | return 66 | self.exported_models.add(model) 67 | model_fields = model._meta.get_fields() 68 | for field in model_fields: 69 | if isinstance(field, GenericRelation | ForeignKey) and issubclass( 70 | field.related_model, LocalizedPublicationModel 71 | ): 72 | self.export_model(field.related_model) 73 | elif (isinstance(field, ManyToManyField)) and issubclass( 74 | field.related_model, LocalizedPublicationModel 75 | ): 76 | self.export_model(field.related_model) 77 | through = getattr(model, field.name).through 78 | self.export_model(through) 79 | 80 | export_model_resource = override_modelresource_factory(model, exclude_m2m=True) 81 | export_model = export_model_resource() 82 | 83 | data = export_model.export() 84 | if self.verbosity >= 1: 85 | self.stdout.write(f"Export data for {model._meta.object_name}") 86 | 87 | dest_dir: Path = self.data_output_dir / model._meta.app_label 88 | dest_dir.mkdir(parents=True, exist_ok=True) 89 | dest_file: Path = dest_dir / f"{model._meta.object_name}.json" 90 | 91 | with open(dest_file, "w") as f: 92 | f.write( 93 | json.dumps(data.dict, indent=2) 94 | if not self.should_compress 95 | else data.json 96 | ) 97 | 98 | def handle(self, *app_labels, **options): 99 | self.base_output_dir = Path(options["output"]) 100 | self.current_time = datetime.now().strftime("%Y%m%d-%H%M%S") 101 | self.data_output_dir = self.base_output_dir / self.current_time 102 | 103 | print(f"Export data to {self.data_output_dir}") 104 | 105 | self.should_compress = options["compress"] 106 | 107 | if not self.data_output_dir.exists(): 108 | self.data_output_dir.mkdir(parents=True, exist_ok=True) 109 | 110 | self.verbosity = options["verbosity"] 111 | 112 | for model in self.get_models(options): 113 | if not issubclass(model, LocalizedPublicationModel): 114 | continue 115 | 116 | self.export_model(model) 117 | 118 | if self.should_compress: 119 | self.compress_output(options["cf"]) 120 | 121 | self.clean_up() 122 | 123 | def compress_output(self, compress_format): 124 | dest_file = self.base_output_dir / f"{self.current_time}" 125 | shutil.make_archive(dest_file, compress_format, self.data_output_dir) 126 | 127 | def clean_up(self): 128 | if self.should_compress: 129 | shutil.rmtree(self.data_output_dir) 130 | -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by "django-admin startproject" using Django 1.10a1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/dev/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | import environ 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = "this-is-a-mock-secret-for-testing" 26 | 27 | # SECURITY WARNING: don"t run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "headless_cms.enhances", 37 | "headless_cms.core", 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.messages", 43 | "django.contrib.staticfiles", 44 | "django.contrib.postgres", 45 | "django_extensions", 46 | "rest_framework", 47 | "reversion", 48 | "martor", 49 | "psqlextra", 50 | "localized_fields", 51 | "import_export", 52 | "drf_spectacular", 53 | "solo", 54 | "adminsortable2", 55 | "test_app", 56 | "headless_cms.contrib.astrowind.astrowind_widgets", 57 | "headless_cms.contrib.astrowind.astrowind_pages", 58 | "headless_cms.contrib.astrowind.astrowind_posts", 59 | "headless_cms.contrib.astrowind.astrowind_metadata", 60 | ] 61 | 62 | MIDDLEWARE = [ 63 | "django.middleware.security.SecurityMiddleware", 64 | "django.contrib.sessions.middleware.SessionMiddleware", 65 | "django.middleware.locale.LocaleMiddleware", 66 | "django.middleware.common.CommonMiddleware", 67 | "django.middleware.csrf.CsrfViewMiddleware", 68 | "django.contrib.auth.middleware.AuthenticationMiddleware", 69 | "django.contrib.messages.middleware.MessageMiddleware", 70 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 71 | ] 72 | 73 | ROOT_URLCONF = "test_project.urls" 74 | 75 | TEMPLATES = [ 76 | { 77 | "BACKEND": "django.template.backends.django.DjangoTemplates", 78 | "DIRS": [], 79 | "APP_DIRS": True, 80 | "OPTIONS": { 81 | "context_processors": [ 82 | "django.template.context_processors.debug", 83 | "django.template.context_processors.request", 84 | "django.contrib.auth.context_processors.auth", 85 | "django.contrib.messages.context_processors.messages", 86 | ], 87 | }, 88 | }, 89 | ] 90 | 91 | WSGI_APPLICATION = "test_project.wsgi.application" 92 | 93 | 94 | # Database 95 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 96 | 97 | env = environ.Env() 98 | 99 | env_file = f"{BASE_DIR}/../.env.test" 100 | 101 | if os.path.isfile(env_file): 102 | environ.Env.read_env(env_file) 103 | 104 | DATABASES = { 105 | "default": { 106 | "ENGINE": "psqlextra.backend", 107 | "HOST": env.str("POSTGRES_HOST", "localhost"), 108 | "NAME": env.str("POSTGRES_DB", "POSTGRES_DB"), 109 | "USER": env.str("POSTGRES_USER", "POSTGRES_USER"), 110 | "PASSWORD": env.str("POSTGRES_PASSWORD", "POSTGRES_PASSWORD"), 111 | "PORT": env.int("POSTGRES_PORT", 5432), 112 | }, 113 | } 114 | 115 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 116 | 117 | # Password validation 118 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators 119 | 120 | AUTH_PASSWORD_VALIDATORS = [ 121 | { 122 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 123 | }, 124 | { 125 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 126 | }, 127 | { 128 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 129 | }, 130 | { 131 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 132 | }, 133 | ] 134 | 135 | 136 | # Internationalization 137 | # https://docs.djangoproject.com/en/dev/topics/i18n/ 138 | 139 | LANGUAGE_CODE = "en" 140 | LANGUAGES = (("en", "English"), ("ro", "Romanian"), ("vi", "Viet Nam")) 141 | 142 | TIME_ZONE = "UTC" 143 | 144 | USE_I18N = True 145 | 146 | USE_L10N = True 147 | 148 | USE_TZ = True 149 | 150 | 151 | # Static files (CSS, JavaScript, Images) 152 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 153 | 154 | STATIC_URL = "/static/" 155 | 156 | MEDIA_URL = "media-test/" 157 | MEDIA_ROOT = f"{BASE_DIR}/../media-test/" 158 | 159 | # Rest framework 160 | REST_FRAMEWORK = { 161 | "DEFAULT_RENDERER_CLASSES": [ 162 | "rest_framework.renderers.JSONRenderer", 163 | ], 164 | "DEFAULT_SCHEMA_CLASS": "headless_cms.schema.auto_schema.CustomAutoSchema", 165 | } 166 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Why Choose Django-headless-cms? 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | Why choose Django-headless-cms over alternatives like `Wagtail `_, 8 | `Django-CMS `_, `Strapi `_, or 9 | `Contentful `_? 10 | 11 | - **Headless CMS with minimal configuration**: Unlike Wagtail and Django-CMS, which are primarily headed CMS solutions. 12 | - **Responsive UI**: Preferred over Strapi for its user interface. 13 | - **Python & Django-based**: Built on a robust framework with numerous extensions. 14 | - **Open Source**: Unlike Contentful and other paid services, it allows you to self-host your CMS. 15 | - **Integration**: Easily integrates with many existing Python and Django libraries. 16 | - **Centralized multi-language support**: Reduces redundancy and allows different content across multiple languages. 17 | 18 | Features 19 | ~~~~~~~~ 20 | 21 | - **Schema Migrations**: Manage content schema as database migrations, making it easier to sync from development to 22 | production environments. 23 | - **Versioning Content**: Revert to any previously saved version. 24 | - **Publish/Draft Content**: Manage published and draft content. 25 | - **Markdown Editor Support**: Enhanced content editing experience, useful for Posts/Articles. 26 | - **Multi-language Support**: Even for Markdown fields. 27 | - **Auto Translate/Force Re-translate**: Use ChatGPT or build your own translation interface. 28 | - **Recursive Actions**: Apply actions like Publish, Translate, and Force Re-translate to referenced objects. 29 | - **Optimized Queries**: Auto prefetch and select related queries for optimization. 30 | - **Filter Published Objects**: Easily filter to show only published content. 31 | - **Auto Admin**: Simplify admin page setup with features like: 32 | -. Sortable inline Many-to-Many (M2M) relationships. 33 | -. Sortable generic inlines. 34 | -. Display publish status of child objects. 35 | - **Auto Serializer**: Simplify serializer creation, including nested relations. Override specific serializers as needed. 36 | - **Rapid API Development**: Build APIs (Views/Viewsets) quickly and easily with auto serializers and optimized queries. 37 | - **Easy Data Import/Export**: Use the admin interface or management commands. 38 | - **Auto-generated API Documentation & Playground**: Automatically generate API documentation and an interactive playground. 39 | - **Hash**: Provide hash utilities to check whether an object (and its descendant children) has changed or not (thanks 40 | to versioning). This can be helpful in building caching mechanisms to reduce API calls and improve performance. 41 | 42 | Dependencies 43 | ~~~~~~~~~~~~ 44 | 45 | Special thanks to these outstanding packages that have been used as supporting components within Django-headless-cms: 46 | 47 | - `Django-localized-fields `_: Supports multi-language fields. 48 | - `django-markdown-editor (Martor) `_: Provides a markdown editor. 49 | - `Django-reversion `_: Offers versioning and publish/draft content support. 50 | - `Django-admin-interface `_: Enhances the Django admin 51 | interface and supports language switching. 52 | - `Django-solo `_: Supports singleton admin models. 53 | - `Django-import-export `_: Facilitates data import and export. 54 | - `Django-filter `_: Assists in building API query filters. 55 | - `Unidecode `_: Helps create readable slug URLs. 56 | - `OpenAI `_: Enables auto-translation with ChatGPT. 57 | - `Django `_ & `Django REST framework `_: 58 | The foundational frameworks. 59 | 60 | [Extra] My Story 61 | ~~~~~~~~~~~~~~~~ 62 | 63 | After experimenting with various CMS frameworks, I couldn't find a suitable one for my needs: 64 | 65 | - **Strapi**: Node.js-based and synchronizing schema between development and production can be challenging. 66 | Additionally, it lacks a responsive UI, making minor updates on mobile devices difficult. 67 | - **Wagtail and Django-CMS**: These are headed CMS solutions, great if you need a WYSIWYG CMS. However, they felt 68 | overly complex for my use case and have a steep learning curve. 69 | - **Contentful & other paid services**: While these services are excellent, their costs can be prohibitive. Why pay 70 | for additional slots when you can add them for free in Django? 71 | 72 | After further research into Django-based (and Node.js-based) frameworks, I found none that met my expectations. Given 73 | Django's popularity and extensive ecosystem of extensions, I decided to build Django-headless-cms. I also noticed that 74 | some Reddit users faced similar issues, which motivated me to create this package. 75 | 76 | If you appreciate this package, please give it a star. If you'd like to see more features, consider contributing. If 77 | you encounter any issues, don't hesitate to open a GitHub issue (and help fix it if you're willing to contribute). 78 | -------------------------------------------------------------------------------- /tests/helpers/schema_utils.py: -------------------------------------------------------------------------------- 1 | """This module is copied from drf spectacular test utils. See more at: https://github.dev/tfranzel/drf-spectacular""" 2 | 3 | import difflib 4 | import json 5 | import os 6 | 7 | from drf_spectacular.validation import validate_schema 8 | 9 | 10 | def build_absolute_file_path(relative_path): 11 | return os.path.join( 12 | os.path.dirname(os.path.dirname(os.path.realpath(__file__))), relative_path 13 | ) 14 | 15 | 16 | def assert_schema(schema, reference_filename, transforms=None, reverse_transforms=None): 17 | from drf_spectacular.renderers import OpenApiJsonRenderer, OpenApiYamlRenderer 18 | 19 | schema_yml = OpenApiYamlRenderer().render(schema, renderer_context={}) 20 | # render also a json and provoke serialization issues 21 | OpenApiJsonRenderer().render(schema, renderer_context={}) 22 | 23 | reference_filename = build_absolute_file_path(reference_filename) 24 | 25 | with open(reference_filename.replace(".yml", "_out.yml"), "wb") as fh: 26 | fh.write(schema_yml) 27 | 28 | if not os.path.exists(reference_filename): 29 | raise RuntimeError( 30 | f"{reference_filename} was not found for comparison. Carefully inspect " 31 | f'the generated {reference_filename.replace(".yml", "_out.yml")} and ' 32 | f"copy it to {reference_filename} to serve as new ground truth." 33 | ) 34 | 35 | generated = schema_yml.decode() 36 | 37 | with open(reference_filename) as fh: 38 | expected = fh.read() 39 | 40 | # apply optional transformations to generated result. this mainly serves to unify 41 | # discrepancies between Django, DRF and library versions. 42 | for t in transforms or []: 43 | generated = t(generated) 44 | for t in reverse_transforms or []: 45 | expected = t(expected) 46 | 47 | assert_equal(generated, expected) 48 | # this is more a less a sanity check as checked-in schemas should be valid anyhow 49 | validate_schema(schema) 50 | 51 | 52 | def assert_api_schema(api_schema, reference_filename): 53 | reference_filename = build_absolute_file_path(reference_filename) 54 | 55 | with open(reference_filename.replace(".yml", "_out.yml"), "wb") as fh: 56 | fh.write(api_schema) 57 | 58 | if not os.path.exists(reference_filename): 59 | raise RuntimeError( 60 | f"{reference_filename} was not found for comparison. Carefully inspect " 61 | f'the generated {reference_filename.replace(".yml", "_out.yml")} and ' 62 | f"copy it to {reference_filename} to serve as new ground truth." 63 | ) 64 | 65 | with open(reference_filename) as fh: 66 | expected = fh.read() 67 | 68 | generated = api_schema.decode() 69 | 70 | # apply optional transformations to generated result. this mainly serves to unify 71 | # discrepancies between Django, DRF and library versions. 72 | assert_equal(generated, expected) 73 | 74 | 75 | def assert_equal(actual, expected): 76 | if not isinstance(actual, str): 77 | actual = json.dumps(actual, indent=4) 78 | if not isinstance(expected, str): 79 | expected = json.dumps(expected, indent=4) 80 | diff = difflib.unified_diff( 81 | expected.splitlines(True), 82 | actual.splitlines(True), 83 | ) 84 | diff = "".join(diff) 85 | assert actual == expected and not diff, diff 86 | 87 | 88 | def generate_schema(route, viewset=None, view=None, view_function=None, patterns=None): 89 | from django.urls import path 90 | from drf_spectacular.generators import SchemaGenerator 91 | from rest_framework import routers 92 | from rest_framework.viewsets import ViewSetMixin 93 | 94 | if viewset: 95 | assert issubclass(viewset, ViewSetMixin) 96 | router = routers.SimpleRouter() 97 | router.register(route, viewset, basename=route) 98 | patterns = router.urls 99 | elif view: 100 | patterns = [path(route, view.as_view())] 101 | elif view_function: 102 | patterns = [path(route, view_function)] 103 | else: 104 | assert route is None and isinstance(patterns, list) 105 | 106 | generator = SchemaGenerator(patterns=patterns) 107 | schema = generator.get_schema(request=None, public=True) 108 | validate_schema(schema) # make sure generated schemas are always valid 109 | return schema 110 | 111 | 112 | def get_response_schema(operation, status=None, content_type="application/json"): 113 | if ( 114 | not status 115 | and operation["operationId"].endswith("_create") 116 | and "201" in operation["responses"] 117 | and "200" not in operation["responses"] 118 | ): 119 | status = "201" 120 | elif not status: 121 | status = "200" 122 | return operation["responses"][status]["content"][content_type]["schema"] 123 | 124 | 125 | def get_request_schema(operation, content_type="application/json"): 126 | return operation["requestBody"]["content"][content_type]["schema"] 127 | 128 | 129 | def is_gis_installed(): 130 | # only load GIS if library is installed. This is required for the GIS test to work 131 | from django.core.exceptions import ImproperlyConfigured 132 | 133 | try: 134 | from django.contrib.gis.gdal import gdal_version # noqa: F401 135 | except ImproperlyConfigured: 136 | return False 137 | else: 138 | return True 139 | 140 | 141 | def strip_int64_details(schema): 142 | """remove new min/max/format for django 5 with sqlite db for comparison’s sake""" 143 | 144 | if schema.get("format") == "int64" and "minimum" in schema and "maximum" in schema: 145 | return { 146 | k: v for k, v in schema.items() if k not in ("format", "minimum", "maximum") 147 | } 148 | else: 149 | return schema 150 | -------------------------------------------------------------------------------- /tests/test_translation/test_openai_translation.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | from unittest.mock import patch 5 | 6 | from django.conf import settings 7 | from headless_cms.auto_translate.openai_translate import OpenAITranslate 8 | from openai.types.chat import ChatCompletion, ChatCompletionMessage 9 | from openai.types.chat.chat_completion import Choice 10 | 11 | from helpers.base import BaseTestCase 12 | from test_app.factories import PostFactory 13 | from test_app.models import Post 14 | 15 | 16 | class TestOpenAITranslate(BaseTestCase): 17 | def setUp(self): 18 | super().setUp() 19 | self.instance: Post = PostFactory() 20 | self.translator = OpenAITranslate(self.instance) 21 | 22 | def batch_translate(raw): 23 | res = {} 24 | for language, obj_to_trans in raw.items(): 25 | res[language] = {k: language + "-" + v for k, v in obj_to_trans.items()} 26 | return res 27 | 28 | self.batch_translate = batch_translate 29 | 30 | @patch("openai.resources.chat.completions.Completions.create") 31 | def test_openai_translate(self, mock_openai_create): 32 | translated_content = "Translated content" 33 | mock_openai_create.return_value = ChatCompletion( 34 | id="id", 35 | model="any-model", 36 | created=int(time.time()), 37 | object="chat.completion", 38 | choices=[ 39 | Choice( 40 | finish_reason="stop", 41 | index=0, 42 | message=ChatCompletionMessage( 43 | content=translated_content, role="assistant" 44 | ), 45 | ) 46 | ], 47 | ) 48 | 49 | result = self.translator.translate("en", "Raw content") 50 | assert result == translated_content 51 | 52 | @patch("openai.resources.chat.completions.Completions.create") 53 | def test_openai_batch_translate(self, mock_openai_create): 54 | translated_obj = { 55 | "title": "Translated title", 56 | "description": "Translated description", 57 | "body": "Translated body", 58 | } 59 | translated_res = json.dumps(translated_obj) 60 | 61 | def mock_openai_translate_create(*args, **kwargs): 62 | if len(kwargs["messages"]) == 2: 63 | return ChatCompletion( 64 | id="id", 65 | model="any-model", 66 | created=int(time.time()), 67 | object="chat.completion", 68 | choices=[ 69 | Choice( 70 | finish_reason="length", 71 | index=0, 72 | message=ChatCompletionMessage( 73 | content=translated_res[:20], role="assistant" 74 | ), 75 | ) 76 | ], 77 | ) 78 | else: 79 | return ChatCompletion( 80 | id="id", 81 | model="any-model", 82 | created=int(time.time()), 83 | object="chat.completion", 84 | choices=[ 85 | Choice( 86 | finish_reason="stop", 87 | index=0, 88 | message=ChatCompletionMessage( 89 | content=translated_res[20:], role="assistant" 90 | ), 91 | ) 92 | ], 93 | ) 94 | 95 | mock_openai_create.side_effect = mock_openai_translate_create 96 | 97 | obj_to_translate = { 98 | "title": "title", 99 | "description": "description", 100 | "body": "body", 101 | } 102 | batches = { 103 | "ro": obj_to_translate, 104 | "vi": obj_to_translate, 105 | } 106 | result = self.translator.batch_translate(batches) 107 | assert result == {"vi": translated_obj, "ro": translated_obj} 108 | 109 | @patch("openai.resources.chat.completions.Completions.create") 110 | def test_process(self, mock_openai_create): 111 | def mock_open_ai_create_side_effect(**kwargs): 112 | messages = kwargs["messages"] 113 | trans_lang = re.search(r"into (.*) \(", messages[0]["content"]).group(1) 114 | obj_to_translate = json.loads(messages[1]["content"]) 115 | translated_obj = { 116 | k: f"Translated {trans_lang}: {v}" for k, v in obj_to_translate.items() 117 | } 118 | return ChatCompletion( 119 | id="id", 120 | model="any-model", 121 | created=int(time.time()), 122 | object="chat.completion", 123 | choices=[ 124 | Choice( 125 | finish_reason="stop", 126 | index=0, 127 | message=ChatCompletionMessage( 128 | content=json.dumps(translated_obj), role="assistant" 129 | ), 130 | ) 131 | ], 132 | ) 133 | 134 | mock_openai_create.side_effect = mock_open_ai_create_side_effect 135 | 136 | self.translator.process() 137 | 138 | self.instance.refresh_from_db() 139 | base_lang = settings.LANGUAGE_CODE 140 | for lang, _code in settings.LANGUAGES: 141 | if lang == base_lang: 142 | continue 143 | assert ( 144 | getattr(self.instance.title, lang) 145 | == f"Translated {lang}: {getattr(self.instance.title, base_lang)}" 146 | ) 147 | -------------------------------------------------------------------------------- /headless_cms/widgets.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from django.forms import Textarea 5 | from django.template.loader import get_template 6 | from django.urls import reverse 7 | from localized_fields.widgets import AdminLocalizedFieldWidget, LocalizedFieldWidget 8 | from martor.settings import ( 9 | MARTOR_ALTERNATIVE_CSS_FILE_THEME, 10 | MARTOR_ALTERNATIVE_JQUERY_JS_FILE, 11 | MARTOR_ALTERNATIVE_JS_FILE_THEME, 12 | MARTOR_ENABLE_ADMIN_CSS, 13 | MARTOR_ENABLE_CONFIGS, 14 | MARTOR_MARKDOWN_BASE_EMOJI_URL, 15 | MARTOR_MARKDOWNIFY_TIMEOUT, 16 | MARTOR_SEARCH_USERS_URL, 17 | MARTOR_TOOLBAR_BUTTONS, 18 | MARTOR_UPLOAD_URL, 19 | ) 20 | from martor.widgets import AdminMartorWidget, MartorWidget, get_theme 21 | 22 | 23 | class LocalizedMartorWidget(LocalizedFieldWidget): 24 | widget = MartorWidget 25 | 26 | 27 | class AdminLocalizedMartorWidget(AdminLocalizedFieldWidget): 28 | widget = AdminMartorWidget 29 | template_name = "custom_localized_fields/admin/widget.html" 30 | 31 | def markdown_render(self, name, value, attrs=None, renderer=None, **kwargs): 32 | random_string = "".join( 33 | random.choice(string.ascii_letters + string.digits) for x in range(10) 34 | ) 35 | attrs["id"] = attrs["id"] + "-" + random_string 36 | 37 | # Make the settings the default attributes to pass 38 | attributes_to_pass = { 39 | "data-enable-configs": MARTOR_ENABLE_CONFIGS, 40 | "data-markdownfy-url": reverse("martor_markdownfy"), 41 | } 42 | 43 | if MARTOR_UPLOAD_URL: 44 | attributes_to_pass["data-upload-url"] = reverse("imgur_uploader") 45 | if MARTOR_SEARCH_USERS_URL: 46 | attributes_to_pass["data-search-users-url"] = reverse("search_user_json") 47 | if MARTOR_SEARCH_USERS_URL: 48 | attributes_to_pass["data-base-emoji-url"] = MARTOR_MARKDOWN_BASE_EMOJI_URL 49 | if MARTOR_MARKDOWNIFY_TIMEOUT: 50 | attributes_to_pass["data-save-timeout"] = MARTOR_MARKDOWNIFY_TIMEOUT 51 | 52 | # Make sure that the martor value is in the class attr passed in 53 | if "class" in attrs: 54 | attrs["class"] += " martor" 55 | else: 56 | attrs["class"] = "martor" 57 | 58 | # Update and overwrite with the attributes passed in 59 | attributes_to_pass.update(attrs) 60 | 61 | # Update and overwrite with any attributes that are on the widget 62 | # itself. This is also the only way we can push something in without 63 | # being part of the render chain. 64 | attributes_to_pass.update(self.attrs) 65 | 66 | template = get_template(f"martor/{get_theme()}/editor.html") 67 | emoji_enabled = MARTOR_ENABLE_CONFIGS.get("emoji") == "true" 68 | mentions_enabled = MARTOR_ENABLE_CONFIGS.get("mention") == "true" 69 | 70 | widget = Textarea(attrs=attributes_to_pass) 71 | widget = widget.render(name, value) 72 | 73 | res = template.render( 74 | { 75 | "martor": widget, 76 | "field_name": name + "-" + random_string, 77 | "emoji_enabled": emoji_enabled, 78 | "mentions_enabled": mentions_enabled, 79 | "toolbar_buttons": MARTOR_TOOLBAR_BUTTONS, 80 | } 81 | ) 82 | return res 83 | 84 | def render(self, name, value, attrs=None, renderer=None): 85 | context = self.get_context(name, value, attrs) 86 | widget = context["widget"] 87 | md = self.markdown_render(widget["name"], "", attrs) 88 | context["widget"]["md"] = md 89 | return self._render(self.template_name, context, renderer) 90 | 91 | class Media: 92 | selected_theme = get_theme() 93 | css = { 94 | "all": ( 95 | "plugins/css/ace.min.css", 96 | "plugins/css/resizable.min.css", 97 | f"martor/css/martor.{selected_theme}.min.css", 98 | ) 99 | } 100 | 101 | if MARTOR_ENABLE_ADMIN_CSS: 102 | admin_theme = ("martor/css/martor-admin.min.css",) 103 | css["all"] = admin_theme.__add__(css.get("all")) 104 | 105 | js = ( 106 | "plugins/js/ace.js", 107 | "plugins/js/mode-markdown.js", 108 | "plugins/js/ext-language_tools.js", 109 | "plugins/js/theme-github.js", 110 | "plugins/js/highlight.min.js", 111 | "plugins/js/resizable.min.js", 112 | "plugins/js/emojis.min.js", 113 | f"martor/js/martor.{selected_theme}.js", 114 | ) 115 | 116 | # Adding the following scripts to the end 117 | # of the tuple in case it affects behaviour. 118 | # spellcheck configuration 119 | if MARTOR_ENABLE_CONFIGS.get("spellcheck") == "true": 120 | js = ("plugins/js/typo.js", "plugins/js/spellcheck.js").__add__(js) 121 | 122 | # support alternative vendor theme file like: bootstrap, semantic) 123 | # 1. vendor css theme 124 | if MARTOR_ALTERNATIVE_CSS_FILE_THEME: 125 | css_theme = MARTOR_ALTERNATIVE_CSS_FILE_THEME 126 | css["all"] = (css_theme,).__add__(css.get("all")) 127 | else: 128 | css_theme = f"plugins/css/{selected_theme}.min.css" 129 | css["all"] = (css_theme,).__add__(css.get("all")) 130 | 131 | # 2. vendor js theme 132 | if MARTOR_ALTERNATIVE_JS_FILE_THEME: 133 | js_theme = MARTOR_ALTERNATIVE_JS_FILE_THEME 134 | js = (MARTOR_ALTERNATIVE_JS_FILE_THEME,).__add__(js) 135 | else: 136 | js_theme = f"plugins/js/{selected_theme}.min.js" 137 | js = (js_theme,).__add__(js) 138 | 139 | # 3. vendor jQUery 140 | if MARTOR_ALTERNATIVE_JQUERY_JS_FILE: 141 | js = (MARTOR_ALTERNATIVE_JQUERY_JS_FILE,).__add__(js) 142 | elif MARTOR_ENABLE_CONFIGS.get("jquery") == "true": 143 | js = ("plugins/js/jquery.min.js",).__add__(js) 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Headless CMS 2 | 3 | **Django Headless CMS** is your tool for effortless headless CMS creation. 4 | Based on Django, it simplifies dashboard and API development, allowing 5 | you to focus on content creation for any device. Enjoy the flexibility 6 | of headless architecture and the efficiency of Django, reshaping your 7 | CMS workflow. 8 | ## Requirements 9 | 10 | * Python 3.10 or greater. 11 | * Django 4.0+ (supports up to Django 5.2+). 12 | * Django knowledge. 13 | 14 | ## Installation 15 | 16 | The module can be installed from [PyPI](https://pypi.org/project/django-headless-cms/): 17 | 18 | ```bash 19 | pip install django-headless-cms 20 | ``` 21 | 22 |
23 | Other installation methods (click to expand) 24 | 25 | ### Install the latest dev version from github (or replace `@master` with a [@release_version] 26 | 27 | ```bash 28 | pip install git+https://github.com/huynguyengl99/django-headless-cms@master 29 | ``` 30 | 31 |
32 | 33 | ## Document 34 | Please visit [Django headless CMS doc](https://django-headless-cms.readthedocs.io/) for 35 | documentation. 36 | 37 | ## Introduction 38 | 39 | ### Why Choose Django-headless-cms? 40 | 41 | Why choose Django-headless-cms over alternatives like [Wagtail](https://wagtail.org/), [Django-CMS](https://www.django-cms.org/), [Strapi](https://strapi.io/), or [Contentful](https://www.contentful.com/)? 42 | 43 | - **Headless CMS with minimal configuration**: Unlike Wagtail and Django-CMS, which are primarily headed CMS solutions. 44 | - **Responsive UI**: Preferred over Strapi for its user interface. 45 | - **Python & Django-based**: Built on a robust framework with numerous extensions. 46 | - **Open Source**: Unlike Contentful and other paid services, it allows you to self-host your CMS. 47 | - **Integration**: Easily integrates with many existing Python and Django libraries. 48 | - **Centralized multi-language support**: Reduces redundancy and allows different content across multiple languages. 49 | 50 | ### Features 51 | 52 | - **Schema Migrations**: Manage content schema as database migrations, making it easier to sync from development to 53 | production environments. 54 | - **Versioning Content**: Revert to any previously saved version. 55 | - **Publish/Draft Content**: Manage published and draft content. 56 | - **Markdown Editor Support**: Enhanced content editing experience, useful for Posts/Articles. 57 | - **Multi-language Support**: Even for Markdown fields. 58 | - **Auto Translate/Force Re-translate**: Use ChatGPT or build your own translation interface. 59 | - **Recursive Actions**: Apply actions like Publish, Translate, and Force Re-translate to referenced objects. 60 | - **Optimized Queries**: Auto prefetch and select related queries for optimization. 61 | - **Filter Published Objects**: Easily filter to show only published content. 62 | - **Auto Admin**: Simplify admin page setup with features like: 63 | - Sortable inline Many-to-Many (M2M) relationships. 64 | - Sortable generic inlines. 65 | - Display publish status of child objects. 66 | - **Auto Serializer**: Simplify serializer creation, including nested relations. Override specific serializers as needed. 67 | - **Rapid API Development**: Build APIs (Views/Viewsets) quickly and easily with auto serializers and optimized queries. 68 | - **Easy Data Import/Export**: Use the admin interface or management commands. 69 | - **Auto-generated API Documentation & Playground**: Automatically generate API documentation and an interactive playground. 70 | 71 | ### Dependencies 72 | 73 | Special thanks to these outstanding packages that have been used as supporting components within Django-headless-cms: 74 | 75 | - **[Django-localized-fields](https://github.com/SectorLabs/django-localized-fields)**: Supports multi-language fields. 76 | - **[django-markdown-editor (Martor)](https://github.com/agusmakmun/django-markdown-editor)**: Provides a markdown editor. 77 | - **[Django-reversion](https://github.com/etianen/django-reversion)**: Offers versioning and publish/draft content support. 78 | - **[Django-admin-interface](https://github.com/fabiocaccamo/django-admin-interface)**: Enhances the Django admin 79 | interface and supports language switching. 80 | - **[Django-solo](https://github.com/lazybird/django-solo)**: Supports singleton admin models. 81 | - **[Django-import-export](https://github.com/django-import-export/django-import-export)**: Facilitates data import and 82 | export. 83 | - **[Django-filter](https://github.com/carltongibson/django-filter)**: Assists in building API query filters. 84 | - **[Unidecode](https://pypi.org/project/Unidecode/)**: Helps create readable slug URLs. 85 | - **[OpenAI](https://github.com/openai/openai-python)**: Enables auto-translation with ChatGPT. 86 | - **[Django](https://www.djangoproject.com/)** & **[Django REST framework](https://www.django-rest-framework.org/)**: 87 | The foundational frameworks. 88 | 89 | ### [Extra] My Story 90 | 91 | After experimenting with various CMS frameworks, I couldn't find a suitable one for my needs: 92 | 93 | - **Strapi**: Node.js-based and synchronizing schema between development and production can be challenging. 94 | Additionally, it lacks a responsive UI, making minor updates on mobile devices difficult. 95 | - **Wagtail and Django-CMS**: These are headed CMS solutions, great if you need a WYSIWYG CMS. However, they felt 96 | overly complex for my use case and have a steep learning curve. 97 | - **Contentful & other paid services**: While these services are excellent, their costs can be prohibitive. Why pay 98 | for additional slots when you can add them for free in Django? 99 | 100 | After further research into Django-based (and Node.js-based) frameworks, I found none that met my expectations. Given 101 | Django's popularity and extensive ecosystem of extensions, I decided to build Django-headless-cms. I also noticed that 102 | some Reddit users faced similar issues, which motivated me to create this package. 103 | 104 | If you appreciate this package, please give it a star. If you'd like to see more features, consider contributing. If 105 | you encounter any issues, don't hesitate to open a GitHub issue (and help fix it if you're willing to contribute). 106 | -------------------------------------------------------------------------------- /headless_cms/contrib/astrowind/astrowind_widgets/migrations/0002_awaction_add_skip_translation.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ("astrowind_widgets", "0001_initial"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="awaction", 13 | name="skip_translation", 14 | field=models.BooleanField(default=False), 15 | ), 16 | migrations.AddField( 17 | model_name="awbloghighlightedpost", 18 | name="skip_translation", 19 | field=models.BooleanField(default=False), 20 | ), 21 | migrations.AddField( 22 | model_name="awbloglatestpost", 23 | name="skip_translation", 24 | field=models.BooleanField(default=False), 25 | ), 26 | migrations.AddField( 27 | model_name="awbrand", 28 | name="skip_translation", 29 | field=models.BooleanField(default=False), 30 | ), 31 | migrations.AddField( 32 | model_name="awcalltoaction", 33 | name="skip_translation", 34 | field=models.BooleanField(default=False), 35 | ), 36 | migrations.AddField( 37 | model_name="awcontact", 38 | name="skip_translation", 39 | field=models.BooleanField(default=False), 40 | ), 41 | migrations.AddField( 42 | model_name="awcontent", 43 | name="skip_translation", 44 | field=models.BooleanField(default=False), 45 | ), 46 | migrations.AddField( 47 | model_name="awdisclaimer", 48 | name="skip_translation", 49 | field=models.BooleanField(default=False), 50 | ), 51 | migrations.AddField( 52 | model_name="awfaq", 53 | name="skip_translation", 54 | field=models.BooleanField(default=False), 55 | ), 56 | migrations.AddField( 57 | model_name="awfeature", 58 | name="skip_translation", 59 | field=models.BooleanField(default=False), 60 | ), 61 | migrations.AddField( 62 | model_name="awfeature2", 63 | name="skip_translation", 64 | field=models.BooleanField(default=False), 65 | ), 66 | migrations.AddField( 67 | model_name="awfeature3", 68 | name="skip_translation", 69 | field=models.BooleanField(default=False), 70 | ), 71 | migrations.AddField( 72 | model_name="awfooter", 73 | name="skip_translation", 74 | field=models.BooleanField(default=False), 75 | ), 76 | migrations.AddField( 77 | model_name="awfooterlink", 78 | name="skip_translation", 79 | field=models.BooleanField(default=False), 80 | ), 81 | migrations.AddField( 82 | model_name="awfooterlinkitem", 83 | name="skip_translation", 84 | field=models.BooleanField(default=False), 85 | ), 86 | migrations.AddField( 87 | model_name="awheader", 88 | name="skip_translation", 89 | field=models.BooleanField(default=False), 90 | ), 91 | migrations.AddField( 92 | model_name="awheaderlink", 93 | name="skip_translation", 94 | field=models.BooleanField(default=False), 95 | ), 96 | migrations.AddField( 97 | model_name="awhero", 98 | name="skip_translation", 99 | field=models.BooleanField(default=False), 100 | ), 101 | migrations.AddField( 102 | model_name="awherotext", 103 | name="skip_translation", 104 | field=models.BooleanField(default=False), 105 | ), 106 | migrations.AddField( 107 | model_name="awimage", 108 | name="skip_translation", 109 | field=models.BooleanField(default=False), 110 | ), 111 | migrations.AddField( 112 | model_name="awinput", 113 | name="skip_translation", 114 | field=models.BooleanField(default=False), 115 | ), 116 | migrations.AddField( 117 | model_name="awitem", 118 | name="skip_translation", 119 | field=models.BooleanField(default=False), 120 | ), 121 | migrations.AddField( 122 | model_name="awpriceitem", 123 | name="skip_translation", 124 | field=models.BooleanField(default=False), 125 | ), 126 | migrations.AddField( 127 | model_name="awpricing", 128 | name="skip_translation", 129 | field=models.BooleanField(default=False), 130 | ), 131 | migrations.AddField( 132 | model_name="awstat", 133 | name="skip_translation", 134 | field=models.BooleanField(default=False), 135 | ), 136 | migrations.AddField( 137 | model_name="awstatitem", 138 | name="skip_translation", 139 | field=models.BooleanField(default=False), 140 | ), 141 | migrations.AddField( 142 | model_name="awstep", 143 | name="skip_translation", 144 | field=models.BooleanField(default=False), 145 | ), 146 | migrations.AddField( 147 | model_name="awstep2", 148 | name="skip_translation", 149 | field=models.BooleanField(default=False), 150 | ), 151 | migrations.AddField( 152 | model_name="awtestimonial", 153 | name="skip_translation", 154 | field=models.BooleanField(default=False), 155 | ), 156 | migrations.AddField( 157 | model_name="awtestimonialitem", 158 | name="skip_translation", 159 | field=models.BooleanField(default=False), 160 | ), 161 | migrations.AddField( 162 | model_name="awtextarea", 163 | name="skip_translation", 164 | field=models.BooleanField(default=False), 165 | ), 166 | ] 167 | --------------------------------------------------------------------------------