init
This commit is contained in:
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
0
backend/account/__init__.py
Normal file
0
backend/account/__init__.py
Normal file
23
backend/account/admin.py
Normal file
23
backend/account/admin.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id", "email", "role", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified", "create_time"
|
||||||
|
)
|
||||||
|
list_filter = ("role", "gdpr", "is_active", "email_verified", "city", "postal_code")
|
||||||
|
search_fields = ("email", "phone_number", "city", "street", "postal_code")
|
||||||
|
ordering = ("-create_time",)
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("email", "password", "role")}),
|
||||||
|
(_("Personal info"), {"fields": ("phone_number", "city", "street", "postal_code")}),
|
||||||
|
(_("Permissions"), {"fields": ("gdpr", "is_active", "email_verified")}),
|
||||||
|
(_("Important dates"), {"fields": ("create_time",)}),
|
||||||
|
)
|
||||||
|
readonly_fields = ("create_time",)
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/account/apps.py
Normal file
6
backend/account/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'account'
|
||||||
24
backend/account/filters.py
Normal file
24
backend/account/filters.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import django_filters
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserFilter(django_filters.FilterSet):
|
||||||
|
role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
|
||||||
|
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
|
||||||
|
phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains")
|
||||||
|
city = django_filters.CharFilter(field_name="city", lookup_expr="icontains")
|
||||||
|
street = django_filters.CharFilter(field_name="street", lookup_expr="icontains")
|
||||||
|
postal_code = django_filters.CharFilter(field_name="postal_code", lookup_expr="exact")
|
||||||
|
gdpr = django_filters.BooleanFilter(field_name="gdpr")
|
||||||
|
is_active = django_filters.BooleanFilter(field_name="is_active")
|
||||||
|
email_verified = django_filters.BooleanFilter(field_name="email_verified")
|
||||||
|
create_time_after = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="gte")
|
||||||
|
create_time_before = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="lte")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified",
|
||||||
|
"create_time_after", "create_time_before"
|
||||||
|
]
|
||||||
54
backend/account/migrations/0001_initial.py
Normal file
54
backend/account/migrations/0001_initial.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-13 23:19
|
||||||
|
|
||||||
|
import account.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.core.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
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')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('user', 'Uživatel')], max_length=32, null=True)),
|
||||||
|
('email_verified', models.BooleanField(default=False)),
|
||||||
|
('phone_number', models.CharField(blank=True, max_length=16, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
|
||||||
|
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||||
|
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('city', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('street', models.CharField(blank=True, max_length=200, null=True)),
|
||||||
|
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
||||||
|
('gdpr', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=False)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', account.models.CustomUserActiveManager()),
|
||||||
|
('all_objects', account.models.CustomUserAllManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/account/migrations/__init__.py
Normal file
0
backend/account/migrations/__init__.py
Normal file
152
backend/account/models.py
Normal file
152
backend/account/models.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import AbstractUser, Group, Permission
|
||||||
|
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from vontor_cz.models import SoftDeleteModel
|
||||||
|
|
||||||
|
from django.contrib.auth.models import UserManager
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Custom User Manager to handle soft deletion
|
||||||
|
class CustomUserActiveManager(UserManager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(is_deleted=False)
|
||||||
|
|
||||||
|
# Custom User Manager to handle all users, including soft deleted
|
||||||
|
class CustomUserAllManager(UserManager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||||
|
groups = models.ManyToManyField(
|
||||||
|
Group,
|
||||||
|
related_name="customuser_set", # <- přidáš related_name
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to.",
|
||||||
|
related_query_name="customuser",
|
||||||
|
)
|
||||||
|
user_permissions = models.ManyToManyField(
|
||||||
|
Permission,
|
||||||
|
related_name="customuser_set", # <- přidáš related_name
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_query_name="customuser",
|
||||||
|
)
|
||||||
|
|
||||||
|
ROLE_CHOICES = (
|
||||||
|
('admin', 'Administrátor'),
|
||||||
|
('user', 'Uživatel'),
|
||||||
|
)
|
||||||
|
role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True)
|
||||||
|
|
||||||
|
"""ACCOUNT_TYPES = (
|
||||||
|
('company', 'Firma'),
|
||||||
|
('individual', 'Fyzická osoba')
|
||||||
|
)
|
||||||
|
account_type = models.CharField(max_length=32, choices=ACCOUNT_TYPES, null=True, blank=True)"""
|
||||||
|
|
||||||
|
email_verified = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
phone_number = models.CharField(
|
||||||
|
unique=True,
|
||||||
|
max_length=16,
|
||||||
|
blank=True,
|
||||||
|
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
|
||||||
|
)
|
||||||
|
|
||||||
|
email = models.EmailField(unique=True, db_index=True)
|
||||||
|
create_time = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
"""company_id = models.CharField(
|
||||||
|
max_length=8,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex=r'^\d{8}$',
|
||||||
|
message="Company ID must contain exactly 8 digits.",
|
||||||
|
code='invalid_company_id'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)"""
|
||||||
|
|
||||||
|
"""personal_id = models.CharField(
|
||||||
|
max_length=11,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex=r'^\d{6}/\d{3,4}$',
|
||||||
|
message="Personal ID must be in the format 123456/7890.",
|
||||||
|
code='invalid_personal_id'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)"""
|
||||||
|
|
||||||
|
city = models.CharField(null=True, blank=True, max_length=100)
|
||||||
|
street = models.CharField(null=True, blank=True, max_length=200)
|
||||||
|
|
||||||
|
postal_code = models.CharField(
|
||||||
|
max_length=5,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex=r'^\d{5}$',
|
||||||
|
message="Postal code must contain exactly 5 digits.",
|
||||||
|
code='invalid_postal_code'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
gdpr = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
objects = CustomUserActiveManager()
|
||||||
|
all_objects = CustomUserAllManager()
|
||||||
|
|
||||||
|
REQUIRED_FIELDS = ['email', "username", "password"]
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
self.is_active = False
|
||||||
|
|
||||||
|
#self.orders.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||||
|
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self.pk is None # check BEFORE saving
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
|
||||||
|
if self.is_superuser or self.role == "admin":
|
||||||
|
self.is_active = True
|
||||||
|
|
||||||
|
if self.role == 'admin':
|
||||||
|
self.is_staff = True
|
||||||
|
self.is_superuser = True
|
||||||
|
|
||||||
|
if self.is_superuser:
|
||||||
|
self.role = 'admin'
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.is_staff = False
|
||||||
|
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
73
backend/account/permissions.py
Normal file
73
backend/account/permissions.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework_api_key.permissions import HasAPIKey
|
||||||
|
|
||||||
|
|
||||||
|
#Podle svého uvážení (NEPOUŽÍVAT!!!)
|
||||||
|
class RolePermission(BasePermission):
|
||||||
|
allowed_roles = []
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Je uživatel přihlášený a má roli z povolených?
|
||||||
|
user_has_role = (
|
||||||
|
request.user and
|
||||||
|
request.user.is_authenticated and
|
||||||
|
getattr(request.user, "role", None) in self.allowed_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
# Má API klíč?
|
||||||
|
has_api_key = HasAPIKey().has_permission(request, view)
|
||||||
|
|
||||||
|
|
||||||
|
return user_has_role or has_api_key
|
||||||
|
|
||||||
|
|
||||||
|
#TOHLE POUŽÍT!!!
|
||||||
|
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
||||||
|
def RoleAllowed(*roles):
|
||||||
|
"""
|
||||||
|
Allows safe methods for any authenticated user.
|
||||||
|
Allows unsafe methods only for users with specific roles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
RolerAllowed('admin', 'user')
|
||||||
|
"""
|
||||||
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Allow safe methods for any authenticated user
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return IsAuthenticated().has_permission(request, view)
|
||||||
|
|
||||||
|
# Otherwise, check the user's role
|
||||||
|
user = request.user
|
||||||
|
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
||||||
|
|
||||||
|
return SafeOrRolePermission
|
||||||
|
|
||||||
|
|
||||||
|
def OnlyRolesAllowed(*roles):
|
||||||
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Allows all methods only for users with specific roles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Otherwise, check the user's role
|
||||||
|
user = request.user
|
||||||
|
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
||||||
|
|
||||||
|
return SafeOrRolePermission
|
||||||
|
|
||||||
|
|
||||||
|
# For Settings.py
|
||||||
|
class AdminOnly(BasePermission):
|
||||||
|
""" Allows access only to users with the 'admin' role.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BasePermission (rest_framework.permissions.BasePermission): Base class for permission classes.
|
||||||
|
"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
210
backend/account/serializers.py
Normal file
210
backend/account/serializers.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import re
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from .permissions import *
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class CustomUserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"role",
|
||||||
|
"account_type",
|
||||||
|
"email_verified",
|
||||||
|
"phone_number",
|
||||||
|
"create_time",
|
||||||
|
"var_symbol",
|
||||||
|
"bank_account",
|
||||||
|
"ICO",
|
||||||
|
"RC",
|
||||||
|
"city",
|
||||||
|
"street",
|
||||||
|
"PSC",
|
||||||
|
"GDPR",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type"
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
user = self.context["request"].user
|
||||||
|
staff_only_fields = ["role", "email_verified", "var_symbol", "is_active"]
|
||||||
|
|
||||||
|
if user.role not in ["admin", "cityClerk"]:
|
||||||
|
unauthorized = [f for f in staff_only_fields if f in validated_data]
|
||||||
|
if unauthorized:
|
||||||
|
raise PermissionDenied(f"You are not allowed to modify: {', '.join(unauthorized)}")
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Token obtaining Default Serializer
|
||||||
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
username_field = User.USERNAME_FIELD
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
login = attrs.get("username")
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
# Allow login by username or email
|
||||||
|
user = User.objects.filter(email__iexact=login).first() or \
|
||||||
|
User.objects.filter(username__iexact=login).first()
|
||||||
|
|
||||||
|
if user is None or not user.check_password(password):
|
||||||
|
raise serializers.ValidationError(_("No active account found with the given credentials"))
|
||||||
|
|
||||||
|
# Call the parent validation to create token
|
||||||
|
data = super().validate({
|
||||||
|
self.username_field: user.username,
|
||||||
|
"password": password
|
||||||
|
})
|
||||||
|
|
||||||
|
data["user_id"] = user.id
|
||||||
|
data["username"] = user.username
|
||||||
|
data["email"] = user.email
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# user creating section start ------------------------------------------
|
||||||
|
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
help_text="Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'first_name', 'last_name', 'email', 'phone_number', 'password',
|
||||||
|
'city', 'street', 'postal_code', 'gdpr'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'},
|
||||||
|
'last_name': {'required': True, 'help_text': 'Příjmení uživatele'},
|
||||||
|
'email': {'required': True, 'help_text': 'Emailová adresa uživatele'},
|
||||||
|
'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'},
|
||||||
|
'city': {'required': True, 'help_text': 'Město uživatele'},
|
||||||
|
'street': {'required': True, 'help_text': 'Ulice uživatele'},
|
||||||
|
'postal_code': {'required': True, 'help_text': 'PSČ uživatele'},
|
||||||
|
'gdpr': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Password must be at least 8 characters long.")
|
||||||
|
if not re.search(r"[A-Z]", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one uppercase letter.")
|
||||||
|
if not re.search(r"[a-z]", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one lowercase letter.")
|
||||||
|
if not re.search(r"\d", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one digit.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
email = data.get("email")
|
||||||
|
phone = data.get("phone_number")
|
||||||
|
dgpr = data.get("GDPR")
|
||||||
|
if not dgpr:
|
||||||
|
raise serializers.ValidationError({"GDPR": "You must agree to the GDPR to register."})
|
||||||
|
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
raise serializers.ValidationError({"email": "Account with this email already exists."})
|
||||||
|
|
||||||
|
if phone and User.objects.filter(phone_number=phone).exists():
|
||||||
|
raise serializers.ValidationError({"phone_number": "Account with this phone number already exists."})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop("password")
|
||||||
|
username = validated_data.get("username", "")
|
||||||
|
user = User.objects.create(
|
||||||
|
username=username,
|
||||||
|
is_active=False, #uživatel je defaultně deaktivovaný
|
||||||
|
**validated_data
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
class UserActivationSerializer(serializers.Serializer):
|
||||||
|
user_id = serializers.IntegerField()
|
||||||
|
var_symbol = serializers.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(9999999999)])
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=self.validated_data['user_id'])
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise NotFound("User with this ID does not exist.")
|
||||||
|
user.var_symbol = self.validated_data['var_symbol']
|
||||||
|
user.is_active = True
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
return {
|
||||||
|
"id": instance.id,
|
||||||
|
"email": instance.email,
|
||||||
|
"var_symbol": instance.var_symbol,
|
||||||
|
"is_active": instance.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'user_id', 'var_symbol'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'user_id': {'required': True, 'help_text': 'ID uživatele'},
|
||||||
|
'var_symbol': {'required': True, 'help_text': 'Variablní symbol, zadán úředníkem'},
|
||||||
|
}
|
||||||
|
# user creating section end --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequestSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField(
|
||||||
|
help_text="E-mail registrovaného a aktivního uživatele, na který bude zaslán reset hesla."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
if not User.objects.filter(email=value, is_active=True).exists():
|
||||||
|
raise serializers.ValidationError("Účet s tímto emailem neexistuje nebo není aktivní.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
help_text="Nové heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
import re
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
|
||||||
|
if not re.search(r"[A-Z]", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat velké písmeno.")
|
||||||
|
if not re.search(r"[a-z]", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat malé písmeno.")
|
||||||
|
if not re.search(r"\d", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat číslici.")
|
||||||
|
return value
|
||||||
85
backend/account/tasks.py
Normal file
85
backend/account/tasks.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from .tokens import *
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_password_reset_email_task(user_id):
|
||||||
|
try:
|
||||||
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent."
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise Exception(error_msg)
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
token = password_reset_token.make_token(user)
|
||||||
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||||
|
html_message = render_to_string(
|
||||||
|
'emails/password_reset.html',
|
||||||
|
{'reset_url': reset_url}
|
||||||
|
)
|
||||||
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||||
|
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Obnova hesla",
|
||||||
|
message=None,
|
||||||
|
html_message=html_message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only email verification for user registration
|
||||||
|
@shared_task
|
||||||
|
def send_email_verification_task(user_id):
|
||||||
|
try:
|
||||||
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent."
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise Exception(error_msg)
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
token = account_activation_token.make_token(user)
|
||||||
|
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
|
html_message = render_to_string(
|
||||||
|
'emails/email_verification.html',
|
||||||
|
{'verification_url': verification_url}
|
||||||
|
)
|
||||||
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||||
|
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Ověření e-mailu",
|
||||||
|
message=None,
|
||||||
|
html_message=html_message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_with_context(recipients, subject, message=None, html_message=None):
|
||||||
|
"""
|
||||||
|
General function to send emails with a specific context.
|
||||||
|
"""
|
||||||
|
if isinstance(recipients, str):
|
||||||
|
recipients = [recipients]
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message if message else '',
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=recipients,
|
||||||
|
fail_silently=False,
|
||||||
|
html_message=html_message
|
||||||
|
)
|
||||||
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||||
|
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
|
return False
|
||||||
19
backend/account/templates/emails/email_verification.html
Normal file
19
backend/account/templates/emails/email_verification.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Ověření e-mailu</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Ověření e-mailu</h2>
|
||||||
|
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p>
|
||||||
|
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
backend/account/templates/emails/password_reset.html
Normal file
19
backend/account/templates/emails/password_reset.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Obnova hesla</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Obnova hesla</h2>
|
||||||
|
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p>
|
||||||
|
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
backend/account/tests.py
Normal file
3
backend/account/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
33
backend/account/tokens.py
Normal file
33
backend/account/tokens.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||||
|
|
||||||
|
# Subclass PasswordResetTokenGenerator to create a separate token generator
|
||||||
|
# for account activation. This allows future customization specific to activation tokens,
|
||||||
|
# even though it currently behaves exactly like the base class.
|
||||||
|
class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
|
||||||
|
pass # No changes yet; inherits all behavior from PasswordResetTokenGenerator
|
||||||
|
|
||||||
|
# Create an instance of AccountActivationTokenGenerator to be used for generating
|
||||||
|
# and validating account activation tokens throughout the app.
|
||||||
|
account_activation_token = AccountActivationTokenGenerator()
|
||||||
|
|
||||||
|
# Create an instance of the base PasswordResetTokenGenerator to be used
|
||||||
|
# for password reset tokens.
|
||||||
|
password_reset_token = PasswordResetTokenGenerator()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
|
||||||
|
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
|
||||||
|
class CookieJWTAuthentication(JWTAuthentication):
|
||||||
|
def authenticate(self, request):
|
||||||
|
|
||||||
|
raw_token = request.COOKIES.get('access_token')
|
||||||
|
|
||||||
|
if not raw_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
validated_token = self.get_validated_token(raw_token)
|
||||||
|
return self.get_user(validated_token), validated_token
|
||||||
|
|
||||||
27
backend/account/urls.py
Normal file
27
backend/account/urls.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'users', views.UserView, basename='user')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Auth endpoints
|
||||||
|
path('login/', views.CookieTokenObtainPairView.as_view(), name='login'),
|
||||||
|
path('token/refresh/', views.CookieTokenRefreshView.as_view(), name='token-refresh'),
|
||||||
|
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||||
|
path('user/me/', views.CurrentUserView.as_view(), name='user-detail'),
|
||||||
|
|
||||||
|
# Registration & email endpoints
|
||||||
|
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
||||||
|
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
||||||
|
path('activate/', views.UserActivationViewSet.as_view(), name='activate-user'),
|
||||||
|
|
||||||
|
# Password reset endpoints
|
||||||
|
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||||
|
path('password-reset-confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
|
||||||
|
|
||||||
|
# User CRUD (list, retrieve, update, delete)
|
||||||
|
path('', include(router.urls)), #/users/
|
||||||
|
]
|
||||||
421
backend/account/views.py
Normal file
421
backend/account/views.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
from django.contrib.auth import get_user_model, authenticate
|
||||||
|
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
|
||||||
|
from django.utils.encoding import force_bytes, force_str
|
||||||
|
|
||||||
|
from .serializers import *
|
||||||
|
from .permissions import *
|
||||||
|
from .models import CustomUser
|
||||||
|
from .tokens import *
|
||||||
|
from .tasks import send_password_reset_email_task
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from .filters import UserFilter
|
||||||
|
|
||||||
|
from rest_framework import generics, permissions, status, viewsets
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
#general user view API
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
|
||||||
|
#---------------------------------------------TOKENY------------------------------------------------
|
||||||
|
|
||||||
|
# Custom Token obtaining view
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Authentication"],
|
||||||
|
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
||||||
|
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
||||||
|
request=CustomTokenObtainPairSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=CustomTokenObtainPairSerializer, description="Tokens returned successfully."),
|
||||||
|
401: OpenApiResponse(description="Invalid credentials or inactive user."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class CookieTokenObtainPairView(TokenObtainPairView):
|
||||||
|
serializer_class = CustomTokenObtainPairSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
response = super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Získáme tokeny z odpovědi
|
||||||
|
access = response.data.get("access")
|
||||||
|
refresh = response.data.get("refresh")
|
||||||
|
|
||||||
|
if not access or not refresh:
|
||||||
|
return response # Např. při chybě přihlášení
|
||||||
|
|
||||||
|
jwt_settings = settings.SIMPLE_JWT
|
||||||
|
|
||||||
|
# Access token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key=jwt_settings.get("AUTH_COOKIE", "access_token"),
|
||||||
|
value=access,
|
||||||
|
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
|
||||||
|
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
|
||||||
|
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
|
||||||
|
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
|
||||||
|
max_age=5 * 60, # 5 minut
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=refresh,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="Lax",
|
||||||
|
path="/",
|
||||||
|
max_age=7 * 24 * 60 * 60, # 7 dní
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
username = attrs.get("username")
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
# Přihlaš uživatele ručně
|
||||||
|
user = authenticate(request=self.context.get('request'), username=username, password=password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise AuthenticationFailed("Špatné uživatelské jméno nebo heslo.")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthenticationFailed("Uživatel je deaktivován.")
|
||||||
|
|
||||||
|
# Nastav validní uživatele (přebere další logiku ze SimpleJWT)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
# Vrátí access a refresh token jako obvykle
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Authentication"],
|
||||||
|
summary="Refresh JWT token using cookie",
|
||||||
|
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Tokens refreshed successfully."),
|
||||||
|
400: OpenApiResponse(description="Refresh token cookie not found."),
|
||||||
|
401: OpenApiResponse(description="Invalid refresh token."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class CookieTokenRefreshView(APIView):
|
||||||
|
def post(self, request):
|
||||||
|
refresh_token = request.COOKIES.get('refresh_token')
|
||||||
|
if not refresh_token:
|
||||||
|
return Response({"detail": "Refresh token cookie not found."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
refresh = RefreshToken(refresh_token)
|
||||||
|
access_token = str(refresh.access_token)
|
||||||
|
new_refresh_token = str(refresh) # volitelně nový refresh token
|
||||||
|
|
||||||
|
response = Response({
|
||||||
|
"access": access_token,
|
||||||
|
"refresh": new_refresh_token,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Nastav nové HttpOnly cookies
|
||||||
|
# Access token cookie (např. 5 minut platnost)
|
||||||
|
response.set_cookie(
|
||||||
|
"access_token",
|
||||||
|
access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=5 * 60,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie (delší platnost, např. 7 dní)
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
new_refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=7 * 24 * 60 * 60,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except TokenError:
|
||||||
|
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
#---------------------------------------------LOGIN/LOGOUT------------------------------------------------
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Authentication"],
|
||||||
|
summary="Logout user (delete access and refresh token cookies)",
|
||||||
|
description="Logs out the user by deleting access and refresh token cookies.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Logout successful."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class LogoutView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# Smazání cookies
|
||||||
|
response.delete_cookie("access_token", path="/")
|
||||||
|
response.delete_cookie("refresh_token", path="/")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User"],
|
||||||
|
summary="List, retrieve, update, and delete users.",
|
||||||
|
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=CustomUserSerializer, description="User(s) retrieved successfully."),
|
||||||
|
403: OpenApiResponse(description="Permission denied."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class UserView(viewsets.ModelViewSet):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = CustomUserSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
filterset_class = UserFilter
|
||||||
|
|
||||||
|
# Require authentication and role permission
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomUser
|
||||||
|
extra_kwargs = {
|
||||||
|
"email": {"help_text": "Unikátní e-mailová adresa uživatele."},
|
||||||
|
"phone_number": {"help_text": "Telefonní číslo ve formátu +420123456789."},
|
||||||
|
"role": {"help_text": "Role uživatele určující jeho oprávnění v systému."},
|
||||||
|
"account_type": {"help_text": "Typ účtu – firma nebo fyzická osoba."},
|
||||||
|
"email_verified": {"help_text": "Určuje, zda je e-mail ověřen."},
|
||||||
|
"create_time": {"help_text": "Datum a čas registrace uživatele (pouze pro čtení).", "read_only": True},
|
||||||
|
"var_symbol": {"help_text": "Variabilní symbol pro platby, pokud je vyžadován."},
|
||||||
|
"bank_account": {"help_text": "Číslo bankovního účtu uživatele."},
|
||||||
|
"ICO": {"help_text": "IČO firmy, pokud se jedná o firemní účet."},
|
||||||
|
"RC": {"help_text": "Rodné číslo pro fyzické osoby."},
|
||||||
|
"city": {"help_text": "Město trvalého pobytu / sídla."},
|
||||||
|
"street": {"help_text": "Ulice a číslo popisné."},
|
||||||
|
"PSC": {"help_text": "PSČ místa pobytu / sídla."},
|
||||||
|
"GDPR": {"help_text": "Souhlas se zpracováním osobních údajů."},
|
||||||
|
"is_active": {"help_text": "Stav aktivace uživatele."},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
# Only admin can list or create users
|
||||||
|
if self.action in ['list', 'create']:
|
||||||
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
|
||||||
|
# Only admin or the user themselves can update or delete
|
||||||
|
elif self.action in ['update', 'partial_update', 'destroy']:
|
||||||
|
if self.request.user.role == 'admin':
|
||||||
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
else:
|
||||||
|
# fallback - deny access
|
||||||
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
|
||||||
|
# Any authenticated user can retrieve (view) any user's profile
|
||||||
|
elif self.action == 'retrieve':
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Get current user data
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User"],
|
||||||
|
summary="Get current authenticated user",
|
||||||
|
description="Returns details of the currently authenticated user based on JWT token or session.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=CustomUserSerializer, description="Current user details."),
|
||||||
|
401: OpenApiResponse(description="Unauthorized, user is not authenticated."),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
class CurrentUserView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
serializer = CustomUserSerializer(request.user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
#------------------------------------------------REGISTRACE--------------------------------------------------------------
|
||||||
|
|
||||||
|
#1. registration API
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User Registration"],
|
||||||
|
summary="Register a new user (company or individual)",
|
||||||
|
description="Register a new user (company or individual). The user will receive an email with a verification link.",
|
||||||
|
request=UserRegistrationSerializer,
|
||||||
|
responses={
|
||||||
|
201: OpenApiResponse(response=UserRegistrationSerializer, description="User registered successfully."),
|
||||||
|
400: OpenApiResponse(description="Invalid registration data."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class UserRegistrationViewSet(ModelViewSet):
|
||||||
|
queryset = CustomUser.objects.all()
|
||||||
|
serializer_class = UserRegistrationSerializer
|
||||||
|
http_method_names = ['post']
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
user = serializer.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email_verification_task.delay(user.id) # posílaní emailu pro potvrzení registrace - CELERY TASK
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Celery not available, using fallback. Error: {e}")
|
||||||
|
send_email_verification_task(user.id) # posílaní emailu pro potvrzení registrace
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
#2. confirming email
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User Registration"],
|
||||||
|
summary="Verify user email via link",
|
||||||
|
description="Verify user email using the link with uid and token.",
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH, description="User ID encoded in base64 from email link."),
|
||||||
|
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH, description="User token from email link."),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Email successfully verified."),
|
||||||
|
400: OpenApiResponse(description="Invalid or expired token."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class EmailVerificationView(APIView):
|
||||||
|
def get(self, request, uidb64, token):
|
||||||
|
try:
|
||||||
|
uid = force_str(urlsafe_base64_decode(uidb64))
|
||||||
|
user = User.objects.get(pk=uid)
|
||||||
|
except (User.DoesNotExist, ValueError, TypeError):
|
||||||
|
return Response({"error": "Neplatný odkaz."}, status=400)
|
||||||
|
|
||||||
|
if account_activation_token.check_token(user, token):
|
||||||
|
user.email_verified = True
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
|
||||||
|
else:
|
||||||
|
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
||||||
|
|
||||||
|
#3. seller activation API (var_symbol)
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User Registration"],
|
||||||
|
summary="Activate user and set variable symbol (admin/cityClerk only)",
|
||||||
|
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
|
||||||
|
request=UserActivationSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
|
||||||
|
400: OpenApiResponse(description="Invalid activation data."),
|
||||||
|
404: OpenApiResponse(description="User not found."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class UserActivationViewSet(APIView):
|
||||||
|
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
|
||||||
|
|
||||||
|
def patch(self, request, *args, **kwargs):
|
||||||
|
serializer = UserActivationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
user = serializer.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Celery not available, using fallback. Error: {e}")
|
||||||
|
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
|
||||||
|
|
||||||
|
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
||||||
|
|
||||||
|
#1. PasswordReset + send Email
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User password reset"],
|
||||||
|
summary="Request password reset (send email)",
|
||||||
|
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
||||||
|
request=PasswordResetRequestSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Email with instructions sent."),
|
||||||
|
400: OpenApiResponse(description="Invalid email or request data."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class PasswordResetRequestView(APIView):
|
||||||
|
def post(self, request):
|
||||||
|
serializer = PasswordResetRequestSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=serializer.validated_data['email'])
|
||||||
|
except User.DoesNotExist:
|
||||||
|
# Always return 200 even if user doesn't exist to avoid user enumeration
|
||||||
|
return Response({"detail": "E-mail s odkazem byl odeslán."})
|
||||||
|
try:
|
||||||
|
send_password_reset_email_task.delay(user.id) # posílaní emailu pro obnovení hesla - CELERY TASK
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Celery not available, using fallback. Error: {e}")
|
||||||
|
send_password_reset_email_task(user.id) # posílaní emailu pro obnovení hesla registrace
|
||||||
|
|
||||||
|
return Response({"detail": "E-mail s odkazem byl odeslán."})
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
#2. Confirming reset
|
||||||
|
@extend_schema(
|
||||||
|
tags=["User password reset"],
|
||||||
|
summary="Confirm password reset via token",
|
||||||
|
description="Confirm password reset using token from email.",
|
||||||
|
request=PasswordResetConfirmSerializer,
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH, description="User ID encoded in base64 from email link."),
|
||||||
|
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH, description="Password reset token from email link."),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Password changed successfully."),
|
||||||
|
400: OpenApiResponse(description="Invalid token or request data."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class PasswordResetConfirmView(APIView):
|
||||||
|
def post(self, request, uidb64, token):
|
||||||
|
try:
|
||||||
|
uid = force_str(urlsafe_base64_decode(uidb64))
|
||||||
|
user = User.objects.get(pk=uid)
|
||||||
|
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
|
||||||
|
return Response({"error": "Neplatný odkaz."}, status=400)
|
||||||
|
|
||||||
|
if not password_reset_token.check_token(user, token):
|
||||||
|
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
||||||
|
|
||||||
|
serializer = PasswordResetConfirmSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
user.set_password(serializer.validated_data['password'])
|
||||||
|
user.save()
|
||||||
|
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vontor_cz.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
91
backend/requirements.txt
Normal file
91
backend/requirements.txt
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# -- BASE --
|
||||||
|
requests
|
||||||
|
|
||||||
|
pip
|
||||||
|
python-dotenv # .env support
|
||||||
|
virtualenv #venv
|
||||||
|
|
||||||
|
Django
|
||||||
|
|
||||||
|
numpy # NumPy je knihovna programovacího jazyka Python, která poskytuje infrastrukturu pro práci s vektory, maticemi a obecně vícerozměrnými poli.
|
||||||
|
|
||||||
|
|
||||||
|
# -- DATABASE --
|
||||||
|
sqlparse #non-validating SQL parser for Python. It provides support for parsing, splitting and formatting SQL statements.
|
||||||
|
tzdata #timezone
|
||||||
|
|
||||||
|
|
||||||
|
psycopg[binary] #PostgreSQL database adapter for the Python
|
||||||
|
|
||||||
|
django-filter
|
||||||
|
|
||||||
|
django-constance #allows you to store and manage settings of page in the Django admin interface!!!!
|
||||||
|
|
||||||
|
# -- OBJECT STORAGE --
|
||||||
|
Pillow #adds image processing capabilities to your Python interpreter
|
||||||
|
|
||||||
|
whitenoise #pomáha se spuštěním serveru a načítaní static files
|
||||||
|
|
||||||
|
|
||||||
|
django-cleanup #odstraní zbytečné media soubory které nejsou v databázi/modelu
|
||||||
|
django-storages # potřeba k S3 bucket storage
|
||||||
|
boto3
|
||||||
|
|
||||||
|
|
||||||
|
# -- PROTOCOLS (asgi, websockets) --
|
||||||
|
redis
|
||||||
|
|
||||||
|
channels_redis
|
||||||
|
|
||||||
|
channels #django channels
|
||||||
|
|
||||||
|
#channels requried package
|
||||||
|
uvicorn[standard]
|
||||||
|
daphne
|
||||||
|
|
||||||
|
gunicorn
|
||||||
|
|
||||||
|
# -- REST API --
|
||||||
|
djangorestframework #REST Framework
|
||||||
|
|
||||||
|
djangorestframework-api-key #API key
|
||||||
|
|
||||||
|
djangorestframework-simplejwt #JWT authentication for Django REST Framework
|
||||||
|
PyJWT #JSON Web Token implementation in Python
|
||||||
|
|
||||||
|
asgiref #ASGI reference implementation, to be used with Django Channels
|
||||||
|
pytz
|
||||||
|
# pytz brings the Olson tz database into Python and allows
|
||||||
|
# accurate and cross platform timezone calculations.
|
||||||
|
# It also solves the issue of ambiguous times at the end of daylight saving time.
|
||||||
|
|
||||||
|
#documentation for frontend dev
|
||||||
|
drf-spectacular
|
||||||
|
|
||||||
|
# -- APPS --
|
||||||
|
|
||||||
|
django-tinymce
|
||||||
|
|
||||||
|
django-cors-headers #csfr
|
||||||
|
|
||||||
|
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
|
||||||
|
django-celery-beat #slouží k plánování úkolů pro Celery
|
||||||
|
|
||||||
|
|
||||||
|
# -- EDITING photos, gifs, videos --
|
||||||
|
|
||||||
|
#aiofiles
|
||||||
|
#opencv-python #moviepy use this better instead of pillow
|
||||||
|
#moviepy
|
||||||
|
|
||||||
|
#yt-dlp
|
||||||
|
|
||||||
|
weasyprint #tvoření PDFek z html dokumentu + css styly
|
||||||
|
|
||||||
|
## -- MISCELLANEOUS --
|
||||||
|
|
||||||
|
faker #generates fake data for testing purposes
|
||||||
|
|
||||||
|
## -- api --
|
||||||
|
stripe
|
||||||
|
gopay
|
||||||
0
backend/thirdparty/gopay/__init__.py
vendored
Normal file
0
backend/thirdparty/gopay/__init__.py
vendored
Normal file
3
backend/thirdparty/gopay/admin.py
vendored
Normal file
3
backend/thirdparty/gopay/admin.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/thirdparty/gopay/apps.py
vendored
Normal file
6
backend/thirdparty/gopay/apps.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GopayConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'gopay'
|
||||||
0
backend/thirdparty/gopay/migrations/__init__.py
vendored
Normal file
0
backend/thirdparty/gopay/migrations/__init__.py
vendored
Normal file
3
backend/thirdparty/gopay/models.py
vendored
Normal file
3
backend/thirdparty/gopay/models.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
37
backend/thirdparty/gopay/serializers.py
vendored
Normal file
37
backend/thirdparty/gopay/serializers.py
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayCreatePaymentRequestSerializer(serializers.Serializer):
|
||||||
|
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
||||||
|
currency = serializers.CharField(required=False, default="CZK")
|
||||||
|
order_number = serializers.CharField(required=False, allow_blank=True, default="order-001")
|
||||||
|
order_description = serializers.CharField(required=False, allow_blank=True, default="Example GoPay payment")
|
||||||
|
return_url = serializers.URLField(required=False)
|
||||||
|
notify_url = serializers.URLField(required=False)
|
||||||
|
preauthorize = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayPaymentCreatedResponseSerializer(serializers.Serializer):
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
state = serializers.CharField()
|
||||||
|
gw_url = serializers.URLField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayStatusResponseSerializer(serializers.Serializer):
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
state = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayRefundRequestSerializer(serializers.Serializer):
|
||||||
|
amount = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, min_value=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayCaptureRequestSerializer(serializers.Serializer):
|
||||||
|
amount = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, min_value=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayCreateRecurrenceRequestSerializer(serializers.Serializer):
|
||||||
|
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
||||||
|
currency = serializers.CharField(required=False, default="CZK")
|
||||||
|
order_number = serializers.CharField(required=False, allow_blank=True, default="recur-001")
|
||||||
|
order_description = serializers.CharField(required=False, allow_blank=True, default="Recurring payment")
|
||||||
3
backend/thirdparty/gopay/tests.py
vendored
Normal file
3
backend/thirdparty/gopay/tests.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
20
backend/thirdparty/gopay/urls.py
vendored
Normal file
20
backend/thirdparty/gopay/urls.py
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import (
|
||||||
|
GoPayPaymentView,
|
||||||
|
GoPayPaymentStatusView,
|
||||||
|
GoPayRefundPaymentView,
|
||||||
|
GoPayCaptureAuthorizationView,
|
||||||
|
GoPayVoidAuthorizationView,
|
||||||
|
GoPayCreateRecurrenceView,
|
||||||
|
GoPayPaymentInstrumentsView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('payment/', GoPayPaymentView.as_view(), name='gopay-payment'),
|
||||||
|
path('payment/<int:payment_id>/status/', GoPayPaymentStatusView.as_view(), name='gopay-payment-status'),
|
||||||
|
path('payment/<int:payment_id>/refund/', GoPayRefundPaymentView.as_view(), name='gopay-refund-payment'),
|
||||||
|
path('payment/<int:payment_id>/capture/', GoPayCaptureAuthorizationView.as_view(), name='gopay-capture-authorization'),
|
||||||
|
path('payment/<int:payment_id>/void/', GoPayVoidAuthorizationView.as_view(), name='gopay-void-authorization'),
|
||||||
|
path('payment/<int:payment_id>/recurrence/', GoPayCreateRecurrenceView.as_view(), name='gopay-create-recurrence'),
|
||||||
|
path('payment-instruments/', GoPayPaymentInstrumentsView.as_view(), name='gopay-payment-instruments'),
|
||||||
|
]
|
||||||
233
backend/thirdparty/gopay/views.py
vendored
Normal file
233
backend/thirdparty/gopay/views.py
vendored
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
import gopay
|
||||||
|
from gopay.enums import TokenScope, Language
|
||||||
|
import os
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
||||||
|
from .serializers import (
|
||||||
|
GoPayCreatePaymentRequestSerializer,
|
||||||
|
GoPayPaymentCreatedResponseSerializer,
|
||||||
|
GoPayStatusResponseSerializer,
|
||||||
|
GoPayRefundRequestSerializer,
|
||||||
|
GoPayCaptureRequestSerializer,
|
||||||
|
GoPayCreateRecurrenceRequestSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayClientMixin:
|
||||||
|
"""Shared helpers for configuring GoPay client and formatting responses."""
|
||||||
|
def get_gopay_client(self):
|
||||||
|
gateway_url = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api")
|
||||||
|
return gopay.payments({
|
||||||
|
"goid": os.getenv("GOPAY_GOID"),
|
||||||
|
"client_id": os.getenv("GOPAY_CLIENT_ID"),
|
||||||
|
"client_secret": os.getenv("GOPAY_CLIENT_SECRET"),
|
||||||
|
"gateway_url": gateway_url,
|
||||||
|
"scope": TokenScope.ALL,
|
||||||
|
"language": Language.CZECH,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _to_response(self, sdk_response):
|
||||||
|
# The GoPay SDK returns a response object with has_succeed(), json, errors, status_code
|
||||||
|
try:
|
||||||
|
if hasattr(sdk_response, "has_succeed") and sdk_response.has_succeed():
|
||||||
|
return Response(getattr(sdk_response, "json", {}))
|
||||||
|
status = getattr(sdk_response, "status_code", 400)
|
||||||
|
errors = getattr(sdk_response, "errors", None)
|
||||||
|
if errors is None and hasattr(sdk_response, "json"):
|
||||||
|
errors = sdk_response.json
|
||||||
|
if errors is None:
|
||||||
|
errors = {"detail": "GoPay request failed"}
|
||||||
|
return Response({"errors": errors}, status=status)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({"errors": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayPaymentView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Create GoPay payment",
|
||||||
|
description="Creates a GoPay payment and returns gateway URL and payment info.",
|
||||||
|
request=GoPayCreatePaymentRequestSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=GoPayPaymentCreatedResponseSerializer, description="Payment created"),
|
||||||
|
400: OpenApiResponse(description="Validation error or SDK error"),
|
||||||
|
},
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Create payment",
|
||||||
|
value={
|
||||||
|
"amount": 123.45,
|
||||||
|
"currency": "CZK",
|
||||||
|
"order_number": "order-001",
|
||||||
|
"order_description": "Example GoPay payment",
|
||||||
|
"return_url": "https://yourfrontend.com/success",
|
||||||
|
"notify_url": "https://yourbackend.com/gopay/notify",
|
||||||
|
"preauthorize": False,
|
||||||
|
},
|
||||||
|
request_only=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
amount = request.data.get("amount")
|
||||||
|
currency = request.data.get("currency", "CZK")
|
||||||
|
order_number = request.data.get("order_number", "order-001")
|
||||||
|
order_description = request.data.get("order_description", "Example GoPay payment")
|
||||||
|
return_url = request.data.get("return_url", "https://yourfrontend.com/success")
|
||||||
|
notify_url = request.data.get("notify_url", "https://yourbackend.com/gopay/notify")
|
||||||
|
preauthorize = bool(request.data.get("preauthorize", False))
|
||||||
|
|
||||||
|
if not amount:
|
||||||
|
return Response({"error": "Amount is required"}, status=400)
|
||||||
|
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
|
||||||
|
payment_data = {
|
||||||
|
"payer": {
|
||||||
|
"allowed_payment_instruments": ["PAYMENT_CARD"],
|
||||||
|
"default_payment_instrument": "PAYMENT_CARD",
|
||||||
|
"allowed_swifts": ["FIOB"],
|
||||||
|
"contact": {
|
||||||
|
"first_name": getattr(request.user, "first_name", ""),
|
||||||
|
"last_name": getattr(request.user, "last_name", ""),
|
||||||
|
"email": getattr(request.user, "email", ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"amount": int(float(amount) * 100), # GoPay expects amount in cents
|
||||||
|
"currency": currency,
|
||||||
|
"order_number": order_number,
|
||||||
|
"order_description": order_description,
|
||||||
|
"items": [
|
||||||
|
{"name": "Example Item", "amount": int(float(amount) * 100)}
|
||||||
|
],
|
||||||
|
"callback": {"return_url": return_url, "notify_url": notify_url},
|
||||||
|
"preauthorize": preauthorize,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = payments.create_payment(payment_data)
|
||||||
|
return self._to_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayPaymentStatusView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Get GoPay payment status",
|
||||||
|
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||||
|
responses={200: OpenApiResponse(response=GoPayStatusResponseSerializer, description="Payment status")},
|
||||||
|
)
|
||||||
|
def get(self, request, payment_id: int):
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
resp = payments.get_status(payment_id)
|
||||||
|
return self._to_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayRefundPaymentView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Refund GoPay payment",
|
||||||
|
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||||
|
request=GoPayRefundRequestSerializer,
|
||||||
|
responses={200: OpenApiResponse(description="Refund processed")},
|
||||||
|
)
|
||||||
|
def post(self, request, payment_id: int):
|
||||||
|
amount = request.data.get("amount") # optional for full refund
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
if amount is None or amount == "":
|
||||||
|
# Full refund
|
||||||
|
resp = payments.refund_payment(payment_id)
|
||||||
|
else:
|
||||||
|
resp = payments.refund_payment(payment_id, int(float(amount) * 100))
|
||||||
|
return self._to_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayCaptureAuthorizationView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Capture GoPay authorization",
|
||||||
|
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||||
|
request=GoPayCaptureRequestSerializer,
|
||||||
|
responses={200: OpenApiResponse(description="Capture processed")},
|
||||||
|
)
|
||||||
|
def post(self, request, payment_id: int):
|
||||||
|
amount = request.data.get("amount") # optional for partial capture
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
if amount is None or amount == "":
|
||||||
|
resp = payments.capture_authorization(payment_id)
|
||||||
|
else:
|
||||||
|
resp = payments.capture_authorization(payment_id, int(float(amount) * 100))
|
||||||
|
return self._to_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayVoidAuthorizationView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Void GoPay authorization",
|
||||||
|
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||||
|
responses={200: OpenApiResponse(description="Authorization voided")},
|
||||||
|
)
|
||||||
|
def post(self, request, payment_id: int):
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
resp = payments.void_authorization(payment_id)
|
||||||
|
return self._to_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayCreateRecurrenceView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Create GoPay recurrence",
|
||||||
|
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||||
|
request=GoPayCreateRecurrenceRequestSerializer,
|
||||||
|
responses={200: OpenApiResponse(description="Recurrence created")},
|
||||||
|
)
|
||||||
|
def post(self, request, payment_id: int):
|
||||||
|
amount = request.data.get("amount")
|
||||||
|
currency = request.data.get("currency", "CZK")
|
||||||
|
order_number = request.data.get("order_number", "recur-001")
|
||||||
|
order_description = request.data.get("order_description", "Recurring payment")
|
||||||
|
if not amount:
|
||||||
|
return Response({"error": "Amount is required"}, status=400)
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
recurrence_payload = {
|
||||||
|
"amount": int(float(amount) * 100),
|
||||||
|
"currency": currency,
|
||||||
|
"order_number": order_number,
|
||||||
|
"order_description": order_description,
|
||||||
|
}
|
||||||
|
resp = payments.create_recurrence(payment_id, recurrence_payload)
|
||||||
|
return self._to_response(resp)
|
||||||
|
|
||||||
|
|
||||||
|
class GoPayPaymentInstrumentsView(GoPayClientMixin, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["GoPay"],
|
||||||
|
summary="Get GoPay payment instruments",
|
||||||
|
parameters=[OpenApiParameter(name="currency", required=False, type=str, location=OpenApiParameter.QUERY)],
|
||||||
|
responses={200: OpenApiResponse(description="Available payment instruments returned")},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
currency = request.query_params.get("currency", "CZK")
|
||||||
|
goid = os.getenv("GOPAY_GOID")
|
||||||
|
if not goid:
|
||||||
|
return Response({"error": "GOPAY_GOID is not configured"}, status=500)
|
||||||
|
payments = self.get_gopay_client()
|
||||||
|
resp = payments.get_payment_instruments(goid, currency)
|
||||||
|
return self._to_response(resp)
|
||||||
0
backend/thirdparty/stripe/__init__.py
vendored
Normal file
0
backend/thirdparty/stripe/__init__.py
vendored
Normal file
3
backend/thirdparty/stripe/admin.py
vendored
Normal file
3
backend/thirdparty/stripe/admin.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/thirdparty/stripe/apps.py
vendored
Normal file
6
backend/thirdparty/stripe/apps.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class StripeConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'stripe'
|
||||||
0
backend/thirdparty/stripe/migrations/__init__.py
vendored
Normal file
0
backend/thirdparty/stripe/migrations/__init__.py
vendored
Normal file
3
backend/thirdparty/stripe/models.py
vendored
Normal file
3
backend/thirdparty/stripe/models.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
12
backend/thirdparty/stripe/serializers.py
vendored
Normal file
12
backend/thirdparty/stripe/serializers.py
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCheckoutRequestSerializer(serializers.Serializer):
|
||||||
|
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
||||||
|
product_name = serializers.CharField(required=False, default="Example Product")
|
||||||
|
success_url = serializers.URLField(required=False)
|
||||||
|
cancel_url = serializers.URLField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCheckoutResponseSerializer(serializers.Serializer):
|
||||||
|
url = serializers.URLField()
|
||||||
3
backend/thirdparty/stripe/tests.py
vendored
Normal file
3
backend/thirdparty/stripe/tests.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
6
backend/thirdparty/stripe/urls.py
vendored
Normal file
6
backend/thirdparty/stripe/urls.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import StripeCheckoutCZKView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('checkout/', StripeCheckoutCZKView.as_view(), name='stripe-checkout-czk'),
|
||||||
|
]
|
||||||
71
backend/thirdparty/stripe/views.py
vendored
Normal file
71
backend/thirdparty/stripe/views.py
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import stripe
|
||||||
|
import os
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
||||||
|
from .serializers import (
|
||||||
|
StripeCheckoutRequestSerializer,
|
||||||
|
StripeCheckoutResponseSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
class StripeCheckoutCZKView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Stripe"],
|
||||||
|
summary="Create Stripe Checkout session in CZK",
|
||||||
|
description="Creates a Stripe Checkout session for payment in Czech Koruna (CZK). Requires authentication.",
|
||||||
|
request=StripeCheckoutRequestSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=StripeCheckoutResponseSerializer, description="Stripe Checkout session URL returned successfully."),
|
||||||
|
400: OpenApiResponse(description="Amount is required or invalid."),
|
||||||
|
},
|
||||||
|
examples=[
|
||||||
|
OpenApiExample(
|
||||||
|
"Success",
|
||||||
|
value={"url": "https://checkout.stripe.com/pay/cs_test_123456"},
|
||||||
|
response_only=True,
|
||||||
|
status_codes=["200"],
|
||||||
|
),
|
||||||
|
OpenApiExample(
|
||||||
|
"Missing amount",
|
||||||
|
value={"error": "Amount is required"},
|
||||||
|
response_only=True,
|
||||||
|
status_codes=["400"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = StripeCheckoutRequestSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
amount = serializer.validated_data.get("amount")
|
||||||
|
product_name = serializer.validated_data.get("product_name", "Example Product")
|
||||||
|
success_url = serializer.validated_data.get("success_url", "https://yourfrontend.com/success")
|
||||||
|
cancel_url = serializer.validated_data.get("cancel_url", "https://yourfrontend.com/cancel")
|
||||||
|
# Stripe expects amount in the smallest currency unit (haléř = 1/100 CZK)
|
||||||
|
amount_in_haler = int(amount * 100)
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
payment_method_types=['card'],
|
||||||
|
line_items=[{
|
||||||
|
'price_data': {
|
||||||
|
'currency': 'czk',
|
||||||
|
'product_data': {
|
||||||
|
'name': product_name,
|
||||||
|
},
|
||||||
|
'unit_amount': amount_in_haler,
|
||||||
|
},
|
||||||
|
'quantity': 1,
|
||||||
|
}],
|
||||||
|
mode='payment',
|
||||||
|
success_url=success_url,
|
||||||
|
cancel_url=cancel_url,
|
||||||
|
customer_email=getattr(request.user, 'email', None)
|
||||||
|
)
|
||||||
|
return Response({"url": session.url})
|
||||||
0
backend/thirdparty/trading212/__init__.py
vendored
Normal file
0
backend/thirdparty/trading212/__init__.py
vendored
Normal file
3
backend/thirdparty/trading212/admin.py
vendored
Normal file
3
backend/thirdparty/trading212/admin.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/thirdparty/trading212/apps.py
vendored
Normal file
6
backend/thirdparty/trading212/apps.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class Trading212Config(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'trading212'
|
||||||
0
backend/thirdparty/trading212/migrations/__init__.py
vendored
Normal file
0
backend/thirdparty/trading212/migrations/__init__.py
vendored
Normal file
3
backend/thirdparty/trading212/models.py
vendored
Normal file
3
backend/thirdparty/trading212/models.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
11
backend/thirdparty/trading212/serializers.py
vendored
Normal file
11
backend/thirdparty/trading212/serializers.py
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# thirdparty/trading212/serializers.py
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class Trading212AccountCashSerializer(serializers.Serializer):
|
||||||
|
blocked = serializers.FloatField()
|
||||||
|
free = serializers.FloatField()
|
||||||
|
invested = serializers.FloatField()
|
||||||
|
pieCash = serializers.FloatField()
|
||||||
|
ppl = serializers.FloatField()
|
||||||
|
result = serializers.FloatField()
|
||||||
|
total = serializers.FloatField()
|
||||||
3
backend/thirdparty/trading212/tests.py
vendored
Normal file
3
backend/thirdparty/trading212/tests.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
6
backend/thirdparty/trading212/urls.py
vendored
Normal file
6
backend/thirdparty/trading212/urls.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import YourTrading212View # Replace with actual view class
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('your-endpoint/', YourTrading212View.as_view(), name='trading212-endpoint'),
|
||||||
|
]
|
||||||
37
backend/thirdparty/trading212/views.py
vendored
Normal file
37
backend/thirdparty/trading212/views.py
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# thirdparty/trading212/views.py
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from decouple import config
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from .serializers import Trading212AccountCashSerializer
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
|
class Trading212AccountCashView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Get Trading212 account cash",
|
||||||
|
responses=Trading212AccountCashSerializer
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
api_key = os.getenv("API_KEY_TRADING212")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = "https://api.trading212.com/api/v0/equity/account/cash"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return Response({"error": str(exc)}, status=400)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
serializer = Trading212AccountCashSerializer(data=data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return Response(serializer.data)
|
||||||
Reference in New Issue
Block a user