From 788eca2eb1024685d5d5d2a7be52b19612b42bb9 Mon Sep 17 00:00:00 2001 From: Lukas Vinclav Date: Mon, 18 Mar 2024 07:59:45 +0100 Subject: [PATCH] feat: vs code devcontainer (#319) --- .devcontainer/Dockerfile | 15 ++ .devcontainer/backend.env | 1 + .devcontainer/devcontainer.json | 32 +++++ .devcontainer/docker-compose.yml | 12 ++ .gitignore | 1 + README.md | 19 +++ pyproject.toml | 29 ++-- tests/server/example/__init__.py | 0 tests/server/example/admin.py | 22 +++ .../server/example/migrations/0001_initial.py | 129 ++++++++++++++++++ tests/server/example/migrations/__init__.py | 0 tests/server/example/models.py | 5 + tests/server/example/settings.py | 79 +++++++++++ tests/server/example/urls.py | 6 + tests/server/manage.py | 21 +++ tests/settings.py | 12 -- tests/test_site_branding.py | 10 +- 17 files changed, 357 insertions(+), 36 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/backend.env create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 tests/server/example/__init__.py create mode 100644 tests/server/example/admin.py create mode 100644 tests/server/example/migrations/0001_initial.py create mode 100644 tests/server/example/migrations/__init__.py create mode 100644 tests/server/example/models.py create mode 100644 tests/server/example/settings.py create mode 100644 tests/server/example/urls.py create mode 100755 tests/server/manage.py delete mode 100644 tests/settings.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..658f9e57 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/devcontainers/python:3.12 + +ENV PYTHONUNBUFFERED 1 + +RUN pip install poetry + +RUN poetry config virtualenvs.create false + +COPY poetry.lock pyproject.toml /app/ + +WORKDIR /app + +RUN poetry install --no-root + +EXPOSE 8000 diff --git a/.devcontainer/backend.env b/.devcontainer/backend.env new file mode 100644 index 00000000..293e7ef9 --- /dev/null +++ b/.devcontainer/backend.env @@ -0,0 +1 @@ +SECRET_KEY=changeme diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..a12384d3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "service": "unfold", + "name": "Unfold", + "dockerComposeFile": ["docker-compose.yml"], + "overrideCommand": true, + "workspaceFolder": "/app", + "forwardPorts": [8000], + "postCreateCommand": "python manage.py migrate", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "latest" + } + }, + "customizations": { + "vscode": { + "settings": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "always" + } + }, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "batisteo.vscode-django", + "tamasfe.even-better-toml", + "charliermarsh.ruff", + "bradlc.vscode-tailwindcss" + ] + } + } +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..ee5d7c86 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.4" + +services: + unfold: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + volumes: + - ..:/app + - ../src/unfold:/usr/local/lib/python3.12/site-packages/unfold + env_file: + - backend.env diff --git a/.gitignore b/.gitignore index e4b4061d..b18cb1f5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__ venv node_modules dist/ +*.sqlite3 diff --git a/README.md b/README.md index b34321d1..05f344cf 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Did you decide to start using Unfold but you don't have time to make the switch - **Colors:** possibility to override default color scheme - **Third party packages:** default support for multiple popular applications - **Environment label**: distinguish between environments by displaying a label +- **VS Code**: project configuration and development container is included ## Table of contents @@ -70,6 +71,9 @@ Did you decide to start using Unfold but you don't have time to make the switch - [Pre-commit](#pre-commit) - [Poetry configuration](#poetry-configuration) - [Compiling Tailwind](#compiling-tailwind) + - [Using VS Code with containers](#using-vs-code-with-containers) + - [Development server](#development-server) + - [Compiling Tailwind in devcontainer](#compiling-tailwind-in-devcontainer) - [Credits](#credits) ## Installation @@ -965,6 +969,7 @@ Before adding any source code, it is recommended to have pre-commit installed on pip install pre-commit pre-commit install pre-commit install --hook-type commit-msg +pre-commit run --all-files # Check if everything is okay ``` ### Poetry configuration @@ -992,6 +997,20 @@ Some components like datepickers, calendars or selectors in admin was not possib **Note:** most of the custom styles located in style.css are created via `@apply some-tailwind-class;` as is not possible to manually add CSS class to element which are for example created via jQuery. +### Using VS Code with containers + +Unfold already contains prepared support for VS Code development. After cloning the project locally, open the main folder in VS Code (in terminal `code .`). Immediately, you would see a message from VS Code **Folder contains a Dev Container configuration file. Reopen folder to develop in a container** which will inform you that the support for containers is prepared. Confirm the message by clicking on **Reopen in Container**. If the message is not there, you can still manually open the project in a container by running the command **Dev Containers: Reopen in Container**. + +#### Development server + +Now the VS Code will build an image and install Python dependencies. After successful installation is completed, VS Code will spin a container and from now it is possible to directly develop in the container. Unfold contains an example development application with the basic Unfold configuration available under `tests/server`. Run `python manage.py runserver` within a `tests/server` folder to start a development Django server. Note that you have to run the command from VS Code terminal which is already connected to the running container. + +**Note:** this is not a production ready server. Use it just for running tests or developing features & fixes. + +#### Compiling Tailwind in devcontainer + +The container has already a node preinstalled so it is possible to compile a new CSS. Open the terminal and run `npm install` which will install all dependencies and will create `node_modules` folder. Now, you can run npm commands for Tailwind as described in the previous chapter. + ## Credits - [TailwindCSS](https://tailwindcss.com/) - CSS framework diff --git a/pyproject.toml b/pyproject.toml index ebc86950..e50ec1a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,8 @@ readme = "README.md" authors = [] homepage = "https://unfoldadmin.com" repository = "https://github.com/unfoldadmin/django-unfold" -packages = [ - { include = "unfold", from = "src" }, -] -keywords = [ - "django", - "admin", - "tailwind", - "theme", -] +packages = [{ include = "unfold", from = "src" }] +keywords = ["django", "admin", "tailwind", "theme"] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3.8", @@ -39,7 +32,6 @@ pytest-django = "^4.5.2" tox = "^4.5.2" - [tool.poetry.group.dev.dependencies] python-semantic-release = "^8.7.0" @@ -56,23 +48,19 @@ select = [ "UP", # pyupgrade ] ignore = [ - "E501", # line too long, handled by black - "B905", # zip() without strict=True - "C901", # too complex + "E501", # line too long, handled by black + "B905", # zip() without strict=True + "C901", # too complex ] [tool.semantic_release] tag_format = "{version}" major_on_zero = true -version_toml = [ - "pyproject.toml:tool.poetry.version" -] +version_toml = ["pyproject.toml:tool.poetry.version"] [tool.semantic_release.changelog] template_dir = ".github/templates" -exclude_commit_patterns = [ - "chore: version bump" -] +exclude_commit_patterns = ["chore: version bump"] [tool.semantic_release.changelog.environment] trim_blocks = true @@ -85,8 +73,9 @@ upload_to_vcs_release = false addopts = """\ --strict-config --strict-markers - --ds=tests.settings + --ds=example.settings """ +pythonpath = "tests/server" django_find_project = false [build-system] diff --git a/tests/server/example/__init__.py b/tests/server/example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/server/example/admin.py b/tests/server/example/admin.py new file mode 100644 index 00000000..0bf8da1c --- /dev/null +++ b/tests/server/example/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import Group +from unfold.admin import ModelAdmin +from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm + +from .models import User + +admin.site.unregister(Group) + + +@admin.register(User) +class UserAdmin(BaseUserAdmin, ModelAdmin): + form = UserChangeForm + add_form = UserCreationForm + change_password_form = AdminPasswordChangeForm + + +@admin.register(Group) +class GroupAdmin(BaseGroupAdmin, ModelAdmin): + pass diff --git a/tests/server/example/migrations/0001_initial.py b/tests/server/example/migrations/0001_initial.py new file mode 100644 index 00000000..9b9e304c --- /dev/null +++ b/tests/server/example/migrations/0001_initial.py @@ -0,0 +1,129 @@ +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/tests/server/example/migrations/__init__.py b/tests/server/example/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/server/example/models.py b/tests/server/example/models.py new file mode 100644 index 00000000..3d305253 --- /dev/null +++ b/tests/server/example/models.py @@ -0,0 +1,5 @@ +from django.contrib.auth.models import AbstractUser + + +class User(AbstractUser): + pass diff --git a/tests/server/example/settings.py b/tests/server/example/settings.py new file mode 100644 index 00000000..23e3c803 --- /dev/null +++ b/tests/server/example/settings.py @@ -0,0 +1,79 @@ +from os import environ +from pathlib import Path + +from django.core.management.utils import get_random_secret_key + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = environ.get("SECRET_KEY", get_random_secret_key()) + +DEBUG = True + +ALLOWED_HOSTS = ["localhost"] + +AUTH_USER_MODEL = "example.User" + +INSTALLED_APPS = [ + "unfold", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "example", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "example.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +STATIC_URL = "static/" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/server/example/urls.py b/tests/server/example/urls.py new file mode 100644 index 00000000..083932c6 --- /dev/null +++ b/tests/server/example/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/tests/server/manage.py b/tests/server/manage.py new file mode 100755 index 00000000..84a24123 --- /dev/null +++ b/tests/server/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") + + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/settings.py b/tests/settings.py deleted file mode 100644 index 9a03ba4f..00000000 --- a/tests/settings.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.core.management.utils import get_random_secret_key - -SECRET_KEY = get_random_secret_key() - -DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} - -INSTALLED_APPS = [ - "django.contrib.auth", - "django.contrib.contenttypes", -] - -USE_TZ = False diff --git a/tests/test_site_branding.py b/tests/test_site_branding.py index afc86471..4a64fe62 100644 --- a/tests/test_site_branding.py +++ b/tests/test_site_branding.py @@ -21,7 +21,7 @@ def test_correct_callback_site_icon(self): request = RequestFactory().get("/rand") request.user = AnonymousUser() context = admin_site.each_context(request) - self.assertEqual(context["site_icon"], "icon.svg") + self.assertEqual(context["site_icon"], static("icon.svg")) @override_settings( UNFOLD={ @@ -55,7 +55,8 @@ def test_correct_mode_site_icon(self): request.user = AnonymousUser() context = admin_site.each_context(request) self.assertDictEqual( - context["site_icon"], {"light": "icon-light.svg", "dark": "icon-dark.svg"} + context["site_icon"], + {"light": static("icon-light.svg"), "dark": static("icon-dark.svg")}, ) @override_settings( @@ -88,7 +89,7 @@ def test_correct_callback_site_logo(self): request = RequestFactory().get("/rand") request.user = AnonymousUser() context = admin_site.each_context(request) - self.assertEqual(context["site_logo"], "logo.svg") + self.assertEqual(context["site_logo"], static("logo.svg")) @override_settings( UNFOLD={ @@ -122,7 +123,8 @@ def test_correct_mode_site_logo(self): request.user = AnonymousUser() context = admin_site.each_context(request) self.assertDictEqual( - context["site_logo"], {"light": "logo-light.svg", "dark": "logo-dark.svg"} + context["site_logo"], + {"light": static("logo-light.svg"), "dark": static("logo-dark.svg")}, ) @override_settings(