diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..f077a3c
--- /dev/null
+++ b/backend/Dockerfile
@@ -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
diff --git a/backend/account/__init__.py b/backend/account/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/account/admin.py b/backend/account/admin.py
new file mode 100644
index 0000000..977191c
--- /dev/null
+++ b/backend/account/admin.py
@@ -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.
diff --git a/backend/account/apps.py b/backend/account/apps.py
new file mode 100644
index 0000000..2b08f1a
--- /dev/null
+++ b/backend/account/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class AccountConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'account'
diff --git a/backend/account/filters.py b/backend/account/filters.py
new file mode 100644
index 0000000..149681a
--- /dev/null
+++ b/backend/account/filters.py
@@ -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"
+ ]
diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py
new file mode 100644
index 0000000..514492d
--- /dev/null
+++ b/backend/account/migrations/0001_initial.py
@@ -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()),
+ ],
+ ),
+ ]
diff --git a/backend/account/migrations/__init__.py b/backend/account/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/account/models.py b/backend/account/models.py
new file mode 100644
index 0000000..ef7d280
--- /dev/null
+++ b/backend/account/models.py
@@ -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)
+
+
diff --git a/backend/account/permissions.py b/backend/account/permissions.py
new file mode 100644
index 0000000..6916300
--- /dev/null
+++ b/backend/account/permissions.py
@@ -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'
+
diff --git a/backend/account/serializers.py b/backend/account/serializers.py
new file mode 100644
index 0000000..1319eff
--- /dev/null
+++ b/backend/account/serializers.py
@@ -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
\ No newline at end of file
diff --git a/backend/account/tasks.py b/backend/account/tasks.py
new file mode 100644
index 0000000..d0e15c7
--- /dev/null
+++ b/backend/account/tasks.py
@@ -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
diff --git a/backend/account/templates/emails/email_verification.html b/backend/account/templates/emails/email_verification.html
new file mode 100644
index 0000000..aa25060
--- /dev/null
+++ b/backend/account/templates/emails/email_verification.html
@@ -0,0 +1,19 @@
+
+
+
+
+ Ověření e-mailu
+
+
+
+
+
+
+
Ověření e-mailu
+
Ověřte svůj e-mail kliknutím na odkaz níže:
+
Ověřit e-mail
+
+
+
+
+
diff --git a/backend/account/templates/emails/password_reset.html b/backend/account/templates/emails/password_reset.html
new file mode 100644
index 0000000..18158a5
--- /dev/null
+++ b/backend/account/templates/emails/password_reset.html
@@ -0,0 +1,19 @@
+
+
+
+
+ Obnova hesla
+
+
+
+
+
+
+
Obnova hesla
+
Pro obnovu hesla klikněte na následující odkaz:
+
Obnovit heslo
+
+
+
+
+
diff --git a/backend/account/tests.py b/backend/account/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/account/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/account/tokens.py b/backend/account/tokens.py
new file mode 100644
index 0000000..08e9bb2
--- /dev/null
+++ b/backend/account/tokens.py
@@ -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
+
diff --git a/backend/account/urls.py b/backend/account/urls.py
new file mode 100644
index 0000000..f43e03c
--- /dev/null
+++ b/backend/account/urls.py
@@ -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///', 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///', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
+
+ # User CRUD (list, retrieve, update, delete)
+ path('', include(router.urls)), #/users/
+]
diff --git a/backend/account/views.py b/backend/account/views.py
new file mode 100644
index 0000000..9744196
--- /dev/null
+++ b/backend/account/views.py
@@ -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)
\ No newline at end of file
diff --git a/backend/manage.py b/backend/manage.py
new file mode 100644
index 0000000..2780f95
--- /dev/null
+++ b/backend/manage.py
@@ -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()
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..be16d6b
--- /dev/null
+++ b/backend/requirements.txt
@@ -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
\ No newline at end of file
diff --git a/backend/thirdparty/gopay/__init__.py b/backend/thirdparty/gopay/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/gopay/admin.py b/backend/thirdparty/gopay/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/backend/thirdparty/gopay/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/backend/thirdparty/gopay/apps.py b/backend/thirdparty/gopay/apps.py
new file mode 100644
index 0000000..9121ef8
--- /dev/null
+++ b/backend/thirdparty/gopay/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class GopayConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'gopay'
diff --git a/backend/thirdparty/gopay/migrations/__init__.py b/backend/thirdparty/gopay/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/gopay/models.py b/backend/thirdparty/gopay/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/backend/thirdparty/gopay/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/backend/thirdparty/gopay/serializers.py b/backend/thirdparty/gopay/serializers.py
new file mode 100644
index 0000000..88501f4
--- /dev/null
+++ b/backend/thirdparty/gopay/serializers.py
@@ -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")
diff --git a/backend/thirdparty/gopay/tests.py b/backend/thirdparty/gopay/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/thirdparty/gopay/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/thirdparty/gopay/urls.py b/backend/thirdparty/gopay/urls.py
new file mode 100644
index 0000000..9f7a71e
--- /dev/null
+++ b/backend/thirdparty/gopay/urls.py
@@ -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//status/', GoPayPaymentStatusView.as_view(), name='gopay-payment-status'),
+ path('payment//refund/', GoPayRefundPaymentView.as_view(), name='gopay-refund-payment'),
+ path('payment//capture/', GoPayCaptureAuthorizationView.as_view(), name='gopay-capture-authorization'),
+ path('payment//void/', GoPayVoidAuthorizationView.as_view(), name='gopay-void-authorization'),
+ path('payment//recurrence/', GoPayCreateRecurrenceView.as_view(), name='gopay-create-recurrence'),
+ path('payment-instruments/', GoPayPaymentInstrumentsView.as_view(), name='gopay-payment-instruments'),
+]
diff --git a/backend/thirdparty/gopay/views.py b/backend/thirdparty/gopay/views.py
new file mode 100644
index 0000000..1bbd833
--- /dev/null
+++ b/backend/thirdparty/gopay/views.py
@@ -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)
\ No newline at end of file
diff --git a/backend/thirdparty/stripe/__init__.py b/backend/thirdparty/stripe/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/stripe/admin.py b/backend/thirdparty/stripe/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/backend/thirdparty/stripe/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/backend/thirdparty/stripe/apps.py b/backend/thirdparty/stripe/apps.py
new file mode 100644
index 0000000..793395d
--- /dev/null
+++ b/backend/thirdparty/stripe/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class StripeConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'stripe'
diff --git a/backend/thirdparty/stripe/migrations/__init__.py b/backend/thirdparty/stripe/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/stripe/models.py b/backend/thirdparty/stripe/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/backend/thirdparty/stripe/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/backend/thirdparty/stripe/serializers.py b/backend/thirdparty/stripe/serializers.py
new file mode 100644
index 0000000..7ad1635
--- /dev/null
+++ b/backend/thirdparty/stripe/serializers.py
@@ -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()
diff --git a/backend/thirdparty/stripe/tests.py b/backend/thirdparty/stripe/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/thirdparty/stripe/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/thirdparty/stripe/urls.py b/backend/thirdparty/stripe/urls.py
new file mode 100644
index 0000000..02287d5
--- /dev/null
+++ b/backend/thirdparty/stripe/urls.py
@@ -0,0 +1,6 @@
+from django.urls import path
+from .views import StripeCheckoutCZKView
+
+urlpatterns = [
+ path('checkout/', StripeCheckoutCZKView.as_view(), name='stripe-checkout-czk'),
+]
\ No newline at end of file
diff --git a/backend/thirdparty/stripe/views.py b/backend/thirdparty/stripe/views.py
new file mode 100644
index 0000000..8028b08
--- /dev/null
+++ b/backend/thirdparty/stripe/views.py
@@ -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})
diff --git a/backend/thirdparty/trading212/__init__.py b/backend/thirdparty/trading212/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/trading212/admin.py b/backend/thirdparty/trading212/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/backend/thirdparty/trading212/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/backend/thirdparty/trading212/apps.py b/backend/thirdparty/trading212/apps.py
new file mode 100644
index 0000000..6e47900
--- /dev/null
+++ b/backend/thirdparty/trading212/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class Trading212Config(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'trading212'
diff --git a/backend/thirdparty/trading212/migrations/__init__.py b/backend/thirdparty/trading212/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/thirdparty/trading212/models.py b/backend/thirdparty/trading212/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/backend/thirdparty/trading212/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/backend/thirdparty/trading212/serializers.py b/backend/thirdparty/trading212/serializers.py
new file mode 100644
index 0000000..85fdf39
--- /dev/null
+++ b/backend/thirdparty/trading212/serializers.py
@@ -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()
diff --git a/backend/thirdparty/trading212/tests.py b/backend/thirdparty/trading212/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/backend/thirdparty/trading212/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/backend/thirdparty/trading212/urls.py b/backend/thirdparty/trading212/urls.py
new file mode 100644
index 0000000..4475ca9
--- /dev/null
+++ b/backend/thirdparty/trading212/urls.py
@@ -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'),
+]
\ No newline at end of file
diff --git a/backend/thirdparty/trading212/views.py b/backend/thirdparty/trading212/views.py
new file mode 100644
index 0000000..67580be
--- /dev/null
+++ b/backend/thirdparty/trading212/views.py
@@ -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)