33 |
Example Project
34 | {% block content %}
35 |
40 | {% if form.is_valid %}
41 |
42 |
Cleaned Data
43 |
{{ cleaned_data }}
44 |
45 | {% endif %}
46 | {% if raw_post %}
47 |
48 |
Raw POST data:
49 |
{{ raw_post }}
50 |
51 | {% endif %}
52 | {% endblock %}
53 |
54 | {% include_jquery_libs %}
55 | {{ form.media.js }}
56 | {% block extra-js %}{% endblock %}
57 |
58 |
59 |
--------------------------------------------------------------------------------
/selectable/decorators.py:
--------------------------------------------------------------------------------
1 | "Decorators for additional lookup functionality."
2 |
3 | from functools import wraps
4 |
5 | from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
6 |
7 | __all__ = (
8 | "ajax_required",
9 | "login_required",
10 | "staff_member_required",
11 | )
12 |
13 |
14 | def results_decorator(func):
15 | """
16 | Helper for constructing simple decorators around Lookup.results.
17 |
18 | func is a function which takes a request as the first parameter. If func
19 | returns an HttpReponse it is returned otherwise the original Lookup.results
20 | is returned.
21 | """
22 |
23 | # Wrap function to maintian the original doc string, etc
24 | @wraps(func)
25 | def decorator(lookup_cls):
26 | # Construct a class decorator from the original function
27 | original = lookup_cls.results
28 |
29 | def inner(self, request):
30 | # Wrap lookup_cls.results by first calling func and checking the result
31 | result = func(request)
32 | if isinstance(result, HttpResponse):
33 | return result
34 | return original(self, request)
35 |
36 | # Replace original lookup_cls.results with wrapped version
37 | lookup_cls.results = inner
38 | return lookup_cls
39 |
40 | # Return the constructed decorator
41 | return decorator
42 |
43 |
44 | @results_decorator
45 | def ajax_required(request):
46 | "Lookup decorator to require AJAX calls to the lookup view."
47 | if not request.headers.get("x-requested-with") == "XMLHttpRequest":
48 | return HttpResponseBadRequest()
49 |
50 |
51 | @results_decorator
52 | def login_required(request):
53 | "Lookup decorator to require the user to be authenticated."
54 | user = getattr(request, "user", None)
55 | if user is None or not user.is_authenticated:
56 | return HttpResponse(status=401) # Unauthorized
57 |
58 |
59 | @results_decorator
60 | def staff_member_required(request):
61 | "Lookup decorator to require the user is a staff member."
62 | user = getattr(request, "user", None)
63 | if user is None or not user.is_authenticated:
64 | return HttpResponse(status=401) # Unauthorized
65 | elif not user.is_staff:
66 | return HttpResponseForbidden()
67 |
--------------------------------------------------------------------------------
/example/core/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.9.12 on 2017-02-25 01:13
3 | from __future__ import unicode_literals
4 |
5 | import django.db.models.deletion
6 | import localflavor.us.models
7 | from django.conf import settings
8 | from django.db import migrations, models
9 |
10 |
11 | class Migration(migrations.Migration):
12 | initial = True
13 |
14 | dependencies = [
15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16 | ]
17 |
18 | operations = [
19 | migrations.CreateModel(
20 | name="City",
21 | fields=[
22 | (
23 | "id",
24 | models.AutoField(
25 | auto_created=True,
26 | primary_key=True,
27 | serialize=False,
28 | verbose_name="ID",
29 | ),
30 | ),
31 | ("name", models.CharField(max_length=200)),
32 | ("state", localflavor.us.models.USStateField(max_length=2)),
33 | ],
34 | ),
35 | migrations.CreateModel(
36 | name="Farm",
37 | fields=[
38 | (
39 | "id",
40 | models.AutoField(
41 | auto_created=True,
42 | primary_key=True,
43 | serialize=False,
44 | verbose_name="ID",
45 | ),
46 | ),
47 | ("name", models.CharField(max_length=200)),
48 | ],
49 | ),
50 | migrations.CreateModel(
51 | name="Fruit",
52 | fields=[
53 | (
54 | "id",
55 | models.AutoField(
56 | auto_created=True,
57 | primary_key=True,
58 | serialize=False,
59 | verbose_name="ID",
60 | ),
61 | ),
62 | ("name", models.CharField(max_length=200)),
63 | ],
64 | ),
65 | migrations.AddField(
66 | model_name="farm",
67 | name="fruit",
68 | field=models.ManyToManyField(to="core.Fruit"),
69 | ),
70 | migrations.AddField(
71 | model_name="farm",
72 | name="owner",
73 | field=models.ForeignKey(
74 | on_delete=django.db.models.deletion.CASCADE,
75 | related_name="farms",
76 | to=settings.AUTH_USER_MODEL,
77 | ),
78 | ),
79 | ]
80 |
--------------------------------------------------------------------------------
/selectable/tests/test_forms.py:
--------------------------------------------------------------------------------
1 | from ..forms import BaseLookupForm
2 | from .base import BaseSelectableTestCase
3 |
4 | __all__ = ("BaseLookupFormTestCase",)
5 |
6 |
7 | class BaseLookupFormTestCase(BaseSelectableTestCase):
8 | def get_valid_data(self):
9 | data = {
10 | "term": "foo",
11 | "limit": 10,
12 | }
13 | return data
14 |
15 | def test_valid_data(self):
16 | data = self.get_valid_data()
17 | form = BaseLookupForm(data)
18 | self.assertTrue(form.is_valid(), "%s" % form.errors)
19 |
20 | def test_invalid_limit(self):
21 | """
22 | Test giving the form an invalid limit.
23 | """
24 |
25 | data = self.get_valid_data()
26 | data["limit"] = "bar"
27 | form = BaseLookupForm(data)
28 | self.assertFalse(form.is_valid())
29 |
30 | def test_no_limit(self):
31 | """
32 | If SELECTABLE_MAX_LIMIT is set and limit is not given then
33 | the form will return SELECTABLE_MAX_LIMIT.
34 | """
35 |
36 | with self.settings(SELECTABLE_MAX_LIMIT=25):
37 | data = self.get_valid_data()
38 | if "limit" in data:
39 | del data["limit"]
40 | form = BaseLookupForm(data)
41 | self.assertTrue(form.is_valid(), "%s" % form.errors)
42 | self.assertEqual(form.cleaned_data["limit"], 25)
43 |
44 | def test_no_max_set(self):
45 | """
46 | If SELECTABLE_MAX_LIMIT is not set but given then the form
47 | will return the given limit.
48 | """
49 |
50 | with self.settings(SELECTABLE_MAX_LIMIT=None):
51 | data = self.get_valid_data()
52 | form = BaseLookupForm(data)
53 | self.assertTrue(form.is_valid(), "%s" % form.errors)
54 | if "limit" in data:
55 | self.assertTrue(form.cleaned_data["limit"], data["limit"])
56 |
57 | def test_no_max_set_not_given(self):
58 | """
59 | If SELECTABLE_MAX_LIMIT is not set and not given then the form
60 | will return no limit.
61 | """
62 |
63 | with self.settings(SELECTABLE_MAX_LIMIT=None):
64 | data = self.get_valid_data()
65 | if "limit" in data:
66 | del data["limit"]
67 | form = BaseLookupForm(data)
68 | self.assertTrue(form.is_valid(), "%s" % form.errors)
69 | self.assertFalse(form.cleaned_data.get("limit"))
70 |
71 | def test_over_limit(self):
72 | """
73 | If SELECTABLE_MAX_LIMIT is set and limit given is greater then
74 | the form will return SELECTABLE_MAX_LIMIT.
75 | """
76 |
77 | with self.settings(SELECTABLE_MAX_LIMIT=25):
78 | data = self.get_valid_data()
79 | data["limit"] = 125
80 | form = BaseLookupForm(data)
81 | self.assertTrue(form.is_valid(), "%s" % form.errors)
82 | self.assertEqual(form.cleaned_data["limit"], 25)
83 |
--------------------------------------------------------------------------------
/selectable/tests/base.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 | from collections import defaultdict
4 |
5 | from django.test import TestCase, override_settings
6 | from django.test.html import parse_html
7 |
8 | from ..base import ModelLookup
9 | from . import Thing
10 |
11 |
12 | def parsed_inputs(html):
13 | "Returns a dictionary mapping name --> node of inputs found in the HTML."
14 | node = parse_html(html)
15 | inputs = {}
16 | for field in [c for c in node.children if c.name == "input"]:
17 | name = dict(field.attributes)["name"]
18 | current = inputs.get(name, [])
19 | current.append(field)
20 | inputs[name] = current
21 | return inputs
22 |
23 |
24 | @override_settings(ROOT_URLCONF="selectable.tests.urls")
25 | class BaseSelectableTestCase(TestCase):
26 | def get_random_string(self, length=10):
27 | return "".join(random.choice(string.ascii_letters) for x in range(length))
28 |
29 | def create_thing(self, data=None):
30 | data = data or {}
31 | defaults = {
32 | "name": self.get_random_string(),
33 | "description": self.get_random_string(),
34 | }
35 | defaults.update(data)
36 | return Thing.objects.create(**defaults)
37 |
38 |
39 | class SimpleModelLookup(ModelLookup):
40 | model = Thing
41 | search_fields = ("name__icontains",)
42 |
43 |
44 | def parsed_widget_attributes(widget):
45 | """
46 | Get a dictionary-like object containing all HTML attributes
47 | of the rendered widget.
48 |
49 | Lookups on this object raise ValueError if there is more than one attribute
50 | of the given name in the HTML, and they have different values.
51 | """
52 | # For the tests that use this, it generally doesn't matter what the value
53 | # is, so we supply anything.
54 | rendered = widget.render("a_name", "a_value")
55 | return AttrMap(rendered)
56 |
57 |
58 | class AttrMap:
59 | def __init__(self, html):
60 | dom = parse_html(html)
61 | self._attrs = defaultdict(set)
62 | self._build_attr_map(dom)
63 |
64 | def _build_attr_map(self, dom):
65 | for node in _walk_nodes(dom):
66 | if node.attributes is not None:
67 | for k, v in node.attributes:
68 | self._attrs[k].add(v)
69 |
70 | def __contains__(self, key):
71 | return key in self._attrs and len(self._attrs[key]) > 0
72 |
73 | def __getitem__(self, key):
74 | if key not in self:
75 | raise KeyError(key)
76 | vals = self._attrs[key]
77 | if len(vals) > 1:
78 | raise ValueError(
79 | "More than one value for attribute {0}: {1}".format(
80 | key, ", ".join(vals)
81 | )
82 | )
83 | else:
84 | return list(vals)[0]
85 |
86 |
87 | def _walk_nodes(dom):
88 | yield dom
89 | for child in dom.children:
90 | for item in _walk_nodes(child):
91 | yield item
92 |
--------------------------------------------------------------------------------
/selectable/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import Mock
2 |
3 | from ..decorators import ajax_required, login_required, staff_member_required
4 | from .base import BaseSelectableTestCase, SimpleModelLookup
5 |
6 | __all__ = (
7 | "AjaxRequiredLookupTestCase",
8 | "LoginRequiredLookupTestCase",
9 | "StaffRequiredLookupTestCase",
10 | )
11 |
12 |
13 | class AjaxRequiredLookupTestCase(BaseSelectableTestCase):
14 | def setUp(self):
15 | self.lookup = ajax_required(SimpleModelLookup)()
16 |
17 | def test_ajax_call(self):
18 | "Ajax call should yield a successful response."
19 | request = Mock(headers={"x-requested-with": "XMLHttpRequest"})
20 | response = self.lookup.results(request)
21 | self.assertTrue(response.status_code, 200)
22 |
23 | def test_non_ajax_call(self):
24 | "Non-Ajax call should yield a bad request response."
25 | request = Mock()
26 | response = self.lookup.results(request)
27 | self.assertEqual(response.status_code, 400)
28 |
29 |
30 | class LoginRequiredLookupTestCase(BaseSelectableTestCase):
31 | def setUp(self):
32 | self.lookup = login_required(SimpleModelLookup)()
33 |
34 | def test_authenicated_call(self):
35 | "Authenicated call should yield a successful response."
36 | request = Mock()
37 | user = Mock()
38 | user.is_authenticated = True
39 | request.user = user
40 | response = self.lookup.results(request)
41 | self.assertTrue(response.status_code, 200)
42 |
43 | def test_non_authenicated_call(self):
44 | "Non-Authenicated call should yield an unauthorized response."
45 | request = Mock()
46 | user = Mock()
47 | user.is_authenticated = False
48 | request.user = user
49 | response = self.lookup.results(request)
50 | self.assertEqual(response.status_code, 401)
51 |
52 |
53 | class StaffRequiredLookupTestCase(BaseSelectableTestCase):
54 | def setUp(self):
55 | self.lookup = staff_member_required(SimpleModelLookup)()
56 |
57 | def test_staff_member_call(self):
58 | "Staff member call should yield a successful response."
59 | request = Mock()
60 | user = Mock()
61 | user.is_authenticated = True
62 | user.is_staff = True
63 | request.user = user
64 | response = self.lookup.results(request)
65 | self.assertTrue(response.status_code, 200)
66 |
67 | def test_authenicated_but_not_staff(self):
68 | "Authenicated but non staff call should yield a forbidden response."
69 | request = Mock()
70 | user = Mock()
71 | user.is_authenticated = True
72 | user.is_staff = False
73 | request.user = user
74 | response = self.lookup.results(request)
75 | self.assertTrue(response.status_code, 403)
76 |
77 | def test_non_authenicated_call(self):
78 | "Non-Authenicated call should yield an unauthorized response."
79 | request = Mock()
80 | user = Mock()
81 | user.is_authenticated = False
82 | user.is_staff = False
83 | request.user = user
84 | response = self.lookup.results(request)
85 | self.assertEqual(response.status_code, 401)
86 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | django-selectable
2 | ===================
3 |
4 | Tools and widgets for using/creating auto-complete selection widgets using Django and jQuery UI.
5 |
6 | .. image:: https://travis-ci.org/mlavin/django-selectable.svg?branch=master
7 | :target: https://travis-ci.org/mlavin/django-selectable
8 |
9 | .. image:: https://codecov.io/github/mlavin/django-selectable/coverage.svg?branch=master
10 | :target: https://codecov.io/github/mlavin/django-selectable?branch=master
11 |
12 |
13 | .. note::
14 |
15 | This project is looking for additional maintainers to help with Django/jQuery compatibility
16 | issues as well as addressing support issues/questions. If you are looking to help out
17 | on this project and take a look at the open
18 | `help-wanted