diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 3c445f943..929035acd 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -10,13 +10,13 @@ jobs: build: strategy: matrix: - pyversion: ['3.8','3.9'] + pyversion: ['3.12'] runs-on: ubuntu-latest services: postgres: - image: postgres:11.18-bullseye + image: postgres:14-bullseye env: POSTGRES_USER: decide POSTGRES_PASSWORD: decide diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b72f88a6e..000000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -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 7bd6302a0..6b044c02c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.com/wadobo/decide.svg?branch=master)](https://travis-ci.com/wadobo/decide) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/6a6e89e141b14761a19288a6b28db474)](https://www.codacy.com/gh/decide-update-4-1/decide-update-4.1/dashboard?utm_source=github.com&utm_medium=referral&utm_content=decide-update-4-1/decide-update-4.1&utm_campaign=Badge_Grade) [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/6a6e89e141b14761a19288a6b28db474)](https://www.codacy.com/gh/decide-update-4-1/decide-update-4.1/dashboard?utm_source=github.com&utm_medium=referral&utm_content=decide-update-4-1/decide-update-4.1&utm_campaign=Badge_Coverage) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/6a6e89e141b14761a19288a6b28db474)](https://www.codacy.com/gh/decide-update-4-1/decide-update-4.1/dashboard?utm_source=github.com&utm_medium=referral&utm_content=decide-update-4-1/decide-update-4.1&utm_campaign=Badge_Grade) [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/6a6e89e141b14761a19288a6b28db474)](https://www.codacy.com/gh/decide-update-4-1/decide-update-4.1/dashboard?utm_source=github.com&utm_medium=referral&utm_content=decide-update-4-1/decide-update-4.1&utm_campaign=Badge_Coverage) Plataforma voto electrónico educativa ===================================== @@ -84,6 +84,40 @@ siguiente manera: ./manage.py runserver +Configuración del correo electrónico +------------------------------------- + +La aplicación incluye un sistema de recuperación de contraseña que envía un enlace por correo +electrónico. Por defecto, en desarrollo los correos se muestran por consola (no se envían realmente). + +Para configurar el envío real de correos en producción, añade lo siguiente en tu fichero +`local_settings.py`: + +```python +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.tuservidor.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'tu-correo@tuservidor.com' +EMAIL_HOST_PASSWORD = 'tu-contraseña' +DEFAULT_FROM_EMAIL = 'noreply@tuservidor.com' +``` + +Ejemplo para Gmail: + +```python +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'tu-correo@gmail.com' +EMAIL_HOST_PASSWORD = 'tu-contraseña-de-aplicacion' +DEFAULT_FROM_EMAIL = 'tu-correo@gmail.com' +``` + +**Nota:** En Gmail es necesario usar una [contraseña de aplicación](https://support.google.com/accounts/answer/185833) +en lugar de la contraseña habitual de la cuenta. + Tests ------------------- @@ -222,7 +256,7 @@ para el servidor de base de datos, otro para el django y otro con un servidor web nginx para servir los ficheros estáticos y hacer de proxy al servidor django: - * decide\_db + * decide\_db (PostgreSQL 14 con pgautoupgrade) * decide\_web * decide\_nginx @@ -233,9 +267,29 @@ contenedores se pueden destruir sin miedo a perder datos: * decide\_db * decide\_static +**Nota sobre la actualización de PostgreSQL:** Si vienes de una versión anterior con +PostgreSQL 11, la imagen `pgautoupgrade` se encargará de migrar automáticamente los +datos al formato de PostgreSQL 14 en el primer arranque. Este proceso puede tardar +unos minutos dependiendo del tamaño de la base de datos. + Se puede editar el fichero docker-settings.py para modificar el settings del proyecto django antes de crear las imágenes del contenedor. +Para habilitar el envío real de correos (por ejemplo, para la recuperación de contraseña), +añade la configuración SMTP en docker-settings.py: + +```python +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.tuservidor.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'tu-correo@tuservidor.com' +EMAIL_HOST_PASSWORD = 'tu-contraseña' +DEFAULT_FROM_EMAIL = 'noreply@tuservidor.com' +``` + +Si no se configura, los correos se mostrarán por consola (visible con `docker logs decide_web`). + Crear imágenes y lanzar contenedores: $ cd docker @@ -261,8 +315,12 @@ Lanzar una consola SQL: $ docker exec -ti decide_db ash -c "su - postgres -c 'psql postgres'" -Ejecutar con vagrant + ansible ------------------------------- +Ejecutar con vagrant + ansible (DEPRECATED) +-------------------------------------------- + +> **DEPRECATED:** La configuración de Vagrant usa `ubuntu/bionic64` (Ubuntu 18.04 EOL) con +> Python 3.6, que es incompatible con la versión actual del proyecto (Python 3.12+). +> Se recomienda usar Docker en su lugar. 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 @@ -463,31 +521,31 @@ Si se quieren añadir más casuística a la carga inicial, basta con editar el " la misma estructura que los datos contenidos en el mismo. -Cabe añadir que previo a ejecutar ambos comandos, deberemos haber activado nuestro entorno de -Python 3.9. +Cabe añadir que previo a ejecutar ambos comandos, deberemos haber activado nuestro entorno de +Python 3.12. El archivo "populate.json" se ha generado manualmente con ayuda de la documentación encontrada en -[el siguiente portal web](https://docs.djangoproject.com/en/4.1/howto/initial-data/). +[el siguiente portal web](https://docs.djangoproject.com/en/5.2/howto/initial-data/). Versiones actuales ------------------ -En las ultimas actualizaciones se han modificado las versiones usadas por la aplicación Decide. Las +En las ultimas actualizaciones se han modificado las versiones usadas por la aplicación Decide. Las versiones usadas actualmente se corresponden a las siguientes: -* Django = 4.1 +* Django = 5.2 * pycryptodome = 3.15.0 -* djangorestframework = 3.14.0 -* django-cors-headers = 3.13.0 -* requests = 2.28.1 -* django-filter = 22.1 -* psycopg2 = 2.9.4 -* coverage = 6.5.0 -* jsonnet = 0.18.0 -* django-nose = 1.4.6 -* django-rest-swagger = 2.2.0 -* Python = 3.9 -* Vue=3 -* Bootstrap=5.2 -* selenium = 4.7.2 +* djangorestframework = 3.15.2 +* django-cors-headers = 4.6.0 +* requests = 2.32.3 +* django-filter = 24.3 +* psycopg2 = 2.9.10 +* coverage = 7.6.10 +* jsonnet = 0.20.0 +* drf-spectacular = 0.29.0 +* Python = 3.12 +* Vue = 3 +* Bootstrap = 5.2 +* selenium = 4.27.1 +* PostgreSQL >= 14 diff --git a/decide/authentication/templates/registration/password_reset_complete.html b/decide/authentication/templates/registration/password_reset_complete.html new file mode 100644 index 000000000..25d7bb57d --- /dev/null +++ b/decide/authentication/templates/registration/password_reset_complete.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Password changed" %}{% endblock %} + +{% block content %} +

{% trans "Password changed" %}

+

{% trans "Your password has been changed successfully." %}

+{% trans "Log in" %} +{% endblock %} diff --git a/decide/authentication/templates/registration/password_reset_confirm.html b/decide/authentication/templates/registration/password_reset_confirm.html new file mode 100644 index 000000000..b94f6b677 --- /dev/null +++ b/decide/authentication/templates/registration/password_reset_confirm.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Set new password" %}{% endblock %} + +{% block content %} +

{% trans "Set new password" %}

+{% if validlink %} +
+ {% csrf_token %} + {{ form.as_div }} + +
+{% else %} +

{% trans "This password reset link is invalid or has expired." %}

+{% trans "Request a new link" %} +{% endif %} +{% endblock %} diff --git a/decide/authentication/templates/registration/password_reset_done.html b/decide/authentication/templates/registration/password_reset_done.html new file mode 100644 index 000000000..0e9db0732 --- /dev/null +++ b/decide/authentication/templates/registration/password_reset_done.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Email sent" %}{% endblock %} + +{% block content %} +

{% trans "Email sent" %}

+

{% trans "If an account exists with the email you entered, you will receive a password reset link shortly." %}

+{% trans "Back to home" %} +{% endblock %} diff --git a/decide/authentication/templates/registration/password_reset_email.html b/decide/authentication/templates/registration/password_reset_email.html new file mode 100644 index 000000000..ea8d025d2 --- /dev/null +++ b/decide/authentication/templates/registration/password_reset_email.html @@ -0,0 +1,9 @@ +{% load i18n %}{% autoescape off %} +{% trans "You have requested a password reset for your Decide account." %} + +{% trans "Please click the following link to set a new password:" %} + +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} + +{% trans "If you did not request this, you can ignore this email." %} +{% endautoescape %} diff --git a/decide/authentication/templates/registration/password_reset_form.html b/decide/authentication/templates/registration/password_reset_form.html new file mode 100644 index 000000000..129e2655d --- /dev/null +++ b/decide/authentication/templates/registration/password_reset_form.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}{% trans "Reset your password" %}{% endblock %} + +{% block content %} +

