From d149f22d768e525967a722a0b3204dee06cffda1 Mon Sep 17 00:00:00 2001 From: Pradish Bijukchhe Date: Fri, 22 Dec 2023 13:22:26 +0545 Subject: [PATCH] feat: initial commit --- .github/workflows/python-publish.yml | 27 ++++++ .gitignore | 136 +++++++++++++++++++++++++++ LICENSE | 19 ++++ MANIFEST.in | 2 + README.md | 60 ++++++++++++ django_form_button/__init__.py | 5 + django_form_button/decorators.py | 117 +++++++++++++++++++++++ django_form_button/mixins.py | 56 +++++++++++ django_form_button/py.typed | 0 docs/changelist.png | Bin 0 -> 1893 bytes docs/form.png | Bin 0 -> 5522 bytes pyproject.toml | 36 +++++++ 12 files changed, 458 insertions(+) create mode 100644 .github/workflows/python-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 django_form_button/__init__.py create mode 100644 django_form_button/decorators.py create mode 100644 django_form_button/mixins.py create mode 100644 django_form_button/py.typed create mode 100644 docs/changelist.png create mode 100644 docs/form.png create mode 100644 pyproject.toml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..ff563de --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,27 @@ +name: Upload Python Package +on: + push: + branches: + - 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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1decb74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,136 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +dist + +# example project +example/ +example_app/ +manage.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96f1555 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 The Python Packaging Authority + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. 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 new file mode 100644 index 0000000..3686955 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# django-form-button + +Django admin extra buttons with form + +## Screenshots + +![changelist](docs/changelist.png) + +![form](docs/form.png) + +## Installation + +You can install the package via pip: + +```bash +pip install django-form-button +``` + +## Usage + +```python +from django.contrib import admin +from django.forms import FileField +from django.forms import Form +from django.http import HttpRequest +from django.http import HttpResponse + +from django_form_button import FormButtonMixin +from django_form_button import form_button + +from .models import Account + + +class UploadForm(Form): + file = FileField() + + +@form_button("Upload Accounts", UploadForm) +def upload_accounts(request: HttpRequest, validated_form: Form): + file = validated_form.cleaned_data["file"] + return HttpResponse(file.name) + + +@admin.register(Account) +class AccountAdmin(FormButtonMixin, admin.ModelAdmin): # type: ignore + form_buttons = [upload_accounts] + +``` + +## License + +This project is licensed under the terms of the MIT license. + +## Contributing + +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. + +## Contact + +If you want to contact me you can reach me at pradishbijukchhe@gmail.com. diff --git a/django_form_button/__init__.py b/django_form_button/__init__.py new file mode 100644 index 0000000..3d75dbb --- /dev/null +++ b/django_form_button/__init__.py @@ -0,0 +1,5 @@ +from django_form_button.decorators import button +from django_form_button.decorators import form_button +from django_form_button.mixins import FormButtonMixin + +__all__ = ["button", "form_button", "FormButtonMixin"] diff --git a/django_form_button/decorators.py b/django_form_button/decorators.py new file mode 100644 index 0000000..aa9cbb0 --- /dev/null +++ b/django_form_button/decorators.py @@ -0,0 +1,117 @@ +from functools import wraps +from typing import Callable +from typing import ParamSpec +from typing import Protocol +from typing import Type +from typing import TypeVar +from typing import cast + +from django.contrib import admin +from django.forms import Form +from django.http import HttpRequest +from django.http import HttpResponse +from django.http.response import HttpResponseBase +from django.template import RequestContext +from django.template import Template + +# https://github.com/microsoft/pylance-release/issues/3777 +P = ParamSpec("P") +R = TypeVar("R", covariant=True) + + +class FuncWithAttrs(Protocol[P, R]): + def __call__(*args: P.args, **kwargs: P.kwargs) -> R: ... + + title: str + name: str + + +def make_func_with_attrs(fn: Callable[P, R]) -> FuncWithAttrs[P, R]: + return cast(FuncWithAttrs[P, R], fn) + + +template = Template(""" +{% extends "admin/base_site.html" %} +{% load admin_urls static l10n %} +{% block extrastyle %} + {{ block.super }} + +{% endblock %} +{% block content %} +
+
+ {% csrf_token %} + {% for obj in queryset.all %}{% endfor %} +
+ {% if form.errors %}

