Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
5a9f3f9
create new app - password_reset
DaniilShomin Jul 24, 2025
ab98d82
add new app - password_reset
DaniilShomin Jul 24, 2025
94dfddd
create CustomPasswordResetForm
DaniilShomin Jul 24, 2025
c174420
update form: CustomPasswordResetForm
DaniilShomin Jul 26, 2025
73ddfb0
add configs file
DaniilShomin Jul 26, 2025
f9c1d00
add PasswordReset model
DaniilShomin Jul 26, 2025
cf0996c
add view password reset
DaniilShomin Jul 26, 2025
27a8f3e
add validators
DaniilShomin Jul 26, 2025
5c0db7e
add views for password_reset
DaniilShomin Jul 26, 2025
3fe56f7
add migraions
DaniilShomin Jul 26, 2025
cf13592
add logger
DaniilShomin Jul 26, 2025
bdf23ef
update app name
DaniilShomin Jul 26, 2025
0b87e21
add configs file
DaniilShomin Jul 26, 2025
c9ca8a1
add tests
DaniilShomin Jul 26, 2025
c431bc8
update logger
DaniilShomin Jul 26, 2025
10c36c6
add django-ratelimit
DaniilShomin Jul 27, 2025
a39f12a
add method update all is_used in user
DaniilShomin Jul 27, 2025
6f96455
update linting code
DaniilShomin Jul 27, 2025
4194bf2
Merge branch 'feature/issue-33' into develop
DaniilShomin Jul 27, 2025
475dc7c
Merge branch 'main' into feature/issue-33
DaniilShomin Aug 23, 2025
6c12cc7
feat: add render from inertia and delete forms
DaniilShomin Aug 23, 2025
f2aefe3
feat: delete save to file
DaniilShomin Aug 23, 2025
66451f7
feat: add base template
DaniilShomin Aug 23, 2025
ac2bc3b
feat: rewrite test for inertia render
DaniilShomin Aug 23, 2025
179e69f
fix: ruff check
DaniilShomin Aug 23, 2025
c5f7658
Merge branch 'main' into feature/issue-33
DaniilShomin Sep 16, 2025
fa95789
feat: rename inertia render and add docs
DaniilShomin Sep 16, 2025
113c4ac
feat: add celery and broker redis for send mail
DaniilShomin Oct 2, 2025
99200c2
Merge branch 'main' into feature/issue-33
DaniilShomin Oct 2, 2025
fc3fef2
feat: add mock for send mail
DaniilShomin Oct 2, 2025
1149631
Merge branch 'main' into feature/issue-33
DaniilShomin Oct 16, 2025
cdd02c1
test: DJANGO_VITE_DEV_MODE = True
DaniilShomin Oct 17, 2025
51ef101
test: DJANGO_VITE_DEV_MODE=DEBUG, rewrite main.tsx and delete button…
DaniilShomin Oct 19, 2025
a9977aa
Merge branch 'main' into feature/issue-33
DaniilShomin Oct 24, 2025
ef50157
Merge branch 'main' into feature/issue-33
DaniilShomin Oct 24, 2025
fb24cc6
Merge branch 'main' into feature/issue-33
DaniilShomin Oct 27, 2025
8a5b7aa
fix: linting
DaniilShomin Oct 27, 2025
d2416cc
Merge branch 'main' into feature/issue-33
DaniilShomin Oct 28, 2025
3f84b98
Merge branch 'main' into feature/issue-33
DaniilShomin Nov 28, 2025
b3b8986
feat: delete setting logging from module
DaniilShomin Nov 28, 2025
929686c
feat: apply migrations
DaniilShomin Nov 28, 2025
5cff017
Merge branch 'main' into feature/issue-33
DaniilShomin Dec 23, 2025
85d5678
feat: delete unused data
DaniilShomin Dec 24, 2025
7a72501
feat: change values ​​to placeholders
DaniilShomin Dec 24, 2025
8797f7c
feat: fix model and update migration
DaniilShomin Dec 24, 2025
07b7332
feat: add mypy
DaniilShomin Dec 24, 2025
1ee9727
feat: update docstring and fix typing
DaniilShomin Dec 24, 2025
d4cde1b
feat: routes have been corrected
DaniilShomin Dec 24, 2025
9e8ed66
feat: improve security, change password validator and add type hints
DaniilShomin Dec 24, 2025
6392ca5
feat: change Celery configurations value
DaniilShomin Dec 24, 2025
6cc3dd2
feat: adapt tests from inertia
DaniilShomin Dec 24, 2025
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
19 changes: 10 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
SECRET_KEY=23984794924798@39485973@h76h@dfhighudfgh
SECRET_KEY=your_secret_key
DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
ALLOWED_HOSTS=127.0.0.1,localhost,yourdomain.com

EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=user@example.com
EMAIL_HOST_PASSWORD=secret123
EMAIL_HOST_USER=your_email_here
EMAIL_HOST_PASSWORD=your_password_here
EMAIL_TIMEOUT=10
DEFAULT_FROM_EMAIL=your_email_for_sending

DATABASE_NAME="postgres"
DATABASE_USER="postgres"
DATABASE_PASSWORD="password"
DATABASE_PORT="5432"
DATABASE_HOST="db"
DATABASE_ENGINE="postgresql"
DATABASE_NAME=your_database_name
DATABASE_USER=your_database_user_name
DATABASE_PASSWORD=your_database_user_password
DATABASE_PORT=database_port
DATABASE_HOST=database_host
DATABASE_ENGINE=postgresql
3 changes: 3 additions & 0 deletions .gitignore

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Круто, что добавили *.session и .mypy_cache в .gitignore — меньше мусора в репо.

Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ coverage.xml
.python-version
.ruff_cache
.pytest_cache
*.log
db.sqlite3
PATH
itlabormarket_project_plan.md
Expand All @@ -23,3 +24,5 @@ error.html
**/node_modules/
staticfiles/
static/
*.session
.mypy_cache
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .celery import app as celery_app

__all__ = ("celery_app",)
8 changes: 8 additions & 0 deletions app/celery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
app = Celery("app")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
Empty file.
3 changes: 3 additions & 0 deletions app/services/auth/password_reset/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions app/services/auth/password_reset/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class PasswordResetConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "app.services.auth.password_reset"
4 changes: 4 additions & 0 deletions app/services/auth/password_reset/configs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Token expiration time in seconds
PASSWORD_RESET_TIMEOUT = 3600
# Maximum number of retries
MAX_RETRIES = 3
27 changes: 27 additions & 0 deletions app/services/auth/password_reset/migrations/0001_initial.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Проблемы миграции следуют из проблем моделей.
После того как поправите модель можно будет либо пересоздать миграцию, либо создать новую миграцию добавляющую необходимые изменения.

Так как это первая миграция, я бы порекомендовал пересоздать миграцию, чтобы было меньше > мусорных файлов и меньше операций по созданию таблиц.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.7 on 2025-12-24 16:23

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='PasswordResetToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField()),
('expires_at', models.DateTimeField()),
('is_used', models.BooleanField(default=False)),
('user_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
Empty file.
19 changes: 19 additions & 0 deletions app/services/auth/password_reset/models.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Модели PasswordResetToken необходимо скорректировать, сейчас в ней задаются не совсем верные поля.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db import models

from app.services.auth.users.models import User


class PasswordResetToken(models.Model):
user_id = models.ForeignKey(User, on_delete=models.CASCADE)
token_hash = models.CharField(null=True)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)

def mark_as_used(self):
self.is_used = True
self.save()

@classmethod
def mark_all_as_used(cls, user: User):
queryset = cls.objects.filter(is_used=False, user_id=user)
return queryset.update(is_used=True)
59 changes: 59 additions & 0 deletions app/services/auth/password_reset/tasks.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

Докстринг тут очень большой для task-функции. Я бы сократил до 5–10 самых важных строк, а подробности вынес в документацию.

Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import logging
from typing import Sequence

from django.core.mail import send_mail

from app.celery import app
from app.services.auth.password_reset import configs

logger = logging.getLogger(__name__)


