zmiany mariusza
This commit is contained in:
0
backend/users/__init__.py
Normal file
0
backend/users/__init__.py
Normal file
BIN
backend/users/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/admin.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/admin.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/apps.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/apps.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/authentication.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/authentication.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/models.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/permissions.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/permissions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/permissions.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/permissions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/serializers.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/serializers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/serializers.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/serializers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/urls.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/urls.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/validators.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/validators.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/views.cpython-312.pyc
Normal file
BIN
backend/users/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/__pycache__/views.cpython-313.pyc
Normal file
BIN
backend/users/__pycache__/views.cpython-313.pyc
Normal file
Binary file not shown.
4
backend/users/admin.py
Normal file
4
backend/users/admin.py
Normal file
@@ -0,0 +1,4 @@
|
||||
#Ten fragment kodu rejestruje aplikację użytkowników w systemie administracyjnym Django, umożliwiając zarządzanie modelami użytkowników z poziomu panelu administracyjnego.
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
8
backend/users/apps.py
Normal file
8
backend/users/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#Ten plik zawiera konfigurację aplikacji Django dla modułu użytkowników.
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
#Definicja klasy konfiguracyjnej aplikacji Django dla modułu użytkowników.
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'users'
|
||||
17
backend/users/authentication.py
Normal file
17
backend/users/authentication.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class VersionedJWTAuthentication(JWTAuthentication):
|
||||
"""
|
||||
Odrzuca KAŻDY access token, którego token_version ≠ user.token_version.
|
||||
"""
|
||||
def get_user(self, validated_token):
|
||||
user = super().get_user(validated_token)
|
||||
token_ver = int(validated_token.get("token_version", -1))
|
||||
user_ver = int(getattr(user, "token_version", 0))
|
||||
if token_ver != user_ver:
|
||||
raise AuthenticationFailed("Token is no longer valid", code="token_stale")
|
||||
return user
|
||||
62
backend/users/migrations/0001_initial.py
Normal file
62
backend/users/migrations/0001_initial.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 4.2 on 2025-05-01 18:16
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('role', models.CharField(choices=[('admin', 'Admin'), ('moderator', 'Moderator'), ('user', 'User')], default='user', max_length=10)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='custom_user_set', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='custom_user_set', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PasswordResetToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(max_length=100, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used', models.BooleanField(default=False)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Password Reset Token',
|
||||
'verbose_name_plural': 'Password Reset Tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
18
backend/users/migrations/0002_alter_user_username.py
Normal file
18
backend/users/migrations/0002_alter_user_username.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2 on 2025-08-07 07:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='username',
|
||||
field=models.CharField(max_length=150, unique=True),
|
||||
),
|
||||
]
|
||||
18
backend/users/migrations/0003_user_avatar_image.py
Normal file
18
backend/users/migrations/0003_user_avatar_image.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2 on 2025-08-08 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0002_alter_user_username'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='avatar_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='avatars/'),
|
||||
),
|
||||
]
|
||||
27
backend/users/migrations/0004_emailchangetoken.py
Normal file
27
backend/users/migrations/0004_emailchangetoken.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2 on 2025-08-10 14:09
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0003_user_avatar_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EmailChangeToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('new_email', models.EmailField(max_length=254)),
|
||||
('token', models.CharField(max_length=100, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used', models.BooleanField(default=False)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.2 on 2025-08-10 18:27
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('users', '0004_emailchangetoken'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Favorite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='favorite',
|
||||
index=models.Index(fields=['user', 'content_type'], name='users_favor_user_id_32bd10_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='favorite',
|
||||
index=models.Index(fields=['content_type', 'object_id'], name='users_favor_content_0c820d_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='favorite',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'content_type', 'object_id'), name='uniq_favorite_per_user_object'),
|
||||
),
|
||||
]
|
||||
18
backend/users/migrations/0006_user_bio.py
Normal file
18
backend/users/migrations/0006_user_bio.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2 on 2025-08-12 08:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0005_favorite_favorite_users_favor_user_id_32bd10_idx_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='bio',
|
||||
field=models.TextField(blank=True, max_length=300, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2 on 2025-08-13 16:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0006_user_bio'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='token_version',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='bio',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
]
|
||||
0
backend/users/migrations/__init__.py
Normal file
0
backend/users/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/users/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
backend/users/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/users/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/users/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
113
backend/users/models.py
Normal file
113
backend/users/models.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.utils import timezone
|
||||
import secrets
|
||||
import string
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings
|
||||
|
||||
# Create your models here.
|
||||
|
||||
# Ta klasa reprezentuje użytkownika w systemie. Dziedziczy po AbstractUser, co pozwala na rozszerzenie domyślnej funkcjonalności użytkownika Django.
|
||||
class User(AbstractUser):
|
||||
ROLES = (
|
||||
('admin', 'Admin'),
|
||||
('moderator', 'Moderator'),
|
||||
('user', 'User'),
|
||||
)
|
||||
role = models.CharField(max_length=10, choices=ROLES, default='user')
|
||||
avatar_image = models.ImageField(upload_to='avatars/', null=True, blank=True)
|
||||
username = models.CharField(max_length=150, unique=True)
|
||||
bio = models.TextField(blank=True, default='')
|
||||
token_version = models.IntegerField(default=0)
|
||||
# Add related_name to resolve clashes
|
||||
groups = models.ManyToManyField(
|
||||
'auth.Group',
|
||||
related_name='custom_user_set',
|
||||
blank=True,
|
||||
help_text='The groups this user belongs to.',
|
||||
verbose_name='groups',
|
||||
)
|
||||
user_permissions = models.ManyToManyField(
|
||||
'auth.Permission',
|
||||
related_name='custom_user_set',
|
||||
blank=True,
|
||||
help_text='Specific permissions for this user.',
|
||||
verbose_name='user permissions',
|
||||
)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
# Ta klasa obsługuje tokeny resetowania hasła, które są używane do weryfikacji użytkowników podczas procesu resetowania hasła.
|
||||
class PasswordResetToken(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=100, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.token:
|
||||
# Generate a random token
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
self.token = ''.join(secrets.choice(alphabet) for _ in range(64))
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
#Ta funkcja sprawdza, czy token jest ważny. Token jest ważny, jeśli nie został użyty i nie wygasł.
|
||||
def is_valid(self):
|
||||
return (
|
||||
not self.used and
|
||||
self.expires_at > timezone.now()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Password Reset Token'
|
||||
verbose_name_plural = 'Password Reset Tokens'
|
||||
|
||||
class EmailChangeToken(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
new_email = models.EmailField()
|
||||
token = models.CharField(max_length=100, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.token:
|
||||
import secrets, string
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
self.token = ''.join(secrets.choice(alphabet) for _ in range(64))
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def is_valid(self):
|
||||
from django.utils import timezone
|
||||
return (not self.used) and (self.expires_at > timezone.now())
|
||||
|
||||
class Favorite(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorites'
|
||||
)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['user', 'content_type', 'object_id'],
|
||||
name='uniq_favorite_per_user_object'
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'content_type']),
|
||||
models.Index(fields=['content_type', 'object_id']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user} ♥ {self.content_type.app_label}.{self.content_type.model}:{self.object_id}'
|
||||
70
backend/users/permissions.py
Normal file
70
backend/users/permissions.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#Ten fragment kodu definiuje niestandardowe uprawnienia dla aplikacji Django REST Framework, które kontrolują dostęp do edycji wpisów i komentarzy oraz publikacji treści.
|
||||
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
#Niestandardowe uprawnienia dla aplikacji Django REST Framework: Kontrola dostępu do edycji wpisów i komentarzy oraz publikacji treści.
|
||||
class IsAdminOrReadOnly(BasePermission):
|
||||
"""
|
||||
Pozwala tylko adminom na modyfikacje (POST, PUT, PATCH, DELETE),
|
||||
reszta użytkowników ma tylko odczyt (GET, HEAD, OPTIONS).
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return request.user and request.user.is_authenticated and request.user.is_staff
|
||||
|
||||
class IsAuthorOrReadOnly(BasePermission):
|
||||
"""
|
||||
Pozwala edytować tylko własny wpis/komentarz.
|
||||
"""
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return obj.author == request.user
|
||||
|
||||
# Kontrola dostęu do publikacji treści.
|
||||
class IsEditorOrAdmin(BasePermission):
|
||||
"""
|
||||
Pozwala publikować treści tylko edytorom lub adminom.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated and request.user.role in ['admin', 'moderator']
|
||||
|
||||
|
||||
class IsSelfOrAdmin(BasePermission):
|
||||
"""
|
||||
Odczyt (GET/HEAD/OPTIONS): dozwolony dla wszystkich.
|
||||
Zapis (POST/PUT/PATCH/DELETE): tylko właściciel konta albo admin/staff/superuser.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Odczyt – OK dla wszystkich
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
# Dla operacji modyfikujących – user musi być zalogowany
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Odczyt – OK dla wszystkich
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
user = request.user
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Admin/staff/superuser
|
||||
is_admin = getattr(user, 'is_superuser', False) or getattr(user, 'is_staff', False) \
|
||||
or getattr(user, 'role', None) == 'admin'
|
||||
|
||||
# Właściciel konta (obsługujemy zarówno User, jak i obiekty z polem user/owner/author)
|
||||
uid = getattr(user, 'id', None)
|
||||
is_owner = (
|
||||
obj is user or
|
||||
getattr(obj, 'id', None) == uid or
|
||||
getattr(obj, 'user_id', None) == uid or
|
||||
getattr(obj, 'owner_id', None) == uid or
|
||||
getattr(obj, 'author_id', None) == uid
|
||||
)
|
||||
|
||||
return is_owner or is_admin
|
||||
235
backend/users/serializers.py
Normal file
235
backend/users/serializers.py
Normal file
@@ -0,0 +1,235 @@
|
||||
#Serializery do obsługi użytkowników w aplikacji Django REST Framework.
|
||||
from rest_framework import serializers
|
||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer, TokenRefreshSerializer
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from .validators import validate_password_with_translation
|
||||
from django.apps import apps
|
||||
|
||||
from users.models import Favorite
|
||||
from .models import PasswordResetToken
|
||||
|
||||
|
||||
TYPE_REGISTRY = {
|
||||
"formula": {"app": "formulas", "model": "Formula", "lookup_field": "code"}, # po code
|
||||
"calculator": {"app": "tools", "model": "Calculator", "lookup_field": "pk"}, # po id/pk
|
||||
# dodawaj kolejne typy wg potrzeb
|
||||
}
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Serializer do rejestracji użytkowników.
|
||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, required=True, validators=[validate_password_with_translation])
|
||||
password2 = serializers.CharField(write_only=True, required=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('username', 'password', 'password2', 'email', 'first_name', 'last_name', 'role')
|
||||
extra_kwargs = {
|
||||
'first_name': {'required': True},
|
||||
'last_name': {'required': True},
|
||||
'email': {'required': True},
|
||||
'role': {'default': 'user'}
|
||||
}
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['password'] != attrs['password2']:
|
||||
raise serializers.ValidationError({"password": "Password fields didn't match."})
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('password2')
|
||||
user = User.objects.create_user(**validated_data)
|
||||
return user
|
||||
|
||||
# Serializer do wyświetlania i edycji użytkowników.
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'role', 'bio', 'date_joined', 'last_login', 'is_active')
|
||||
read_only_fields = ('role', 'username', 'is_staff', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'groups', 'user_permissions', 'id')
|
||||
|
||||
#Serializer do zmiany hasła.
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField(required=True)
|
||||
|
||||
def validate_email(self, value):
|
||||
try:
|
||||
User.objects.get(email=value)
|
||||
except User.DoesNotExist:
|
||||
pass # Don't reveal that the email doesn't exist
|
||||
return value
|
||||
|
||||
# Serializer do potwierdzenia resetowania hasła.
|
||||
class ResetPasswordConfirmSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(required=True)
|
||||
new_password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
|
||||
confirm_password = serializers.CharField(write_only=True, required=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['new_password'] != attrs['confirm_password']:
|
||||
raise serializers.ValidationError({"new_password": "Password fields didn't match."})
|
||||
|
||||
try:
|
||||
reset_token = PasswordResetToken.objects.get(token=attrs['token'])
|
||||
if not reset_token.is_valid():
|
||||
raise serializers.ValidationError({"token": "Invalid or expired token."})
|
||||
except PasswordResetToken.DoesNotExist:
|
||||
raise serializers.ValidationError({"token": "Invalid token."})
|
||||
|
||||
return attrs
|
||||
|
||||
class AvatarSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['avatar_image'] # zapis przez ImageField (writeable)
|
||||
|
||||
# Walidacja typu i rozmiaru
|
||||
def validate_avatar_image(self, file):
|
||||
if file is None:
|
||||
return file
|
||||
ct = getattr(file, 'content_type', None)
|
||||
if ct not in ('image/png', 'image/jpeg', 'image/webp'):
|
||||
raise serializers.ValidationError('Dozwolone: PNG, JPG, WEBP.')
|
||||
if file.size > 2 * 1024 * 1024:
|
||||
raise serializers.ValidationError('Maks 2 MB.')
|
||||
return file
|
||||
|
||||
# Zwracaj ABSOLUTNY URL w odpowiedzi (a nie tylko /media/...)
|
||||
def to_representation(self, instance):
|
||||
data = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
url = data.get('avatar_image')
|
||||
if url and request:
|
||||
# Jeśli storage zwrócił ścieżkę względną, zbuduj absolutny adres,
|
||||
# działający także za proxy/HTTPS.
|
||||
if not url.startswith('http'):
|
||||
proto = request.META.get('HTTP_X_FORWARDED_PROTO') or ('https' if request.is_secure() else 'http')
|
||||
host = request.META.get('HTTP_X_FORWARDED_HOST') or request.get_host()
|
||||
url = f'{proto}://{host}{url}'
|
||||
data['avatar_image'] = url
|
||||
return data
|
||||
|
||||
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||
"""
|
||||
Dodaje do tokena:
|
||||
- role (np. 'admin'/'user'/'moderator')
|
||||
- is_staff (bool)
|
||||
- adm (bool: True dla adminów)
|
||||
- token_version (do unieważniania tokenów)
|
||||
"""
|
||||
@classmethod
|
||||
def get_token(cls, user: User):
|
||||
token = super().get_token(user)
|
||||
|
||||
role = getattr(user, "role", None)
|
||||
is_staff = bool(getattr(user, "is_staff", False))
|
||||
|
||||
token["role"] = role
|
||||
token["is_staff"] = is_staff
|
||||
token["adm"] = bool(is_staff or role == "admin")
|
||||
token["username"] = user.get_username()
|
||||
token["token_version"] = getattr(user, "token_version", 0)
|
||||
|
||||
return token
|
||||
|
||||
|
||||
class CustomTokenRefreshSerializer(TokenRefreshSerializer):
|
||||
"""
|
||||
Refresh: odrzuca stary refresh (inna token_version) i nadaje nowemu access spójne claimy.
|
||||
"""
|
||||
def validate(self, attrs):
|
||||
try:
|
||||
refresh = RefreshToken(attrs['refresh'])
|
||||
except TokenError as e:
|
||||
raise InvalidToken(e.args[0])
|
||||
|
||||
user_id = refresh.get('user_id', None)
|
||||
if user_id is None:
|
||||
raise InvalidToken("Malformed refresh token")
|
||||
|
||||
user = User.objects.get(pk=user_id)
|
||||
|
||||
# TWARDY CHECK: refresh z inną wersją → odrzuć
|
||||
if int(refresh.get("token_version", 0)) != int(getattr(user, "token_version", 0)):
|
||||
raise InvalidToken("Refresh token invalidated by version bump")
|
||||
|
||||
# Standardowe działanie (zwróci 'access' i – jeśli ROTATE_REFRESH_TOKENS=True – nowy 'refresh')
|
||||
data = super().validate(attrs)
|
||||
|
||||
# Do nowego accessa wstrzykuj aktualne claimy
|
||||
new_access = RefreshToken(attrs['refresh']).access_token
|
||||
role = getattr(user, "role", None)
|
||||
is_staff = bool(getattr(user, "is_staff", False))
|
||||
|
||||
new_access['username'] = user.get_username()
|
||||
new_access['role'] = role
|
||||
new_access['is_staff'] = is_staff
|
||||
new_access['adm'] = bool(is_staff or role == "admin")
|
||||
new_access['token_version'] = getattr(user, "token_version", 0)
|
||||
|
||||
data['access'] = str(new_access)
|
||||
return data
|
||||
|
||||
class EmailChangeConfirmSerializer(serializers.Serializer):
|
||||
token = serializers.CharField(required=True)
|
||||
|
||||
|
||||
|
||||
class FavoriteCreateSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=list(TYPE_REGISTRY.keys()))
|
||||
ref = serializers.CharField(max_length=128) # dla pk też zadziała, zrzutujemy na int
|
||||
|
||||
def validate(self, attrs):
|
||||
cfg = TYPE_REGISTRY[attrs['type']]
|
||||
Model = apps.get_model(cfg['app'], cfg['model'])
|
||||
|
||||
lookup_field = cfg['lookup_field'] # 'code' albo 'pk'
|
||||
value = attrs['ref']
|
||||
|
||||
if lookup_field in ('pk', 'id'):
|
||||
# pk/id traktujemy jako int
|
||||
try:
|
||||
value = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise serializers.ValidationError({'ref': 'Wartość musi być liczbą (id/pk).'})
|
||||
|
||||
obj = Model.objects.filter(**{lookup_field: value}).first()
|
||||
if not obj:
|
||||
what = 'code' if lookup_field == 'code' else 'id'
|
||||
raise serializers.ValidationError({'ref': f'Obiekt typu {attrs["type"]} o podanym {what} nie istnieje.'})
|
||||
|
||||
attrs['content_type'] = ContentType.objects.get_for_model(Model)
|
||||
attrs['object_id'] = obj.pk
|
||||
attrs['resolved_ref'] = getattr(obj, 'code', obj.pk) # przyda się do odpowiedzi
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
user = self.context['request'].user
|
||||
fav, _ = Favorite.objects.get_or_create(
|
||||
user=user,
|
||||
content_type=validated_data['content_type'],
|
||||
object_id=validated_data['object_id'],
|
||||
)
|
||||
return fav
|
||||
|
||||
|
||||
class FavoriteDeleteSerializer(FavoriteCreateSerializer):
|
||||
# ten sam kontrakt (type + ref), tylko operacja delete
|
||||
def delete(self):
|
||||
user = self.context['request'].user
|
||||
Favorite.objects.filter(
|
||||
user=user,
|
||||
content_type=self.validated_data['content_type'],
|
||||
object_id=self.validated_data['object_id'],
|
||||
).delete()
|
||||
4
backend/users/tests.py
Normal file
4
backend/users/tests.py
Normal file
@@ -0,0 +1,4 @@
|
||||
#Obsługa testów dla aplikacji users
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
39
backend/users/urls.py
Normal file
39
backend/users/urls.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#Obsługa ścieżek używanych do zarządzania kontem użytkownika.
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
RegisterView,
|
||||
ChangePasswordView,
|
||||
ResetPasswordView,
|
||||
ResetPasswordConfirmView,
|
||||
UserViewSet,
|
||||
UserProfileView,
|
||||
ThrottledTokenObtainPairView,
|
||||
CustomTokenRefreshView,
|
||||
FavoriteView,
|
||||
UserFavoriteObjectView,
|
||||
UserFavoriteDetailView,
|
||||
AvatarView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'users-list', UserViewSet, basename='users-list')
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', ThrottledTokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('refresh/', CustomTokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('register/', RegisterView.as_view(), name='register'),
|
||||
path('change-password/', ChangePasswordView.as_view(), name='change-password'),
|
||||
path('reset-password/', ResetPasswordView.as_view(), name='reset-password'),
|
||||
path('reset-password/confirm/', ResetPasswordConfirmView.as_view(), name='reset-password-confirm'),
|
||||
path('profile/<str:username>/', UserProfileView.as_view(), name='user-profile'),
|
||||
path('favorites/', FavoriteView.as_view(), name='favorites'),
|
||||
path('<str:username>/favorites/', UserFavoriteObjectView.as_view(), name='user-favorite-objects'),
|
||||
path('<str:username>/favorites/<int:favorite_id>/', UserFavoriteDetailView.as_view(), name='user-favorite-detail'),
|
||||
path('<str:username>/avatar/', AvatarView.as_view(), name='avatar'),
|
||||
]
|
||||
|
||||
urlpatterns += [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
18
backend/users/validators.py
Normal file
18
backend/users/validators.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
TRANSLATION_MAP = {
|
||||
"This password is too short. It must contain at least 8 characters.": "Hasło jest za krótkie. Musi mieć co najmniej 8 znaków.",
|
||||
"This password is too common.": "Hasło jest zbyt popularne.",
|
||||
"This password is entirely numeric.": "Hasło nie może składać się wyłącznie z cyfr.",
|
||||
"The password is too similar to the username.": "Hasło jest zbyt podobne do nazwy użytkownika.",
|
||||
"The password is too similar to the email address.": "Hasło jest zbyt podobne do adresu e-mail.",
|
||||
# dodaj więcej jeśli potrzeba
|
||||
}
|
||||
|
||||
def validate_password_with_translation(password, user=None):
|
||||
try:
|
||||
validate_password(password, user)
|
||||
except ValidationError as e:
|
||||
translated_errors = [TRANSLATION_MAP.get(msg, msg) for msg in e.messages]
|
||||
raise ValidationError(translated_errors)
|
||||
525
backend/users/views.py
Normal file
525
backend/users/views.py
Normal file
@@ -0,0 +1,525 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User management views for Django + DRF (dev-ready, prod-notes in comments)."""
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
|
||||
from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.mail import send_mail
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery, DateTimeField, IntegerField
|
||||
|
||||
from rest_framework_simplejwt.token_blacklist.models import OutstandingToken, BlacklistedToken
|
||||
from rest_framework import generics, status, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError as DRFValidationError
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated, IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from .permissions import IsSelfOrAdmin
|
||||
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView # ← użyjemy z throttlingiem
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
|
||||
from .models import PasswordResetToken, Favorite
|
||||
from .serializers import (
|
||||
UserRegistrationSerializer,
|
||||
UserSerializer,
|
||||
ResetPasswordSerializer,
|
||||
ResetPasswordConfirmSerializer,
|
||||
AvatarSerializer,
|
||||
CustomTokenObtainPairSerializer,
|
||||
EmailChangeConfirmSerializer,
|
||||
CustomTokenRefreshSerializer,
|
||||
FavoriteCreateSerializer,
|
||||
FavoriteDeleteSerializer,
|
||||
TYPE_REGISTRY,
|
||||
)
|
||||
|
||||
from django.db.models import F
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# ✅ DEV + PROD: Rejestracja użytkownika
|
||||
class RegisterView(generics.CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = UserRegistrationSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return Response(
|
||||
{"message": "User registered successfully. Please login to continue."},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
# ✅ DEV + PROD: Widok listy użytkowników (tylko admin)
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
lookup_field = 'username'
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
if instance.role == 'admin' and getattr(request.user, 'role', '') != 'admin':
|
||||
return Response({'detail': 'Only admin can delete another admin.'}, status=403)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ✅ DEV + PROD: Profil użytkownika po username
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import User, EmailChangeToken
|
||||
from .serializers import UserSerializer
|
||||
|
||||
|
||||
class UserProfileView(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
GET /profile/<username>/ – podgląd dla wszystkich
|
||||
PUT/PATCH – tylko właściciel (bez wyjątków dla adminów)
|
||||
Zmiana e-maila: wymaga potwierdzenia linkiem.
|
||||
"""
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
lookup_field = 'username'
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "GET":
|
||||
return [AllowAny()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def _start_email_change_flow(self, request, obj, new_email):
|
||||
# Waliduj format i unikalność e-maila
|
||||
try:
|
||||
validate_email(new_email)
|
||||
except DjangoValidationError:
|
||||
raise DRFValidationError({"email": "Invalid email address."})
|
||||
if User.objects.filter(email__iexact=new_email).exclude(pk=obj.pk).exists():
|
||||
raise DRFValidationError({"email": "This email is already in use."})
|
||||
|
||||
# Weryfikacja hasła
|
||||
current_password = request.data.get("current_password")
|
||||
if not current_password or not request.user.check_password(current_password):
|
||||
raise DRFValidationError({"current_password": "Current password is required and must be correct."})
|
||||
|
||||
# Anuluj stare żądania
|
||||
EmailChangeToken.objects.filter(user=obj, used=False).update(used=True)
|
||||
|
||||
# Utwórz token ważny 24h
|
||||
tok = EmailChangeToken.objects.create(
|
||||
user=obj,
|
||||
new_email=new_email,
|
||||
expires_at=timezone.now() + timedelta(hours=24),
|
||||
)
|
||||
|
||||
confirm_url = f"{settings.FRONTEND_URL}/confirm-email-change/{tok.token}"
|
||||
|
||||
# Powiadomienia mailowe
|
||||
try:
|
||||
send_mail(
|
||||
"Confirm your new email",
|
||||
f"To confirm your new email, open: {confirm_url}",
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[new_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
if obj.email:
|
||||
send_mail(
|
||||
"Email change requested",
|
||||
"We received a request to change the email on your account. "
|
||||
"If this wasn't you, please secure your account immediately.",
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[obj.email],
|
||||
fail_silently=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
{"detail": "Email change requested. Check your new inbox to confirm."},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
return self.partial_update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
|
||||
# Blokada edycji przez innych użytkowników
|
||||
if request.user != obj:
|
||||
return Response({"detail": "You do not have permission to edit this profile."},
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
data = request.data.copy()
|
||||
|
||||
# Flow zmiany e-maila
|
||||
new_email = data.get("email")
|
||||
if new_email and new_email != obj.email:
|
||||
return self._start_email_change_flow(request, obj, new_email)
|
||||
|
||||
# Usuń email z aktualizacji, jeśli nie jest zmieniany
|
||||
data.pop("email", None)
|
||||
|
||||
serializer = self.get_serializer(obj, data=data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_update(serializer)
|
||||
return Response(serializer.data, status=200)
|
||||
|
||||
|
||||
# ✅ DEV + PROD: Zmiana hasła
|
||||
class ChangePasswordView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
old_password = request.data.get('old_password')
|
||||
new_password = request.data.get('new_password')
|
||||
|
||||
# 1) Walidacja wejścia
|
||||
if not old_password or not new_password:
|
||||
return Response({"detail": "Both old and new password are required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 2) Sprawdzenie starego hasła
|
||||
if not user.check_password(old_password):
|
||||
return Response({"detail": "Current password is incorrect."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 3) Polityka haseł
|
||||
try:
|
||||
validate_password(new_password, user)
|
||||
except DjangoValidationError as e:
|
||||
return Response({"detail": list(e.messages)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 4) Zapis nowego hasła + bump token_version (atomowo, bez race condition)
|
||||
with transaction.atomic():
|
||||
user.set_password(new_password)
|
||||
user.token_version = F('token_version') + 1
|
||||
user.save(update_fields=["password", "token_version"])
|
||||
user.refresh_from_db(fields=["token_version"])
|
||||
|
||||
# 5) (Opcjonalnie) ZBLACKLISTUJ wszystkie refresh jednym strzałem
|
||||
try:
|
||||
token_ids = list(
|
||||
OutstandingToken.objects.filter(user=user).values_list("id", flat=True)
|
||||
)
|
||||
if token_ids:
|
||||
BlacklistedToken.objects.bulk_create(
|
||||
[BlacklistedToken(token_id=tid) for tid in token_ids],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except Exception:
|
||||
pass # brak appki blacklist → nie wysypuj 500
|
||||
|
||||
return Response(
|
||||
{"detail": "Password successfully updated. All sessions have been logged out."},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
# ✅ DEV + PROD: Reset hasła (wysyłka maila z tokenem)
|
||||
class ResetPasswordView(generics.GenericAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = ResetPasswordSerializer
|
||||
throttle_scope = "reset_password" # ← działa po dodaniu w settings.py
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
email = serializer.validated_data['email']
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
# unieważnij stare tokeny
|
||||
PasswordResetToken.objects.filter(user=user, used=False).update(used=True)
|
||||
PasswordResetToken.objects.filter(user=user, expires_at__lte=timezone.now()).delete()
|
||||
|
||||
token = PasswordResetToken.objects.create(
|
||||
user=user,
|
||||
expires_at=timezone.now() + timedelta(hours=24)
|
||||
)
|
||||
|
||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{token.token}"
|
||||
send_mail(
|
||||
'Password Reset Request',
|
||||
f'Click the following link to reset your password: {reset_url}',
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
pass # nie ujawniamy czy email istnieje
|
||||
|
||||
return Response({
|
||||
'detail': 'If an account exists with this email, you will receive password reset instructions.'
|
||||
}, status=200)
|
||||
|
||||
|
||||
# ✅ DEV + PROD: Potwierdzenie resetu hasła
|
||||
class ResetPasswordConfirmView(generics.GenericAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = ResetPasswordConfirmSerializer
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
token = serializer.validated_data['token']
|
||||
new_password = serializer.validated_data['new_password']
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
reset_token = PasswordResetToken.objects.select_for_update().get(token=token)
|
||||
except PasswordResetToken.DoesNotExist:
|
||||
raise DRFValidationError({"token": "Invalid token."})
|
||||
|
||||
if not reset_token.is_valid():
|
||||
raise DRFValidationError({"token": "Invalid or expired token."})
|
||||
|
||||
user = reset_token.user
|
||||
try:
|
||||
validate_password(new_password, user)
|
||||
except DjangoValidationError as e:
|
||||
raise DRFValidationError({"new_password": list(e.messages)})
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
reset_token.used = True
|
||||
reset_token.save()
|
||||
|
||||
return Response({'detail': 'Password has been reset successfully.'}, status=200)
|
||||
|
||||
class AvatarView(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
GET -> publiczny podgląd URL avatara (z throttlingiem 'avatar_get')
|
||||
PATCH -> upload tylko dla właściciela (z throttlingiem 'avatar_upload')
|
||||
"""
|
||||
serializer_class = AvatarSerializer
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
throttle_classes = [ScopedRateThrottle] # <-- throttling włączony
|
||||
|
||||
def get_permissions(self):
|
||||
# GET dla każdego, PATCH tylko zalogowany
|
||||
return [AllowAny()] if self.request.method == 'GET' else [IsAuthenticated()]
|
||||
|
||||
def get_throttles(self):
|
||||
# Ustaw osobny scope zależnie od metody
|
||||
self.throttle_scope = 'avatar_get' if self.request.method == 'GET' else 'avatar_upload'
|
||||
return super().get_throttles()
|
||||
|
||||
def get_object(self):
|
||||
if self.request.method == 'GET':
|
||||
# publiczny odczyt po username
|
||||
return get_object_or_404(User, username=self.kwargs['username'])
|
||||
# zapis wyłącznie dla właściciela
|
||||
user = self.request.user
|
||||
if self.kwargs.get('username') != user.username:
|
||||
raise PermissionDenied('Brak uprawnień do edycji.')
|
||||
return user
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['request'] = self.request # absolutny URL w serializerze
|
||||
return ctx
|
||||
|
||||
# ✅ DEV + PROD: Logowanie z throttlingiem
|
||||
class ThrottledTokenObtainPairView(TokenObtainPairView):
|
||||
serializer_class = CustomTokenObtainPairSerializer
|
||||
throttle_scope = "login" # ← działa po dodaniu w settings.py
|
||||
|
||||
class EmailChangeConfirmView(generics.GenericAPIView):
|
||||
"""
|
||||
Potwierdza zmianę e-maila na podstawie tokenu z maila.
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
serializer_class = EmailChangeConfirmSerializer
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
token = serializer.validated_data["token"]
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
ect = EmailChangeToken.objects.select_for_update().get(token=token)
|
||||
except EmailChangeToken.DoesNotExist:
|
||||
raise DRFValidationError({"token": "Invalid token."})
|
||||
|
||||
if not ect.is_valid():
|
||||
raise DRFValidationError({"token": "Invalid or expired token."})
|
||||
|
||||
user = ect.user
|
||||
old_email = user.email
|
||||
user.email = ect.new_email
|
||||
user.save(update_fields=["email"])
|
||||
|
||||
ect.used = True
|
||||
ect.save(update_fields=["used"])
|
||||
|
||||
# Powiadomienie na nowy e-mail o udanej zmianie (opcjonalne)
|
||||
try:
|
||||
send_mail(
|
||||
"Your email was changed",
|
||||
"Your account email has been successfully updated.",
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[user.email],
|
||||
fail_silently=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response({"detail": "Email successfully updated."}, status=200)
|
||||
|
||||
|
||||
class CustomTokenRefreshView(TokenRefreshView):
|
||||
serializer_class = CustomTokenRefreshSerializer
|
||||
|
||||
class FavoriteView(APIView):
|
||||
"""
|
||||
Zarządzanie ulubionymi (dodaj/usuń) dla zalogowanego użytkownika, per-typ z TYPE_REGISTRY.
|
||||
|
||||
POST /api/favorites/
|
||||
Body: { "type": "formula", "ref": "1A2-34-01" } # formula → ref = code
|
||||
{ "type": "calculator", "ref": "42" } # calculator → ref = id/pk
|
||||
201: { "status": "added", "type": "...", "ref": "..." }
|
||||
|
||||
DELETE /api/favorites/?type=formula&ref=1A2-34-01
|
||||
/api/favorites/?type=calculator&ref=42
|
||||
204: (no content)
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
ser = FavoriteCreateSerializer(data=request.data, context={'request': request})
|
||||
ser.is_valid(raise_exception=True)
|
||||
fav = ser.save()
|
||||
|
||||
# 'resolved_ref' serializera – code lub pk w zależności od typu
|
||||
return Response(
|
||||
{"status": "added", "type": ser.validated_data["type"], "ref": ser.validated_data["resolved_ref"]},
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
def delete(self, request):
|
||||
ser = FavoriteDeleteSerializer(data=request.query_params, context={'request': request})
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UserFavoriteObjectView(APIView):
|
||||
"""
|
||||
GET /api/users/{username}/favorites/ # wszystkie typy
|
||||
GET /api/users/{username}/favorites/?type=formula # tylko wybrany typ
|
||||
|
||||
Zwracamy elementy:
|
||||
{
|
||||
"id": <ID rekordu Favorite>,
|
||||
"type": "<typ z registry>",
|
||||
"ref": "<code lub pk – wg lookup_field>",
|
||||
"title": "<jeśli model ma>",
|
||||
"slug": "<jeśli model ma>",
|
||||
"favorited_at": "<ISO>",
|
||||
# dla formula:
|
||||
"description": "...",
|
||||
"latex": "..."
|
||||
}
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, IsSelfOrAdmin]
|
||||
|
||||
def get(self, request, username):
|
||||
target = get_object_or_404(User, username=username)
|
||||
self.check_object_permissions(request, target)
|
||||
|
||||
only_type = request.query_params.get("type")
|
||||
if only_type and only_type not in TYPE_REGISTRY:
|
||||
return Response({"detail": "Unknown type."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
types = [only_type] if only_type else list(TYPE_REGISTRY.keys())
|
||||
results = []
|
||||
|
||||
for t in types:
|
||||
cfg = TYPE_REGISTRY[t]
|
||||
Model = apps.get_model(cfg["app"], cfg["model"])
|
||||
ct = ContentType.objects.get_for_model(Model)
|
||||
|
||||
# podzapytania: data i ID ulubionego
|
||||
fav_dt_sq = (Favorite.objects
|
||||
.filter(user=target, content_type=ct, object_id=OuterRef('pk'))
|
||||
.values('created_at')[:1])
|
||||
fav_id_sq = (Favorite.objects
|
||||
.filter(user=target, content_type=ct, object_id=OuterRef('pk'))
|
||||
.values('id')[:1])
|
||||
|
||||
qs = (Model.objects
|
||||
.filter(pk__in=Favorite.objects.filter(user=target, content_type=ct).values('object_id'))
|
||||
.annotate(favorited_at=Subquery(fav_dt_sq, output_field=DateTimeField()))
|
||||
.annotate(favorite_id=Subquery(fav_id_sq, output_field=IntegerField()))
|
||||
.order_by('-favorited_at', 'pk'))
|
||||
|
||||
lookup_field = cfg.get("lookup_field", "pk")
|
||||
|
||||
for obj in qs:
|
||||
ref_value = getattr(obj, lookup_field) if lookup_field != 'pk' else obj.pk
|
||||
|
||||
item = {
|
||||
"id": getattr(obj, "favorite_id", None), # ID w tabeli Favorite
|
||||
"type": t,
|
||||
"ref": ref_value, # dla formula = code
|
||||
"title": getattr(obj, "title", None),
|
||||
"slug": getattr(obj, "slug", None),
|
||||
"favorited_at": getattr(obj, "favorited_at", None),
|
||||
}
|
||||
|
||||
if t == "formula":
|
||||
item["description"] = getattr(obj, "description", None)
|
||||
# LaTeX z pola 'latex' lub awaryjnie z JSON-a w 'meta'
|
||||
latex = getattr(obj, "latex", None)
|
||||
if not latex:
|
||||
try:
|
||||
meta = json.loads(getattr(obj, "meta", "") or "{}")
|
||||
latex = meta.get("latex")
|
||||
except Exception:
|
||||
latex = None
|
||||
item["latex"] = latex
|
||||
|
||||
results.append(item)
|
||||
|
||||
results.sort(key=lambda x: (x["favorited_at"] is not None, x["favorited_at"]), reverse=True)
|
||||
return Response(results, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserFavoriteDetailView(APIView):
|
||||
"""
|
||||
DELETE /api/users/{username}/favorites/{favorite_id}/
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, IsSelfOrAdmin]
|
||||
|
||||
def delete(self, request, username, favorite_id):
|
||||
target = get_object_or_404(User, username=username)
|
||||
self.check_object_permissions(request, target)
|
||||
|
||||
fav = get_object_or_404(Favorite, id=favorite_id, user=target)
|
||||
fav.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user