Removed the legacy form wizard.
This commit is contained in:
parent
052271168b
commit
d4ea02b992
|
@ -10,7 +10,6 @@ import warnings
|
|||
from django import http
|
||||
from django.conf import settings
|
||||
from django.contrib.formtools import preview, utils
|
||||
from django.contrib.formtools.wizard import FormWizard
|
||||
from django.test import TestCase
|
||||
from django.test.html import parse_html
|
||||
from django.test.utils import override_settings
|
||||
|
@ -187,255 +186,3 @@ class FormHmacTests(unittest.TestCase):
|
|||
hash1 = utils.form_hmac(f1)
|
||||
hash2 = utils.form_hmac(f2)
|
||||
self.assertEqual(hash1, hash2)
|
||||
|
||||
|
||||
#
|
||||
# FormWizard tests
|
||||
#
|
||||
|
||||
class TestWizardClass(FormWizard):
|
||||
|
||||
def get_template(self, step):
|
||||
return 'forms/wizard.html'
|
||||
|
||||
def done(self, request, cleaned_data):
|
||||
return http.HttpResponse(success_string)
|
||||
|
||||
|
||||
class DummyRequest(http.HttpRequest):
|
||||
|
||||
def __init__(self, POST=None):
|
||||
super(DummyRequest, self).__init__()
|
||||
self.method = POST and "POST" or "GET"
|
||||
if POST is not None:
|
||||
self.POST.update(POST)
|
||||
self._dont_enforce_csrf_checks = True
|
||||
|
||||
|
||||
@override_settings(
|
||||
SECRET_KEY="123",
|
||||
TEMPLATE_DIRS=(
|
||||
os.path.join(os.path.dirname(upath(__file__)), 'templates'),
|
||||
),
|
||||
)
|
||||
class WizardTests(TestCase):
|
||||
urls = 'django.contrib.formtools.tests.urls'
|
||||
wizard_step_data = (
|
||||
{
|
||||
'0-name': 'Pony',
|
||||
'0-thirsty': '2',
|
||||
},
|
||||
{
|
||||
'1-address1': '123 Main St',
|
||||
'1-address2': 'Djangoland',
|
||||
},
|
||||
{
|
||||
'2-random_crap': 'blah blah',
|
||||
}
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super(WizardTests, self).setUp()
|
||||
self.save_warnings_state()
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning,
|
||||
module='django.contrib.formtools.wizard')
|
||||
|
||||
def tearDown(self):
|
||||
super(WizardTests, self).tearDown()
|
||||
self.restore_warnings_state()
|
||||
|
||||
def test_step_starts_at_zero(self):
|
||||
"""
|
||||
step should be zero for the first form
|
||||
"""
|
||||
response = self.client.get('/wizard1/')
|
||||
self.assertEqual(0, response.context['step0'])
|
||||
|
||||
def test_step_increments(self):
|
||||
"""
|
||||
step should be incremented when we go to the next page
|
||||
"""
|
||||
response = self.client.post('/wizard1/', {"0-field":"test", "wizard_step":"0"})
|
||||
self.assertEqual(1, response.context['step0'])
|
||||
|
||||
def test_bad_hash(self):
|
||||
"""
|
||||
Form should not advance if the hash is missing or bad
|
||||
"""
|
||||
response = self.client.post('/wizard1/',
|
||||
{"0-field":"test",
|
||||
"1-field":"test2",
|
||||
"wizard_step": "1"})
|
||||
self.assertEqual(0, response.context['step0'])
|
||||
|
||||
def test_good_hash(self):
|
||||
"""
|
||||
Form should advance if the hash is present and good, as calculated using
|
||||
current method.
|
||||
"""
|
||||
data = {"0-field": "test",
|
||||
"1-field": "test2",
|
||||
"hash_0": {
|
||||
2: "cd13b1db3e8f55174bc5745a1b1a53408d4fd1ca",
|
||||
3: "9355d5dff22d49dbad58e46189982cec649f9f5b",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"wizard_step": "1"}
|
||||
response = self.client.post('/wizard1/', data)
|
||||
self.assertEqual(2, response.context['step0'])
|
||||
|
||||
def test_11726(self):
|
||||
"""
|
||||
Regression test for ticket #11726.
|
||||
Wizard should not raise Http404 when steps are added dynamically.
|
||||
"""
|
||||
reached = [False]
|
||||
that = self
|
||||
|
||||
class WizardWithProcessStep(TestWizardClass):
|
||||
def process_step(self, request, form, step):
|
||||
if step == 0:
|
||||
if self.num_steps() < 2:
|
||||
self.form_list.append(WizardPageTwoForm)
|
||||
if step == 1:
|
||||
that.assertTrue(isinstance(form, WizardPageTwoForm))
|
||||
reached[0] = True
|
||||
|
||||
wizard = WizardWithProcessStep([WizardPageOneForm])
|
||||
data = {"0-field": "test",
|
||||
"1-field": "test2",
|
||||
"hash_0": {
|
||||
2: "cd13b1db3e8f55174bc5745a1b1a53408d4fd1ca",
|
||||
3: "9355d5dff22d49dbad58e46189982cec649f9f5b",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"wizard_step": "1"}
|
||||
wizard(DummyRequest(POST=data))
|
||||
self.assertTrue(reached[0])
|
||||
|
||||
data = {"0-field": "test",
|
||||
"1-field": "test2",
|
||||
"hash_0": {
|
||||
2: "cd13b1db3e8f55174bc5745a1b1a53408d4fd1ca",
|
||||
3: "9355d5dff22d49dbad58e46189982cec649f9f5b",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"hash_1": {
|
||||
2: "1e6f6315da42e62f33a30640ec7e007ad3fbf1a1",
|
||||
3: "c33142ef9d01b1beae238adf22c3c6c57328f51a",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"wizard_step": "2"}
|
||||
self.assertRaises(http.Http404, wizard, DummyRequest(POST=data))
|
||||
|
||||
def test_14498(self):
|
||||
"""
|
||||
Regression test for ticket #14498. All previous steps' forms should be
|
||||
validated.
|
||||
"""
|
||||
reached = [False]
|
||||
that = self
|
||||
|
||||
class WizardWithProcessStep(TestWizardClass):
|
||||
def process_step(self, request, form, step):
|
||||
that.assertTrue(form.is_valid())
|
||||
reached[0] = True
|
||||
|
||||
wizard = WizardWithProcessStep([WizardPageOneForm,
|
||||
WizardPageTwoForm,
|
||||
WizardPageThreeForm])
|
||||
data = {"0-field": "test",
|
||||
"1-field": "test2",
|
||||
"hash_0": {
|
||||
2: "cd13b1db3e8f55174bc5745a1b1a53408d4fd1ca",
|
||||
3: "9355d5dff22d49dbad58e46189982cec649f9f5b",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"wizard_step": "1"}
|
||||
wizard(DummyRequest(POST=data))
|
||||
self.assertTrue(reached[0])
|
||||
|
||||
def test_14576(self):
|
||||
"""
|
||||
Regression test for ticket #14576.
|
||||
|
||||
The form of the last step is not passed to the done method.
|
||||
"""
|
||||
reached = [False]
|
||||
that = self
|
||||
|
||||
class Wizard(TestWizardClass):
|
||||
def done(self, request, form_list):
|
||||
reached[0] = True
|
||||
that.assertTrue(len(form_list) == 2)
|
||||
|
||||
wizard = Wizard([WizardPageOneForm,
|
||||
WizardPageTwoForm])
|
||||
|
||||
data = {"0-field": "test",
|
||||
"1-field": "test2",
|
||||
"hash_0": {
|
||||
2: "cd13b1db3e8f55174bc5745a1b1a53408d4fd1ca",
|
||||
3: "9355d5dff22d49dbad58e46189982cec649f9f5b",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"wizard_step": "1"}
|
||||
wizard(DummyRequest(POST=data))
|
||||
self.assertTrue(reached[0])
|
||||
|
||||
def test_15075(self):
|
||||
"""
|
||||
Regression test for ticket #15075. Allow modifying wizard's form_list
|
||||
in process_step.
|
||||
"""
|
||||
reached = [False]
|
||||
that = self
|
||||
|
||||
class WizardWithProcessStep(TestWizardClass):
|
||||
def process_step(self, request, form, step):
|
||||
if step == 0:
|
||||
self.form_list[1] = WizardPageTwoAlternativeForm
|
||||
if step == 1:
|
||||
that.assertTrue(isinstance(form, WizardPageTwoAlternativeForm))
|
||||
reached[0] = True
|
||||
|
||||
wizard = WizardWithProcessStep([WizardPageOneForm,
|
||||
WizardPageTwoForm,
|
||||
WizardPageThreeForm])
|
||||
data = {"0-field": "test",
|
||||
"1-field": "test2",
|
||||
"hash_0": {
|
||||
2: "cd13b1db3e8f55174bc5745a1b1a53408d4fd1ca",
|
||||
3: "9355d5dff22d49dbad58e46189982cec649f9f5b",
|
||||
}[pickle.HIGHEST_PROTOCOL],
|
||||
"wizard_step": "1"}
|
||||
wizard(DummyRequest(POST=data))
|
||||
self.assertTrue(reached[0])
|
||||
|
||||
def grab_field_data(self, response):
|
||||
"""
|
||||
Pull the appropriate field data from the context to pass to the next wizard step
|
||||
"""
|
||||
previous_fields = parse_html(response.context['previous_fields'])
|
||||
fields = {'wizard_step': response.context['step0']}
|
||||
|
||||
for input_field in previous_fields:
|
||||
input_attrs = dict(input_field.attributes)
|
||||
fields[input_attrs["name"]] = input_attrs["value"]
|
||||
|
||||
return fields
|
||||
|
||||
def check_wizard_step(self, response, step_no):
|
||||
"""
|
||||
Helper function to test each step of the wizard
|
||||
- Make sure the call succeeded
|
||||
- Make sure response is the proper step number
|
||||
- return the result from the post for the next step
|
||||
"""
|
||||
step_count = len(self.wizard_step_data)
|
||||
|
||||
self.assertContains(response, 'Step %d of %d' % (step_no, step_count))
|
||||
|
||||
data = self.grab_field_data(response)
|
||||
data.update(self.wizard_step_data[step_no - 1])
|
||||
|
||||
return self.client.post('/wizard2/', data)
|
||||
|
||||
def test_9473(self):
|
||||
response = self.client.get('/wizard2/')
|
||||
for step_no in range(1, len(self.wizard_step_data) + 1):
|
||||
response = self.check_wizard_step(response, step_no)
|
||||
|
|
|
@ -1,21 +1,4 @@
|
|||
from django import forms
|
||||
from django.contrib.formtools.wizard import FormWizard
|
||||
from django.http import HttpResponse
|
||||
|
||||
class Page1(forms.Form):
|
||||
name = forms.CharField(max_length=100)
|
||||
thirsty = forms.NullBooleanField()
|
||||
|
||||
class Page2(forms.Form):
|
||||
address1 = forms.CharField(max_length=100)
|
||||
address2 = forms.CharField(max_length=100)
|
||||
|
||||
class Page3(forms.Form):
|
||||
random_crap = forms.CharField(max_length=100)
|
||||
|
||||
class ContactWizard(FormWizard):
|
||||
def done(self, request, form_list):
|
||||
return HttpResponse("")
|
||||
|
||||
class TestForm(forms.Form):
|
||||
field1 = forms.CharField()
|
||||
|
@ -30,15 +13,3 @@ class HashTestForm(forms.Form):
|
|||
class HashTestBlankForm(forms.Form):
|
||||
name = forms.CharField(required=False)
|
||||
bio = forms.CharField(required=False)
|
||||
|
||||
class WizardPageOneForm(forms.Form):
|
||||
field = forms.CharField()
|
||||
|
||||
class WizardPageTwoForm(forms.Form):
|
||||
field = forms.CharField()
|
||||
|
||||
class WizardPageTwoAlternativeForm(forms.Form):
|
||||
field = forms.CharField()
|
||||
|
||||
class WizardPageThreeForm(forms.Form):
|
||||
field = forms.CharField()
|
||||
|
|
|
@ -5,15 +5,11 @@ This is a URLconf to be loaded by tests.py. Add any URLs needed for tests only.
|
|||
from __future__ import absolute_import
|
||||
|
||||
from django.conf.urls import patterns, url
|
||||
from django.contrib.formtools.tests import TestFormPreview, TestWizardClass
|
||||
from django.contrib.formtools.tests import TestFormPreview
|
||||
|
||||
from django.contrib.formtools.tests.forms import (ContactWizard, Page1, Page2,
|
||||
Page3, TestForm, WizardPageOneForm, WizardPageTwoForm, WizardPageThreeForm)
|
||||
from django.contrib.formtools.tests.forms import TestForm
|
||||
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^preview/', TestFormPreview(TestForm)),
|
||||
url(r'^wizard1/$', TestWizardClass(
|
||||
[WizardPageOneForm, WizardPageTwoForm, WizardPageThreeForm])),
|
||||
url(r'^wizard2/$', ContactWizard([Page1, Page2, Page3])),
|
||||
)
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
from django.contrib.formtools.wizard.legacy import FormWizard
|
|
@ -1,270 +0,0 @@
|
|||
"""
|
||||
FormWizard class -- implements a multi-page form, validating between each
|
||||
step and storing the form's state as HTML hidden fields so that no state is
|
||||
stored on the server side.
|
||||
"""
|
||||
from django.forms import HiddenInput
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render_to_response
|
||||
from django.template.context import RequestContext
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
|
||||
from django.contrib.formtools.utils import form_hmac
|
||||
|
||||
class FormWizard(object):
|
||||
# The HTML (and POST data) field name for the "step" variable.
|
||||
step_field_name="wizard_step"
|
||||
|
||||
# METHODS SUBCLASSES SHOULDN'T OVERRIDE ###################################
|
||||
|
||||
def __init__(self, form_list, initial=None):
|
||||
"""
|
||||
Start a new wizard with a list of forms.
|
||||
|
||||
form_list should be a list of Form classes (not instances).
|
||||
"""
|
||||
self.form_list = form_list[:]
|
||||
self.initial = initial or {}
|
||||
|
||||
# Dictionary of extra template context variables.
|
||||
self.extra_context = {}
|
||||
|
||||
# A zero-based counter keeping track of which step we're in.
|
||||
self.step = 0
|
||||
|
||||
import warnings
|
||||
warnings.warn(
|
||||
'Old-style form wizards have been deprecated; use the class-based '
|
||||
'views in django.contrib.formtools.wizard.views instead.',
|
||||
DeprecationWarning)
|
||||
|
||||
def __repr__(self):
|
||||
return "step: %d\nform_list: %s\ninitial_data: %s" % (self.step, self.form_list, self.initial)
|
||||
|
||||
def get_form(self, step, data=None):
|
||||
"Helper method that returns the Form instance for the given step."
|
||||
# Sanity check.
|
||||
if step >= self.num_steps():
|
||||
raise Http404('Step %s does not exist' % step)
|
||||
return self.form_list[step](data, prefix=self.prefix_for_step(step), initial=self.initial.get(step, None))
|
||||
|
||||
def num_steps(self):
|
||||
"Helper method that returns the number of steps."
|
||||
# You might think we should just set "self.num_steps = len(form_list)"
|
||||
# in __init__(), but this calculation needs to be dynamic, because some
|
||||
# hook methods might alter self.form_list.
|
||||
return len(self.form_list)
|
||||
|
||||
def _check_security_hash(self, token, request, form):
|
||||
expected = self.security_hash(request, form)
|
||||
return constant_time_compare(token, expected)
|
||||
|
||||
@method_decorator(csrf_protect)
|
||||
def __call__(self, request, *args, **kwargs):
|
||||
"""
|
||||
Main method that does all the hard work, conforming to the Django view
|
||||
interface.
|
||||
"""
|
||||
if 'extra_context' in kwargs:
|
||||
self.extra_context.update(kwargs['extra_context'])
|
||||
current_step = self.get_current_or_first_step(request, *args, **kwargs)
|
||||
self.parse_params(request, *args, **kwargs)
|
||||
|
||||
# Validate and process all the previous forms before instantiating the
|
||||
# current step's form in case self.process_step makes changes to
|
||||
# self.form_list.
|
||||
|
||||
# If any of them fails validation, that must mean the validator relied
|
||||
# on some other input, such as an external Web site.
|
||||
|
||||
# It is also possible that alidation might fail under certain attack
|
||||
# situations: an attacker might be able to bypass previous stages, and
|
||||
# generate correct security hashes for all the skipped stages by virtue
|
||||
# of:
|
||||
# 1) having filled out an identical form which doesn't have the
|
||||
# validation (and does something different at the end),
|
||||
# 2) or having filled out a previous version of the same form which
|
||||
# had some validation missing,
|
||||
# 3) or previously having filled out the form when they had more
|
||||
# privileges than they do now.
|
||||
#
|
||||
# Since the hashes only take into account values, and not other other
|
||||
# validation the form might do, we must re-do validation now for
|
||||
# security reasons.
|
||||
previous_form_list = []
|
||||
for i in range(current_step):
|
||||
f = self.get_form(i, request.POST)
|
||||
if not self._check_security_hash(request.POST.get("hash_%d" % i, ''),
|
||||
request, f):
|
||||
return self.render_hash_failure(request, i)
|
||||
|
||||
if not f.is_valid():
|
||||
return self.render_revalidation_failure(request, i, f)
|
||||
else:
|
||||
self.process_step(request, f, i)
|
||||
previous_form_list.append(f)
|
||||
|
||||
# Process the current step. If it's valid, go to the next step or call
|
||||
# done(), depending on whether any steps remain.
|
||||
if request.method == 'POST':
|
||||
form = self.get_form(current_step, request.POST)
|
||||
else:
|
||||
form = self.get_form(current_step)
|
||||
|
||||
if form.is_valid():
|
||||
self.process_step(request, form, current_step)
|
||||
next_step = current_step + 1
|
||||
|
||||
if next_step == self.num_steps():
|
||||
return self.done(request, previous_form_list + [form])
|
||||
else:
|
||||
form = self.get_form(next_step)
|
||||
self.step = current_step = next_step
|
||||
|
||||
return self.render(form, request, current_step)
|
||||
|
||||
def render(self, form, request, step, context=None):
|
||||
"Renders the given Form object, returning an HttpResponse."
|
||||
old_data = request.POST
|
||||
prev_fields = []
|
||||
if old_data:
|
||||
hidden = HiddenInput()
|
||||
# Collect all data from previous steps and render it as HTML hidden fields.
|
||||
for i in range(step):
|
||||
old_form = self.get_form(i, old_data)
|
||||
hash_name = 'hash_%s' % i
|
||||
prev_fields.extend([bf.as_hidden() for bf in old_form])
|
||||
prev_fields.append(hidden.render(hash_name, old_data.get(hash_name, self.security_hash(request, old_form))))
|
||||
return self.render_template(request, form, ''.join(prev_fields), step, context)
|
||||
|
||||
# METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ########################
|
||||
|
||||
def prefix_for_step(self, step):
|
||||
"Given the step, returns a Form prefix to use."
|
||||
return str(step)
|
||||
|
||||
def render_hash_failure(self, request, step):
|
||||
"""
|
||||
Hook for rendering a template if a hash check failed.
|
||||
|
||||
step is the step that failed. Any previous step is guaranteed to be
|
||||
valid.
|
||||
|
||||
This default implementation simply renders the form for the given step,
|
||||
but subclasses may want to display an error message, etc.
|
||||
"""
|
||||
return self.render(self.get_form(step), request, step, context={'wizard_error': _('We apologize, but your form has expired. Please continue filling out the form from this page.')})
|
||||
|
||||
def render_revalidation_failure(self, request, step, form):
|
||||
"""
|
||||
Hook for rendering a template if final revalidation failed.
|
||||
|
||||
It is highly unlikely that this point would ever be reached, but See
|
||||
the comment in __call__() for an explanation.
|
||||
"""
|
||||
return self.render(form, request, step)
|
||||
|
||||
def security_hash(self, request, form):
|
||||
"""
|
||||
Calculates the security hash for the given HttpRequest and Form instances.
|
||||
|
||||
Subclasses may want to take into account request-specific information,
|
||||
such as the IP address.
|
||||
"""
|
||||
return form_hmac(form)
|
||||
|
||||
def get_current_or_first_step(self, request, *args, **kwargs):
|
||||
"""
|
||||
Given the request object and whatever *args and **kwargs were passed to
|
||||
__call__(), returns the current step (which is zero-based).
|
||||
|
||||
Note that the result should not be trusted. It may even be a completely
|
||||
invalid number. It's not the job of this method to validate it.
|
||||
"""
|
||||
if not request.POST:
|
||||
return 0
|
||||
try:
|
||||
step = int(request.POST.get(self.step_field_name, 0))
|
||||
except ValueError:
|
||||
return 0
|
||||
return step
|
||||
|
||||
def parse_params(self, request, *args, **kwargs):
|
||||
"""
|
||||
Hook for setting some state, given the request object and whatever
|
||||
*args and **kwargs were passed to __call__(), sets some state.
|
||||
|
||||
This is called at the beginning of __call__().
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_template(self, step):
|
||||
"""
|
||||
Hook for specifying the name of the template to use for a given step.
|
||||
|
||||
Note that this can return a tuple of template names if you'd like to
|
||||
use the template system's select_template() hook.
|
||||
"""
|
||||
return 'forms/wizard.html'
|
||||
|
||||
def render_template(self, request, form, previous_fields, step, context=None):
|
||||
"""
|
||||
Renders the template for the given step, returning an HttpResponse object.
|
||||
|
||||
Override this method if you want to add a custom context, return a
|
||||
different MIME type, etc. If you only need to override the template
|
||||
name, use get_template() instead.
|
||||
|
||||
The template will be rendered with the following context:
|
||||
step_field -- The name of the hidden field containing the step.
|
||||
step0 -- The current step (zero-based).
|
||||
step -- The current step (one-based).
|
||||
step_count -- The total number of steps.
|
||||
form -- The Form instance for the current step (either empty
|
||||
or with errors).
|
||||
previous_fields -- A string representing every previous data field,
|
||||
plus hashes for completed forms, all in the form of
|
||||
hidden fields. Note that you'll need to run this
|
||||
through the "safe" template filter, to prevent
|
||||
auto-escaping, because it's raw HTML.
|
||||
"""
|
||||
context = context or {}
|
||||
context.update(self.extra_context)
|
||||
return render_to_response(self.get_template(step), dict(context,
|
||||
step_field=self.step_field_name,
|
||||
step0=step,
|
||||
step=step + 1,
|
||||
step_count=self.num_steps(),
|
||||
form=form,
|
||||
previous_fields=previous_fields
|
||||
), context_instance=RequestContext(request))
|
||||
|
||||
def process_step(self, request, form, step):
|
||||
"""
|
||||
Hook for modifying the FormWizard's internal state, given a fully
|
||||
validated Form object. The Form is guaranteed to have clean, valid
|
||||
data.
|
||||
|
||||
This method should *not* modify any of that data. Rather, it might want
|
||||
to set self.extra_context or dynamically alter self.form_list, based on
|
||||
previously submitted forms.
|
||||
|
||||
Note that this method is called every time a page is rendered for *all*
|
||||
submitted steps.
|
||||
"""
|
||||
pass
|
||||
|
||||
# METHODS SUBCLASSES MUST OVERRIDE ########################################
|
||||
|
||||
def done(self, request, form_list):
|
||||
"""
|
||||
Hook for doing something with the validated data. This is responsible
|
||||
for the final processing.
|
||||
|
||||
form_list is a list of Form instances, each containing clean, valid
|
||||
data.
|
||||
"""
|
||||
raise NotImplementedError("Your %s class has not defined a done() method, which is required." % self.__class__.__name__)
|
Loading…
Reference in New Issue