diff --git a/.gitignore b/.gitignore index 00e4d1b..5daadca 100644 --- a/.gitignore +++ b/.gitignore @@ -99,7 +99,7 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 97e92ca..9bcb045 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,81 +1,84 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks default_language_version: - python: python3.12 + python: python3.12 default_stages: [pre-commit, pre-push] repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - - id: check-toml - - id: check-xml - - id: check-yaml - - id: check-added-large-files - - id: detect-aws-credentials - args: - - --allow-missing-credentials - - id: end-of-file-fixer - - id: mixed-line-ending - - id: pretty-format-json - args: - - --autofix - - id: trailing-whitespace - exclude_types: - - javascript - - markdown -- repo: https://github.com/PyCQA/flake8 - rev: 7.2.0 - hooks: - - id: flake8 - additional_dependencies: - - flake8-bugbear - - flake8-noqa - args: - - --max-line-length=120 - - --max-complexity=18 -- repo: https://github.com/psf/black - rev: 25.1.0 - hooks: - - id: black - language_version: python3.12 - args: - - --line-length=120 -- repo: https://github.com/PyCQA/bandit - rev: '1.8.3' - hooks: - - id: bandit -- repo: https://github.com/PyCQA/isort - rev: '6.0.1' - hooks: - - id: isort -- repo: https://github.com/dosisod/refurb - rev: v2.0.0 - hooks: - - id: refurb - additional_dependencies: - - boto3 - - django-constance - - django-cors-headers - - django-environ - - django-extensions - - django-filter - - django-simple-history - - django-stubs[compatible-mypy] - - drf-spectacular - - drf-standardized-errors - - djangorestframework-stubs[compatible-mypy] - - zappa-django-utils -- repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.6.12 - hooks: - - id: uv-lock -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.4 - hooks: - - id: ruff - args: - - --fix - - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-xml + - id: check-yaml + - id: check-added-large-files + - id: detect-aws-credentials + args: + - --allow-missing-credentials + - id: end-of-file-fixer + - id: mixed-line-ending + - id: pretty-format-json + args: + - --autofix + - id: trailing-whitespace + exclude_types: + - javascript + - markdown + - repo: https://github.com/PyCQA/flake8 + rev: 7.2.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-noqa + args: + - --max-line-length=120 + - --max-complexity=18 + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3.12 + args: + - --line-length=120 + - repo: https://github.com/PyCQA/bandit + rev: "1.8.3" + hooks: + - id: bandit + args: + - -c + - bandit.yaml + - repo: https://github.com/PyCQA/isort + rev: "6.0.1" + hooks: + - id: isort + - repo: https://github.com/dosisod/refurb + rev: v2.0.0 + hooks: + - id: refurb + additional_dependencies: + - boto3 + - django-constance + - django-cors-headers + - django-environ + - django-extensions + - django-filter + - django-simple-history + - django-stubs[compatible-mypy] + - drf-spectacular + - drf-standardized-errors + - djangorestframework-stubs[compatible-mypy] + - zappa-django-utils + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.6.12 + hooks: + - id: uv-lock + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.4 + hooks: + - id: ruff + args: + - --fix + - id: ruff-format # - repo: https://github.com/pre-commit/mirrors-mypy # rev: 'v1.15.0' # hooks: diff --git a/app/cms/apps.py b/app/cms/apps.py index 6e90a36..0dc102b 100644 --- a/app/cms/apps.py +++ b/app/cms/apps.py @@ -1,5 +1,17 @@ +import importlib + from django.apps import AppConfig class CmsConfig(AppConfig): name = "cms" + + def ready(self): + importlib.import_module("cms.translation") + + from cms.models import Page, Section, Sitemap + from simple_history import register + + register(Page) + register(Sitemap) + register(Section) diff --git a/app/cms/migrations/0002_historicalpage_historicalsection_historicalsitemap.py b/app/cms/migrations/0002_historicalpage_historicalsection_historicalsitemap.py new file mode 100644 index 0000000..d60549e --- /dev/null +++ b/app/cms/migrations/0002_historicalpage_historicalsection_historicalsitemap.py @@ -0,0 +1,191 @@ +# Generated by Django 5.2 on 2025-04-21 13:38 + +import uuid + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="HistoricalPage", + fields=[ + ( + "id", + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ("is_active", models.BooleanField(default=False)), + ("css", models.TextField(blank=True, default=None, null=True)), + ("title", models.CharField(max_length=256)), + ("title_ko", models.CharField(max_length=256, null=True)), + ("title_en", models.CharField(max_length=256, null=True)), + ("subtitle", models.CharField(max_length=512)), + ("subtitle_ko", models.CharField(max_length=512, null=True)), + ("subtitle_en", models.CharField(max_length=512, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical page", + "verbose_name_plural": "historical pages", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalSection", + fields=[ + ( + "id", + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ("order", models.IntegerField(default=0)), + ("css", models.TextField(blank=True, default=None, null=True)), + ( + "body", + models.TextField(help_text="Content of the page, Written in markdown format"), + ), + ( + "body_ko", + models.TextField( + help_text="Content of the page, Written in markdown format", + null=True, + ), + ), + ( + "body_en", + models.TextField( + help_text="Content of the page, Written in markdown format", + null=True, + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "page", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.page", + ), + ), + ], + options={ + "verbose_name": "historical section", + "verbose_name_plural": "historical sections", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalSitemap", + fields=[ + ( + "id", + models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + ("name", models.CharField(max_length=256)), + ("name_ko", models.CharField(max_length=256, null=True)), + ("name_en", models.CharField(max_length=256, null=True)), + ("order", models.IntegerField(default=0)), + ("display_start_at", models.DateTimeField(blank=True, null=True)), + ("display_end_at", models.DateTimeField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "page", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.page", + ), + ), + ( + "parent_sitemap", + models.ForeignKey( + blank=True, + db_constraint=False, + default=None, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.sitemap", + ), + ), + ], + options={ + "verbose_name": "historical sitemap", + "verbose_name_plural": "historical sitemaps", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/app/cms/models.py b/app/cms/models.py index 83fb295..c10914f 100644 --- a/app/cms/models.py +++ b/app/cms/models.py @@ -1,7 +1,6 @@ import uuid from django.db import models -from simple_history.models import HistoricalRecords class Page(models.Model): @@ -10,7 +9,7 @@ class Page(models.Model): css = models.TextField(null=True, blank=True, default=None) title = models.CharField(max_length=256) subtitle = models.CharField(max_length=512) - history = HistoricalRecords() + # history = HistoricalRecords() def __str__(self): return str(self.title) @@ -26,7 +25,7 @@ class Sitemap(models.Model): page = models.ForeignKey(Page, on_delete=models.PROTECT) display_start_at = models.DateTimeField(null=True, blank=True) display_end_at = models.DateTimeField(null=True, blank=True) - history = HistoricalRecords() + # history = HistoricalRecords() def __str__(self): return str(self.name) @@ -38,7 +37,7 @@ class Section(models.Model): order = models.IntegerField(default=0) css = models.TextField(null=True, blank=True, default=None) body = models.TextField(help_text="Content of the page, Written in markdown format") - history = HistoricalRecords() + # history = HistoricalRecords() def __str__(self): return f"Section {self.order} of {self.page}" diff --git a/app/cms/serializers.py b/app/cms/serializers.py new file mode 100644 index 0000000..ec21529 --- /dev/null +++ b/app/cms/serializers.py @@ -0,0 +1,14 @@ +from cms.models import Page, Sitemap +from rest_framework import serializers + + +class SitemapSerializer(serializers.ModelSerializer): + class Meta: + model = Sitemap + fields = "__all__" + + +class PageSerializer(serializers.ModelSerializer): + class Meta: + model = Page + fields = "__all__" diff --git a/app/cms/test/conftest.py b/app/cms/test/conftest.py new file mode 100644 index 0000000..9f56a32 --- /dev/null +++ b/app/cms/test/conftest.py @@ -0,0 +1,23 @@ +import pytest +from cms.models import Page, Sitemap +from rest_framework.test import APIClient + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def create_page(): + page = Page.objects.create( + title="제목", + subtitle="부제목", + ) + return page + + +@pytest.fixture +def create_sitemap(create_page): + sitemap = Sitemap.objects.create(page=create_page) + return sitemap diff --git a/app/cms/test/page_api_test.py b/app/cms/test/page_api_test.py new file mode 100644 index 0000000..e652958 --- /dev/null +++ b/app/cms/test/page_api_test.py @@ -0,0 +1,18 @@ +import http + +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_list_view(api_client, create_page): + url = reverse("v1:cms-page-list") + response = api_client.get(url) + assert response.status_code == http.HTTPStatus.OK + + +@pytest.mark.django_db +def test_retrieve_view(api_client, create_page): + url = reverse("v1:cms-page-detail", kwargs={"pk": create_page.id}) + response = api_client.get(url) + assert response.status_code == http.HTTPStatus.OK diff --git a/app/cms/test/sitemap_api_test.py b/app/cms/test/sitemap_api_test.py new file mode 100644 index 0000000..3566afe --- /dev/null +++ b/app/cms/test/sitemap_api_test.py @@ -0,0 +1,20 @@ +import http + +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_list_view(api_client, create_sitemap): + url = reverse("v1:cms-sitemap-list") + response = api_client.get(url) + if response.status_code != http.HTTPStatus.OK: + raise Exception("cms Sitemap list API raised error") + + +@pytest.mark.django_db +def test_retrieve_view(api_client, create_sitemap): + url = reverse("v1:cms-sitemap-detail", kwargs={"pk": create_sitemap.id}) + response = api_client.get(url) + if response.status_code != http.HTTPStatus.OK: + raise Exception("cms Sitemap retrieve API raised error") diff --git a/app/cms/urls.py b/app/cms/urls.py new file mode 100644 index 0000000..08930ac --- /dev/null +++ b/app/cms/urls.py @@ -0,0 +1,26 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from cms import views +from django.urls import include, path +from rest_framework import routers + +cms_router = routers.SimpleRouter() +cms_router.register("sitemap", views.SitemapListRetrieveViewSet, basename="cms-sitemap") +cms_router.register("page", views.PageListRetrieveViewSet, basename="cms-page") + +urlpatterns = [path("", include(cms_router.urls))] diff --git a/app/cms/views.py b/app/cms/views.py new file mode 100644 index 0000000..c48956a --- /dev/null +++ b/app/cms/views.py @@ -0,0 +1,13 @@ +from cms.models import Page, Sitemap +from cms.serializers import PageSerializer, SitemapSerializer +from rest_framework.viewsets import ReadOnlyModelViewSet + + +class SitemapListRetrieveViewSet(ReadOnlyModelViewSet): + serializer_class = SitemapSerializer + queryset = Sitemap.objects.all() + + +class PageListRetrieveViewSet(ReadOnlyModelViewSet): + serializer_class = PageSerializer + queryset = Page.objects.all() diff --git a/app/core/urls.py b/app/core/urls.py index 715103c..942f940 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -22,7 +22,8 @@ from django.urls import include, path, re_path, resolvers from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView -v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [] # type: ignore[assignment] +# type: ignore[assignment] +v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [path("cms/", include("cms.urls"))] urlpatterns = [ # Health Check diff --git a/bandit.yaml b/bandit.yaml new file mode 100644 index 0000000..25eb09c --- /dev/null +++ b/bandit.yaml @@ -0,0 +1,4 @@ +assert_used: + skips: + - "test*.py" + - "*_test.py" diff --git a/pyproject.toml b/pyproject.toml index 09fa2fe..21e80f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "setuptools>=78.1.0", "zappa>=0.59.0", "zappa-django-utils>=0.4.1", + "boto3 (>=1.37.37,<2.0.0)", ] [dependency-groups] @@ -51,6 +52,10 @@ deployment = [ package = false default-groups = ["dev", "deployment"] +[tool.poetry.group.dev.dependencies] +pytest = ">=8.3.5,<9.0.0" +pytest-django = "^4.11.1" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/uv.lock b/uv.lock index 0f0f4d6..a35e44a 100644 --- a/uv.lock +++ b/uv.lock @@ -106,6 +106,7 @@ source = { virtual = "." } dependencies = [ { name = "argon2-cffi" }, { name = "awslambdaric" }, + { name = "boto3" }, { name = "django" }, { name = "django-constance" }, { name = "django-cors-headers" }, @@ -150,6 +151,7 @@ dev = [ requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1.0" }, { name = "awslambdaric", specifier = ">=3.0.2" }, + { name = "boto3", specifier = ">=1.37.37,<2.0.0" }, { name = "django", specifier = ">=5.2" }, { name = "django-constance", specifier = ">=4.3.2" }, { name = "django-cors-headers", specifier = ">=4.7.0" }, @@ -192,16 +194,16 @@ dev = [ [[package]] name = "boto3" -version = "1.37.28" +version = "1.37.37" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/f5/dd50ed0a20019fa38c22797718c80d38e8b75b5e97c971a908c638e819aa/boto3-1.37.28.tar.gz", hash = "sha256:09ee85ba70a88286bba0d1bf5f0460a4b3bde52d162216accfe637b8bfac351b", size = 111385 } +sdist = { url = "https://files.pythonhosted.org/packages/82/8c/2ca661db6c9e591d9dc46149b43a91385283c852436ccba62e199643e196/boto3-1.37.37.tar.gz", hash = "sha256:752d31105a45e3e01c8c68471db14ae439990b75a35e72b591ca528e2575b28f", size = 111666 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/76/2723dede8c69d04e37f0897e9c05b597b6906df3d4c80186e39a0bc1b914/boto3-1.37.28-py3-none-any.whl", hash = "sha256:e584d9d33808633e73af3d962e22cf2cea91a38bc5a17577bb25618f8ded504f", size = 139562 }, + { url = "https://files.pythonhosted.org/packages/e4/5f/032d93e74949222ffbfbc3270f29a3ee423fe648de8a31c49cce0cbb0a09/boto3-1.37.37-py3-none-any.whl", hash = "sha256:d125cb11e22817f7a2581bade4bf7b75247b401888890239ceb5d3e902ccaf38", size = 139917 }, ] [[package]] @@ -236,16 +238,16 @@ ssm = [ [[package]] name = "botocore" -version = "1.37.28" +version = "1.37.37" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/90/557082a8379ece106b37eb00766efc7a32cbfcdaa0d1d78f38f99eefd218/botocore-1.37.28.tar.gz", hash = "sha256:69ea327f70f0607d174c4c2b1dcc87327b9c48e413c9d322179172b614b28e03", size = 13799915 } +sdist = { url = "https://files.pythonhosted.org/packages/96/d0/70969515e3ae8ff0fcccf22827d5d131bc7b8729331127415cf8f2861d63/botocore-1.37.37.tar.gz", hash = "sha256:3eadde6fed95c4cb469cc39d1c3558528b7fa76d23e7e16d4bddc77250431a64", size = 13828530 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/c3/29ffcb4c90492bcfcff1b7e5ddb5529846acc0e627569432db9842c47675/botocore-1.37.28-py3-none-any.whl", hash = "sha256:c26b645d7b125bf42ffc1671b862b47500ee658e3a1c95d2438cb689fc85df15", size = 13467675 }, + { url = "https://files.pythonhosted.org/packages/fe/17/602915b29cb695e1e66f65e33b1026f1534e49975d99ea4e32e58d963542/botocore-1.37.37-py3-none-any.whl", hash = "sha256:eb730ff978f47c02f0c8ed07bccdc0db6d8fa098ed32ac31bee1da0e9be480d1", size = 13495584 }, ] [[package]]