Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/django.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 0 additions & 21 deletions .travis.yml

This file was deleted.

102 changes: 80 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
=====================================
Expand Down Expand Up @@ -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
-------------------

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% load i18n %}

{% block title %}{% trans "Password changed" %}{% endblock %}

{% block content %}
<h2>{% trans "Password changed" %}</h2>
<p>{% trans "Your password has been changed successfully." %}</p>
<a href="/admin/">{% trans "Log in" %}</a>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% load i18n %}

{% block title %}{% trans "Set new password" %}{% endblock %}

{% block content %}
<h2>{% trans "Set new password" %}</h2>
{% if validlink %}
<form method="post">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">{% trans "Change password" %}</button>
</form>
{% else %}
<p>{% trans "This password reset link is invalid or has expired." %}</p>
<a href="{% url 'password_reset' %}">{% trans "Request a new link" %}</a>
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% load i18n %}

{% block title %}{% trans "Email sent" %}{% endblock %}

{% block content %}
<h2>{% trans "Email sent" %}</h2>
<p>{% trans "If an account exists with the email you entered, you will receive a password reset link shortly." %}</p>
<a href="/">{% trans "Back to home" %}</a>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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 %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load i18n %}

{% block title %}{% trans "Reset your password" %}{% endblock %}

{% block content %}
<h2>{% trans "Reset your password" %}</h2>
<p>{% trans "Enter your email address and we will send you a link to reset your password." %}</p>
<form method="post">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">{% trans "Send reset link" %}</button>
</form>
{% endblock %}
81 changes: 78 additions & 3 deletions decide/authentication/tests.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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!'))
9 changes: 8 additions & 1 deletion decide/authentication/urls.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]
3 changes: 3 additions & 0 deletions decide/booth/templates/booth/booth.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ <h5 class="modal-title" id="registerModalLabel">Login</h5>
required />
</div>
<button class="btn btn-primary mt-3" type="submit" data-bs-dismiss="modal">{% trans "Login" %}</button>
<div class="mt-2">
<a href="{% url 'password_reset' %}">{% trans "Forgot your password?" %}</a>
</div>
</form>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Loading