{% trans "Reset your password" %}

+

{% trans "Enter your email address and we will send you a link to reset your password." %}

+
+ {% csrf_token %} + {{ form.as_div }} + +
+{% endblock %} diff --git a/decide/authentication/tests.py b/decide/authentication/tests.py index dfd09df19..e36f195ad 100644 --- a/decide/authentication/tests.py +++ b/decide/authentication/tests.py @@ -1,8 +1,14 @@ -from django.test import TestCase -from rest_framework.test import APIClient -from rest_framework.test import APITestCase +import re +from django.test import TestCase, override_settings +from django.core import mail from django.contrib.auth.models import User +from django.contrib.auth.tokens import default_token_generator +from django.utils.encoding import force_bytes +from django.urls import reverse +from django.utils.http import urlsafe_base64_encode +from rest_framework.test import APIClient +from rest_framework.test import APITestCase from rest_framework.authtoken.models import Token from base import mods @@ -128,3 +134,72 @@ def test_register(self): sorted(list(response.json().keys())), ['token', 'user_pk'] ) + + +@override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') +class PasswordResetTestCase(TestCase): + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', + email='testuser@example.com', + password='testpass123', + ) + + def test_password_reset_view_get(self): + response = self.client.get(reverse('password_reset')) + self.assertEqual(response.status_code, 200) + + def test_password_reset_post_valid_email(self): + response = self.client.post(reverse('password_reset'), {'email': 'testuser@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn('testuser@example.com', mail.outbox[0].to) + + def test_password_reset_post_invalid_email(self): + response = self.client.post(reverse('password_reset'), {'email': 'noexiste@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_password_reset_done_view(self): + response = self.client.get(reverse('password_reset_done')) + self.assertEqual(response.status_code, 200) + + def test_password_reset_confirm_valid_token(self): + uid = urlsafe_base64_encode(force_bytes(self.user.pk)) + token = default_token_generator.make_token(self.user) + url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_password_reset_confirm_invalid_token(self): + uid = urlsafe_base64_encode(force_bytes(self.user.pk)) + url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': 'invalid-token'}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'invalid') + + def test_password_reset_complete_flow(self): + # 1. Request password reset + self.client.post(reverse('password_reset'), {'email': 'testuser@example.com'}) + self.assertEqual(len(mail.outbox), 1) + + # 2. Extract reset link from email + email_body = mail.outbox[0].body + reset_url = re.search(r'/authentication/reset/[^/]+/[^/]+/', email_body).group() + + # 3. GET the reset link (Django redirects to set-password URL) + response = self.client.get(reset_url) + self.assertEqual(response.status_code, 302) + redirect_url = response.url + + # 4. POST new password + response = self.client.post(redirect_url, { + 'new_password1': 'newSecurePass456!', + 'new_password2': 'newSecurePass456!', + }) + self.assertEqual(response.status_code, 302) + + # 5. Verify login with new password works + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('newSecurePass456!')) diff --git a/decide/authentication/urls.py b/decide/authentication/urls.py index d05bfed6f..dfea0cfcf 100644 --- a/decide/authentication/urls.py +++ b/decide/authentication/urls.py @@ -1,4 +1,5 @@ -from django.urls import include, path +from django.contrib.auth import views +from django.urls import path from rest_framework.authtoken.views import obtain_auth_token from .views import GetUserView, LogoutView, RegisterView @@ -9,4 +10,10 @@ path('logout/', LogoutView.as_view()), path('getuser/', GetUserView.as_view()), path('register/', RegisterView.as_view()), + + # Password reset + path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'), + path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'), + path('reset///', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), ] diff --git a/decide/booth/templates/booth/booth.html b/decide/booth/templates/booth/booth.html index a67564fc9..6fffd1d55 100644 --- a/decide/booth/templates/booth/booth.html +++ b/decide/booth/templates/booth/booth.html @@ -107,6 +107,9 @@ required /> + diff --git a/decide/census/migrations/0002_alter_census_unique_together_and_more.py b/decide/census/migrations/0002_alter_census_unique_together_and_more.py new file mode 100644 index 000000000..ccbd9ff98 --- /dev/null +++ b/decide/census/migrations/0002_alter_census_unique_together_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2 on 2026-02-11 17:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('census', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='census', + unique_together=set(), + ), + migrations.AddConstraint( + model_name='census', + constraint=models.UniqueConstraint(fields=('voting_id', 'voter_id'), name='unique_voting_voter'), + ), + ] diff --git a/decide/census/models.py b/decide/census/models.py index e51a5b44e..6d6ed06e0 100644 --- a/decide/census/models.py +++ b/decide/census/models.py @@ -6,4 +6,6 @@ class Census(models.Model): voter_id = models.PositiveIntegerField() class Meta: - unique_together = (('voting_id', 'voter_id'),) + constraints = [ + models.UniqueConstraint(fields=['voting_id', 'voter_id'], name='unique_voting_voter'), + ] diff --git a/decide/decide/settings.py b/decide/decide/settings.py index 1d22b6732..96585c799 100644 --- a/decide/decide/settings.py +++ b/decide/decide/settings.py @@ -42,7 +42,7 @@ 'django_filters', 'rest_framework', 'rest_framework.authtoken', - 'rest_framework_swagger', + 'drf_spectacular', 'gateway', ] @@ -51,7 +51,15 @@ 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.TokenAuthentication', ), - 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.QueryParameterVersioning' + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.QueryParameterVersioning', + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Decide API', + 'DESCRIPTION': 'Plataforma de voto electrónico educativa', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, } AUTHENTICATION_BACKENDS = [ @@ -146,18 +154,18 @@ USE_I18N = True -USE_L10N = True - USE_TZ = True -TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ STATIC_URL = '/static/' +# Email configuration (override in local_settings.py for production SMTP) +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +DEFAULT_FROM_EMAIL = 'noreply@localhost' + # number of bits for the key, all auths should use the same number of bits KEYBITS = 256 diff --git a/decide/decide/urls.py b/decide/decide/urls.py index d73f3cdb5..79237bbf2 100644 --- a/decide/decide/urls.py +++ b/decide/decide/urls.py @@ -16,14 +16,12 @@ from django.conf import settings from django.contrib import admin from django.urls import path, include -from rest_framework_swagger.views import get_swagger_view - - -schema_view = get_swagger_view(title='Decide API') +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ path('admin/', admin.site.urls), - path('doc/', schema_view), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('doc/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('gateway/', include('gateway.urls')), ] diff --git a/docker/Dockerfile b/docker/Dockerfile index 1e119996c..827b1ca2f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,12 +1,9 @@ -from python:3.9-alpine +from python:3.12-alpine -RUN apk add --no-cache git postgresql-dev gcc libc-dev -RUN apk add --no-cache gcc g++ make libffi-dev python3-dev build-base +RUN apk add --no-cache postgresql-dev gcc libc-dev gcc g++ make libffi-dev python3-dev build-base -RUN pip install gunicorn -RUN pip install psycopg2 -RUN pip install ipdb -RUN pip install ipython +RUN pip install --upgrade pip==26.0.1 +RUN pip install gunicorn==25.0.3 WORKDIR /app diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 50ff01798..867a1b90c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,7 +4,7 @@ services: db: restart: always container_name: decide_db - image: postgres:11.18-bullseye + image: pgautoupgrade/pgautoupgrade:14-alpine3.21 volumes: - db:/var/lib/postgresql/data networks: @@ -16,7 +16,7 @@ services: container_name: decide_web image: decide_web:latest build: . - command: ash -c "python manage.py migrate && gunicorn -w 5 decide.wsgi --timeout=500 -b 0.0.0.0:5000" + command: ash -c "python manage.py collectstatic --noinput --clear && python manage.py migrate && gunicorn -w 5 decide.wsgi --timeout=500 -b 0.0.0.0:5000" expose: - "5000" volumes: diff --git a/requirements.txt b/requirements.txt index 02390a41f..b6c0e931c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,11 @@ -Django==4.1 +Django==5.2 pycryptodome==3.15.0 -djangorestframework==3.14.0 -django-cors-headers==3.13.0 -requests==2.28.1 -django-filter==22.1 -psycopg2==2.9.4 -coverage==6.5.0 -jsonnet==0.18.0 -django-nose==1.4.6 -django-rest-swagger==2.2.0 -selenium==4.7.2 +djangorestframework==3.15.2 +django-cors-headers==4.6.0 +requests==2.32.3 +django-filter==24.3 +psycopg2==2.9.10 +coverage==7.6.10 +jsonnet==0.20.0 +drf-spectacular==0.29.0 +selenium==4.27.1