diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..4ba5549f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,157 @@
+# 2.0.0
+Major release of DREST that includes core API-ORM translation improvements and a robust admin renderer. Compatability with 1.x is likely but not guaranteed, many private APIs have changed, some setting defaults have changed.
+
+## Boilerplate
+
+- Added integration with [dj](https://github.com/aleontiev/dj), including an `api` blueprint that can be used to bootstrap a new view/serializer:
+
+```
+ dj add dynamic-rest
+ dj generate model product
+ dj generate api v0 product
+```
+
+## Fields
+
+- `dynamic_rest.fields.DynamicRelationField`
+ - New initialization arguments: `getter` and `setter`
+ - Allows for custom relationship getting and setting.
+ This can be useful for simplifying complex "through" relations.
+
+ - Argument change: `serializer_class` is now optional
+ - `DynamicRelationField` will attempt to infer `serializer_class` from the
+ given source using `DynamicRouter.get_canonical_serializer`.
+
+- New dynamic model fields: `dynamic_rest.fields.model`
+ - These fields add dynamic-value support to base DRF fields.
+ Dynamic values are values that can contain metadata used
+ by higher-level renderers (admin), such as styling directives.
+ At a lower-level (JSON), only the base value is rendered.
+ See `dynamic_rest.fields.DynamicChoicesField`.
+
+## Serializers
+
+- New setting: `ENABLE_SELF_LINKS`
+ - When enabled, links in representation will include reference to the current resource.
+ - Default is True.
+
+- New serializer fields, see `dynamic_rest.fields.model`
+ - These fields extend the base DRF fields with dynamic value behavior.
+
+- New serializer method: `get_url`
+ - Returns a URL to the serializer's collection or detail endpoint,
+ dependening on whether a PK is passed in.
+
+ - The URL key can be injected into the serializer by the router when
+ the serializer's view is registered. If not, this method will fallback
+ to `DynamicRouter.get_canonical_url`.
+
+- New serializer method: `get_field`
+ - Returns a serializer field by name. Can also pass in "pk" as shorthand
+
+- New serializer method: `resolve`
+ - Provides a consistent way to resolve an API-field
+ into a chain of model fields. Returns a model field list
+ and serializer field list.
+
+ - For example, consider the API field `creator.location_name`
+ on a `BlogSerializer` and underlying model path
+ `user.location.name` starting from the `Blog` model.
+ `BlogSerializer.resolve("creator.location_name")`
+ will return two paths of model and serializer fields necessary
+ to "reach" the field from the serializer.
+
+ ```
+ [
+ ("user", blog.user),
+ ("location", user.location),
+ ("name", location.name)
+ ],
+ [
+ ("creator", DynamicRelationField("UserSerializer", source="user")),
+ ("location_name", DynamicCharField(source="location.name"))
+ ]
+ ```
+
+ - The two lists do not necessarily contain the same number of elements
+ because API fields can reference nested model fields.
+
+ - Calling resolve on a method field (`source == '*'`) will cause an exception.
+
+- New serializer functionality: **nested updates**
+ - DREST serializers will now attempt to properly handle
+ nested-source fields during updates.
+
+ - For example, if there is a user with related `profile`,
+ a `UserSerializer` for the user can support updates
+ to the related profile phone by creating a field with
+ the nested source "profile.phone". Updates to the phone field
+ will be set on the profile object, which is then saved.
+
+ - If related objects do not exist, the serializer will attempt
+ to craete it using the fields in the request.
+ Multiple fields on a related model can be mapped.
+
+## Views
+
+- Fixed `sort[]` behavior around rewrites
+ - API-name to model-name rewrites are now properly handled by `sort[]`.
+
+- New views: `dynamic_rest.login` and `dynamic_rest.logout`
+ - Wraps `django.contrib.auth.views` login and logout
+ using the DREST admin login template.
+
+## Routers
+
+- Renamed option: `ROOT_VIEW_NAME` renamed to `API_NAME`
+ - Human-friendly name of the API.
+
+- New option: `API_DESCRIPTION`
+ - Human-friendly description of the API.
+
+- New option: `API_ROOT_SECURE`
+ - If enabled, the root view will redirect if the user is not authenticated.
+ - Default is False.
+
+## Renderers
+
+- New renderer: `dynamic_rest.renderers.DynamicAdminRenderer`
+ - Extends `rest_framework.renderers.AdminRenderer`, adding a
+ new, responsively designed admin UI that integrates with DREST filters
+ and relationships.
+
+ - Serializers from 1.x should work as expected, but are recommended to set
+ the following configuration options in their `Meta` class to support an
+ ideal experience.
+ - `name_field`: a human-friendly field name for records
+ - defaults to the model PK
+ - used for relationship lookup and representation
+ - e.g. `"name"`
+ - `search_key`: a filter key to search against to find records of this resource
+ - defaults to None
+ - used for search
+ - e.g: `"filter{name.icontains}"`
+ - `list_fields`: a list of fields to display within long lists
+ - defaults to all fields
+ - used for displaying the list view
+ - e.g. `["name", "description"]`
+ - `description`: a description of the resource
+ - e.g: "The Build resource represents a backend build."
+
+- New option: `ADMIN_LOGIN_URL`
+ - The login URL to use within the admin UI.
+
+- New option: `ADMIN_LOGOUT_URL`
+ - The logout URL to use within the admin UI.
+
+- New option: `ADMIN_TEMPLATE`
+ - Template file name for the admin view.
+ - Defaults to "dynamic_rest/admin.html"
+ - Customizations are possible by settings `ADMIN_TEMPLATE` to an
+ application-specific template, e.g. "app/admin.html".
+ - Common blocks to override: `bootstrap_css`, `brand`.
+ - The base UI is implemented in and requires Bootstrap 4.
+
+- New option: `ADMIN_LOGIN_TEMPLATE`
+ - Template file name for the admin login view.
+ - Defaults to "dynamic_rest/login.html"
diff --git a/README.md b/README.md
index 2f90cccf..7c46aa39 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ See http://dynamic-rest.readthedocs.org for full documentation.
- [Overview](#overview)
- [Maintainers](#maintainers)
+- [Changelog](#changelog)
- [Requirements](#requirements)
- [Installation](#installation)
- [Demo](#demo)
@@ -26,7 +27,7 @@ See http://dynamic-rest.readthedocs.org for full documentation.
- [Exclusions](#exclusions)
- [Filtering](#filtering)
- [Ordering](#ordering)
- - [Directory panel](#directory-panel)
+ - [Admin UI](#admin-ui)
- [Optimizations](#optimizations)
- [Settings](#settings)
- [Compatibility table](#compatibility-table)
@@ -49,26 +50,40 @@ DREST classes can be used as a drop-in replacement for DRF classes, which offer
* Exclusions
* Filtering
* Sorting
-* Directory panel for your Browsable API
+* Admin UI
* Optimizations
DREST was initially written to complement [Ember Data](https://github.com/emberjs/data),
but it can be used to provide fast and flexible CRUD operations to any consumer that supports JSON over HTTP.
-## Maintainers
+# Maintainers
* [Anthony Leontiev](mailto:ant@altschool.com)
* [Ryo Chijiiwa](mailto:ryo@altschool.com)
+# Changelog
+
+See the [Changelog](CHANGELOG.md).
+
# Requirements
-* Python (2.7, 3.3, 3.4, 3.5)
+* Python (2.7, 3.4, 3.5)
* Django (1.8, 1.9, 1.10, 1.11)
* Django REST Framework (3.1, 3.2, 3.3, 3.4, 3.5, 3.6)
# Installation
-1) Install using `pip`:
+1) Add and generate boilerplate using [dj](https://github.com/aleontiev/dj):
+
+```bash
+ dj add dynamic-rest
+ dj generate model part
+ dj generate api v1 part
+```
+
+OR
+
+1a) Install using `pip`:
```bash
pip install dynamic-rest
@@ -76,7 +91,7 @@ but it can be used to provide fast and flexible CRUD operations to any consumer
(or add `dynamic-rest` to `requirements.txt` or `setup.py`)
-2) Add `rest_framework` and `dynamic_rest` to `INSTALLED_APPS` in `settings.py`:
+1b) Add `rest_framework` and `dynamic_rest` to `INSTALLED_APPS` in `settings.py`:
```python
INSTALLED_APPS = (
@@ -87,14 +102,14 @@ but it can be used to provide fast and flexible CRUD operations to any consumer
```
-3) If you want to use the [Directory panel](#directory-panel), replace DRF's browsable API renderer with DREST's
-in your settings:
+2) If you want to use the [admin UI renderer](#admin-ui), update your default renderer list:
```python
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
- 'dynamic_rest.renderers.DynamicBrowsableAPIRenderer',
+ 'dynamic_rest.renderers.DynamicAdminRenderer',
+ 'rest_framework.renderers.BrowsableAPIRenderer',
],
}
```
@@ -537,12 +552,15 @@ You can use the `sort[]` feature to order your response by one or more fields. D
...
```
-## Directory panel
+## Admin UI
+
+DREST includes a revamped version of DRF's `AdminRenderer`, which includes:
-We love the DRF browsable API, but wish that it included a directory that would let you see your entire list of endpoints at a glance from any page.
-DREST adds that in:
+- Human-friendly record names, labels, and tooltips
+- Integration with DREST relationships
+- Responsive layout built on Bootstrap 4
-![Directory panel][directory]
+![Admin UI][admin-ui]
## Optimizations
@@ -563,134 +581,21 @@ Cubic benchmark: rendering a list of lists of lists
# Settings
-DREST is configurable, and all settings should be nested under a single block in your `settings.py` file.
-Here are our [defaults](dynamic_rest/conf.py):
+All [DREST settings](dynamic_rest/conf.py) should be nested under a single block in your `settings.py` file.
+
+# Compatibility
+
+See the [tox file](tox.ini) for all combinations of Python, Django, and DRF that we support.
-```python
-DYNAMIC_REST = {
- # DEBUG: enable/disable internal debugging
- 'DEBUG': False,
-
- # ENABLE_BROWSABLE_API: enable/disable the browsable API.
- # It can be useful to disable it in production.
- 'ENABLE_BROWSABLE_API': True,
-
- # ENABLE_LINKS: enable/disable relationship links
- 'ENABLE_LINKS': True,
-
- # ENABLE_SERIALIZER_CACHE: enable/disable caching of related serializers
- 'ENABLE_SERIALIZER_CACHE': True,
-
- # ENABLE_SERIALIZER_OPTIMIZATIONS: enable/disable representation speedups
- 'ENABLE_SERIALIZER_OPTIMIZATIONS': True,
-
- # DEFER_MANY_RELATIONS: automatically defer many-relations, unless
- # `deferred=False` is explicitly set on the field.
- 'DEFER_MANY_RELATIONS': False,
-
- # MAX_PAGE_SIZE: global setting for max page size.
- # Can be overriden at the viewset level.
- 'MAX_PAGE_SIZE': None,
-
- # PAGE_QUERY_PARAM: global setting for the pagination query parameter.
- # Can be overriden at the viewset level.
- 'PAGE_QUERY_PARAM': 'page',
-
- # PAGE_SIZE: global setting for page size.
- # Can be overriden at the viewset level.
- 'PAGE_SIZE': None,
-
- # PAGE_SIZE_QUERY_PARAM: global setting for the page size query parameter.
- # Can be overriden at the viewset level.
- 'PAGE_SIZE_QUERY_PARAM': 'per_page',
-
- # ADDITIONAL_PRIMARY_RESOURCE_PREFIX: String to prefix additional
- # instances of the primary resource when sideloading.
- 'ADDITIONAL_PRIMARY_RESOURCE_PREFIX': '+',
-
- # Enables host-relative links. Only compatible with resources registered
- # through the dynamic router. If a resource doesn't have a canonical
- # path registered, links will default back to being resource-relative urls
- 'ENABLE_HOST_RELATIVE_LINKS': True
-}
-```
-
-# Compatibility table
-
-Not all versions of Python, Django, and DRF are compatible. Here are the combinations you can use reliably with DREST (all tested by our tox configuration):
-
-| Python | Django | DRF | OK |
-| ------ | ------ | --- | --- |
-| 2.7 | 1.8 | 3.1 | YES |
-| 2.7 | 1.8 | 3.2 | YES |
-| 2.7 | 1.8 | 3.3 | YES |
-| 2.7 | 1.8 | 3.4 | YES |
-| 2.7 | 1.9 | 3.1 | NO1 |
-| 2.7 | 1.9 | 3.2 | YES |
-| 2.7 | 1.9 | 3.3 | YES |
-| 2.7 | 1.9 | 3.4 | YES |
-| 2.7 | 1.10 | 3.2 | NO3 |
-| 2.7 | 1.10 | 3.3 | NO3 |
-| 2.7 | 1.10 | 3.4 | YES |
-| 2.7 | 1.10 | 3.5 | YES |
-| 2.7 | 1.10 | 3.6 | YES |
-| 2.7 | 1.11 | 3.4 | YES |
-| 2.7 | 1.11 | 3.5 | YES |
-| 2.7 | 1.11 | 3.6 | YES |
-| 3.3 | 1.8 | 3.1 | YES |
-| 3.3 | 1.8 | 3.2 | YES |
-| 3.3 | 1.8 | 3.3 | YES |
-| 3.3 | 1.8 | 3.4 | YES |
-| 3.3 | 1.9 | x.x | NO2 |
-| 3.3 | 1.10 | x.x | NO4 |
-| 3.3 | 1.11 | x.x | NO5 |
-| 3.4 | 1.8 | 3.1 | YES |
-| 3.4 | 1.8 | 3.2 | YES |
-| 3.4 | 1.8 | 3.3 | YES |
-| 3.4 | 1.8 | 3.4 | YES |
-| 3.4 | 1.9 | 3.1 | NO1 |
-| 3.4 | 1.9 | 3.2 | YES |
-| 3.4 | 1.9 | 3.3 | YES |
-| 3.4 | 1.9 | 3.4 | YES |
-| 3.4 | 1.10 | 3.2 | NO3 |
-| 3.4 | 1.10 | 3.3 | NO3 |
-| 3.4 | 1.10 | 3.4 | YES |
-| 3.4 | 1.10 | 3.5 | YES |
-| 3.4 | 1.10 | 3.6 | YES |
-| 3.4 | 1.11 | 3.3 | NO3 |
-| 3.4 | 1.11 | 3.4 | YES |
-| 3.4 | 1.11 | 3.5 | YES |
-| 3.5 | 1.8 | 3.1 | YES |
-| 3.5 | 1.8 | 3.2 | YES |
-| 3.5 | 1.8 | 3.3 | YES |
-| 3.5 | 1.8 | 3.4 | YES |
-| 3.5 | 1.9 | 3.1 | NO1 |
-| 3.5 | 1.9 | 3.2 | YES |
-| 3.5 | 1.9 | 3.3 | YES |
-| 3.5 | 1.9 | 3.4 | YES |
-| 3.5 | 1.10 | 3.2 | NO3 |
-| 3.5 | 1.10 | 3.3 | NO3 |
-| 3.5 | 1.10 | 3.4 | YES |
-| 3.5 | 1.10 | 3.5 | YES |
-| 3.5 | 1.10 | 3.6 | YES |
-| 3.5 | 1.11 | 3.4 | YES |
-| 3.5 | 1.11 | 3.5 | YES |
-| 3.5 | 1.11 | 3.6 | YES |
-
-* 1: Django 1.9 is not compatible with DRF 3.1
-* 2: Django 1.9 is not compatible with Python 3.3
-* 3: Django 1.10 is only compatible with DRF 3.4+
-* 4: Django 1.10 requires Python 2.7, 3.4, 3.5
-* 5: Django 1.11 requires Python 2.7, 3.4, 3.5, 3.6
# Contributing
-See [Contributing](CONTRIBUTING.md).
+See the [contributing guide](CONTRIBUTING.md) for more information about contributing to DREST.
# License
-See [License](LICENSE.md).
+See the [license](LICENSE.md) for legal information.
-[directory]: images/directory.png
+[admin-ui]: images/admin-ui.png
[benchmark-linear]: images/benchmark-linear.png
[benchmark-quadratic]: images/benchmark-quadratic.png
[benchmark-cubic]: images/benchmark-cubic.png
diff --git a/benchmarks.html b/benchmarks.html
index 977f0870..bea343e0 100644
--- a/benchmarks.html
+++ b/benchmarks.html
@@ -36,7 +36,7 @@
verticalAlign: 'middle',
borderWidth: 0
},
- series: [{"data": [[3, 0.010598], [14, 0.0129435], [39, 0.0169945], [84, 0.0209405], [155, 0.029427500000000002], [258, 0.0405075], [399, 0.048523], [584, 0.0625845], [819, 0.084913], [1110, 0.100662], [1463, 0.12601800000000002], [1884, 0.156378], [2379, 0.19036199999999998], [2954, 0.223039], [3615, 0.29465399999999997], [4368, 0.356249]], "name": "DREST 1.3.9"}, {"data": [[3, 0.004224], [14, 0.0083045], [39, 0.013636], [84, 0.0233625], [155, 0.034529000000000004], [258, 0.052696], [399, 0.06697], [584, 0.091702], [819, 0.127831], [1110, 0.1734345], [1463, 0.21316000000000002], [1884, 0.2423265], [2379, 0.2996175], [2954, 0.39503699999999997], [3615, 0.4300615], [4368, 0.579607]], "name": "DRF 3.3.0"}]
+ series: [{"data": [[3, 0.012709499999999999], [14, 0.014771], [39, 0.017771], [84, 0.022964], [155, 0.030886], [258, 0.0383965], [399, 0.0532185], [584, 0.062736], [819, 0.081077], [1110, 0.140235], [1463, 0.133131], [1884, 0.161371], [2379, 0.21393250000000003], [2954, 0.275948], [3615, 0.312623], [4368, 0.35323]], "name": "DREST 2.0.0"}, {"data": [[3, 0.00572], [14, 0.007874], [39, 0.012481], [84, 0.025058], [155, 0.03508], [258, 0.0489175], [399, 0.070116], [584, 0.0936625], [819, 0.118194], [1110, 0.206882], [1463, 0.229178], [1884, 0.236276], [2379, 0.298641], [2954, 0.399391], [3615, 0.426536], [4368, 0.479243]], "name": "DRF 3.6.2"}]
});
});
@@ -75,7 +75,7 @@
verticalAlign: 'middle',
borderWidth: 0
},
- series: [{"data": [[256, 0.013802499999999999], [512, 0.022781000000000003], [768, 0.0313405], [1024, 0.043452], [1280, 0.053339], [1536, 0.060793], [1792, 0.07044500000000001], [2048, 0.0799765], [2304, 0.09236649999999999], [2560, 0.09833549999999999], [2816, 0.10974600000000001], [3072, 0.1534385], [3328, 0.1260365], [3584, 0.14711249999999998], [3840, 0.15910649999999998], [4096, 0.1562075]], "name": "DREST 1.3.9"}, {"data": [[256, 0.185573], [512, 0.37659200000000004], [768, 0.5544685], [1024, 0.762219], [1280, 0.9522345], [1536, 1.1424555], [1792, 1.3354335], [2048, 1.4902134999999999], [2304, 1.6737704999999998], [2560, 1.9133445], [2816, 1.9982449999999998], [3072, 2.3125815000000003], [3328, 2.449006], [3584, 2.68817], [3840, 2.7430269999999997], [4096, 2.9553125]], "name": "DRF 3.3.0"}]
+ series: [{"data": [[256, 0.012856], [512, 0.023586500000000003], [768, 0.031781000000000004], [1024, 0.0433255], [1280, 0.048382], [1536, 0.0812905], [1792, 0.0801585], [2048, 0.080902], [2304, 0.087063], [2560, 0.096558], [2816, 0.106538], [3072, 0.1137445], [3328, 0.1438885], [3584, 0.136525], [3840, 0.184981], [4096, 0.15157700000000002]], "name": "DREST 2.0.0"}, {"data": [[256, 0.17829], [512, 0.368021], [768, 0.559124], [1024, 0.7413295], [1280, 0.911822], [1536, 1.173424], [1792, 1.3320925], [2048, 1.64994], [2304, 1.741455], [2560, 1.930515], [2816, 2.054733], [3072, 2.2834725000000002], [3328, 2.6331245], [3584, 2.6160655], [3840, 2.770563], [4096, 3.0086880000000003]], "name": "DRF 3.6.2"}]
});
});
@@ -114,7 +114,7 @@
verticalAlign: 'middle',
borderWidth: 0
},
- series: [{"data": [[20, 0.008997], [72, 0.013781999999999999], [156, 0.018541500000000002], [272, 0.0260965], [420, 0.034469], [600, 0.041933], [812, 0.053789000000000003], [1056, 0.069213], [1332, 0.081873], [1640, 0.097342], [1980, 0.135785], [2352, 0.1346085], [2756, 0.18510549999999998], [3192, 0.175554], [3660, 0.2170925], [4160, 0.2353975]], "name": "DREST 1.3.9"}, {"data": [[20, 0.007074], [72, 0.0140755], [156, 0.0215725], [272, 0.032983], [420, 0.0472835], [600, 0.062189], [812, 0.0772765], [1056, 0.105318], [1332, 0.12905250000000001], [1640, 0.1433465], [1980, 0.1805545], [2352, 0.222216], [2756, 0.25613400000000003], [3192, 0.3107965], [3660, 0.34135099999999996], [4160, 0.38530699999999996]], "name": "DRF 3.3.0"}]
+ series: [{"data": [[20, 0.012109], [72, 0.0135365], [156, 0.0191795], [272, 0.026558], [420, 0.0385815], [600, 0.047727], [812, 0.073093], [1056, 0.0703125], [1332, 0.084044], [1640, 0.105789], [1980, 0.11506], [2352, 0.14081], [2756, 0.16327], [3192, 0.172203], [3660, 0.237317], [4160, 0.21132099999999998]], "name": "DREST 2.0.0"}, {"data": [[20, 0.006505], [72, 0.012369], [156, 0.0221435], [272, 0.031177], [420, 0.0600235], [600, 0.060455], [812, 0.079007], [1056, 0.111045], [1332, 0.120839], [1640, 0.160524], [1980, 0.223235], [2352, 0.250813], [2756, 0.270025], [3192, 0.287625], [3660, 0.286124], [4160, 0.36744299999999996]], "name": "DRF 3.6.2"}]
});
});
diff --git a/benchmarks/urls.py b/benchmarks/urls.py
index ba38d937..3fba7bc1 100644
--- a/benchmarks/urls.py
+++ b/benchmarks/urls.py
@@ -1,10 +1,9 @@
-from django.conf.urls import include, patterns, url
+from django.conf.urls import include, url
from .drest import router as drest_router
from .drf import router as drf_router
-urlpatterns = patterns(
- '',
+urlpatterns = [
url(r'^', include(drf_router.urls)),
url(r'^', include(drest_router.urls)),
-)
+]
diff --git a/circle.yml b/circle.yml
index 92348cbc..ae0de359 100644
--- a/circle.yml
+++ b/circle.yml
@@ -3,10 +3,9 @@ dependencies:
- pip install -r requirements.txt
- python setup.py develop
- pyenv install -s 2.7.10
- - pyenv install -s 3.3.3
- pyenv install -s 3.4.3
- pyenv install -s 3.5.0
- - pyenv local 2.7.10 3.3.3 3.4.3 3.5.0
+ - pyenv local 2.7.10 3.4.3 3.5.0
test:
override:
diff --git a/dj.yml b/dj.yml
new file mode 100644
index 00000000..5973eabd
--- /dev/null
+++ b/dj.yml
@@ -0,0 +1,4 @@
+devRequirements: requirements.txt.dev
+name: dynamic_rest
+requirements: requirements.txt
+runtime: system
diff --git a/dynamic_rest/base.py b/dynamic_rest/base.py
new file mode 100644
index 00000000..fac2d679
--- /dev/null
+++ b/dynamic_rest/base.py
@@ -0,0 +1,2 @@
+class DynamicBase(object):
+ pass
diff --git a/dynamic_rest/bases.py b/dynamic_rest/bases.py
deleted file mode 100644
index ac04f3c2..00000000
--- a/dynamic_rest/bases.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""This module contains base classes for DREST."""
-
-
-class DynamicSerializerBase(object):
-
- """Base class for all DREST serializers."""
- pass
diff --git a/dynamic_rest/blueprints/api/templates/{{app}}/api/{{version}}/urls.py.j2 b/dynamic_rest/blueprints/api/templates/{{app}}/api/{{version}}/urls.py.j2
index 344d2e07..ad104c56 100644
--- a/dynamic_rest/blueprints/api/templates/{{app}}/api/{{version}}/urls.py.j2
+++ b/dynamic_rest/blueprints/api/templates/{{app}}/api/{{version}}/urls.py.j2
@@ -7,8 +7,17 @@ for name in dir(views):
view = getattr(views, name, None)
if (
isclass(view) and
- hasattr(view, 'serializer_class')
+ hasattr(view, 'get_serializer_class')
):
- router.register_resource(view)
+ view_instance = view()
+ serializer = view_instance.get_serializer_class()
+ plural_name = serializer.get_plural_name()
+ name = serializer.get_name()
+ router.register(
+ r'%s' % plural_name,
+ view,
+ name,
+ namespace='{{version}}'
+ )
urlpatterns = router.urls
diff --git a/dynamic_rest/bound.py b/dynamic_rest/bound.py
new file mode 100644
index 00000000..1f3a2baf
--- /dev/null
+++ b/dynamic_rest/bound.py
@@ -0,0 +1,69 @@
+import json
+from rest_framework.compat import unicode_to_repr
+
+
+class DynamicBoundField(object):
+ """
+ A field object that also includes `.value` and `.error` properties
+ as well as `.instance`.
+ Returned when iterating over a serializer instance,
+ providing an API similar to Django forms and form fields.
+ """
+
+ def __init__(self, field, value, errors, prefix='', instance=None):
+ self._field = field
+ self._prefix = prefix
+ self.value = value
+ self.errors = errors
+ self.instance = instance
+ self.name = prefix + self.field_name
+
+ def __getattr__(self, attr_name):
+ return getattr(self._field, attr_name)
+
+ @property
+ def _proxy_class(self):
+ return self._field.__class__
+
+ def __repr__(self):
+ return unicode_to_repr('<%s %s value=%s errors=%s instance=%s>' % (
+ self.__class__.__name__,
+ self._field.field_name,
+ self.value,
+ self.errors,
+ self.instance
+ ))
+
+ def get_rendered_value(self):
+ if not hasattr(self, '_rendered_value'):
+ if callable(getattr(self._field, 'admin_render', None)):
+ self._rendered_value = self._field.admin_render(
+ instance=self.instance, value=self.value
+ )
+ else:
+ self._rendered_value = self.value
+ return self._rendered_value
+
+ def as_form_field(self):
+ value = '' if (
+ self.value is None or self.value is False
+ ) else self.value
+ return self.__class__(
+ self._field,
+ value,
+ self.errors,
+ self._prefix,
+ self.instance
+ )
+
+
+class DynamicJSONBoundField(DynamicBoundField):
+ def as_form_field(self):
+ value = self.value
+ try:
+ value = json.dumps(self.value, sort_keys=True, indent=4)
+ except TypeError:
+ pass
+ return self.__class__(
+ self._field, value, self.errors, self._prefix, self.instance
+ )
diff --git a/dynamic_rest/compat.py b/dynamic_rest/compat.py
new file mode 100644
index 00000000..d17b0f85
--- /dev/null
+++ b/dynamic_rest/compat.py
@@ -0,0 +1,159 @@
+# flake8: noqa
+from __future__ import absolute_import
+
+from django.utils import six
+from django import VERSION
+
+DJANGO110 = VERSION >= (1, 10)
+try:
+ from django.urls import (
+ NoReverseMatch,
+ RegexURLPattern,
+ RegexURLResolver,
+ ResolverMatch,
+ Resolver404,
+ get_script_prefix,
+ reverse,
+ reverse_lazy,
+ resolve
+ )
+except ImportError:
+ from django.core.urlresolvers import ( # Will be removed in Django 2.0
+ NoReverseMatch,
+ RegexURLPattern,
+ RegexURLResolver,
+ ResolverMatch,
+ Resolver404,
+ get_script_prefix,
+ reverse,
+ reverse_lazy,
+ resolve
+ )
+
+
+def set_many(instance, field, value):
+ if DJANGO110:
+ field = getattr(instance, field)
+ field.set(value)
+ else:
+ setattr(instance, field, value)
+
+
+try:
+ from rest_framework.relations import Hyperlink
+except ImportError:
+ class Hyperlink(six.text_type):
+ """
+ A string like object that additionally has an associated name.
+ We use this for hyperlinked URLs that may render as a named link
+ in some contexts, or render as a plain URL in others.
+
+ Taken from DRF 3.2, used for compatability with DRF 3.1.
+ TODO(compat): remove when we drop compat for DRF 3.1.
+ """
+ def __new__(self, url, obj):
+ ret = six.text_type.__new__(self, url)
+ ret.obj = obj
+ return ret
+
+ def __getnewargs__(self):
+ return(str(self), self.name,)
+
+ @property
+ def name(self):
+ # This ensures that we only called `__str__` lazily,
+ # as in some cases calling __str__ on a model instances *might*
+ # involve a database lookup.
+ return six.text_type(self.obj)
+
+ is_hyperlink = True
+
+try:
+ from rest_framework.renderers import AdminRenderer
+except ImportError:
+ from django.template import RequestContext, loader
+ from rest_framework.request import override_method
+ from rest_framework.renderers import BrowsableAPIRenderer
+
+ class AdminRenderer(BrowsableAPIRenderer):
+ template = 'rest_framework/admin.html'
+ format = 'admin'
+
+ def render(self, data, accepted_media_type=None, renderer_context=None):
+ self.accepted_media_type = accepted_media_type or ''
+ self.renderer_context = renderer_context or {}
+
+ response = renderer_context['response']
+ request = renderer_context['request']
+ view = self.renderer_context['view']
+
+ if response.status_code == 400:
+ # Errors still need to display the list or detail information.
+ # The only way we can get at that is to simulate a GET request.
+ self.error_form = self.get_rendered_html_form(data, view, request.method, request)
+ self.error_title = {'POST': 'Create', 'PUT': 'Edit'}.get(request.method, 'Errors')
+
+ with override_method(view, request, 'GET') as request:
+ response = view.get(request, *view.args, **view.kwargs)
+ data = response.data
+
+ template = loader.get_template(self.template)
+ context = self.get_context(data, accepted_media_type, renderer_context)
+ context = RequestContext(renderer_context['request'], context)
+ ret = template.render(context)
+
+ # Creation and deletion should use redirects in the admin style.
+ if (response.status_code == 201) and ('Location' in response):
+ response.status_code = 302
+ response['Location'] = request.build_absolute_uri()
+ ret = ''
+
+ if response.status_code == 204:
+ response.status_code = 302
+ try:
+ # Attempt to get the parent breadcrumb URL.
+ response['Location'] = self.get_breadcrumbs(request)[-2][1]
+ except KeyError:
+ # Otherwise reload current URL to get a 'Not Found' page.
+ response['Location'] = request.full_path
+ ret = ''
+
+ return ret
+
+ def get_context(self, data, accepted_media_type, renderer_context):
+ """
+ Render the HTML for the browsable API representation.
+ """
+ context = super(AdminRenderer, self).get_context(
+ data, accepted_media_type, renderer_context
+ )
+
+ paginator = getattr(context['view'], 'paginator', None)
+ if (paginator is not None and data is not None):
+ try:
+ results = paginator.get_results(data)
+ except KeyError:
+ results = data
+ else:
+ results = data
+
+ if results is None:
+ header = {}
+ style = 'detail'
+ elif isinstance(results, list):
+ header = results[0] if results else {}
+ style = 'list'
+ else:
+ header = results
+ style = 'detail'
+
+ columns = [key for key in header.keys() if key != 'url']
+ details = [key for key in header.keys() if key != 'url']
+
+ context['style'] = style
+ context['columns'] = columns
+ context['details'] = details
+ context['results'] = results
+ context['error_form'] = getattr(self, 'error_form', None)
+ context['error_title'] = getattr(self, 'error_title', None)
+ return context
diff --git a/dynamic_rest/conf.py b/dynamic_rest/conf.py
index 7cf0fbb0..9146f6ed 100644
--- a/dynamic_rest/conf.py
+++ b/dynamic_rest/conf.py
@@ -2,32 +2,69 @@
from django.test.signals import setting_changed
DYNAMIC_REST = {
+ # API_NAME: Name of the API
+ 'API_NAME': 'DREST',
+
+ # API_DESCRIPTION: Description of the API
+ 'API_DESCRIPTION': 'My DREST API',
+
+ # API_ROOT_SECURE: whether or not the root API view requires authentication
+ 'API_ROOT_SECURE': False,
+
+ # API_ROOT_URL: API root URL
+ 'API_ROOT_URL': '/',
+
+ # ADDITIONAL_PRIMARY_RESOURCE_PREFIX: String to prefix additional
+ # instances of the primary resource when sideloading.
+ 'ADDITIONAL_PRIMARY_RESOURCE_PREFIX': '+',
+
+ # ADMIN_TEMPLATE: Name of the admin template
+ # Override this to add custom styling or UI
+ 'ADMIN_TEMPLATE': 'dynamic_rest/admin.html',
+
+ # ADMIN_LOGIN_TEMPLATE: the login template used to render login UI
+ 'ADMIN_LOGIN_TEMPLATE': 'dynamic_rest/login.html',
+
+ # ADMIN_LOGIN_URL: the login URL, defaults to reverse-URL lookup
+ 'ADMIN_LOGIN_URL': '',
+
+ # ADMIN_LOGOUT_URL: the logout URL, defaults to reverse-URL lookup
+ 'ADMIN_LOGOUT_URL': '',
+
# DEBUG: enable/disable internal debugging
'DEBUG': False,
+ # DEFER_MANY_RELATIONS: automatically defer many-relations, unless
+ # `deferred=False` is explicitly set on the field.
+ 'DEFER_MANY_RELATIONS': False,
+
# ENABLE_BROWSABLE_API: enable/disable the browsable API.
# It can be useful to disable it in production.
'ENABLE_BROWSABLE_API': True,
+ # ENABLE_BULK_PARTIAL_CREATION: enable/disable partial creation in bulk
+ 'ENABLE_BULK_PARTIAL_CREATION': False,
+
+ # ENABLE_BULK_UPDATE: enable/disable update in bulk
+ 'ENABLE_BULK_UPDATE': True,
+
# ENABLE_LINKS: enable/disable relationship links
'ENABLE_LINKS': True,
+ # Enables host-relative links. Only compatible with resources registered
+ # through the dynamic router. If a resource doesn't have a canonical
+ # path registered, links will default back to being resource-relative urls
+ 'ENABLE_HOST_RELATIVE_LINKS': True,
+
+ # ENABLE_SELF_LINKS: enable/disable links to self
+ 'ENABLE_SELF_LINKS': True,
+
# ENABLE_SERIALIZER_CACHE: enable/disable caching of related serializers
'ENABLE_SERIALIZER_CACHE': True,
# ENABLE_SERIALIZER_OPTIMIZATIONS: enable/disable representation speedups
'ENABLE_SERIALIZER_OPTIMIZATIONS': True,
- # ENABLE_BULK_PARTIAL_CREATION: enable/disable partial creation in bulk
- 'ENABLE_BULK_PARTIAL_CREATION': False,
-
- # ENABLE_BULK_UPDATE: enable/disable update in bulk
- 'ENABLE_BULK_UPDATE': True,
-
- # DEFER_MANY_RELATIONS: automatically defer many-relations, unless
- # `deferred=False` is explicitly set on the field.
- 'DEFER_MANY_RELATIONS': False,
-
# MAX_PAGE_SIZE: global setting for max page size.
# Can be overriden at the viewset level.
'MAX_PAGE_SIZE': None,
@@ -43,15 +80,6 @@
# PAGE_SIZE_QUERY_PARAM: global setting for the page size query parameter.
# Can be overriden at the viewset level.
'PAGE_SIZE_QUERY_PARAM': 'per_page',
-
- # ADDITIONAL_PRIMARY_RESOURCE_PREFIX: String to prefix additional
- # instances of the primary resource when sideloading.
- 'ADDITIONAL_PRIMARY_RESOURCE_PREFIX': '+',
-
- # Enables host-relative links. Only compatible with resources registered
- # through the dynamic router. If a resource doesn't have a canonical
- # path registered, links will default back to being resource-relative urls
- 'ENABLE_HOST_RELATIVE_LINKS': True,
}
diff --git a/dynamic_rest/fields/__init__.py b/dynamic_rest/fields/__init__.py
index 49c3095a..99c5f265 100644
--- a/dynamic_rest/fields/__init__.py
+++ b/dynamic_rest/fields/__init__.py
@@ -1,2 +1,10 @@
-from dynamic_rest.fields.fields import * # noqa
-from dynamic_rest.fields.generic import * # noqa
+# flake8: noqa
+from .base import (
+ DynamicField,
+ CountField,
+ DynamicComputedField
+)
+from .relation import DynamicRelationField, DynamicCreatorField
+from .generic import DynamicGenericRelationField
+from .choices import DynamicChoicesField
+from .model import *
diff --git a/dynamic_rest/fields/base.py b/dynamic_rest/fields/base.py
new file mode 100644
index 00000000..36c42766
--- /dev/null
+++ b/dynamic_rest/fields/base.py
@@ -0,0 +1,327 @@
+from rest_framework import fields
+from uuid import UUID
+from django.utils import six
+from django.db.models import QuerySet
+from django.core.exceptions import ObjectDoesNotExist
+from dynamic_rest.meta import get_model_field, is_field_remote
+from dynamic_rest.base import DynamicBase
+from rest_framework.fields import ListField
+
+
+class DynamicField(fields.Field, DynamicBase):
+
+ """
+ Generic field base to capture additional custom field attributes.
+ """
+
+ def __init__(
+ self,
+ *args,
+ **kwargs
+ ):
+ """
+ Arguments:
+ deferred: Whether or not this field is deferred.
+ Deferred fields are not included in the response,
+ unless explicitly requested.
+ field_type: Field data type, if not inferrable from model.
+ requires: List of fields that this field depends on.
+ Processed by the view layer during queryset build time.
+ immutable: True if the field cannot be updated
+ get_classes: a parent serializer method name that should
+ return a list of classes to apply
+ getter: name of a method to call on the parent serializer for
+ reading related objects.
+ If source is '*', this will default to 'get_$FIELD_NAME'.
+ setter: name of a method to call on the parent serializer for
+ saving related objects.
+ If source is '*', this will default to 'set_$FIELD_NAME'.
+ """
+ source = kwargs.get('source', None)
+ self.requires = kwargs.pop('requires', None)
+ self.deferred = kwargs.pop('deferred', None)
+ self.field_type = kwargs.pop('field_type', None)
+ self.immutable = kwargs.pop('immutable', False)
+ self.get_classes = kwargs.pop('get_classes', None)
+ self.getter = kwargs.pop('getter', None)
+ self.setter = kwargs.pop('setter', None)
+ self.bound = False
+ if self.getter or self.setter:
+ # dont bind to fields
+ kwargs['source'] = '*'
+ elif source == '*':
+ # use default getter/setter
+ self.getter = self.getter or '*'
+ self.setter = self.setter or '*'
+ self.kwargs = kwargs
+ super(DynamicField, self).__init__(*args, **kwargs)
+
+ def bind(self, *args, **kwargs):
+ """Bind to the parent serializer."""
+ if self.bound: # Prevent double-binding
+ return
+
+ super(DynamicField, self).bind(*args, **kwargs)
+ self.bound = True
+
+ source = self.source
+ if source == '*':
+ if self.getter == '*':
+ self.getter = 'get_%s' % self.field_name
+ if self.setter == '*':
+ self.setter = 'set_%s' % self.field_name
+ return
+
+ parent_model = self.parent_model
+ if parent_model:
+ remote = is_field_remote(parent_model, source)
+ model_field = self.model_field
+
+ # Infer `required` and `allow_null`
+ if 'required' not in self.kwargs and (
+ remote or (
+ model_field and (
+ ( hasattr( model_field, 'has_default') and model_field.has_default() )
+ or ( hasattr( model_field, 'null') and model_field.null )
+ )
+ )
+ ):
+ self.required = False
+ if 'allow_null' not in self.kwargs and getattr(
+ model_field, 'null', False
+ ):
+ self.allow_null = True
+
+ def get_format(self):
+ return self.parent.get_format()
+
+ def prepare_value(self, instance):
+ if instance is None:
+ return None
+ value = None
+ many = self.kwargs.get('many', False)
+ getter = self.getter
+ if getter:
+ # use custom getter to get the value
+ getter = getattr(self.parent, getter)
+ if isinstance(instance, list):
+ value = [getter(i) for i in instance]
+ else:
+ value = getter(instance)
+ else:
+ # use source to get the value
+ source = self.source or self.field_name
+ sources = source.split('.')
+ value = instance
+ for source in sources:
+ if source == '*':
+ break
+ try:
+ value = getattr(value, source)
+ except (ObjectDoesNotExist, AttributeError):
+ return None
+ if (
+ value and
+ many and
+ callable(getattr(value, 'all', None))
+ ):
+ # get list from manager
+ value = value.all()
+
+ if value and many:
+ value = list(value)
+ if isinstance(value, QuerySet):
+ value = list(value)
+
+ return value
+
+ def admin_get_label(self, instance, value):
+ result = value
+ if isinstance(result, list):
+ return ', '.join([str(r) for r in result])
+ return result
+
+ def admin_get_url(self, instance, value):
+ serializer = self.parent
+ name_field = serializer.get_name_field()
+ if name_field == self.field_name:
+ return serializer.get_url(instance.pk)
+
+ def admin_get_classes(self, instance, value=None):
+ # override this to set custom CSS based on value
+ parent = self.parent
+ getter = self.get_classes
+ if not getter:
+ name_field_name = parent.get_name_field()
+ if self.field_name == name_field_name:
+ getter = parent.get_class_getter()
+ if getter and instance and parent:
+ return getattr(parent, getter)(instance)
+ return None
+
+ def admin_get_icon(self, instance, value):
+ serializer = self.parent
+ name_field = serializer.get_name_field()
+ if name_field == self.field_name:
+ return serializer.get_icon()
+
+ return None
+
+ def admin_render(self, instance, value=None):
+ value = value or self.prepare_value(instance)
+ if isinstance(value, list) and not isinstance(
+ value, six.string_types
+ ) and not isinstance(value, UUID) and not isinstance(
+ instance, list
+ ):
+ ret = [
+ self.admin_render(instance, v)
+ for v in value
+ ]
+ return ', '.join(ret)
+
+ # URL link or None
+ url = self.admin_get_url(instance, value)
+ # list of classes or None
+ classes = self.admin_get_classes(instance, value) or []
+ classes.append('drest-value')
+ # name of an icon or None
+ icon = self.admin_get_icon(instance, value)
+ # label or None
+ label = self.admin_get_label(instance, value)
+ if not label:
+ label = '– '
+
+ tag = 'a' if url else 'span'
+ result = label or value
+
+ if icon:
+ result = """
+
+
+ {2}
+
+ """.format('fa', icon, result)
+
+ result = '<{0} {3} class="{1}">{2}{0}>'.format(
+ tag,
+ ' '.join(classes),
+ result,
+ ('href="%s"' % url) if url else ''
+ )
+
+ return result
+
+ def to_internal_value(self, value):
+ return value
+
+ def to_representation(self, value):
+ try:
+ return super(DynamicField, self).to_representation(value)
+ except:
+ return value
+
+ @property
+ def parent_model(self):
+ if not hasattr(self, '_parent_model'):
+ parent = self.parent
+ if isinstance(parent, ListField):
+ parent = parent.parent
+ if parent:
+ self._parent_model = getattr(parent.Meta, 'model', None)
+ else:
+ return None
+ return self._parent_model
+
+ @property
+ def model_field(self):
+ if not hasattr(self, '_model_field'):
+ try:
+ self._model_field = get_model_field(
+ self.parent_model, self.source
+ )
+ except:
+ self._model_field = None
+ return self._model_field
+
+
+class DynamicComputedField(DynamicField):
+ def __init__(self, *args, **kwargs):
+ kwargs['read_only'] = True
+ super(DynamicComputedField, self).__init__(*args, **kwargs)
+
+
+class CountField(DynamicComputedField):
+
+ """
+ Computed field that counts the number of elements in another field.
+ """
+
+ def __init__(self, serializer_source, *args, **kwargs):
+ """
+ Arguments:
+ serializer_source: A serializer field.
+ unique: Whether or not to perform a count of distinct elements.
+ """
+ self.field_type = int
+ # Use `serializer_source`, which indicates a field at the API level,
+ # instead of `source`, which indicates a field at the model level.
+ self.serializer_source = serializer_source
+ # Set `source` to an empty value rather than the field name to avoid
+ # an attempt to look up this field.
+ kwargs['source'] = '*'
+ self.unique = kwargs.pop('unique', True)
+ return super(CountField, self).__init__(*args, **kwargs)
+
+ def get_attribute(self, obj):
+ source = self.serializer_source
+ try:
+ field = self.parent.fields[source]
+ except:
+ return None
+
+ value = field.get_attribute(obj)
+ data = field.to_representation(value)
+
+ # How to count None is undefined... let the consumer decide.
+ if data is None:
+ return None
+
+ # Check data type. Technically len() works on dicts, strings, but
+ # since this is a "count" field, we'll limit to list, set, tuple.
+ if not isinstance(data, (list, set, tuple)):
+ raise TypeError(
+ '"%s" is %s (%s). '
+ "Must be list, set or tuple to be countable." % (
+ source, data, type(data)
+ )
+ )
+
+ if self.unique:
+ # Try to create unique set. This may fail if `data` contains
+ # non-hashable elements (like dicts).
+ try:
+ data = set(data)
+ except TypeError:
+ pass
+
+ return len(data)
+
+
+class WithRelationalFieldMixin(object):
+ """Mostly code shared by DynamicRelationField and
+ DynamicGenericRelationField.
+ """
+
+ def _get_request_fields_from_parent(self):
+ """Get request fields from the parent serializer."""
+ if not self.parent:
+ return None
+
+ if not getattr(self.parent, 'request_fields'):
+ return None
+
+ if not isinstance(self.parent.request_fields, dict):
+ return None
+
+ return self.parent.request_fields.get(self.field_name)
diff --git a/dynamic_rest/fields/choices.py b/dynamic_rest/fields/choices.py
new file mode 100644
index 00000000..3e2bf996
--- /dev/null
+++ b/dynamic_rest/fields/choices.py
@@ -0,0 +1,16 @@
+from .base import DynamicField
+from rest_framework.serializers import ChoiceField
+from dynamic_rest.meta import Meta
+
+
+class DynamicChoicesField(
+ DynamicField,
+ ChoiceField,
+):
+ def prepare_value(self, instance):
+ model = self.parent_model
+ source = self.source or self.field_name
+ choices = Meta(model).get_field(source).choices
+ value = getattr(instance, source)
+ choice = dict(choices).get(value)
+ return choice
diff --git a/dynamic_rest/fields/common.py b/dynamic_rest/fields/common.py
deleted file mode 100644
index 6cfab182..00000000
--- a/dynamic_rest/fields/common.py
+++ /dev/null
@@ -1,17 +0,0 @@
-class WithRelationalFieldMixin(object):
- """Mostly code shared by DynamicRelationField and
- DynamicGenericRelationField.
- """
-
- def _get_request_fields_from_parent(self):
- """Get request fields from the parent serializer."""
- if not self.parent:
- return None
-
- if not getattr(self.parent, 'request_fields'):
- return None
-
- if not isinstance(self.parent.request_fields, dict):
- return None
-
- return self.parent.request_fields.get(self.field_name)
diff --git a/dynamic_rest/fields/generic.py b/dynamic_rest/fields/generic.py
index 7d1e0d90..29e378a7 100644
--- a/dynamic_rest/fields/generic.py
+++ b/dynamic_rest/fields/generic.py
@@ -2,9 +2,7 @@
from rest_framework.exceptions import ValidationError
-from dynamic_rest.fields.common import WithRelationalFieldMixin
-from dynamic_rest.fields.fields import DynamicField
-from dynamic_rest.routers import DynamicRouter
+from .base import WithRelationalFieldMixin, DynamicField
from dynamic_rest.tagged import TaggedDict
@@ -64,6 +62,7 @@ def get_pk_object(self, type_key, id_value):
}
def get_serializer_class_for_instance(self, instance):
+ from dynamic_rest.routers import DynamicRouter
return DynamicRouter.get_canonical_serializer(
resource_key=None,
instance=instance
@@ -118,6 +117,7 @@ def to_internal_value(self, data):
model_name = data.get('type', None)
model_id = data.get('id', None)
if model_name and model_id:
+ from dynamic_rest.routers import DynamicRouter
serializer_class = DynamicRouter.get_canonical_serializer(
resource_key=None,
resource_name=model_name
diff --git a/dynamic_rest/fields/model.py b/dynamic_rest/fields/model.py
new file mode 100644
index 00000000..ae989837
--- /dev/null
+++ b/dynamic_rest/fields/model.py
@@ -0,0 +1,45 @@
+import sys
+from .base import DynamicField
+from rest_framework import serializers
+
+for cls_name in (
+ 'BooleanField',
+ 'CharField',
+ 'DateField',
+ 'DateTimeField',
+ 'DecimalField',
+ 'DictField',
+ 'EmailField',
+ 'FileField',
+ 'FilePathField',
+ 'FloatField',
+ 'HiddenField',
+ 'IPAddressField',
+ 'ImageField',
+ 'IntegerField',
+ 'JSONField',
+ 'ListField',
+ 'RegexField',
+ 'SlugField',
+ 'TimeField',
+ 'URLField',
+ 'UUIDField',
+):
+ cls = getattr(serializers, cls_name, None)
+ if not cls:
+ continue
+
+ new_name = 'Dynamic%s' % cls_name
+ new_cls = type(
+ new_name,
+ (DynamicField, cls),
+ {}
+ )
+ setattr(sys.modules[__name__], new_name, new_cls)
+
+
+class DynamicMethodField(
+ serializers.SerializerMethodField,
+ DynamicField
+):
+ pass
diff --git a/dynamic_rest/fields/fields.py b/dynamic_rest/fields/relation.py
similarity index 58%
rename from dynamic_rest/fields/fields.py
rename to dynamic_rest/fields/relation.py
index a5a160f7..15316485 100644
--- a/dynamic_rest/fields/fields.py
+++ b/dynamic_rest/fields/relation.py
@@ -1,63 +1,24 @@
-"""This module contains custom field classes."""
-
import importlib
import pickle
+from rest_framework.serializers import CreateOnlyDefault, CurrentUserDefault
from django.utils import six
from django.utils.functional import cached_property
+from rest_framework.exceptions import (
+ APIException,
+ NotFound,
+ ParseError
+)
from rest_framework import fields
-from rest_framework.exceptions import NotFound, ParseError
-from rest_framework.serializers import SerializerMethodField
-
-from dynamic_rest.bases import DynamicSerializerBase
from dynamic_rest.conf import settings
-from dynamic_rest.fields.common import WithRelationalFieldMixin
-from dynamic_rest.meta import is_field_remote, get_model_field
-
-
-class DynamicField(fields.Field):
-
- """
- Generic field base to capture additional custom field attributes.
- """
-
- def __init__(
- self,
- requires=None,
- deferred=None,
- field_type=None,
- immutable=False,
- **kwargs
- ):
- """
- Arguments:
- deferred: Whether or not this field is deferred.
- Deferred fields are not included in the response,
- unless explicitly requested.
- field_type: Field data type, if not inferrable from model.
- requires: List of fields that this field depends on.
- Processed by the view layer during queryset build time.
- """
- self.requires = requires
- self.deferred = deferred
- self.field_type = field_type
- self.immutable = immutable
- self.kwargs = kwargs
- super(DynamicField, self).__init__(**kwargs)
-
- def to_representation(self, value):
- return value
-
- def to_internal_value(self, data):
- return data
-
-
-class DynamicComputedField(DynamicField):
- pass
-
-
-class DynamicMethodField(SerializerMethodField, DynamicField):
- pass
+from dynamic_rest.meta import (
+ get_related_model
+)
+from .base import (
+ DynamicField,
+ WithRelationalFieldMixin
+)
+from dynamic_rest.base import DynamicBase
class DynamicRelationField(WithRelationalFieldMixin, DynamicField):
@@ -78,7 +39,7 @@ class DynamicRelationField(WithRelationalFieldMixin, DynamicField):
def __init__(
self,
- serializer_class,
+ serializer_class=None,
many=False,
queryset=None,
embed=False,
@@ -96,58 +57,58 @@ def __init__(
sideloading: if True, force sideloading all the way down.
if False, force embedding all the way down.
This overrides the "embed" option if set.
+ debug: if True, representation will include a meta key with extra
+ instance information.
embed: If True, always embed related object(s). Will not sideload,
and will include the full object unless specifically excluded.
"""
self._serializer_class = serializer_class
- self.bound = False
self.queryset = queryset
self.sideloading = sideloading
self.debug = debug
self.embed = embed if sideloading is None else not sideloading
- if '.' in kwargs.get('source', ''):
- raise Exception('Nested relationships are not supported')
if 'link' in kwargs:
self.link = kwargs.pop('link')
super(DynamicRelationField, self).__init__(**kwargs)
self.kwargs['many'] = self.many = many
- def get_model(self):
- """Get the child serializer's model."""
- return getattr(self.serializer_class.Meta, 'model', None)
+ def get_pk_field(self):
+ return self.serializer.get_pk_field()
- def bind(self, *args, **kwargs):
- """Bind to the parent serializer."""
- if self.bound: # Prevent double-binding
- return
- super(DynamicRelationField, self).bind(*args, **kwargs)
- self.bound = True
- parent_model = getattr(self.parent.Meta, 'model', None)
+ def get_url(self, pk=None):
+ """Get the serializer's endpoint."""
+ return self.serializer_class.get_url(pk=pk)
- remote = is_field_remote(parent_model, self.source)
+ def get_plural_name(self):
+ """Get the serializer's plural name."""
+ return self.serializer_class.get_plural_name()
- try:
- model_field = get_model_field(parent_model, self.source)
- except:
- # model field may not be available for m2o fields with no
- # related_name
- model_field = None
-
- # Infer `required` and `allow_null`
- if 'required' not in self.kwargs and (
- remote or (
- model_field and (
- model_field.has_default() or model_field.null
- )
- )
- ):
- self.required = False
- if 'allow_null' not in self.kwargs and getattr(
- model_field, 'null', False
- ):
- self.allow_null = True
+ def get_name_field(self):
+ """Get the serializer's name field."""
+ return self.serializer_class.get_name_field()
- self.model_field = model_field
+ def get_search_key(self):
+ """Get the serializer's search key."""
+ return self.serializer_class.get_search_key()
+
+ def get_model(self):
+ """Get the serializer's model."""
+ return self.serializer_class.get_model()
+
+ def get_value(self, dictionary):
+ """Extract value from QueryDict.
+
+ Taken from DRF's ManyRelatedField
+ """
+ if hasattr(dictionary, 'getlist'):
+ # Don't return [] if the update is partial
+ if self.field_name not in dictionary:
+ if getattr(self.root, 'partial', False):
+ return fields.empty
+ return dictionary.getlist(
+ self.field_name
+ ) if self.many else dictionary.get(self.field_name)
+ return dictionary.get(self.field_name, fields.empty)
@property
def root_serializer(self):
@@ -264,78 +225,136 @@ def _is_dynamic(self):
"""Return True if the child serializer is dynamic."""
return issubclass(
self.serializer_class,
- DynamicSerializerBase
+ DynamicBase
)
def get_attribute(self, instance):
return instance
+ def admin_get_classes(self, instance, value):
+ serializer = self.serializer
+ getter = serializer.get_class_getter()
+ if getter and value is not None:
+ if hasattr(serializer, 'child'):
+ serializer = serializer.child
+ getter = getattr(serializer, getter)
+ return getter(value)
+ else:
+ return super(DynamicRelationField, self).admin_get_classes(instance, value)
+
+ def admin_get_icon(self, instance, value):
+ serializer = self.serializer
+ if serializer:
+ icon = serializer.get_icon()
+ label = self.admin_get_label(instance, value)
+ if label:
+ return icon
+
+ return None
+
+ def admin_get_label(self, instance, value):
+ # use the name field
+ serializer = self.serializer
+ name_field_name = serializer.get_name_field()
+ name_field = serializer.get_field(name_field_name)
+ source = name_field.source or name_field_name
+ sources = source.split('.')
+ related = value
+ if related:
+ for source in sources:
+ if source == '*':
+ break
+ related = getattr(related, source)
+ return related
+ else:
+ return ''
+
+ def admin_get_url(self, instance, value):
+ serializer = self.serializer
+ related = value
+
+ if related:
+ return serializer.get_url(related.pk)
+
+ return None
+
+ def get_related(self, instance):
+ return self.prepare_value(instance)
+
def to_representation(self, instance):
"""Represent the relationship, either as an ID or object."""
serializer = self.serializer
- model = serializer.get_model()
source = self.source
- if not self.kwargs['many'] and serializer.id_only():
+ if (
+ not self.getter and
+ not self.many and
+ serializer.id_only()
+ ):
# attempt to optimize by reading the related ID directly
# from the current instance rather than from the related object
source_id = '%s_id' % source
- if hasattr(instance, source_id):
- return getattr(instance, source_id)
+ value = getattr(instance, source_id, None)
+ if value:
+ return value
- if model is None:
- related = getattr(instance, source)
- else:
- try:
- related = getattr(instance, source)
- except model.DoesNotExist:
- return None
+ value = self.prepare_value(instance)
- if related is None:
+ if value is None:
return None
try:
- return serializer.to_representation(related)
+ return serializer.to_representation(value)
except Exception as e:
# Provide more context to help debug these cases
if getattr(serializer, 'debug', False):
import traceback
traceback.print_exc()
- raise Exception(
+ raise APIException(
"Failed to serialize %s.%s: %s\nObj: %s" %
(
self.parent.__class__.__name__,
self.source,
str(e),
- repr(related)
+ repr(value)
)
)
- def to_internal_value_single(self, data, serializer):
+ def to_internal_value_single(self, data):
"""Return the underlying object, given the serialized form."""
- related_model = serializer.Meta.model
- if isinstance(data, related_model):
+ model = self.get_model()
+ if isinstance(data, model):
return data
try:
- instance = related_model.objects.get(pk=data)
- except related_model.DoesNotExist:
+ instance = model.objects.get(pk=data)
+ except model.DoesNotExist:
raise NotFound(
- "'%s object with ID=%s not found" %
- (related_model.__name__, data)
+ '"%s" with ID "%s" not found' %
+ (model.__name__, data)
)
return instance
+ def run_validation(self, data):
+ if self.setter:
+ if data == fields.empty:
+ data = [] if self.kwargs.get('many') else None
+ def fn(instance):
+ setter = getattr(self.parent, self.setter)
+ setter(instance, data)
+
+ self.parent.add_post_save(fn)
+ raise fields.SkipField()
+ return super(DynamicRelationField, self).run_validation(data)
+
def to_internal_value(self, data):
"""Return the underlying object(s), given the serialized form."""
if self.kwargs['many']:
- serializer = self.serializer.child
if not isinstance(data, list):
- raise ParseError("'%s' value must be a list" % self.field_name)
+ raise ParseError('"%s" value must be a list' % self.field_name)
return [
self.to_internal_value_single(
instance,
- serializer
) for instance in data
]
- return self.to_internal_value_single(data, self.serializer)
+ return self.to_internal_value_single(data)
@property
def serializer_class(self):
@@ -344,6 +363,13 @@ def serializer_class(self):
Resolves string imports.
"""
serializer_class = self._serializer_class
+ if serializer_class is None:
+ from dynamic_rest.routers import DynamicRouter
+ serializer_class = DynamicRouter.get_canonical_serializer(
+ None,
+ model=get_related_model(self.model_field)
+ )
+
if not isinstance(serializer_class, six.string_types):
return serializer_class
@@ -353,7 +379,8 @@ def serializer_class(self):
if getattr(self, 'parent', None) is None:
raise Exception(
"Can not load serializer '%s'" % serializer_class +
- ' before binding or without specifying full path')
+ ' before binding or without specifying full path'
+ )
# try the module of the parent class
module_path = self.parent.__module__
@@ -365,54 +392,12 @@ def serializer_class(self):
return serializer_class
-class CountField(DynamicComputedField):
-
- """
- Computed field that counts the number of elements in another field.
- """
-
- def __init__(self, serializer_source, *args, **kwargs):
- """
- Arguments:
- serializer_source: A serializer field.
- unique: Whether or not to perform a count of distinct elements.
- """
- self.field_type = int
- # Use `serializer_source`, which indicates a field at the API level,
- # instead of `source`, which indicates a field at the model level.
- self.serializer_source = serializer_source
- # Set `source` to an empty value rather than the field name to avoid
- # an attempt to look up this field.
- kwargs['source'] = ''
- self.unique = kwargs.pop('unique', True)
- return super(CountField, self).__init__(*args, **kwargs)
-
- def get_attribute(self, obj):
- source = self.serializer_source
- if source not in self.parent.fields:
- return None
- value = self.parent.fields[source].get_attribute(obj)
- data = self.parent.fields[source].to_representation(value)
-
- # How to count None is undefined... let the consumer decide.
- if data is None:
- return None
-
- # Check data type. Technically len() works on dicts, strings, but
- # since this is a "count" field, we'll limit to list, set, tuple.
- if not isinstance(data, (list, set, tuple)):
- raise TypeError(
- "'%s' is %s. Must be list, set or tuple to be countable." % (
- source, type(data)
- )
- )
-
- if self.unique:
- # Try to create unique set. This may fail if `data` contains
- # non-hashable elements (like dicts).
- try:
- data = set(data)
- except TypeError:
- pass
-
- return len(data)
+class DynamicCreatorField(DynamicRelationField):
+ def __init__(self, *args, **kwargs):
+ kwargs['default'] = CreateOnlyDefault(
+ CurrentUserDefault()
+ )
+ if 'read_only' not in kwargs:
+ # default to read_only
+ kwargs['read_only'] = True
+ super(DynamicCreatorField, self).__init__(*args, **kwargs)
diff --git a/dynamic_rest/filters.py b/dynamic_rest/filters.py
index f2141653..bcb9cb82 100644
--- a/dynamic_rest/filters.py
+++ b/dynamic_rest/filters.py
@@ -6,7 +6,6 @@
from django.utils import six
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
-from rest_framework.fields import BooleanField, NullBooleanField
from rest_framework.filters import BaseFilterBackend, OrderingFilter
from dynamic_rest.utils import is_truthy
@@ -14,13 +13,10 @@
from dynamic_rest.datastructures import TreeMap
from dynamic_rest.fields import DynamicRelationField
from dynamic_rest.meta import (
- get_model_field,
- is_field_remote,
- is_model_field,
+ Meta,
get_related_model
)
from dynamic_rest.patches import patch_prefetch_one_level
-from dynamic_rest.related import RelatedObject
patch_prefetch_one_level()
@@ -37,109 +33,33 @@ def has_joins(queryset):
return False
-class FilterNode(object):
-
- def __init__(self, field, operator, value):
- """Create an object representing a filter, to be stored in a TreeMap.
-
- For example, a filter query like `filter{users.events.capacity.lte}=1`
- would be passed into a `FilterNode` as follows:
-
- ```
- field = ['users', 'events', 'capacity']
- operator = 'lte'
- value = 1
- node = FilterNode(field, operator, value)
- ```
-
- Arguments:
- field: A list of field parts.
- operator: A valid filter operator, or None.
- Per Django convention, `None` means the equality operator.
- value: The value to filter on.
- """
- self.field = field
- self.operator = operator
- self.value = value
-
- @property
- def key(self):
- return '%s%s' % (
- '__'.join(self.field),
- '__' + self.operator if self.operator else ''
- )
-
- def generate_query_key(self, serializer):
- """Get the key that can be passed to Django's filter method.
-
- To account for serialier field name rewrites, this method
- translates serializer field names to model field names
- by inspecting `serializer`.
-
- For example, a query like `filter{users.events}` would be
- returned as `users__events`.
-
- Arguments:
- serializer: A DRF serializer
-
- Returns:
- A filter key.
- """
- rewritten = []
- last = len(self.field) - 1
- s = serializer
- field = None
- for i, field_name in enumerate(self.field):
- # Note: .fields can be empty for related serializers that aren't
- # sideloaded. Fields that are deferred also won't be present.
- # If field name isn't in serializer.fields, get full list from
- # get_all_fields() method. This is somewhat expensive, so only do
- # this if we have to.
- fields = s.fields
- if field_name not in fields:
- fields = getattr(s, 'get_all_fields', lambda: {})()
-
- if field_name == 'pk':
- rewritten.append('pk')
- continue
-
- if field_name not in fields:
- raise ValidationError(
- "Invalid filter field: %s" % field_name
- )
-
- field = fields[field_name]
-
- # For remote fields, strip off '_set' for filtering. This is a
- # weird Django inconsistency.
- model_field_name = field.source or field_name
- model_field = get_model_field(s.get_model(), model_field_name)
- if isinstance(model_field, RelatedObject):
- model_field_name = model_field.field.related_query_name()
-
- # If get_all_fields() was used above, field could be unbound,
- # and field.source would be None
- rewritten.append(model_field_name)
-
- if i == last:
- break
-
- # Recurse into nested field
- s = getattr(field, 'serializer', None)
- if isinstance(s, serializers.ListSerializer):
- s = s.child
- if not s:
- raise ValidationError(
- "Invalid nested filter field: %s" % field_name
- )
+class WithGetSerializerClass(object):
+ def get_serializer_class(self, view=None):
+ view = view or getattr(self, 'view', None)
+ serializer_class = None
+ # prefer the overriding method
+ if hasattr(view, 'get_serializer_class'):
+ try:
+ serializer_class = view.get_serializer_class()
+ except AssertionError:
+ # Raised by the default implementation if
+ # no serializer_class was found
+ pass
+ # use the attribute
+ else:
+ serializer_class = getattr(view, 'serializer_class', None)
- if self.operator:
- rewritten.append(self.operator)
+ if serializer_class is None:
+ msg = (
+ "Cannot use %s on a view which does not have"
+ " a 'serializer_class' attribute."
+ )
+ raise ImproperlyConfigured(msg % self.__class__.__name__)
- return ('__'.join(rewritten), field)
+ return serializer_class
-class DynamicFilterBackend(BaseFilterBackend):
+class DynamicFilterBackend(WithGetSerializerClass, BaseFilterBackend):
"""A DRF filter backend that constructs DREST querysets.
@@ -185,14 +105,8 @@ def filter_queryset(self, request, queryset, view):
self.view = view
self.DEBUG = settings.DEBUG
- return self._build_queryset(queryset=queryset)
-
- """
- This function was renamed and broke downstream dependencies that haven't
- been updated to use the new naming convention.
- """
- def _extract_filters(self, **kwargs):
- return self._get_requested_filters(**kwargs)
+ queryset = self._build_queryset(queryset=queryset)
+ return queryset
def _get_requested_filters(self, **kwargs):
"""
@@ -200,39 +114,44 @@ def _get_requested_filters(self, **kwargs):
to Q. Returns a dict with two fields, 'include' and 'exclude',
which can be used like:
- result = self._get_requested_filters()
- q = Q(**result['include'] & ~Q(**result['exclude'])
+ result = self._get_requested_filters()
+ q = Q(**result['_include'] & ~Q(**result['_exclude'])
"""
- filters_map = (
- kwargs.get('filters_map') or
- self.view.get_request_feature(self.view.FILTER)
- )
+ filters_map = kwargs.get('filters_map')
+
+ view = getattr(self, 'view', None)
+ if view:
+ serializer_class = view.get_serializer_class()
+ serializer = serializer_class()
+ if not filters_map:
+ filters_map = view.get_request_feature(view.FILTER)
+ else:
+ serializer = None
out = TreeMap()
- for spec, value in six.iteritems(filters_map):
+ for key, value in six.iteritems(filters_map):
# Inclusion or exclusion?
- if spec[0] == '-':
- spec = spec[1:]
- inex = '_exclude'
+ if key[0] == '-':
+ key = key[1:]
+ category = '_exclude'
else:
- inex = '_include'
+ category = '_include'
# for relational filters, separate out relation path part
- if '|' in spec:
- rel, spec = spec.split('|')
+ if '|' in key:
+ rel, key = key.split('|')
rel = rel.split('.')
else:
rel = None
- parts = spec.split('.')
-
+ terms = key.split('.')
# Last part could be operator, e.g. "events.capacity.gte"
- if len(parts) > 1 and parts[-1] in self.VALID_FILTER_OPERATORS:
- operator = parts.pop()
+ if len(terms) > 1 and terms[-1] in self.VALID_FILTER_OPERATORS:
+ operator = terms.pop()
else:
operator = None
@@ -252,56 +171,75 @@ def _get_requested_filters(self, **kwargs):
elif operator == 'eq':
operator = None
- node = FilterNode(parts, operator, value)
+ if serializer:
+ s = serializer
+
+ if rel:
+ # get related serializer
+ model_fields, serializer_fields = serializer.resolve(rel)
+ s = serializer_fields[-1]
+ s = getattr(s, 'serializer', s)
+ rel = [
+ Meta.get_query_name(f) for f in model_fields
+ ]
+
+ # perform model-field resolution
+ model_fields, serializer_fields = s.resolve(terms)
+ field = serializer_fields[-1] if serializer_fields else None
+ # if the field is a boolean,
+ # coerce the value
+ if field and isinstance(
+ field,
+ (
+ serializers.BooleanField,
+ serializers.NullBooleanField
+ )
+ ):
+ value = is_truthy(value)
+ key = '__'.join(
+ [Meta.get_query_name(f) for f in model_fields]
+ )
+
+ else:
+ key = '__'.join(terms)
+
+ if operator:
+ key += '__%s' % operator
# insert into output tree
path = rel if rel else []
- path += [inex, node.key]
- out.insert(path, node)
-
+ path += [category, key]
+ out.insert(path, value)
return out
- def _filters_to_query(self, includes, excludes, serializer, q=None):
+ def _filters_to_query(self, filters):
"""
Construct Django Query object from request.
Arguments are dictionaries, which will be passed to Q() as kwargs.
e.g.
includes = { 'foo' : 'bar', 'baz__in' : [1, 2] }
- produces:
+ produces:
Q(foo='bar', baz__in=[1, 2])
Arguments:
- includes: TreeMap representing inclusion filters.
- excludes: TreeMap representing exclusion filters.
- serializer: serializer instance of top-level object
- q: Q() object (optional)
+ filters: TreeMap representing inclusion/exclusion filters
Returns:
- Q() instance or None if no inclusion or exclusion filters
- were specified.
+ Q() instance or None if no inclusion or exclusion filters
+ were specified.
"""
- def rewrite_filters(filters, serializer):
- out = {}
- for k, node in six.iteritems(filters):
- filter_key, field = node.generate_query_key(serializer)
- if isinstance(field, (BooleanField, NullBooleanField)):
- node.value = is_truthy(node.value)
- out[filter_key] = node.value
-
- return out
-
- q = q or Q()
+ includes = filters.get('_include')
+ excludes = filters.get('_exclude')
+ q = Q()
if not includes and not excludes:
return None
if includes:
- includes = rewrite_filters(includes, serializer)
q &= Q(**includes)
if excludes:
- excludes = rewrite_filters(excludes, serializer)
for k, v in six.iteritems(excludes):
q &= ~Q(**{k: v})
return q
@@ -314,12 +252,13 @@ def _build_implicit_prefetches(
):
"""Build a prefetch dictionary based on internal requirements."""
+ meta = Meta(model)
for source, remainder in six.iteritems(requirements):
if not remainder or isinstance(remainder, six.string_types):
# no further requirements to prefetch
continue
- related_field = get_model_field(model, source)
+ related_field = meta.get_field(source)
related_model = get_related_model(related_field)
queryset = self._build_implicit_queryset(
@@ -346,8 +285,7 @@ def _build_implicit_queryset(self, model, requirements):
)
prefetch = prefetches.values()
queryset = queryset.prefetch_related(*prefetch).distinct()
- if self.DEBUG:
- queryset._using_prefetches = prefetches
+ queryset._using_prefetches = prefetches
return queryset
def _build_requested_prefetches(
@@ -356,10 +294,11 @@ def _build_requested_prefetches(
requirements,
model,
fields,
- filters
+ filters,
+ is_root_level
):
"""Build a prefetch dictionary based on request requirements."""
-
+ meta = Meta(model)
for name, field in six.iteritems(fields):
original_field = field
if isinstance(field, DynamicRelationField):
@@ -372,42 +311,51 @@ def _build_requested_prefetches(
source = field.source or name
if '.' in source:
raise ValidationError(
- 'nested relationship values '
+ 'Nested relationship values '
'are not supported'
)
+ if source == '*':
+ # ignore custom getter/setter
+ continue
if source in prefetches:
# ignore duplicated sources
continue
- is_remote = is_field_remote(model, source)
- is_id_only = getattr(field, 'id_only', lambda: False)()
- if is_id_only and not is_remote:
- continue
-
related_queryset = getattr(original_field, 'queryset', None)
-
if callable(related_queryset):
related_queryset = related_queryset(field)
- source = field.source or name
+ is_id_only = getattr(field, 'id_only', lambda: False)()
+ is_remote = meta.is_field_remote(source)
+ is_gui_root = self.view.get_format() == 'admin' and is_root_level
+ if (
+ related_queryset is None and
+ is_id_only and not is_remote
+ and not is_gui_root
+ ):
+ # full representation and remote fields
+ # should all trigger prefetching
+ continue
+
# Popping the source here (during explicit prefetch construction)
# guarantees that implicitly required prefetches that follow will
# not conflict.
required = requirements.pop(source, None)
+ query_name = Meta.get_query_name(original_field.model_field)
prefetch_queryset = self._build_queryset(
serializer=field,
- filters=filters.get(name, {}),
+ filters=filters.get(query_name, {}),
queryset=related_queryset,
requirements=required
)
- # Note: There can only be one prefetch per source, even
- # though there can be multiple fields pointing to
- # the same source. This could break in some cases,
- # but is mostly an issue on writes when we use all
- # fields by default.
+ # There can only be one prefetch per source, even
+ # though there can be multiple fields pointing to
+ # the same source. This could break in some cases,
+ # but is mostly an issue on writes when we use all
+ # fields by default.
prefetches[source] = Prefetch(
source,
queryset=prefetch_queryset
@@ -467,11 +415,13 @@ def _build_queryset(
serializer = self.view.get_serializer()
is_root_level = True
- model = getattr(serializer.Meta, 'model', None)
+ model = serializer.get_model()
if not model:
return queryset
+ meta = Meta(model)
+
prefetches = {}
# build a nested Prefetch queryset
@@ -495,7 +445,8 @@ def _build_queryset(
requirements,
model,
fields,
- filters
+ filters,
+ is_root_level
)
# build remaining prefetches out of internal requirements
@@ -509,27 +460,25 @@ def _build_queryset(
# use requirements at this level to limit fields selected
# only do this for GET requests where we are not requesting the
# entire fieldset
+ is_gui = self.view.get_format() == 'admin'
if (
'*' not in requirements and
not self.view.is_update() and
- not self.view.is_delete()
+ not self.view.is_delete() and
+ not is_gui
):
id_fields = getattr(serializer, 'get_id_fields', lambda: [])()
# only include local model fields
only = [
field for field in set(
id_fields + list(requirements.keys())
- ) if is_model_field(model, field) and
- not is_field_remote(model, field)
+ ) if meta.is_field(field) and
+ not meta.is_field_remote(field)
]
queryset = queryset.only(*only)
# add request filters
- query = self._filters_to_query(
- includes=filters.get('_include'),
- excludes=filters.get('_exclude'),
- serializer=serializer
- )
+ query = self._filters_to_query(filters)
if query:
# Convert internal django ValidationError to
@@ -567,7 +516,7 @@ def _build_queryset(
return queryset
-class DynamicSortingFilter(OrderingFilter):
+class DynamicSortingFilter(WithGetSerializerClass, OrderingFilter):
"""Subclass of DRF's OrderingFilter.
@@ -606,7 +555,12 @@ def get_ordering(self, request, queryset, view):
# else return the ordering
if invalid_ordering:
raise ValidationError(
- "Invalid filter field: %s" % invalid_ordering
+ "Invalid ordering: %s" % (
+ ','.join((
+ '%s: %s' % (ex[0], str(ex[1])) for ex in
+ invalid_ordering
+ ))
+ )
)
else:
return valid_ordering
@@ -620,29 +574,64 @@ def remove_invalid_fields(self, queryset, fields, view):
Overwrites the DRF default remove_invalid_fields method to return
both the valid orderings and any invalid orderings.
"""
- # get valid field names for sorting
- valid_fields_map = {
- name: source for name, source in self.get_valid_fields(
- queryset, view)
- }
-
valid_orderings = []
invalid_orderings = []
# for each field sent down from the query param,
# determine if its valid or invalid
- for term in fields:
- stripped_term = term.lstrip('-')
- # add back the '-' add the end if necessary
- reverse_sort_term = '' if len(stripped_term) is len(term) else '-'
- if stripped_term in valid_fields_map:
- name = reverse_sort_term + valid_fields_map[stripped_term]
- valid_orderings.append(name)
- else:
- invalid_orderings.append(term)
+ if fields:
+ serializer = self.get_serializer_class(view)()
+ for term in fields:
+ stripped_term = term.lstrip('-')
+ # add back the '-' add the end if necessary
+ reverse_sort_term = (
+ '' if len(stripped_term) is len(term)
+ else '-'
+ )
+ try:
+ ordering = self.resolve(serializer, stripped_term, view)
+ valid_orderings.append(reverse_sort_term + ordering)
+ except ValidationError as e:
+ invalid_orderings.append((term, e))
return valid_orderings, invalid_orderings
+ def resolve(self, serializer, query, view=None):
+ """Resolve an ordering.
+
+ Arguments:
+ query: a string representing an API field
+ e.g: "location.name"
+ serializer: a serializer instance
+ e.g. UserSerializer
+ view: a view instance (optional)
+ e.g. UserViewSet
+
+ Returns:
+ Double-underscore-separated list of strings,
+ representing a model field.
+ e.g. "location__real_name"
+
+ Raises:
+ ValidationError if the query cannot be rewritten
+ """
+ if not self._is_allowed_query(query, view):
+ raise ValidationError('Invalid sort option: %s' % query)
+
+ model_fields, _ = serializer.resolve(query)
+ return '__'.join([
+ Meta.get_query_name(f) for f in model_fields
+ ])
+
+ def _is_allowed_query(self, query, view=None):
+ if not view:
+ return True
+
+ # views can define ordering_fields to limit ordering
+ valid_fields = getattr(view, 'ordering_fields', self.ordering_fields)
+ all_fields_allowed = valid_fields is None or valid_fields == '__all__'
+ return all_fields_allowed or query in valid_fields
+
def get_valid_fields(self, queryset, view, context={}):
"""Return valid fields for ordering.
@@ -651,25 +640,10 @@ def get_valid_fields(self, queryset, view, context={}):
"""
valid_fields = getattr(view, 'ordering_fields', self.ordering_fields)
- # prefer the overriding method
- if hasattr(view, 'get_serializer_class'):
- try:
- serializer_class = view.get_serializer_class()
- except AssertionError:
- # Raised by the default implementation if
- # no serializer_class was found
- serializer_class = None
- # use the attribute
- else:
- serializer_class = getattr(view, 'serializer_class', None)
-
- # neither a method nor an attribute has been specified
- if serializer_class is None:
- msg = (
- "Cannot use %s on a view which does not have either a "
- "'serializer_class' or an overriding 'get_serializer_class'."
- )
- raise ImproperlyConfigured(msg % self.__class__.__name__)
+ try:
+ serializer_class = self.get_serializer_class(view)
+ except (AssertionError, ImproperlyConfigured):
+ serializer_class = None
if valid_fields is None or valid_fields == '__all__':
# Default to allowing filtering on serializer fields
diff --git a/dynamic_rest/links.py b/dynamic_rest/links.py
index c0743e8b..076f1b00 100644
--- a/dynamic_rest/links.py
+++ b/dynamic_rest/links.py
@@ -2,7 +2,6 @@
from django.utils import six
from dynamic_rest.conf import settings
-from dynamic_rest.routers import DynamicRouter
def merge_link_object(serializer, data, instance):
@@ -20,6 +19,14 @@ def merge_link_object(serializer, data, instance):
# This generally only affectes Ephemeral Objects.
return data
+ base_url = ''
+ if settings.ENABLE_HOST_RELATIVE_LINKS:
+ # if the resource isn't registered, this will default back to
+ # using resource-relative urls for links.
+ base_url = serializer.get_url(instance.pk)
+ if settings.ENABLE_SELF_LINKS:
+ link_object['self'] = base_url
+
link_fields = serializer.get_link_fields()
for name, field in six.iteritems(link_fields):
# For included fields, omit link if there's no data.
@@ -28,14 +35,6 @@ def merge_link_object(serializer, data, instance):
link = getattr(field, 'link', None)
if link is None:
- base_url = ''
- if settings.ENABLE_HOST_RELATIVE_LINKS:
- # if the resource isn't registered, this will default back to
- # using resource-relative urls for links.
- base_url = DynamicRouter.get_canonical_path(
- serializer.get_resource_key(),
- instance.pk
- ) or ''
link = '%s%s/' % (base_url, name)
# Default to DREST-generated relation endpoints.
elif callable(link):
diff --git a/dynamic_rest/meta.py b/dynamic_rest/meta.py
index 11c7d2f3..c11635bf 100644
--- a/dynamic_rest/meta.py
+++ b/dynamic_rest/meta.py
@@ -1,121 +1,186 @@
"""Module containing Django meta helpers."""
from itertools import chain
-from django import VERSION
-from django.db.models import ManyToManyField
+from django.db import models
from dynamic_rest.related import RelatedObject
+from dynamic_rest.compat import DJANGO110
+
+
+class Meta(object):
+ _instances = {}
+
+ def __new__(cls, model):
+ key = model._meta.db_table if hasattr(model, '_meta') else model
+ if key not in cls._instances:
+ instance = cls._instances[key] = super(Meta, cls).__new__(cls)
+ instance.model = model
+ return cls._instances.get(key)
+
+ def __init__(self, model):
+ self.model = model
+ self.fields = {} # lazy
+
+ @property
+ def meta(self):
+ return getattr(self.model, '_meta', None)
+
+ @classmethod
+ def get_related_model(cls, field):
+ return field.related_model if field else None
+
+ def get_name(self):
+ meta = self.meta
+ return '%s.%s' % (
+ meta.app_label, meta.db_table
+ ) if meta else None
+
+ @classmethod
+ def get_query_name(cls, field):
+ if (
+ hasattr(field, 'field') and
+ hasattr(field.field, 'related_query_name')
+ ):
+ return field.field.related_query_name()
+ return field.name
+
+ def is_field(self, field_name):
+ """Check whether a given field exists on a model.
+
+ Arguments:
+ model: a Django model
+ field_name: the name of a field
+
+ Returns:
+ True if `field_name` exists on `model`, False otherwise.
+ """
+ try:
+ self.get_field(field_name)
+ return True
+ except AttributeError:
+ return False
+
+ def get_fields(self, **kwargs):
+ return self.meta.get_fields(**kwargs)
+
+ def get_pk_field(self):
+ return self.get_field(self.meta.pk.name)
+
+ def get_field(self, field_name):
+ """Return a field given a model and field name.
+
+ The field name may contain dots (.), indicating
+ a remote field.
+
+ Arguments:
+ model: a Django model
+ field_name: the name of a field
+
+ Returns:
+ A Django field if `field_name` is a valid field for `model`,
+ None otherwise.
+ """
+ if field_name in self.fields:
+ return self.fields[field_name]
+
+ field = None
+ model = self.model
+ meta = self.meta
+
+ if '.' in field_name:
+ parts = field_name.split('.')
+ last = len(parts) - 1
+ for i, part in enumerate(parts):
+ if i == last:
+ field_name = part
+ break
+ field = get_model_field(model, part)
+ model = get_related_model(field)
+ if not model:
+ raise AttributeError(
+ '%s is not a related field on %s' % (
+ part,
+ model
+ )
+ )
+ meta = model._meta
+
+ try:
+ if DJANGO110:
+ field = meta.get_field(field_name)
+ else:
+ field = meta.get_field_by_name(field_name)[0]
+ except:
+ if DJANGO110:
+ related_objs = (
+ f for f in meta.get_fields()
+ if (f.one_to_many or f.one_to_one)
+ and f.auto_created and not f.concrete
+ )
+ related_m2m_objs = (
+ f for f in meta.get_fields(include_hidden=True)
+ if f.many_to_many and f.auto_created
+ )
+ else:
+ related_objs = meta.get_all_related_objects()
+ related_m2m_objs = meta.get_all_related_many_to_many_objects()
+
+ related_objects = {
+ o.get_accessor_name(): o
+ for o in chain(related_objs, related_m2m_objs)
+ }
+ if field_name in related_objects:
+ field = related_objects[field_name]
+
+ if not field:
+ raise AttributeError(
+ '%s is not a valid field for %s' % (field_name, model)
+ )
-DJANGO110 = VERSION >= (1, 10)
-
-
-def is_model_field(model, field_name):
- """Check whether a given field exists on a model.
+ self.fields[field_name] = field
+ return field
- Arguments:
- model: a Django model
- field_name: the name of a field
+ def is_field_remote(self, field_name):
+ """Check whether a given model field is a remote field.
- Returns:
- True if `field_name` exists on `model`, False otherwise.
- """
- try:
- get_model_field(model, field_name)
- return True
- except AttributeError:
- return False
+ A remote field is the inverse of a one-to-many or a
+ many-to-many relationship.
+ Arguments:
+ model: a Django model
+ field_name: the name of a field
-def get_model_field(model, field_name):
- """Return a field given a model and field name.
-
- Arguments:
- model: a Django model
- field_name: the name of a field
-
- Returns:
- A Django field if `field_name` is a valid field for `model`,
- None otherwise.
- """
- meta = model._meta
- try:
- if DJANGO110:
- field = meta.get_field(field_name)
- else:
- field = meta.get_field_by_name(field_name)[0]
- return field
- except:
- if DJANGO110:
- related_objs = (
- f for f in meta.get_fields()
- if (f.one_to_many or f.one_to_one)
- and f.auto_created and not f.concrete
+ Returns:
+ True if `field_name` is a remote field, False otherwise.
+ """
+ try:
+ model_field = self.get_field(field_name)
+ return isinstance(
+ model_field,
+ (models.ManyToManyField, RelatedObject)
)
- related_m2m_objs = (
- f for f in meta.get_fields(include_hidden=True)
- if f.many_to_many and f.auto_created
- )
- else:
- related_objs = meta.get_all_related_objects()
- related_m2m_objs = meta.get_all_related_many_to_many_objects()
-
- related_objects = {
- o.get_accessor_name(): o
- for o in chain(related_objs, related_m2m_objs)
- }
- if field_name in related_objects:
- return related_objects[field_name]
- else:
- # check virtual fields (1.7)
- if hasattr(meta, 'virtual_fields'):
- for field in meta.virtual_fields:
- if field.name == field_name:
- return field
+ except AttributeError:
+ return False
- raise AttributeError(
- '%s is not a valid field for %s' % (field_name, model)
- )
+ def get_table(self):
+ return self.meta.db_table
-def is_field_remote(model, field_name):
- """Check whether a given model field is a remote field.
+def get_model_table(model):
+ return Meta(model).get_table()
- A remote field is the inverse of a one-to-many or a
- many-to-many relationship.
- Arguments:
- model: a Django model
- field_name: the name of a field
+def get_related_model(field):
+ return Meta.get_related_model(field)
- Returns:
- True if `field_name` is a remote field, False otherwise.
- """
- if not hasattr(model, '_meta'):
- # ephemeral model with no metaclass
- return False
- model_field = get_model_field(model, field_name)
- return isinstance(model_field, (ManyToManyField, RelatedObject))
+def is_model_field(model, field_name):
+ return Meta(model).is_field(field_name)
-def get_related_model(field):
- try:
- # django 1.8+
- return field.related_model
- except AttributeError:
- # django 1.7
- if hasattr(field, 'field'):
- return field.field.model
- elif hasattr(field, 'rel'):
- return field.rel.to
- elif field.__class__.__name__ == 'GenericForeignKey':
- return None
- else:
- raise
+def get_model_field(model, field_name):
+ return Meta(model).get_field(field_name)
-def get_model_table(model):
- try:
- return model._meta.db_table
- except:
- return None
+def is_field_remote(model, field_name):
+ return Meta(model).is_field_remote(field_name)
diff --git a/dynamic_rest/pagination.py b/dynamic_rest/pagination.py
index e2830642..c746cbaf 100644
--- a/dynamic_rest/pagination.py
+++ b/dynamic_rest/pagination.py
@@ -16,6 +16,7 @@ class DynamicPageNumberPagination(PageNumberPagination):
page_query_param = settings.PAGE_QUERY_PARAM
max_page_size = settings.MAX_PAGE_SIZE
page_size = settings.PAGE_SIZE or api_settings.PAGE_SIZE
+ template = 'dynamic_rest/pagination/numbers.html'
def get_page_metadata(self):
# returns total_results, total_pages, page, per_page
@@ -33,3 +34,6 @@ def get_paginated_response(self, data):
else:
data['meta'] = meta
return Response(data)
+
+ def get_results(self, data):
+ return data['results']
diff --git a/dynamic_rest/processors.py b/dynamic_rest/processors.py
index 44dcd12f..1c54de4f 100644
--- a/dynamic_rest/processors.py
+++ b/dynamic_rest/processors.py
@@ -81,7 +81,9 @@ def process(self, obj, parent=None, parent_key=None, depth=0):
serializer = obj.serializer
name = serializer.get_plural_name()
instance = getattr(obj, 'instance', serializer.instance)
- instance_pk = instance.pk if instance else None
+ instance_pk = (
+ getattr(instance, 'pk', None) if instance else None
+ )
pk = getattr(obj, 'pk_value', instance_pk) or instance_pk
# For polymorphic relations, `pk` can be a dict, so use the
diff --git a/dynamic_rest/renderers.py b/dynamic_rest/renderers.py
index ff89c28a..37953772 100644
--- a/dynamic_rest/renderers.py
+++ b/dynamic_rest/renderers.py
@@ -1,5 +1,15 @@
"""This module contains custom renderer classes."""
-from rest_framework.renderers import BrowsableAPIRenderer
+from django.utils import six
+import copy
+from rest_framework.renderers import (
+ BrowsableAPIRenderer,
+ HTMLFormRenderer,
+ ClassLookupDict
+)
+from django.utils.safestring import mark_safe
+from dynamic_rest.compat import reverse, NoReverseMatch, AdminRenderer
+from dynamic_rest.conf import settings
+from dynamic_rest import fields
class DynamicBrowsableAPIRenderer(BrowsableAPIRenderer):
@@ -18,3 +28,291 @@ def get_context(self, data, media_type, context):
request = context['request']
context['directory'] = get_directory(request)
return context
+
+ def render_form_for_serializer(self, serializer):
+ if hasattr(serializer, 'initial_data'):
+ serializer.is_valid()
+
+ form_renderer = self.form_renderer_class()
+ return form_renderer.render(
+ serializer.data,
+ self.accepted_media_type,
+ {'style': {'template_pack': 'dynamic_rest/horizontal'}}
+ )
+
+
+mapping = copy.deepcopy(HTMLFormRenderer.default_style.mapping)
+mapping[fields.DynamicRelationField] = {
+ 'base_template': 'relation.html'
+}
+mapping[fields.DynamicListField] = {
+ 'base_template': 'list.html'
+}
+
+
+class DynamicHTMLFormRenderer(HTMLFormRenderer):
+ template_pack = 'dynamic_rest/horizontal'
+ default_style = ClassLookupDict(mapping)
+
+ def render(self, data, accepted_media_type=None, renderer_context=None):
+ """
+ Render serializer data and return an HTML form, as a string.
+ """
+ if renderer_context:
+ style = renderer_context.get('style', {})
+ style['template_pack'] = self.template_pack
+ return super(DynamicHTMLFormRenderer, self).render(
+ data,
+ accepted_media_type,
+ renderer_context
+ )
+
+
+class DynamicAdminRenderer(AdminRenderer):
+ """Admin renderer."""
+ form_renderer_class = DynamicHTMLFormRenderer
+ template = settings.ADMIN_TEMPLATE
+
+ def get_context(self, data, media_type, context):
+ view = context.get('view')
+ response = context.get('response')
+ request = context.get('request')
+ is_error = response.status_code > 399
+ is_auth_error = response.status_code in (401, 403)
+ user = request.user if request else None
+ referer = request.META.get('HTTP_REFERER') if request else None
+
+ # remove envelope for successful responses
+ if getattr(data, 'serializer', None):
+ serializer = data.serializer
+ if hasattr(serializer, 'disable_envelope'):
+ serializer.disable_envelope()
+ data = serializer.data
+
+ context = super(DynamicAdminRenderer, self).get_context(
+ data,
+ media_type,
+ context
+ )
+
+ # add context
+ name_field = None
+ meta = None
+ is_update = getattr(view, 'is_update', lambda: False)()
+ is_root = view and view.__class__.__name__ == 'API'
+ header = ''
+ header_url = '#'
+ description = ''
+
+ results = context.get('results')
+ style = context.get('style')
+ paginator = context.get('paginator')
+ columns = context.get('columns')
+ serializer = getattr(results, 'serializer', None)
+ instance = serializer.instance if serializer else None
+ if isinstance(instance, list):
+ instance = None
+
+ if is_root:
+ style = context['style'] = 'root'
+ header = settings.API_NAME or ''
+ description = settings.API_DESCRIPTION
+ header_url = '/'
+
+ back_url = None
+ back = None
+ root_url = settings.API_ROOT_URL
+
+ if serializer:
+ meta = serializer.get_meta()
+ search_key = serializer.get_search_key()
+ search_help = getattr(meta, 'search_help', None)
+ singular_name = serializer.get_name().title()
+ plural_name = serializer.get_plural_name().title()
+ description = serializer.get_description()
+ icon = serializer.get_icon()
+ header = serializer.get_plural_name().title()
+ name_field = serializer.get_name_field()
+
+ if style == 'list':
+ if paginator:
+ paging = paginator.get_page_metadata()
+ count = paging['total_results']
+ else:
+ count = len(results)
+ header = '%d %s' % (count, header)
+ elif not is_error:
+ back_url = serializer.get_url()
+ back = plural_name
+ header = results.get(name_field)
+ header_url = serializer.get_url(
+ pk=instance.pk
+ )
+
+ if icon:
+ header = mark_safe(' %s' % (
+ icon,
+ header
+ ))
+
+ if style == 'list':
+ list_fields = getattr(meta, 'list_fields', None) or meta.fields
+ else:
+ list_fields = meta.fields
+ blacklist = ('id', )
+ if not isinstance(list_fields, six.string_types):
+ # respect serializer field ordering
+ columns = [
+ f for f in list_fields
+ if f in columns and f not in blacklist
+ ]
+
+ fields = serializer.get_all_fields()
+ else:
+ fields = {}
+ search_key = search_help = None
+ singular_name = plural_name = ''
+
+ # search value
+ search_value = (
+ request.query_params.get(search_key, '')
+ if search_key else ''
+ )
+
+ # login and logout
+ login_url = ''
+ try:
+ login_url = settings.ADMIN_LOGIN_URL or reverse(
+ 'dynamic_rest:login'
+ )
+ except NoReverseMatch:
+ try:
+ login_url = (
+ settings.ADMIN_LOGIN_URL or reverse('rest_framework:login')
+ )
+ except NoReverseMatch:
+ pass
+
+ logout_url = ''
+ try:
+ logout_url = (
+ settings.ADMIN_LOGOUT_URL or reverse('dynamic_rest:logout')
+ )
+ except NoReverseMatch:
+ try:
+ logout_url = (
+ settings.ADMIN_LOGOUT_URL or reverse('dynamic_rest:logout')
+ )
+ except NoReverseMatch:
+ pass
+
+ # alerts
+ alert = request.query_params.get('alert', None)
+ alert_class = request.query_params.get('alert-class', None)
+ if is_error:
+ alert = 'An error has occurred'
+ alert_class = 'danger'
+ elif is_update:
+ alert = 'Saved successfully'
+ alert_class = 'success'
+ elif (
+ login_url and user and
+ referer and login_url in referer
+ ):
+ alert = 'Welcome back'
+ name = getattr(user, 'name', None)
+ if name:
+ alert += ', %s!' % name
+ else:
+ alert += '!'
+ alert_class = 'success'
+ if alert and not alert_class:
+ alert_class = 'info'
+
+ # methods
+ allowed_methods = set(
+ (x.lower() for x in (view.http_method_names or ()))
+ )
+
+ context['root_url'] = root_url
+ context['back_url'] = back_url
+ context['back'] = back
+ context['columns'] = columns
+ context['fields'] = fields
+ context['details'] = context['columns']
+ context['description'] = description
+ context['singular_name'] = singular_name
+ context['plural_name'] = plural_name
+ context['is_auth_error'] = is_auth_error
+ context['login_url'] = login_url
+ context['logout_url'] = logout_url
+ context['header'] = header
+ context['header_url'] = header_url
+ context['search_value'] = search_value
+ context['search_key'] = search_key
+ context['search_help'] = search_help
+ context['allow_filter'] = (
+ 'get' in allowed_methods and style == 'list'
+ ) and search_key
+ context['allow_delete'] = (
+ 'delete' in allowed_methods and style == 'detail'
+ and bool(instance)
+ )
+ context['allow_edit'] = (
+ 'put' in allowed_methods and
+ style == 'detail' and
+ bool(instance)
+ )
+ context['allow_create'] = (
+ 'post' in allowed_methods and style == 'list'
+ )
+ context['alert'] = alert
+ context['alert_class'] = alert_class
+ return context
+
+ def render_form_for_serializer(self, serializer):
+ serializer.disable_envelope()
+ if hasattr(serializer, 'initial_data'):
+ serializer.is_valid()
+
+ form_renderer = self.form_renderer_class()
+ return form_renderer.render(
+ serializer.data,
+ self.accepted_media_type,
+ {'style': {'template_pack': 'dynamic_rest/horizontal'}}
+ )
+
+ def render(self, data, accepted_media_type=None, renderer_context=None):
+ # add redirects for successful creates and deletes
+ renderer_context = renderer_context or {}
+ response = renderer_context.get('response')
+ serializer = getattr(data, 'serializer', None)
+ # Creation and deletion should use redirects in the admin style.
+ location = None
+
+ did_create = response and response.status_code == 201
+ did_delete = response and response.status_code == 204
+
+ if (
+ did_create
+ and serializer
+ ):
+ location = '%s?alert=Created+successfully&alert-class=success' % (
+ serializer.get_url(pk=serializer.instance.pk)
+ )
+
+ result = super(DynamicAdminRenderer, self).render(
+ data,
+ accepted_media_type,
+ renderer_context
+ )
+
+ if did_delete:
+ location = (
+ response.get('Location', '/') +
+ '?alert=Deleted+successfully&alert-class=success'
+ )
+
+ if response and location:
+ response['Location'] = location
+ return result
diff --git a/dynamic_rest/routers.py b/dynamic_rest/routers.py
index 9bd5a947..4bb6448c 100644
--- a/dynamic_rest/routers.py
+++ b/dynamic_rest/routers.py
@@ -1,37 +1,48 @@
"""This module contains custom router classes."""
from collections import OrderedDict
-
-# Backwards compatability for django < 1.10.x
-try:
- from django.urls import get_script_prefix
-except ImportError:
- from django.core.urlresolvers import get_script_prefix
+import re
from django.utils import six
+from django.shortcuts import redirect
from rest_framework import views
from rest_framework.response import Response
-from rest_framework.reverse import reverse
from rest_framework.routers import DefaultRouter, Route, replace_methodname
from dynamic_rest.meta import get_model_table
from dynamic_rest.conf import settings
+from dynamic_rest.compat import (
+ get_script_prefix,
+ reverse,
+ NoReverseMatch
+)
directory = {}
resource_map = {}
resource_name_map = {}
-def get_directory(request):
+def get_directory(request, show_all=True):
"""Get API directory as a nested list of lists."""
-
def get_url(url):
- return reverse(url, request=request) if url else url
+ try:
+ return reverse(url) if url else url
+ except NoReverseMatch:
+ return '#'
- def is_active_url(path, url):
+ def is_prefix_of(path, url):
return path.startswith(url) if url and path else False
- path = request.path
+ def get_relative_url(url):
+ if url is None:
+ return url
+ match = re.match(r'(?:https?://[^/]+)/?(.*)', url)
+ if match:
+ return '/' + match.group(1)
+ else:
+ return url
+
directory_list = []
+ path = request.path
def sort_key(r):
return r[0]
@@ -51,16 +62,22 @@ def sort_key(r):
if endpoint_name[:1] == '_':
continue
endpoint_url = get_url(endpoint.get('_url', None))
- active = is_active_url(path, endpoint_url)
- endpoints_list.append(
- (endpoint_name, endpoint_url, [], active)
- )
+ relative_url = get_relative_url(endpoint_url)
+ active = is_prefix_of(path, relative_url)
+ relevant = is_prefix_of(relative_url, path)
+ if relevant or show_all:
+ endpoints_list.append(
+ (endpoint_name, endpoint_url, [], active)
+ )
url = get_url(endpoints.get('_url', None))
- active = is_active_url(path, url)
- directory_list.append(
- (group_name, url, endpoints_list, active)
- )
+ relative_url = get_relative_url(url)
+ active = is_prefix_of(path, relative_url)
+ relevant = is_prefix_of(relative_url, path)
+ if url is None or relevant or show_all:
+ directory_list.append(
+ (group_name, url, endpoints_list, active)
+ )
return directory_list
@@ -89,22 +106,33 @@ def get_api_root_view(self, **kwargs):
class API(views.APIView):
_ignore_model_permissions = True
+ def get_view_name(self, *args, **kwargs):
+ return settings.API_NAME
+
def get(self, request, *args, **kwargs):
- directory_list = get_directory(request)
+ if (
+ settings.API_ROOT_SECURE and
+ not request.user.is_authenticated()
+ ):
+ return redirect(
+ '%s?next=%s' % (
+ settings.ADMIN_LOGIN_URL,
+ request.path
+ )
+ )
+ directory_list = get_directory(request, show_all=False)
result = OrderedDict()
for group_name, url, endpoints, _ in directory_list:
if url:
result[group_name] = url
else:
- group = OrderedDict()
for endpoint_name, url, _, _ in endpoints:
- group[endpoint_name] = url
- result[group_name] = group
+ result[endpoint_name.title()] = url
return Response(result)
return API.as_view()
- def register(self, prefix, viewset, base_name=None):
+ def register(self, prefix, viewset, base_name=None, namespace=None):
"""Add any registered route into a global API directory.
If the prefix includes a path separator,
@@ -127,12 +155,15 @@ def register(self, prefix, viewset, base_name=None):
}
}
"""
+ prefix_parts = prefix.split('/')
if base_name is None:
base_name = prefix
+ if namespace:
+ prefix_parts = [namespace] + prefix_parts
+ base_name = '%s-%s' % (namespace, base_name)
super(DynamicRouter, self).register(prefix, viewset, base_name)
- prefix_parts = prefix.split('/')
if len(prefix_parts) > 1:
prefix = prefix_parts[0]
endpoint = '/'.join(prefix_parts[1:])
@@ -150,6 +181,16 @@ def register(self, prefix, viewset, base_name=None):
if endpoint not in current:
current[endpoint] = {}
current[endpoint]['_url'] = url_name
+
+ serializer_class = getattr(viewset, 'serializer_class', None)
+ if serializer_class:
+ # Set URL on the serializer class so that it can determine its
+ # endpoint URL.
+ # If a serializer class is associated with multiple views,
+ # it will take on the URL of the last view.
+ # TODO: is this a problem?
+ serializer_class._url = url_name
+
current[endpoint]['_viewset'] = viewset
def register_resource(self, viewset, namespace=None):
diff --git a/dynamic_rest/serializers.py b/dynamic_rest/serializers.py
index 3af31bab..c920cd19 100644
--- a/dynamic_rest/serializers.py
+++ b/dynamic_rest/serializers.py
@@ -2,24 +2,65 @@
import copy
import inspect
+from rest_framework.serializers import *
+
+from collections import Mapping
import inflection
from django.db import models
from django.utils import six
from django.utils.functional import cached_property
from rest_framework import exceptions, fields, serializers
-from rest_framework.fields import SkipField
+from rest_framework.fields import SkipField, JSONField
+from rest_framework.reverse import reverse
+from rest_framework.exceptions import ValidationError
from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList
-from dynamic_rest.bases import DynamicSerializerBase
from dynamic_rest.conf import settings
from dynamic_rest.fields import DynamicRelationField
from dynamic_rest.links import merge_link_object
-from dynamic_rest.meta import get_model_table
+from dynamic_rest.bound import DynamicJSONBoundField, DynamicBoundField
+from dynamic_rest.meta import (
+ Meta,
+ get_model_table,
+ get_model_field,
+ get_related_model
+)
from dynamic_rest.processors import SideloadingProcessor
from dynamic_rest.tagged import tag_dict
+from dynamic_rest.base import DynamicBase
+
+
+def nested_update(instance, key, value, objects=None):
+ objects = objects or []
+ nested = getattr(instance, key, None)
+ if not nested:
+ # object does not exist, try to create it
+ try:
+ field = get_model_field(instance, key)
+ related_model = get_related_model(field)
+ except:
+ raise exceptions.ValidationError(
+ 'Invalid relationship: %s' % key
+ )
+ else:
+ try:
+ nested = related_model.objects.create(**value)
+ setattr(instance, key, nested)
+ except Exception as e:
+ raise exceptions.ValidationError(str(e))
+ else:
+ # object exists, perform a nested update
+ for k, v in six.iteritems(value):
+ if isinstance(v, dict):
+ nested_update(nested, k, v, objects)
+ else:
+ setattr(nested, k, v)
+ objects.append(nested)
+ return objects
class WithResourceKeyMixin(object):
+ @classmethod
def get_resource_key(self):
"""Return canonical resource key, usually the DB table name."""
model = self.get_model()
@@ -43,30 +84,76 @@ def __init__(self, *args, **kwargs):
super(DynamicListSerializer, self).__init__(*args, **kwargs)
self.child.parent = self
+ def get_all_fields(self):
+ return self.child.get_all_fields()
+
+ def get_id_fields(self):
+ return self.child.get_id_fields()
+
+ def __iter__(self):
+ return self.child.__iter__()
+
+ def get_field(self, name):
+ return self.child.get_field(name)
+
+ @property
+ def fields(self):
+ return self.child.fields
+
+ def get_meta(self):
+ return self.child.get_meta()
+
+ def disable_envelope(self):
+ self.child.disable_envelope()
+ self._processed_data = None
+
def to_representation(self, data):
iterable = data.all() if isinstance(data, models.Manager) else data
return [self.child.to_representation(item) for item in iterable]
+ def get_description(self):
+ return self.child.get_description()
+
+ def resolve(self, query):
+ return self.child.resolve(query)
+
+ def get_name_field(self):
+ return self.child.get_name_field()
+
+ def get_class_getter(self):
+ return self.child.get_class_getter()
+
+ def get_search_key(self):
+ return self.child.get_search_key()
+
+ def get_icon(self):
+ return self.child.get_icon()
+
+ def get_url(self, pk=None):
+ return self.child.get_url(pk=pk)
+
def get_model(self):
- """Get the child's model."""
return self.child.get_model()
+ def get_pk_field(self):
+ return self.child.get_pk_field()
+
+ def get_format(self):
+ return self.child.get_format()
+
def get_name(self):
- """Get the child's name."""
return self.child.get_name()
def get_plural_name(self):
- """Get the child's plural name."""
return self.child.get_plural_name()
def id_only(self):
- """Get the child's rendering mode."""
return self.child.id_only()
@property
def data(self):
"""Get the data, after performing post-processing if necessary."""
- if not hasattr(self, '_processed_data'):
+ if getattr(self, '_processed_data', None) is None:
data = super(DynamicListSerializer, self).data
self._processed_data = ReturnDict(
SideloadingProcessor(self, data).data,
@@ -114,7 +201,7 @@ def update(self, queryset, validated_data):
return updated_objects
-class WithDynamicSerializerMixin(WithResourceKeyMixin, DynamicSerializerBase):
+class WithDynamicSerializerMixin(WithResourceKeyMixin, DynamicBase):
"""Base class for DREST serializers.
This class provides support for dynamic field inclusions/exclusions.
@@ -192,7 +279,6 @@ def __init__(
if data is not fields.empty and name in data and len(data) == 1:
# support POST/PUT key'd by resource name
data = data[name]
-
if data is not fields.empty:
# if a field is nullable but not required and the implementation
# passes null as a value, remove the field from the data
@@ -208,7 +294,7 @@ def __init__(
kwargs['instance'] = instance
kwargs['data'] = data
- # "sideload" argument is pending deprecation as of 1.6
+ # "sideload" argument is pending deprecation
if kwargs.pop('sideload', False):
# if "sideload=True" is passed, turn on the envelope
envelope = True
@@ -279,6 +365,222 @@ def _dynamic_init(self, only_fields, include_fields, exclude_fields):
# not sideloading this field
self.request_fields[name] = True
+ def get_field_value(self, key, instance=None):
+ if instance == '':
+ instance = None
+
+ field = self.fields[key]
+ if hasattr(field, 'prepare_value'):
+ value = field.prepare_value(instance)
+ else:
+ value = field.to_representation(
+ field.get_attribute(instance)
+ )
+ if isinstance(value, list):
+ value = [
+ getattr(v, 'instance', v) for v in value
+ ]
+ else:
+ value = getattr(value, 'instance', value)
+ error = self.errors.get(key) if hasattr(self, '_errors') else None
+ if isinstance(field, JSONField):
+ return DynamicJSONBoundField(
+ field, value, error, prefix='', instance=instance
+ )
+ return DynamicBoundField(
+ field, value, error, prefix='', instance=instance
+ )
+
+ def get_pk_field(self):
+ try:
+ field = self.get_field('pk')
+ return field.field_name
+ except:
+ pass
+ return 'pk'
+
+ @classmethod
+ def get_icon(cls):
+ meta = cls.get_meta()
+ return getattr(meta, 'icon', None)
+
+ @classmethod
+ def get_meta(cls):
+ return cls.Meta
+
+ def resolve(self, query):
+ """Resolves a query into model and serializer fields.
+
+ Arguments:
+ query: an API field path, in dot-nation
+ e.g: "creator.location_name"
+
+ Returns:
+ (model_fields, api_fields)
+ e.g:
+ [
+ Blog._meta.fields.user,
+ User._meta.fields.location,
+ Location._meta.fields.name
+ ],
+ [
+ DynamicRelationField(source="user"),
+ DynamicCharField(source="location.name")
+ ]
+
+ Raises:
+ ValidationError if the query is invalid,
+ e.g. references a method field or an undefined field
+ ```
+
+ Note that the lists do not necessarily contain the
+ same number of elements because API fields can reference nested model fields.
+ """ # noqa
+ if not isinstance(query, six.string_types):
+ parts = query
+ query = '.'.join(query)
+ else:
+ parts = query.split('.')
+
+ model_fields = []
+ api_fields = []
+
+ serializer = self
+
+ model = serializer.get_model()
+ resource_name = serializer.get_name()
+ meta = Meta(model)
+ api_name = parts[0]
+ other = parts[1:]
+
+ try:
+ api_field = serializer.get_field(api_name)
+ except:
+ api_field = None
+
+ if other:
+ if not (
+ api_field and
+ isinstance(api_field, DynamicRelationField)
+ ):
+ raise ValidationError({
+ api_name:
+ 'Could not resolve "%s": '
+ '"%s.%s" is not an API relation' % (
+ query,
+ resource_name,
+ api_name
+ )
+ })
+
+ source = api_field.source or api_name
+ related = api_field.serializer_class()
+ other = '.'.join(other)
+ model_fields, api_fields = related.resolve(other)
+
+ try:
+ model_field = meta.get_field(source)
+ except AttributeError:
+ raise ValidationError({
+ api_name:
+ 'Could not resolve "%s": '
+ '"%s.%s" is not a model relation' % (
+ query,
+ meta.get_name(),
+ source
+ )
+ })
+
+ model_fields.insert(0, model_field)
+ api_fields.insert(0, api_field)
+ else:
+ if api_name == 'pk':
+ # pk is an alias for the id field
+ model_field = meta.get_pk_field()
+ model_fields.append(model_field)
+ if api_field:
+ # the pk field may not exist
+ # on the serializer
+ api_fields.append(api_field)
+ else:
+ if not api_field:
+ raise ValidationError({
+ api_name:
+ 'Could not resolve "%s": '
+ '"%s.%s" is not an API field' % (
+ query,
+ resource_name,
+ api_name
+ )
+ })
+
+ api_fields.append(api_field)
+
+ if api_field.source == '*':
+ # a method field was requested, model field is unknown
+ return (model_fields, api_fields)
+
+ source = api_field.source or api_name
+ if '.' in source:
+ fields = source.split('.')
+ for field in fields[:-1]:
+ related_model = None
+ try:
+ model_field = meta.get_field(field)
+ related_model = model_field.related_model
+ except:
+ pass
+
+ if not related_model:
+ raise ValidationError({
+ api_name:
+ 'Could not resolve "%s": '
+ '"%s.%s" is not a model relation' % (
+ query,
+ meta.get_name(),
+ field
+ )
+ })
+ model = related_model
+ meta = Meta(model)
+ model_fields.append(model_field)
+ field = fields[-1]
+ try:
+ model_field = meta.get_field(field)
+ except:
+ raise ValidationError({
+ api_name:
+ 'Could not resolve: "%s", '
+ '"%s.%s" is not a model field' % (
+ query,
+ meta.get_name(),
+ field
+ )
+ })
+ model_fields.append(model_field)
+ else:
+ try:
+ model_field = meta.get_field(source)
+ except:
+ raise ValidationError({
+ api_name:
+ 'Could not resolve "%s": '
+ '"%s.%s" is not a model field' % (
+ query,
+ meta.get_name(),
+ source
+ )
+ })
+ model_fields.append(model_field)
+
+ return (model_fields, api_fields)
+
+ def disable_envelope(self):
+ envelope = self.envelope
+ self.envelope = False
+ if envelope:
+ self._processed_data = None
+
@classmethod
def get_model(cls):
"""Get the model, if the serializer has one.
@@ -287,6 +589,63 @@ def get_model(cls):
"""
return None
+ def get_field(self, field_name):
+ # it might be deferred
+ fields = self.get_all_fields()
+ if field_name == 'pk':
+ meta = self.get_meta()
+ if hasattr(meta, '_pk'):
+ return meta._pk
+
+ field = None
+ model = self.get_model()
+ primary_key = getattr(meta, 'primary_key', None)
+
+ if primary_key:
+ field = fields.get(primary_key)
+ else:
+ for n, f in fields.items():
+ # try to use model fields
+ try:
+ if getattr(field, 'primary_key', False):
+ field = f
+ break
+
+ model_field = get_model_field(
+ model,
+ f.source or n
+ )
+
+ if model_field.primary_key:
+ field = f
+ break
+ except:
+ pass
+
+ if not field:
+ # fall back to a field called ID
+ if 'id' in fields:
+ field = fields['id']
+
+ if field:
+ meta._pk = field
+ return field
+ else:
+ if field_name in fields:
+ field = fields[field_name]
+ return field
+
+ raise ValidationError({
+ field_name: '"%s" is not an API field' % field_name
+ })
+
+ def get_format(self):
+ view = self.context.get('view')
+ get_format = getattr(view, 'get_format', None)
+ if callable(get_format):
+ return get_format()
+ return None
+
@classmethod
def get_name(cls):
"""Get the serializer name.
@@ -304,6 +663,53 @@ def get_name(cls):
return cls.Meta.name
+ @classmethod
+ def get_url(self, pk=None):
+ # if associated with a registered viewset, use its URL
+ url = getattr(self, '_url', None)
+ if url:
+ # use URL key to get endpoint
+ url = reverse(url)
+ if not url:
+ # otherwise, return canonical URL for this model
+ from dynamic_rest.routers import DynamicRouter
+ url = DynamicRouter.get_canonical_path(
+ self.get_resource_key()
+ )
+ if pk:
+ return '%s/%s/' % (url, pk)
+ return url
+
+ @classmethod
+ def get_description(cls):
+ return getattr(cls.Meta, 'description', None)
+
+ @classmethod
+ def get_class_getter(self):
+ meta = self.get_meta()
+ return getattr(meta, 'get_classes', None)
+
+ @classmethod
+ def get_name_field(cls):
+ if not hasattr(cls.Meta, 'name_field'):
+ # fallback to primary key
+ return 'pk'
+ return cls.Meta.name_field
+
+ @classmethod
+ def get_search_key(cls):
+ meta = cls.get_meta()
+ if hasattr(meta, 'search_key'):
+ return meta.search_key
+
+ # fallback to name field
+ name_field = cls.get_name_field()
+ if name_field:
+ return 'filter{%s.icontains}' % name_field
+
+ # fallback to PK
+ return 'pk'
+
@classmethod
def get_plural_name(cls):
"""Get the serializer's plural name.
@@ -345,13 +751,16 @@ def get_all_fields(self):
).get_fields()
for k, field in six.iteritems(self._all_fields):
field.field_name = k
+ label = inflection.humanize(k)
+ field.label = getattr(field, 'label', label) or label
field.parent = self
return self._all_fields
def _get_flagged_field_names(self, fields, attr, meta_attr=None):
+ meta = self.get_meta()
if meta_attr is None:
meta_attr = '%s_fields' % attr
- meta_list = set(getattr(self.Meta, meta_attr, []))
+ meta_list = set(getattr(meta, meta_attr, []))
return {
name for name, field in six.iteritems(fields)
if getattr(field, attr, None) is True or name in
@@ -359,14 +768,16 @@ def _get_flagged_field_names(self, fields, attr, meta_attr=None):
}
def _get_deferred_field_names(self, fields):
+ meta = self.get_meta()
deferred_fields = self._get_flagged_field_names(
fields,
'deferred'
)
+
defer_many_relations = (
settings.DEFER_MANY_RELATIONS
- if not hasattr(self.Meta, 'defer_many_relations')
- else self.Meta.defer_many_relations
+ if not hasattr(meta, 'defer_many_relations')
+ else meta.defer_many_relations
)
if defer_many_relations:
# Auto-defer all fields, unless the 'deferred' attribute
@@ -405,8 +816,10 @@ def get_fields(self):
# apply request overrides
if request_fields:
+ if request_fields is True:
+ request_fields = {}
for name, include in six.iteritems(request_fields):
- if name not in serializer_fields:
+ if name not in serializer_fields and name != 'pk':
raise exceptions.ParseError(
'"%s" is not a valid field name for "%s".' %
(name, self.get_name())
@@ -421,10 +834,11 @@ def get_fields(self):
# Set read_only flags based on read_only_fields meta list.
# Here to cover DynamicFields not covered by DRF.
- ro_fields = getattr(self.Meta, 'read_only_fields', [])
+ meta = self.get_meta()
+ ro_fields = getattr(meta, 'read_only_fields', [])
self.flag_fields(serializer_fields, ro_fields, 'read_only', True)
- pw_fields = getattr(self.Meta, 'untrimmed_fields', [])
+ pw_fields = getattr(meta, 'untrimmed_fields', [])
self.flag_fields(
serializer_fields,
pw_fields,
@@ -519,6 +933,9 @@ def _faster_to_representation(self, instance):
return ret
+ def is_root(self):
+ return self.parent is None
+
def to_representation(self, instance):
"""Modified to_representation method.
@@ -528,7 +945,13 @@ def to_representation(self, instance):
Instance ID if the serializer is meant to represent its ID.
Otherwise, a tagged data dict representation.
"""
- if self.id_only():
+ id_only = self.id_only()
+ if (
+ self.get_format() == 'admin' and
+ self.is_root()
+ ):
+ id_only = False
+ if id_only:
return instance.pk
else:
if self.enable_optimization:
@@ -539,9 +962,11 @@ def to_representation(self, instance):
self
).to_representation(instance)
- if settings.ENABLE_LINKS:
- # TODO: Make this function configurable to support other
- # formats like JSON API link objects.
+ query_params = self.get_request_attribute('query_params', {})
+ if (
+ settings.ENABLE_LINKS and
+ 'exclude_links' not in query_params
+ ):
representation = merge_link_object(
self, representation, instance
)
@@ -560,32 +985,94 @@ def to_representation(self, instance):
embed=self.embed
)
+
def to_internal_value(self, data):
+ meta = self.get_meta()
value = super(WithDynamicSerializerMixin, self).to_internal_value(data)
- id_attr = getattr(self.Meta, 'update_lookup_field', 'id')
+
+ id_attr = getattr(meta, 'update_lookup_field', 'id')
request_method = self.get_request_method()
# Add update_lookup_field field back to validated data
# since super by default strips out read-only fields
# hence id will no longer be present in validated_data.
- if all((isinstance(self.root, DynamicListSerializer),
- id_attr, request_method in ('PUT', 'PATCH'))):
+ if all((
+ isinstance(self.root, DynamicListSerializer),
+ id_attr,
+ request_method in ('PUT', 'PATCH')
+ )):
id_field = self.fields[id_attr]
id_value = id_field.get_value(data)
value[id_attr] = id_value
return value
+ def add_post_save(self, fn):
+ if not hasattr(self, '_post_save'):
+ self._post_save = []
+ self._post_save.append(fn)
+
+ def do_post_save(self, instance):
+ if hasattr(self, '_post_save'):
+ for fn in self._post_save:
+ fn(instance)
+ self._post_save = []
+
+ def update(self, instance, validated_data):
+ # support nested writes if possible
+ meta = Meta(instance)
+ to_save = [instance]
+ # Simply set each attribute on the instance, and then save it.
+ # Note that unlike `.create()` we don't need to treat many-to-many
+ # relationships as being a special case. During updates we already
+ # have an instance pk for the relationships to be associated with.
+ for attr, value in validated_data.items():
+ try:
+ field = meta.get_field(attr)
+ if field.related_model:
+ if isinstance(value, dict):
+ # nested dictionary on a has-one relationship
+ # means we should take the current related value
+ # and apply updates to it
+ to_save.extend(
+ nested_update(instance, attr, value)
+ )
+ else:
+ # normal relationship update
+ setattr(instance, attr, value)
+ else:
+ setattr(instance, attr, value)
+ except AttributeError:
+ setattr(instance, attr, value)
+
+ for s in to_save:
+ s.save()
+
+ return instance
+
def save(self, *args, **kwargs):
- """Serializer save that address prefetch issues."""
+ """Serializer save that addresses prefetch issues."""
update = getattr(self, 'instance', None) is not None
- instance = super(
- WithDynamicSerializerMixin,
- self
- ).save(
- *args,
- **kwargs
- )
+ try:
+ instance = super(
+ WithDynamicSerializerMixin,
+ self
+ ).save(
+ *args,
+ **kwargs
+ )
+ except exceptions.APIException as e:
+ raise
+ except Exception as e:
+ error = e.args[0] if e.args else str(e)
+ if not isinstance(error, dict):
+ error = {'error': error}
+ self._errors = error
+ raise exceptions.ValidationError(
+ self.errors
+ )
+ self.do_post_save(instance)
+
view = self._context.get('view')
if update and view:
# Reload the object on update
@@ -599,11 +1086,14 @@ def id_only(self):
Returns:
True if and only if `request_fields` is True.
"""
- return self.dynamic and self.request_fields is True
+ return (
+ self.dynamic and
+ self.request_fields is True
+ )
@property
def data(self):
- if not hasattr(self, '_processed_data'):
+ if getattr(self, '_processed_data', None) is None:
data = super(WithDynamicSerializerMixin, self).data
data = SideloadingProcessor(
self, data
@@ -632,8 +1122,9 @@ def get_id_fields(self):
return a list of IDs.
"""
model = self.get_model()
+ meta = Meta(model)
- out = [model._meta.pk.name] # get PK field name
+ out = [meta.get_pk_field().attname]
# If this is being called, it means it
# is a many-relation to its parent.
@@ -643,9 +1134,9 @@ def get_id_fields(self):
# we will just pull all ID fields.
# TODO: We also might need to return all non-nullable fields,
# or else it is possible Django will issue another request.
- for field in model._meta.fields:
+ for field in meta.get_fields():
if isinstance(field, models.ForeignKey):
- out.append(field.name + '_id')
+ out.append(field.attname)
return out
diff --git a/dynamic_rest/static/dynamic_rest/css/bootstrap.css b/dynamic_rest/static/dynamic_rest/css/bootstrap.css
new file mode 100644
index 00000000..c57dbb87
--- /dev/null
+++ b/dynamic_rest/static/dynamic_rest/css/bootstrap.css
@@ -0,0 +1,5773 @@
+/*! normalize.css v3.0.0 | MIT License | git.io/normalize */
+html {
+ font-family: sans-serif;
+ -ms-text-size-adjust: 100%;
+ -webkit-text-size-adjust: 100%;
+}
+body {
+ margin: 0;
+ padding-top: 50px;
+ padding-bottom: 50px;
+}
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+nav,
+section,
+summary {
+ display: block;
+}
+audio,
+canvas,
+progress,
+video {
+ display: inline-block;
+ vertical-align: baseline;
+}
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+[hidden],
+template {
+ display: none;
+}
+a {
+ background: transparent;
+}
+a:active,
+a:hover {
+ outline: 0;
+}
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+b,
+strong {
+ font-weight: bold;
+}
+dfn {
+ font-style: italic;
+}
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+mark {
+ background: #ff0;
+ color: #000;
+}
+small {
+ font-size: 80%;
+}
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+sup {
+ top: -0.5em;
+}
+sub {
+ bottom: -0.25em;
+}
+img {
+ border: 0;
+}
+svg:not(:root) {
+ overflow: hidden;
+}
+figure {
+ margin: 1em 40px;
+}
+hr {
+ -moz-box-sizing: content-box;
+ box-sizing: content-box;
+ height: 0;
+}
+pre {
+ overflow: auto;
+}
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+button,
+input,
+optgroup,
+select,
+textarea {
+ color: inherit;
+ font: inherit;
+ margin: 0;
+}
+button {
+ overflow: visible;
+}
+button,
+select {
+ text-transform: none;
+}
+button,
+html input[type="button"],
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button;
+ cursor: pointer;
+}
+button[disabled],
+html input[disabled] {
+ cursor: default;
+}
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+input {
+ line-height: normal;
+}
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box;
+ padding: 0;
+}
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+input[type="search"] {
+ -webkit-appearance: textfield;
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box;
+ box-sizing: content-box;
+}
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+legend {
+ border: 0;
+ padding: 0;
+}
+textarea {
+ overflow: auto;
+}
+optgroup {
+ font-weight: bold;
+}
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+td,
+th {
+ padding: 0;
+}
+@media print {
+ * {
+ text-shadow: none !important;
+ color: #000 !important;
+ background: transparent !important;
+ box-shadow: none !important;
+ }
+ a,
+ a:visited {
+ text-decoration: underline;
+ }
+ a[href]:after {
+ content: " (" attr(href) ")";
+ }
+ abbr[title]:after {
+ content: " (" attr(title) ")";
+ }
+ a[href^="javascript:"]:after,
+ a[href^="#"]:after {
+ content: "";
+ }
+ pre,
+ blockquote {
+ border: 1px solid #999;
+ page-break-inside: avoid;
+ }
+ thead {
+ display: table-header-group;
+ }
+ tr,
+ img {
+ page-break-inside: avoid;
+ }
+ img {
+ max-width: 100% !important;
+ }
+ p,
+ h2,
+ h3 {
+ orphans: 3;
+ widows: 3;
+ }
+ h2,
+ h3 {
+ page-break-after: avoid;
+ }
+ select {
+ background: #fff !important;
+ }
+ .navbar {
+ display: none;
+ }
+ .table td,
+ .table th {
+ background-color: #fff !important;
+ }
+ .btn > .caret,
+ .dropup > .btn > .caret {
+ border-top-color: #000 !important;
+ }
+ .label {
+ border: 1px solid #000;
+ }
+ .table {
+ border-collapse: collapse !important;
+ }
+ .table-bordered th,
+ .table-bordered td {
+ border: 1px solid #ddd !important;
+ }
+}
+* {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+*:before,
+*:after {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+html {
+ font-size: 62.5%;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}
+body {
+ font-family: Open Sans;
+ font-size: 14px;
+ line-height: 1.428571429;
+ color: #333333;
+ background-color: #ffffff;
+}
+input,
+button,
+select,
+textarea {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+}
+a {
+ color: #428bca;
+ text-decoration: none;
+}
+a:hover,
+a:focus {
+ color: #2a6496;
+ text-decoration: underline;
+}
+a:focus {
+ outline: thin dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: -2px;
+}
+figure {
+ margin: 0;
+}
+img {
+ vertical-align: middle;
+}
+.img-responsive,
+.thumbnail > img,
+.thumbnail a > img,
+.carousel-inner > .item > img,
+.carousel-inner > .item > a > img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+.img-rounded {
+ border-radius: 6px;
+}
+.img-thumbnail {
+ padding: 4px;
+ line-height: 1.428571429;
+ background-color: #ffffff;
+ border: 1px solid #dddddd;
+ border-radius: 4px;
+ -webkit-transition: all 0.2s ease-in-out;
+ transition: all 0.2s ease-in-out;
+ display: inline-block;
+ max-width: 100%;
+ height: auto;
+}
+.img-circle {
+ border-radius: 50%;
+}
+hr {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ border: 0;
+ border-top: 1px solid #eeeeee;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+.h1,
+.h2,
+.h3,
+.h4,
+.h5,
+.h6 {
+ font-family: Open Sans;
+ font-weight: 500;
+ line-height: 1.1;
+ color: inherit;
+}
+h1 small,
+h2 small,
+h3 small,
+h4 small,
+h5 small,
+h6 small,
+.h1 small,
+.h2 small,
+.h3 small,
+.h4 small,
+.h5 small,
+.h6 small,
+h1 .small,
+h2 .small,
+h3 .small,
+h4 .small,
+h5 .small,
+h6 .small,
+.h1 .small,
+.h2 .small,
+.h3 .small,
+.h4 .small,
+.h5 .small,
+.h6 .small {
+ font-weight: normal;
+ line-height: 1;
+ color: #999999;
+}
+h1,
+.h1,
+h2,
+.h2,
+h3,
+.h3 {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+h1 small,
+.h1 small,
+h2 small,
+.h2 small,
+h3 small,
+.h3 small,
+h1 .small,
+.h1 .small,
+h2 .small,
+.h2 .small,
+h3 .small,
+.h3 .small {
+ font-size: 65%;
+}
+h4,
+.h4,
+h5,
+.h5,
+h6,
+.h6 {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+h4 small,
+.h4 small,
+h5 small,
+.h5 small,
+h6 small,
+.h6 small,
+h4 .small,
+.h4 .small,
+h5 .small,
+.h5 .small,
+h6 .small,
+.h6 .small {
+ font-size: 75%;
+}
+h1,
+.h1 {
+ font-size: 36px;
+}
+h2,
+.h2 {
+ font-size: 30px;
+}
+h3,
+.h3 {
+ font-size: 23px;
+}
+h4,
+.h4 {
+ font-size: 17px;
+}
+h5,
+.h5 {
+ font-size: 14px;
+}
+h6,
+.h6 {
+ font-size: 11px;
+}
+p {
+ margin: 0 0 10px;
+}
+.lead {
+ margin-bottom: 20px;
+ font-size: 16px;
+ font-weight: 200;
+ line-height: 1.4;
+}
+@media (min-width: 768px) {
+ .lead {
+ font-size: 21px;
+ }
+}
+small,
+.small {
+ font-size: 85%;
+}
+cite {
+ font-style: normal;
+}
+.text-left {
+ text-align: left;
+}
+.text-right {
+ text-align: right;
+}
+.text-center {
+ text-align: center;
+}
+.text-justify {
+ text-align: justify;
+}
+.text-muted {
+ color: #999999;
+}
+.text-primary {
+ color: #428bca;
+}
+a.text-primary:hover {
+ color: #3071a9;
+}
+.text-success {
+ color: #468847;
+}
+a.text-success:hover {
+ color: #356635;
+}
+.text-info {
+ color: #3a87ad;
+}
+a.text-info:hover {
+ color: #2d6987;
+}
+.text-warning {
+ color: #c09853;
+}
+a.text-warning:hover {
+ color: #a47e3c;
+}
+.text-danger {
+ color: #b94a48;
+}
+a.text-danger:hover {
+ color: #953b39;
+}
+.bg-primary {
+ color: #fff;
+ background-color: #428bca;
+}
+a.bg-primary:hover {
+ background-color: #3071a9;
+}
+.bg-success {
+ background-color: #dff0d8;
+}
+a.bg-success:hover {
+ background-color: #c1e2b3;
+}
+.bg-info {
+ background-color: #d9edf7;
+}
+a.bg-info:hover {
+ background-color: #afd9ee;
+}
+.bg-warning {
+ background-color: #fcf8e3;
+}
+a.bg-warning:hover {
+ background-color: #f7ecb5;
+}
+.bg-danger {
+ background-color: #f2dede;
+}
+a.bg-danger:hover {
+ background-color: #e4b9b9;
+}
+.page-header {
+ padding-bottom: 9px;
+ margin: 40px 0 20px;
+ border-bottom: 1px solid #eeeeee;
+}
+ul,
+ol {
+ margin-top: 0;
+ margin-bottom: 10px;
+}
+ul ul,
+ol ul,
+ul ol,
+ol ol {
+ margin-bottom: 0;
+}
+.list-unstyled {
+ padding-left: 0;
+ list-style: none;
+}
+.list-inline {
+ padding-left: 0;
+ list-style: none;
+}
+.list-inline > li {
+ display: inline-block;
+ padding-left: 5px;
+ padding-right: 5px;
+}
+.list-inline > li:first-child {
+ padding-left: 0;
+}
+dl {
+ margin-top: 0;
+ margin-bottom: 20px;
+}
+dt,
+dd {
+ line-height: 1.428571429;
+}
+dt {
+ font-weight: bold;
+}
+dd {
+ margin-left: 0;
+}
+@media (min-width: 768px) {
+ .dl-horizontal dt {
+ float: left;
+ width: 160px;
+ clear: left;
+ text-align: right;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .dl-horizontal dd {
+ margin-left: 180px;
+ }
+}
+abbr[title],
+abbr[data-original-title] {
+ cursor: help;
+ border-bottom: 1px dotted #999999;
+}
+.initialism {
+ font-size: 90%;
+ text-transform: uppercase;
+}
+blockquote {
+ padding: 10px 20px;
+ margin: 0 0 20px;
+ font-size: 17.5px;
+ border-left: 5px solid #eeeeee;
+}
+blockquote p:last-child,
+blockquote ul:last-child,
+blockquote ol:last-child {
+ margin-bottom: 0;
+}
+blockquote footer,
+blockquote small,
+blockquote .small {
+ display: block;
+ font-size: 80%;
+ line-height: 1.428571429;
+ color: #999999;
+}
+blockquote footer:before,
+blockquote small:before,
+blockquote .small:before {
+ content: '\2014 \00A0';
+}
+.blockquote-reverse,
+blockquote.pull-right {
+ padding-right: 15px;
+ padding-left: 0;
+ border-right: 5px solid #eeeeee;
+ border-left: 0;
+ text-align: right;
+}
+.blockquote-reverse footer:before,
+blockquote.pull-right footer:before,
+.blockquote-reverse small:before,
+blockquote.pull-right small:before,
+.blockquote-reverse .small:before,
+blockquote.pull-right .small:before {
+ content: '';
+}
+.blockquote-reverse footer:after,
+blockquote.pull-right footer:after,
+.blockquote-reverse small:after,
+blockquote.pull-right small:after,
+.blockquote-reverse .small:after,
+blockquote.pull-right .small:after {
+ content: '\00A0 \2014';
+}
+blockquote:before,
+blockquote:after {
+ content: "";
+}
+address {
+ margin-bottom: 20px;
+ font-style: normal;
+ line-height: 1.428571429;
+}
+code,
+kbd,
+pre,
+samp {
+ font-family: Monaco;
+}
+code {
+ padding: 2px 4px;
+ font-size: 90%;
+ color: #c7254e;
+ background-color: #f9f2f4;
+ white-space: nowrap;
+ border-radius: 4px;
+}
+kbd {
+ padding: 2px 4px;
+ font-size: 90%;
+ color: #ffffff;
+ background-color: #333333;
+ border-radius: 3px;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);
+}
+pre {
+ display: block;
+ padding: 9.5px;
+ margin: 0 0 10px;
+ font-size: 13px;
+ line-height: 1.428571429;
+ word-break: break-all;
+ word-wrap: break-word;
+ color: #333333;
+ background-color: #f5f5f5;
+ border: 1px solid #cccccc;
+ border-radius: 4px;
+}
+pre code {
+ padding: 0;
+ font-size: inherit;
+ color: inherit;
+ white-space: pre-wrap;
+ background-color: transparent;
+ border-radius: 0;
+}
+.pre-scrollable {
+ max-height: 340px;
+ overflow-y: scroll;
+}
+.container {
+ margin-right: auto;
+ margin-left: auto;
+ padding-left: 15px;
+ padding-right: 15px;
+}
+@media (min-width: 768px) {
+ .container {
+ width: 750px;
+ }
+}
+@media (min-width: 992px) {
+ .container {
+ width: 970px;
+ }
+}
+@media (min-width: 1200px) {
+ .container {
+ width: 1170px;
+ }
+}
+.container-fluid {
+ margin-right: auto;
+ margin-left: auto;
+ padding-left: 15px;
+ padding-right: 15px;
+}
+.row {
+ margin-left: -15px;
+ margin-right: -15px;
+}
+.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {
+ position: relative;
+ min-height: 1px;
+ padding-left: 15px;
+ padding-right: 15px;
+}
+.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {
+ float: left;
+}
+.col-xs-12 {
+ width: 100%;
+}
+.col-xs-11 {
+ width: 91.66666666666666%;
+}
+.col-xs-10 {
+ width: 83.33333333333334%;
+}
+.col-xs-9 {
+ width: 75%;
+}
+.col-xs-8 {
+ width: 66.66666666666666%;
+}
+.col-xs-7 {
+ width: 58.333333333333336%;
+}
+.col-xs-6 {
+ width: 50%;
+}
+.col-xs-5 {
+ width: 41.66666666666667%;
+}
+.col-xs-4 {
+ width: 33.33333333333333%;
+}
+.col-xs-3 {
+ width: 25%;
+}
+.col-xs-2 {
+ width: 16.666666666666664%;
+}
+.col-xs-1 {
+ width: 8.333333333333332%;
+}
+.col-xs-pull-12 {
+ right: 100%;
+}
+.col-xs-pull-11 {
+ right: 91.66666666666666%;
+}
+.col-xs-pull-10 {
+ right: 83.33333333333334%;
+}
+.col-xs-pull-9 {
+ right: 75%;
+}
+.col-xs-pull-8 {
+ right: 66.66666666666666%;
+}
+.col-xs-pull-7 {
+ right: 58.333333333333336%;
+}
+.col-xs-pull-6 {
+ right: 50%;
+}
+.col-xs-pull-5 {
+ right: 41.66666666666667%;
+}
+.col-xs-pull-4 {
+ right: 33.33333333333333%;
+}
+.col-xs-pull-3 {
+ right: 25%;
+}
+.col-xs-pull-2 {
+ right: 16.666666666666664%;
+}
+.col-xs-pull-1 {
+ right: 8.333333333333332%;
+}
+.col-xs-pull-0 {
+ right: 0%;
+}
+.col-xs-push-12 {
+ left: 100%;
+}
+.col-xs-push-11 {
+ left: 91.66666666666666%;
+}
+.col-xs-push-10 {
+ left: 83.33333333333334%;
+}
+.col-xs-push-9 {
+ left: 75%;
+}
+.col-xs-push-8 {
+ left: 66.66666666666666%;
+}
+.col-xs-push-7 {
+ left: 58.333333333333336%;
+}
+.col-xs-push-6 {
+ left: 50%;
+}
+.col-xs-push-5 {
+ left: 41.66666666666667%;
+}
+.col-xs-push-4 {
+ left: 33.33333333333333%;
+}
+.col-xs-push-3 {
+ left: 25%;
+}
+.col-xs-push-2 {
+ left: 16.666666666666664%;
+}
+.col-xs-push-1 {
+ left: 8.333333333333332%;
+}
+.col-xs-push-0 {
+ left: 0%;
+}
+.col-xs-offset-12 {
+ margin-left: 100%;
+}
+.col-xs-offset-11 {
+ margin-left: 91.66666666666666%;
+}
+.col-xs-offset-10 {
+ margin-left: 83.33333333333334%;
+}
+.col-xs-offset-9 {
+ margin-left: 75%;
+}
+.col-xs-offset-8 {
+ margin-left: 66.66666666666666%;
+}
+.col-xs-offset-7 {
+ margin-left: 58.333333333333336%;
+}
+.col-xs-offset-6 {
+ margin-left: 50%;
+}
+.col-xs-offset-5 {
+ margin-left: 41.66666666666667%;
+}
+.col-xs-offset-4 {
+ margin-left: 33.33333333333333%;
+}
+.col-xs-offset-3 {
+ margin-left: 25%;
+}
+.col-xs-offset-2 {
+ margin-left: 16.666666666666664%;
+}
+.col-xs-offset-1 {
+ margin-left: 8.333333333333332%;
+}
+.col-xs-offset-0 {
+ margin-left: 0%;
+}
+@media (min-width: 768px) {
+ .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {
+ float: left;
+ }
+ .col-sm-12 {
+ width: 100%;
+ }
+ .col-sm-11 {
+ width: 91.66666666666666%;
+ }
+ .col-sm-10 {
+ width: 83.33333333333334%;
+ }
+ .col-sm-9 {
+ width: 75%;
+ }
+ .col-sm-8 {
+ width: 66.66666666666666%;
+ }
+ .col-sm-7 {
+ width: 58.333333333333336%;
+ }
+ .col-sm-6 {
+ width: 50%;
+ }
+ .col-sm-5 {
+ width: 41.66666666666667%;
+ }
+ .col-sm-4 {
+ width: 33.33333333333333%;
+ }
+ .col-sm-3 {
+ width: 25%;
+ }
+ .col-sm-2 {
+ width: 16.666666666666664%;
+ }
+ .col-sm-1 {
+ width: 8.333333333333332%;
+ }
+ .col-sm-pull-12 {
+ right: 100%;
+ }
+ .col-sm-pull-11 {
+ right: 91.66666666666666%;
+ }
+ .col-sm-pull-10 {
+ right: 83.33333333333334%;
+ }
+ .col-sm-pull-9 {
+ right: 75%;
+ }
+ .col-sm-pull-8 {
+ right: 66.66666666666666%;
+ }
+ .col-sm-pull-7 {
+ right: 58.333333333333336%;
+ }
+ .col-sm-pull-6 {
+ right: 50%;
+ }
+ .col-sm-pull-5 {
+ right: 41.66666666666667%;
+ }
+ .col-sm-pull-4 {
+ right: 33.33333333333333%;
+ }
+ .col-sm-pull-3 {
+ right: 25%;
+ }
+ .col-sm-pull-2 {
+ right: 16.666666666666664%;
+ }
+ .col-sm-pull-1 {
+ right: 8.333333333333332%;
+ }
+ .col-sm-pull-0 {
+ right: 0%;
+ }
+ .col-sm-push-12 {
+ left: 100%;
+ }
+ .col-sm-push-11 {
+ left: 91.66666666666666%;
+ }
+ .col-sm-push-10 {
+ left: 83.33333333333334%;
+ }
+ .col-sm-push-9 {
+ left: 75%;
+ }
+ .col-sm-push-8 {
+ left: 66.66666666666666%;
+ }
+ .col-sm-push-7 {
+ left: 58.333333333333336%;
+ }
+ .col-sm-push-6 {
+ left: 50%;
+ }
+ .col-sm-push-5 {
+ left: 41.66666666666667%;
+ }
+ .col-sm-push-4 {
+ left: 33.33333333333333%;
+ }
+ .col-sm-push-3 {
+ left: 25%;
+ }
+ .col-sm-push-2 {
+ left: 16.666666666666664%;
+ }
+ .col-sm-push-1 {
+ left: 8.333333333333332%;
+ }
+ .col-sm-push-0 {
+ left: 0%;
+ }
+ .col-sm-offset-12 {
+ margin-left: 100%;
+ }
+ .col-sm-offset-11 {
+ margin-left: 91.66666666666666%;
+ }
+ .col-sm-offset-10 {
+ margin-left: 83.33333333333334%;
+ }
+ .col-sm-offset-9 {
+ margin-left: 75%;
+ }
+ .col-sm-offset-8 {
+ margin-left: 66.66666666666666%;
+ }
+ .col-sm-offset-7 {
+ margin-left: 58.333333333333336%;
+ }
+ .col-sm-offset-6 {
+ margin-left: 50%;
+ }
+ .col-sm-offset-5 {
+ margin-left: 41.66666666666667%;
+ }
+ .col-sm-offset-4 {
+ margin-left: 33.33333333333333%;
+ }
+ .col-sm-offset-3 {
+ margin-left: 25%;
+ }
+ .col-sm-offset-2 {
+ margin-left: 16.666666666666664%;
+ }
+ .col-sm-offset-1 {
+ margin-left: 8.333333333333332%;
+ }
+ .col-sm-offset-0 {
+ margin-left: 0%;
+ }
+}
+@media (min-width: 992px) {
+ .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {
+ float: left;
+ }
+ .col-md-12 {
+ width: 100%;
+ }
+ .col-md-11 {
+ width: 91.66666666666666%;
+ }
+ .col-md-10 {
+ width: 83.33333333333334%;
+ }
+ .col-md-9 {
+ width: 75%;
+ }
+ .col-md-8 {
+ width: 66.66666666666666%;
+ }
+ .col-md-7 {
+ width: 58.333333333333336%;
+ }
+ .col-md-6 {
+ width: 50%;
+ }
+ .col-md-5 {
+ width: 41.66666666666667%;
+ }
+ .col-md-4 {
+ width: 33.33333333333333%;
+ }
+ .col-md-3 {
+ width: 25%;
+ }
+ .col-md-2 {
+ width: 16.666666666666664%;
+ }
+ .col-md-1 {
+ width: 8.333333333333332%;
+ }
+ .col-md-pull-12 {
+ right: 100%;
+ }
+ .col-md-pull-11 {
+ right: 91.66666666666666%;
+ }
+ .col-md-pull-10 {
+ right: 83.33333333333334%;
+ }
+ .col-md-pull-9 {
+ right: 75%;
+ }
+ .col-md-pull-8 {
+ right: 66.66666666666666%;
+ }
+ .col-md-pull-7 {
+ right: 58.333333333333336%;
+ }
+ .col-md-pull-6 {
+ right: 50%;
+ }
+ .col-md-pull-5 {
+ right: 41.66666666666667%;
+ }
+ .col-md-pull-4 {
+ right: 33.33333333333333%;
+ }
+ .col-md-pull-3 {
+ right: 25%;
+ }
+ .col-md-pull-2 {
+ right: 16.666666666666664%;
+ }
+ .col-md-pull-1 {
+ right: 8.333333333333332%;
+ }
+ .col-md-pull-0 {
+ right: 0%;
+ }
+ .col-md-push-12 {
+ left: 100%;
+ }
+ .col-md-push-11 {
+ left: 91.66666666666666%;
+ }
+ .col-md-push-10 {
+ left: 83.33333333333334%;
+ }
+ .col-md-push-9 {
+ left: 75%;
+ }
+ .col-md-push-8 {
+ left: 66.66666666666666%;
+ }
+ .col-md-push-7 {
+ left: 58.333333333333336%;
+ }
+ .col-md-push-6 {
+ left: 50%;
+ }
+ .col-md-push-5 {
+ left: 41.66666666666667%;
+ }
+ .col-md-push-4 {
+ left: 33.33333333333333%;
+ }
+ .col-md-push-3 {
+ left: 25%;
+ }
+ .col-md-push-2 {
+ left: 16.666666666666664%;
+ }
+ .col-md-push-1 {
+ left: 8.333333333333332%;
+ }
+ .col-md-push-0 {
+ left: 0%;
+ }
+ .col-md-offset-12 {
+ margin-left: 100%;
+ }
+ .col-md-offset-11 {
+ margin-left: 91.66666666666666%;
+ }
+ .col-md-offset-10 {
+ margin-left: 83.33333333333334%;
+ }
+ .col-md-offset-9 {
+ margin-left: 75%;
+ }
+ .col-md-offset-8 {
+ margin-left: 66.66666666666666%;
+ }
+ .col-md-offset-7 {
+ margin-left: 58.333333333333336%;
+ }
+ .col-md-offset-6 {
+ margin-left: 50%;
+ }
+ .col-md-offset-5 {
+ margin-left: 41.66666666666667%;
+ }
+ .col-md-offset-4 {
+ margin-left: 33.33333333333333%;
+ }
+ .col-md-offset-3 {
+ margin-left: 25%;
+ }
+ .col-md-offset-2 {
+ margin-left: 16.666666666666664%;
+ }
+ .col-md-offset-1 {
+ margin-left: 8.333333333333332%;
+ }
+ .col-md-offset-0 {
+ margin-left: 0%;
+ }
+}
+@media (min-width: 1200px) {
+ .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {
+ float: left;
+ }
+ .col-lg-12 {
+ width: 100%;
+ }
+ .col-lg-11 {
+ width: 91.66666666666666%;
+ }
+ .col-lg-10 {
+ width: 83.33333333333334%;
+ }
+ .col-lg-9 {
+ width: 75%;
+ }
+ .col-lg-8 {
+ width: 66.66666666666666%;
+ }
+ .col-lg-7 {
+ width: 58.333333333333336%;
+ }
+ .col-lg-6 {
+ width: 50%;
+ }
+ .col-lg-5 {
+ width: 41.66666666666667%;
+ }
+ .col-lg-4 {
+ width: 33.33333333333333%;
+ }
+ .col-lg-3 {
+ width: 25%;
+ }
+ .col-lg-2 {
+ width: 16.666666666666664%;
+ }
+ .col-lg-1 {
+ width: 8.333333333333332%;
+ }
+ .col-lg-pull-12 {
+ right: 100%;
+ }
+ .col-lg-pull-11 {
+ right: 91.66666666666666%;
+ }
+ .col-lg-pull-10 {
+ right: 83.33333333333334%;
+ }
+ .col-lg-pull-9 {
+ right: 75%;
+ }
+ .col-lg-pull-8 {
+ right: 66.66666666666666%;
+ }
+ .col-lg-pull-7 {
+ right: 58.333333333333336%;
+ }
+ .col-lg-pull-6 {
+ right: 50%;
+ }
+ .col-lg-pull-5 {
+ right: 41.66666666666667%;
+ }
+ .col-lg-pull-4 {
+ right: 33.33333333333333%;
+ }
+ .col-lg-pull-3 {
+ right: 25%;
+ }
+ .col-lg-pull-2 {
+ right: 16.666666666666664%;
+ }
+ .col-lg-pull-1 {
+ right: 8.333333333333332%;
+ }
+ .col-lg-pull-0 {
+ right: 0%;
+ }
+ .col-lg-push-12 {
+ left: 100%;
+ }
+ .col-lg-push-11 {
+ left: 91.66666666666666%;
+ }
+ .col-lg-push-10 {
+ left: 83.33333333333334%;
+ }
+ .col-lg-push-9 {
+ left: 75%;
+ }
+ .col-lg-push-8 {
+ left: 66.66666666666666%;
+ }
+ .col-lg-push-7 {
+ left: 58.333333333333336%;
+ }
+ .col-lg-push-6 {
+ left: 50%;
+ }
+ .col-lg-push-5 {
+ left: 41.66666666666667%;
+ }
+ .col-lg-push-4 {
+ left: 33.33333333333333%;
+ }
+ .col-lg-push-3 {
+ left: 25%;
+ }
+ .col-lg-push-2 {
+ left: 16.666666666666664%;
+ }
+ .col-lg-push-1 {
+ left: 8.333333333333332%;
+ }
+ .col-lg-push-0 {
+ left: 0%;
+ }
+ .col-lg-offset-12 {
+ margin-left: 100%;
+ }
+ .col-lg-offset-11 {
+ margin-left: 91.66666666666666%;
+ }
+ .col-lg-offset-10 {
+ margin-left: 83.33333333333334%;
+ }
+ .col-lg-offset-9 {
+ margin-left: 75%;
+ }
+ .col-lg-offset-8 {
+ margin-left: 66.66666666666666%;
+ }
+ .col-lg-offset-7 {
+ margin-left: 58.333333333333336%;
+ }
+ .col-lg-offset-6 {
+ margin-left: 50%;
+ }
+ .col-lg-offset-5 {
+ margin-left: 41.66666666666667%;
+ }
+ .col-lg-offset-4 {
+ margin-left: 33.33333333333333%;
+ }
+ .col-lg-offset-3 {
+ margin-left: 25%;
+ }
+ .col-lg-offset-2 {
+ margin-left: 16.666666666666664%;
+ }
+ .col-lg-offset-1 {
+ margin-left: 8.333333333333332%;
+ }
+ .col-lg-offset-0 {
+ margin-left: 0%;
+ }
+}
+table {
+ max-width: 100%;
+ background-color: transparent;
+}
+th {
+ text-align: left;
+}
+.table {
+ width: 100%;
+ margin-bottom: 20px;
+}
+.table > thead > tr > th,
+.table > tbody > tr > th,
+.table > tfoot > tr > th,
+.table > thead > tr > td,
+.table > tbody > tr > td,
+.table > tfoot > tr > td {
+ padding: 8px;
+ line-height: 1.428571429;
+ vertical-align: top;
+ border-top: 1px solid #dddddd;
+}
+.table > thead > tr > th {
+ vertical-align: bottom;
+ border-bottom: 2px solid #dddddd;
+}
+.table > caption + thead > tr:first-child > th,
+.table > colgroup + thead > tr:first-child > th,
+.table > thead:first-child > tr:first-child > th,
+.table > caption + thead > tr:first-child > td,
+.table > colgroup + thead > tr:first-child > td,
+.table > thead:first-child > tr:first-child > td {
+ border-top: 0;
+}
+.table > tbody + tbody {
+ border-top: 2px solid #dddddd;
+}
+.table .table {
+ background-color: #ffffff;
+}
+.table-condensed > thead > tr > th,
+.table-condensed > tbody > tr > th,
+.table-condensed > tfoot > tr > th,
+.table-condensed > thead > tr > td,
+.table-condensed > tbody > tr > td,
+.table-condensed > tfoot > tr > td {
+ padding: 5px;
+}
+.table-bordered {
+ border: 1px solid #dddddd;
+}
+.table-bordered > thead > tr > th,
+.table-bordered > tbody > tr > th,
+.table-bordered > tfoot > tr > th,
+.table-bordered > thead > tr > td,
+.table-bordered > tbody > tr > td,
+.table-bordered > tfoot > tr > td {
+ border: 1px solid #dddddd;
+}
+.table-bordered > thead > tr > th,
+.table-bordered > thead > tr > td {
+ border-bottom-width: 2px;
+}
+.table-striped > tbody > tr:nth-child(odd) > td,
+.table-striped > tbody > tr:nth-child(odd) > th {
+ background-color: #f9f9f9;
+}
+.table-hover > tbody > tr:hover > td,
+.table-hover > tbody > tr:hover > th {
+ background-color: #f5f5f5;
+}
+table col[class*="col-"] {
+ position: static;
+ float: none;
+ display: table-column;
+}
+table td[class*="col-"],
+table th[class*="col-"] {
+ position: static;
+ float: none;
+ display: table-cell;
+}
+.table > thead > tr > td.active,
+.table > tbody > tr > td.active,
+.table > tfoot > tr > td.active,
+.table > thead > tr > th.active,
+.table > tbody > tr > th.active,
+.table > tfoot > tr > th.active,
+.table > thead > tr.active > td,
+.table > tbody > tr.active > td,
+.table > tfoot > tr.active > td,
+.table > thead > tr.active > th,
+.table > tbody > tr.active > th,
+.table > tfoot > tr.active > th {
+ background-color: #f5f5f5;
+}
+.table-hover > tbody > tr > td.active:hover,
+.table-hover > tbody > tr > th.active:hover,
+.table-hover > tbody > tr.active:hover > td,
+.table-hover > tbody > tr.active:hover > th {
+ background-color: #e8e8e8;
+}
+.table > thead > tr > td.success,
+.table > tbody > tr > td.success,
+.table > tfoot > tr > td.success,
+.table > thead > tr > th.success,
+.table > tbody > tr > th.success,
+.table > tfoot > tr > th.success,
+.table > thead > tr.success > td,
+.table > tbody > tr.success > td,
+.table > tfoot > tr.success > td,
+.table > thead > tr.success > th,
+.table > tbody > tr.success > th,
+.table > tfoot > tr.success > th {
+ background-color: #dff0d8;
+}
+.table-hover > tbody > tr > td.success:hover,
+.table-hover > tbody > tr > th.success:hover,
+.table-hover > tbody > tr.success:hover > td,
+.table-hover > tbody > tr.success:hover > th {
+ background-color: #d0e9c6;
+}
+.table > thead > tr > td.info,
+.table > tbody > tr > td.info,
+.table > tfoot > tr > td.info,
+.table > thead > tr > th.info,
+.table > tbody > tr > th.info,
+.table > tfoot > tr > th.info,
+.table > thead > tr.info > td,
+.table > tbody > tr.info > td,
+.table > tfoot > tr.info > td,
+.table > thead > tr.info > th,
+.table > tbody > tr.info > th,
+.table > tfoot > tr.info > th {
+ background-color: #d9edf7;
+}
+.table-hover > tbody > tr > td.info:hover,
+.table-hover > tbody > tr > th.info:hover,
+.table-hover > tbody > tr.info:hover > td,
+.table-hover > tbody > tr.info:hover > th {
+ background-color: #c4e3f3;
+}
+.table > thead > tr > td.warning,
+.table > tbody > tr > td.warning,
+.table > tfoot > tr > td.warning,
+.table > thead > tr > th.warning,
+.table > tbody > tr > th.warning,
+.table > tfoot > tr > th.warning,
+.table > thead > tr.warning > td,
+.table > tbody > tr.warning > td,
+.table > tfoot > tr.warning > td,
+.table > thead > tr.warning > th,
+.table > tbody > tr.warning > th,
+.table > tfoot > tr.warning > th {
+ background-color: #fcf8e3;
+}
+.table-hover > tbody > tr > td.warning:hover,
+.table-hover > tbody > tr > th.warning:hover,
+.table-hover > tbody > tr.warning:hover > td,
+.table-hover > tbody > tr.warning:hover > th {
+ background-color: #faf2cc;
+}
+.table > thead > tr > td.danger,
+.table > tbody > tr > td.danger,
+.table > tfoot > tr > td.danger,
+.table > thead > tr > th.danger,
+.table > tbody > tr > th.danger,
+.table > tfoot > tr > th.danger,
+.table > thead > tr.danger > td,
+.table > tbody > tr.danger > td,
+.table > tfoot > tr.danger > td,
+.table > thead > tr.danger > th,
+.table > tbody > tr.danger > th,
+.table > tfoot > tr.danger > th {
+ background-color: #f2dede;
+}
+.table-hover > tbody > tr > td.danger:hover,
+.table-hover > tbody > tr > th.danger:hover,
+.table-hover > tbody > tr.danger:hover > td,
+.table-hover > tbody > tr.danger:hover > th {
+ background-color: #ebcccc;
+}
+@media (max-width: 767px) {
+ .table-responsive {
+ width: 100%;
+ margin-bottom: 15px;
+ overflow-y: hidden;
+ overflow-x: scroll;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
+ border: 1px solid #dddddd;
+ -webkit-overflow-scrolling: touch;
+ }
+ .table-responsive > .table {
+ margin-bottom: 0;
+ }
+ .table-responsive > .table > thead > tr > th,
+ .table-responsive > .table > tbody > tr > th,
+ .table-responsive > .table > tfoot > tr > th,
+ .table-responsive > .table > thead > tr > td,
+ .table-responsive > .table > tbody > tr > td,
+ .table-responsive > .table > tfoot > tr > td {
+ white-space: nowrap;
+ }
+ .table-responsive > .table-bordered {
+ border: 0;
+ }
+ .table-responsive > .table-bordered > thead > tr > th:first-child,
+ .table-responsive > .table-bordered > tbody > tr > th:first-child,
+ .table-responsive > .table-bordered > tfoot > tr > th:first-child,
+ .table-responsive > .table-bordered > thead > tr > td:first-child,
+ .table-responsive > .table-bordered > tbody > tr > td:first-child,
+ .table-responsive > .table-bordered > tfoot > tr > td:first-child {
+ border-left: 0;
+ }
+ .table-responsive > .table-bordered > thead > tr > th:last-child,
+ .table-responsive > .table-bordered > tbody > tr > th:last-child,
+ .table-responsive > .table-bordered > tfoot > tr > th:last-child,
+ .table-responsive > .table-bordered > thead > tr > td:last-child,
+ .table-responsive > .table-bordered > tbody > tr > td:last-child,
+ .table-responsive > .table-bordered > tfoot > tr > td:last-child {
+ border-right: 0;
+ }
+ .table-responsive > .table-bordered > tbody > tr:last-child > th,
+ .table-responsive > .table-bordered > tfoot > tr:last-child > th,
+ .table-responsive > .table-bordered > tbody > tr:last-child > td,
+ .table-responsive > .table-bordered > tfoot > tr:last-child > td {
+ border-bottom: 0;
+ }
+}
+fieldset {
+ padding: 0;
+ margin: 0;
+ border: 0;
+ min-width: 0;
+}
+legend {
+ display: block;
+ width: 100%;
+ padding: 0;
+ margin-bottom: 20px;
+ font-size: 21px;
+ line-height: inherit;
+ color: #333333;
+ border: 0;
+ border-bottom: 1px solid #e5e5e5;
+}
+label {
+ display: inline-block;
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+input[type="search"] {
+ -webkit-box-sizing: border-box;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+}
+input[type="radio"],
+input[type="checkbox"] {
+ margin: 4px 0 0;
+ margin-top: 1px \9;
+ /* IE8-9 */
+
+ line-height: normal;
+}
+input[type="file"] {
+ display: block;
+}
+input[type="range"] {
+ display: block;
+ width: 100%;
+}
+select[multiple],
+select[size] {
+ height: auto;
+}
+input[type="file"]:focus,
+input[type="radio"]:focus,
+input[type="checkbox"]:focus {
+ outline: thin dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: -2px;
+}
+output {
+ display: block;
+ padding-top: 7px;
+ font-size: 14px;
+ line-height: 1.428571429;
+ color: #555555;
+}
+.form-control {
+ display: block;
+ width: 100%;
+ height: 34px;
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.428571429;
+ color: #555555;
+ background-color: #ffffff;
+ background-image: none;
+ border: 1px solid #cccccc;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
+ transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
+}
+.form-control:focus {
+ border-color: #66afe9;
+ outline: 0;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);
+ box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);
+}
+.form-control::-moz-placeholder {
+ color: #999999;
+ opacity: 1;
+}
+.form-control:-ms-input-placeholder {
+ color: #999999;
+}
+.form-control::-webkit-input-placeholder {
+ color: #999999;
+}
+.form-control[disabled],
+.form-control[readonly],
+fieldset[disabled] .form-control {
+ cursor: not-allowed;
+ background-color: #eeeeee;
+ opacity: 1;
+}
+textarea.form-control {
+ height: auto;
+}
+input[type="search"] {
+ -webkit-appearance: none;
+}
+input[type="date"] {
+ line-height: 34px;
+}
+.form-group {
+ margin-bottom: 15px;
+}
+.radio,
+.checkbox {
+ display: block;
+ min-height: 20px;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ padding-left: 20px;
+}
+.radio label,
+.checkbox label {
+ display: inline;
+ font-weight: normal;
+ cursor: pointer;
+}
+.radio input[type="radio"],
+.radio-inline input[type="radio"],
+.checkbox input[type="checkbox"],
+.checkbox-inline input[type="checkbox"] {
+ float: left;
+ margin-left: -20px;
+}
+.radio + .radio,
+.checkbox + .checkbox {
+ margin-top: -5px;
+}
+.radio-inline,
+.checkbox-inline {
+ display: inline-block;
+ padding-left: 20px;
+ margin-bottom: 0;
+ vertical-align: middle;
+ font-weight: normal;
+ cursor: pointer;
+}
+.radio-inline + .radio-inline,
+.checkbox-inline + .checkbox-inline {
+ margin-top: 0;
+ margin-left: 10px;
+}
+input[type="radio"][disabled],
+input[type="checkbox"][disabled],
+.radio[disabled],
+.radio-inline[disabled],
+.checkbox[disabled],
+.checkbox-inline[disabled],
+fieldset[disabled] input[type="radio"],
+fieldset[disabled] input[type="checkbox"],
+fieldset[disabled] .radio,
+fieldset[disabled] .radio-inline,
+fieldset[disabled] .checkbox,
+fieldset[disabled] .checkbox-inline {
+ cursor: not-allowed;
+}
+.input-sm {
+ height: 30px;
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+select.input-sm {
+ height: 30px;
+ line-height: 30px;
+}
+textarea.input-sm,
+select[multiple].input-sm {
+ height: auto;
+}
+.input-lg {
+ height: 45px;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 1.33;
+ border-radius: 6px;
+}
+select.input-lg {
+ height: 45px;
+ line-height: 45px;
+}
+textarea.input-lg,
+select[multiple].input-lg {
+ height: auto;
+}
+.has-feedback {
+ position: relative;
+}
+.has-feedback .form-control {
+ padding-right: 42.5px;
+}
+.has-feedback .form-control-feedback {
+ position: absolute;
+ top: 25px;
+ right: 0;
+ display: block;
+ width: 34px;
+ height: 34px;
+ line-height: 34px;
+ text-align: center;
+}
+.has-success .help-block,
+.has-success .control-label,
+.has-success .radio,
+.has-success .checkbox,
+.has-success .radio-inline,
+.has-success .checkbox-inline {
+ color: #468847;
+}
+.has-success .form-control {
+ border-color: #468847;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+.has-success .form-control:focus {
+ border-color: #356635;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #7aba7b;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #7aba7b;
+}
+.has-success .input-group-addon {
+ color: #468847;
+ border-color: #468847;
+ background-color: #dff0d8;
+}
+.has-success .form-control-feedback {
+ color: #468847;
+}
+.has-warning .help-block,
+.has-warning .control-label,
+.has-warning .radio,
+.has-warning .checkbox,
+.has-warning .radio-inline,
+.has-warning .checkbox-inline {
+ color: #c09853;
+}
+.has-warning .form-control {
+ border-color: #c09853;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+.has-warning .form-control:focus {
+ border-color: #a47e3c;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #dbc59e;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #dbc59e;
+}
+.has-warning .input-group-addon {
+ color: #c09853;
+ border-color: #c09853;
+ background-color: #fcf8e3;
+}
+.has-warning .form-control-feedback {
+ color: #c09853;
+}
+.has-error .help-block,
+.has-error .control-label,
+.has-error .radio,
+.has-error .checkbox,
+.has-error .radio-inline,
+.has-error .checkbox-inline {
+ color: #b94a48;
+}
+.has-error .form-control {
+ border-color: #b94a48;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+.has-error .form-control:focus {
+ border-color: #953b39;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #d59392;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #d59392;
+}
+.has-error .input-group-addon {
+ color: #b94a48;
+ border-color: #b94a48;
+ background-color: #f2dede;
+}
+.has-error .form-control-feedback {
+ color: #b94a48;
+}
+.form-control-static {
+ margin-bottom: 0;
+}
+.help-block {
+ display: block;
+ margin-top: 5px;
+ margin-bottom: 10px;
+ color: #737373;
+}
+@media (min-width: 768px) {
+ .form-inline .form-group {
+ display: inline-block;
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .form-inline .form-control {
+ display: inline-block;
+ width: auto;
+ vertical-align: middle;
+ }
+ .form-inline .input-group > .form-control {
+ width: 100%;
+ }
+ .form-inline .control-label {
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .form-inline .radio,
+ .form-inline .checkbox {
+ display: inline-block;
+ margin-top: 0;
+ margin-bottom: 0;
+ padding-left: 0;
+ vertical-align: middle;
+ }
+ .form-inline .radio input[type="radio"],
+ .form-inline .checkbox input[type="checkbox"] {
+ float: none;
+ margin-left: 0;
+ }
+ .form-inline .has-feedback .form-control-feedback {
+ top: 0;
+ }
+}
+.form-horizontal .control-label,
+.form-horizontal .radio,
+.form-horizontal .checkbox,
+.form-horizontal .radio-inline,
+.form-horizontal .checkbox-inline {
+ margin-top: 0;
+ margin-bottom: 0;
+ padding-top: 7px;
+}
+.form-horizontal .radio,
+.form-horizontal .checkbox {
+ min-height: 27px;
+}
+.form-horizontal .form-group {
+ margin-left: -15px;
+ margin-right: -15px;
+}
+.form-horizontal .form-control-static {
+ padding-top: 7px;
+}
+@media (min-width: 768px) {
+ .form-horizontal .control-label {
+ text-align: right;
+ }
+}
+.form-horizontal .has-feedback .form-control-feedback {
+ top: 0;
+ right: 15px;
+}
+.btn {
+ display: inline-block;
+ margin-bottom: 0;
+ font-weight: normal;
+ text-align: center;
+ vertical-align: middle;
+ cursor: pointer;
+ background-image: none;
+ border: 1px solid transparent;
+ white-space: nowrap;
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.428571429;
+ border-radius: 4px;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ -o-user-select: none;
+ user-select: none;
+}
+.btn:focus {
+ outline: thin dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: -2px;
+}
+.btn:hover,
+.btn:focus {
+ color: #333333;
+ text-decoration: none;
+}
+.btn:active,
+.btn.active {
+ outline: 0;
+ background-image: none;
+ -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
+.btn.disabled,
+.btn[disabled],
+fieldset[disabled] .btn {
+ cursor: not-allowed;
+ pointer-events: none;
+ opacity: 0.65;
+ filter: alpha(opacity=65);
+ -webkit-box-shadow: none;
+ box-shadow: none;
+}
+.btn-default {
+ color: #333333;
+ background-color: #ffffff;
+ border-color: #cccccc;
+}
+.btn-default:hover,
+.btn-default:focus,
+.btn-default:active,
+.btn-default.active,
+.open .dropdown-toggle.btn-default {
+ color: #333333;
+ background-color: #ebebeb;
+ border-color: #adadad;
+}
+.btn-default:active,
+.btn-default.active,
+.open .dropdown-toggle.btn-default {
+ background-image: none;
+}
+.btn-default.disabled,
+.btn-default[disabled],
+fieldset[disabled] .btn-default,
+.btn-default.disabled:hover,
+.btn-default[disabled]:hover,
+fieldset[disabled] .btn-default:hover,
+.btn-default.disabled:focus,
+.btn-default[disabled]:focus,
+fieldset[disabled] .btn-default:focus,
+.btn-default.disabled:active,
+.btn-default[disabled]:active,
+fieldset[disabled] .btn-default:active,
+.btn-default.disabled.active,
+.btn-default[disabled].active,
+fieldset[disabled] .btn-default.active {
+ background-color: #ffffff;
+ border-color: #cccccc;
+}
+.btn-default .badge {
+ color: #ffffff;
+ background-color: #333333;
+}
+.btn-primary {
+ color: #ffffff;
+ background-color: #428bca;
+ border-color: #357ebd;
+}
+.btn-primary:hover,
+.btn-primary:focus,
+.btn-primary:active,
+.btn-primary.active,
+.open .dropdown-toggle.btn-primary {
+ color: #ffffff;
+ background-color: #3276b1;
+ border-color: #285e8e;
+}
+.btn-primary:active,
+.btn-primary.active,
+.open .dropdown-toggle.btn-primary {
+ background-image: none;
+}
+.btn-primary.disabled,
+.btn-primary[disabled],
+fieldset[disabled] .btn-primary,
+.btn-primary.disabled:hover,
+.btn-primary[disabled]:hover,
+fieldset[disabled] .btn-primary:hover,
+.btn-primary.disabled:focus,
+.btn-primary[disabled]:focus,
+fieldset[disabled] .btn-primary:focus,
+.btn-primary.disabled:active,
+.btn-primary[disabled]:active,
+fieldset[disabled] .btn-primary:active,
+.btn-primary.disabled.active,
+.btn-primary[disabled].active,
+fieldset[disabled] .btn-primary.active {
+ background-color: #428bca;
+ border-color: #357ebd;
+}
+.btn-primary .badge {
+ color: #428bca;
+ background-color: #ffffff;
+}
+.btn-success {
+ color: #ffffff;
+ background-color: #5cb85c;
+ border-color: #4cae4c;
+}
+.btn-success:hover,
+.btn-success:focus,
+.btn-success:active,
+.btn-success.active,
+.open .dropdown-toggle.btn-success {
+ color: #ffffff;
+ background-color: #47a447;
+ border-color: #398439;
+}
+.btn-success:active,
+.btn-success.active,
+.open .dropdown-toggle.btn-success {
+ background-image: none;
+}
+.btn-success.disabled,
+.btn-success[disabled],
+fieldset[disabled] .btn-success,
+.btn-success.disabled:hover,
+.btn-success[disabled]:hover,
+fieldset[disabled] .btn-success:hover,
+.btn-success.disabled:focus,
+.btn-success[disabled]:focus,
+fieldset[disabled] .btn-success:focus,
+.btn-success.disabled:active,
+.btn-success[disabled]:active,
+fieldset[disabled] .btn-success:active,
+.btn-success.disabled.active,
+.btn-success[disabled].active,
+fieldset[disabled] .btn-success.active {
+ background-color: #5cb85c;
+ border-color: #4cae4c;
+}
+.btn-success .badge {
+ color: #5cb85c;
+ background-color: #ffffff;
+}
+.btn-info {
+ color: #ffffff;
+ background-color: #5bc0de;
+ border-color: #46b8da;
+}
+.btn-info:hover,
+.btn-info:focus,
+.btn-info:active,
+.btn-info.active,
+.open .dropdown-toggle.btn-info {
+ color: #ffffff;
+ background-color: #39b3d7;
+ border-color: #269abc;
+}
+.btn-info:active,
+.btn-info.active,
+.open .dropdown-toggle.btn-info {
+ background-image: none;
+}
+.btn-info.disabled,
+.btn-info[disabled],
+fieldset[disabled] .btn-info,
+.btn-info.disabled:hover,
+.btn-info[disabled]:hover,
+fieldset[disabled] .btn-info:hover,
+.btn-info.disabled:focus,
+.btn-info[disabled]:focus,
+fieldset[disabled] .btn-info:focus,
+.btn-info.disabled:active,
+.btn-info[disabled]:active,
+fieldset[disabled] .btn-info:active,
+.btn-info.disabled.active,
+.btn-info[disabled].active,
+fieldset[disabled] .btn-info.active {
+ background-color: #5bc0de;
+ border-color: #46b8da;
+}
+.btn-info .badge {
+ color: #5bc0de;
+ background-color: #ffffff;
+}
+.btn-warning {
+ color: #ffffff;
+ background-color: #f0ad4e;
+ border-color: #eea236;
+}
+.btn-warning:hover,
+.btn-warning:focus,
+.btn-warning:active,
+.btn-warning.active,
+.open .dropdown-toggle.btn-warning {
+ color: #ffffff;
+ background-color: #ed9c28;
+ border-color: #d58512;
+}
+.btn-warning:active,
+.btn-warning.active,
+.open .dropdown-toggle.btn-warning {
+ background-image: none;
+}
+.btn-warning.disabled,
+.btn-warning[disabled],
+fieldset[disabled] .btn-warning,
+.btn-warning.disabled:hover,
+.btn-warning[disabled]:hover,
+fieldset[disabled] .btn-warning:hover,
+.btn-warning.disabled:focus,
+.btn-warning[disabled]:focus,
+fieldset[disabled] .btn-warning:focus,
+.btn-warning.disabled:active,
+.btn-warning[disabled]:active,
+fieldset[disabled] .btn-warning:active,
+.btn-warning.disabled.active,
+.btn-warning[disabled].active,
+fieldset[disabled] .btn-warning.active {
+ background-color: #f0ad4e;
+ border-color: #eea236;
+}
+.btn-warning .badge {
+ color: #f0ad4e;
+ background-color: #ffffff;
+}
+.btn-danger {
+ color: #ffffff;
+ background-color: #d9534f;
+ border-color: #d43f3a;
+}
+.btn-danger:hover,
+.btn-danger:focus,
+.btn-danger:active,
+.btn-danger.active,
+.open .dropdown-toggle.btn-danger {
+ color: #ffffff;
+ background-color: #d2322d;
+ border-color: #ac2925;
+}
+.btn-danger:active,
+.btn-danger.active,
+.open .dropdown-toggle.btn-danger {
+ background-image: none;
+}
+.btn-danger.disabled,
+.btn-danger[disabled],
+fieldset[disabled] .btn-danger,
+.btn-danger.disabled:hover,
+.btn-danger[disabled]:hover,
+fieldset[disabled] .btn-danger:hover,
+.btn-danger.disabled:focus,
+.btn-danger[disabled]:focus,
+fieldset[disabled] .btn-danger:focus,
+.btn-danger.disabled:active,
+.btn-danger[disabled]:active,
+fieldset[disabled] .btn-danger:active,
+.btn-danger.disabled.active,
+.btn-danger[disabled].active,
+fieldset[disabled] .btn-danger.active {
+ background-color: #d9534f;
+ border-color: #d43f3a;
+}
+.btn-danger .badge {
+ color: #d9534f;
+ background-color: #ffffff;
+}
+.btn-link {
+ color: #428bca;
+ font-weight: normal;
+ cursor: pointer;
+ border-radius: 0;
+}
+.btn-link,
+.btn-link:active,
+.btn-link[disabled],
+fieldset[disabled] .btn-link {
+ background-color: transparent;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+}
+.btn-link,
+.btn-link:hover,
+.btn-link:focus,
+.btn-link:active {
+ border-color: transparent;
+}
+.btn-link:hover,
+.btn-link:focus {
+ color: #2a6496;
+ text-decoration: underline;
+ background-color: transparent;
+}
+.btn-link[disabled]:hover,
+fieldset[disabled] .btn-link:hover,
+.btn-link[disabled]:focus,
+fieldset[disabled] .btn-link:focus {
+ color: #999999;
+ text-decoration: none;
+}
+.btn-lg,
+.btn-group-lg > .btn {
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 1.33;
+ border-radius: 6px;
+}
+.btn-sm,
+.btn-group-sm > .btn {
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+.btn-xs,
+.btn-group-xs > .btn {
+ padding: 1px 5px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+.btn-block {
+ display: block;
+ width: 100%;
+ padding-left: 0;
+ padding-right: 0;
+}
+.btn-block + .btn-block {
+ margin-top: 5px;
+}
+input[type="submit"].btn-block,
+input[type="reset"].btn-block,
+input[type="button"].btn-block {
+ width: 100%;
+}
+.fade {
+ opacity: 0;
+ -webkit-transition: opacity 0.15s linear;
+ transition: opacity 0.15s linear;
+}
+.fade.in {
+ opacity: 1;
+}
+.collapse {
+ display: none;
+}
+.collapse.in {
+ display: block;
+}
+.collapsing {
+ position: relative;
+ height: 0;
+ overflow: hidden;
+ -webkit-transition: height 0.35s ease;
+ transition: height 0.35s ease;
+}
+@font-face {
+ font-family: 'Glyphicons Halflings';
+ src: url('../../rest_framework/fonts/glyphicons-halflings-regular.eot');
+ src: url('../../rest_framework/fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../../rest_framework/fonts/glyphicons-halflings-regular.woff') format('woff'), url('../../rest_framework/fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../../rest_framework/fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');
+}
+.glyphicon {
+ position: relative;
+ top: 1px;
+ display: inline-block;
+ font-family: 'Glyphicons Halflings';
+ font-style: normal;
+ font-weight: normal;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.glyphicon-asterisk:before {
+ content: "\2a";
+}
+.glyphicon-plus:before {
+ content: "\2b";
+}
+.glyphicon-euro:before {
+ content: "\20ac";
+}
+.glyphicon-minus:before {
+ content: "\2212";
+}
+.glyphicon-cloud:before {
+ content: "\2601";
+}
+.glyphicon-envelope:before {
+ content: "\2709";
+}
+.glyphicon-pencil:before {
+ content: "\270f";
+}
+.glyphicon-glass:before {
+ content: "\e001";
+}
+.glyphicon-music:before {
+ content: "\e002";
+}
+.glyphicon-search:before {
+ content: "\e003";
+}
+.glyphicon-heart:before {
+ content: "\e005";
+}
+.glyphicon-star:before {
+ content: "\e006";
+}
+.glyphicon-star-empty:before {
+ content: "\e007";
+}
+.glyphicon-user:before {
+ content: "\e008";
+}
+.glyphicon-film:before {
+ content: "\e009";
+}
+.glyphicon-th-large:before {
+ content: "\e010";
+}
+.glyphicon-th:before {
+ content: "\e011";
+}
+.glyphicon-th-list:before {
+ content: "\e012";
+}
+.glyphicon-ok:before {
+ content: "\e013";
+}
+.glyphicon-remove:before {
+ content: "\e014";
+}
+.glyphicon-zoom-in:before {
+ content: "\e015";
+}
+.glyphicon-zoom-out:before {
+ content: "\e016";
+}
+.glyphicon-off:before {
+ content: "\e017";
+}
+.glyphicon-signal:before {
+ content: "\e018";
+}
+.glyphicon-cog:before {
+ content: "\e019";
+}
+.glyphicon-trash:before {
+ content: "\e020";
+}
+.glyphicon-home:before {
+ content: "\e021";
+}
+.glyphicon-file:before {
+ content: "\e022";
+}
+.glyphicon-time:before {
+ content: "\e023";
+}
+.glyphicon-road:before {
+ content: "\e024";
+}
+.glyphicon-download-alt:before {
+ content: "\e025";
+}
+.glyphicon-download:before {
+ content: "\e026";
+}
+.glyphicon-upload:before {
+ content: "\e027";
+}
+.glyphicon-inbox:before {
+ content: "\e028";
+}
+.glyphicon-play-circle:before {
+ content: "\e029";
+}
+.glyphicon-repeat:before {
+ content: "\e030";
+}
+.glyphicon-refresh:before {
+ content: "\e031";
+}
+.glyphicon-list-alt:before {
+ content: "\e032";
+}
+.glyphicon-lock:before {
+ content: "\e033";
+}
+.glyphicon-flag:before {
+ content: "\e034";
+}
+.glyphicon-headphones:before {
+ content: "\e035";
+}
+.glyphicon-volume-off:before {
+ content: "\e036";
+}
+.glyphicon-volume-down:before {
+ content: "\e037";
+}
+.glyphicon-volume-up:before {
+ content: "\e038";
+}
+.glyphicon-qrcode:before {
+ content: "\e039";
+}
+.glyphicon-barcode:before {
+ content: "\e040";
+}
+.glyphicon-tag:before {
+ content: "\e041";
+}
+.glyphicon-tags:before {
+ content: "\e042";
+}
+.glyphicon-book:before {
+ content: "\e043";
+}
+.glyphicon-bookmark:before {
+ content: "\e044";
+}
+.glyphicon-print:before {
+ content: "\e045";
+}
+.glyphicon-camera:before {
+ content: "\e046";
+}
+.glyphicon-font:before {
+ content: "\e047";
+}
+.glyphicon-bold:before {
+ content: "\e048";
+}
+.glyphicon-italic:before {
+ content: "\e049";
+}
+.glyphicon-text-height:before {
+ content: "\e050";
+}
+.glyphicon-text-width:before {
+ content: "\e051";
+}
+.glyphicon-align-left:before {
+ content: "\e052";
+}
+.glyphicon-align-center:before {
+ content: "\e053";
+}
+.glyphicon-align-right:before {
+ content: "\e054";
+}
+.glyphicon-align-justify:before {
+ content: "\e055";
+}
+.glyphicon-list:before {
+ content: "\e056";
+}
+.glyphicon-indent-left:before {
+ content: "\e057";
+}
+.glyphicon-indent-right:before {
+ content: "\e058";
+}
+.glyphicon-facetime-video:before {
+ content: "\e059";
+}
+.glyphicon-picture:before {
+ content: "\e060";
+}
+.glyphicon-map-marker:before {
+ content: "\e062";
+}
+.glyphicon-adjust:before {
+ content: "\e063";
+}
+.glyphicon-tint:before {
+ content: "\e064";
+}
+.glyphicon-edit:before {
+ content: "\e065";
+}
+.glyphicon-share:before {
+ content: "\e066";
+}
+.glyphicon-check:before {
+ content: "\e067";
+}
+.glyphicon-move:before {
+ content: "\e068";
+}
+.glyphicon-step-backward:before {
+ content: "\e069";
+}
+.glyphicon-fast-backward:before {
+ content: "\e070";
+}
+.glyphicon-backward:before {
+ content: "\e071";
+}
+.glyphicon-play:before {
+ content: "\e072";
+}
+.glyphicon-pause:before {
+ content: "\e073";
+}
+.glyphicon-stop:before {
+ content: "\e074";
+}
+.glyphicon-forward:before {
+ content: "\e075";
+}
+.glyphicon-fast-forward:before {
+ content: "\e076";
+}
+.glyphicon-step-forward:before {
+ content: "\e077";
+}
+.glyphicon-eject:before {
+ content: "\e078";
+}
+.glyphicon-chevron-left:before {
+ content: "\e079";
+}
+.glyphicon-chevron-right:before {
+ content: "\e080";
+}
+.glyphicon-plus-sign:before {
+ content: "\e081";
+}
+.glyphicon-minus-sign:before {
+ content: "\e082";
+}
+.glyphicon-remove-sign:before {
+ content: "\e083";
+}
+.glyphicon-ok-sign:before {
+ content: "\e084";
+}
+.glyphicon-question-sign:before {
+ content: "\e085";
+}
+.glyphicon-info-sign:before {
+ content: "\e086";
+}
+.glyphicon-screenshot:before {
+ content: "\e087";
+}
+.glyphicon-remove-circle:before {
+ content: "\e088";
+}
+.glyphicon-ok-circle:before {
+ content: "\e089";
+}
+.glyphicon-ban-circle:before {
+ content: "\e090";
+}
+.glyphicon-arrow-left:before {
+ content: "\e091";
+}
+.glyphicon-arrow-right:before {
+ content: "\e092";
+}
+.glyphicon-arrow-up:before {
+ content: "\e093";
+}
+.glyphicon-arrow-down:before {
+ content: "\e094";
+}
+.glyphicon-share-alt:before {
+ content: "\e095";
+}
+.glyphicon-resize-full:before {
+ content: "\e096";
+}
+.glyphicon-resize-small:before {
+ content: "\e097";
+}
+.glyphicon-exclamation-sign:before {
+ content: "\e101";
+}
+.glyphicon-gift:before {
+ content: "\e102";
+}
+.glyphicon-leaf:before {
+ content: "\e103";
+}
+.glyphicon-fire:before {
+ content: "\e104";
+}
+.glyphicon-eye-open:before {
+ content: "\e105";
+}
+.glyphicon-eye-close:before {
+ content: "\e106";
+}
+.glyphicon-warning-sign:before {
+ content: "\e107";
+}
+.glyphicon-plane:before {
+ content: "\e108";
+}
+.glyphicon-calendar:before {
+ content: "\e109";
+}
+.glyphicon-random:before {
+ content: "\e110";
+}
+.glyphicon-comment:before {
+ content: "\e111";
+}
+.glyphicon-magnet:before {
+ content: "\e112";
+}
+.glyphicon-chevron-up:before {
+ content: "\e113";
+}
+.glyphicon-chevron-down:before {
+ content: "\e114";
+}
+.glyphicon-retweet:before {
+ content: "\e115";
+}
+.glyphicon-shopping-cart:before {
+ content: "\e116";
+}
+.glyphicon-folder-close:before {
+ content: "\e117";
+}
+.glyphicon-folder-open:before {
+ content: "\e118";
+}
+.glyphicon-resize-vertical:before {
+ content: "\e119";
+}
+.glyphicon-resize-horizontal:before {
+ content: "\e120";
+}
+.glyphicon-hdd:before {
+ content: "\e121";
+}
+.glyphicon-bullhorn:before {
+ content: "\e122";
+}
+.glyphicon-bell:before {
+ content: "\e123";
+}
+.glyphicon-certificate:before {
+ content: "\e124";
+}
+.glyphicon-thumbs-up:before {
+ content: "\e125";
+}
+.glyphicon-thumbs-down:before {
+ content: "\e126";
+}
+.glyphicon-hand-right:before {
+ content: "\e127";
+}
+.glyphicon-hand-left:before {
+ content: "\e128";
+}
+.glyphicon-hand-up:before {
+ content: "\e129";
+}
+.glyphicon-hand-down:before {
+ content: "\e130";
+}
+.glyphicon-circle-arrow-right:before {
+ content: "\e131";
+}
+.glyphicon-circle-arrow-left:before {
+ content: "\e132";
+}
+.glyphicon-circle-arrow-up:before {
+ content: "\e133";
+}
+.glyphicon-circle-arrow-down:before {
+ content: "\e134";
+}
+.glyphicon-globe:before {
+ content: "\e135";
+}
+.glyphicon-wrench:before {
+ content: "\e136";
+}
+.glyphicon-tasks:before {
+ content: "\e137";
+}
+.glyphicon-filter:before {
+ content: "\e138";
+}
+.glyphicon-briefcase:before {
+ content: "\e139";
+}
+.glyphicon-fullscreen:before {
+ content: "\e140";
+}
+.glyphicon-dashboard:before {
+ content: "\e141";
+}
+.glyphicon-paperclip:before {
+ content: "\e142";
+}
+.glyphicon-heart-empty:before {
+ content: "\e143";
+}
+.glyphicon-link:before {
+ content: "\e144";
+}
+.glyphicon-phone:before {
+ content: "\e145";
+}
+.glyphicon-pushpin:before {
+ content: "\e146";
+}
+.glyphicon-usd:before {
+ content: "\e148";
+}
+.glyphicon-gbp:before {
+ content: "\e149";
+}
+.glyphicon-sort:before {
+ content: "\e150";
+}
+.glyphicon-sort-by-alphabet:before {
+ content: "\e151";
+}
+.glyphicon-sort-by-alphabet-alt:before {
+ content: "\e152";
+}
+.glyphicon-sort-by-order:before {
+ content: "\e153";
+}
+.glyphicon-sort-by-order-alt:before {
+ content: "\e154";
+}
+.glyphicon-sort-by-attributes:before {
+ content: "\e155";
+}
+.glyphicon-sort-by-attributes-alt:before {
+ content: "\e156";
+}
+.glyphicon-unchecked:before {
+ content: "\e157";
+}
+.glyphicon-expand:before {
+ content: "\e158";
+}
+.glyphicon-collapse-down:before {
+ content: "\e159";
+}
+.glyphicon-collapse-up:before {
+ content: "\e160";
+}
+.glyphicon-log-in:before {
+ content: "\e161";
+}
+.glyphicon-flash:before {
+ content: "\e162";
+}
+.glyphicon-log-out:before {
+ content: "\e163";
+}
+.glyphicon-new-window:before {
+ content: "\e164";
+}
+.glyphicon-record:before {
+ content: "\e165";
+}
+.glyphicon-save:before {
+ content: "\e166";
+}
+.glyphicon-open:before {
+ content: "\e167";
+}
+.glyphicon-saved:before {
+ content: "\e168";
+}
+.glyphicon-import:before {
+ content: "\e169";
+}
+.glyphicon-export:before {
+ content: "\e170";
+}
+.glyphicon-send:before {
+ content: "\e171";
+}
+.glyphicon-floppy-disk:before {
+ content: "\e172";
+}
+.glyphicon-floppy-saved:before {
+ content: "\e173";
+}
+.glyphicon-floppy-remove:before {
+ content: "\e174";
+}
+.glyphicon-floppy-save:before {
+ content: "\e175";
+}
+.glyphicon-floppy-open:before {
+ content: "\e176";
+}
+.glyphicon-credit-card:before {
+ content: "\e177";
+}
+.glyphicon-transfer:before {
+ content: "\e178";
+}
+.glyphicon-cutlery:before {
+ content: "\e179";
+}
+.glyphicon-header:before {
+ content: "\e180";
+}
+.glyphicon-compressed:before {
+ content: "\e181";
+}
+.glyphicon-earphone:before {
+ content: "\e182";
+}
+.glyphicon-phone-alt:before {
+ content: "\e183";
+}
+.glyphicon-tower:before {
+ content: "\e184";
+}
+.glyphicon-stats:before {
+ content: "\e185";
+}
+.glyphicon-sd-video:before {
+ content: "\e186";
+}
+.glyphicon-hd-video:before {
+ content: "\e187";
+}
+.glyphicon-subtitles:before {
+ content: "\e188";
+}
+.glyphicon-sound-stereo:before {
+ content: "\e189";
+}
+.glyphicon-sound-dolby:before {
+ content: "\e190";
+}
+.glyphicon-sound-5-1:before {
+ content: "\e191";
+}
+.glyphicon-sound-6-1:before {
+ content: "\e192";
+}
+.glyphicon-sound-7-1:before {
+ content: "\e193";
+}
+.glyphicon-copyright-mark:before {
+ content: "\e194";
+}
+.glyphicon-registration-mark:before {
+ content: "\e195";
+}
+.glyphicon-cloud-download:before {
+ content: "\e197";
+}
+.glyphicon-cloud-upload:before {
+ content: "\e198";
+}
+.glyphicon-tree-conifer:before {
+ content: "\e199";
+}
+.glyphicon-tree-deciduous:before {
+ content: "\e200";
+}
+.caret {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ margin-left: 2px;
+ vertical-align: middle;
+ border-top: 4px solid;
+ border-right: 4px solid transparent;
+ border-left: 4px solid transparent;
+}
+.dropdown {
+ position: relative;
+}
+.dropdown-toggle:focus {
+ outline: 0;
+}
+.dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 1000;
+ display: none;
+ float: left;
+ min-width: 160px;
+ padding: 5px 0;
+ margin: 2px 0 0;
+ list-style: none;
+ font-size: 14px;
+ background-color: #ffffff;
+ border: 1px solid #cccccc;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 4px;
+ -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
+ background-clip: padding-box;
+}
+.dropdown-menu.pull-right {
+ right: 0;
+ left: auto;
+}
+.dropdown-menu .divider {
+ height: 1px;
+ margin: 9px 0;
+ overflow: hidden;
+ background-color: #e5e5e5;
+}
+.dropdown-menu > li > a {
+ display: block;
+ padding: 3px 20px;
+ clear: both;
+ font-weight: normal;
+ line-height: 1.428571429;
+ color: #333333;
+ white-space: nowrap;
+}
+.dropdown-menu > li > a:hover,
+.dropdown-menu > li > a:focus {
+ text-decoration: none;
+ color: #ffffff;
+ background-color: #428bca;
+}
+.dropdown-menu > .active > a,
+.dropdown-menu > .active > a:hover,
+.dropdown-menu > .active > a:focus {
+ color: #ffffff;
+ text-decoration: none;
+ outline: 0;
+ background-color: #428bca;
+}
+.dropdown-menu > .disabled > a,
+.dropdown-menu > .disabled > a:hover,
+.dropdown-menu > .disabled > a:focus {
+ color: #999999;
+}
+.dropdown-menu > .disabled > a:hover,
+.dropdown-menu > .disabled > a:focus {
+ text-decoration: none;
+ background-color: transparent;
+ background-image: none;
+ filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);
+ cursor: not-allowed;
+}
+.open > .dropdown-menu {
+ display: block;
+}
+.open > a {
+ outline: 0;
+}
+.dropdown-menu-right {
+ left: auto;
+ right: 0;
+}
+.dropdown-menu-left {
+ left: 0;
+ right: auto;
+}
+.dropdown-header {
+ display: block;
+ padding: 3px 20px;
+ font-size: 12px;
+ line-height: 1.428571429;
+ color: #999999;
+}
+.dropdown-backdrop {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: 0;
+ z-index: 990;
+}
+.pull-right > .dropdown-menu {
+ right: 0;
+ left: auto;
+}
+.dropup .caret,
+.navbar-fixed-bottom .dropdown .caret {
+ border-top: 0;
+ border-bottom: 4px solid;
+ content: "";
+}
+.dropup .dropdown-menu,
+.navbar-fixed-bottom .dropdown .dropdown-menu {
+ top: auto;
+ bottom: 100%;
+ margin-bottom: 1px;
+}
+@media (min-width: 768px) {
+ .navbar-right .dropdown-menu {
+ left: auto;
+ right: 0;
+ }
+ .navbar-right .dropdown-menu-left {
+ left: 0;
+ right: auto;
+ }
+}
+.btn-group,
+.btn-group-vertical {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+}
+.btn-group > .btn,
+.btn-group-vertical > .btn {
+ position: relative;
+ float: left;
+}
+.btn-group > .btn:hover,
+.btn-group-vertical > .btn:hover,
+.btn-group > .btn:focus,
+.btn-group-vertical > .btn:focus,
+.btn-group > .btn:active,
+.btn-group-vertical > .btn:active,
+.btn-group > .btn.active,
+.btn-group-vertical > .btn.active {
+ z-index: 2;
+}
+.btn-group > .btn:focus,
+.btn-group-vertical > .btn:focus {
+ outline: none;
+}
+.btn-group .btn + .btn,
+.btn-group .btn + .btn-group,
+.btn-group .btn-group + .btn,
+.btn-group .btn-group + .btn-group {
+ margin-left: -1px;
+}
+.btn-toolbar {
+ margin-left: -5px;
+}
+.btn-toolbar .btn-group,
+.btn-toolbar .input-group {
+ float: left;
+}
+.btn-toolbar > .btn,
+.btn-toolbar > .btn-group,
+.btn-toolbar > .input-group {
+ margin-left: 5px;
+}
+.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {
+ border-radius: 0;
+}
+.btn-group > .btn:first-child {
+ margin-left: 0;
+}
+.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {
+ border-bottom-right-radius: 0;
+ border-top-right-radius: 0;
+}
+.btn-group > .btn:last-child:not(:first-child),
+.btn-group > .dropdown-toggle:not(:first-child) {
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+}
+.btn-group > .btn-group {
+ float: left;
+}
+.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {
+ border-radius: 0;
+}
+.btn-group > .btn-group:first-child > .btn:last-child,
+.btn-group > .btn-group:first-child > .dropdown-toggle {
+ border-bottom-right-radius: 0;
+ border-top-right-radius: 0;
+}
+.btn-group > .btn-group:last-child > .btn:first-child {
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+}
+.btn-group .dropdown-toggle:active,
+.btn-group.open .dropdown-toggle {
+ outline: 0;
+}
+.btn-group > .btn + .dropdown-toggle {
+ padding-left: 8px;
+ padding-right: 8px;
+}
+.btn-group > .btn-lg + .dropdown-toggle {
+ padding-left: 12px;
+ padding-right: 12px;
+}
+.btn-group.open .dropdown-toggle {
+ -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
+.btn-group.open .dropdown-toggle.btn-link {
+ -webkit-box-shadow: none;
+ box-shadow: none;
+}
+.btn .caret {
+ margin-left: 0;
+}
+.btn-lg .caret {
+ border-width: 5px 5px 0;
+ border-bottom-width: 0;
+}
+.dropup .btn-lg .caret {
+ border-width: 0 5px 5px;
+}
+.btn-group-vertical > .btn,
+.btn-group-vertical > .btn-group,
+.btn-group-vertical > .btn-group > .btn {
+ display: block;
+ float: none;
+ width: 100%;
+ max-width: 100%;
+}
+.btn-group-vertical > .btn-group > .btn {
+ float: none;
+}
+.btn-group-vertical > .btn + .btn,
+.btn-group-vertical > .btn + .btn-group,
+.btn-group-vertical > .btn-group + .btn,
+.btn-group-vertical > .btn-group + .btn-group {
+ margin-top: -1px;
+ margin-left: 0;
+}
+.btn-group-vertical > .btn:not(:first-child):not(:last-child) {
+ border-radius: 0;
+}
+.btn-group-vertical > .btn:first-child:not(:last-child) {
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+.btn-group-vertical > .btn:last-child:not(:first-child) {
+ border-bottom-left-radius: 4px;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+}
+.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {
+ border-radius: 0;
+}
+.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,
+.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+}
+.btn-group-justified {
+ display: table;
+ width: 100%;
+ table-layout: fixed;
+ border-collapse: separate;
+}
+.btn-group-justified > .btn,
+.btn-group-justified > .btn-group {
+ float: none;
+ display: table-cell;
+ width: 1%;
+}
+.btn-group-justified > .btn-group .btn {
+ width: 100%;
+}
+[data-toggle="buttons"] > .btn > input[type="radio"],
+[data-toggle="buttons"] > .btn > input[type="checkbox"] {
+ display: none;
+}
+.input-group {
+ position: relative;
+ display: table;
+ border-collapse: separate;
+}
+.input-group[class*="col-"] {
+ float: none;
+ padding-left: 0;
+ padding-right: 0;
+}
+.input-group .form-control {
+ float: left;
+ width: 100%;
+ margin-bottom: 0;
+}
+.input-group-lg > .form-control,
+.input-group-lg > .input-group-addon,
+.input-group-lg > .input-group-btn > .btn {
+ height: 45px;
+ padding: 10px 16px;
+ font-size: 18px;
+ line-height: 1.33;
+ border-radius: 6px;
+}
+select.input-group-lg > .form-control,
+select.input-group-lg > .input-group-addon,
+select.input-group-lg > .input-group-btn > .btn {
+ height: 45px;
+ line-height: 45px;
+}
+textarea.input-group-lg > .form-control,
+textarea.input-group-lg > .input-group-addon,
+textarea.input-group-lg > .input-group-btn > .btn,
+select[multiple].input-group-lg > .form-control,
+select[multiple].input-group-lg > .input-group-addon,
+select[multiple].input-group-lg > .input-group-btn > .btn {
+ height: auto;
+}
+.input-group-sm > .form-control,
+.input-group-sm > .input-group-addon,
+.input-group-sm > .input-group-btn > .btn {
+ height: 30px;
+ padding: 5px 10px;
+ font-size: 12px;
+ line-height: 1.5;
+ border-radius: 3px;
+}
+select.input-group-sm > .form-control,
+select.input-group-sm > .input-group-addon,
+select.input-group-sm > .input-group-btn > .btn {
+ height: 30px;
+ line-height: 30px;
+}
+textarea.input-group-sm > .form-control,
+textarea.input-group-sm > .input-group-addon,
+textarea.input-group-sm > .input-group-btn > .btn,
+select[multiple].input-group-sm > .form-control,
+select[multiple].input-group-sm > .input-group-addon,
+select[multiple].input-group-sm > .input-group-btn > .btn {
+ height: auto;
+}
+.input-group-addon,
+.input-group-btn,
+.input-group .form-control {
+ display: table-cell;
+}
+.input-group-addon:not(:first-child):not(:last-child),
+.input-group-btn:not(:first-child):not(:last-child),
+.input-group .form-control:not(:first-child):not(:last-child) {
+ border-radius: 0;
+}
+.input-group-addon,
+.input-group-btn {
+ width: 1%;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+.input-group-addon {
+ padding: 6px 12px;
+ font-size: 14px;
+ font-weight: normal;
+ line-height: 1;
+ color: #555555;
+ text-align: center;
+ background-color: #eeeeee;
+ border: 1px solid #cccccc;
+ border-radius: 4px;
+}
+.input-group-addon.input-sm {
+ padding: 5px 10px;
+ font-size: 12px;
+ border-radius: 3px;
+}
+.input-group-addon.input-lg {
+ padding: 10px 16px;
+ font-size: 18px;
+ border-radius: 6px;
+}
+.input-group-addon input[type="radio"],
+.input-group-addon input[type="checkbox"] {
+ margin-top: 0;
+}
+.input-group .form-control:first-child,
+.input-group-addon:first-child,
+.input-group-btn:first-child > .btn,
+.input-group-btn:first-child > .btn-group > .btn,
+.input-group-btn:first-child > .dropdown-toggle,
+.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),
+.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {
+ border-bottom-right-radius: 0;
+ border-top-right-radius: 0;
+}
+.input-group-addon:first-child {
+ border-right: 0;
+}
+.input-group .form-control:last-child,
+.input-group-addon:last-child,
+.input-group-btn:last-child > .btn,
+.input-group-btn:last-child > .btn-group > .btn,
+.input-group-btn:last-child > .dropdown-toggle,
+.input-group-btn:first-child > .btn:not(:first-child),
+.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+}
+.input-group-addon:last-child {
+ border-left: 0;
+}
+.input-group-btn {
+ position: relative;
+ font-size: 0;
+ white-space: nowrap;
+}
+.input-group-btn > .btn {
+ position: relative;
+}
+.input-group-btn > .btn + .btn {
+ margin-left: -1px;
+}
+.input-group-btn > .btn:hover,
+.input-group-btn > .btn:focus,
+.input-group-btn > .btn:active {
+ z-index: 2;
+}
+.input-group-btn:first-child > .btn,
+.input-group-btn:first-child > .btn-group {
+ margin-right: -1px;
+}
+.input-group-btn:last-child > .btn,
+.input-group-btn:last-child > .btn-group {
+ margin-left: -1px;
+}
+.nav {
+ margin-bottom: 0;
+ padding-left: 0;
+ list-style: none;
+}
+.nav > li {
+ position: relative;
+ display: block;
+}
+.nav > li > a {
+ position: relative;
+ display: block;
+ padding: 10px 15px;
+}
+.nav > li > a:hover,
+.nav > li > a:focus {
+ text-decoration: none;
+ background-color: #eeeeee;
+}
+.nav > li.disabled > a {
+ color: #999999;
+}
+.nav > li.disabled > a:hover,
+.nav > li.disabled > a:focus {
+ color: #999999;
+ text-decoration: none;
+ background-color: transparent;
+ cursor: not-allowed;
+}
+.nav .open > a,
+.nav .open > a:hover,
+.nav .open > a:focus {
+ background-color: #eeeeee;
+ border-color: #428bca;
+}
+.nav .nav-divider {
+ height: 1px;
+ margin: 9px 0;
+ overflow: hidden;
+ background-color: #e5e5e5;
+}
+.nav > li > a > img {
+ max-width: none;
+}
+.nav-tabs {
+ border-bottom: 1px solid #dddddd;
+}
+.nav-tabs > li {
+ float: left;
+ margin-bottom: -1px;
+}
+.nav-tabs > li > a {
+ margin-right: 2px;
+ line-height: 1.428571429;
+ border: 1px solid transparent;
+ border-radius: 4px 4px 0 0;
+}
+.nav-tabs > li > a:hover {
+ border-color: #eeeeee #eeeeee #dddddd;
+}
+.nav-tabs > li.active > a,
+.nav-tabs > li.active > a:hover,
+.nav-tabs > li.active > a:focus {
+ color: #555555;
+ background-color: #ffffff;
+ border: 1px solid #dddddd;
+ border-bottom-color: transparent;
+ cursor: default;
+}
+.nav-tabs.nav-justified {
+ width: 100%;
+ border-bottom: 0;
+}
+.nav-tabs.nav-justified > li {
+ float: none;
+}
+.nav-tabs.nav-justified > li > a {
+ text-align: center;
+ margin-bottom: 5px;
+}
+.nav-tabs.nav-justified > .dropdown .dropdown-menu {
+ top: auto;
+ left: auto;
+}
+@media (min-width: 768px) {
+ .nav-tabs.nav-justified > li {
+ display: table-cell;
+ width: 1%;
+ }
+ .nav-tabs.nav-justified > li > a {
+ margin-bottom: 0;
+ }
+}
+.nav-tabs.nav-justified > li > a {
+ margin-right: 0;
+ border-radius: 4px;
+}
+.nav-tabs.nav-justified > .active > a,
+.nav-tabs.nav-justified > .active > a:hover,
+.nav-tabs.nav-justified > .active > a:focus {
+ border: 1px solid #dddddd;
+}
+@media (min-width: 768px) {
+ .nav-tabs.nav-justified > li > a {
+ border-bottom: 1px solid #dddddd;
+ border-radius: 4px 4px 0 0;
+ }
+ .nav-tabs.nav-justified > .active > a,
+ .nav-tabs.nav-justified > .active > a:hover,
+ .nav-tabs.nav-justified > .active > a:focus {
+ border-bottom-color: #ffffff;
+ }
+}
+.nav-pills > li {
+ float: left;
+}
+.nav-pills > li > a {
+ border-radius: 4px;
+}
+.nav-pills > li + li {
+ margin-left: 2px;
+}
+.nav-pills > li.active > a,
+.nav-pills > li.active > a:hover,
+.nav-pills > li.active > a:focus {
+ color: #ffffff;
+ background-color: #428bca;
+}
+.nav-stacked > li {
+ float: none;
+}
+.nav-stacked > li + li {
+ margin-top: 2px;
+ margin-left: 0;
+}
+.nav-justified {
+ width: 100%;
+}
+.nav-justified > li {
+ float: none;
+}
+.nav-justified > li > a {
+ text-align: center;
+ margin-bottom: 5px;
+}
+.nav-justified > .dropdown .dropdown-menu {
+ top: auto;
+ left: auto;
+}
+@media (min-width: 768px) {
+ .nav-justified > li {
+ display: table-cell;
+ width: 1%;
+ }
+ .nav-justified > li > a {
+ margin-bottom: 0;
+ }
+}
+.nav-tabs-justified {
+ border-bottom: 0;
+}
+.nav-tabs-justified > li > a {
+ margin-right: 0;
+ border-radius: 4px;
+}
+.nav-tabs-justified > .active > a,
+.nav-tabs-justified > .active > a:hover,
+.nav-tabs-justified > .active > a:focus {
+ border: 1px solid #dddddd;
+}
+@media (min-width: 768px) {
+ .nav-tabs-justified > li > a {
+ border-bottom: 1px solid #dddddd;
+ border-radius: 4px 4px 0 0;
+ }
+ .nav-tabs-justified > .active > a,
+ .nav-tabs-justified > .active > a:hover,
+ .nav-tabs-justified > .active > a:focus {
+ border-bottom-color: #ffffff;
+ }
+}
+.tab-content > .tab-pane {
+ display: none;
+}
+.tab-content > .active {
+ display: block;
+}
+.nav-tabs .dropdown-menu {
+ margin-top: -1px;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+}
+.navbar {
+ position: relative;
+ min-height: 50px;
+ /* margin-bottom: 20px; */
+ border: 1px solid transparent;
+}
+@media (min-width: 768px) {
+ .navbar {
+ border-radius: 4px;
+ }
+}
+@media (min-width: 768px) {
+ .navbar-header {
+ float: left;
+ }
+}
+.navbar-collapse {
+ max-height: 340px;
+ overflow-x: visible;
+ padding-right: 15px;
+ padding-left: 15px;
+ border-top: 1px solid transparent;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
+ -webkit-overflow-scrolling: touch;
+}
+.navbar-collapse.in {
+ overflow-y: auto;
+}
+@media (min-width: 768px) {
+ .navbar-collapse {
+ width: auto;
+ border-top: 0;
+ box-shadow: none;
+ }
+ .navbar-collapse.collapse {
+ display: block !important;
+ height: auto !important;
+ padding-bottom: 0;
+ overflow: visible !important;
+ }
+ .navbar-collapse.in {
+ overflow-y: visible;
+ }
+ .navbar-fixed-top .navbar-collapse,
+ .navbar-static-top .navbar-collapse,
+ .navbar-fixed-bottom .navbar-collapse {
+ padding-left: 0;
+ padding-right: 0;
+ }
+}
+.container > .navbar-header,
+.container-fluid > .navbar-header,
+.container > .navbar-collapse,
+.container-fluid > .navbar-collapse {
+ margin-right: -15px;
+ margin-left: -15px;
+}
+@media (min-width: 768px) {
+ .container > .navbar-header,
+ .container-fluid > .navbar-header,
+ .container > .navbar-collapse,
+ .container-fluid > .navbar-collapse {
+ margin-right: 0;
+ margin-left: 0;
+ }
+}
+.navbar-static-top {
+ z-index: 1000;
+ border-width: 0 0 1px;
+}
+@media (min-width: 768px) {
+ .navbar-static-top {
+ border-radius: 0;
+ }
+}
+.navbar-fixed-top,
+.navbar-fixed-bottom {
+ position: fixed;
+ right: 0;
+ left: 0;
+ z-index: 1030;
+}
+@media (min-width: 768px) {
+ .navbar-fixed-top,
+ .navbar-fixed-bottom {
+ border-radius: 0;
+ }
+}
+.navbar-fixed-top {
+ top: 0;
+ border-width: 0 0 1px;
+}
+.navbar-fixed-bottom {
+ bottom: 0;
+ margin-bottom: 0;
+ border-width: 1px 0 0;
+}
+.navbar-brand {
+ float: left;
+ padding: 15px 15px;
+ font-size: 18px;
+ line-height: 20px;
+ height: 50px;
+}
+.navbar-brand:hover,
+.navbar-brand:focus {
+ text-decoration: none;
+}
+@media (min-width: 768px) {
+ .navbar > .container .navbar-brand,
+ .navbar > .container-fluid .navbar-brand {
+ margin-left: -15px;
+ }
+}
+.navbar-toggle {
+ position: relative;
+ float: right;
+ margin-right: 15px;
+ padding: 9px 10px;
+ margin-top: 8px;
+ margin-bottom: 8px;
+ background-color: transparent;
+ background-image: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+.navbar-toggle:focus {
+ outline: none;
+}
+.navbar-toggle .icon-bar {
+ display: block;
+ width: 22px;
+ height: 2px;
+ border-radius: 1px;
+}
+.navbar-toggle .icon-bar + .icon-bar {
+ margin-top: 4px;
+}
+@media (min-width: 768px) {
+ .navbar-toggle {
+ display: none;
+ }
+}
+.navbar-nav {
+ margin: 7.5px -15px;
+}
+.navbar-nav > li > a {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ line-height: 20px;
+}
+@media (max-width: 767px) {
+ .navbar-nav .open .dropdown-menu {
+ position: static;
+ float: none;
+ width: auto;
+ margin-top: 0;
+ background-color: transparent;
+ border: 0;
+ box-shadow: none;
+ }
+ .navbar-nav .open .dropdown-menu > li > a,
+ .navbar-nav .open .dropdown-menu .dropdown-header {
+ padding: 5px 15px 5px 25px;
+ }
+ .navbar-nav .open .dropdown-menu > li > a {
+ line-height: 20px;
+ }
+ .navbar-nav .open .dropdown-menu > li > a:hover,
+ .navbar-nav .open .dropdown-menu > li > a:focus {
+ background-image: none;
+ }
+}
+@media (min-width: 768px) {
+ .navbar-nav {
+ float: left;
+ margin: 0;
+ }
+ .navbar-nav > li {
+ float: left;
+ }
+ .navbar-nav > li > a {
+ padding-top: 15px;
+ padding-bottom: 15px;
+ }
+ .navbar-nav.navbar-right:last-child {
+ margin-right: -15px;
+ }
+}
+@media (min-width: 768px) {
+ .navbar-left {
+ float: left !important;
+ }
+ .navbar-right {
+ float: right !important;
+ }
+}
+.navbar-form {
+ margin-left: -15px;
+ margin-right: -15px;
+ padding: 10px 15px;
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);
+ margin-top: 8px;
+ margin-bottom: 8px;
+}
+@media (min-width: 768px) {
+ .navbar-form .form-group {
+ display: inline-block;
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .navbar-form .form-control {
+ display: inline-block;
+ width: auto;
+ vertical-align: middle;
+ }
+ .navbar-form .input-group > .form-control {
+ width: 100%;
+ }
+ .navbar-form .control-label {
+ margin-bottom: 0;
+ vertical-align: middle;
+ }
+ .navbar-form .radio,
+ .navbar-form .checkbox {
+ display: inline-block;
+ margin-top: 0;
+ margin-bottom: 0;
+ padding-left: 0;
+ vertical-align: middle;
+ }
+ .navbar-form .radio input[type="radio"],
+ .navbar-form .checkbox input[type="checkbox"] {
+ float: none;
+ margin-left: 0;
+ }
+ .navbar-form .has-feedback .form-control-feedback {
+ top: 0;
+ }
+}
+@media (max-width: 767px) {
+ .navbar-form .form-group {
+ margin-bottom: 5px;
+ }
+}
+@media (min-width: 768px) {
+ .navbar-form {
+ width: auto;
+ border: 0;
+ margin-left: 0;
+ margin-right: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ }
+ .navbar-form.navbar-right:last-child {
+ margin-right: -15px;
+ }
+}
+.navbar-nav > li > .dropdown-menu {
+ margin-top: 0;
+ border-top-right-radius: 0;
+ border-top-left-radius: 0;
+}
+.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+.navbar-btn {
+ margin-top: 8px;
+ margin-bottom: 8px;
+}
+.navbar-btn.btn-sm {
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+.navbar-btn.btn-xs {
+ margin-top: 14px;
+ margin-bottom: 14px;
+}
+.navbar-text {
+ margin-top: 15px;
+ margin-bottom: 15px;
+}
+@media (min-width: 768px) {
+ .navbar-text {
+ float: left;
+ margin-left: 15px;
+ margin-right: 15px;
+ }
+ .navbar-text.navbar-right:last-child {
+ margin-right: 0;
+ }
+}
+.navbar-default {
+ background-color: #f8f8f8;
+ border-color: #e7e7e7;
+}
+.navbar-default .navbar-brand {
+ color: #777777;
+}
+.navbar-default .navbar-brand:hover,
+.navbar-default .navbar-brand:focus {
+ color: #5e5e5e;
+ background-color: transparent;
+}
+.navbar-default .navbar-text {
+ color: #777777;
+}
+.navbar-default .navbar-nav > li > a {
+ color: #777777;
+}
+.navbar-default .navbar-nav > li > a:hover,
+.navbar-default .navbar-nav > li > a:focus {
+ color: #333333;
+ background-color: transparent;
+}
+.navbar-default .navbar-nav > .active > a,
+.navbar-default .navbar-nav > .active > a:hover,
+.navbar-default .navbar-nav > .active > a:focus {
+ color: #555555;
+ background-color: #e7e7e7;
+}
+.navbar-default .navbar-nav > .disabled > a,
+.navbar-default .navbar-nav > .disabled > a:hover,
+.navbar-default .navbar-nav > .disabled > a:focus {
+ color: #cccccc;
+ background-color: transparent;
+}
+.navbar-default .navbar-toggle {
+ border-color: #dddddd;
+}
+.navbar-default .navbar-toggle:hover,
+.navbar-default .navbar-toggle:focus {
+ background-color: #dddddd;
+}
+.navbar-default .navbar-toggle .icon-bar {
+ background-color: #888888;
+}
+.navbar-default .navbar-collapse,
+.navbar-default .navbar-form {
+ border-color: #e7e7e7;
+}
+.navbar-default .navbar-nav > .open > a,
+.navbar-default .navbar-nav > .open > a:hover,
+.navbar-default .navbar-nav > .open > a:focus {
+ background-color: #e7e7e7;
+ color: #555555;
+}
+@media (max-width: 767px) {
+ .navbar-default .navbar-nav .open .dropdown-menu > li > a {
+ color: #777777;
+ }
+ .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,
+ .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {
+ color: #333333;
+ background-color: transparent;
+ }
+ .navbar-default .navbar-nav .open .dropdown-menu > .active > a,
+ .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,
+ .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {
+ color: #555555;
+ background-color: #e7e7e7;
+ }
+ .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,
+ .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,
+ .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {
+ color: #cccccc;
+ background-color: transparent;
+ }
+}
+.navbar-default .navbar-link {
+ color: #777777;
+}
+.navbar-default .navbar-link:hover {
+ color: #333333;
+}
+.navbar-inverse {
+ background-color: #222222;
+ border-color: #080808;
+}
+.navbar-inverse .navbar-brand {
+ color: #999999;
+}
+.navbar-inverse .navbar-brand:hover,
+.navbar-inverse .navbar-brand:focus {
+ color: #ffffff;
+ background-color: transparent;
+}
+.navbar-inverse .navbar-text {
+ color: #999999;
+}
+.navbar-inverse .navbar-nav > li > a {
+ color: #999999;
+}
+.navbar-inverse .navbar-nav > li > a:hover,
+.navbar-inverse .navbar-nav > li > a:focus {
+ color: #ffffff;
+ background-color: transparent;
+}
+.navbar-inverse .navbar-nav > .active > a,
+.navbar-inverse .navbar-nav > .active > a:hover,
+.navbar-inverse .navbar-nav > .active > a:focus {
+ color: #ffffff;
+ background-color: #080808;
+}
+.navbar-inverse .navbar-nav > .disabled > a,
+.navbar-inverse .navbar-nav > .disabled > a:hover,
+.navbar-inverse .navbar-nav > .disabled > a:focus {
+ color: #444444;
+ background-color: transparent;
+}
+.navbar-inverse .navbar-toggle {
+ border-color: #333333;
+}
+.navbar-inverse .navbar-toggle:hover,
+.navbar-inverse .navbar-toggle:focus {
+ background-color: #333333;
+}
+.navbar-inverse .navbar-toggle .icon-bar {
+ background-color: #ffffff;
+}
+.navbar-inverse .navbar-collapse,
+.navbar-inverse .navbar-form {
+ border-color: #101010;
+}
+.navbar-inverse .navbar-nav > .open > a,
+.navbar-inverse .navbar-nav > .open > a:hover,
+.navbar-inverse .navbar-nav > .open > a:focus {
+ background-color: #080808;
+ color: #ffffff;
+}
+@media (max-width: 767px) {
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {
+ border-color: #080808;
+ }
+ .navbar-inverse .navbar-nav .open .dropdown-menu .divider {
+ background-color: #080808;
+ }
+ .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {
+ color: #999999;
+ }
+ .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,
+ .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {
+ color: #ffffff;
+ background-color: transparent;
+ }
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {
+ color: #ffffff;
+ background-color: #080808;
+ }
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,
+ .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {
+ color: #444444;
+ background-color: transparent;
+ }
+}
+.navbar-inverse .navbar-link {
+ color: #999999;
+}
+.navbar-inverse .navbar-link:hover {
+ color: #ffffff;
+}
+.breadcrumb {
+ /*
+ padding: 8px 15px;
+ margin-bottom: 20px;
+ background-color: #f5f5f5;
+ */
+ list-style: none;
+ border-radius: 4px;
+}
+.breadcrumb > li {
+ display: inline-block;
+}
+.breadcrumb > li + li:before {
+ content: "\00a0";
+ padding: 0 5px;
+ color: #cccccc;
+}
+.breadcrumb > .active {
+ color: #999999;
+}
+.pagination {
+ display: inline-block;
+ padding-left: 0;
+ margin: 20px 0;
+ border-radius: 4px;
+}
+.pagination > li {
+ display: inline;
+}
+.pagination > li > a,
+.pagination > li > span {
+ position: relative;
+ float: left;
+ padding: 6px 12px;
+ line-height: 1.428571429;
+ text-decoration: none;
+ color: #428bca;
+ background-color: #ffffff;
+ border: 1px solid #dddddd;
+ margin-left: -1px;
+}
+.pagination > li:first-child > a,
+.pagination > li:first-child > span {
+ margin-left: 0;
+ border-bottom-left-radius: 4px;
+ border-top-left-radius: 4px;
+}
+.pagination > li:last-child > a,
+.pagination > li:last-child > span {
+ border-bottom-right-radius: 4px;
+ border-top-right-radius: 4px;
+}
+.pagination > li > a:hover,
+.pagination > li > span:hover,
+.pagination > li > a:focus,
+.pagination > li > span:focus {
+ color: #2a6496;
+ background-color: #eeeeee;
+ border-color: #dddddd;
+}
+.pagination > .active > a,
+.pagination > .active > span,
+.pagination > .active > a:hover,
+.pagination > .active > span:hover,
+.pagination > .active > a:focus,
+.pagination > .active > span:focus {
+ z-index: 2;
+ color: #ffffff;
+ background-color: #428bca;
+ border-color: #428bca;
+ cursor: default;
+}
+.pagination > .disabled > span,
+.pagination > .disabled > span:hover,
+.pagination > .disabled > span:focus,
+.pagination > .disabled > a,
+.pagination > .disabled > a:hover,
+.pagination > .disabled > a:focus {
+ color: #999999;
+ background-color: #ffffff;
+ border-color: #dddddd;
+ cursor: not-allowed;
+}
+.pagination-lg > li > a,
+.pagination-lg > li > span {
+ padding: 10px 16px;
+ font-size: 18px;
+}
+.pagination-lg > li:first-child > a,
+.pagination-lg > li:first-child > span {
+ border-bottom-left-radius: 6px;
+ border-top-left-radius: 6px;
+}
+.pagination-lg > li:last-child > a,
+.pagination-lg > li:last-child > span {
+ border-bottom-right-radius: 6px;
+ border-top-right-radius: 6px;
+}
+.pagination-sm > li > a,
+.pagination-sm > li > span {
+ padding: 5px 10px;
+ font-size: 12px;
+}
+.pagination-sm > li:first-child > a,
+.pagination-sm > li:first-child > span {
+ border-bottom-left-radius: 3px;
+ border-top-left-radius: 3px;
+}
+.pagination-sm > li:last-child > a,
+.pagination-sm > li:last-child > span {
+ border-bottom-right-radius: 3px;
+ border-top-right-radius: 3px;
+}
+.pager {
+ padding-left: 0;
+ margin: 20px 0;
+ list-style: none;
+ text-align: center;
+}
+.pager li {
+ display: inline;
+}
+.pager li > a,
+.pager li > span {
+ display: inline-block;
+ padding: 5px 14px;
+ background-color: #ffffff;
+ border: 1px solid #dddddd;
+ border-radius: 15px;
+}
+.pager li > a:hover,
+.pager li > a:focus {
+ text-decoration: none;
+ background-color: #eeeeee;
+}
+.pager .next > a,
+.pager .next > span {
+ float: right;
+}
+.pager .previous > a,
+.pager .previous > span {
+ float: left;
+}
+.pager .disabled > a,
+.pager .disabled > a:hover,
+.pager .disabled > a:focus,
+.pager .disabled > span {
+ color: #999999;
+ background-color: #ffffff;
+ cursor: not-allowed;
+}
+.label {
+ display: inline;
+ padding: .2em .6em .3em;
+ font-size: 75%;
+ font-weight: bold;
+ line-height: 1;
+ color: #ffffff;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: .25em;
+}
+.label[href]:hover,
+.label[href]:focus {
+ color: #ffffff;
+ text-decoration: none;
+ cursor: pointer;
+}
+.label:empty {
+ display: none;
+}
+.btn .label {
+ position: relative;
+ top: -1px;
+}
+.label-default {
+ background-color: #999999;
+}
+.label-default[href]:hover,
+.label-default[href]:focus {
+ background-color: #808080;
+}
+.label-primary {
+ background-color: #428bca;
+}
+.label-primary[href]:hover,
+.label-primary[href]:focus {
+ background-color: #3071a9;
+}
+.label-success {
+ background-color: #5cb85c;
+}
+.label-success[href]:hover,
+.label-success[href]:focus {
+ background-color: #449d44;
+}
+.label-info {
+ background-color: #5bc0de;
+}
+.label-info[href]:hover,
+.label-info[href]:focus {
+ background-color: #31b0d5;
+}
+.label-warning {
+ background-color: #f0ad4e;
+}
+.label-warning[href]:hover,
+.label-warning[href]:focus {
+ background-color: #ec971f;
+}
+.label-danger {
+ background-color: #d9534f;
+}
+.label-danger[href]:hover,
+.label-danger[href]:focus {
+ background-color: #c9302c;
+}
+.badge {
+ display: inline-block;
+ min-width: 10px;
+ padding: 3px 7px;
+ font-size: 12px;
+ font-weight: bold;
+ color: : #fff;
+ line-height: 1;
+ vertical-align: baseline;
+ white-space: nowrap;
+ text-align: center;
+ background-color: #999999;
+ border-radius: 10px;
+}
+.badge:empty {
+ display: none;
+}
+.btn .badge {
+ position: relative;
+ top: -1px;
+}
+.btn-xs .badge {
+ top: 0;
+ padding: 1px 5px;
+}
+a.badge:hover,
+a.badge:focus {
+ color: #ffffff;
+ text-decoration: none;
+ cursor: pointer;
+}
+a.list-group-item.active > .badge,
+.nav-pills > .active > a > .badge {
+ color: #428bca;
+ background-color: #ffffff;
+}
+.nav-pills > li > a > .badge {
+ margin-left: 3px;
+}
+.jumbotron {
+ padding: 30px;
+ margin-bottom: 30px;
+ color: inherit;
+ background-color: #eeeeee;
+}
+.jumbotron h1,
+.jumbotron .h1 {
+ color: inherit;
+}
+.jumbotron p {
+ margin-bottom: 15px;
+ font-size: 21px;
+ font-weight: 200;
+}
+.container .jumbotron {
+ border-radius: 6px;
+}
+.jumbotron .container {
+ max-width: 100%;
+}
+@media screen and (min-width: 768px) {
+ .jumbotron {
+ padding-top: 48px;
+ padding-bottom: 48px;
+ }
+ .container .jumbotron {
+ padding-left: 60px;
+ padding-right: 60px;
+ }
+ .jumbotron h1,
+ .jumbotron .h1 {
+ font-size: 63px;
+ }
+}
+.thumbnail {
+ display: block;
+ padding: 4px;
+ margin-bottom: 20px;
+ line-height: 1.428571429;
+ background-color: #ffffff;
+ border: 1px solid #dddddd;
+ border-radius: 4px;
+ -webkit-transition: all 0.2s ease-in-out;
+ transition: all 0.2s ease-in-out;
+}
+.thumbnail > img,
+.thumbnail a > img {
+ margin-left: auto;
+ margin-right: auto;
+}
+a.thumbnail:hover,
+a.thumbnail:focus,
+a.thumbnail.active {
+ border-color: #428bca;
+}
+.thumbnail .caption {
+ padding: 9px;
+ color: #333333;
+}
+.alert {
+ padding: 15px;
+ margin-bottom: 20px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+.alert h4 {
+ margin-top: 0;
+ color: inherit;
+}
+.alert .alert-link {
+ font-weight: bold;
+}
+.alert > p,
+.alert > ul {
+ margin-bottom: 0;
+}
+.alert > p + p {
+ margin-top: 5px;
+}
+.alert-dismissable {
+ padding-right: 35px;
+}
+.alert-dismissable .close {
+ position: relative;
+ top: -2px;
+ right: -21px;
+ color: inherit;
+}
+.alert-success {
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+ color: #468847;
+}
+.alert-success hr {
+ border-top-color: #c9e2b3;
+}
+.alert-success .alert-link {
+ color: #356635;
+}
+.alert-info {
+ background-color: #d9edf7;
+ border-color: #bce8f1;
+ color: #3a87ad;
+}
+.alert-info hr {
+ border-top-color: #a6e1ec;
+}
+.alert-info .alert-link {
+ color: #2d6987;
+}
+.alert-warning {
+ background-color: #fcf8e3;
+ border-color: #fbeed5;
+ color: #c09853;
+}
+.alert-warning hr {
+ border-top-color: #f8e5be;
+}
+.alert-warning .alert-link {
+ color: #a47e3c;
+}
+.alert-danger {
+ background-color: #f2dede;
+ border-color: #eed3d7;
+ color: #b94a48;
+}
+.alert-danger hr {
+ border-top-color: #e6c1c7;
+}
+.alert-danger .alert-link {
+ color: #953b39;
+}
+@-webkit-keyframes progress-bar-stripes {
+ from {
+ background-position: 40px 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+@keyframes progress-bar-stripes {
+ from {
+ background-position: 40px 0;
+ }
+ to {
+ background-position: 0 0;
+ }
+}
+.progress {
+ overflow: hidden;
+ height: 20px;
+ margin-bottom: 20px;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
+}
+.progress-bar {
+ float: left;
+ width: 0%;
+ height: 100%;
+ font-size: 12px;
+ line-height: 20px;
+ color: #ffffff;
+ text-align: center;
+ background-color: #428bca;
+ -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+ -webkit-transition: width 0.6s ease;
+ transition: width 0.6s ease;
+}
+.progress-striped .progress-bar {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-size: 40px 40px;
+}
+.progress.active .progress-bar {
+ -webkit-animation: progress-bar-stripes 2s linear infinite;
+ animation: progress-bar-stripes 2s linear infinite;
+}
+.progress-bar-success {
+ background-color: #5cb85c;
+}
+.progress-striped .progress-bar-success {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+}
+.progress-bar-info {
+ background-color: #5bc0de;
+}
+.progress-striped .progress-bar-info {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+}
+.progress-bar-warning {
+ background-color: #f0ad4e;
+}
+.progress-striped .progress-bar-warning {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+}
+.progress-bar-danger {
+ background-color: #d9534f;
+}
+.progress-striped .progress-bar-danger {
+ background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+ background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);
+}
+.media,
+.media-body {
+ overflow: hidden;
+ zoom: 1;
+}
+.media,
+.media .media {
+ margin-top: 15px;
+}
+.media:first-child {
+ margin-top: 0;
+}
+.media-object {
+ display: block;
+}
+.media-heading {
+ margin: 0 0 5px;
+}
+.media > .pull-left {
+ margin-right: 10px;
+}
+.media > .pull-right {
+ margin-left: 10px;
+}
+.media-list {
+ padding-left: 0;
+ list-style: none;
+}
+.list-group {
+ margin-bottom: 20px;
+ padding-left: 0;
+}
+.list-group-item {
+ position: relative;
+ display: block;
+ padding: 10px 15px;
+ margin-bottom: -1px;
+ background-color: #ffffff;
+ border: 1px solid #dddddd;
+}
+.list-group-item:first-child {
+ border-top-right-radius: 4px;
+ border-top-left-radius: 4px;
+}
+.list-group-item:last-child {
+ margin-bottom: 0;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+}
+.list-group-item > .badge {
+ float: right;
+}
+.list-group-item > .badge + .badge {
+ margin-right: 5px;
+}
+a.list-group-item {
+ color: #555555;
+}
+a.list-group-item .list-group-item-heading {
+ color: #333333;
+}
+a.list-group-item:hover,
+a.list-group-item:focus {
+ text-decoration: none;
+ background-color: #f5f5f5;
+}
+a.list-group-item.active,
+a.list-group-item.active:hover,
+a.list-group-item.active:focus {
+ z-index: 2;
+ color: #ffffff;
+ background-color: #428bca;
+ border-color: #428bca;
+}
+a.list-group-item.active .list-group-item-heading,
+a.list-group-item.active:hover .list-group-item-heading,
+a.list-group-item.active:focus .list-group-item-heading {
+ color: inherit;
+}
+a.list-group-item.active .list-group-item-text,
+a.list-group-item.active:hover .list-group-item-text,
+a.list-group-item.active:focus .list-group-item-text {
+ color: #e1edf7;
+}
+.list-group-item-success {
+ color: #468847;
+ background-color: #dff0d8;
+}
+a.list-group-item-success {
+ color: #468847;
+}
+a.list-group-item-success .list-group-item-heading {
+ color: inherit;
+}
+a.list-group-item-success:hover,
+a.list-group-item-success:focus {
+ color: #468847;
+ background-color: #d0e9c6;
+}
+a.list-group-item-success.active,
+a.list-group-item-success.active:hover,
+a.list-group-item-success.active:focus {
+ color: #fff;
+ background-color: #468847;
+ border-color: #468847;
+}
+.list-group-item-info {
+ color: #3a87ad;
+ background-color: #d9edf7;
+}
+a.list-group-item-info {
+ color: #3a87ad;
+}
+a.list-group-item-info .list-group-item-heading {
+ color: inherit;
+}
+a.list-group-item-info:hover,
+a.list-group-item-info:focus {
+ color: #3a87ad;
+ background-color: #c4e3f3;
+}
+a.list-group-item-info.active,
+a.list-group-item-info.active:hover,
+a.list-group-item-info.active:focus {
+ color: #fff;
+ background-color: #3a87ad;
+ border-color: #3a87ad;
+}
+.list-group-item-warning {
+ color: #c09853;
+ background-color: #fcf8e3;
+}
+a.list-group-item-warning {
+ color: #c09853;
+}
+a.list-group-item-warning .list-group-item-heading {
+ color: inherit;
+}
+a.list-group-item-warning:hover,
+a.list-group-item-warning:focus {
+ color: #c09853;
+ background-color: #faf2cc;
+}
+a.list-group-item-warning.active,
+a.list-group-item-warning.active:hover,
+a.list-group-item-warning.active:focus {
+ color: #fff;
+ background-color: #c09853;
+ border-color: #c09853;
+}
+.list-group-item-danger {
+ color: #b94a48;
+ background-color: #f2dede;
+}
+a.list-group-item-danger {
+ color: #b94a48;
+}
+a.list-group-item-danger .list-group-item-heading {
+ color: inherit;
+}
+a.list-group-item-danger:hover,
+a.list-group-item-danger:focus {
+ color: #b94a48;
+ background-color: #ebcccc;
+}
+a.list-group-item-danger.active,
+a.list-group-item-danger.active:hover,
+a.list-group-item-danger.active:focus {
+ color: #fff;
+ background-color: #b94a48;
+ border-color: #b94a48;
+}
+.list-group-item-heading {
+ margin-top: 0;
+ margin-bottom: 5px;
+}
+.list-group-item-text {
+ margin-bottom: 0;
+ line-height: 1.3;
+}
+.panel {
+ margin-bottom: 20px;
+ background-color: #ffffff;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
+}
+.panel-body {
+ padding: 15px;
+}
+.panel-heading {
+ padding: 10px 15px;
+ border-bottom: 1px solid transparent;
+ border-top-right-radius: 3px;
+ border-top-left-radius: 3px;
+}
+.panel-heading > .dropdown .dropdown-toggle {
+ color: inherit;
+}
+.panel-title {
+ margin-top: 0;
+ margin-bottom: 0;
+ font-size: 16px;
+ color: inherit;
+}
+.panel-title > a {
+ color: inherit;
+}
+.panel-footer {
+ padding: 10px 15px;
+ background-color: #f5f5f5;
+ border-top: 1px solid #dddddd;
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+.panel > .list-group {
+ margin-bottom: 0;
+}
+.panel > .list-group .list-group-item {
+ border-width: 1px 0;
+ border-radius: 0;
+}
+.panel > .list-group .list-group-item:first-child {
+ border-top: 0;
+}
+.panel > .list-group .list-group-item:last-child {
+ border-bottom: 0;
+}
+.panel > .list-group:first-child .list-group-item:first-child {
+ border-top-right-radius: 3px;
+ border-top-left-radius: 3px;
+}
+.panel > .list-group:last-child .list-group-item:last-child {
+ border-bottom-right-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+.panel-heading + .list-group .list-group-item:first-child {
+ border-top-width: 0;
+}
+.panel > .table,
+.panel > .table-responsive > .table {
+ margin-bottom: 0;
+}
+.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,
+.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,
+.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,
+.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,
+.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,
+.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,
+.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,
+.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {
+ border-top-left-radius: 3px;
+}
+.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,
+.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,
+.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,
+.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,
+.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,
+.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,
+.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,
+.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {
+ border-top-right-radius: 3px;
+}
+.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,
+.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,
+.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,
+.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,
+.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,
+.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,
+.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,
+.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {
+ border-bottom-left-radius: 3px;
+}
+.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,
+.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,
+.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,
+.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,
+.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,
+.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,
+.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,
+.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {
+ border-bottom-right-radius: 3px;
+}
+.panel > .panel-body + .table,
+.panel > .panel-body + .table-responsive {
+ border-top: 1px solid #dddddd;
+}
+.panel > .table > tbody:first-child > tr:first-child th,
+.panel > .table > tbody:first-child > tr:first-child td {
+ border-top: 0;
+}
+.panel > .table-bordered,
+.panel > .table-responsive > .table-bordered {
+ border: 0;
+}
+.panel > .table-bordered > thead > tr > th:first-child,
+.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,
+.panel > .table-bordered > tbody > tr > th:first-child,
+.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,
+.panel > .table-bordered > tfoot > tr > th:first-child,
+.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,
+.panel > .table-bordered > thead > tr > td:first-child,
+.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,
+.panel > .table-bordered > tbody > tr > td:first-child,
+.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,
+.panel > .table-bordered > tfoot > tr > td:first-child,
+.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {
+ border-left: 0;
+}
+.panel > .table-bordered > thead > tr > th:last-child,
+.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,
+.panel > .table-bordered > tbody > tr > th:last-child,
+.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,
+.panel > .table-bordered > tfoot > tr > th:last-child,
+.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,
+.panel > .table-bordered > thead > tr > td:last-child,
+.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,
+.panel > .table-bordered > tbody > tr > td:last-child,
+.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,
+.panel > .table-bordered > tfoot > tr > td:last-child,
+.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {
+ border-right: 0;
+}
+.panel > .table-bordered > thead > tr:first-child > td,
+.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,
+.panel > .table-bordered > tbody > tr:first-child > td,
+.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,
+.panel > .table-bordered > thead > tr:first-child > th,
+.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,
+.panel > .table-bordered > tbody > tr:first-child > th,
+.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {
+ border-bottom: 0;
+}
+.panel > .table-bordered > tbody > tr:last-child > td,
+.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,
+.panel > .table-bordered > tfoot > tr:last-child > td,
+.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,
+.panel > .table-bordered > tbody > tr:last-child > th,
+.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,
+.panel > .table-bordered > tfoot > tr:last-child > th,
+.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {
+ border-bottom: 0;
+}
+.panel > .table-responsive {
+ border: 0;
+ margin-bottom: 0;
+}
+.panel-group {
+ margin-bottom: 20px;
+}
+.panel-group .panel {
+ margin-bottom: 0;
+ border-radius: 4px;
+ overflow: hidden;
+}
+.panel-group .panel + .panel {
+ margin-top: 5px;
+}
+.panel-group .panel-heading {
+ border-bottom: 0;
+}
+.panel-group .panel-heading + .panel-collapse .panel-body {
+ border-top: 1px solid #dddddd;
+}
+.panel-group .panel-footer {
+ border-top: 0;
+}
+.panel-group .panel-footer + .panel-collapse .panel-body {
+ border-bottom: 1px solid #dddddd;
+}
+.panel-default {
+ border-color: #dddddd;
+}
+.panel-default > .panel-heading {
+ color: #333333;
+ background-color: #f5f5f5;
+ border-color: #dddddd;
+}
+.panel-default > .panel-heading + .panel-collapse .panel-body {
+ border-top-color: #dddddd;
+}
+.panel-default > .panel-footer + .panel-collapse .panel-body {
+ border-bottom-color: #dddddd;
+}
+.panel-primary {
+ border-color: #428bca;
+}
+.panel-primary > .panel-heading {
+ color: #ffffff;
+ background-color: #428bca;
+ border-color: #428bca;
+}
+.panel-primary > .panel-heading + .panel-collapse .panel-body {
+ border-top-color: #428bca;
+}
+.panel-primary > .panel-footer + .panel-collapse .panel-body {
+ border-bottom-color: #428bca;
+}
+.panel-success {
+ border-color: #d6e9c6;
+}
+.panel-success > .panel-heading {
+ color: #468847;
+ background-color: #dff0d8;
+ border-color: #d6e9c6;
+}
+.panel-success > .panel-heading + .panel-collapse .panel-body {
+ border-top-color: #d6e9c6;
+}
+.panel-success > .panel-footer + .panel-collapse .panel-body {
+ border-bottom-color: #d6e9c6;
+}
+.panel-info {
+ border-color: #bce8f1;
+}
+.panel-info > .panel-heading {
+ color: #3a87ad;
+ background-color: #d9edf7;
+ border-color: #bce8f1;
+}
+.panel-info > .panel-heading + .panel-collapse .panel-body {
+ border-top-color: #bce8f1;
+}
+.panel-info > .panel-footer + .panel-collapse .panel-body {
+ border-bottom-color: #bce8f1;
+}
+.panel-warning {
+ border-color: #fbeed5;
+}
+.panel-warning > .panel-heading {
+ color: #c09853;
+ background-color: #fcf8e3;
+ border-color: #fbeed5;
+}
+.panel-warning > .panel-heading + .panel-collapse .panel-body {
+ border-top-color: #fbeed5;
+}
+.panel-warning > .panel-footer + .panel-collapse .panel-body {
+ border-bottom-color: #fbeed5;
+}
+.panel-danger {
+ border-color: #eed3d7;
+}
+.panel-danger > .panel-heading {
+ color: #b94a48;
+ background-color: #f2dede;
+ border-color: #eed3d7;
+}
+.panel-danger > .panel-heading + .panel-collapse .panel-body {
+ border-top-color: #eed3d7;
+}
+.panel-danger > .panel-footer + .panel-collapse .panel-body {
+ border-bottom-color: #eed3d7;
+}
+.well {
+ min-height: 20px;
+ padding: 19px;
+ margin-bottom: 20px;
+ background-color: #f5f5f5;
+ border: 1px solid #e3e3e3;
+ border-radius: 4px;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
+}
+.well blockquote {
+ border-color: #ddd;
+ border-color: rgba(0, 0, 0, 0.15);
+}
+.well-lg {
+ padding: 24px;
+ border-radius: 6px;
+}
+.well-sm {
+ padding: 9px;
+ border-radius: 3px;
+}
+.close {
+ float: right;
+ font-size: 21px;
+ font-weight: bold;
+ line-height: 1;
+ color: #000000;
+ text-shadow: 0 1px 0 #ffffff;
+ opacity: 0.2;
+ filter: alpha(opacity=20);
+}
+.close:hover,
+.close:focus {
+ color: #000000;
+ text-decoration: none;
+ cursor: pointer;
+ opacity: 0.5;
+ filter: alpha(opacity=50);
+}
+button.close {
+ padding: 0;
+ cursor: pointer;
+ background: transparent;
+ border: 0;
+ -webkit-appearance: none;
+}
+.modal-open {
+ overflow: hidden;
+}
+.modal {
+ display: none;
+ overflow: auto;
+ overflow-y: scroll;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1050;
+ -webkit-overflow-scrolling: touch;
+ outline: 0;
+}
+.modal.fade .modal-dialog {
+ -webkit-transform: translate(0, -25%);
+ -ms-transform: translate(0, -25%);
+ transform: translate(0, -25%);
+ -webkit-transition: -webkit-transform 0.3s ease-out;
+ -moz-transition: -moz-transform 0.3s ease-out;
+ -o-transition: -o-transform 0.3s ease-out;
+ transition: transform 0.3s ease-out;
+}
+.modal.in .modal-dialog {
+ -webkit-transform: translate(0, 0);
+ -ms-transform: translate(0, 0);
+ transform: translate(0, 0);
+}
+.modal-dialog {
+ position: relative;
+ width: auto;
+ margin: 10px;
+}
+.modal-content {
+ position: relative;
+ background-color: #ffffff;
+ border: 1px solid #999999;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+ -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);
+ box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);
+ background-clip: padding-box;
+ outline: none;
+}
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1040;
+ background-color: #000000;
+}
+.modal-backdrop.fade {
+ opacity: 0;
+ filter: alpha(opacity=0);
+}
+.modal-backdrop.in {
+ opacity: 0.5;
+ filter: alpha(opacity=50);
+}
+.modal-header {
+ padding: 15px;
+ border-bottom: 1px solid #e5e5e5;
+ min-height: 16.428571429px;
+}
+.modal-header .close {
+ margin-top: -2px;
+}
+.modal-title {
+ margin: 0;
+ line-height: 1.428571429;
+}
+.modal-body {
+ position: relative;
+ padding: 20px;
+}
+.modal-footer {
+ margin-top: 15px;
+ padding: 19px 20px 20px;
+ text-align: right;
+ border-top: 1px solid #e5e5e5;
+}
+.modal-footer .btn + .btn {
+ margin-left: 5px;
+ margin-bottom: 0;
+}
+.modal-footer .btn-group .btn + .btn {
+ margin-left: -1px;
+}
+.modal-footer .btn-block + .btn-block {
+ margin-left: 0;
+}
+@media (min-width: 768px) {
+ .modal-dialog {
+ width: 600px;
+ margin: 30px auto;
+ }
+ .modal-content {
+ -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
+ }
+ .modal-sm {
+ width: 300px;
+ }
+}
+@media (min-width: 992px) {
+ .modal-lg {
+ width: 900px;
+ }
+}
+.tooltip {
+ position: absolute;
+ z-index: 1030;
+ display: block;
+ visibility: visible;
+ font-size: 12px;
+ line-height: 1.4;
+ opacity: 0;
+ filter: alpha(opacity=0);
+}
+.tooltip.in {
+ opacity: 0.9;
+ filter: alpha(opacity=90);
+}
+.tooltip.top {
+ margin-top: -3px;
+ padding: 5px 0;
+}
+.tooltip.right {
+ margin-left: 3px;
+ padding: 0 5px;
+}
+.tooltip.bottom {
+ margin-top: 3px;
+ padding: 5px 0;
+}
+.tooltip.left {
+ margin-left: -3px;
+ padding: 0 5px;
+}
+.tooltip-inner {
+ max-width: 200px;
+ padding: 3px 8px;
+ color: #ffffff;
+ text-align: center;
+ text-decoration: none;
+ background-color: #000000;
+ border-radius: 4px;
+}
+.tooltip-arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+}
+.tooltip.top .tooltip-arrow {
+ bottom: 0;
+ left: 50%;
+ margin-left: -5px;
+ border-width: 5px 5px 0;
+ border-top-color: #000000;
+}
+.tooltip.top-left .tooltip-arrow {
+ bottom: 0;
+ left: 5px;
+ border-width: 5px 5px 0;
+ border-top-color: #000000;
+}
+.tooltip.top-right .tooltip-arrow {
+ bottom: 0;
+ right: 5px;
+ border-width: 5px 5px 0;
+ border-top-color: #000000;
+}
+.tooltip.right .tooltip-arrow {
+ top: 50%;
+ left: 0;
+ margin-top: -5px;
+ border-width: 5px 5px 5px 0;
+ border-right-color: #000000;
+}
+.tooltip.left .tooltip-arrow {
+ top: 50%;
+ right: 0;
+ margin-top: -5px;
+ border-width: 5px 0 5px 5px;
+ border-left-color: #000000;
+}
+.tooltip.bottom .tooltip-arrow {
+ top: 0;
+ left: 50%;
+ margin-left: -5px;
+ border-width: 0 5px 5px;
+ border-bottom-color: #000000;
+}
+.tooltip.bottom-left .tooltip-arrow {
+ top: 0;
+ left: 5px;
+ border-width: 0 5px 5px;
+ border-bottom-color: #000000;
+}
+.tooltip.bottom-right .tooltip-arrow {
+ top: 0;
+ right: 5px;
+ border-width: 0 5px 5px;
+ border-bottom-color: #000000;
+}
+.popover {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1010;
+ display: none;
+ max-width: 276px;
+ padding: 1px;
+ text-align: left;
+ background-color: #ffffff;
+ background-clip: padding-box;
+ border: 1px solid #cccccc;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 6px;
+ -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
+ box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
+ white-space: normal;
+}
+.popover.top {
+ margin-top: -10px;
+}
+.popover.right {
+ margin-left: 10px;
+}
+.popover.bottom {
+ margin-top: 10px;
+}
+.popover.left {
+ margin-left: -10px;
+}
+.popover-title {
+ margin: 0;
+ padding: 8px 14px;
+ font-size: 14px;
+ font-weight: normal;
+ line-height: 18px;
+ background-color: #f7f7f7;
+ border-bottom: 1px solid #ebebeb;
+ border-radius: 5px 5px 0 0;
+}
+.popover-content {
+ padding: 9px 14px;
+}
+.popover > .arrow,
+.popover > .arrow:after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-color: transparent;
+ border-style: solid;
+}
+.popover > .arrow {
+ border-width: 11px;
+}
+.popover > .arrow:after {
+ border-width: 10px;
+ content: "";
+}
+.popover.top > .arrow {
+ left: 50%;
+ margin-left: -11px;
+ border-bottom-width: 0;
+ border-top-color: #999999;
+ border-top-color: rgba(0, 0, 0, 0.25);
+ bottom: -11px;
+}
+.popover.top > .arrow:after {
+ content: " ";
+ bottom: 1px;
+ margin-left: -10px;
+ border-bottom-width: 0;
+ border-top-color: #ffffff;
+}
+.popover.right > .arrow {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-left-width: 0;
+ border-right-color: #999999;
+ border-right-color: rgba(0, 0, 0, 0.25);
+}
+.popover.right > .arrow:after {
+ content: " ";
+ left: 1px;
+ bottom: -10px;
+ border-left-width: 0;
+ border-right-color: #ffffff;
+}
+.popover.bottom > .arrow {
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+ border-bottom-color: #999999;
+ border-bottom-color: rgba(0, 0, 0, 0.25);
+ top: -11px;
+}
+.popover.bottom > .arrow:after {
+ content: " ";
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ border-bottom-color: #ffffff;
+}
+.popover.left > .arrow {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+ border-left-color: #999999;
+ border-left-color: rgba(0, 0, 0, 0.25);
+}
+.popover.left > .arrow:after {
+ content: " ";
+ right: 1px;
+ border-right-width: 0;
+ border-left-color: #ffffff;
+ bottom: -10px;
+}
+.carousel {
+ position: relative;
+}
+.carousel-inner {
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+}
+.carousel-inner > .item {
+ display: none;
+ position: relative;
+ -webkit-transition: 0.6s ease-in-out left;
+ transition: 0.6s ease-in-out left;
+}
+.carousel-inner > .item > img,
+.carousel-inner > .item > a > img {
+ line-height: 1;
+}
+.carousel-inner > .active,
+.carousel-inner > .next,
+.carousel-inner > .prev {
+ display: block;
+}
+.carousel-inner > .active {
+ left: 0;
+}
+.carousel-inner > .next,
+.carousel-inner > .prev {
+ position: absolute;
+ top: 0;
+ width: 100%;
+}
+.carousel-inner > .next {
+ left: 100%;
+}
+.carousel-inner > .prev {
+ left: -100%;
+}
+.carousel-inner > .next.left,
+.carousel-inner > .prev.right {
+ left: 0;
+}
+.carousel-inner > .active.left {
+ left: -100%;
+}
+.carousel-inner > .active.right {
+ left: 100%;
+}
+.carousel-control {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 15%;
+ opacity: 0.5;
+ filter: alpha(opacity=50);
+ font-size: 20px;
+ color: #ffffff;
+ text-align: center;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
+}
+.carousel-control.left {
+ background-image: -webkit-linear-gradient(left, color-stop(rgba(0, 0, 0, 0.5) 0%), color-stop(rgba(0, 0, 0, 0.0001) 100%));
+ background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);
+ background-repeat: repeat-x;
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);
+}
+.carousel-control.right {
+ left: auto;
+ right: 0;
+ background-image: -webkit-linear-gradient(left, color-stop(rgba(0, 0, 0, 0.0001) 0%), color-stop(rgba(0, 0, 0, 0.5) 100%));
+ background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);
+ background-repeat: repeat-x;
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);
+}
+.carousel-control:hover,
+.carousel-control:focus {
+ outline: none;
+ color: #ffffff;
+ text-decoration: none;
+ opacity: 0.9;
+ filter: alpha(opacity=90);
+}
+.carousel-control .icon-prev,
+.carousel-control .icon-next,
+.carousel-control .glyphicon-chevron-left,
+.carousel-control .glyphicon-chevron-right {
+ position: absolute;
+ top: 50%;
+ z-index: 5;
+ display: inline-block;
+}
+.carousel-control .icon-prev,
+.carousel-control .glyphicon-chevron-left {
+ left: 50%;
+}
+.carousel-control .icon-next,
+.carousel-control .glyphicon-chevron-right {
+ right: 50%;
+}
+.carousel-control .icon-prev,
+.carousel-control .icon-next {
+ width: 20px;
+ height: 20px;
+ margin-top: -10px;
+ margin-left: -10px;
+ font-family: serif;
+}
+.carousel-control .icon-prev:before {
+ content: '\2039';
+}
+.carousel-control .icon-next:before {
+ content: '\203a';
+}
+.carousel-indicators {
+ position: absolute;
+ bottom: 10px;
+ left: 50%;
+ z-index: 15;
+ width: 60%;
+ margin-left: -30%;
+ padding-left: 0;
+ list-style: none;
+ text-align: center;
+}
+.carousel-indicators li {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ margin: 1px;
+ text-indent: -999px;
+ border: 1px solid #ffffff;
+ border-radius: 10px;
+ cursor: pointer;
+ background-color: #000 \9;
+ background-color: rgba(0, 0, 0, 0);
+}
+.carousel-indicators .active {
+ margin: 0;
+ width: 12px;
+ height: 12px;
+ background-color: #ffffff;
+}
+.carousel-caption {
+ position: absolute;
+ left: 15%;
+ right: 15%;
+ bottom: 20px;
+ z-index: 10;
+ padding-top: 20px;
+ padding-bottom: 20px;
+ color: #ffffff;
+ text-align: center;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
+}
+.carousel-caption .btn {
+ text-shadow: none;
+}
+@media screen and (min-width: 768px) {
+ .carousel-control .glyphicons-chevron-left,
+ .carousel-control .glyphicons-chevron-right,
+ .carousel-control .icon-prev,
+ .carousel-control .icon-next {
+ width: 30px;
+ height: 30px;
+ margin-top: -15px;
+ margin-left: -15px;
+ font-size: 30px;
+ }
+ .carousel-caption {
+ left: 20%;
+ right: 20%;
+ padding-bottom: 30px;
+ }
+ .carousel-indicators {
+ bottom: 20px;
+ }
+}
+.clearfix:before,
+.clearfix:after,
+.container:before,
+.container:after,
+.container-fluid:before,
+.container-fluid:after,
+.row:before,
+.row:after,
+.form-horizontal .form-group:before,
+.form-horizontal .form-group:after,
+.btn-toolbar:before,
+.btn-toolbar:after,
+.btn-group-vertical > .btn-group:before,
+.btn-group-vertical > .btn-group:after,
+.nav:before,
+.nav:after,
+.navbar:before,
+.navbar:after,
+.navbar-header:before,
+.navbar-header:after,
+.navbar-collapse:before,
+.navbar-collapse:after,
+.pager:before,
+.pager:after,
+.panel-body:before,
+.panel-body:after,
+.modal-footer:before,
+.modal-footer:after {
+ content: " ";
+ display: table;
+}
+.clearfix:after,
+.container:after,
+.container-fluid:after,
+.row:after,
+.form-horizontal .form-group:after,
+.btn-toolbar:after,
+.btn-group-vertical > .btn-group:after,
+.nav:after,
+.navbar:after,
+.navbar-header:after,
+.navbar-collapse:after,
+.pager:after,
+.panel-body:after,
+.modal-footer:after {
+ clear: both;
+}
+.center-block {
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+}
+.pull-right {
+ float: right !important;
+}
+.pull-left {
+ float: left !important;
+}
+.hide {
+ display: none !important;
+}
+.show {
+ display: block !important;
+}
+.invisible {
+ visibility: hidden;
+}
+.text-hide {
+ font: 0/0 a;
+ color: transparent;
+ text-shadow: none;
+ background-color: transparent;
+ border: 0;
+}
+.hidden {
+ display: none !important;
+ visibility: hidden !important;
+}
+.affix {
+ position: fixed;
+}
+@-ms-viewport {
+ width: device-width;
+}
+.visible-xs,
+.visible-sm,
+.visible-md,
+.visible-lg {
+ display: none !important;
+}
+@media (max-width: 767px) {
+ .visible-xs {
+ display: block !important;
+ }
+ table.visible-xs {
+ display: table;
+ }
+ tr.visible-xs {
+ display: table-row !important;
+ }
+ th.visible-xs,
+ td.visible-xs {
+ display: table-cell !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .visible-sm {
+ display: block !important;
+ }
+ table.visible-sm {
+ display: table;
+ }
+ tr.visible-sm {
+ display: table-row !important;
+ }
+ th.visible-sm,
+ td.visible-sm {
+ display: table-cell !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .visible-md {
+ display: block !important;
+ }
+ table.visible-md {
+ display: table;
+ }
+ tr.visible-md {
+ display: table-row !important;
+ }
+ th.visible-md,
+ td.visible-md {
+ display: table-cell !important;
+ }
+}
+@media (min-width: 1200px) {
+ .visible-lg {
+ display: block !important;
+ }
+ table.visible-lg {
+ display: table;
+ }
+ tr.visible-lg {
+ display: table-row !important;
+ }
+ th.visible-lg,
+ td.visible-lg {
+ display: table-cell !important;
+ }
+}
+@media (max-width: 767px) {
+ .hidden-xs {
+ display: none !important;
+ }
+}
+@media (min-width: 768px) and (max-width: 991px) {
+ .hidden-sm {
+ display: none !important;
+ }
+}
+@media (min-width: 992px) and (max-width: 1199px) {
+ .hidden-md {
+ display: none !important;
+ }
+}
+@media (min-width: 1200px) {
+ .hidden-lg {
+ display: none !important;
+ }
+}
+.visible-print {
+ display: none !important;
+}
+@media print {
+ .visible-print {
+ display: block !important;
+ }
+ table.visible-print {
+ display: table;
+ }
+ tr.visible-print {
+ display: table-row !important;
+ }
+ th.visible-print,
+ td.visible-print {
+ display: table-cell !important;
+ }
+}
+@media print {
+ .hidden-print {
+ display: none !important;
+ }
+}
+
diff --git a/dynamic_rest/static/dynamic_rest/css/custom.css b/dynamic_rest/static/dynamic_rest/css/custom.css
new file mode 100644
index 00000000..e351f463
--- /dev/null
+++ b/dynamic_rest/static/dynamic_rest/css/custom.css
@@ -0,0 +1,180 @@
+.has-error .help-block {
+ color: #d9534f!important
+}
+.drest-content-container {
+ display: none;
+ position: fixed;
+ top: 50px;
+ bottom: 50px;
+ right: 0;
+ left: 0;
+ margin-left: auto;
+ margin-right: auto;
+}
+.drest-content {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+}
+.drest-content > .container {
+ padding: 0;
+}
+.drest-nav {
+ justify-content: space-between;
+ flex-direction: row;
+}
+.drest-nav-left, .drest-nav-right {
+ flex: 0 0 auto;
+ width: 100px;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+.drest-nav-right {
+ text-align: end;
+}
+
+.drest-nav-center {
+ flex: 1 1 auto;
+ text-align: center;
+ justify-content: center;
+ flex-direction: column;
+ display: flex;
+}
+
+.drest-logo {
+ width: 38px;
+}
+.drest-btn {
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+}
+.drest-btn-label {
+ display: inline-block;
+}
+@media screen and (max-width: 420px) {
+ .drest-nav .nav-link {
+ padding: 0;
+ }
+ .navbar-brand.drest-nav-left {
+ }
+ .drest-btn {
+ display: flex;
+ width: 38px;
+ height: 38px;
+ flex-direction: column;
+ }
+ .drest-btn-label {
+ font-size: 9px;
+ }
+ .drest-nav-right,.drest-nav-left {
+ width: 30px;
+ }
+}
+.drest-content .table {
+ border: 0px;
+}
+.drest-content .table th {
+ border-top: 0px;
+}
+.drest-content .stacktable.small-only {
+ border: 1px solid #999;
+}
+.navbar-brand.drest-nav-left {
+ margin-right: 0;
+}
+.drest-content .stacktable tr:first-child td {
+ border-top: 0px;
+}
+.drest-content .st-key, .drest-content th, .modal .form-control-label, .drest-field .drest-field-label {
+ font-weight: 600;
+ font-size: 12px;
+ text-align: inherit;
+ align-self: center;
+ vertical-align: inherit;
+ text-transform: uppercase;
+}
+.drest-content-container .drest-content th {
+ border-bottom: 1px solid #999;
+}
+.drest-content th:last-child {
+ border-right: 0px;
+}
+.drest-nav-alert {
+ padding: 14px;
+ display: flex;
+ justify-content: space-between;
+ margin: 0;
+ position: fixed;
+ left: 0;
+ right: 0;
+ z-index: 9001;
+ bottom: 0;
+}
+.drest-field {
+ border-bottom: 1px solid #eaeaea;
+}
+.drest-field:last-child {
+ border-bottom: 0px;
+}
+.navbar-nav .drest-has-tooltip {
+ border-bottom: 0px;
+}
+.modal-dialog.modal-dialog {
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ margin: 0;
+ padding: 0;
+}
+.modal-header.modal-header {
+ height: 56px;
+ border: 0px;
+}
+.modal-header .close {
+ color: white;
+}
+.modal-content.modal-content {
+ height: 100%;
+ width: 100%;
+ background-color: rgba(255, 255, 255, 1);
+ border: 0px;
+ border-radius: 0;
+}
+.modal-content .modal-body {
+ position: fixed;
+ top: 56px;
+ bottom: 60px;
+ left: 0px;
+ right: 0px;
+ overflow-y: scroll;
+}
+.modal-content .modal-header{
+ position: fixed;
+ color: white;
+ height: 56px;
+ left: 0px;
+ right: 0px;
+ top: 0px;
+}
+.modal-footer.modal-footer {
+ position: fixed;
+ bottom: 0px;
+ left: 0px;
+ right: 0px;
+ border: 0px;
+ height: 60px;
+}
+.drest-has-tooltip {
+ border-bottom: 1px dotted #999;
+}
+.drest-table-details {
+ border: 0;
+}
+.table.drest-table-details td, .table.drest-table-details th {
+ border: 0;
+}
diff --git a/dynamic_rest/static/dynamic_rest/css/default.css b/dynamic_rest/static/dynamic_rest/css/default.css
index ee09ac04..5cf81270 100644
--- a/dynamic_rest/static/dynamic_rest/css/default.css
+++ b/dynamic_rest/static/dynamic_rest/css/default.css
@@ -59,3 +59,7 @@ html, body {
padding: 0;
height: 100%;
}
+
+.navbar {
+ background: green;
+}
diff --git a/dynamic_rest/static/dynamic_rest/css/details.css b/dynamic_rest/static/dynamic_rest/css/details.css
new file mode 100644
index 00000000..2744a29f
--- /dev/null
+++ b/dynamic_rest/static/dynamic_rest/css/details.css
@@ -0,0 +1,108 @@
+body {
+ background: initial !important;
+}
+
+.left {
+ float: left;
+}
+
+.container {
+ width: auto !important;
+}
+
+body a {
+ color: #2789d8;
+ cursor: pointer;
+}
+
+body a:hover {
+ color: #36a3fa;
+ text-decoration: none;
+}
+
+#content {
+ margin-top: 100px !important;
+}
+
+.breadcrumb>li+li:before {
+ content: "\003e" !important;
+}
+
+.navbar {
+ background: rgba(28, 118, 193, 1);
+ border-color: rgba(39, 137, 216, .95);
+}
+
+.navbar-brand {
+ height: auto;
+ padding: 0;
+}
+
+.logo {
+ height: 60px;
+ padding: 15px;
+}
+
+.breadcrumb {
+ background-color: transparent;
+ display: inline-block;
+ margin: 12px auto 0 !important;
+ width: auto;
+}
+
+.breadcrumb li.active a, .breadcrumb li a {
+ color: white;
+}
+
+.breadcrumb li.active a:hover, .breadcrumb li a:hover {
+ color: rgba(255,255,255,.9);
+}
+
+#content {
+ margin-top: 120px;
+}
+
+.sidebar-actions {
+ float: right;
+}
+
+.sidebar-action {
+ margin-bottom: 10px;
+ margin-right: 1em;
+ width: 85px;
+}
+
+/* Buttons */
+
+.btn { border: none; color: white; }
+
+.btn:active:focus, .btn:focus { outline: transparent; }
+
+.btn-label { color: white; margin: 0; }
+
+.btn-primary { background-color: #2789d8; }
+
+.btn-primary:hover { background-color: #36a3fa; }
+
+.btn-import { background-color: #098f90; }
+
+.btn-import:hover { background-color: #0CADAE; }
+
+.btn-export { background-color: #744D98; }
+
+.btn-export:hover { background-color: #AB80D4; }
+
+.btn-delete { margin-right: 0; }
+
+.btn-filters, .btn-filters:active, .btn-filters:focus, .btn-filters:visited {
+ background-color: #F09648;
+ color: white;
+}
+.btn-filters:hover {
+ background-color: #F8A65F !important;
+ color: white !important;
+}
+
+.page-header > h1 {
+ margin-top: 0;
+}
diff --git a/dynamic_rest/static/dynamic_rest/css/stacktable.css b/dynamic_rest/static/dynamic_rest/css/stacktable.css
new file mode 100755
index 00000000..2d501aa1
--- /dev/null
+++ b/dynamic_rest/static/dynamic_rest/css/stacktable.css
@@ -0,0 +1,17 @@
+.stacktable { width: 100%; }
+.st-head-row { padding-top: 1em; }
+.st-head-row.st-head-row-main { font-size: 1.5em; padding-top: 0; }
+.st-key { width: 49%; text-align: right; padding-right: 1%; }
+.st-val { width: 49%; padding-left: 1%; }
+
+
+
+/* RESPONSIVE EXAMPLE */
+
+.stacktable.large-only { display: table; }
+.stacktable.small-only { display: none; }
+
+@media (max-width: 800px) {
+ .stacktable.large-only { display: none; }
+ .stacktable.small-only { display: table; }
+}
\ No newline at end of file
diff --git a/dynamic_rest/static/dynamic_rest/img/logo.svg b/dynamic_rest/static/dynamic_rest/img/logo.svg
new file mode 100644
index 00000000..8dc91a6b
--- /dev/null
+++ b/dynamic_rest/static/dynamic_rest/img/logo.svg
@@ -0,0 +1 @@
+
diff --git a/dynamic_rest/static/dynamic_rest/js/stacktable.js b/dynamic_rest/static/dynamic_rest/js/stacktable.js
new file mode 100755
index 00000000..82b25247
--- /dev/null
+++ b/dynamic_rest/static/dynamic_rest/js/stacktable.js
@@ -0,0 +1,210 @@
+/**
+ * stacktable.js
+ * Author & copyright (c) 2012: John Polacek
+ * CardTable by: Justin McNally (2015)
+ * MIT license
+ *
+ * Page: http://johnpolacek.github.com/stacktable.js
+ * Repo: https://github.com/johnpolacek/stacktable.js/
+ *
+ * jQuery plugin for stacking tables on small screens
+ * Requires jQuery version 1.7 or above
+ *
+ */
+;(function($) {
+ $.fn.cardtable = function(options) {
+ var $tables = this,
+ defaults = {headIndex:0},
+ settings = $.extend({}, defaults, options),
+ headIndex;
+
+ // checking the "headIndex" option presence... or defaults it to 0
+ if(options && options.headIndex)
+ headIndex = options.headIndex;
+ else
+ headIndex = 0;
+
+ return $tables.each(function() {
+ var $table = $(this);
+ if ($table.hasClass('stacktable')) {
+ return;
+ }
+ var table_css = $(this).prop('class');
+ var $stacktable = $('
');
+ if (typeof settings.myClass !== 'undefined') $stacktable.addClass(settings.myClass);
+ var markup = '';
+ var $caption, $topRow, headMarkup, bodyMarkup, tr_class;
+
+ $table.addClass('stacktable large-only');
+
+ $caption = $table.find(">caption").clone();
+ $topRow = $table.find('>thead>tr,>tbody>tr,>tfoot>tr,>tr').eq(0);
+
+ // avoid duplication when paginating
+ $table.siblings().filter('.small-only').remove();
+
+ // using rowIndex and cellIndex in order to reduce ambiguity
+ $table.find('>tbody>tr').each(function() {
+
+ // declaring headMarkup and bodyMarkup, to be used for separately head and body of single records
+ headMarkup = '';
+ bodyMarkup = '';
+ tr_class = $(this).prop('class');
+ // for the first row, "headIndex" cell is the head of the table
+ // for the other rows, put the "headIndex" cell as the head for that row
+ // then iterate through the key/values
+ $(this).find('>td,>th').each(function(cellIndex) {
+ if ($(this).html() !== ''){
+ bodyMarkup += '';
+ if ($topRow.find('>td,>th').eq(cellIndex).html()){
+ bodyMarkup += ''+$topRow.find('>td,>th').eq(cellIndex).html()+' ';
+ } else {
+ bodyMarkup += ' ';
+ }
+ bodyMarkup += ''+$(this).html()+' ';
+ bodyMarkup += ' ';
+ }
+ });
+
+ markup += '' + headMarkup + bodyMarkup + '
';
+ });
+
+ $table.find('>tfoot>tr>td').each(function(rowIndex,value) {
+ if ($.trim($(value).text()) !== '') {
+ markup += '';
+ }
+ });
+
+ $stacktable.prepend($caption);
+ $stacktable.append($(markup));
+ $table.before($stacktable);
+ });
+ };
+
+ $.fn.stacktable = function(options) {
+ var $tables = this,
+ defaults = {headIndex:0,displayHeader:true},
+ settings = $.extend({}, defaults, options),
+ headIndex;
+
+ // checking the "headIndex" option presence... or defaults it to 0
+ if(options && options.headIndex)
+ headIndex = options.headIndex;
+ else
+ headIndex = 0;
+
+ return $tables.each(function() {
+ var table_css = $(this).prop('class');
+ var $stacktable = $('');
+ if (typeof settings.myClass !== 'undefined') $stacktable.addClass(settings.myClass);
+ var markup = '';
+ var $table, $caption, $topRow, headMarkup, bodyMarkup, tr_class, displayHeader;
+
+ $table = $(this);
+ $table.addClass('stacktable large-only');
+ $caption = $table.find(">caption").clone();
+ $topRow = $table.find('>thead>tr,>tbody>tr,>tfoot>tr').eq(0);
+
+ displayHeader = $table.data('display-header') === undefined ? settings.displayHeader : $table.data('display-header');
+
+ // using rowIndex and cellIndex in order to reduce ambiguity
+ $table.find('>tbody>tr, >thead>tr').each(function(rowIndex) {
+
+ // declaring headMarkup and bodyMarkup, to be used for separately head and body of single records
+ headMarkup = '';
+ bodyMarkup = '';
+ tr_class = $(this).prop('class');
+
+ // for the first row, "headIndex" cell is the head of the table
+ if (rowIndex === 0) {
+ // the main heading goes into the markup variable
+ if (displayHeader) {
+ markup += ''+$(this).find('>th,>td').eq(headIndex).html()+' ';
+ }
+ } else {
+ // for the other rows, put the "headIndex" cell as the head for that row
+ // then iterate through the key/values
+ $(this).find('>td,>th').each(function(cellIndex) {
+ if (cellIndex === headIndex) {
+ headMarkup = ''+$(this).html()+' ';
+ } else {
+ if ($(this).html() !== ''){
+ bodyMarkup += '';
+ if ($topRow.find('>td,>th').eq(cellIndex).html()){
+ bodyMarkup += ''+$topRow.find('>td,>th').eq(cellIndex).html()+' ';
+ } else {
+ bodyMarkup += ' ';
+ }
+ bodyMarkup += ''+$(this).html()+' ';
+ bodyMarkup += ' ';
+ }
+ }
+ });
+
+ markup += headMarkup + bodyMarkup;
+ }
+ });
+
+ $stacktable.prepend($caption);
+ $stacktable.append($(markup));
+ $table.before($stacktable);
+ });
+ };
+
+ $.fn.stackcolumns = function(options) {
+ var $tables = this,
+ defaults = {},
+ settings = $.extend({}, defaults, options);
+
+ return $tables.each(function() {
+ var $table = $(this);
+ var $caption = $table.find(">caption").clone();
+ var num_cols = $table.find('>thead>tr,>tbody>tr,>tfoot>tr').eq(0).find('>td,>th').length; //first table must not contain colspans, or add sum(colspan-1) here.
+ if(num_cols<3) //stackcolumns has no effect on tables with less than 3 columns
+ return;
+
+ var $stackcolumns = $('');
+ if (typeof settings.myClass !== 'undefined') $stackcolumns.addClass(settings.myClass);
+ $table.addClass('stacktable large-only');
+ var tb = $(' ');
+ var col_i = 1; //col index starts at 0 -> start copy at second column.
+
+ while (col_i < num_cols) {
+ $table.find('>thead>tr,>tbody>tr,>tfoot>tr').each(function(index) {
+ var tem = $(' '); // todo opt. copy styles of $this; todo check if parent is thead or tfoot to handle accordingly
+ if(index === 0) tem.addClass("st-head-row st-head-row-main");
+ var first = $(this).find('>td,>th').eq(0).clone().addClass("st-key");
+ var target = col_i;
+ // if colspan apply, recompute target for second cell.
+ if ($(this).find("*[colspan]").length) {
+ var i =0;
+ $(this).find('>td,>th').each(function() {
+ var cs = $(this).attr("colspan");
+ if (cs) {
+ cs = parseInt(cs, 10);
+ target -= cs-1;
+ if ((i+cs) > (col_i)) //out of current bounds
+ target += i + cs - col_i -1;
+ i += cs;
+ } else {
+ i++;
+ }
+
+ if (i > col_i)
+ return false; //target is set; break.
+ });
+ }
+ var second = $(this).find('>td,>th').eq(target).clone().addClass("st-val").removeAttr("colspan");
+ tem.append(first, second);
+ tb.append(tem);
+ });
+ ++col_i;
+ }
+
+ $stackcolumns.append($(tb));
+ $stackcolumns.prepend($caption);
+ $table.before($stackcolumns);
+ });
+ };
+
+}(jQuery));
diff --git a/dynamic_rest/tagged.py b/dynamic_rest/tagged.py
index e0f93901..e621d3d6 100644
--- a/dynamic_rest/tagged.py
+++ b/dynamic_rest/tagged.py
@@ -20,8 +20,8 @@ class TaggedDict(object):
"""
def __init__(self, *args, **kwargs):
- self.serializer = kwargs.pop('serializer')
- self.instance = kwargs.pop('instance')
+ self.serializer = kwargs.pop('serializer', None)
+ self.instance = kwargs.pop('instance', None)
self.embed = kwargs.pop('embed', False)
self.pk_value = kwargs.pop('pk_value', None)
if not isinstance(self, dict):
diff --git a/dynamic_rest/templates/dynamic_rest/admin.html b/dynamic_rest/templates/dynamic_rest/admin.html
new file mode 100644
index 00000000..719df062
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/admin.html
@@ -0,0 +1,351 @@
+{% load staticfiles %}
+{% load i18n %}
+{% load rest_framework %}
+{% load dynamic_rest %}
+
+
+
+
+ {% block head %}
+
+ {% block meta %}
+
+
+
+ {% endblock %}
+
+
+ {% block title %}
+ {% drest_settings 'API_NAME' %}
+ {% endblock %}
+
+
+ {% block style %}
+ {% block bootstrap_css %}
+
+ {% endblock %}
+ {% block fontawesome_css %}
+
+ {% endblock %}
+
+
+
+ {% endblock %}
+
+ {% block jquery %}
+
+ {% endblock %}
+
+ {% endblock %}
+
+ {% block body %}
+
+ {% block header %}
+
+
+
+ {% endblock %}
+
+ {% block content %}
+
+
+ {% if is_auth_error %}
+
+ You are not authorized to view
+ this page. Try to login.
+
+ {% else %}
+ {% if style == 'list' %}
+ {% include "dynamic_rest/admin/list.html" %}
+ {% else %}
+ {% if style == 'root' %}
+ {% include "dynamic_rest/admin/root.html" %}
+ {% else %}
+ {% include "dynamic_rest/admin/detail.html" %}
+ {% endif %}
+ {% endif %}
+ {% endif %}
+
+
+ {% endblock %}
+
+ {% block alert %}
+ {% if alert %}
+
+ {% endif %}
+ {% endblock %}
+
+ {% block footer %}
+
+
+
+ {% if allow_delete %}
+
+
+ Delete
+
+ {% endif %}
+ {% if allow_filter %}
+
+
+ {% if search_value %}"{{ search_value }}"{% else %}Filter{% endif %}
+
+ {% endif %}
+
+
+ {% if paginator %}
+ {% get_pagination_html paginator %}
+ {% endif %}
+ {% if back %}
+
+
+ {{back}}
+
+ {% endif %}
+
+
+ {% if allow_create %}
+
+
+ Create
+
+ {% endif %}
+ {% if allow_edit %}
+
+
+ Edit
+
+ {% endif %}
+
+
+
+ {% endblock %}
+
+ {% block create_modal %}
+
+ {% endblock %}
+
+ {% block delete_modal %}
+
+ {% endblock %}
+
+ {% block edit_modal %}
+
+ {% endblock %}
+
+ {% block error_form %}
+ {% if error_form %}
+
+ {% endif %}
+ {% endblock %}
+
+ {% block filter_form %}
+
+ {% endblock %}
+
+ {% block script %}
+
+
+
+
+
+
+
+ {% endblock %}
+
+ {% endblock %}
+
diff --git a/dynamic_rest/templates/dynamic_rest/admin/detail.html b/dynamic_rest/templates/dynamic_rest/admin/detail.html
new file mode 100644
index 00000000..87210b3a
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/admin/detail.html
@@ -0,0 +1,21 @@
+{% load rest_framework %}
+{% load dynamic_rest %}
+
+
+ {% for key in details %}
+ {% get_field_value results.serializer results.serializer.instance key as field %}
+
+
+ {% if field and field.help_text %}
+ {{ field.label }}
+ {% else %}
+ {{ field.label }}
+ {% endif %}
+
+
+ {{ field | render_field_value }}
+
+
+ {% endfor %}
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/admin/list.html b/dynamic_rest/templates/dynamic_rest/admin/list.html
new file mode 100644
index 00000000..8c1282db
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/admin/list.html
@@ -0,0 +1,34 @@
+{% load rest_framework %}
+{% load dynamic_rest %}
+{% if results %}
+
+
+
+ {% for column in columns %}
+ {% get_value_from_dict fields column as field %}
+ {% if field %}
+ {% if field.help_text %}
+ {{ field.label }}
+ {% else %}
+ {{ field.label }}
+ {% endif %}
+ {% else %}
+ {{ column }}
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% for row in results %}
+
+ {% for key in columns %}
+ {% get_field_value row.serializer row.instance key as field %}
+ {{ field | render_field_value }}
+ {% endfor %}
+
+ {% endfor %}
+
+
+{% else %}
+ No results, try creating a new record.
+{% endif %}
diff --git a/dynamic_rest/templates/dynamic_rest/admin/root.html b/dynamic_rest/templates/dynamic_rest/admin/root.html
new file mode 100644
index 00000000..6f40522c
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/admin/root.html
@@ -0,0 +1,13 @@
+{% load rest_framework %}
+
+
+ {% for key, value in results.items %}
+ {% if key in details %}
+
+ {{ key|capfirst }}
+
+
+ {% endif %}
+ {% endfor %}
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html
new file mode 100644
index 00000000..57198c17
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox.html
@@ -0,0 +1,20 @@
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html
new file mode 100644
index 00000000..b3431b27
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/checkbox_multiple.html
@@ -0,0 +1,39 @@
+{% load rest_framework %}
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/choices.html b/dynamic_rest/templates/dynamic_rest/horizontal/choices.html
new file mode 100644
index 00000000..feb0601a
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/choices.html
@@ -0,0 +1,46 @@
+{% load i18n %}
+{% load rest_framework %}
+{% load dynamic_rest %}
+
+{% trans "No items to select." as no_items %}
+
+
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html b/dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html
new file mode 100644
index 00000000..f81d60af
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/fieldset.html
@@ -0,0 +1,20 @@
+{% load rest_framework %}
+
+ {% if field.label %}
+
+
+ {% if field.help_text %}
+ {{ field.label }}
+ {% else %}
+ {{ field.label }}
+ {% endif %}
+
+
+ {% endif %}
+
+ {% for nested_field in field %}
+ {% if not nested_field.read_only %}
+ {% render_field nested_field style=style %}
+ {% endif %}
+ {% endfor %}
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/form.html b/dynamic_rest/templates/dynamic_rest/horizontal/form.html
new file mode 100644
index 00000000..13fc807e
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/form.html
@@ -0,0 +1,6 @@
+{% load rest_framework %}
+{% for field in form %}
+ {% if not field.read_only %}
+ {% render_field field style=style %}
+ {% endif %}
+{% endfor %}
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/input.html b/dynamic_rest/templates/dynamic_rest/horizontal/input.html
new file mode 100644
index 00000000..3aca1f3c
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/input.html
@@ -0,0 +1,22 @@
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/list.html b/dynamic_rest/templates/dynamic_rest/horizontal/list.html
new file mode 100644
index 00000000..9682471e
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/list.html
@@ -0,0 +1,46 @@
+{% load i18n %}
+{% load rest_framework %}
+{% load dynamic_rest %}
+
+{% trans "No items to select." as no_items %}
+
+
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html b/dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html
new file mode 100644
index 00000000..8538a217
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/list_fieldset.html
@@ -0,0 +1,13 @@
+{% load rest_framework %}
+
+
+ {% if field.label %}
+
+
+ {{ field.label }}
+
+
+ {% endif %}
+
+ Lists are not currently supported in HTML input.
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/radio.html b/dynamic_rest/templates/dynamic_rest/horizontal/radio.html
new file mode 100644
index 00000000..35fc10ea
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/radio.html
@@ -0,0 +1,58 @@
+{% load i18n %}
+{% load rest_framework %}
+
+{% trans "None" as none_choice %}
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/relation.html b/dynamic_rest/templates/dynamic_rest/horizontal/relation.html
new file mode 100644
index 00000000..6ca678a0
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/relation.html
@@ -0,0 +1,119 @@
+{% load i18n %}
+{% load rest_framework %}
+{% load dynamic_rest %}
+
+{% trans "No items to select." as no_items %}
+
+
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/select.html b/dynamic_rest/templates/dynamic_rest/horizontal/select.html
new file mode 100644
index 00000000..6377cfb3
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/select.html
@@ -0,0 +1,37 @@
+{% load rest_framework %}
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html b/dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html
new file mode 100644
index 00000000..3209d91b
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/select_multiple.html
@@ -0,0 +1,39 @@
+{% load i18n %}
+{% load rest_framework %}
+
+{% trans "No items to select." as no_items %}
+
+
diff --git a/dynamic_rest/templates/dynamic_rest/horizontal/textarea.html b/dynamic_rest/templates/dynamic_rest/horizontal/textarea.html
new file mode 100644
index 00000000..e4f681db
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/horizontal/textarea.html
@@ -0,0 +1,22 @@
+
diff --git a/dynamic_rest/templates/dynamic_rest/login.html b/dynamic_rest/templates/dynamic_rest/login.html
new file mode 100644
index 00000000..2aecdb14
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/login.html
@@ -0,0 +1,68 @@
+{% extends "dynamic_rest/admin.html" %}
+{% load staticfiles %}
+{% load rest_framework %}
+{% load dynamic_rest %}
+
+{% block content %}
+
+{% endblock %}
+
+{% block footer %}
+{% endblock %}
diff --git a/dynamic_rest/templates/dynamic_rest/pagination/numbers.html b/dynamic_rest/templates/dynamic_rest/pagination/numbers.html
new file mode 100644
index 00000000..29409846
--- /dev/null
+++ b/dynamic_rest/templates/dynamic_rest/pagination/numbers.html
@@ -0,0 +1,20 @@
+
diff --git a/dynamic_rest/constants.py b/dynamic_rest/templatetags/__init__.py
similarity index 100%
rename from dynamic_rest/constants.py
rename to dynamic_rest/templatetags/__init__.py
diff --git a/dynamic_rest/templatetags/dynamic_rest.py b/dynamic_rest/templatetags/dynamic_rest.py
new file mode 100644
index 00000000..630c58f4
--- /dev/null
+++ b/dynamic_rest/templatetags/dynamic_rest.py
@@ -0,0 +1,81 @@
+from __future__ import absolute_import
+
+import json
+from uuid import UUID
+from django import template
+from django.utils.safestring import mark_safe
+from django.utils import six
+from dynamic_rest.conf import settings
+from rest_framework.fields import get_attribute
+try:
+ from rest_framework.templatetags.rest_framework import format_value
+except:
+ format_value = lambda x: x
+
+register = template.Library()
+
+
+@register.filter
+def as_id_to_name(field):
+ serializer = field.serializer
+ name_field_name = serializer.get_name_field()
+ name_source = serializer.get_field(
+ name_field_name
+ ).source or name_field_name
+ source_attrs = name_source.split('.')
+ value = field.value
+
+ if not (
+ isinstance(value, list)
+ and not isinstance(value, six.string_types)
+ and not isinstance(value, UUID)
+ ):
+ value = [value]
+
+ result = {}
+ for v in value:
+ if v:
+ if hasattr(v, 'instance'):
+ instance = v.instance
+ else:
+ instance = serializer.get_model().objects.get(
+ pk=str(v)
+ )
+ result[str(instance.pk)] = get_attribute(instance, source_attrs)
+ return mark_safe(json.dumps(result))
+
+
+@register.simple_tag
+def get_value_from_dict(d, key):
+ return d.get(key, '')
+
+
+@register.filter
+def format_key(key):
+ return key
+
+
+@register.simple_tag
+def drest_settings(key):
+ return getattr(settings, key)
+
+
+@register.filter
+def to_json(value):
+ return json.dumps(value)
+
+
+@register.filter
+def admin_format_value(value):
+ return format_value(value)
+
+
+@register.simple_tag
+def get_field_value(serializer, instance, key, idx=None):
+ return serializer.get_field_value(key, instance)
+
+
+@register.filter
+def render_field_value(field):
+ value = getattr(field, 'get_rendered_value', lambda *x: field)()
+ return mark_safe(value)
diff --git a/dynamic_rest/test.py b/dynamic_rest/test.py
new file mode 100644
index 00000000..451eb274
--- /dev/null
+++ b/dynamic_rest/test.py
@@ -0,0 +1,337 @@
+import json
+
+import datetime
+from uuid import UUID
+from django.test import TestCase
+from model_mommy import mommy
+from rest_framework.fields import empty
+from rest_framework.test import APIClient
+from django.contrib.auth import get_user_model
+from .compat import resolve
+from dynamic_rest.meta import Meta
+
+
+class ViewSetTestCase(TestCase):
+ """Base class that makes it easy to test dynamic viewsets.
+
+ You must set the "view" property to an API-bound view.
+
+ This test runs through the various exposed endpoints,
+ making internal API calls as a superuser.
+
+ Default test cases:
+ test_get_detail:
+ - Only runs if the view allows GET
+ test_get_list
+ - Only runs if the view allows GET
+ test_create
+ - Only runs if the view allows POST
+ test_update
+ - Only run if the view allows PUT
+ test_delete
+ - Only run if the view allows DELETE
+
+ Overriding methods:
+ get_client:
+ - should return a suitable API client
+ get_post_params:
+ - returns an object that can be POSTed to the view
+ get_put_params:
+ - return an object that can be PUT to the view given an instance
+ create_instance:
+ - return a committed instance of the model
+ prepare_instance:
+ - return an uncomitted instance of the model
+
+
+ Example usage:
+
+ class MyAdminViewSetTestCase(AdminViewSetTestCase):
+ viewset = UserViewSet
+
+ # use custom post params
+ def get_post_params(self):
+ return {
+ 'foo': 1
+ }
+
+ """
+ viewset = None
+
+ def setUp(self):
+ if self.viewset:
+ try:
+ # trigger URL loading
+ resolve('/')
+ except:
+ pass
+
+ def get_model(self):
+ serializer = self.serializer_class
+ return serializer.get_model()
+
+ def get_url(self, pk=None):
+ return self.serializer_class.get_url(pk)
+
+ @property
+ def serializer_class(self):
+ if not hasattr(self, '_serializer_class'):
+ self._serializer_class = self.view.get_serializer_class()
+ return self._serializer_class
+
+ @property
+ def view(self):
+ if not hasattr(self, '_view'):
+ self._view = self.viewset() if self.viewset else None
+ return self._view
+
+ @property
+ def api_client(self):
+ if not getattr(self, '_api_client', None):
+ self._api_client = self.get_client()
+ return self._api_client
+
+ def get_superuser(self):
+ User = get_user_model()
+ return mommy.make(
+ User,
+ is_superuser=True
+ )
+
+ def get_client(self):
+ user = self.get_superuser()
+ client = APIClient()
+ client.force_authenticate(user)
+ return client
+
+ def get_create_params(self):
+ return {}
+
+ def get_put_params(self, instance):
+ return self.get_post_params(instance)
+
+ def get_post_params(self, instance=None):
+ def format_value(v):
+ if isinstance(
+ v,
+ (UUID, datetime.datetime, datetime.date)
+ ):
+ v = str(v)
+ return v
+
+ delete = False
+ if not instance:
+ delete = True
+ instance = self.create_instance()
+
+ serializer_class = self.serializer_class
+ serializer = serializer_class(include_fields='*')
+ fields = serializer.get_all_fields()
+ data = serializer.to_representation(instance)
+ data = {
+ k: format_value(v) for k, v in data.items()
+ if k in fields and (
+ (not fields[k].read_only) or
+ (fields[k].default is not empty)
+ )
+ }
+
+ if delete:
+ instance.delete()
+
+ return data
+
+ def prepare_instance(self):
+ # prepare an uncomitted instance
+ return mommy.prepare(
+ self.get_model(),
+ **self.get_create_params()
+ )
+
+ def create_instance(self):
+ # create a sample instance
+ instance = self.prepare_instance()
+ instance.save()
+ return instance
+
+ def test_get_list(self):
+ view = self.view
+ if view is None:
+ return
+
+ if 'get' not in view.http_method_names:
+ return
+
+ url = self.get_url()
+
+ EMPTY = 0
+ NON_EMPTY = 1
+ for case in (EMPTY, NON_EMPTY):
+ if case == NON_EMPTY:
+ self.create_instance()
+
+ for renderer in view.get_renderers():
+ url = '%s?format=%s' % (url, renderer.format)
+ response = self.api_client.get(url)
+ self.assertEquals(
+ response.status_code,
+ 200,
+ 'GET %s failed with %d: %s' % (
+ url,
+ response.status_code,
+ response.content.decode('utf-8')
+ )
+ )
+
+ def test_get_detail(self):
+ view = self.view
+ if view is None:
+ return
+
+ if 'get' not in view.http_method_names:
+ return
+
+ instance = self.create_instance()
+ # generate an invalid PK by modifying a valid PK
+ # this ensures the ID looks valid to the framework,
+ # e.g. a UUID looks like a UUID
+ try:
+ invalid_pk = int(str(instance.pk)) + 1
+ except:
+ invalid_pk = list(str(instance.pk))
+ invalid_pk[0] = 'a' if invalid_pk[0] == 'b' else 'b'
+ invalid_pk = "".join(invalid_pk)
+
+ for (pk, status) in (
+ (instance.pk, 200),
+ (invalid_pk, 404)
+ ):
+ url = self.get_url(pk)
+ for renderer in view.get_renderers():
+ url = '%s?format=%s' % (url, renderer.format)
+ response = self.api_client.get(url)
+ self.assertEquals(
+ response.status_code,
+ status,
+ 'GET %s failed with %d:\n%s' % (
+ url,
+ response.status_code,
+ response.content.decode('utf-8')
+ )
+ )
+
+ def test_create(self):
+ view = self.view
+ if view is None:
+ return
+
+ if 'post' not in view.http_method_names:
+ return
+
+ model = self.get_model()
+ for renderer in view.get_renderers():
+
+ format = renderer.format
+ url = '%s?format=%s' % (
+ self.get_url(),
+ format
+ )
+ data = self.get_post_params()
+ response = self.api_client.post(
+ url,
+ content_type='application/json',
+ data=json.dumps(data)
+ )
+ self.assertTrue(
+ response.status_code < 400,
+ 'POST %s failed with %d:\n%s' % (
+ url,
+ response.status_code,
+ response.content.decode('utf-8')
+ )
+ )
+ content = response.content.decode('utf-8')
+ if format == 'json':
+ content = json.loads(content)
+ model = self.get_model()
+ model_name = Meta(model).get_name()
+ serializer = self.serializer_class()
+ name = serializer.get_name()
+ pk_field = serializer.get_field('pk')
+ if pk_field:
+ pk_field = pk_field.field_name
+ pk = content[name][pk_field]
+ self.assertTrue(
+ model.objects.filter(pk=pk).exists(),
+ 'POST %s succeeded but instance '
+ '"%s.%s" does not exist' % (
+ url,
+ model_name,
+ pk
+ )
+ )
+
+ def test_update(self):
+ view = self.view
+ if view is None:
+ return
+
+ if 'put' not in view.http_method_names:
+ return
+
+ instance = self.create_instance()
+ for renderer in view.get_renderers():
+ data = self.get_put_params(instance)
+ url = '%s?format=%s' % (
+ self.get_url(instance.pk),
+ renderer.format
+ )
+ response = self.api_client.put(
+ url,
+ content_type='application/json',
+ data=json.dumps(data)
+ )
+ self.assertTrue(
+ response.status_code < 400,
+ 'PUT %s failed with %d:\n%s' % (
+ url,
+ response.status_code,
+ response.content.decode('utf-8')
+ )
+ )
+
+ def test_delete(self):
+ view = self.view
+
+ if view is None:
+ return
+
+ if 'delete' not in view.http_method_names:
+ return
+
+ for renderer in view.get_renderers():
+ instance = self.create_instance()
+ url = '%s?format=%s' % (
+ self.get_url(instance.pk),
+ renderer.format
+ )
+ response = self.api_client.delete(url)
+ self.assertTrue(
+ response.status_code < 400,
+ 'DELETE %s failed with %d: %s' % (
+ url,
+ response.status_code,
+ response.content.decode('utf-8')
+ )
+ )
+ model = self.get_model()
+ model_name = Meta(model).get_name()
+ pk = instance.pk
+ self.assertFalse(
+ model.objects.filter(pk=pk).exists(),
+ 'DELETE %s succeeded but instance "%s.%s" still exists' % (
+ url,
+ model_name,
+ pk
+ )
+ )
diff --git a/dynamic_rest/urls.py b/dynamic_rest/urls.py
new file mode 100644
index 00000000..ac047079
--- /dev/null
+++ b/dynamic_rest/urls.py
@@ -0,0 +1,27 @@
+"""
+Login and logout views for the admin API.
+
+Add these to your root URLconf if you're using the browsable API and
+your API requires authentication:
+
+ urlpatterns = [
+ ...
+ url(r'^auth/', include('rest_framework.urls', namespace='rest_framework'))
+ ]
+
+In Django versions older than 1.9, the urls must be namespaced as 'rest_framework',
+and you should make sure your authentication settings include `SessionAuthentication`.
+""" # noqa
+from __future__ import unicode_literals
+
+from django.conf.urls import url
+from django.contrib.auth import views
+from dynamic_rest.conf import settings as drest
+
+template_name = {'template_name': drest.ADMIN_LOGIN_TEMPLATE}
+
+app_name = 'dynamic_rest'
+urlpatterns = [
+ url(r'^login/$', views.login, template_name, name='login'),
+ url(r'^logout/$', views.logout, template_name, name='logout'),
+]
diff --git a/dynamic_rest/utils.py b/dynamic_rest/utils.py
index 29c007c0..0bd6c96f 100644
--- a/dynamic_rest/utils.py
+++ b/dynamic_rest/utils.py
@@ -11,13 +11,3 @@ def is_truthy(x):
if isinstance(x, string_types):
return x.lower() not in FALSEY_STRINGS
return bool(x)
-
-
-def unpack(content):
- if not content:
- # empty values pass through
- return content
-
- keys = [k for k in content.keys() if k != 'meta']
- unpacked = content[keys[0]]
- return unpacked
diff --git a/dynamic_rest/views.py b/dynamic_rest/views.py
new file mode 100644
index 00000000..72759444
--- /dev/null
+++ b/dynamic_rest/views.py
@@ -0,0 +1,12 @@
+from __future__ import absolute_import
+from django.contrib.auth import views
+from dynamic_rest.conf import settings
+
+
+def login(request):
+ template_name = settings.ADMIN_LOGIN_TEMPLATE
+ return views.login(request, template_name=template_name)
+
+
+def logout(request):
+ return views.logout(request)
diff --git a/dynamic_rest/viewsets.py b/dynamic_rest/viewsets.py
index 93022914..ea323558 100644
--- a/dynamic_rest/viewsets.py
+++ b/dynamic_rest/viewsets.py
@@ -1,11 +1,13 @@
"""This module contains custom viewset classes."""
-from django.core.exceptions import ObjectDoesNotExist
+import csv
+from io import StringIO
+import inflection
+
from django.http import QueryDict
from django.utils import six
from rest_framework import exceptions, status, viewsets
-from rest_framework.exceptions import ValidationError
-from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.response import Response
+from rest_framework.request import is_form_media_type
from dynamic_rest.conf import settings
from dynamic_rest.filters import DynamicFilterBackend, DynamicSortingFilter
@@ -140,15 +142,50 @@ def handle_encodings(request):
return request
def get_renderers(self):
- """Optionally block Browsable API rendering. """
+ """Optionally block browsable/admin API rendering. """
renderers = super(WithDynamicViewSetMixin, self).get_renderers()
+ blacklist = set(('admin', 'api'))
if settings.ENABLE_BROWSABLE_API is False:
return [
- r for r in renderers if not isinstance(r, BrowsableAPIRenderer)
+ r for r in renderers
+ if r.format not in blacklist
]
else:
return renderers
+ def get_success_headers(self, data):
+ serializer = getattr(data, 'serializer', None)
+ headers = super(WithDynamicViewSetMixin, self).get_success_headers(
+ data
+ )
+ if serializer and serializer.instance:
+ headers['Location'] = serializer.get_url(
+ pk=getattr(serializer.instance, 'pk', None)
+ )
+ return headers
+
+ def get_view_name(self):
+ serializer_class = self.get_serializer_class()
+ suffix = self.suffix or ''
+ if serializer_class:
+ serializer = self.serializer_class()
+ if suffix.lower() == 'list':
+ name = serializer.get_plural_name()
+ else:
+ try:
+ obj = self.get_object()
+ name_field = serializer.get_name_field()
+ name = str(getattr(obj, name_field))
+ except:
+ name = serializer.get_name()
+ else:
+ name = self.__class__.__name__
+ name = (
+ inflection.pluralize(name)
+ if suffix.lower() == 'list' else name
+ )
+ return name.title()
+
def get_request_feature(self, name):
"""Parses the request for a particular feature.
@@ -208,7 +245,8 @@ def get_queryset(self, queryset=None):
queryset: Optional root-level queryset.
"""
serializer = self.get_serializer()
- return getattr(self, 'queryset', serializer.Meta.model.objects.all())
+ meta = getattr(serializer, 'get_meta', lambda x: serializer.Meta)()
+ return getattr(self, 'queryset', meta.model.objects.all())
def get_request_fields(self):
"""Parses the INCLUDE and EXCLUDE features.
@@ -271,6 +309,16 @@ def is_update(self):
else:
return False
+ def is_list(self):
+ if (
+ self.request and
+ self.request.method.upper() == 'GET' and
+ (self.lookup_url_kwarg or self.lookup_field)
+ not in self.kwargs
+ ):
+ return True
+ return False
+
def is_delete(self):
if (
self.request and
@@ -280,6 +328,11 @@ def is_delete(self):
else:
return False
+ def get_format(self):
+ if self.request and self.request.accepted_renderer:
+ return self.request.accepted_renderer.format
+ return None
+
def get_serializer(self, *args, **kwargs):
if 'request_fields' not in kwargs:
kwargs['request_fields'] = self.get_request_fields()
@@ -291,11 +344,14 @@ def get_serializer(self, *args, **kwargs):
kwargs['envelope'] = True
if self.is_update():
kwargs['include_fields'] = '*'
- return super(
+ if self.is_list():
+ kwargs['many'] = True
+ serializer = super(
WithDynamicViewSetMixin, self
).get_serializer(
*args, **kwargs
)
+ return serializer
def paginate_queryset(self, *args, **kwargs):
if self.PAGE in self.features:
@@ -326,11 +382,9 @@ def _prefix_inex_params(self, request, feature, prefix):
def list_related(self, request, pk=None, field_name=None):
"""Fetch related object(s), as if sideloaded (used to support
link objects).
-
This method gets mapped to `////` by
DynamicRouter for all DynamicRelationField fields. Generally,
this method probably shouldn't be overridden.
-
An alternative implementation would be to generate reverse queries.
For an exploration of that approach, see:
https://gist.github.com/ryochiji/54687d675978c7d96503
@@ -385,12 +439,35 @@ def list_related(self, request, pk=None, field_name=None):
return Response(serializer.data)
+
class DynamicModelViewSet(WithDynamicViewSetMixin, viewsets.ModelViewSet):
ENABLE_BULK_PARTIAL_CREATION = settings.ENABLE_BULK_PARTIAL_CREATION
ENABLE_BULK_UPDATE = settings.ENABLE_BULK_UPDATE
def _get_bulk_payload(self, request):
+ if self._is_csv_upload(request):
+ return self._get_bulk_payload_csv(request)
+ else:
+ return self._get_bulk_payload_json(request)
+
+ def _is_csv_upload(self, request):
+ if is_form_media_type(request.content_type):
+ if (
+ 'file' in request.data and
+ request.data['file'].name.lower().endswith('.csv')
+ ):
+ return True
+ return False
+
+ def _get_bulk_payload_csv(self, request):
+ file = request.data['file']
+ reader = csv.DictReader(
+ StringIO(file.read().decode('utf-8'))
+ )
+ return [r for r in reader]
+
+ def _get_bulk_payload_json(self, request):
plural_name = self.get_serializer_class().get_plural_name()
if isinstance(request.data, list):
return request.data
@@ -455,7 +532,7 @@ def _create_many(self, data):
serializer.is_valid(raise_exception=True)
except exceptions.ValidationError as e:
errors.append({
- 'detail': str(e),
+ 'detail': e.detail,
'source': entry
})
else:
@@ -527,8 +604,13 @@ def create(self, request, *args, **kwargs):
bulk_payload = self._get_bulk_payload(request)
if bulk_payload:
return self._create_many(bulk_payload)
- return super(DynamicModelViewSet, self).create(
+ response = super(DynamicModelViewSet, self).create(
request, *args, **kwargs)
+ serializer = getattr(response.data, 'serializer')
+ if serializer and serializer.instance:
+ url = serializer.get_url(pk=serializer.instance.pk)
+ response['Location'] = url
+ return response
def _destroy_many(self, data):
instances = self.get_queryset().filter(
diff --git a/images/admin-ui.png b/images/admin-ui.png
new file mode 100644
index 00000000..dc4c7ca4
Binary files /dev/null and b/images/admin-ui.png differ
diff --git a/images/benchmark-cubic.png b/images/benchmark-cubic.png
index 2ee2c2c3..d9c87f31 100644
Binary files a/images/benchmark-cubic.png and b/images/benchmark-cubic.png differ
diff --git a/images/benchmark-linear.png b/images/benchmark-linear.png
index b2e076c6..4f576e2e 100644
Binary files a/images/benchmark-linear.png and b/images/benchmark-linear.png differ
diff --git a/images/benchmark-quadratic.png b/images/benchmark-quadratic.png
index 2088bc68..4c7a4ad7 100644
Binary files a/images/benchmark-quadratic.png and b/images/benchmark-quadratic.png differ
diff --git a/install_requires.txt b/install_requires.txt
index 3ccd60fd..dc274787 100644
--- a/install_requires.txt
+++ b/install_requires.txt
@@ -1,4 +1,4 @@
Django>=1.8,<1.12
-djangorestframework>=3.1.0,<3.7
+djangorestframework>=3.1.0,<3.6.4
inflection==0.3.1
requests
diff --git a/requirements.txt b/requirements.txt
index cae3d83e..6f7828e0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,11 +2,12 @@ Sphinx==1.3.4
dj-database-url==0.3.0
django-debug-toolbar==1.7
flake8==2.4.0
+model_mommy
pytest-cov==1.8.1
pytest-django==2.8.0
pytest-sugar==0.5.1
pytest==2.7.2
-psycopg2==2.5.1
+psycopg2>=2.7
tox-pyenv==1.0.2
tox==2.3.1
djay==0.0.4
diff --git a/requirements.txt.dev b/requirements.txt.dev
new file mode 100644
index 00000000..e079f8a6
--- /dev/null
+++ b/requirements.txt.dev
@@ -0,0 +1 @@
+pytest
diff --git a/runtests.py b/runtests.py
index 9efa69f9..1fbbf759 100755
--- a/runtests.py
+++ b/runtests.py
@@ -83,6 +83,13 @@ def is_class(string):
style = 'fast'
run_flake8 = False
+ try:
+ sys.argv.remove('--nosugar')
+ except ValueError:
+ sugar = True
+ else:
+ sugar = False
+
if len(sys.argv) > 1:
pytest_args = sys.argv[1:]
first_arg = pytest_args[0]
@@ -117,6 +124,9 @@ def is_class(string):
pytest_args[0] = BENCHMARKS
pytest_args.append('--ds=%s.settings' % BENCHMARKS)
+ if not sugar:
+ pytest_args.extend(('-p', 'no:sugar'))
+
if run_tests:
exit_on_failure(pytest.main(pytest_args))
diff --git a/setup.py b/setup.py
index 152a8417..40a10f88 100644
--- a/setup.py
+++ b/setup.py
@@ -3,7 +3,7 @@
NAME = 'dynamic-rest'
DESCRIPTION = 'Adds Dynamic API support to Django REST Framework.'
URL = 'http://github.com/AltSchool/dynamic-rest'
-VERSION = '1.6.8'
+VERSION = '2.0.0'
SCRIPTS = ['manage.py']
setup(
diff --git a/tests/groups.csv b/tests/groups.csv
new file mode 100644
index 00000000..721fe5bb
--- /dev/null
+++ b/tests/groups.csv
@@ -0,0 +1,3 @@
+name,random_input
+foo,f
+bar,b
diff --git a/tests/integration/test_blueprints.py b/tests/integration/test_blueprints.py
index c0e57db6..a600e46a 100644
--- a/tests/integration/test_blueprints.py
+++ b/tests/integration/test_blueprints.py
@@ -34,7 +34,7 @@ def test_blueprints(self):
response = requests.post('http://localhost:9123/api/v0/foos/')
self.assertTrue(response.status_code, 201)
content = json.loads(response.content)
- self.assertEquals(content, {'foo': {'id': 1}})
+ self.assertEquals(content['foo']['id'], 1)
# stop the server
server.terminate()
diff --git a/tests/migrations/0006_auto_20170831_1908.py b/tests/migrations/0006_auto_20170831_1908.py
new file mode 100644
index 00000000..eb38887a
--- /dev/null
+++ b/tests/migrations/0006_auto_20170831_1908.py
@@ -0,0 +1,22 @@
+# flake8: noqa
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-08-31 19:08
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tests', '0005_auto_20170712_0759'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='car',
+ name='country',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='tests.Country'),
+ ),
+ ]
diff --git a/tests/models.py b/tests/models.py
index c06b630b..debc1650 100644
--- a/tests/models.py
+++ b/tests/models.py
@@ -19,6 +19,9 @@ class User(models.Model):
)
is_dead = models.NullBooleanField(default=False)
+ def __unicode__(self):
+ return str(self.pk)
+
class Profile(models.Model):
user = models.OneToOneField(User)
@@ -72,6 +75,9 @@ class Location(models.Model):
name = models.TextField()
blob = models.TextField()
+ def __unicode__(self):
+ return str(self.pk)
+
class Event(models.Model):
"""
@@ -108,7 +114,7 @@ class Country(models.Model):
class Car(models.Model):
name = models.CharField(max_length=60)
- country = models.ForeignKey(Country)
+ country = models.ForeignKey(Country, null=True)
class Part(models.Model):
diff --git a/tests/serializers.py b/tests/serializers.py
index 48ebe0e0..2a7711f9 100644
--- a/tests/serializers.py
+++ b/tests/serializers.py
@@ -42,6 +42,7 @@ class CatSerializer(DynamicModelSerializer):
class Meta:
model = Cat
name = 'cat'
+ name_field = 'name'
fields = ('id', 'name', 'home', 'backup_home', 'foobar', 'parent')
deferred_fields = ('home', 'backup_home', 'foobar', 'parent')
immutable_fields = ('name',)
@@ -53,6 +54,7 @@ class LocationSerializer(DynamicModelSerializer):
class Meta:
defer_many_relations = False
model = Location
+ name_field = 'name'
name = 'location'
fields = (
'id', 'name', 'users', 'user_count', 'address',
@@ -80,6 +82,7 @@ class Meta:
defer_many_relations = True
model = Permission
name = 'permission'
+ name_field = 'name'
fields = ('id', 'name', 'code', 'users', 'groups')
deferred_fields = ('code',)
@@ -90,6 +93,7 @@ class Meta:
class GroupSerializer(DynamicModelSerializer):
class Meta:
+ name_field = 'name'
model = Group
name = 'group'
fields = (
@@ -99,33 +103,38 @@ class Meta:
'members',
'users',
'loc1users',
- 'loc1usersLambda'
+ 'loc1usersLambda',
+ 'loc1usersGetter',
)
permissions = DynamicRelationField(
'PermissionSerializer',
many=True,
deferred=True)
+
+ # Infer serializer from source
members = DynamicRelationField(
- 'UserSerializer',
source='users',
many=True,
- deferred=True)
+ deferred=True
+ )
# Intentional duplicate of 'users':
users = DynamicRelationField(
- 'UserSerializer',
many=True,
- deferred=True)
+ deferred=True
+ )
- # For testing default queryset on relations:
+ # Queryset for get filter
loc1users = DynamicRelationField(
'UserSerializer',
source='users',
many=True,
queryset=User.objects.filter(location_id=1),
- deferred=True)
+ deferred=True
+ )
+ # Dynamic queryset through lambda
loc1usersLambda = DynamicRelationField(
'UserSerializer',
source='users',
@@ -133,12 +142,39 @@ class Meta:
queryset=lambda srlzr: User.objects.filter(location_id=1),
deferred=True)
+ # Custom getter/setter
+ loc1usersGetter = DynamicRelationField(
+ 'UserSerializer',
+ source='*',
+ requires=['users.*'],
+ required=False,
+ deferred=True,
+ many=True
+ )
+
+ def get_loc1usersGetter(self, instance):
+ return [u for u in instance.users.all() if u.location_id == 1]
+
+ def set_loc1usersGetter(self, instance, user_ids):
+ users = instance.users.all()
+ user_ids = set(user_ids)
+ for user in users:
+ if user.location_id == 1:
+ if user.id in user_ids:
+ user_ids.remove(user.id)
+ else:
+ instance.users.remove(user)
+ new_users = User.objects.filter(pk__in=user_ids)
+ for user in new_users:
+ instance.users.add(user)
+
class UserSerializer(DynamicModelSerializer):
class Meta:
model = User
name = 'user'
+ name_field = 'name'
fields = (
'id',
'name',
@@ -171,6 +207,7 @@ class Meta:
permissions = DynamicRelationField(
'PermissionSerializer',
many=True,
+ help_text='Permissions for this user',
deferred=True
)
groups = DynamicRelationField('GroupSerializer', many=True, deferred=True)
@@ -201,6 +238,7 @@ class ProfileSerializer(DynamicModelSerializer):
class Meta:
model = Profile
+ name_field = 'display_name'
name = 'profile'
fields = (
'user',
@@ -256,6 +294,8 @@ class DogSerializer(DynamicModelSerializer):
class Meta:
model = Dog
+ name_field = 'name'
+ description = 'Woof woof!'
fields = ('id', 'name', 'origin', 'fur')
fur = CharField(source='fur_color')
@@ -265,6 +305,8 @@ class HorseSerializer(DynamicModelSerializer):
class Meta:
model = Horse
+ fields = '__all__'
+ name_field = 'name'
name = 'horse'
fields = (
'id',
@@ -278,6 +320,7 @@ class ZebraSerializer(DynamicModelSerializer):
class Meta:
model = Zebra
name = 'zebra'
+ name_field = 'zebra'
fields = (
'id',
'name',
@@ -289,6 +332,7 @@ class CountrySerializer(DynamicModelSerializer):
class Meta:
model = Country
+ name_field = 'name'
fields = ('id', 'name', 'short_name')
deferred_fields = ('name', 'short_name')
@@ -298,6 +342,7 @@ class PartSerializer(DynamicModelSerializer):
class Meta:
model = Part
+ name_field = 'name'
fields = ('id', 'name', 'country')
deferred_fields = ('name', 'country')
@@ -305,8 +350,20 @@ class Meta:
class CarSerializer(DynamicModelSerializer):
country = DynamicRelationField('CountrySerializer')
parts = DynamicRelationField('PartSerializer', many=True, source='part_set') # noqa
+ country_name = DynamicField(source='country.name')
+ country_short_name = DynamicField(source='country.short_name')
class Meta:
model = Car
- fields = ('id', 'name', 'country', 'parts')
- deferred_fields = ('name', 'country', 'parts')
+ name_field = 'name'
+ fields = (
+ 'id',
+ 'name',
+ 'country',
+ 'parts',
+ 'country_name',
+ 'country_short_name'
+ )
+ deferred_fields = (
+ 'name', 'country', 'parts', 'country_name', 'country_short_name'
+ )
diff --git a/tests/settings.py b/tests/settings.py
index ef26ae29..64bec2a5 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -47,8 +47,9 @@
'PAGE_SIZE': 50,
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
+ 'dynamic_rest.renderers.DynamicAdminRenderer',
'dynamic_rest.renderers.DynamicBrowsableAPIRenderer'
- )
+ ),
}
ROOT_URLCONF = 'tests.urls'
@@ -68,5 +69,6 @@
DYNAMIC_REST = {
'ENABLE_LINKS': True,
+ 'ENABLE_SELF_LINKS': True,
'DEBUG': os.environ.get('DYNAMIC_REST_DEBUG', 'false').lower() == 'true'
}
diff --git a/tests/setup.py b/tests/setup.py
index c7e69abb..62de7abb 100644
--- a/tests/setup.py
+++ b/tests/setup.py
@@ -206,6 +206,13 @@ def create_fixture():
'id': 1,
'name': 'Porshe',
'country': 1
+ }, {
+ 'id': 2,
+ 'name': 'Forta',
+ 'country': 2
+ }, {
+ 'id': 3,
+ 'name': 'BMW',
}]
parts = [{
diff --git a/tests/test_api.py b/tests/test_api.py
index 976bcb09..387e7aad 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -28,7 +28,9 @@ def setUp(self):
def _get_json(self, url, expected_status=200):
response = self.client.get(url)
- self.assertEquals(expected_status, response.status_code)
+ self.assertEquals(
+ expected_status, response.status_code, response.content
+ )
return json.loads(response.content.decode('utf-8'))
def test_get(self):
@@ -535,6 +537,21 @@ def test_post(self):
}
})
+ def test_post_with_related_setter(self):
+ data = {
+ 'name': 'test',
+ 'loc1usersGetter': [1]
+ }
+ response = self.client.post(
+ '/groups/', json.dumps(data), content_type='application/json'
+ )
+ self.assertEqual(201, response.status_code)
+ content = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(
+ [1],
+ content['group']['loc1usersGetter']
+ )
+
def test_put(self):
group = Group.objects.create(name='test group')
data = {
@@ -544,7 +561,7 @@ def test_put(self):
'/groups/%s/' % group.pk,
json.dumps(data),
content_type='application/json')
- self.assertEquals(200, response.status_code)
+ self.assertEquals(200, response.status_code, response.content)
updated_group = Group.objects.get(pk=group.pk)
self.assertEquals(updated_group.name, data['name'])
@@ -565,6 +582,20 @@ def test_get_with_default_lambda_queryset(self):
content['groups'][0]['loc1usersLambda']
)
+ def test_get_with_related_getter(self):
+ url = '/groups/?filter{id}=1&include[]=loc1usersGetter.location.*'
+ response = self.client.get(url)
+ content = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(200, response.status_code)
+ self.assertEqual(
+ [1, 2],
+ content['groups'][0]['loc1usersGetter']
+ )
+ self.assertEqual(
+ 1,
+ content['locations'][0]['id']
+ )
+
def test_get_with_default_queryset_filtered(self):
"""
Make sure filter can be added to relational fields with default
@@ -876,7 +907,7 @@ def test_options(self):
actual = json.loads(response.content.decode('utf-8'))
expected = {
'description': '',
- 'name': 'Location List',
+ 'name': 'Locations',
'parses': [
'application/json',
'application/x-www-form-urlencoded',
@@ -913,7 +944,7 @@ def test_options(self):
'immutable': False,
'label': 'User count',
'nullable': False,
- 'read_only': False,
+ 'read_only': True,
'required': False,
'type': 'field'
},
@@ -958,7 +989,6 @@ def test_options(self):
'type': 'many'
}
},
- 'renders': ['application/json', 'text/html'],
'resource_name': 'location',
'resource_name_plural': 'locations'
}
@@ -968,6 +998,7 @@ def test_options(self):
for field in ['cats', 'friendly_cats', 'bad_cats', 'users']:
del actual['properties'][field]['nullable']
del expected['properties'][field]['nullable']
+ actual.pop('renders')
actual.pop('features')
self.assertEquals(
json.loads(json.dumps(expected)),
@@ -977,7 +1008,7 @@ def test_options(self):
def test_get_with_filter_by_user(self):
url = '/locations/?filter{users}=1'
response = self.client.get(url)
- self.assertEqual(200, response.status_code)
+ self.assertEqual(200, response.status_code, response.content)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(1, len(content['locations']))
@@ -990,7 +1021,7 @@ def test_get_with_filter_rewrites(self):
]
for url in urls:
response = self.client.get(url)
- self.assertEqual(200, response.status_code)
+ self.assertEqual(200, response.status_code, response.content)
class TestRelationsAPI(APITestCase):
@@ -1007,8 +1038,8 @@ def test_generated_relation_fields(self):
self.assertEqual(404, r.status_code)
r = self.client.get('/users/1/permissions/')
+ self.assertEqual(200, r.status_code, r.content)
self.assertFalse('groups' in r.data['permissions'][0])
- self.assertEqual(200, r.status_code)
r = self.client.get('/users/1/groups/')
self.assertEqual(200, r.status_code)
@@ -1023,21 +1054,21 @@ def test_location_users_relations_identical_to_sideload(self):
r1_data = json.loads(r1.content.decode('utf-8'))
r2 = self.client.get('/locations/1/users/')
- self.assertEqual(200, r2.status_code)
+ self.assertEqual(200, r2.status_code, r2.content)
r2_data = json.loads(r2.content.decode('utf-8'))
self.assertEqual(r2_data['users'], r1_data['users'])
def test_relation_includes(self):
r = self.client.get('/locations/1/users/?include[]=location.')
- self.assertEqual(200, r.status_code)
+ self.assertEqual(200, r.status_code, r.content)
content = json.loads(r.content.decode('utf-8'))
self.assertTrue('locations' in content)
def test_relation_excludes(self):
r = self.client.get('/locations/1/users/?exclude[]=location')
- self.assertEqual(200, r.status_code)
+ self.assertEqual(200, r.status_code, r.content)
content = json.loads(r.content.decode('utf-8'))
self.assertFalse('location' in content['users'][0])
@@ -1180,7 +1211,7 @@ def test_one_to_one_dne(self):
url = '/users/%s/profile/' % user.pk
r = self.client.get(url)
- self.assertEqual(200, r.status_code)
+ self.assertEqual(200, r.status_code, r.content)
# Check error message to differentiate from a routing error 404
content = json.loads(r.content.decode('utf-8'))
self.assertEqual({}, content)
@@ -1262,7 +1293,7 @@ def setUp(self):
self.fixture = create_fixture()
def test_sort(self):
- url = '/dogs/?sort[]=name'
+ url = '/dogs/?sort[]=name&exclude_links'
# 2 queries - one for getting dogs, one for the meta (count)
with self.assertNumQueries(2):
response = self.client.get(url)
@@ -1298,7 +1329,7 @@ def test_sort(self):
self.assertEquals(expected_response, actual_response)
def test_sort_reverse(self):
- url = '/dogs/?sort[]=-name'
+ url = '/dogs/?sort[]=-name&exclude_links'
# 2 queries - one for getting dogs, one for the meta (count)
with self.assertNumQueries(2):
response = self.client.get(url)
@@ -1334,7 +1365,7 @@ def test_sort_reverse(self):
self.assertEquals(expected_response, actual_response)
def test_sort_multiple(self):
- url = '/dogs/?sort[]=-name&sort[]=-origin'
+ url = '/dogs/?sort[]=-name&sort[]=-origin&exclude_links'
# 2 queries - one for getting dogs, one for the meta (count)
with self.assertNumQueries(2):
response = self.client.get(url)
@@ -1370,7 +1401,7 @@ def test_sort_multiple(self):
self.assertEquals(expected_response, actual_response)
def test_sort_rewrite(self):
- url = '/dogs/?sort[]=fur'
+ url = '/dogs/?sort[]=fur&exclude_links'
# 2 queries - one for getting dogs, one for the meta (count)
with self.assertNumQueries(2):
response = self.client.get(url)
@@ -1424,7 +1455,7 @@ def setUp(self):
self.fixture = create_fixture()
def test_sort(self):
- url = '/horses'
+ url = '/horses?exclude_links'
# 1 query - one for getting horses
# (the viewset as features specified, so no meta is returned)
with self.assertNumQueries(1):
@@ -1466,7 +1497,7 @@ def setUp(self):
self.fixture = create_fixture()
def test_sort(self):
- url = '/zebras?sort[]=-name'
+ url = '/zebras?sort[]=-name&exclude_links'
# 1 query - one for getting zebras
# (the viewset as features specified, so no meta is returned)
with self.assertNumQueries(1):
@@ -1489,29 +1520,6 @@ def test_sort(self):
self.assertEquals(expected_response, actual_response)
-class TestBrowsableAPI(APITestCase):
-
- """
- Tests for Browsable API directory
- """
-
- def test_get_root(self):
- response = self.client.get('/?format=api')
- content = response.content.decode('utf-8')
- self.assertIn('directory', content)
- self.assertIn('/horses', content)
- self.assertIn('/zebras', content)
- self.assertIn('/users', content)
-
- def test_get_list(self):
- response = self.client.get('/users/?format=api')
- content = response.content.decode('utf-8')
- self.assertIn('directory', content)
- self.assertIn('/horses', content)
- self.assertIn('/zebras', content)
- self.assertIn('/users', content)
-
-
class TestCatsAPI(APITestCase):
"""
@@ -1597,6 +1605,56 @@ def test_immutable_field(self):
self.assertEqual(data['cat']['parent'], parent_id)
self.assertEqual(data['cat']['name'], kitten_name)
+ def test_filter_relationship_rewrite(self):
+ response = self.client.get(
+ '/cars?filter{country_name.icontains}=Chi&include[]=name'
+ )
+ self.assertEqual(200, response.status_code, response.content)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data['cars'][0]['name'], 'Forta')
+
+ def test_sort_relationship_rewrite(self):
+ response = self.client.get(
+ '/cars?sort[]=-country_name&include[]=name'
+ )
+ self.assertEqual(200, response.status_code, response.content)
+ data = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(data['cars'][0]['name'], 'Porshe')
+
+ def test_update_nested_field(self):
+ patch_data = {
+ 'country_name': 'foobar'
+ }
+ response = self.client.patch(
+ '/cars/1',
+ json.dumps(patch_data),
+ content_type='application/json'
+ )
+ self.assertEqual(200, response.status_code, response.content)
+
+ def test_update_create_nested_data(self):
+ patch_data = {
+ 'country_name': 'Germany',
+ 'country_short_name': None,
+ }
+ response = self.client.patch(
+ '/cars/3',
+ json.dumps(patch_data),
+ content_type='application/json'
+ )
+ # should fail because short name is required
+ self.assertEqual(400, response.status_code, response.content)
+ patch_data['country_short_name'] = 'DE'
+ response = self.client.patch(
+ '/cars/3',
+ json.dumps(patch_data),
+ content_type='application/json'
+ )
+ self.assertEqual(200, response.status_code, response.content)
+ content = json.loads(response.content.decode('utf-8'))
+ self.assertEqual(content['car']['country_short_name'], 'DE')
+ self.assertEqual(content['car']['country_name'], 'Germany')
+
class TestFilters(APITestCase):
diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py
index 84aeba01..2d438a8d 100644
--- a/tests/test_viewsets.py
+++ b/tests/test_viewsets.py
@@ -5,9 +5,9 @@
from rest_framework import exceptions, status
from rest_framework.request import Request
-from dynamic_rest.filters import DynamicFilterBackend, FilterNode
+from dynamic_rest.filters import DynamicFilterBackend
+from dynamic_rest.test import ViewSetTestCase
from tests.models import Dog, Group, User
-from tests.serializers import GroupSerializer
from tests.setup import create_fixture
from tests.viewsets import (
GroupNoMergeDictViewSet,
@@ -15,6 +15,10 @@
)
+class TestViewSetTestCase(ViewSetTestCase):
+ viewset = UserViewSet
+
+
class TestUserViewSet(TestCase):
def setUp(self):
@@ -75,15 +79,15 @@ def test_filter_extraction(self):
backend = DynamicFilterBackend()
out = backend._get_requested_filters(filters_map=filters_map)
- self.assertEqual(out['_include']['attr'].value, 'bar')
- self.assertEqual(out['_include']['attr2'].value, 'bar')
- self.assertEqual(out['_exclude']['attr3'].value, 'bar')
- self.assertEqual(out['rel']['_include']['attr1'].value, 'val')
- self.assertEqual(out['rel']['_exclude']['attr2'].value, 'val')
- self.assertEqual(out['_include']['rel__attr'].value, 'baz')
- self.assertEqual(out['rel']['bar']['_include']['attr'].value, 'val')
- self.assertEqual(out['_include']['attr4__lt'].value, 'val')
- self.assertEqual(len(out['_include']['attr5__in'].value), 3)
+ self.assertEqual(out['_include']['attr'], 'bar')
+ self.assertEqual(out['_include']['attr2'], 'bar')
+ self.assertEqual(out['_exclude']['attr3'], 'bar')
+ self.assertEqual(out['rel']['_include']['attr1'], 'val')
+ self.assertEqual(out['rel']['_exclude']['attr2'], 'val')
+ self.assertEqual(out['_include']['rel__attr'], 'baz')
+ self.assertEqual(out['rel']['bar']['_include']['attr'], 'val')
+ self.assertEqual(out['_include']['attr4__lt'], 'val')
+ self.assertEqual(len(out['_include']['attr5__in']), 3)
def test_is_null_casting(self):
filters_map = {
@@ -104,25 +108,19 @@ def test_is_null_casting(self):
backend = DynamicFilterBackend()
out = backend._get_requested_filters(filters_map=filters_map)
- self.assertEqual(out['_include']['f1__isnull'].value, True)
- self.assertEqual(out['_include']['f2__isnull'].value, ['a'])
- self.assertEqual(out['_include']['f3__isnull'].value, True)
- self.assertEqual(out['_include']['f4__isnull'].value, True)
- self.assertEqual(out['_include']['f5__isnull'].value, 1)
+ self.assertEqual(out['_include']['f1__isnull'], True)
+ self.assertEqual(out['_include']['f2__isnull'], ['a'])
+ self.assertEqual(out['_include']['f3__isnull'], True)
+ self.assertEqual(out['_include']['f4__isnull'], True)
+ self.assertEqual(out['_include']['f5__isnull'], 1)
- self.assertEqual(out['_include']['f6__isnull'].value, False)
- self.assertEqual(out['_include']['f7__isnull'].value, [])
- self.assertEqual(out['_include']['f8__isnull'].value, False)
- self.assertEqual(out['_include']['f9__isnull'].value, False)
- self.assertEqual(out['_include']['f10__isnull'].value, False)
- self.assertEqual(out['_include']['f11__isnull'].value, False)
- self.assertEqual(out['_include']['f12__isnull'].value, None)
-
- def test_nested_filter_rewrite(self):
- node = FilterNode(['members', 'id'], 'in', [1])
- gs = GroupSerializer(include_fields='*')
- filter_key, field = node.generate_query_key(gs)
- self.assertEqual(filter_key, 'users__id__in')
+ self.assertEqual(out['_include']['f6__isnull'], False)
+ self.assertEqual(out['_include']['f7__isnull'], [])
+ self.assertEqual(out['_include']['f8__isnull'], False)
+ self.assertEqual(out['_include']['f9__isnull'], False)
+ self.assertEqual(out['_include']['f10__isnull'], False)
+ self.assertEqual(out['_include']['f11__isnull'], False)
+ self.assertEqual(out['_include']['f12__isnull'], None)
class TestMergeDictConvertsToDict(TestCase):
@@ -224,6 +222,21 @@ def test_post_single(self):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(1, Group.objects.all().count())
+ def test_csv_upload(self):
+ with open('tests/groups.csv', 'rb') as file:
+ response = self.client.post(
+ '/groups/',
+ data={
+ 'file': file
+ }
+ )
+ self.assertEqual(
+ response.status_code,
+ status.HTTP_201_CREATED,
+ response.content
+ )
+ self.assertEqual(2, Group.objects.count())
+
def test_post_bulk_from_resource_plural_name(self):
data = {
'groups': [
diff --git a/tests/urls.py b/tests/urls.py
index be6ea6b9..593bc4cc 100644
--- a/tests/urls.py
+++ b/tests/urls.py
@@ -1,5 +1,5 @@
from django.conf.urls import include, url
-
+from dynamic_rest.views import login, logout
from dynamic_rest.routers import DynamicRouter
from tests import viewsets
@@ -20,6 +20,8 @@
# the above routes are duplicated to test versioned prefixes
router.register_resource(viewsets.CatViewSet, namespace='v2') # canonical
router.register(r'v1/user_locations', viewsets.UserLocationViewSet)
+router.register('login', login)
+router.register('logout', logout)
urlpatterns = [
url(r'^', include(router.urls))
diff --git a/tests/viewsets.py b/tests/viewsets.py
index 3c1300aa..0f9c3ad9 100644
--- a/tests/viewsets.py
+++ b/tests/viewsets.py
@@ -25,10 +25,12 @@
UserLocationSerializer,
UserSerializer,
ZebraSerializer
- )
+)
class UserViewSet(DynamicModelViewSet):
+ """Represents a User"""
+
features = (
DynamicModelViewSet.INCLUDE, DynamicModelViewSet.EXCLUDE,
DynamicModelViewSet.FILTER, DynamicModelViewSet.SORT,
@@ -96,7 +98,7 @@ class LocationViewSet(DynamicModelViewSet):
features = (
DynamicModelViewSet.INCLUDE, DynamicModelViewSet.EXCLUDE,
DynamicModelViewSet.FILTER, DynamicModelViewSet.SORT,
- DynamicModelViewSet.DEBUG, DynamicModelViewSet.SIDELOADING
+ DynamicModelViewSet.DEBUG, DynamicModelViewSet.SIDELOADING,
)
model = Location
serializer_class = LocationSerializer
diff --git a/tox.ini b/tox.ini
index 423ec391..44a81707 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,26 +4,26 @@ addopts=--tb=short
[tox]
envlist =
py27-lint,
- {py27,py33,py34,py35}-django18-drf{31,32,33,34},
+ {py27,py34,py35}-django18-drf{31,32,33,34},
{py27,py34,py35}-django19-drf{32,33,34},
{py27,py34,py35}-django110-drf{34,35,36},
{py27,py34,py35}-django111-drf{34,35,36},
[testenv]
-commands = ./runtests.py --fast {posargs} --coverage -rw
+commands = ./runtests.py --fast {posargs} --coverage -rw --nosugar
setenv =
PYTHONDONTWRITEBYTECODE=1
deps =
- django18: Django==1.8.14
- django19: Django==1.9.9
- django110: Django==1.10.0
- django111: Django==1.11.2
- drf31: djangorestframework==3.1.0
- drf32: djangorestframework==3.2.0
- drf33: djangorestframework==3.3.0
- drf34: djangorestframework==3.4.0
- drf35: djangorestframework==3.5.0
- drf36: djangorestframework==3.6.2
+ django18: Django<1.9,>=1.8
+ django19: Django<1.10,>=1.9
+ django110: Django<1.11,>=1.10
+ django111: Django<1.12,>=1.11
+ drf31: djangorestframework<3.2,>=3.1
+ drf32: djangorestframework<3.3,>=3.2
+ drf33: djangorestframework<3.4,>=3.3
+ drf34: djangorestframework<3.5,>=3.4
+ drf35: djangorestframework<3.6,>=3.5
+ drf36: djangorestframework<3.7>=3.6
-rrequirements.txt
[testenv:py27-lint]
@@ -34,5 +34,5 @@ deps =
[testenv:py27-drf33-benchmarks]
commands = ./runtests.py --benchmarks
deps =
- drf33: djangorestframework==3.3.0
+ drf33: djangorestframework<3.4,>=3.3
-rrequirements.txt