diff --git a/README.md b/README.md index da9d2020..0bf9c7b6 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,19 @@ [![Build Status](https://travis-ci.org/CodeYellowBV/django-binder.svg?branch=master)](https://travis-ci.org/CodeYellowBV/django-binder) [![codecov](https://codecov.io/gh/CodeYellowBV/django-binder/branch/master/graph/badge.svg)](https://codecov.io/gh/CodeYellowBV/django-binder) -Code Yellow backend framework for SPA webapps with REST-like API. +Code Yellow backend framework for SPA webapps with REST-like API. Dive into the [documentation](docs/api.md) to get started. **This framework is a work-in-progress. There is no complete documentation yet. We are using it for a couple of projects and fine-tuning it.** +## Playing around with the test application + +The `project/` folder contains a test application that shows basic usage of most features. You may play around with it by starting a backend and database service using +``` +docker-compose up +``` +Once the containers have started, you may point your browser at `localhost:8000`, which exposes the Django admin panel at `/admin/` (which you may use to login in) and the Binder api at `/api/`. See [Test Application](docs/test-app.md) for more information. + + ## Running the tests There are two ways to run the tests: diff --git a/binder/views.py b/binder/views.py index c12c1760..83f6aea5 100644 --- a/binder/views.py +++ b/binder/views.py @@ -33,6 +33,8 @@ from .models import FieldFilter, BinderModel, ContextAnnotation, OptionalAnnotation, BinderFileField from .json import JsonResponse, jsonloads +logger = logging.getLogger(__name__) + def split_par_aware(content): start = 0 @@ -129,11 +131,6 @@ def annotate(qs, request=None, annotations=None): return qs - -logger = logging.getLogger(__name__) - - - # Used to truncate request bodies. def ellipsize(msg, length=2048): msglen = len(msg) @@ -156,6 +153,7 @@ def sign(num): RelatedModel = namedtuple('RelatedModel', ['fieldname', 'model', 'reverse_fieldname']) FilterDescription = namedtuple('FilterDescription', ['filter', 'need_distinct']) + # Stolen and improved from https://stackoverflow.com/a/30462851 def image_transpose_exif(im): exif_orientation_tag = 0x0112 # contains an integer, 1 through 8 @@ -216,6 +214,7 @@ def prefix_db_expression(value, prefix): raise ValueError('Unknown expression type, cannot apply db prefix: %s', value) + class ModelView(View): # Model this is a view for. Use None for views not tied to a particular model. model = None @@ -440,7 +439,6 @@ def _model_name(cls): return ''.join((x + '_' if x.islower() and y.isupper() else x.lower() for x, y in zip(mn, mn[1:] + 'x'))) - # Use this to instantiate other views you need. It returns a properly initialized view instance. # Call like: foo_view_instance = self.get_view(FooView) def get_view(self, cls): @@ -449,14 +447,12 @@ def get_view(self, cls): return view - # Use this to instantiate the default view for a specific model class. # Call like: foo_view_instance = self.get_model_view(FooModel) def get_model_view(self, model): return self.get_view(self.router.model_view(model)) - # Return a list of RelatedObjects for all _visible_ reverse relations (from both FKs and m2ms). def _get_reverse_relations(self): return [ @@ -578,7 +574,6 @@ def _get_obj(self, pk, request, include_annotations=None): raise self.model.DoesNotExist() - # Split ['animals(name:contains=lion)'] # in ['animals': ['name:contains=lion']] # { 'animals': {'filters': ['name:contains=lion'], 'subrels': {}}} @@ -617,7 +612,7 @@ def _parse_wheres(self, wheres, withs): # Find which objects of which models to include according to for the objects in . # returns three dictionaries: - # - withs: { related_modal_name: [ids] } + # - withs: { related_model_name: [ids] } # - mappings: { with_name: related_model_name } # - related_name_mappings: { with_name: related_model_reverse_key } # @@ -1047,7 +1042,6 @@ def _parse_filter(self, field, value, request, include_annotations, partial=''): return FilterDescription(q, need_distinct) - def _filter_field(self, field_name, qualifier, value, invert, request, include_annotations, partial=''): try: if field_name in self.hidden_fields: @@ -1084,7 +1078,6 @@ def _filter_field(self, field_name, qualifier, value, invert, request, include_a .format(field.__class__.__name__, self.model.__name__, field_name)) - def _parse_order_by(self, queryset, field, request, partial=''): head, *tail = field.split('.') @@ -1114,7 +1107,6 @@ def _parse_order_by(self, queryset, field, request, partial=''): return (queryset, partial + head, nulls_last) - def search(self, queryset, search, request): if not search: return queryset @@ -1156,7 +1148,6 @@ def filter_deleted(self, queryset, pk, deleted, request): raise BinderRequestError('Invalid value: deleted={{{}}}.'.format(request.GET.get('deleted'))) - def _paginate(self, queryset, request): limit = self.limit_default if request.GET.get('limit') == 'none': @@ -1186,12 +1177,10 @@ def _paginate(self, queryset, request): return queryset - def get_queryset(self, request): return self.model.objects.all() - def order_by(self, queryset, request): #### order_by order_bys = list(filter(None, request.GET.get('order_by', '').split(','))) @@ -1348,7 +1337,6 @@ def _sanity_check_meta_results(self, request, response_data): logger.error('Detected anomalous total record count versus data response length. Please check if there are any scopes returning Q() objects which follow one-to-many links!') - def binder_validation_error(self, obj, validation_error, pk=None): model_name = self.get_model_view(obj.__class__)._model_name() @@ -1432,7 +1420,7 @@ def store_m2m_field(obj, field, value, request): if validation_errors: raise sum(validation_errors, None) - # Skip re-fetch and serialization via get_objs if we're in + # Skip re-fetch and serialization via _get_objs if we're in # multi-put (data is discarded!). if getattr(request, '_is_multi_put', False): return None @@ -1448,7 +1436,6 @@ def store_m2m_field(obj, field, value, request): return data - # NOTE: This is misnamed because it also stores the reverse side # of OneToOne fields. def _store_m2m_field(self, obj, field, value, request): @@ -1534,15 +1521,12 @@ def _store_m2m_field(self, obj, field, value, request): raise sum(validation_errors, None) - - # Override _store_field example for a "FOO" field # Try to override setters using these methods, if at all possible. # def _store__FOO(self, obj, field, value, request): # return self._store_field(obj, field, value, request) - # Store on . # If the field is a m2m, it should do all validation and then return a list of ids # which will be actually set when the object is known to be saved. @@ -1689,7 +1673,6 @@ def _store_field(self, obj, field, value, request, pk=None): raise BinderInvalidField(self.model.__name__, field) - def _require_model_perm(self, perm_type, request, pk=None): if hasattr(self, 'perms_via'): model = self.perms_via @@ -1706,7 +1689,6 @@ def _require_model_perm(self, perm_type, request, pk=None): logger.debug('passed permission check: {}'.format(perm)) - def _obj_diff(self, old, new, name): if isinstance(old, dict) and isinstance(new, dict): changes = [] @@ -1735,7 +1717,6 @@ def _obj_diff(self, old, new, name): return [] - # Put data and with on one big pile, that's easier for us def _multi_put_parse_request(self, request): body = jsonloads(request.body) @@ -1769,7 +1750,6 @@ def _multi_put_parse_request(self, request): return data, deletions - # Sort object values by model/id def _multi_put_collect_objects(self, data): objects = {} @@ -1874,7 +1854,6 @@ def _multi_put_convert_backref_to_forwardref(self, objects): return objects - def _multi_put_calculate_dependencies(self, objects): logger.info('Resolving dependencies for {} objects'.format(len(objects))) dependencies = {} @@ -1912,7 +1891,6 @@ def _multi_put_calculate_dependencies(self, objects): return dependencies - # Actually sort the objects by dependency (and within dependency layer by model/id) def _multi_put_order_dependencies(self, dependencies): ordered_objects = [] @@ -1940,7 +1918,6 @@ def _multi_put_order_dependencies(self, dependencies): return ordered_objects - def _multi_put_save_objects(self, ordered_objects, objects, request): new_id_map = {} validation_errors = [] @@ -2073,7 +2050,7 @@ def _multi_put_deletions(self, deletions, new_id_map, request): def multi_put(self, request): - logger.info('ACTIVATING THE MULTI-PUT!!!1!') + logger.info('ACTIVATING THE MULTI-PUT!!!!!') # Hack to communicate to _store() that we're not interested in # the new data (for perf reasons). @@ -2095,9 +2072,11 @@ def multi_put(self, request): return JsonResponse({'idmap': output}) + def _get_request_values(self, request): return jsonloads(request.body) + def put(self, request, pk=None): if pk is None: return self.multi_put(request) @@ -2137,12 +2116,10 @@ def put(self, request, pk=None): return JsonResponse(data) - def patch(self, request, pk=None): return self.put(request, pk) - def post(self, request, pk=None): self._require_model_perm('add', request) @@ -2167,7 +2144,6 @@ def post(self, request, pk=None): return JsonResponse(data) - def delete(self, request, pk=None, undelete=False, skip_body_check=False): if not undelete: self._require_model_perm('delete', request) @@ -2195,12 +2171,10 @@ def delete(self, request, pk=None, undelete=False, skip_body_check=False): return HttpResponse(status=204) # No content - def delete_obj(self, obj, undelete, request): return self.soft_delete(obj, undelete, request) - def soft_delete(self, obj, undelete, request): # Not only for soft delets, actually handles all deletions try: @@ -2238,7 +2212,6 @@ def soft_delete(self, obj, undelete, request): raise self.binder_validation_error(obj, ve) - def dispatch_file_field(self, request, pk=None, file_field=None): if not request.method in ('GET', 'POST', 'DELETE'): raise BinderMethodNotAllowed() @@ -2417,7 +2390,6 @@ def dispatch_file_field(self, request, pk=None, file_field=None): return JsonResponse( {"data": {file_field_name: None}} ) - def filefield_get_name(self, instance=None, request=None, file_field=None): try: method = getattr(self, 'filefield_get_name_' + file_field.field.name) @@ -2426,7 +2398,6 @@ def filefield_get_name(self, instance=None, request=None, file_field=None): return method(instance=instance, request=request, file_field=file_field) - def view_history(self, request, pk=None, **kwargs): if request.method != 'GET': raise BinderMethodNotAllowed() @@ -2454,7 +2425,6 @@ def api_catchall(request): return e.response(request=request) - def debug_changesets_24h(request): if request.method != 'GET': raise BinderMethodNotAllowed() @@ -2471,7 +2441,6 @@ def debug_changesets_24h(request): return history.view_changesets_debug(request, changesets.order_by('-id')) - def handler500(request): try: request_id = request.request_id diff --git a/docs/api.md b/docs/api.md index 31b0cbf1..ad3038d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,10 +1,10 @@ # API -Binder automatically exposes a fairly powerful API for all your models. +Binder automatically exposes a fairly powerful API for all registered models. You may want to use the [test application](./test-app.md) for trying out the below described features. -## Registering an API endpoint +## Registering API endpoints -We’ll use this example model, added in `models.py`. +In order to illustrate the API, we’ll use the following minimal set of models (similar to the models found in the test application): ```python from binder.models import BinderModel @@ -12,33 +12,43 @@ from django.db import models class Animal(BinderModel): name = models.TextField() + zoo = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='animals', blank=True, null=True) + +class Zoo(BinderModel): + name = models.TextField() + contacts = models.ManyToManyField('ContactPerson', blank=True, related_name='zoos') + +class ContactPerson(BinderModel): + name = models.CharField(unique=True, max_length=50) ``` -In `views.py`, add the following: +Each model is registered as a separate API endpoint by defining a `ModelView` for it: ```python from binder.views import ModelView - from .models import Animal - class AnimalView(ModelView): model = Animal -``` -And that’s it! - -## Using an API endpoint +class ZooView(ModelView): + model = Zoo + +class ContactPersonView(ModelView): + model = ContactPerson +``` -After registering the model, a couple of new routes are at your disposal: +After registering the models, a couple of routes is immediately available for each of them: - `GET api/animal/` - view collection of models - `GET api/animal/[id]/` - view a specific model - `POST api/animal/` - create a new model -- `PUT api/animal/` - create or update (nested) models - `PUT api/animal/[id]/` - update a specific model +- `PUT api/animal/` - create, update or delete multiple models at once ("Multi PUT") - `DELETE api/animal/[id]/` - delete a specific model -- `POST api/animal/[id]/` - undelete a specific model +- `POST api/animal/[id]/` - undelete a specific "soft-deleted" (see below) model + +## Viewing data ### Filtering on the collection @@ -50,7 +60,6 @@ To use a partial case-insensitive match, you can use `api/animal?.name:icontains Note that currently, it is not possible to search on many-to-many fields. #### More advanced searching - Sometimes you want to search on multiple fields at once. ```python @@ -66,10 +75,78 @@ Ordering is a simple matter of enumerating the fields in the `order_by` query pa The default sort order is ascending. If you want to sort in descending order, simply prefix the attribute name with a minus sign. This honors the scoping, so `api/animal?order_by=-name,id` will sort by `name` in descending order and by `id` in ascending order. +### Fetching related resources (aka compound documents) +When fetching an object that has relations, it is very convenient to receive these related models in the same response. Related resources may be requested by specifying a list of model types in the `with` query parameter using "dotted relationship" notation. Consider the following example: an animal belongs to at most one zoo, which may have multiple contact persons. Suppose that we want to list all animals with their zoo and all contact persons for each zoo, then we would make the request + +`api/animal/?with=zoo.contacts` + +which produces the following response (some fields are left out for clarity) -### Saving a model +```json5 +{ + "data": [ + { + "name": "Scooby Doo", + "id": 1, + "zoo": 1, + + // ... + + } + ], + "with": { + "zoo": [ + { + "name": "Dierentuin", + "id": 1, + "contacts": [ + 1 + ], + + // ... + + } + ], + "contact_person": [ + { + "name": "Tom", + "id": 1, + "zoos": [ + 1 + ], + + // ... + + } + ] + }, + "with_mapping": { + "zoo": "zoo", + "zoo.contacts": "contact_person" + }, + "with_related_name_mapping": { + "zoo": "animals", + "zoo.contacts": "zoos" + }, + + // ... + +} +``` + +We note that there is currently only one animal (Scooby Doo) in our database. The `with` clause includes a list of related objects per related object type (`related_model_name`), which includes the zoo that the animal belongs to and a list of contact persons that belong to that zoo. The translation between the dotted relationship and the actual name of the related model is given in the `with_mapping` entry. The `with_related_name_mapping` entry gives backwards relationship name (`related_name`) that belongs to the last part of each dotted relationship. So for example, we see that in `zoo.contacts`, the `related_name` for the last `contacts` many-to-many relation is `zoos`. To summarize: + +- `withs: { related model name: [ids] }` +- `mappings: { dotted relationship: related model name }` +- `related_name_mappings: { dotted relationship: related model reverse key }` -Creating a new model is possible with `POST api/animal/`, and updating a model with `PUT api/animal/`. Both requests accept a JSON body, like this: +Note that the `with` query parameter is heavily used by [mobx-spine](https://github.com/CodeYellowBV/mobx-spine). For some more background we refer to the `json-api` specification of [Compound Documents](https://jsonapi.org/format/#document-compound-documents), which is related to our implementation. + + +## Writing data + +### Creating and updating a single object +Creating a new object is possible with `POST api/animal/`, and updating an object with `PUT api/animal/[id]`. Both requests accept a JSON body, like this: ```json { @@ -89,7 +166,7 @@ If the request succeeds, it will return a `200` response, with a JSON body: } ``` -If you leave the `name` field blank, and `blank=True` is not set on the field, this will result in a response with status `400`; +If the request did not pass validation, errors are included in the response, grouped by field name. For example, if you leave the `name` field blank, and `blank=True` is not set on the field, this will result in a response with status `400`: ```json { @@ -106,17 +183,16 @@ If you leave the `name` field blank, and `blank=True` is not set on the field, t } ``` -#### Multi PUT +### Creating and updating multiple objects using Multi PUT +Instead of having to make separate requests for each model type, it is common practice to group operations on a bunch of (possibly related) objects together in a single request. For some additional background on this technique, we refer to the `json-api` specification of [Atomic Operations](https://jsonapi.org/ext/atomic), to which our specification of Multi PUT is somewhat related. -For models with relations, you often don't want to make a separate request to save each model. Multi PUT makes it easy to save related models in one request. - -Imagine that the `Animal` model from above is linked to a `Zoo` model; +Remember that the `Animal` model that we defined is linked to the `Zoo` model by: ```python -zoo = models.ForeignKey(Zoo, on_delete=models.CASCADE, related_name='+') +zoo = models.ForeignKey('Zoo', on_delete=models.CASCADE, related_name='animals', blank=True, null=True) ``` -Now you can create a new animal and zoo in one request to `PUT api/animal/`; +Now you can create objects for a zoo housing two animals in one request to `PUT api/animal/`: ```json { @@ -138,11 +214,11 @@ Now you can create a new animal and zoo in one request to `PUT api/animal/`; } ``` -The negative `id` indicates that it is made up. Because those models are not created yet, they don't have an `id`. By using a "fake" `id`, it is possible to reference a model in another model. +The negative `id` indicates that it is made up. Because those objects are not created yet, they don't have an `id`. By using a "fake" `id`, it is possible to reference an object in another object. The fake `id` has to be unique per model type. So you can use `-1` once for `Animal`, and once for `Zoo`. The backend does not care what number you use exactly, as long as it is negative. -If this request succeeds, you'll get back a mapping of the fake ids and the real ones; +If this request succeeds, you'll get back a mapping from the fake ids to the real ones: ```json { @@ -159,7 +235,63 @@ If this request succeeds, you'll get back a mapping of the fake ids and the real } ``` -It is also possible to update existing models with multi PUT. If you use a "real" id instead of a fake one, the model will be updated instead of created. +It is also possible to update existing models with Multi PUT. If you use a "real" id instead of a fake one, the model will be updated instead of created. + +It is also possible to delete existing models with Multi PUT by providing a `deletions` list containing the ids of the models that need to be deleted. It is also possible to remove related models by specifying a list of ids for each related model type in the `with_deletions` dictionary. For example, we may make the following request to the endpoint for `animal`, which deletes three animals, one zoo and two care takers: + +```json +{ + "deletions": [ + 1, + 2, + 3 + ], + "with_deletions": { + "zoo": [ + 1, + ], + "care_taker": [ + 2, + 4 + ] + } +} +``` + + +### Updating relationship fields +Updating relations is rather straightforward either using a direct PUT request or a Multi PUT request. Set the foreign key field to the id of the object you want to link to or include the id in the list in case of a reverse foreign key or many-to-many relation. When leaving out an id in the list of related object ids, you are effectively "unlinking" the object, which also triggers the action as defined by the `on_delete` setting (CASCADE, PROTECT, SET_NULL). + +We will now shortly illustrate how to update foreign keys (one-to-many) in both directions and many-to-many relations. Suppose that we have the following objects and relationships: two animals (1 and 2), one zoo (1) that houses both animals and two contact persons (1 and 2) that are both affiliated to the zoo. The following API calls may be made using curl, see [Test Application](test-app.md). You may verify their effects using the Django admin panels. + +**Forward foreign key.** +Let's unlink Animal 2 from the zoo: +```json +PUT api/animal/2/ +{ "zoo": null } +``` + +**Reverse foreign key.** +Now let's add Animal 2 back to the zoo and at the same time unlink Animal 1 by updating the `related_name` field on the zoo: +```json +PUT api/zoo/1/ +{ "animals": [ 2 ] } +``` + +**M2M zoos.** +Let's unlink Contact Person 1 from the zoo: +```json +PUT api/contact_person/1/ +{ "zoos": [ ] } +``` + +**M2M contacts.** +Let's now swap Contact Person 1 back in and remove Contact Person 2 from the zoo: +```json +PUT api/zoo/1/ +{ "contacts": [ 2 ] } +``` + ### Uploading files @@ -175,9 +307,9 @@ Then, to upload the file, do a `POST api////` with t To retrieve the file, do `GET api////` -TODO: +**TODO** - permissions --- change permission model + - change permission model ## Hacking the API @@ -277,7 +409,7 @@ class FooView(BaseView): -TODO: +**TODO** - how to add custom saving logic - how to add custom viewing logic diff --git a/docs/images/django-admin.png b/docs/images/django-admin.png new file mode 100644 index 00000000..716c3502 Binary files /dev/null and b/docs/images/django-admin.png differ diff --git a/docs/permissions.md b/docs/permissions.md index 63bc6bf0..84e7342b 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -5,11 +5,11 @@ To install and use the permission system do the following: - First create a file `permissions.py` in the main package. This will define the permissions in the system - Add the following content to the `permissions.py` -``` +```python permissions = { 'default': [ # Default permissions everybody has ('auth.view_user', 'own'), - ('auth.unmasquerade_user', None), # If you are masquarade, the user must be able to unmasquarade + ('auth.unmasquerade_user', None), # If you are masquerade, the user must be able to unmasquerade ('auth.login_user', None), ('auth.logout_user', None), ], @@ -17,7 +17,7 @@ permissions = { ``` This will allow everybody to login, logout, unmasquerade, and view their own user account. - Add the following to settings.py -``` +```python from .permissions import permissions BINDER_PERMISSION = permissions ``` diff --git a/docs/test-app.md b/docs/test-app.md new file mode 100644 index 00000000..89919c3f --- /dev/null +++ b/docs/test-app.md @@ -0,0 +1,20 @@ +# Test Application + +In order to test the implementation and to provide a minimal working example on how to use Binder, a demo application for administring a zoo is included in the `project/` folder. Note that this folder contains a symlink to the `testapp` folder that is used for unit testing Binder. + +Start the application by running `docker-compose up` from the `project/` folder. The default Django admin panel is enabled, so you can easily create models using the web interface, see image below. However, feel free to dive into the terminal and use `curl`, see below. + +![admin interface in test application](images/django-admin.png) + +You may need to create a superuser using the Django's command line utility, which is accessible by executing + +`docker-compose exec web ./manage.py createsuperuser` + +from the `project/` directory. + +## Using `curl` +You can hit the api directly using `curl`. A simple PUT request can for example be made using + +`curl -b 'sessionid=' -d @animal.json -X PUT localhost:8000/api/animal/1/` + +Log in to the Django admin via a browser to obtain the `sessionid` cookie, which you may copy from the developer tools. You may want to use the flag `-s` ('silent') to hide the progress bar of `curl`. diff --git a/docs/test_app.md b/docs/test_app.md deleted file mode 100644 index c236f152..00000000 --- a/docs/test_app.md +++ /dev/null @@ -1,18 +0,0 @@ -# Using the test project - -Binder comes with a test-project that allows developers to play with -the API, try things out, etc. To get it running: - - - `cd django-binder` - - Ensure you have a virtualenv (`virtualenv --python=python3 venv`). - - Ensure the dependencies are installed (`pip install -Ur project/packages.pip`, note `psycopg2`!). - - `cd project` - - Create a postgres DB for the project (`createdb binder`) - - The testapp doesn't have migrations, you'll need to make them (`./manage.py makemigrations testapp`) - - And apply them (`./manage.py migrate`) - - You'll need a user (`./manage.py createsuperuser`) - - And then you can play with the project (`./manage.py runserver localhost:8010`) - -A note about migrations: when the models in `tests/testapp` have changed, you are responsible for making and applying the migrations. -We don't commit the migrations to the repo. -If you're in a mess, `dropdb binder; createdb binder` and recreate the migrations. diff --git a/project/Dockerfile b/project/Dockerfile new file mode 100644 index 00000000..309772d5 --- /dev/null +++ b/project/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3 +ENV PYTHONUNBUFFERED 1 +WORKDIR /code +COPY packages.pip /code/ +RUN pip install -r packages.pip +COPY . /code/ diff --git a/project/docker-compose.yml b/project/docker-compose.yml new file mode 100644 index 00000000..f2156f8f --- /dev/null +++ b/project/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3' + +services: + + db: + image: postgres:11.5 + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/code + - ./binder:/code/binder + - ./testapp:/code/testapp + ports: + - "8000:8000" + depends_on: + - db diff --git a/project/project/settings.py b/project/project/settings.py index c9e56f70..b0888f1d 100644 --- a/project/project/settings.py +++ b/project/project/settings.py @@ -83,9 +83,13 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'binder', - } + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'postgres', + 'USER': 'postgres', + 'PASSWORD': 'postgres', + 'HOST': 'db', + 'PORT': 5432, +} }