Skip to content

Add AdminMixin: Form management #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: feat/admin
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions django_fsm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,12 +339,8 @@ def get_all_transitions(self, instance_cls):
"""
Returns [(source, target, name, method)] for all field transitions
"""
transitions = self.transitions[instance_cls]

for transition in transitions.values():
meta = transition._django_fsm

yield from meta.transitions.values()
for transition in self.transitions[instance_cls].values():
yield from transition._django_fsm.transitions.values()

def contribute_to_class(self, cls, name, **kwargs):
self.base_cls = cls
Expand Down
213 changes: 156 additions & 57 deletions django_fsm/admin.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
from __future__ import annotations

import typing
from dataclasses import dataclass
from functools import partial
from typing import Any

from django.conf import settings
from django.contrib import admin
from django.contrib import messages
from django.contrib.admin.options import BaseModelAdmin
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.core.exceptions import FieldDoesNotExist
from django.http import HttpRequest
from django.http import HttpResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.shortcuts import render
from django.urls import path
from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _

import django_fsm as fsm

if typing.TYPE_CHECKING:
from django.forms import Form

Check warning on line 27 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L27

Added line #L27 was not covered by tests

try:
import django_fsm_log # noqa: F401
except ModuleNotFoundError:
Expand All @@ -27,7 +37,6 @@
@dataclass
class FSMObjectTransition:
fsm_field: str
block_label: str
available_transitions: list[fsm.Transition]


Expand All @@ -42,55 +51,48 @@
fsm_context_key = "fsm_object_transitions"
fsm_post_param = "_fsm_transition_to"
default_disallow_transition = not getattr(settings, "FSM_ADMIN_FORCE_PERMIT", False)
fsm_transition_form_template = "django_fsm/fsm_admin_transition_form.html"

def get_fsm_field_instance(self, fsm_field_name: str) -> fsm.FSMField | None:
try:
return self.model._meta.get_field(fsm_field_name)
except FieldDoesNotExist:
return None
def get_urls(self):
meta = self.model._meta
return [

Check warning on line 58 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L57-L58

Added lines #L57 - L58 were not covered by tests
path(
"<path:object_id>/transition/<str:transition_name>/",
self.admin_site.admin_view(self.fsm_transition_view),
name=f"{meta.app_label}_{meta.model_name}_transition",
),
*super().get_urls(),
]

def get_readonly_fields(self, request: HttpRequest, obj: typing.Any = None) -> tuple[str]:
"""Add FSM fields to readonly fields if they are protected."""

def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> tuple[str]:
read_only_fields = super().get_readonly_fields(request, obj)

for fsm_field_name in self.fsm_fields:
if fsm_field_name in read_only_fields:
continue
field = self.get_fsm_field_instance(fsm_field_name=fsm_field_name)
if field and getattr(field, "protected", False):
read_only_fields += (fsm_field_name,)
try:
field = self.model._meta.get_field(fsm_field_name)
except FieldDoesNotExist:
pass

Check warning on line 78 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L77-L78

Added lines #L77 - L78 were not covered by tests
else:
if getattr(field, "protected", False):
read_only_fields += (fsm_field_name,)

return read_only_fields

@staticmethod
def get_fsm_block_label(fsm_field_name: str) -> str:
return f"Transition ({fsm_field_name})"

def get_fsm_object_transitions(self, request: HttpRequest, obj: Any) -> list[FSMObjectTransition]:
fsm_object_transitions = []

for field_name in sorted(self.fsm_fields):
if func := getattr(obj, f"get_available_user_{field_name}_transitions"):
fsm_object_transitions.append( # noqa: PERF401
FSMObjectTransition(
fsm_field=field_name,
block_label=self.get_fsm_block_label(fsm_field_name=field_name),
available_transitions=[
t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition)
],
)
)

return fsm_object_transitions

def change_view(
self,
request: HttpRequest,
object_id: str,
form_url: str = "",
extra_context: dict[str, Any] | None = None,
extra_context: dict[str, typing.Any] | None = None,
) -> HttpResponse:
"""Override the change view to add FSM transitions to the context."""

