Skip to content
Merged
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
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_DB=

UNICORE_USER=
UNICORE_PASSWORD=
UNICORE_ORG_ID=
UNICORE_URL=https://unicorecustomapi.mecenat.com/utn

# Email settings for local testing with MailHog
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=mailhog
EMAIL_PORT=1025
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=False
EMAIL_USE_SSL=False
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ venv
.DS_Store
node_modules
team_logos
.venv/
backend/.venv/
18 changes: 18 additions & 0 deletions backend/backend/migrations/0008_alter_member_is_superuser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2026-05-14 12:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('backend', '0007_merge_0006_alter_appointment_member_0006_role_role_description_url'),
]

operations = [
migrations.AlterField(
model_name='member',
name='is_superuser',
field=models.BooleanField(default=False, help_text='Designates whether the user is a superuser'),
),
]
50 changes: 50 additions & 0 deletions backend/backend/migrations/0009_add_email_verification_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("backend", "0008_alter_member_is_superuser"),
]

operations = [
migrations.AddField(
model_name="member",
name="email_verification_code",
field=models.CharField(
blank=True,
default=None,
help_text="One-time email verification code",
max_length=128,
null=True,
),
),
migrations.AddField(
model_name="member",
name="email_verification_code_expires_at",
field=models.DateTimeField(
blank=True,
default=None,
help_text="When the email verification code expires",
null=True,
),
),
migrations.AddField(
model_name="member",
name="email_verification_attempts",
field=models.PositiveSmallIntegerField(
default=0,
help_text="Failed email verification attempts",
),
),
migrations.AddField(
model_name="member",
name="email_verification_sent_at",
field=models.DateTimeField(
blank=True,
default=None,
help_text="When the last email verification code was sent",
null=True,
),
),
]
27 changes: 26 additions & 1 deletion backend/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ class Member(AbstractBaseUser, PermissionsMixin):
)

verified_email = models.BooleanField(default=False)
email_verification_code = models.CharField(
max_length=128,
null=True,
blank=True,
default=None,
help_text=_("One-time email verification code"),
)
email_verification_code_expires_at = models.DateTimeField(
null=True,
blank=True,
default=None,
help_text=_("When the email verification code expires"),
)
email_verification_attempts = models.PositiveSmallIntegerField(
default=0,
help_text=_("Failed email verification attempts"),
)
email_verification_sent_at = models.DateTimeField(
null=True,
blank=True,
default=None,
help_text=_("When the last email verification code was sent"),
)

phone_number = models.CharField(
max_length=20,
Expand All @@ -82,7 +105,9 @@ class Member(AbstractBaseUser, PermissionsMixin):
)

is_superuser = models.BooleanField(
help_text=("Designates whether the user is a superuser"))
default=False,
help_text=("Designates whether the user is a superuser"),
)

