From f3a43de9ef0a8cfab8035080a5dd72e7a3b439cf Mon Sep 17 00:00:00 2001 From: Daniel Cortes Date: Sat, 4 Jul 2020 00:39:36 -0400 Subject: [PATCH] Issue #9 y #13 API basica de lista --- lists/forms.py | 9 + lists/migrations/0001_initial.py | 76 ++++++++ lists/models.py | 6 +- lists/test.py | 138 ++++++++++++++ lists/urls.py | 9 + lists/views.py | 193 ++++++++++++++++++++ musiclist/settings/__init__.py | 1 + musiclist/urls.py | 1 + pre-commit.sh | 9 - setup.cfg | 2 +- users/migrations/0002_auto_20200628_1836.py | 18 ++ 11 files changed, 449 insertions(+), 13 deletions(-) create mode 100644 lists/forms.py create mode 100644 lists/migrations/0001_initial.py create mode 100644 lists/test.py create mode 100644 lists/urls.py create mode 100644 lists/views.py create mode 100644 users/migrations/0002_auto_20200628_1836.py diff --git a/lists/forms.py b/lists/forms.py new file mode 100644 index 0000000..f941b12 --- /dev/null +++ b/lists/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from lists.models import ListItem + + +class ListItemForm(forms.ModelForm): + class Meta: + model = ListItem + fields = ['entity', 'tags'] diff --git a/lists/migrations/0001_initial.py b/lists/migrations/0001_initial.py new file mode 100644 index 0000000..bd9c4ae --- /dev/null +++ b/lists/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 3.0.7 on 2020-06-28 22:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Entity', + fields=[ + ('mbid', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('entity_type', models.CharField(choices=[('artist', 'Artista'), ('release-group', 'Grupo de Lanzamientos'), ('release', 'Lanzamiento'), ('recording', 'Grabación')], max_length=25)), + ], + ), + migrations.CreateModel( + name='ListItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lists.Entity')), + ], + ), + migrations.CreateModel( + name='Opinion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('opinion_text', models.TextField()), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lists.ListItem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Stars', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField()), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lists.ListItem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='OpinionHelpful', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vote', models.CharField(choices=[('Y', 'Si'), ('N', 'No'), ('F', 'Divertida')], max_length=1)), + ('opinion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lists.Opinion')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='listitem', + name='tags', + field=models.ManyToManyField(to='lists.Tag'), + ), + migrations.AddField( + model_name='listitem', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='list', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/lists/models.py b/lists/models.py index 040fdad..0004675 100644 --- a/lists/models.py +++ b/lists/models.py @@ -23,15 +23,15 @@ class Entity(Model): class Tag(Model): """Tag creada por un usuario""" - user = ForeignKey(User, on_delete=CASCADE) + user = ForeignKey(User, on_delete=CASCADE, related_name='tags') name = CharField(max_length=50) class ListItem(Model): """Item de la lista de un usuario""" - user = ForeignKey(User, on_delete=CASCADE) + user = ForeignKey(User, on_delete=CASCADE, related_name='list') entity = ForeignKey(Entity, on_delete=CASCADE) - tags = ManyToManyField(Tag, on_delete=CASCADE) + tags = ManyToManyField(Tag) class Stars(Model): diff --git a/lists/test.py b/lists/test.py new file mode 100644 index 0000000..43a036f --- /dev/null +++ b/lists/test.py @@ -0,0 +1,138 @@ +import json +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from lists.models import ListItem, Entity, Tag +from users.models import User + + +class TestList(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='user', email='email@email.com', password='superpassword') + self.other = User.objects.create_user(username='other', email='email@email.com', password='superpassword') + + self.entities = [ + Entity.objects.create(mbid='a', entity_type='artist'), + Entity.objects.create(mbid='b', entity_type='release-group'), + Entity.objects.create(mbid='c', entity_type='recording'), + ] + + self.tags = [ + Tag.objects.create(user=self.user, name='1'), + Tag.objects.create(user=self.user, name='2'), + Tag.objects.create(user=self.user, name='3') + ] + + for tag in self.tags: + self.user.tags.add(tag) + + list_item = ListItem.objects.create(user_id=self.user.id, entity=self.entities[0]) + list_item.tags.set(self.tags) + self.user.list.add(list_item) + + tomorrow = timezone.now() + timedelta(1) + self.user_token = self.user.oauth2_provider_accesstoken.create(expires=tomorrow, token='usertoken') + self.other_token = self.other.oauth2_provider_accesstoken.create(expires=tomorrow, token='ohnotoken') + + def _user_bearer_token(self): + return f'Bearer {self.user_token.token}' + + def _other_bearer_token(self): + return f'Bearer {self.other_token.token}' + + def test_get_list(self): + response = self.client.get('/api/lists/list/1', follow=True) + + self.assertEqual(response.status_code, 200) + self.assertTrue('list' in response.json()) + + for list_item in response.json()['list']: + self.assertTrue('entity' in list_item) + self.assertTrue('tags' in list_item) + + def test_get_list_user_doesnt_exists(self): + response = self.client.get('/api/lists/list/3', follow=True) + self.assertEqual(response.status_code, 404) + + def test_add_to_list_is_protected(self): + response = self.client.post('/api/lists/list/1/') + self.assertEqual(response.status_code, 403) + + response = self.client.post('/api/lists/list/1/', + HTTP_AUTHORIZATION=self._other_bearer_token()) + self.assertEqual(response.status_code, 403) + + def test_add_to_list(self): + to_add = { + 'entity': 'b', + 'tags': ['1', '2'] + } + + response = self.client.post('/api/lists/list/1/', json.dumps(to_add), + content_type='application/json', + HTTP_AUTHORIZATION=self._user_bearer_token()) + + self.assertEqual(response.status_code, 200) + + list_item = ListItem.objects.filter(pk=2) + + self.assertEqual(list_item.count(), 1) + + list_item = list_item[0] + + self.assertEqual(list_item.user_id, self.user.id) + self.assertEqual(list_item.entity_id, to_add['entity']) + self.assertEqual(list_item.tags.count(), len(to_add['tags'])) + + def test_delete_list_item_is_protected(self): + response = self.client.delete('/api/lists/list/1/1/') + self.assertEqual(response.status_code, 403) + + response = self.client.delete('/api/lists/list/1/1/', + HTTP_AUTHORIZATION=self._other_bearer_token()) + self.assertEqual(response.status_code, 403) + + def test_delete_list_item(self): + self.assertEqual(ListItem.objects.filter(pk=1).count(), 1) + + response = self.client.delete('/api/lists/list/1/1/', + HTTP_AUTHORIZATION=self._user_bearer_token()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(ListItem.objects.filter(pk=1).count(), 0) + + def test_get_list_item(self): + response = self.client.get('/api/lists/list/1/1', follow=True) + + self.assertEqual(response.status_code, 200) + + list_item = response.json() + self.assertEqual(list_item['entity']['mbid'], 'a') + self.assertEqual(list_item['entity']['type'], 'artist') + self.assertEqual(len(list_item['tags']), 3) + + def test_update_list_item_is_protected(self): + response = self.client.put('/api/lists/list/1/1/') + self.assertEqual(response.status_code, 403) + + response = self.client.put('/api/lists/list/1/1/', + HTTP_AUTHORIZATION=self._other_bearer_token()) + self.assertEqual(response.status_code, 403) + + def test_update_list_item(self): + self.assertEqual(ListItem.objects.get(pk=1).tags.count(), 3) + + to_update = { + 'entity': 'a', + 'tags': ['1', '2'] + } + + response = self.client.put('/api/lists/list/1/1/', json.dumps(to_update), + content_type='application/json', + HTTP_AUTHORIZATION=self._user_bearer_token()) + + self.assertEqual(response.status_code, 200) + + self.assertEqual(ListItem.objects.get(pk=1).tags.count(), 2) diff --git a/lists/urls.py b/lists/urls.py new file mode 100644 index 0000000..2ce24bc --- /dev/null +++ b/lists/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from lists import views + +urlpatterns = [ + path('list//', views.list_view), + path('list///', views.list_item_view), + path('tag//', views.tag_view) +] diff --git a/lists/views.py b/lists/views.py new file mode 100644 index 0000000..c354a5c --- /dev/null +++ b/lists/views.py @@ -0,0 +1,193 @@ +import json +from json.decoder import JSONDecodeError + +from django.http import JsonResponse +from oauth2_provider.decorators import protected_resource + +from lists.forms import ListItemForm +from lists.models import ListItem +from users.models import User + + +def list_view(request, user_id): + """ + Punto de entrada de las vistas de lista de un usuario + """ + + # Tiene que existir un usuario con el id entregado. + user = User.objects.filter(pk=user_id) + if user.count() != 1: + return JsonResponse({'status': 404, 'error': f'No existe un usuario con id {user_id}'}, status=404) + user = user[0] + + if request.method == 'GET': + return _get_list(request, user) + elif request.method == 'POST': + return _add_to_list(request, user) + else: + return JsonResponse({'status': 404, 'error': 'La ruta no existe'}, status=404) + + +def list_item_view(request, user_id, list_item_id): + """ + Punto de entrada a las vistas de item de lista de un usuario + """ + + # Tiene que existir un usuario con el id entregado. + user = User.objects.filter(pk=user_id) + if user.count() != 1: + return JsonResponse({'status': 404, 'error': f'No existe un usuario con id {user_id}'}, status=404) + user = user[0] + + # Tiene que existir un item de lista de usuario con el id entregado + list_item = ListItem.objects.filter(id=list_item_id, user_id=user_id) + if list_item.count() == 0: + return JsonResponse({'status': 404, 'error': 'No existe el list_item'}, status=404) + list_item = list_item[0] + + if request.method == 'GET': + return _get_list_item(request, user, list_item) + if request.method == 'PUT': + return _update_list_item(request, user, list_item) + elif request.method == 'DELETE': + return _remove_list_item(request, user, list_item) + else: + return JsonResponse({'status': 404, 'error': 'La ruta no existe'}, status=404) + + +def tag_view(request, user_id): + """ + Punto de entrada para las vistas de tags + """ + + # Tiene que existir un usuario con el id entregado. + user = User.objects.filter(pk=user_id) + if user.count() != 1: + return JsonResponse({'status': 404, 'error': f'No existe un usuario con id {user_id}'}, status=404) + user = user[0] + + if request.method == 'GET': + return _get_tags(request, user) + elif request.method == 'POST': + return _post_tag(request, user) + else: + return JsonResponse({'status': 404, 'error': 'La ruta no existe'}, status=404) + + +def _get_list(request, user): + """Retorna la lista de un usuario""" + + encoded_list = [] + for list_item in user.list.all(): + encoded_list.append({ + 'entity': { + 'mbid': list_item.entity.mbid, + 'type': list_item.entity.entity_type, + }, + 'tags': [{ + 'id': tag.id, + 'name': tag.name + } for tag in list_item.tags.all()] + }) + + return JsonResponse({'list': encoded_list}) + + +@protected_resource() +def _add_to_list(request, user): + """Agrega un nuevo item a la lista del usuario""" + + # Solo el dueño o un administrador puede modificar la lista + if request.user.id != user.id and not request.user.is_admin: + return JsonResponse({'status': 403, + 'error': 'El usuario no tiene permiso para hacer esta acción'}, + status=403) + + try: + request_data = json.loads(request.body.decode('utf8')) + except JSONDecodeError: + return JsonResponse({'status': 400, + 'error': 'El body de la request no es json valido'}, + status=400) + + list_item = ListItem(user=user) + + form = ListItemForm(request_data, instance=list_item) + + if not form.is_valid(): + return JsonResponse({'status': 400, 'error': form.errors.as_json()}, status=400) + + if user.list.filter(entity__mbid=request_data['entity']).count() > 0: + return JsonResponse({'status': 400, + 'error': 'El usuario ya tiene esta entidad en su lista'}, + status=400) + + form.save() + + return JsonResponse({'status': 200}) + + +def _get_list_item(request, user, list_item): + """Obtiene un item de la lista del usuario""" + + encoded_list_item = { + 'entity': { + 'mbid': list_item.entity.mbid, + 'type': list_item.entity.entity_type, + }, + 'tags': [{ + 'id': tag.id, + 'name': tag.name + } for tag in list_item.tags.all()] + } + + return JsonResponse(encoded_list_item) + + +@protected_resource() +def _update_list_item(request, user, list_item): + # Solo el dueño o un administrador puede modificar la lista + if request.user.id != user.id and not request.user.is_admin: + return JsonResponse({'status': 403, + 'error': 'El usuario no tiene permiso para hacer esta acción'}, + status=403) + + try: + request_data = json.loads(request.body.decode('utf8')) + except JSONDecodeError: + return JsonResponse({'status': 400, + 'error': 'El body de la request no es json valido'}, + status=400) + + form = ListItemForm(request_data, instance=list_item) + + if not form.is_valid(): + return JsonResponse({'status': 400, 'error': form.errors.as_json()}, status=400) + + form.save() + + return JsonResponse({'status': 200}) + + +@protected_resource() +def _remove_list_item(request, user, list_item): + """Elimina un item de la lista del usuario""" + + # Solo el dueño o un administrador puede modificar la lista + if request.user.id != user.id and not request.user.is_admin: + return JsonResponse({'status': 403, + 'error': 'El usuario no tiene permiso para hacer esta acción'}, + status=403) + + list_item.delete() + + return JsonResponse({'status': 200}) + + +def _get_tags(request, user): + pass + + +@protected_resource() +def _post_tag(request, user): + pass diff --git a/musiclist/settings/__init__.py b/musiclist/settings/__init__.py index cba5ad2..be28bad 100644 --- a/musiclist/settings/__init__.py +++ b/musiclist/settings/__init__.py @@ -32,6 +32,7 @@ INSTALLED_APPS = [ 'fetcher.apps.FetcherConfig', 'users.apps.UsersConfig', 'views.apps.ViewsConfig', + 'lists.apps.ListsConfig', ] """Middlewares on every call""" diff --git a/musiclist/urls.py b/musiclist/urls.py index 5b788d0..15564d0 100644 --- a/musiclist/urls.py +++ b/musiclist/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path('auth/', include('users.urls')), path('api/brainz/', include('fetcher.urls')), path('api/users/', include('users.api_urls')), + path('api/lists/', include('lists.urls')), ] handler400 = 'views.views.handle400' diff --git a/pre-commit.sh b/pre-commit.sh index e6c9398..5766b6a 100755 --- a/pre-commit.sh +++ b/pre-commit.sh @@ -3,21 +3,12 @@ set -eu . .venv/bin/activate -STASH_NAME=pre-commit-$(date +%s) -git stash save -q --keep-index $STASH_NAME - flake8 . FLAKE_8=$? ./test.sh TEST=$? -STASH_NUM=$(git stash list | grep $STASH_NAME | sed -re 's/stash@\{(.*)\}.*/\1/') -if [ -n "$STASH_NUM" ]; then - git stash pop -q stash@{$STASH_NUM} -fi - - if [ $FLAKE_8 -ne 0 ]; then exit $FLAKE_8 fi diff --git a/setup.cfg b/setup.cfg index a731e86..6c1b74a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,3 @@ [flake8] max-line-length = 120 -exclude = __pycache__,.git,.idea,static,.venv,users/migrations/ +exclude = __pycache__,.git,.idea,static,.venv,users/migrations/,lists/migrations/ diff --git a/users/migrations/0002_auto_20200628_1836.py b/users/migrations/0002_auto_20200628_1836.py new file mode 100644 index 0000000..67caa99 --- /dev/null +++ b/users/migrations/0002_auto_20200628_1836.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-06-28 22:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ], + ), + ]