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}'.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 += '
' + $(value).html() + '
'; + } + }); + + $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 %} +
+ {{ alert }} + × +
+ {% endif %} + {% endblock %} + + {% block footer %} + + {% 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 %} + + + + + {% endfor %} + +
+ {% if field and field.help_text %} + {{ field.label }} + {% else %} + {{ field.label }} + {% endif %} + + {{ field | render_field_value }} +
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 %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + {% endfor %} + + + + {% for row in results %} + + {% for key in columns %} + {% get_field_value row.serializer row.instance key as field %} + + {% endfor %} + + {% endfor %} + +
{{ field.label }}{{ field.label }}{{ column }}
{{ field | render_field_value }}
+{% 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 %} + + + + + {% endif %} + {% endfor %} + +
{{ key|capfirst }}
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 @@ +
+ {% if field.label %} + + {% endif %} + +
+ + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+
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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ {% if style.inline %} + {% for key, text in field.choices.items %} + + {% endfor %} + {% else %} + {% for key, text in field.choices.items %} +
+ +
+ {% endfor %} + {% endif %} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+
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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
+ + 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 @@ +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} +
+
+ + 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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ {% if style.inline %} + {% if field.allow_null or field.allow_blank %} + + {% endif %} + + {% for key, text in field.choices.items %} + + {% endfor %} + {% else %} + {% if field.allow_null or field.allow_blank %} +
+ +
+ {% endif %} + {% for key, text in field.choices.items %} +
+ +
+ {% endfor %} + {% endif %} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
+ + 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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
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 %} + +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
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 @@ +
+ {% if field.label %} + + {% endif %} + +
+ + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} + +
+
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 %} +
+
+
+ {% csrf_token %} + + +
+
+ + + {% if form.username.errors %} +

+ {{ form.username.errors|striptags }} +

+ {% endif %} +
+
+ +
+
+ + + {% if form.password.errors %} +

+ {{ form.password.errors|striptags }} +

+ {% endif %} +
+
+ + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + {% endif %} + +
+ +
+
+
+
+{% 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