@app.task(
bind=True,
max_retries=configs.MAX_RETRIES,
default_retry_delay=60,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
)
def send_mail_task(
self,
subject: str,
message: str,
html_message: str,
from_email: str,
recipient_list: Sequence[str],
fail_silently: bool = False,
) -> None:
"""
Асинхронная отправка email через Celery с автоматическими повторами.

Использует настройки Django EMAIL_* и конфиг `configs.MAX_RETRIES`
для повторных попыток.
Поддерживает plain text и HTML версии письма.
"""
current_retry = self.request.retries
max_retries = self.max_retries
try:
send_mail(
subject=subject,
message=message,
html_message=html_message,
from_email=from_email,
recipient_list=recipient_list,
fail_silently=fail_silently,
)
logger.info(f"Password reset email sent to '{recipient_list}'")
except Exception as e:
logger.warning(
f"Attempt {current_retry + 1} failed to send password "
f"reset email to '{recipient_list}': {str(e)}"
)
if current_retry >= max_retries - 1:
logger.error(
f"Failed to send password reset email to '{recipient_list}' "
f"after [{max_retries}] attempts"
)
if not fail_silently:
raise
98 changes: 98 additions & 0 deletions app/services/auth/password_reset/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from datetime import timedelta
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.test import Client
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from inertia.test import InertiaTestCase # type: ignore

from app.services.auth.password_reset import configs
from app.services.auth.password_reset.models import PasswordResetToken
from app.services.auth.password_reset.views import PasswordResetView

User = get_user_model()


class PasswordResetTestCase(InertiaTestCase):
def setUp(self):
super().setUp()
self.client = Client()
self.url = reverse_lazy("password_reset")
user_data = {
"email": "testuser@example.com",
"password": "password123",
}
self.user = User.objects.create_user(**user_data)
current_time = timezone.now()
time_out = timedelta(configs.PASSWORD_RESET_TIMEOUT)
expires_at = current_time + time_out
token_data = {
"user_id": self.user,
"token_hash": hash("test_token"),
"expires_at": expires_at,
}
self.token = PasswordResetToken.objects.create(**token_data)


@patch.object(PasswordResetView, "send_reset_email")
class PasswordResetTests(PasswordResetTestCase):
def test_post_valid_email(self, mock_get_stats):
mock_get_stats.return_value = None
response = self.client.post(self.url, {"email": "testuser@example.com"})
assert response.props["status_code"] == 200
token = PasswordResetToken.objects.filter(user_id=self.user.id).first()
assert token.token_hash
assert token.expires_at > timezone.now()

def test_post_invalid_email(self, mock_get_stats):
mock_get_stats.return_value = None
response = self.client.post(self.url, {"email": "invalid@example.com"})
assert response.props["status_code"] == 200
assert response.props["status"] == "ok"


class PasswordResetConfirmTests(PasswordResetTestCase):
def test_get_valid_token(self):
url = reverse("password_reset_confirm") + "?token=test_token"
self.client.get(url)
self.assertIncludesProps({"status_code": 200})

def test_get_expired_token(self):
PasswordResetToken.objects.create(
user_id=self.user,
token_hash=hash("expired_token"),
expires_at=timezone.now() - timedelta(hours=1),
)

url = reverse_lazy("password_reset_confirm") + "?token=expired_token"
self.client.get(url)
self.assertIncludesProps({"status_code": 400})

def test_post_valid_password(self):
url = reverse_lazy("password_reset_confirm")
data = {
"token": self.token.token_hash,
"newPassword": "StrongPass123!",
}
response = self.client.post(url, data)
assert response.props["status_code"] == 200

self.user.refresh_from_db()
self.token.refresh_from_db()
assert self.user.check_password("StrongPass123!")
assert self.token.is_used

def test_post_weak_password(self):
url = reverse_lazy("password_reset_confirm")
data = {"token": self.token.token_hash, "newPassword": "weak"}

self.client.post(url, data)
self.assertIncludesProps({"status_code": 422})

def test_post_invalid_token(self):
url = reverse_lazy("password_reset_confirm")
data = {"token": "invalid_token", "newPassword": "StrongPass123!"}

self.client.post(url, data)
self.assertIncludesProps({"status_code": 400})
16 changes: 16 additions & 0 deletions app/services/auth/password_reset/urls.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

Я бы сделал маршруты со слешем: forgot/ вместо forget и reset/ вместо reset — так будет единый стиль роутинга и меньше сюрпризов с редиректами/реверсом.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.urls import path

from . import views

urlpatterns = [
path(
"forgot/",
views.PasswordResetView.as_view(),
name="password_reset",
),
path(
"reset/",
views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm",
),
]
Loading