From 696d0e61f1bab63b1a3c487ef245b53a7173c34d Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Wed, 1 Oct 2025 18:37:59 +0200 Subject: [PATCH] init --- backend/Dockerfile | 10 + backend/account/__init__.py | 0 backend/account/admin.py | 23 + backend/account/apps.py | 6 + backend/account/filters.py | 24 + backend/account/migrations/0001_initial.py | 54 +++ backend/account/migrations/__init__.py | 0 backend/account/models.py | 152 +++++++ backend/account/permissions.py | 73 +++ backend/account/serializers.py | 210 +++++++++ backend/account/tasks.py | 85 ++++ .../templates/emails/email_verification.html | 19 + .../templates/emails/password_reset.html | 19 + backend/account/tests.py | 3 + backend/account/tokens.py | 33 ++ backend/account/urls.py | 27 ++ backend/account/views.py | 421 ++++++++++++++++++ backend/manage.py | 22 + backend/requirements.txt | 91 ++++ backend/thirdparty/gopay/__init__.py | 0 backend/thirdparty/gopay/admin.py | 3 + backend/thirdparty/gopay/apps.py | 6 + .../thirdparty/gopay/migrations/__init__.py | 0 backend/thirdparty/gopay/models.py | 3 + backend/thirdparty/gopay/serializers.py | 37 ++ backend/thirdparty/gopay/tests.py | 3 + backend/thirdparty/gopay/urls.py | 20 + backend/thirdparty/gopay/views.py | 233 ++++++++++ backend/thirdparty/stripe/__init__.py | 0 backend/thirdparty/stripe/admin.py | 3 + backend/thirdparty/stripe/apps.py | 6 + .../thirdparty/stripe/migrations/__init__.py | 0 backend/thirdparty/stripe/models.py | 3 + backend/thirdparty/stripe/serializers.py | 12 + backend/thirdparty/stripe/tests.py | 3 + backend/thirdparty/stripe/urls.py | 6 + backend/thirdparty/stripe/views.py | 71 +++ backend/thirdparty/trading212/__init__.py | 0 backend/thirdparty/trading212/admin.py | 3 + backend/thirdparty/trading212/apps.py | 6 + .../trading212/migrations/__init__.py | 0 backend/thirdparty/trading212/models.py | 3 + backend/thirdparty/trading212/serializers.py | 11 + backend/thirdparty/trading212/tests.py | 3 + backend/thirdparty/trading212/urls.py | 6 + backend/thirdparty/trading212/views.py | 37 ++ 46 files changed, 1750 insertions(+) create mode 100644 backend/Dockerfile create mode 100644 backend/account/__init__.py create mode 100644 backend/account/admin.py create mode 100644 backend/account/apps.py create mode 100644 backend/account/filters.py create mode 100644 backend/account/migrations/0001_initial.py create mode 100644 backend/account/migrations/__init__.py create mode 100644 backend/account/models.py create mode 100644 backend/account/permissions.py create mode 100644 backend/account/serializers.py create mode 100644 backend/account/tasks.py create mode 100644 backend/account/templates/emails/email_verification.html create mode 100644 backend/account/templates/emails/password_reset.html create mode 100644 backend/account/tests.py create mode 100644 backend/account/tokens.py create mode 100644 backend/account/urls.py create mode 100644 backend/account/views.py create mode 100644 backend/manage.py create mode 100644 backend/requirements.txt create mode 100644 backend/thirdparty/gopay/__init__.py create mode 100644 backend/thirdparty/gopay/admin.py create mode 100644 backend/thirdparty/gopay/apps.py create mode 100644 backend/thirdparty/gopay/migrations/__init__.py create mode 100644 backend/thirdparty/gopay/models.py create mode 100644 backend/thirdparty/gopay/serializers.py create mode 100644 backend/thirdparty/gopay/tests.py create mode 100644 backend/thirdparty/gopay/urls.py create mode 100644 backend/thirdparty/gopay/views.py create mode 100644 backend/thirdparty/stripe/__init__.py create mode 100644 backend/thirdparty/stripe/admin.py create mode 100644 backend/thirdparty/stripe/apps.py create mode 100644 backend/thirdparty/stripe/migrations/__init__.py create mode 100644 backend/thirdparty/stripe/models.py create mode 100644 backend/thirdparty/stripe/serializers.py create mode 100644 backend/thirdparty/stripe/tests.py create mode 100644 backend/thirdparty/stripe/urls.py create mode 100644 backend/thirdparty/stripe/views.py create mode 100644 backend/thirdparty/trading212/__init__.py create mode 100644 backend/thirdparty/trading212/admin.py create mode 100644 backend/thirdparty/trading212/apps.py create mode 100644 backend/thirdparty/trading212/migrations/__init__.py create mode 100644 backend/thirdparty/trading212/models.py create mode 100644 backend/thirdparty/trading212/serializers.py create mode 100644 backend/thirdparty/trading212/tests.py create mode 100644 backend/thirdparty/trading212/urls.py create mode 100644 backend/thirdparty/trading212/views.py 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)