diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 56eeec2..a73c869 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,32 +1,27 @@ name: Upload Python Package - on: push: branches: - - master - + - release permissions: contents: read - jobs: deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 22d3d29..245a949 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.vscode # Spyder project settings .spyderproject @@ -127,8 +128,13 @@ dmypy.json # Pyre type checker .pyre/ +dist -# Custom -dummy -dummyapp -manage.py +# demo project +/demoproject +/manage.py +/core + +# project specific +/test.py +/temp diff --git a/LICENSE b/LICENSE index 6dab25e..9b687d8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,4 @@ -MIT License - -Copyright (c) 2023 Sandbox +Copyright (c) 2024 Pradish Bijukchhe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c1a7121 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include README.md diff --git a/README.md b/README.md index 8d5abd1..3937fb0 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,67 @@ # django-form-action -Django action/button with an intermediate page to parse data from a form +Django action with an intermediate page to parse data from a form ## Installation -Just install the pakage from PyPI +You can install the package via pip: ``` pip install django-form-action ``` +## Demo + +![Step 1](docs/step1.png) +![Step 2](docs/step2.png) +![Step 3](docs/step3.png) + ## Usage -Django admin action with form -![Demo Form Action](https://raw.githubusercontent.com/sandbox-pokhara/django-form-action/master/demo/form-action.gif) +Example usage showing an action in UserAdmin which has an intermediate form that parses data on how to perform that action. ```python +from typing import Any + from django.contrib import admin from django.contrib import messages +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.db.models import QuerySet from django.forms import CharField from django.forms import Form +from django.http import HttpRequest -from dummyapp.models import Fruit -from form_action import form_action - - -class MyForm(Form): - message = CharField() - - -@form_action(MyForm, description="Do some task") -def my_action(modeladmin, request, queryset, form): - msg = form.cleaned_data["message"] - messages.add_message(request, messages.INFO, f"Got message: {msg}") - +from django_form_action import form_action -@admin.register(Fruit) -class MyModelAdmin(admin.ModelAdmin): - actions = [my_action] -``` - -Or use it as an extra button with form -![Demo Extra Button](https://raw.githubusercontent.com/sandbox-pokhara/django-form-action/master/demo/extra-button.gif) +admin.site.unregister(User) -```python -from django.contrib import admin -from django.forms import CharField -from django.forms import Form -from django.http.response import HttpResponse -from dummyapp.models import Fruit -from form_action.decorators import extra_button -from form_action.mixins import ExtraButtonMixin +class ChangeFirstName(Form): + first_name = CharField() -class MyForm(Form): - message = CharField() +@form_action(ChangeFirstName, "Change selected users' first name") +def change_first_name( + modeladmin: Any, + request: HttpRequest, + queryset: QuerySet[User], + form: ChangeFirstName, +): + queryset.update(first_name=form.cleaned_data["first_name"]) + messages.add_message( + request, + messages.INFO, + "Successfully changed the first name of selected users.", + ) -@extra_button("Test Button", MyForm) -def test(request, form): - msg = form.cleaned_data["message"] - return HttpResponse(f"Got message: {msg}") +@admin.register(User) +class CustomUserAdmin(UserAdmin): + actions = [change_first_name] +``` -@admin.register(Fruit) -class MyModelAdmin(ExtraButtonMixin, admin.ModelAdmin): - extra_buttons = [test] +## License -``` +This project is licensed under the terms of the MIT license. diff --git a/demo/extra-button.gif b/demo/extra-button.gif deleted file mode 100644 index c4b6b42..0000000 Binary files a/demo/extra-button.gif and /dev/null differ diff --git a/demo/form-action.gif b/demo/form-action.gif deleted file mode 100644 index 9980ed6..0000000 Binary files a/demo/form-action.gif and /dev/null differ diff --git a/django_form_action/__init__.py b/django_form_action/__init__.py new file mode 100644 index 0000000..f33e478 --- /dev/null +++ b/django_form_action/__init__.py @@ -0,0 +1,4 @@ +from django_form_action.decorators import form_action + +__version__ = "2.0.0" +__all__ = ["form_action"] diff --git a/form_action/decorators.py b/django_form_action/decorators.py similarity index 62% rename from form_action/decorators.py rename to django_form_action/decorators.py index 2eaded1..163048a 100644 --- a/form_action/decorators.py +++ b/django_form_action/decorators.py @@ -1,9 +1,20 @@ +from functools import wraps +from typing import Any +from typing import Callable +from typing import Type +from typing import TypeVar +from typing import cast + from django.contrib import admin +from django.db.models import QuerySet +from django.forms import Form +from django.http import HttpRequest from django.http import HttpResponse from django.template import RequestContext from django.template import Template -template = Template(""" +template = Template( + """ {% extends "admin/base_site.html" %} {% load admin_urls static l10n %} {% block extrastyle %} @@ -36,10 +47,17 @@ {% endblock %} -""") +""" +) -def render_form(request, form, title, action="", qs=None): +def render_form( + request: HttpRequest, + form: Form, + title: str, + action: str = "", + qs: Any = None, +): context = { "site_header": admin.site.site_header, "site_title": admin.site.site_title, @@ -53,48 +71,39 @@ def render_form(request, form, title, action="", qs=None): return HttpResponse(template.render(context)) -def form_action(form, description): - def decorator(func): - def wrapper(modeladmin, request, queryset): - action = request.POST["action"] +F = TypeVar("F", bound=Form) + + +def form_action(form_cls: Type[F], description: str): + def decorator( + func: Callable[ + [Any, HttpRequest, QuerySet[Any], F], + HttpResponse | None, + ], + ) -> Callable[[Any, HttpRequest, QuerySet[Any]], HttpResponse | None]: + @wraps(func) + def wrapper( + modeladmin: Any, request: HttpRequest, queryset: QuerySet[Any] + ) -> HttpResponse | None: + action = cast(str, request.POST["action"]) if request.POST.get("submit") is not None: - my_form = form(request.POST, request.FILES) + my_form = form_cls(request.POST, request.FILES) if my_form.is_valid(): # sucess return func(modeladmin, request, queryset, my_form) # show form with errors - return render_form(request, my_form, description, action, queryset) + return render_form( + request, my_form, description, action, queryset + ) else: # show an empty form - return render_form(request, form(), description, action, queryset) + return render_form( + request, form_cls(), description, action, queryset + ) - wrapper.short_description = description + wrapper.short_description = description # type:ignore # required because django requires unique name for action names wrapper.__name__ = func.__name__ return wrapper return decorator - - -def extra_button(title, form=None): - def decorator(func): - def wrapper(request): - if form is None: - return func(request) - if request.POST.get("submit") is not None: - my_form = form(request.POST, request.FILES) - if my_form.is_valid(): - # success - return func(request, my_form) - # show form with errors - return render_form(request, my_form, title) - else: - # show an empty form - return render_form(request, form(), title) - - wrapper.title = title - wrapper.name = func.__name__ - wrapper.__name__ = func.__name__ - return wrapper - - return decorator diff --git a/django_form_action/py.typed b/django_form_action/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/step1.png b/docs/step1.png new file mode 100644 index 0000000..a453ca4 Binary files /dev/null and b/docs/step1.png differ diff --git a/docs/step2.png b/docs/step2.png new file mode 100644 index 0000000..b945686 Binary files /dev/null and b/docs/step2.png differ diff --git a/docs/step3.png b/docs/step3.png new file mode 100644 index 0000000..899a3f9 Binary files /dev/null and b/docs/step3.png differ diff --git a/form_action/__init__.py b/form_action/__init__.py deleted file mode 100644 index 33a36ae..0000000 --- a/form_action/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .decorators import extra_button -from .decorators import form_action -from .mixins import ExtraButtonMixin diff --git a/form_action/mixins.py b/form_action/mixins.py deleted file mode 100644 index f45e2fc..0000000 --- a/form_action/mixins.py +++ /dev/null @@ -1,47 +0,0 @@ -from urllib.parse import urljoin - -from django.template import engines -from django.urls import path - -template = engines["django"].from_string(""" -{% extends "admin/change_list.html" %} -{% block object-tools-items %} - {% for button in extra_buttons %} -
  • - - {{ button.title }} - -
  • - {% endfor %} - {{ block.super }} -{% endblock %} -""") - - -class ExtraButtonMixin: - change_list_template = template - - def changelist_view(self, request, extra_context=None): - extra_context = extra_context or {} - extra_buttons = getattr(self, "extra_buttons", []) - extra_buttons = [ - { - "name": b.name, - "title": b.title, - "url": urljoin(request.path, f"actions/{b.name}/"), - } - for b in extra_buttons - ] - extra_context.update({"extra_buttons": extra_buttons}) - return super().changelist_view(request, extra_context) - - def get_urls(self): - urls = super().get_urls() - return self.get_extra_urls() + urls - - def get_extra_urls(self): - extra_buttons = getattr(self, "extra_buttons", []) - return [ - path(f"actions/{func.name}/", self.admin_site.admin_view(func)) - for func in extra_buttons - ] diff --git a/pyproject.toml b/pyproject.toml index f158299..db2b46e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,41 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel", -] +requires = ["setuptools>=70.0.0"] build-backend = "setuptools.build_meta" +[project] +name = "django-form-action" +requires-python = ">=3" +authors = [{ name = "Pradish Bijukchhe", email = "pradish@sandbox.com.np" }] +description = "Django action with an intermediate page to parse data from a form" +readme = "README.md" +license = { file = "LICENSE" } +keywords = [] +classifiers = ["Programming Language :: Python :: 3"] +dynamic = ["version", "dependencies"] + + +[project.urls] +Homepage = "https://github.com/sandbox-pokhara/django-form-action" +Issues = "https://github.com/sandbox-pokhara/django-form-action/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-dir] +"django_form_action" = "django_form_action" + +[tool.setuptools.dynamic] +version = { attr = "django_form_action.__version__" } +dependencies = { file = "requirements.txt" } + +[tool.isort] +line_length = 79 +force_single_line = true + [tool.black] +line-length = 79 preview = true + +[tool.pyright] +include = ["django_form_action", "test.py"] +typeCheckingMode = "strict" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d3e4ba5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +django diff --git a/setup.py b/setup.py deleted file mode 100644 index 0cc3537..0000000 --- a/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -import setuptools - -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -setuptools.setup( - name="django-form-action", - version="1.0.5", - author="Pradish Bijukchhe", - author_email="pradishbijukchhe@gmail.com", - description="Django action to add an intermediate page to parse form data", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/sandbox-pokhara/django-form-action", - project_urls={ - "Bug Tracker": "https://github.com/sandbox-pokhara/django-form-action/issues", - }, - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: Microsoft :: Windows", - ], - include_package_data=True, - package_dir={"form_action": "form_action"}, - python_requires=">=3", - install_requires=["django"], -)