_context = extra_context or {}
_context[self.fsm_context_key] = self.get_fsm_object_transitions(
_context[self.fsm_context_key] = self._get_fsm_object_transitions(
request=request,
obj=self.get_object(request=request, object_id=object_id),
)
Expand All @@ -102,24 +104,19 @@
extra_context=_context,
)

def get_fsm_redirect_url(self, request: HttpRequest, obj: Any) -> str:
return request.path

def get_fsm_response(self, request: HttpRequest, obj: Any) -> HttpResponse:
redirect_url = self.get_fsm_redirect_url(request=request, obj=obj)
redirect_url = add_preserved_filters(
context={
"preserved_filters": self.get_preserved_filters(request),
"opts": self.model._meta,
},
url=redirect_url,
)
return HttpResponseRedirect(redirect_to=redirect_url)
def _get_fsm_object_transitions(self, request: HttpRequest, obj: typing.Any) -> list[FSMObjectTransition]:
for field_name in sorted(self.fsm_fields):
if func := getattr(obj, f"get_available_user_{field_name}_transitions"):
yield FSMObjectTransition(
fsm_field=field_name,
available_transitions=[
t for t in func(user=request.user) if t.custom.get("admin", self.default_disallow_transition)
],
)

def response_change(self, request: HttpRequest, obj: Any) -> HttpResponse:
if self.fsm_post_param in request.POST:
def response_change(self, request: HttpRequest, obj: typing.Any) -> HttpResponse: # noqa: C901
if transition_name := request.POST.get(self.fsm_post_param):
try:
transition_name = request.POST[self.fsm_post_param]
transition_func = getattr(obj, transition_name)
except AttributeError:
self.message_user(
Expand All @@ -129,9 +126,18 @@
),
level=messages.ERROR,
)
return self.get_fsm_response(
request=request,
obj=obj,
return self.get_fsm_response(request=request, obj=obj)

# NOTE: if a form is defined in the transition.custom, we redirect to the form view
if self.get_fsm_transition_custom(instance=obj, transition_func=transition_func).get("form"):
return redirect(

Check warning on line 133 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L133

Added line #L133 was not covered by tests
reverse(
f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_transition",
kwargs={
"object_id": obj.pk,
"transition_name": transition_name,
},
)
)

try:
Expand Down Expand Up @@ -173,9 +179,102 @@
level=messages.INFO,
)

return self.get_fsm_response(
request=request,
obj=obj,
)
return self.get_fsm_response(request=request, obj=obj)

return super().response_change(request=request, obj=obj)

def get_fsm_response(self, request: HttpRequest, obj: typing.Any) -> HttpResponse:
redirect_url = add_preserved_filters(
context={
"preserved_filters": self.get_preserved_filters(request),
"opts": self.model._meta,
},
url=self.get_fsm_redirect_url(request=request, obj=obj),
)
return HttpResponseRedirect(redirect_to=redirect_url)

def get_fsm_redirect_url(self, request: HttpRequest, obj: typing.Any) -> str:
return request.path

def get_fsm_transition_custom(self, instance, transition_func):
"""Helper function to get custom attributes for the current transition"""
return getattr(self.get_fsm_transition(instance, transition_func), "custom", {})

def get_fsm_transition(self, instance, transition_func) -> fsm.Transition | None:
"""
Extract custom attributes from a transition function for the current state.
"""
if not hasattr(transition_func, "_django_fsm"):
return None

Check warning on line 208 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L208

Added line #L208 was not covered by tests

fsm_meta = transition_func._django_fsm
current_state = fsm_meta.field.get_state(instance)
return fsm_meta.get_transition(current_state)

def get_fsm_transition_form(self, transition: fsm.Transition) -> Form | None:
form = transition.custom.get("form")
if isinstance(form, str):
form = import_string(form)
return form

Check warning on line 218 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L215-L218

Added lines #L215 - L218 were not covered by tests

def fsm_transition_view(self, request, *args, **kwargs):
transition_name = kwargs["transition_name"]
obj = self.get_object(request, kwargs["object_id"])

Check warning on line 222 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L221-L222

Added lines #L221 - L222 were not covered by tests

transition_method = getattr(obj, transition_name)
if not hasattr(transition_method, "_django_fsm"):
return HttpResponseBadRequest(f"{transition_name} is not a transition method")

