├── .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 |
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 | | {% trans 'Date/time' %} |
16 | {% trans 'User' %} |
17 | {% trans 'Action' %} |
18 | {% trans 'Published' %} |
19 |
20 |
21 |
22 | {% for action in action_list %}
23 |
24 | | {{action.revision.date_created|date:"DATETIME_FORMAT"}} |
25 |
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 | |
33 | {{action.revision.get_comment|linebreaksbr|default:""}} |
34 | {% if action.revision.id == object.published_version.revision_id %}✅{% else %}-{% endif %} |
35 |
36 | {% endfor %}
37 |
38 |
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 |
--------------------------------------------------------------------------------