diff --git a/.gitignore b/.gitignore index 7bbc71c092..b823e313b4 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,5 @@ ENV/ # mypy .mypy_cache/ + +.vagrant diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..b72f88a6e0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +dist: xenial + +services: + - postgresql +addons: + postgresql: "9.4" +before_script: + - psql -U postgres -c "create user decide password 'decide'" + - psql -U postgres -c "create database test_decide owner decide" + - psql -U postgres -c "ALTER USER decide CREATEDB" +language: python +python: + - "3.6" +install: + - pip install -r requirements.txt + - pip install codacy-coverage +script: + - cd decide + - coverage run --branch --source=. ./manage.py test --keepdb --with-xunit + - coverage xml + - python-codacy-coverage -r coverage.xml diff --git a/README.md b/README.md index 9ca86f1d2f..83d0a57e27 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Build Status](https://travis-ci.com/wadobo/decide.svg?branch=master)](https://travis-ci.com/wadobo/decide) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/94a85eaa0e974c71af6899ea3b0d27e0)](https://www.codacy.com/app/Wadobo/decide?utm_source=github.com&utm_medium=referral&utm_content=wadobo/decide&utm_campaign=Badge_Grade) [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/94a85eaa0e974c71af6899ea3b0d27e0)](https://www.codacy.com/app/Wadobo/decide?utm_source=github.com&utm_medium=referral&utm_content=wadobo/decide&utm_campaign=Badge_Coverage) + Plataforma voto electrónico educativa ===================================== @@ -19,6 +21,33 @@ entre ellos. Para conseguir esto, los subsistemas se conectarán entre si median Este proyecto Django estará dividido en apps (subsistemas y proyecto base), donde cualquier app podrá ser reemplazada individualmente. +Gateway +------- + +Para ofrecer un punto de entrada conocido para todos los subsistemas +existe el llamado **gateway** que no es más que una ruta disponible +que redirigirá todas las peticiones al subsistema correspondiente, de +tal forma que cualquier cliente que use la API no tiene por qué saber +en qué servidor está desplegado cada subsistema. + +La ruta se compone de: + + http://DOMINIO/gateway/SUBSISTEMA/RUTA/EN/EL/SUBSISTEMA + +Por ejemplo para acceder al subsistema de autenticación y hacer la petición +al endpoint de /authentication/login/ deberíamos hacer la petición a la +siguiente ruta: + + http://DOMINIO/gateway/authentication/login/ + +Otro ejemplo sería para obtener una votación por id: + + http://DOMINIO/gateway/voting/?id=1 + +A nivel interno, el módulo `mods` ofrece esta funcionalidad, pero el +gateway es útil para hacer uso desde peticiones de cliente, por ejemplo +en el javascript de la cabina de votación o la visualización de resultados, +y también para módulos externos que no sean aplicaciones django. Configurar y ejecutar el proyecto --------------------------------- @@ -94,3 +123,170 @@ Lanzar tests: Lanzar una consola SQL: $ docker exec -ti decide_db ash -c "su - postgres -c 'psql postgres'" + +Ejecutar con vagrant + ansible +------------------------------ + +Existe una configuración de vagrant que crea una máquina virtual con todo +lo necesario instalado y listo para funcionar. La configuración está en +vagrant/Vagrantfile y por defecto utiliza Virtualbox, por lo que para +que esto funcione debes tener instalado en tu sistema vagrant y Virtualbox. + +Crear la máquina virtual con vagrant: + + $ cd vagrant + $ vagrant up + +Una vez creada podremos acceder a la web, con el usuario admin/admin: + +http://localhost:8080/admin + +Acceder por ssh a la máquina: + + $ vagrant ssh + +Esto nos dará una consola con el usuario vagrant, que tiene permisos de +sudo, por lo que podremos acceder al usuario administrador con: + + $ sudo su + +Parar la máquina virtual: + + $ vagrant stop + +Una vez parada la máquina podemos volver a lanzarla con `vagrant up`. + +Eliminar la máquina virtual: + + $ vagrant destroy + +Ansible +------- + +El provisionamiento de la aplicación con vagrant está hecho con Ansible, +algo que nos permite utilizarlo de forma independiente para provisionar +una instalación de Decide en uno o varios servidores remotos con un +simple comando. + + $ cd vagrant + $ ansible-playbook -i inventory playbook.yml + +Para que esto funcione debes definir un fichero [inventory](https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html) +con los servidores destino. + +Los scripts de ansible están divididos en varios ficheros .yml donde +se definen las diferentes tareas, por lo que es posible lanzar partes +independientes: + + * packages.yml, dependencias del sistema + * user.yml, creación de usuario decide + * python.yml, git clone del repositorio e instalación de dependencias python en virtualenv + * files.yml, ficheros de configuración, systemd, nginx y local\_settings.py + * database.yml, creación de usuario y base de datos postgres + * django.yml, comandos django básicos y creación de usuario admin + * services.yml, reinicio de servicios, decide, nginx y postgres + +Por ejemplo este comando sólo reinicia los servicios en el servidor: + + $ ansible-playbook -i inventory -t services + +El provisionamiento de ansible está diseñado para funcionar con **ubuntu/bionic64**, +para funcionar con otras distribuciones es posible que haga falta modificar +el fichero packages.yml. + +Versionado +---------- + +El versionado de API está hecho utilizando Django Rest Framework, y la forma +elegida para este versionado es mediante [parámetros de búsqueda](https://www.django-rest-framework.org/api-guide/versioning/#queryparameterversioning), +podemos cambiarlo a parámetros en la URL o en el nombre del HOST, hay diferentes +tipos de versionado disponibles en Django Rest Framework, podemos verlos +[aqui](https://www.django-rest-framework.org/api-guide/versioning/#versioning). + +Nosotros hemos escogido el de por parámetros por ser el más sencillo, y hemos +creado un ejemplo para que veamos su uso, podemos verlo en voting/views.py + +Si nosotros queremos que la salida que nos da la llamada a la API /voting/, sea +diferente en la versión 2, solo tenemos que comprobar en la versión nos está +llegando, y hacer lo que queramos, por ejemplo: + + +``` + def get(self, request, *args, **kwargs): + version = request.version # Con request.version obtenemos la versión + if version not in settings.ALLOWED_VERSIONS: # Versiones permitidas + version = settings.DEFAULT_VERSION # Si no existe: versión por defecto + # En el caso de usar la versión 2, usamos un serializador diferente + if version == 'v2': + self.serializer_class = SimpleVotingSerializer + return super().get(request, *args, **kwargs) +``` + +Para llamar a las diferentes versiones, haremos lo siguiente: + +* /voting/?version=v1 +* /voting/?version=v2 + + +Test de estrés con Locust +------------------------- + +Antes de empezar, comentaré para que sirven las pruebas de estrés. A veces necesitamos soportar que +nuestra aplicación ofrezca una cantidad de peticiones por segundo, porque habrá mucha gente entrando +a la misma vez, y ante este estrés, tenemos que comprobar como se comporta nuestra aplicación. + +No es lo mismo que cuando la estresemos nos de un error 500 a que nos devuelva la petición de otro +usuario. Con estos test conseguiremos comprobar cual es ese comportamiento, y quizás mejorar la +velocidad de las peticiones para permitir más peticiones por segundo. + +Para ejecutar los test de estrés utilizando locust, necesitaremos tener instalado locust: + + $ pip install locust + +Una vez instalado, necesitaremos tener un fichero locustfile.py donde tengamos la configuración de +lo que vamos a ejecutar. En nuestro caso, tenemos hecho dos ejemplos: + +1. Visualizer: entra en el visualizador de una votación para ver cuantas peticiones puede aguantar. + + Para ejecutar el test de Visualizer, tenemos que tener en cuenta que entra en la votación 1, por lo + que necesitaremos tenerla creada para que funcione correctamente, una vez hecho esto, podemos + comenzar a probar con el siguiente comando (dentro de la carpeta loadtest): + + $ locust Visualizer + + Esto abrirá un servidor que podremos ver en el navegador, el mismo comando nos dirá el puerto. + Cuando se abra, nos preguntará cuantos usuarios queremos que hagan peticiones a la vez, y como + queremos que vaya creciendo hasta llegar a ese número. Por ejemplo, si ponemos 100 y 5, estaremos + creando 5 nuevos usuarios cada segundo hasta llegar a 100. + +2. Voters: utilizaremos usuarios previamente creados, y haremos una secuencia de peticiones: login, +getuser y store. Sería lo que realizaría un usuario cuando va a votar, por lo que con este ejemplo +estaremos comprobando cuantas votaciones podemos hacer. + + + Para ejecutar el test de Voter, necesitaremos realizar varios preparos. Necesitaremos la votación 1 + abierta, y necesitaremos crear una serie de usuarios en el censo de esta votación, para que cuando + hagamos el test, estos usuario puedan autenticarse y votar correctamente. Para facilitar esta + tarea, hemos creado el script de python gen_census.py, en el cual creamos los usuarios que + tenemos dentro del fichero voters.json y los añadimos al censo utilizando la librería requests. + Para que este script funcione, necesitaremos tener instalado request: + + $ pip install requests + + Una vez instalado, ejecutamos el script: + + $ python gen_census.py + + Tras esto, ya podremos comenzar el test de estrés de votantes: + + $ locust Voters + +Importante mirar bien el fichero locustfile.py, donde existen algunas configuraciones que podremos +cambiar, dependiendo del HOST donde queramos hacer las pruebas y del id de la votación. + +A tener en cuenta: + +* En un servidor local, con un postgres que por defecto nos viene limitado a 100 usuarios + concurrentes, cuando pongamos más de 100, lo normal es que empiecen a fallar muchas peticiones. +* Si hacemos las pruebas en local, donde tenemos activado el modo debug de Django, lo normal es que + las peticiones tarden algo más y consigamos menos RPS (Peticiones por segundo). diff --git a/decide/authentication/tests.py b/decide/authentication/tests.py index 69178f914d..dfd09df191 100644 --- a/decide/authentication/tests.py +++ b/decide/authentication/tests.py @@ -17,6 +17,11 @@ def setUp(self): u.set_password('123') u.save() + u2 = User(username='admin') + u2.set_password('admin') + u2.is_superuser = True + u2.save() + def tearDown(self): self.client = None @@ -79,3 +84,47 @@ def test_logout(self): self.assertEqual(response.status_code, 200) self.assertEqual(Token.objects.filter(user__username='voter1').count(), 0) + + def test_register_bad_permissions(self): + data = {'username': 'voter1', 'password': '123'} + response = self.client.post('/authentication/login/', data, format='json') + self.assertEqual(response.status_code, 200) + token = response.json() + + token.update({'username': 'user1'}) + response = self.client.post('/authentication/register/', token, format='json') + self.assertEqual(response.status_code, 401) + + def test_register_bad_request(self): + data = {'username': 'admin', 'password': 'admin'} + response = self.client.post('/authentication/login/', data, format='json') + self.assertEqual(response.status_code, 200) + token = response.json() + + token.update({'username': 'user1'}) + response = self.client.post('/authentication/register/', token, format='json') + self.assertEqual(response.status_code, 400) + + def test_register_user_already_exist(self): + data = {'username': 'admin', 'password': 'admin'} + response = self.client.post('/authentication/login/', data, format='json') + self.assertEqual(response.status_code, 200) + token = response.json() + + token.update(data) + response = self.client.post('/authentication/register/', token, format='json') + self.assertEqual(response.status_code, 400) + + def test_register(self): + data = {'username': 'admin', 'password': 'admin'} + response = self.client.post('/authentication/login/', data, format='json') + self.assertEqual(response.status_code, 200) + token = response.json() + + token.update({'username': 'user1', 'password': 'pwd1'}) + response = self.client.post('/authentication/register/', token, format='json') + self.assertEqual(response.status_code, 201) + self.assertEqual( + sorted(list(response.json().keys())), + ['token', 'user_pk'] + ) diff --git a/decide/authentication/urls.py b/decide/authentication/urls.py index 5099cc8095..d05bfed6fc 100644 --- a/decide/authentication/urls.py +++ b/decide/authentication/urls.py @@ -1,11 +1,12 @@ from django.urls import include, path from rest_framework.authtoken.views import obtain_auth_token -from .views import GetUserView, LogoutView +from .views import GetUserView, LogoutView, RegisterView urlpatterns = [ path('login/', obtain_auth_token), path('logout/', LogoutView.as_view()), path('getuser/', GetUserView.as_view()), + path('register/', RegisterView.as_view()), ] diff --git a/decide/authentication/views.py b/decide/authentication/views.py index 5beab1e3dc..7825be67c2 100644 --- a/decide/authentication/views.py +++ b/decide/authentication/views.py @@ -1,6 +1,13 @@ from rest_framework.response import Response +from rest_framework.status import ( + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED +) from rest_framework.views import APIView from rest_framework.authtoken.models import Token +from django.contrib.auth.models import User +from django.db import IntegrityError from django.shortcuts import get_object_or_404 from django.core.exceptions import ObjectDoesNotExist @@ -24,3 +31,25 @@ def post(self, request): pass return Response({}) + + +class RegisterView(APIView): + def post(self, request): + key = request.data.get('token', '') + tk = get_object_or_404(Token, key=key) + if not tk.user.is_superuser: + return Response({}, status=HTTP_401_UNAUTHORIZED) + + username = request.data.get('username', '') + pwd = request.data.get('password', '') + if not username or not pwd: + return Response({}, status=HTTP_400_BAD_REQUEST) + + try: + user = User(username=username) + user.set_password(pwd) + user.save() + token, _ = Token.objects.get_or_create(user=user) + except IntegrityError: + return Response({}, status=HTTP_400_BAD_REQUEST) + return Response({'user_pk': user.pk, 'token': token.key}, HTTP_201_CREATED) diff --git a/decide/booth/static/booth/style.css b/decide/booth/static/booth/style.css new file mode 100644 index 0000000000..e5aa677cad --- /dev/null +++ b/decide/booth/static/booth/style.css @@ -0,0 +1,4 @@ +.voting { + margin-top: 20px; + margin-left: 40px; +} diff --git a/decide/booth/templates/booth/booth.html b/decide/booth/templates/booth/booth.html index 2933af171b..164f547a31 100644 --- a/decide/booth/templates/booth/booth.html +++ b/decide/booth/templates/booth/booth.html @@ -1,30 +1,70 @@ {% extends "base.html" %} {% load i18n static %} -{% block content %} -