Check warning on line 226 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L224-L226

Added lines #L224 - L226 were not covered by tests

transitions = transition_method._django_fsm.transitions
if isinstance(transitions, dict):
transitions = list(transitions.values())
transition = transitions[0]

Check warning on line 231 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L228-L231

Added lines #L228 - L231 were not covered by tests

if TransitionForm := self.get_fsm_transition_form(transition):
if request.method == "POST":
transition_form = TransitionForm(data=request.POST, instance=obj)
if transition_form.is_valid():
transition_method(**transition_form.cleaned_data)
obj.save()

Check warning on line 238 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L233-L238

Added lines #L233 - L238 were not covered by tests
else:
return render(

Check warning on line 240 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L240

Added line #L240 was not covered by tests
request,
self.fsm_transition_form_template,
context=admin.site.each_context(request)
| {
"opts": self.model._meta,
"original": obj,
"transition": transition,
"transition_form": transition_form,
},
)
else:
transition_form = TransitionForm(instance=obj)
return render(

Check warning on line 253 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L252-L253

Added lines #L252 - L253 were not covered by tests
request,
self.fsm_transition_form_template,
context=admin.site.each_context(request)
| {
"opts": self.model._meta,
"original": obj,
"transition": transition,
"transition_form": transition_form,
},
)
else:
try:
transition_method()
except fsm.TransitionNotAllowed:
self.message_user(

Check warning on line 268 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L265-L268

Added lines #L265 - L268 were not covered by tests
request,
self.fsm_transition_not_allowed_msg.format(transition_name=transition_name),
messages.ERROR,
)
else:
obj.save()
self.message_user(

Check warning on line 275 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L274-L275

Added lines #L274 - L275 were not covered by tests
request,
self.fsm_transition_success_msg.format(transition_name=transition_name),
messages.SUCCESS,
)
return redirect(f"admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change", object_id=obj.id)

Check warning on line 280 in django_fsm/admin.py

View check run for this annotation

Codecov / codecov/patch

django_fsm/admin.py#L280

Added line #L280 was not covered by tests
6 changes: 4 additions & 2 deletions django_fsm/templates/django_fsm/fsm_admin_change_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

{% for fsm_object_transition in fsm_object_transitions %}
<div class="submit-row">
<label>{{ fsm_object_transition.block_label }}</label>
<label>Transition ({{ fsm_object_transition.fsm_field }})</label>
{% for transition in fsm_object_transition.available_transitions %}
<input type="submit" value="{{ transition.custom.label|default:transition.name }}" name="_fsm_transition_to">
<button type="submit" class="button" name="_fsm_transition_to" value="{{ transition.name }}">
{{ transition.custom.label|default:transition.name }}
</button>
{% endfor %}
</div>
{% endfor %}
Expand Down
24 changes: 24 additions & 0 deletions django_fsm/templates/django_fsm/fsm_admin_transition_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% extends 'admin/change_form.html' %}

{% load i18n admin_urls static admin_modify %}

{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object_id=original.pk %}">{{ original|truncatewords:"18" }}</a>
&rsaquo; {{ transition.custom.short_description|default:transition.name }}
</div>
{% endblock %}

{% block content %}
<h1>{{ transition.custom.short_description|default:transition.name }}</h1>

<form method="post">
{% csrf_token %}
{{ transition_form.as_p }}
<input type="submit" value="{% translate 'Apply' %}">
</form>

{% endblock %}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ extend-ignore = [
"COM812", # This rule may cause conflicts when used with the formatter
"D", # pydocstyle
"DOC", # pydoclint
"N806", # Variable in function should be lowercase
"B",
"PTH",
"ANN", # Missing type annotation
Expand Down
16 changes: 16 additions & 0 deletions tests/testapp/admin_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from __future__ import annotations

from django import forms

from .models import AdminBlogPost


class AdminBlogPostRenameForm(forms.ModelForm):
"""
This form is used to test the admin form renaming functionality.
It should not be used in production.
"""

class Meta:
model = AdminBlogPost
fields = ["title"] # Do not try to update the state field, especially if it's "protected" in the model.
Loading