is_staff = models.BooleanField(
_("Staff status"),
Expand Down
64 changes: 51 additions & 13 deletions backend/backend/send_email.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,58 @@
import secrets
import string
from datetime import timedelta

from django.contrib.auth.hashers import make_password
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.shortcuts import get_current_site
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.utils import timezone

VERIFICATION_CODE_LENGTH = 6
VERIFICATION_CODE_TTL_MINUTES = 15
VERIFICATION_RESEND_COOLDOWN_SECONDS = 60


def generate_verification_code(length=VERIFICATION_CODE_LENGTH):
characters = string.ascii_uppercase + string.digits
return "".join(secrets.choice(characters) for _ in range(length))


# This needs rate limiting
def send_verification_email(user):
def send_verification_email(user, *, allow_rate_limit=True):
"""
TEMPORARY FUNCTION
This function sends an email verification link to the user.
The link contains a token that the user can use to verify their email address.

Sends an email verification code to the user.
"""

token = default_token_generator.make_token(user)
now = timezone.now()
if (
allow_rate_limit
and user.email_verification_sent_at
and (now - user.email_verification_sent_at).total_seconds()
< VERIFICATION_RESEND_COOLDOWN_SECONDS
):
return False

code = generate_verification_code()
user.email_verification_code = make_password(code)
user.email_verification_code_expires_at = now + timedelta(
minutes=VERIFICATION_CODE_TTL_MINUTES
)
user.email_verification_attempts = 0
user.email_verification_sent_at = now
user.save(
update_fields=[
"email_verification_code",
"email_verification_code_expires_at",
"email_verification_attempts",
"email_verification_sent_at",
]
)

subject = "Verify your email address"
message = f"Sign in with the secure link: http://localhost:3000/auth/verify-email?id={user.id}&token={token}"
message = (
f"Verify your email address for Apply using the verification code {code}\n\n"
f"Or you can verify by visiting http://localhost:3000/verify-email?email={user.email}&code={code}"
)

send_mail(
subject,
Expand All @@ -26,19 +61,22 @@ def send_verification_email(user):
[user.email],
)

return True


# This needs rate limiting
def send_password_reset_email(user):
"""
TEMPORARY FUNCTION
This function sends a password reset email to the user.
The email contains a link to reset the user's password.

"""
if not user.verified_email:
return

token = default_token_generator.make_token(user)

subject = "Reset your password"
message = f"Click to reset your password: http://localhost:3000/reset-password?id={user.id}&token={token}"
message = f"Follow this link to reset your password:\nhttp://localhost:3000/reset-password?id={user.id}&token={token}"
send_mail(
subject,
message,
Expand Down
35 changes: 34 additions & 1 deletion backend/backend/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Application,
Reference,
)
from .send_email import send_verification_email


def get_language_from_request(request):
Expand Down Expand Up @@ -63,13 +64,33 @@ class MemberSerializer(ModelSerializer):
"""

study_program = StudyProgramSerializer(read_only=True)
study_program_id = serializers.PrimaryKeyRelatedField(
queryset=StudyProgram.objects.all(),
source="study_program",
write_only=True,
required=False,
allow_null=True,
)
section_id = serializers.PrimaryKeyRelatedField(
queryset=Section.objects.all(),
write_only=True,
required=False,
allow_null=True,
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is not None:
self.fields["email"].read_only = True

class Meta:
model = Member
fields = (
"name",
"phone_number",
"study_program",
"study_program_id",
"section_id",
"registration_year",
"status",
"ssn",
Expand All @@ -94,15 +115,27 @@ def validate_password(self, value):
raise serializers.ValidationError(err.messages)
return value

def validate(self, attrs):
section = attrs.get("section_id")
study_program = attrs.get("study_program")

if section and study_program and study_program.section_id != section.id:
raise serializers.ValidationError(
{"section_id": ["Section does not match selected program."]}
)

return attrs

def create(self, validated_data):
validated_data.pop("section_id", None)
password = validated_data.pop("password", None)
if password is None:
raise serializers.ValidationError({"password": ["Password must be set"]})
user = Member(**validated_data)
user.set_password(password)
user.save()

# TODO: Email verification here
send_verification_email(user)

return user

Expand Down
54 changes: 53 additions & 1 deletion backend/backend/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
from django.contrib.auth import get_user_model
from django.core import mail
from django.urls import reverse
from rest_framework.test import APIClient
from django.test import TestCase

# Create your tests here.

class PasswordResetTests(TestCase):
def setUp(self):
self.client = APIClient()
User = get_user_model()

self.verified_user = User.objects.create(
email="verified@example.com",
name="Verified User",
phone_number="+4600000000",
ssn="199001010001",
verified_email=True,
is_active=True,
)
self.verified_user.set_password("securepass123")
self.verified_user.save()

self.unverified_user = User.objects.create(
email="unverified@example.com",
name="Unverified User",
phone_number="+4600000001",
ssn="199001010002",
verified_email=False,
is_active=True,
)
self.unverified_user.set_password("securepass123")
self.unverified_user.save()

def test_password_reset_email_sent_for_verified_user(self):
response = self.client.post(
reverse("reset-password"),
{"email": self.verified_user.email},
format="json",
)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1)
self.assertIn("reset-password", mail.outbox[0].body)
self.assertEqual(mail.outbox[0].to, [self.verified_user.email])

def test_password_reset_email_not_sent_for_unverified_user(self):
response = self.client.post(
reverse("reset-password"),
{"email": self.unverified_user.email},
format="json",
)

self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 0)
4 changes: 2 additions & 2 deletions backend/backend/utils/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class NumberValidator:
def validate(self, password, user=None):
if not any(char.isdigit() for char in password):
raise validators.ValidationError(
_("This password must contain at least one number."),
_("The password must contain at least one number."),
code="password_no_number",
)

Expand All @@ -50,7 +50,7 @@ class SpecialCharacterValidator:
def validate(self, password, user=None):
if not any(char in self.SPECIAL_CHARACTERS for char in password):
raise validators.ValidationError(
_("This password must contain at least one special character."),
_("The password must contain at least one special character."),
code="password_no_special_character",
)

Expand Down
Loading
Loading