Please correct the errors below.

{% endif %} +
+ {% for field in form %} +
+ {{ field.errors }} + {{ field.label_tag }} {{ field }} + {% if field.help_text %}
{{ field.help_text|safe }}
{% endif %} +
+ {% endfor %} +
+ +
+ + +
+
+
+{% endblock %} +""") + + +def render_form(request: HttpRequest, form: Form, title: str): + context = { + "site_header": admin.site.site_header, + "site_title": admin.site.site_title, + "site_title": admin.site.site_title, + "title": title, + "form": form, + } + context = RequestContext(request, context) + return HttpResponse(template.render(context)) + + +def form_button(title: str, form_cls: Type[Form]): + def decorator(func: Callable[[HttpRequest, Form], HttpResponseBase]): + @make_func_with_attrs + @wraps(func) + def wrapper(request: HttpRequest): + if request.POST.get("submit") is not None: + form = form_cls(request.POST, request.FILES) + if form.is_valid(): + # success + return func(request, form) + # show form with errors + return render_form(request, form, title) + else: + # show an empty form + return render_form(request, form_cls(), title) + + wrapper.title = title + wrapper.name = func.__name__ + wrapper.__name__ = func.__name__ + return wrapper + + return decorator + + +def button(title: str): + def decorator(func: Callable[[HttpRequest], HttpResponseBase]): + @make_func_with_attrs + @wraps(func) + def wrapper(request: HttpRequest): + return func(request) + + wrapper.title = title + wrapper.name = func.__name__ + wrapper.__name__ = func.__name__ + return wrapper + + return decorator diff --git a/django_form_button/mixins.py b/django_form_button/mixins.py new file mode 100644 index 0000000..a2bb4e1 --- /dev/null +++ b/django_form_button/mixins.py @@ -0,0 +1,56 @@ +from typing import Any +from typing import Optional +from urllib.parse import urljoin + +from django.contrib.admin import ModelAdmin +from django.http import HttpRequest +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 form_buttons %} +
  • + + {{ button.title }} + +
  • + {% endfor %} + {{ block.super }} +{% endblock %} +""") + + +class FormButtonMixin(ModelAdmin): # type: ignore + change_list_template = template # type: ignore + + def changelist_view( + self, + request: HttpRequest, + extra_context: Optional[dict[str, Any]] = None, + ): + extra_context = extra_context or {} + form_buttons = getattr(self, "form_buttons", []) + form_buttons = [ + { + "name": b.name, + "title": b.title, + "url": urljoin(request.path, f"actions/{b.name}/"), + } + for b in form_buttons + ] + extra_context.update({"form_buttons": form_buttons}) + super() + 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): + form_buttons = getattr(self, "form_buttons", []) + return [ + path(f"actions/{func.name}/", self.admin_site.admin_view(func)) + for func in form_buttons + ] diff --git a/django_form_button/py.typed b/django_form_button/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/changelist.png b/docs/changelist.png new file mode 100644 index 0000000000000000000000000000000000000000..7bb54c761c4ce54105c3ca0bbbaa0c7510b1b745 GIT binary patch literal 1893 zcmbuAX*AS}8^?cil_hSjtt`=k#*%D<46;Y|nXzX|u9z{1WDE_5xQvQ~T*}rRV|S6g zG1=0jEXm9;_Oq&P@5{w&W8&>*<;??j_zZ#!r?jyO(+%wrui);fOn#J6(H_{IMICBf zWQ$690ev%PJ|Ohb|G)UGD|NXBK~4&pY8P&5YU(;*Zu}9sF)6I4rnTHqK$mA zYiD?3c`LUsR306>18twa*PL456=j0!$!$J-@?~-p0>RmnN^FAiv}i}Gi#ZxhWL_ZK z_lM?$QJL_M=9IG9gMIzoD`UoxY^D1qi-&3h8jLegHpVeFHNsi6HZX+tLC^f8n% z@B2muB+YLJwYR7Zt8sJw{JVNdS?TW04L$pSK<6j&j$(55u1J@jsCepg%|9q?=_vH$ z&V|wZYbeCIX{AKo2)cJ*$Kt#%sqO!q5NbTruxmTskx6hWAq^B%2$W|jgw~`4)}O%lQxi8@6R;of!*cg1&-m|MSq6m1Ibtds1jX~ zb*JU*YZaA_Pt6szc1ztzw;M^ekh)U5B74iEta>ovrSu+2V5nxL_o_G*{H)C~qUMlv z^z=wn$X93GdM7@embki=8MW$I&d{9bkMJKKp>uy+3pU@ICciYQu{2C{^!FMwk99HH zHX4bE%OX54|0)JLsC8d6G@wMAss+Rn$sO_O3%wPyZcTeE`>DTPea9R~cIyO>veY*R znXs-}ukN{Lf5z&HP*|XxSS?sJd;ZLU+gsvTwChNIGg^N~43aCm=Up^hqlmK|L=$%# z3|Wf*o-HRp8BCKp^hAng2Prci4)(wVHg*D+zIZdIK5~s z^<-pXzKwF$S2&$<)yjImxC{G6JIe`+m6?P2LUdCoU>NXZ&8%SLUQlHq!V}sngAmbP z8_-vJXz+Lv5#YrfKAk$KJc9F`d34B65b_OLs=L}4$YJ@`ZQP@km85=crF$~$=SqA1 z^OmASKE+sM6kR6amy2j+t>a}{C#Lb5ag0+c5i*_SuMEaTT}z<{{LkC%nj17!PpYbp z{kr}$^8S&(=xeE?x=-e1tpq7f6TPfdJ|RUQd5lmCI{zb!sbwYB#!$m;@$@N9g#DQ>LbEtJo2|ea5Ls8 zyxZ!=K)2w$cyuKtcMuL^SwzIBv*u$VeNYn?VmgE~J-V$|zNzNOk%0@5XvfZHdt$2! z`3!obz3LZTV65n6Oj3sC(Yi_gZAYp%ewml-JLBBQmd=iU>Dxb9*&+!yo&35Gq>2T3 zlp8ra@fuiGxlzYcm*c%@J&81WxgT+H?49ce5cclc-^Hy8O%EtW_1k9?W0b#f^ucUe zrrmY$#S0l(Ul=?_=ZGO)+I0^wTx4Kw{C9->50rvWc5z0c-;10O*~W3v2-sLUSX7(c GivI_<2cAd( literal 0 HcmV?d00001 diff --git a/docs/form.png b/docs/form.png new file mode 100644 index 0000000000000000000000000000000000000000..631c9015e080024314ce4af81ad7b6f82622de2c GIT binary patch literal 5522 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3924c81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "django-form-button" +version = "1.0.0" +dependencies = ["django"] +requires-python = ">=3" +authors = [{ name = "Pradish Bijukchhe", email = "pradishbijukchhe@gmail.com" }] +description = "Django admin extra buttons with form" +readme = "README.md" +license = { file = "LICENSE" } +keywords = [] +classifiers = ["Programming Language :: Python :: 3"] + +[project.urls] +Homepage = "https://github.com/sandbox-pokhara/django-form-button" +Issues = "https://github.com/sandbox-pokhara/django-form-button/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-dir] +"django_form_button" = "django_form_button" + +[tool.isort] +line_length = 79 +force_single_line = true + +[tool.black] +line-length = 79 +preview = true + +[tool.pyright] +typeCheckingMode = "strict"