Compare commits

...

9 Commits

Author SHA1 Message Date
a324a9cf49 Merge branch 'bruno' of https://git.vontor.cz/Brunobrno/vontor-cz into bruno 2025-11-04 02:16:18 +01:00
47b9770a70 GoPay 2025-11-04 02:16:17 +01:00
David Bruno Vontor
4791bbc92c websockets + chat app (django) 2025-10-31 13:32:39 +01:00
8dd4f6e731 converter 2025-10-30 01:58:28 +01:00
dd9d076bd2 okay 2025-10-29 00:58:37 +01:00
73da41b514 commit 2025-10-28 03:21:01 +01:00
10796dcb31 integrace api, stripe, vytvoření commecre app 2025-10-05 23:41:14 +02:00
f5cf8bbaa7 style changes 2025-10-03 01:48:36 +02:00
d0227e4539 fixed components 2025-10-02 02:10:07 +02:00
119 changed files with 4705 additions and 1605 deletions

119
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,119 @@
# Copilot Instructions for Vontor CZ
## Overview
This monorepo contains a Django backend and a Vite/React frontend, orchestrated via Docker Compose. The project is designed for a Czech e-marketplace, with custom payment integrations and real-time features.
## Architecture
- **backend/**: Django project (`vontor_cz`), custom `account` app, and third-party payment integrations (`thirdparty/`).
- Uses Django REST Framework, Channels (ASGI), Celery, and S3/static/media via `django-storages`.
- Custom user model: `account.CustomUser`.
- API docs: DRF Spectacular (`/api/schema/`).
- **frontend/**: Vite + React + TypeScript app.
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`).
- Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS.
## Developer Workflows
- **Backend**
- Local dev: `python manage.py runserver` (or use Docker Compose)
- Migrations: `python manage.py makemigrations && python manage.py migrate`
- Celery: `celery -A vontor_cz worker -l info`
- Channels: Daphne/ASGI (see Docker Compose command)
- Env config: `.env` files in `backend/` (see `.gitignore` for secrets)
- **Frontend**
- Install: `npm install`
- Dev server: `npm run dev`
- Build: `npm run build`
- Preview: `npm run preview`
- Static assets: `src/assets/` (import in JS/CSS), `public/` (referenced in HTML)
## Conventions & Patterns
- **Backend**
- Use environment variables for secrets and config (see `settings.py`).
- Static/media files: S3 in production, local in dev (see `settings.py`).
- API versioning and docs: DRF Spectacular config in `settings.py`.
- Custom permissions, filters, and serializers in each app.
- **Frontend**
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
- API calls and JWT handling in `src/api/`.
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
- Use TypeScript strict mode (see `tsconfig.*.json`).
- Linting: ESLint config in `eslint.config.js`.
- Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary.
### Frontend API Client (required)
All frontend API calls must use the shared client at frontend/src/api/Client.ts.
- Client.public: no cookies, no Authorization header (for public Django endpoints).
- Client.auth: sends cookies and includes Bearer token; auto-refreshes on 401 (retries up to 2x).
- Centralized error handling: subscribe via Client.onError to show toasts/snackbars.
- Tokens are stored in cookies by Client.setTokens and cleared by Client.clearTokens.
Example usage (TypeScript)
```ts
import Client from "@/api/Client";
// Public request (no credentials)
async function listPublicItems() {
const res = await Client.public.get("/api/public/items/");
return res.data;
}
// Login (obtain tokens and persist to cookies)
async function login(username: string, password: string) {
// Default SimpleJWT endpoint (adjust if your backend differs)
const res = await Client.public.post("/api/token/", { username, password });
const { access, refresh } = res.data;
Client.setTokens(access, refresh);
}
// Authenticated requests (auto Bearer + refresh on 401)
async function fetchProfile() {
const res = await Client.auth.get("/api/users/me/");
return res.data;
}
function logout() {
Client.clearTokens();
window.location.assign("/login");
}
// Global error toasts
import { useEffect } from "react";
function useApiErrors(showToast: (msg: string) => void) {
useEffect(() => {
const unsubscribe = Client.onError((e) => {
const { message, status } = e.detail;
showToast(status ? `${status}: ${message}` : message);
});
return unsubscribe;
}, [showToast]);
}
```
Vite env used by the client:
- VITE_API_BASE_URL (default: http://localhost:8000)
- VITE_API_REFRESH_URL (default: /api/token/refresh/)
- VITE_LOGIN_PATH (default: /login)
Notes
- Public client never sends cookies or Authorization.
- Ensure Django CORS settings allow your frontend origin. See backend/vontor_cz/settings.py.
- Use React Router layouts and guards as documented in frontend/src/routes/ROUTES.md and frontend/src/layouts/LAYOUTS.md.
## Integration Points
- **Payments**: `thirdparty/` contains custom integrations for Stripe, GoPay, Trading212.
- **Real-time**: Django Channels (ASGI, Redis) for websockets.
- **Task queue**: Celery + Redis for async/background jobs.
- **API**: REST endpoints, JWT auth, API key support.
## References
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.
- [frontend/src/routes/ROUTES.md](../frontend/src/routes/ROUTES.md): Routing conventions.
- [backend/vontor_cz/settings.py](../backend/vontor_cz/settings.py): All backend config, env, and integration details.
- [docker-compose.yml](../docker-compose.yml): Service orchestration and dev workflow.
---
**When in doubt, check the referenced markdown files and `settings.py` for project-specific logic and patterns.**

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@@ -2,6 +2,8 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt update && apt install ffmpeg -y
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.5 on 2025-08-13 23:19 # Generated by Django 5.2.7 on 2025-10-28 22:28
import account.models import account.models
import django.contrib.auth.validators import django.contrib.auth.validators
@@ -30,16 +30,16 @@ class Migration(migrations.Migration):
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('is_deleted', models.BooleanField(default=False)), ('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)), ('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)), ('role', models.CharField(choices=[('admin', 'Admin'), ('mod', 'Moderator'), ('regular', 'Regular')], default='regular', max_length=20)),
('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
('email_verified', models.BooleanField(default=False)), ('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)), ('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('gdpr', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=False)),
('create_time', models.DateTimeField(auto_now_add=True)), ('create_time', models.DateTimeField(auto_now_add=True)),
('city', models.CharField(blank=True, max_length=100, null=True)), ('city', models.CharField(blank=True, max_length=100, null=True)),
('street', models.CharField(blank=True, max_length=200, 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}$')])), ('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')), ('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')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
], ],
@@ -47,8 +47,8 @@ class Migration(migrations.Migration):
'abstract': False, 'abstract': False,
}, },
managers=[ managers=[
('objects', account.models.CustomUserActiveManager()), ('objects', account.models.CustomUserManager()),
('all_objects', account.models.CustomUserAllManager()), ('active', account.models.ActiveUserManager()),
], ],
), ),
] ]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-10-31 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='email_verification_sent_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='email_verification_token',
field=models.CharField(blank=True, db_index=True, max_length=128, null=True),
),
]

View File

@@ -1,8 +1,9 @@
import uuid import uuid
from django.db import models from django.db import models
from django.contrib.auth.models import AbstractUser, Group, Permission from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator from django.core.validators import RegexValidator
from django.utils.crypto import get_random_string
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@@ -16,16 +17,13 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Custom User Manager to handle soft deletion class CustomUserManager(UserManager):
class CustomUserActiveManager(UserManager): # Inherit get_by_natural_key and all auth behaviors
def get_queryset(self): use_in_migrations = True
return super().get_queryset().filter(is_deleted=False)
# Custom User Manager to handle all users, including soft deleted class ActiveUserManager(CustomUserManager):
class CustomUserAllManager(UserManager):
def get_queryset(self): def get_queryset(self):
return super().get_queryset() return super().get_queryset().filter(is_active=True)
class CustomUser(SoftDeleteModel, AbstractUser): class CustomUser(SoftDeleteModel, AbstractUser):
groups = models.ManyToManyField( groups = models.ManyToManyField(
@@ -43,64 +41,45 @@ class CustomUser(SoftDeleteModel, AbstractUser):
related_query_name="customuser", related_query_name="customuser",
) )
ROLE_CHOICES = ( class Role(models.TextChoices):
('admin', 'Administrátor'), ADMIN = "admin", "Admin"
('user', 'Uživatel'), MANAGER = "mod", "Moderator"
) CUSTOMER = "regular", "Regular"
role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
"""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( phone_number = models.CharField(
null=True,
blank=True,
unique=True, unique=True,
max_length=16, max_length=16,
blank=True,
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")] validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
) )
email_verified = models.BooleanField(default=False)
email = models.EmailField(unique=True, db_index=True) email = models.EmailField(unique=True, db_index=True)
# + fields for email verification flow
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
gdpr = models.BooleanField(default=False)
is_active = models.BooleanField(default=False)
create_time = models.DateTimeField(auto_now_add=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) city = models.CharField(null=True, blank=True, max_length=100)
street = models.CharField(null=True, blank=True, max_length=200) street = models.CharField(null=True, blank=True, max_length=200)
postal_code = models.CharField( postal_code = models.CharField(
max_length=5,
blank=True, blank=True,
null=True, null=True,
max_length=5,
validators=[ validators=[
RegexValidator( RegexValidator(
regex=r'^\d{5}$', regex=r'^\d{5}$',
@@ -109,44 +88,73 @@ class CustomUser(SoftDeleteModel, AbstractUser):
) )
] ]
) )
gdpr = models.BooleanField(default=False)
is_active = models.BooleanField(default=False) USERNAME_FIELD = "username"
REQUIRED_FIELDS = [
"email"
]
objects = CustomUserActiveManager() # Ensure default manager has get_by_natural_key
all_objects = CustomUserAllManager() objects = CustomUserManager()
# Optional convenience manager for active users only
REQUIRED_FIELDS = ['email', "username", "password"] active = ActiveUserManager()
def __str__(self):
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.is_active = False self.is_active = False
#self.orders.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None # check BEFORE saving is_new = self._state.adding # True if object hasn't been saved yet
# Pre-save flags for new users
if is_new: if is_new:
if self.is_superuser or self.role == "admin": if self.is_superuser or self.role == "admin":
# ensure admin flags are consistent
self.is_active = True self.is_active = True
self.is_staff = True
if self.role == 'admin': self.is_superuser = True
self.is_staff = True self.role = "admin"
self.is_superuser = True
if self.is_superuser:
self.role = 'admin'
else: else:
self.is_staff = False self.is_staff = False
# First save to obtain a primary key
super().save(*args, **kwargs)
# Assign group after we have a PK
if is_new:
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name=self.role)
# Use add/set now that PK exists
self.groups.set([group])
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
token = get_random_string(length=length)
self.email_verification_token = token
self.email_verification_sent_at = timezone.now()
if save:
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
return token
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
if not token or not self.email_verification_token:
return False
# optional expiry check
if self.email_verification_sent_at:
age = timezone.now() - self.email_verification_sent_at
if age > timedelta(hours=max_age_hours):
return False
if token != self.email_verification_token:
return False
if not self.email_verified:
self.email_verified = True
# clear token after success
self.email_verification_token = None
self.email_verification_sent_at = None
if save:
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
return True

View File

@@ -1,41 +1,25 @@
from urllib import request
from rest_framework.permissions import BasePermission, SAFE_METHODS from rest_framework.permissions import BasePermission, SAFE_METHODS
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework_api_key.permissions import HasAPIKey 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!!! #TOHLE POUŽÍT!!!
#Prostě stačí vložit: RoleAllowed('seller','cityClerk') #Prostě stačí vložit: RoleAllowed('seller','cityClerk')
def RoleAllowed(*roles): def RoleAllowed(*roles):
""" """
Allows safe methods for any authenticated user. Allows safe methods for any authenticated user.
Allows unsafe methods only for users with specific roles. Allows unsafe methods only for users with specific roles.
Allows access if a valid API key is provided.
Args: Args:
RolerAllowed('admin', 'user') RoleAllowed('admin', 'user')
""" """
class SafeOrRolePermission(BasePermission): class SafeOrRolePermission(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
# Má API klíč?
has_api_key = HasAPIKey().has_permission(request, view)
# Allow safe methods for any authenticated user # Allow safe methods for any authenticated user
if request.method in SAFE_METHODS: if request.method in SAFE_METHODS:
return IsAuthenticated().has_permission(request, view) return IsAuthenticated().has_permission(request, view)

View File

@@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer):
"last_name", "last_name",
"email", "email",
"role", "role",
"account_type",
"email_verified", "email_verified",
"phone_number", "phone_number",
"create_time", "create_time",
"var_symbol",
"bank_account",
"ICO",
"RC",
"city", "city",
"street", "street",
"PSC", "postal_code",
"GDPR", "gdpr",
"is_active", "is_active",
] ]
read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type" read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
def update(self, instance, validated_data): def update(self, instance, validated_data):
user = self.context["request"].user user = self.context["request"].user

View File

@@ -10,76 +10,143 @@ from .models import CustomUser
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
@shared_task def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
def send_password_reset_email_task(user_id): """
try: General function to send emails with a specific context.
user = CustomUser.objects.get(pk=user_id) Supports rendering plain text and HTML templates.
except CustomUser.DoesNotExist: Converts `user` in context to a plain dict to avoid template access to the model.
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent." """
logger.error(error_msg) if isinstance(recipients, str):
raise Exception(error_msg) recipients = [recipients]
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 html_message = None
if template_name or html_template_name:
# Best effort to resolve both templates if only one provided
if not template_name and html_template_name:
template_name = html_template_name.replace(".html", ".txt")
if not html_template_name and template_name:
html_template_name = template_name.replace(".txt", ".html")
ctx = dict(context or {})
# Sanitize user if someone passes the model by mistake
if "user" in ctx and not isinstance(ctx["user"], dict):
try:
ctx["user"] = _build_user_template_ctx(ctx["user"])
except Exception:
ctx["user"] = {}
message = render_to_string(template_name, ctx)
html_message = render_to_string(html_template_name, ctx)
try:
send_mail(
subject=subject,
message=message or "",
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(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
return True
except Exception as e:
logger.error(f"E-mail se neodeslal: {e}")
return False
def _build_user_template_ctx(user: CustomUser) -> dict:
"""
Return a plain dict for templates instead of passing the DB model.
Provides aliases to avoid template errors (firstname vs first_name).
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
"""
first_name = getattr(user, "first_name", "") or ""
last_name = getattr(user, "last_name", "") or ""
full_name = f"{first_name} {last_name}".strip()
return {
"id": user.pk,
"email": getattr(user, "email", "") or "",
"first_name": first_name,
"firstname": first_name, # alias for templates using `firstname`
"last_name": last_name,
"lastname": last_name, # alias for templates using `lastname`
"full_name": full_name,
"get_full_name": full_name, # compatibility for templates using method-style access
}
#----------------------------------------------------------------------------------------------------
# This function sends an email to the user for email verification after registration.
@shared_task @shared_task
def send_email_verification_task(user_id): def send_email_verification_task(user_id):
try: try:
user = CustomUser.objects.get(pk=user_id) user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist: except CustomUser.DoesNotExist:
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent." logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
logger.error(error_msg) return 0
raise Exception(error_msg)
uid = urlsafe_base64_encode(force_bytes(user.pk)) uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user) # {changed} generate and store a per-user token
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}" token = user.generate_email_verification_token()
html_message = render_to_string( verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
'emails/email_verification.html',
{'verification_url': verification_url} context = {
) "user": _build_user_template_ctx(user),
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': "action_url": verify_url,
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU") "frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
}
send_email_with_context( send_email_with_context(
recipients=user.email, recipients=user.email,
subject="Ověření e-mailu", subject="Ověření emailu",
message=None, template_name="email/email_verification.txt",
html_message=html_message html_template_name="email/email_verification.html",
context=context,
) )
def send_email_with_context(recipients, subject, message=None, html_message=None): @shared_task
""" def send_email_test_task(email):
General function to send emails with a specific context. context = {
""" "action_url": settings.FRONTEND_URL,
if isinstance(recipients, str): "frontend_url": settings.FRONTEND_URL,
recipients = [recipients] "cta_label": "Otevřít aplikaci",
}
send_email_with_context(
recipients=email,
subject="Testovací email",
template_name="email/test.txt",
html_template_name="email/test.html",
context=context,
)
@shared_task
def send_password_reset_email_task(user_id):
try: try:
send_mail( user = CustomUser.objects.get(pk=user_id)
subject=subject, except CustomUser.DoesNotExist:
message=message if message else '', logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
from_email=None, return 0
recipient_list=recipients,
fail_silently=False, uid = urlsafe_base64_encode(force_bytes(user.pk))
html_message=html_message token = password_reset_token.make_token(user)
) reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU") context = {
return True "user": _build_user_template_ctx(user),
except Exception as e: "action_url": reset_url,
logger.error(f"E-mail se neodeslal: {e}") "frontend_url": settings.FRONTEND_URL,
return False "cta_label": "Obnovit heslo",
}
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
template_name="email/password_reset.txt",
html_template_name="email/password_reset.html",
context=context,
)

View File

@@ -1,19 +1,46 @@
<!DOCTYPE html> <!doctype html>
<html lang="cs"> <html lang="cs">
<head> <body style="margin:0; padding:0; background-color:#f5f7fb;">
<meta charset="UTF-8"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<title>Ověření e-mailu</title> <tr>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <td align="center" style="padding:24px;">
</head> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<body> <tr>
<div class="container mt-5"> <td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
<div class="card"> Ověření emailu
<div class="card-body"> </td>
<h2 class="card-title">Ověření e-mailu</h2> </tr>
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p> <tr>
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a> <td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
</div> {% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
</div> <p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
</div> {% endwith %}
</body> <p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na tlačítko níže.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html> </html>

View File

@@ -0,0 +1,7 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na následující odkaz:
{{ action_url }}
Pokud jste účet nevytvořili vy, tento email ignorujte.

View File

@@ -1,19 +1,46 @@
<!DOCTYPE html> <!doctype html>
<html lang="cs"> <html lang="cs">
<head> <body style="margin:0; padding:0; background-color:#f5f7fb;">
<meta charset="UTF-8"> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<title>Obnova hesla</title> <tr>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <td align="center" style="padding:24px;">
</head> <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<body> <tr>
<div class="container mt-5"> <td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
<div class="card"> Obnova hesla
<div class="card-body"> </td>
<h2 class="card-title">Obnova hesla</h2> </tr>
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p> <tr>
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a> <td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
</div> {% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
</div> <p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
</div> {% endwith %}
</body> <p style="margin:0 0 12px 0;">Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento email ignorujte.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html> </html>

View File

@@ -0,0 +1,7 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu.
Pokud jste o změnu nepožádali, tento email ignorujte.
Pro nastavení nového hesla použijte tento odkaz:
{{ action_url }}

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Testovací email
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
<p style="margin:0 0 12px 0;">Dobrý den,</p>
<p style="margin:0 0 16px 0;">Toto je testovací email z aplikace etržnice.</p>
{% if action_url and cta_label %}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
<tr>
<td bgcolor="#2563eb" style="border-radius:6px;">
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
{% endif %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,6 @@
Dobrý den,
Toto je testovací email z aplikace etržnice.
Odkaz na aplikaci:
{{ action_url }}

View File

@@ -1,3 +1,28 @@
from django.test import TestCase from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
# Create your tests here.
class UserViewAnonymousTests(TestCase):
def setUp(self):
self.client = APIClient()
User = get_user_model()
self.target_user = User.objects.create_user(
username="target",
email="target@example.com",
password="pass1234",
is_active=True,
)
def test_anonymous_update_user_is_forbidden_and_does_not_crash(self):
url = f"/api/account/users/{self.target_user.id}/"
payload = {"username": "newname", "email": self.target_user.email}
resp = self.client.put(url, data=payload, format="json")
# Expect 403 Forbidden (permission denied), but most importantly no 500 error
self.assertEqual(resp.status_code, 403, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
def test_anonymous_retrieve_user_is_unauthorized(self):
url = f"/api/account/users/{self.target_user.id}/"
resp = self.client.get(url)
# Retrieve requires authentication per view; expect 401 Unauthorized
self.assertEqual(resp.status_code, 401, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")

View File

@@ -160,7 +160,7 @@ class CookieTokenRefreshView(APIView):
except TokenError: except TokenError:
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED) return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
#---------------------------------------------LOGIN/LOGOUT------------------------------------------------ #---------------------------------------------LOGOUT------------------------------------------------
@extend_schema( @extend_schema(
tags=["Authentication"], tags=["Authentication"],
@@ -229,13 +229,17 @@ class UserView(viewsets.ModelViewSet):
# Only admin or the user themselves can update or delete # Only admin or the user themselves can update or delete
elif self.action in ['update', 'partial_update', 'destroy']: elif self.action in ['update', 'partial_update', 'destroy']:
if self.request.user.role == 'admin': user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
# Admins can modify any user
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
return [OnlyRolesAllowed("admin")()] return [OnlyRolesAllowed("admin")()]
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
# Users can modify their own record
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
return [IsAuthenticated()] return [IsAuthenticated()]
else:
# fallback - deny access # Fallback - deny access (prevents AttributeError for AnonymousUser)
return [OnlyRolesAllowed("admin")()] return [OnlyRolesAllowed("admin")()]
# Any authenticated user can retrieve (view) any user's profile # Any authenticated user can retrieve (view) any user's profile
elif self.action == 'retrieve': elif self.action == 'retrieve':

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AdvertisementConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'advertisement'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,2 @@
#udělat zasílaní reklamních emailů uživatelům.
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

14
backend/commerce/admin.py Normal file
View File

@@ -0,0 +1,14 @@
from django.contrib import admin
from .models import Carrier, Product
# Register your models here.
@admin.register(Carrier)
class CarrierAdmin(admin.ModelAdmin):
list_display = ("name", "base_price", "is_active")
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "price", "currency", "stock", "is_active")
search_fields = ("name", "description")

6
backend/commerce/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommerceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'commerce'

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.7 on 2025-10-28 22:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Carrier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('delivery_time', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
('external_id', models.CharField(blank=True, max_length=50, null=True)),
],
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='czk', max_length=10)),
('stock', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
],
),
]

View File

@@ -0,0 +1,40 @@
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=10, default="czk")
stock = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
default_carrier = models.ForeignKey(
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
)
created_at = models.DateTimeField(auto_now_add=True)
@property
def available(self):
return self.is_active and self.stock > 0
def __str__(self):
return f"{self.name} ({self.price} {self.currency.upper()})"
# Dopravci a způsoby dopravy
from django.db import models
class Carrier(models.Model):
name = models.CharField(max_length=100) # název dopravce (Zásilkovna, Česká pošta…)
base_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) # základní cena dopravy
delivery_time = models.CharField(max_length=100, blank=True) # např. "23 pracovní dny"
is_active = models.BooleanField(default=True)
# pole pro logo
logo = models.ImageField(upload_to="carriers/", blank=True, null=True)
# pole pro propojení s externím API (např. ID služby u Zásilkovny)
external_id = models.CharField(max_length=50, blank=True, null=True)
def __str__(self):
return f"{self.name} ({self.base_price} Kč)"

View File

@@ -0,0 +1,26 @@
from rest_framework import serializers
from .models import Carrier
class CarrierSerializer(serializers.ModelSerializer):
class Meta:
model = Carrier
fields = [
"id", "name", "base_price", "delivery_time",
"is_active", "logo", "external_id"
]
from rest_framework import serializers
from .models import Product, Carrier, Order
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = "__all__"
class CarrierSerializer(serializers.ModelSerializer):
class Meta:
model = Carrier
fields = "__all__"

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

59
backend/env Normal file
View File

@@ -0,0 +1,59 @@
# ------------------ NGINX ------------------
FRONTEND_URL=http://192.168.67.98
#FRONTEND_URL=http://localhost:5173
# ------------------ CORE ------------------
DEBUG=True
SSL=False
DJANGO_SECRET_KEY=CHANGE_ME_SECURE_RANDOM_KEY
# ------------------ DATABASE (Postgres in Docker) ------------------
USE_DOCKER_DB=True
DATABASE_ENGINE=django.db.backends.postgresql
DATABASE_HOST=db
DATABASE_PORT=5432
POSTGRES_DB=djangoDB
POSTGRES_USER=dockerDBuser
POSTGRES_PASSWORD=AWSJeMocDrahaZalezitost
# Legacy/unused (was: USE_PRODUCTION_DB) removed
# ------------------ MEDIA / STATIC ------------------
#MEDIA_URL=http://192.168.67.98/media/
# ------------------ REDIS / CACHING / CHANNELS ------------------
# Was REDIS=... (not used). Docker expects REDIS_PASSWORD.
REDIS_PASSWORD=passwd
# ------------------ CELERY ------------------
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_ACCEPT_CONTENT=json
CELERY_TASK_SERIALIZER=json
CELERY_TIMEZONE=Europe/Prague
CELERY_BEAT_SCHEDULER=django_celery_beat.schedulers:DatabaseScheduler
# ------------------ EMAIL (dev/prod logic in settings) ------------------
EMAIL_HOST_DEV=kerio4.vitkovice.cz
EMAIL_PORT_DEV=465
EMAIL_USER_DEV=Test.django@vitkovice.cz
EMAIL_USER_PASSWORD_DEV=PRneAP0819b
# DEFAULT_FROM_EMAIL_DEV unused in settings; kept for reference
DEFAULT_FROM_EMAIL_DEV=Test.django@vitkovice.cz
# ------------------ AWS (disabled unless USE_AWS=True) ------------------
USE_AWS=False
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_STORAGE_BUCKET_NAME=
AWS_S3_REGION_NAME=eu-central-1
# ------------------ JWT / TOKENS (lifetimes defined in code) ------------------
# (No env vars needed; kept placeholder section)
# ------------------ MISC ------------------
# FRONTEND_URL_DEV not used; rely on FRONTEND_URL
# Add any extra custom vars below

View File

@@ -78,7 +78,7 @@ django-celery-beat #slouží k plánování úkolů pro Celery
#opencv-python #moviepy use this better instead of pillow #opencv-python #moviepy use this better instead of pillow
#moviepy #moviepy
#yt-dlp yt-dlp
weasyprint #tvoření PDFek z html dokumentu + css styly weasyprint #tvoření PDFek z html dokumentu + css styly

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'social.chat'
label = "chat"

View File

@@ -0,0 +1,27 @@
# chat/consumers.py
import json
from account.models import UserProfile
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
await self.accept()
async def disconnect(self, close_code):
pass
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json["message"]
await self.send(text_data=json.dumps({"message": message}))
@database_sync_to_async
def get_user_profile(user_id):
return UserProfile.objects.get(pk=user_id)

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,8 @@
# chat/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

10
backend/thirdparty/downloader/admin.py vendored Normal file
View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import DownloaderRecord
@admin.register(DownloaderRecord)
class DownloaderRecordAdmin(admin.ModelAdmin):
list_display = ("id", "url", "format", "length_of_media", "file_size", "download_time")
list_filter = ("format",)
search_fields = ("url",)
ordering = ("-download_time",)
readonly_fields = ("download_time",)

10
backend/thirdparty/downloader/apps.py vendored Normal file
View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class DownloaderConfig(AppConfig):
# Ensure stable default primary key type
default_auto_field = "django.db.models.BigAutoField"
# Must be the full dotted path of this app
name = "thirdparty.downloader"
# Keep a short, stable label (used in migrations/admin)
label = "downloader"

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2025-10-29 14:53
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DownloaderRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('url', models.URLField()),
('download_time', models.DateTimeField(auto_now_add=True)),
('format', models.CharField(max_length=50)),
('length_of_media', models.IntegerField(help_text='Length of media in seconds')),
('file_size', models.BigIntegerField(help_text='File size in bytes')),
],
options={
'abstract': False,
},
),
]

15
backend/thirdparty/downloader/models.py vendored Normal file
View File

@@ -0,0 +1,15 @@
from django.db import models
from django.conf import settings
from vontor_cz.models import SoftDeleteModel
# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu
class DownloaderRecord(SoftDeleteModel):
url = models.URLField()
download_time = models.DateTimeField(auto_now_add=True)
format = models.CharField(max_length=50)
length_of_media = models.IntegerField(help_text="Length of media in seconds")
file_size = models.BigIntegerField(help_text="File size in bytes")

View File

@@ -0,0 +1,9 @@
from rest_framework import serializers
class DownloaderStatsSerializer(serializers.Serializer):
total_downloads = serializers.IntegerField()
avg_length_of_media = serializers.FloatField(allow_null=True)
avg_file_size = serializers.FloatField(allow_null=True)
total_length_of_media = serializers.IntegerField(allow_null=True)
total_file_size = serializers.IntegerField(allow_null=True)
most_common_format = serializers.CharField(allow_null=True)

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
backend/thirdparty/downloader/urls.py vendored Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import Downloader, DownloaderStats
urlpatterns = [
# Probe formats for a URL (size-checked)
path("download/", Downloader.as_view(), name="downloader-download"),
path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
]

305
backend/thirdparty/downloader/views.py vendored Normal file
View File

@@ -0,0 +1,305 @@
# ---------------------- Inline serializers for documentation only ----------------------
# Using inline_serializer to avoid creating new files.
import yt_dlp
import tempfile
import os
import shutil
from rest_framework import serializers
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from drf_spectacular.utils import extend_schema, inline_serializer
from drf_spectacular.types import OpenApiTypes
from django.conf import settings
from django.http import StreamingHttpResponse
from django.utils.text import slugify
# NEW: aggregations and timeseries helpers
from django.db import models
from django.utils import timezone
from django.db.models.functions import TruncDay, TruncHour
from .models import DownloaderRecord
# Allowed container formats for output/remux
FORMAT_CHOICES = ("mp4", "mkv", "webm", "flv", "mov", "avi", "ogg")
FORMAT_HELP = (
"Choose container format: "
"mp4 (H.264 + AAC, most compatible), "
"mkv (flexible, lossless container), "
"webm (VP9/AV1 + Opus), "
"flv (legacy), mov (Apple-friendly), "
"avi (older), ogg (mostly obsolete)."
)
# Minimal mime map by extension
MIME_BY_EXT = {
"mp4": "video/mp4",
"mkv": "video/x-matroska",
"webm": "video/webm",
"flv": "video/x-flv",
"mov": "video/quicktime",
"avi": "video/x-msvideo",
"ogg": "video/ogg",
}
class Downloader(APIView):
permission_classes = [AllowAny]
authentication_classes = []
@extend_schema(
tags=["downloader"],
summary="Get video info from URL",
parameters=[
inline_serializer(
name="VideoInfoParams",
fields={
"url": serializers.URLField(help_text="Video URL to analyze"),
},
)
],
responses={
200: inline_serializer(
name="VideoInfoResponse",
fields={
"title": serializers.CharField(),
"duration": serializers.IntegerField(allow_null=True),
"thumbnail": serializers.URLField(allow_null=True),
"video_resolutions": serializers.ListField(child=serializers.CharField()),
"audio_resolutions": serializers.ListField(child=serializers.CharField()),
},
),
400: inline_serializer(
name="ErrorResponse",
fields={"error": serializers.CharField()},
),
},
)
def get(self, request):
url = request.data.get("url") or request.query_params.get("url")
if not url:
return Response({"error": "URL is required"}, status=400)
ydl_options = {
"quiet": True,
}
try:
with yt_dlp.YoutubeDL(ydl_options) as ydl:
info = ydl.extract_info(url, download=False)
except Exception:
return Response({"error": "Failed to retrieve video info"}, status=400)
formats = info.get("formats", []) or []
# Video: collect unique heights and sort desc
heights = {
int(f.get("height"))
for f in formats
if f.get("vcodec") != "none" and isinstance(f.get("height"), int)
}
video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)]
# Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing
bitrates = set()
for f in formats:
if f.get("acodec") != "none" and f.get("vcodec") == "none":
abr = f.get("abr")
tbr = f.get("tbr")
val = None
if isinstance(abr, (int, float)):
val = int(abr)
elif isinstance(tbr, (int, float)):
val = int(tbr)
if val and val > 0:
bitrates.add(val)
audio_resolutions = [f"{b}kbps" for b in sorted(bitrates, reverse=True)]
return Response(
{
"title": info.get("title"),
"duration": info.get("duration"),
"thumbnail": info.get("thumbnail"),
"video_resolutions": video_resolutions,
"audio_resolutions": audio_resolutions,
},
status=200,
)
@extend_schema(
tags=["downloader"],
summary="Download video from URL",
request=inline_serializer(
name="DownloadRequest",
fields={
"url": serializers.URLField(help_text="Video URL to download"),
"ext": serializers.ChoiceField(
choices=FORMAT_CHOICES,
required=False,
default="mp4",
help_text=FORMAT_HELP,
),
"format": serializers.ChoiceField(
choices=FORMAT_CHOICES,
required=False,
help_text="Alias of 'ext' (deprecated)."
),
"video_quality": serializers.IntegerField(
required=True,
help_text="Target max video height (e.g. 1080)."
),
"audio_quality": serializers.IntegerField(
required=True,
help_text="Target max audio bitrate in kbps (e.g. 160)."
),
},
),
responses={
200: OpenApiTypes.BINARY,
400: inline_serializer(
name="DownloadErrorResponse",
fields={
"error": serializers.CharField(),
"allowed": serializers.ListField(child=serializers.CharField(), required=False),
},
),
},
)
def post(self, request):
url = request.data.get("url")
# Accept ext or legacy format param
ext = (request.data.get("ext") or request.data.get("format") or "mp4").lower()
try:
video_quality = int(request.data.get("video_quality")) # height, e.g., 1080
audio_quality = int(request.data.get("audio_quality")) # abr kbps, e.g., 160
except Exception:
return Response({"error": "Invalid quality parameters, not integers!"}, status=400)
if not url:
return Response({"error": "URL is required"}, status=400)
if ext not in FORMAT_CHOICES:
return Response({"error": f"Unsupported extension '{ext}'", "allowed": FORMAT_CHOICES}, status=400)
# Ensure base tmp dir exists
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
tmpdir = tempfile.mkdtemp(prefix="downloader_", dir=settings.DOWNLOADER_TMP_DIR)
outtmpl = os.path.join(tmpdir, "download.%(ext)s")
# Build a format selector using requested quality caps
# Example: "bv[height<=1080]+ba[abr<=160]/b"
video_part = f"bv[height<={video_quality}]" if video_quality else "bv*"
audio_part = f"ba[abr<={audio_quality}]" if audio_quality else "ba"
format_selector = f"{video_part}+{audio_part}/b"
ydl_options = {
"format": format_selector, # select by requested quality
"merge_output_format": ext, # container
"outtmpl": outtmpl, # temp dir
"quiet": True,
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
"socket_timeout": settings.DOWNLOADER_TIMEOUT,
# remux to container without re-encoding where possible
"postprocessors": [
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
],
}
file_path = ""
try:
with yt_dlp.YoutubeDL(ydl_options) as ydl:
info = ydl.extract_info(url, download=True)
base = ydl.prepare_filename(info)
file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}"
# Stats before streaming
duration = int((info or {}).get("duration") or 0)
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
DownloaderRecord.objects.create(
url=url,
format=ext,
length_of_media=duration,
file_size=size,
)
# Streaming generator that deletes file & temp dir after send (or on abort)
def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192):
try:
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield chunk
finally:
try:
if os.path.exists(path):
os.remove(path)
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
safe_title = slugify(info.get("title") or "video")
filename = f"{safe_title}.{ext}"
content_type = MIME_BY_EXT.get(ext, "application/octet-stream")
response = StreamingHttpResponse(
streaming_content=stream_and_cleanup(file_path, tmpdir),
content_type=content_type,
)
if size:
response["Content-Length"] = str(size)
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except Exception as e:
shutil.rmtree(tmpdir, ignore_errors=True)
return Response({"error": str(e)}, status=400)
# ---------------- STATS FOR GRAPHS ----------------
from .serializers import DownloaderStatsSerializer
from django.db.models import Count, Avg, Sum
class DownloaderStats(APIView):
"""
Vrací agregované statistiky z tabulky DownloaderRecord.
"""
authentication_classes = []
permission_classes = [AllowAny]
@extend_schema(
tags=["downloader"],
summary="Get aggregated downloader statistics",
responses={200: DownloaderStatsSerializer},
)
def get(self, request):
# agregace číselných polí
agg = DownloaderRecord.objects.aggregate(
total_downloads=Count("id"),
avg_length_of_media=Avg("length_of_media"),
avg_file_size=Avg("file_size"),
total_length_of_media=Sum("length_of_media"),
total_file_size=Sum("file_size"),
)
# zjištění nejčastějšího formátu
most_common = (
DownloaderRecord.objects.values("format")
.annotate(count=Count("id"))
.order_by("-count")
.first()
)
agg["most_common_format"] = most_common["format"] if most_common else None
serializer = DownloaderStatsSerializer(agg)
return Response(serializer.data)

View File

@@ -1,3 +1,206 @@
from django.db import models from django.db import models
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
# Create your models here. import gopay # GoPay official SDK
def _gopay_api():
"""
Instantiate the GoPay SDK; SDK manages access token internally.
https://doc.gopay.cz/
"""
return gopay.payments({
"goid": settings.GOPAY_GOID,
"client_id": settings.GOPAY_CLIENT_ID,
"client_secret": settings.GOPAY_CLIENT_SECRET,
"gateway_url": getattr(settings, "GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api"),
})
def _as_dict(resp):
"""
Try to normalize SDK response to a dict for persistence.
The SDK usually exposes `.json` (attr or method).
"""
if resp is None:
return None
# attr style
if hasattr(resp, "json") and not callable(getattr(resp, "json")):
return resp.json
# method style
if hasattr(resp, "json") and callable(getattr(resp, "json")):
try:
return resp.json()
except Exception:
pass
# already a dict
if isinstance(resp, dict):
return resp
# best-effort fallback
try:
return dict(resp) # may fail for non-mapping
except Exception:
return {"raw": str(resp)}
class GoPayPayment(models.Model):
"""
Local representation of a GoPay payment. Creating this model will create a real payment
at GoPay (via post_save hook) and persist provider identifiers and gateway URL.
Amounts are stored in minor units (cents/haléře).
"""
# Optional link to your Order model
order = models.ForeignKey(
'commerce.Order',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='gopay_payments',
)
# Basic money + currency
amount_cents = models.PositiveBigIntegerField()
currency = models.CharField(max_length=8, default='CZK')
# Provider identifiers + redirect URL
provider_payment_id = models.BigIntegerField(null=True, blank=True, unique=True)
gw_url = models.URLField(null=True, blank=True)
# Status bookkeeping
status = models.CharField(max_length=64, default='CREATED') # CREATED, PAID, CANCELED, REFUNDED, PARTIALLY_REFUNDED, FAILED
refunded_amount_cents = models.PositiveBigIntegerField(default=0)
# Raw responses for auditing
raw_create_response = models.JSONField(null=True, blank=True)
raw_last_status = models.JSONField(null=True, blank=True)
# Optional text for statements/UX
description = models.CharField(max_length=255, null=True, blank=True)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return f'GoPayPayment#{self.pk} {self.amount_cents} {self.currency} [{self.status}]'
class GoPayRefund(models.Model):
"""
Local representation of a GoPay refund. Creating this model will trigger
a refund call at GoPay (via post_save hook).
If amount_cents is null, a full refund is attempted by provider.
"""
payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, related_name='refunds')
amount_cents = models.PositiveBigIntegerField(null=True, blank=True) # null => full refund
reason = models.CharField(max_length=255, null=True, blank=True)
provider_refund_id = models.CharField(max_length=128, null=True, blank=True)
raw_response = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self) -> str:
amount = self.amount_cents if self.amount_cents is not None else 'FULL'
return f'GoPayRefund#{self.pk} payment={self.payment_id} amount={amount}'
@receiver(post_save, sender=GoPayPayment)
def create_gopay_payment(sender, instance: GoPayPayment, created: bool, **kwargs):
"""
On first save (creation), create the payment at GoPay.
Per docs:
- return_url: user is redirected back with ?id=<payment_id>
- notification_url: GoPay sends HTTP GET with ?id=<payment_id> on state changes
- Always verify state via API get_status(id)
"""
if not created:
return
# Already linked to provider (unlikely on brand-new rows)
if instance.provider_payment_id:
return
api = _gopay_api()
payload = {
"amount": int(instance.amount_cents), # GoPay expects minor units
"currency": (instance.currency or "CZK").upper(),
"order_number": str(instance.pk),
"order_description": instance.description or (f"Order {instance.order_id}" if instance.order_id else "Payment"),
"lang": "CS",
"callback": {
"return_url": f"{getattr(settings, 'FRONTEND_URL', 'http://localhost:5173')}/payment/return",
# Per docs: GoPay sends HTTP GET notification to this URL with ?id=<payment_id>
"notification_url": getattr(settings, "GOPAY_NOTIFICATION_URL", None),
},
# Optionally add items here if you later extend the model to store them.
}
resp = api.create_payment(payload)
# SDK returns Response object (no exceptions); check success + use .json
if getattr(resp, "success", False):
data = getattr(resp, "json", None)
instance.provider_payment_id = data.get("id")
instance.gw_url = data.get("gw_url")
instance.raw_create_response = data
instance.status = 'CREATED'
instance.save(update_fields=["provider_payment_id", "gw_url", "raw_create_response", "status", "updated_at"])
else:
# Persist error payload and mark as FAILED
err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)}
instance.raw_create_response = {"error": err}
instance.status = "FAILED"
instance.save(update_fields=["raw_create_response", "status", "updated_at"])
@receiver(post_save, sender=GoPayRefund)
def create_gopay_refund(sender, instance: GoPayRefund, created: bool, **kwargs):
"""
On first save (creation), request a refund at GoPay.
- amount_cents None => full refund
- Updates parent payment refunded amount and status.
- Stores raw provider response.
"""
if not created:
return
# If already linked (e.g., imported), skip
if instance.provider_refund_id:
return
payment = instance.payment
if not payment.provider_payment_id:
# Payment was not created at provider; record as failed in raw_response
instance.raw_response = {"error": "Missing provider payment id"}
instance.save(update_fields=["raw_response"])
return
api = _gopay_api()
# Compute amount to refund. If not provided -> refund remaining (full if none refunded yet).
refund_amount = instance.amount_cents
if refund_amount is None:
remaining = max(0, int(payment.amount_cents) - int(payment.refunded_amount_cents))
refund_amount = remaining
resp = api.refund_payment(payment.provider_payment_id, int(refund_amount))
if getattr(resp, "success", False):
data = getattr(resp, "json", None)
instance.provider_refund_id = str(data.get("id")) if data and data.get("id") is not None else None
instance.raw_response = data
instance.save(update_fields=["provider_refund_id", "raw_response"])
# Update parent payment bookkeeping against the remaining balance
new_total = min(payment.amount_cents, payment.refunded_amount_cents + int(refund_amount))
payment.refunded_amount_cents = new_total
payment.status = "REFUNDED" if new_total >= payment.amount_cents else "PARTIALLY_REFUNDED"
payment.save(update_fields=["refunded_amount_cents", "status", "updated_at"])
else:
err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)}
instance.raw_response = {"error": err}
instance.save(update_fields=["raw_response"])

View File

@@ -1,37 +1,21 @@
from rest_framework import serializers from rest_framework import serializers
class GoPayRefundROSerializer(serializers.Serializer):
class GoPayCreatePaymentRequestSerializer(serializers.Serializer): id = serializers.IntegerField()
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01) amount_cents = serializers.IntegerField(allow_null=True)
currency = serializers.CharField(required=False, default="CZK") reason = serializers.CharField(allow_null=True, allow_blank=True)
order_number = serializers.CharField(required=False, allow_blank=True, default="order-001") provider_refund_id = serializers.CharField(allow_null=True)
order_description = serializers.CharField(required=False, allow_blank=True, default="Example GoPay payment") created_at = serializers.DateTimeField()
return_url = serializers.URLField(required=False)
notify_url = serializers.URLField(required=False)
preauthorize = serializers.BooleanField(required=False, default=False)
class GoPayPaymentCreatedResponseSerializer(serializers.Serializer): class GoPayPaymentROSerializer(serializers.Serializer):
id = serializers.IntegerField() id = serializers.IntegerField()
state = serializers.CharField() order = serializers.IntegerField(allow_null=True)
gw_url = serializers.URLField(required=False, allow_null=True) amount_cents = serializers.IntegerField()
currency = serializers.CharField()
status = serializers.CharField()
class GoPayStatusResponseSerializer(serializers.Serializer): refunded_amount_cents = serializers.IntegerField()
id = serializers.IntegerField() gw_url = serializers.URLField(allow_null=True)
state = serializers.CharField() provider_payment_id = serializers.IntegerField(allow_null=True)
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
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")

View File

@@ -1,20 +1,11 @@
from django.urls import path from django.urls import path
from .views import ( from .views import PaymentStatusView, PaymentRefundListView, GoPayWebhookView
GoPayPaymentView,
GoPayPaymentStatusView,
GoPayRefundPaymentView,
GoPayCaptureAuthorizationView,
GoPayVoidAuthorizationView,
GoPayCreateRecurrenceView,
GoPayPaymentInstrumentsView,
)
urlpatterns = [ urlpatterns = [
path('payment/', GoPayPaymentView.as_view(), name='gopay-payment'), # Dotaz na stav platby (GET)
path('payment/<int:payment_id>/status/', GoPayPaymentStatusView.as_view(), name='gopay-payment-status'), path('api/payments/payment/<int:pk>', PaymentStatusView.as_view(), name='gopay-payment-status'),
path('payment/<int:payment_id>/refund/', GoPayRefundPaymentView.as_view(), name='gopay-refund-payment'), # Historie refundací (GET)
path('payment/<int:payment_id>/capture/', GoPayCaptureAuthorizationView.as_view(), name='gopay-capture-authorization'), path('api/payments/payment/<int:pk>/refunds', PaymentRefundListView.as_view(), name='gopay-payment-refunds'),
path('payment/<int:payment_id>/void/', GoPayVoidAuthorizationView.as_view(), name='gopay-void-authorization'), # Webhook od GoPay (HTTP GET s ?id=...)
path('payment/<int:payment_id>/recurrence/', GoPayCreateRecurrenceView.as_view(), name='gopay-create-recurrence'), path('api/payments/gopay/webhook', GoPayWebhookView.as_view(), name='gopay-webhook'),
path('payment-instruments/', GoPayPaymentInstrumentsView.as_view(), name='gopay-payment-instruments'),
] ]

View File

@@ -1,233 +1,187 @@
from django.shortcuts import render from typing import Optional
from django.shortcuts import get_object_or_404
from django.conf import settings
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
# Create your views here.
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework import status, permissions, serializers
import gopay import gopay
from gopay.enums import TokenScope, Language
import os from .models import GoPayPayment
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
from .serializers import (
GoPayCreatePaymentRequestSerializer,
GoPayPaymentCreatedResponseSerializer,
GoPayStatusResponseSerializer,
GoPayRefundRequestSerializer,
GoPayCaptureRequestSerializer,
GoPayCreateRecurrenceRequestSerializer,
)
class GoPayClientMixin: def _gopay_api():
"""Shared helpers for configuring GoPay client and formatting responses.""" # SDK handles token internally; credentials from settings/env
def get_gopay_client(self): return gopay.payments({
gateway_url = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api") "goid": settings.GOPAY_GOID,
return gopay.payments({ "client_id": settings.GOPAY_CLIENT_ID,
"goid": os.getenv("GOPAY_GOID"), "client_secret": settings.GOPAY_CLIENT_SECRET,
"client_id": os.getenv("GOPAY_CLIENT_ID"), "gateway_url": getattr(settings, 'GOPAY_GATEWAY_URL', 'https://gw.sandbox.gopay.com/api'),
"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): def _as_dict(resp):
permission_classes = [IsAuthenticated] if resp is None:
return None
@extend_schema( if hasattr(resp, "json") and not callable(getattr(resp, "json")):
tags=["GoPay"], return resp.json
summary="Create GoPay payment", if hasattr(resp, "json") and callable(getattr(resp, "json")):
description="Creates a GoPay payment and returns gateway URL and payment info.", try:
request=GoPayCreatePaymentRequestSerializer, return resp.json()
responses={ except Exception:
200: OpenApiResponse(response=GoPayPaymentCreatedResponseSerializer, description="Payment created"), pass
400: OpenApiResponse(description="Validation error or SDK error"), if isinstance(resp, dict):
}, return resp
examples=[ try:
OpenApiExample( return dict(resp)
"Create payment", except Exception:
value={ return {"raw": str(resp)}
"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): def _map_status(provider_state: Optional[str]) -> str:
permission_classes = [IsAuthenticated] if not provider_state:
return 'UNKNOWN'
@extend_schema( state = provider_state.upper()
tags=["GoPay"], if state == 'PAID':
summary="Get GoPay payment status", return 'PAID'
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], if state in ('AUTHORIZED',):
responses={200: OpenApiResponse(response=GoPayStatusResponseSerializer, description="Payment status")}, return 'AUTHORIZED'
) if state in ('PAYMENT_METHOD_CHOSEN',):
def get(self, request, payment_id: int): return 'PAYMENT_METHOD_CHOSEN'
payments = self.get_gopay_client() if state in ('CREATED', 'CREATED_WITH_PAYMENT', 'PENDING'):
resp = payments.get_status(payment_id) return 'CREATED'
return self._to_response(resp) if state in ('CANCELED', 'CANCELLED'):
return 'CANCELED'
if state in ('TIMEOUTED',):
return 'TIMEOUTED'
if state in ('REFUNDED',):
return 'REFUNDED'
if state in ('PARTIALLY_REFUNDED',):
return 'PARTIALLY_REFUNDED'
if state in ('FAILED', 'DECLINED'):
return 'FAILED'
return state
class GoPayRefundPaymentView(GoPayClientMixin, APIView): # --- Serializers kept here (small and read-only) ---
permission_classes = [IsAuthenticated] class GoPayRefundROSerializer(serializers.Serializer):
id = serializers.IntegerField()
@extend_schema( amount_cents = serializers.IntegerField(allow_null=True)
tags=["GoPay"], reason = serializers.CharField(allow_null=True, allow_blank=True)
summary="Refund GoPay payment", provider_refund_id = serializers.CharField(allow_null=True)
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], created_at = serializers.DateTimeField()
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): class GoPayPaymentROSerializer(serializers.Serializer):
permission_classes = [IsAuthenticated] id = serializers.IntegerField()
order = serializers.IntegerField(source='order_id', allow_null=True)
@extend_schema( amount_cents = serializers.IntegerField()
tags=["GoPay"], currency = serializers.CharField()
summary="Capture GoPay authorization", status = serializers.CharField()
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], refunded_amount_cents = serializers.IntegerField()
request=GoPayCaptureRequestSerializer, gw_url = serializers.URLField(allow_null=True)
responses={200: OpenApiResponse(description="Capture processed")}, provider_payment_id = serializers.IntegerField(allow_null=True)
) created_at = serializers.DateTimeField()
def post(self, request, payment_id: int): updated_at = serializers.DateTimeField()
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): class PaymentStatusView(APIView):
permission_classes = [IsAuthenticated] """
GET /api/payments/payment/{id}
- Refresh status from GoPay (if provider_payment_id present).
- Return current local payment record (read-only).
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema( def get(self, request, pk: int):
tags=["GoPay"], payment = get_object_or_404(GoPayPayment, pk=pk)
summary="Void GoPay authorization",
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], if payment.provider_payment_id:
responses={200: OpenApiResponse(description="Authorization voided")}, api = _gopay_api()
) resp = api.get_status(payment.provider_payment_id)
def post(self, request, payment_id: int): if getattr(resp, "success", False):
payments = self.get_gopay_client() data = getattr(resp, "json", None)
resp = payments.void_authorization(payment_id) payment.status = _map_status(data.get('state'))
return self._to_response(resp) payment.raw_last_status = data
payment.save(update_fields=['status', 'raw_last_status', 'updated_at'])
else:
err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)}
return Response({'detail': 'Failed to fetch status', 'error': err}, status=status.HTTP_502_BAD_GATEWAY)
serialized = GoPayPaymentROSerializer(payment)
return Response(serialized.data)
class GoPayCreateRecurrenceView(GoPayClientMixin, APIView): class PaymentRefundListView(APIView):
permission_classes = [IsAuthenticated] """
GET /api/payments/payment/{id}/refunds
- List local refund records for a payment (read-only).
"""
permission_classes = [permissions.IsAuthenticated]
@extend_schema( def get(self, request, pk: int):
tags=["GoPay"], payment = get_object_or_404(GoPayPayment, pk=pk)
summary="Create GoPay recurrence", refunds = payment.refunds.all().values(
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], 'id', 'amount_cents', 'reason', 'provider_refund_id', 'created_at'
request=GoPayCreateRecurrenceRequestSerializer, )
responses={200: OpenApiResponse(description="Recurrence created")}, ser = GoPayRefundROSerializer(refunds, many=True)
) return Response(ser.data)
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): @method_decorator(csrf_exempt, name='dispatch')
permission_classes = [IsAuthenticated] class GoPayWebhookView(APIView):
"""
GET /api/payments/gopay/webhook?id=<provider_payment_id>
- Called by GoPay (HTTP GET with query params) on payment state change.
- We verify by fetching status via GoPay SDK and persist it locally.
"""
permission_classes = [permissions.AllowAny]
@extend_schema( def get(self, request):
tags=["GoPay"], provider_id = request.GET.get("id") or request.GET.get("payment_id")
summary="Get GoPay payment instruments", if not provider_id:
parameters=[OpenApiParameter(name="currency", required=False, type=str, location=OpenApiParameter.QUERY)], return Response({"detail": "Missing payment id"}, status=status.HTTP_400_BAD_REQUEST)
responses={200: OpenApiResponse(description="Available payment instruments returned")},
) try:
def get(self, request): provider_id_int = int(provider_id)
currency = request.query_params.get("currency", "CZK") except Exception:
goid = os.getenv("GOPAY_GOID") provider_id_int = provider_id # fallback
if not goid:
return Response({"error": "GOPAY_GOID is not configured"}, status=500) api = _gopay_api()
payments = self.get_gopay_client() resp = api.get_status(provider_id_int)
resp = payments.get_payment_instruments(goid, currency) if not getattr(resp, "success", False):
return self._to_response(resp) err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)}
return Response({"detail": "Failed to verify status with GoPay", "error": err}, status=status.HTTP_502_BAD_GATEWAY)
data = getattr(resp, "json", None)
state = data.get("state")
# Find local payment by provider id, fallback to order_number from response
payment = None
try:
payment = GoPayPayment.objects.get(provider_payment_id=provider_id_int)
except GoPayPayment.DoesNotExist:
order_number = data.get("order_number")
if order_number:
try:
payment = GoPayPayment.objects.get(pk=int(order_number))
except Exception:
payment = None
if not payment:
return Response({"detail": "Payment not found locally", "provider_id": provider_id_int}, status=status.HTTP_202_ACCEPTED)
payment.status = _map_status(state)
payment.raw_last_status = data
payment.save(update_fields=["status", "raw_last_status", "updated_at"])
return Response({"ok": True})
# Implementation notes:
# - GoPay notification is an HTTP GET to notification_url with ?id=...
# - Always verify the state by calling get_status(id); never trust inbound payload alone.
# - Amounts are kept in minor units (cents). States covered: CREATED, PAYMENT_METHOD_CHOSEN, AUTHORIZED, PAID, CANCELED, TIMEOUTED, PARTIALLY_REFUNDED, REFUNDED.

View File

@@ -1,3 +1,23 @@
from django.contrib import admin from django.contrib import admin
from .models import Order
# Register your models here. @admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "amount", "currency", "status", "created_at")
list_filter = ("status", "currency", "created_at")
search_fields = ("id", "stripe_session_id", "stripe_payment_intent")
readonly_fields = ("created_at", "stripe_session_id", "stripe_payment_intent")
fieldsets = (
(None, {
"fields": ("amount", "currency", "status")
}),
("Stripe info", {
"fields": ("stripe_session_id", "stripe_payment_intent"),
"classes": ("collapse",),
}),
("Metadata", {
"fields": ("created_at",),
}),
)
ordering = ("-created_at",)

View File

@@ -3,4 +3,5 @@ from django.apps import AppConfig
class StripeConfig(AppConfig): class StripeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'stripe' name = 'thirdparty.stripe'
label = "stripe"

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2025-10-28 22:28
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='czk', max_length=10)),
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
('stripe_session_id', models.CharField(blank=True, max_length=255, null=True)),
('stripe_payment_intent', models.CharField(blank=True, max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -1,3 +1,21 @@
from django.db import models from django.db import models
# Create your models here. # Create your models here.
class Order(models.Model):
STATUS_CHOICES = [
("pending", "Pending"),
("paid", "Paid"),
("failed", "Failed"),
("cancelled", "Cancelled"),
]
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=10, default="czk")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
stripe_session_id = models.CharField(max_length=255, blank=True, null=True)
stripe_payment_intent = models.CharField(max_length=255, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Order {self.id} - {self.status}"

View File

@@ -1,12 +1,29 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Order
class StripeCheckoutRequestSerializer(serializers.Serializer): class OrderSerializer(serializers.ModelSerializer):
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01) # Nested read-only representations
product_name = serializers.CharField(required=False, default="Example Product") # product = ProductSerializer(read_only=True)
success_url = serializers.URLField(required=False) # carrier = CarrierSerializer(read_only=True)
cancel_url = serializers.URLField(required=False)
# Write-only foreign keys
# product_id = serializers.PrimaryKeyRelatedField(
# queryset=Product.objects.all(), source="product", write_only=True
# )
# carrier_id = serializers.PrimaryKeyRelatedField(
# queryset=Carrier.objects.all(), source="carrier", write_only=True
# )
class StripeCheckoutResponseSerializer(serializers.Serializer): class Meta:
url = serializers.URLField() model = Order
fields = [
"id",
"amount",
"currency",
"status",
"stripe_session_id",
"stripe_payment_intent",
"created_at",
]
read_only_fields = ("created_at",)

View File

@@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from .views import StripeCheckoutCZKView from .views import CreateCheckoutSessionView
urlpatterns = [ urlpatterns = [
path('checkout/', StripeCheckoutCZKView.as_view(), name='stripe-checkout-czk'), path("orders/create-checkout/", CreateCheckoutSessionView.as_view(), name="create-checkout"),
] ]

View File

@@ -1,71 +1,78 @@
import stripe from django.views.decorators.csrf import csrf_exempt
import os from django.conf import settings
from rest_framework.views import APIView from django.http import HttpResponse
from rest_framework.permissions import IsAuthenticated
from rest_framework import generics
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter from .models import Order
from .serializers import ( from .serializers import OrderSerializer
StripeCheckoutRequestSerializer, import os
StripeCheckoutResponseSerializer,
)
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY") stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
class CreateCheckoutSessionView(APIView):
class StripeCheckoutCZKView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema( @extend_schema(
tags=["Stripe"], 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): def post(self, request):
serializer = StripeCheckoutRequestSerializer(data=request.data) serializer = OrderSerializer(data=request.data) #obecný serializer
if not serializer.is_valid(): serializer.is_valid(raise_exception=True)
return Response(serializer.errors, status=400)
amount = serializer.validated_data.get("amount") order = Order.objects.create(
product_name = serializer.validated_data.get("product_name", "Example Product") amount=serializer.validated_data["amount"],
success_url = serializer.validated_data.get("success_url", "https://yourfrontend.com/success") currency=serializer.validated_data.get("currency", "czk"),
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})
# Vytvoření Stripe Checkout Session
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": order.currency,
"product_data": {"name": f"Order {order.id}"},
"unit_amount": int(order.amount * 100), # v centech
},
"quantity": 1,
}],
mode="payment",
success_url=request.build_absolute_uri(f"/payment/success/{order.id}"),
cancel_url=request.build_absolute_uri(f"/payment/cancel/{order.id}"),
)
order.stripe_session_id = session.id
order.stripe_payment_intent = session.payment_intent
order.save()
data = OrderSerializer(order).data
data["checkout_url"] = session.url
return Response(data)
@csrf_exempt
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
event = None
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
order = Order.objects.filter(stripe_session_id=session.get("id")).first()
if order:
order.status = "paid"
order.save()
return HttpResponse(status=200)

View File

@@ -3,4 +3,5 @@ from django.apps import AppConfig
class Trading212Config(AppConfig): class Trading212Config(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'trading212' name = 'thirdparty.trading212'
label = "trading212"

View File

@@ -1,6 +1,6 @@
from django.urls import path from django.urls import path
from .views import YourTrading212View # Replace with actual view class from .views import Trading212AccountCashView # Replace with actual view class
urlpatterns = [ urlpatterns = [
path('your-endpoint/', YourTrading212View.as_view(), name='trading212-endpoint'), path('your-endpoint/', Trading212AccountCashView.as_view(), name='trading212-endpoint'),
] ]

View File

@@ -1,7 +1,6 @@
# thirdparty/trading212/views.py # thirdparty/trading212/views.py
import os import os
import requests import requests
from decouple import config
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@@ -13,11 +12,13 @@ class Trading212AccountCashView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@extend_schema( @extend_schema(
tags=["trading212"],
summary="Get Trading212 account cash", summary="Get Trading212 account cash",
responses=Trading212AccountCashSerializer responses=Trading212AccountCashSerializer
) )
def get(self, request): def get(self, request):
api_key = os.getenv("API_KEY_TRADING212") api_key = os.getenv("API_KEY_TRADING212")
headers = { headers = {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
"Accept": "application/json", "Accept": "application/json",

View File

@@ -17,9 +17,9 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
application = ProtocolTypeRouter({ application = ProtocolTypeRouter({
"http": get_asgi_application(), "http": get_asgi_application(),
"websocket": AuthMiddlewareStack( # "websocket": AuthMiddlewareStack(
URLRouter( # URLRouter(
#myapp.routing.websocket_urlpatterns # #myapp.routing.websocket_urlpatterns
) # )
), # ),
}) })

View File

@@ -17,18 +17,23 @@ from django.core.management.utils import get_random_secret_key
from django.db import OperationalError, connections from django.db import OperationalError, connections
from datetime import timedelta from datetime import timedelta
import json
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné
# Robust boolean parser and SSL flag
def _env_bool(key: str, default: bool = False) -> bool:
return os.getenv(key, str(default)).strip().lower() in ("true", "1", "yes", "on")
USE_SSL = _env_bool("SSL", False)
#---------------- ENV VARIABLES USECASE-------------- #---------------- ENV VARIABLES USECASE--------------
# v jiné app si to importneš skrz: from django.conf import settings # v jiné app si to importneš skrz: from django.conf import settings
# a použiješ takto: settings.FRONTEND_URL # a použiješ takto: settings.FRONTEND_URL
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:9000")
FRONTEND_URL_DEV = os.getenv("FRONTEND_URL_DEV", "http://localhost:5173") print(f"FRONTEND_URL: {FRONTEND_URL}\n")
print(f"FRONTEND_URL: {FRONTEND_URL}\nFRONTEND_URL_DEV: {FRONTEND_URL_DEV}\n")
#-------------------------BASE ⚙️------------------------ #-------------------------BASE ⚙️------------------------
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -153,62 +158,96 @@ AUTHENTICATION_BACKENDS = [
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
from urllib.parse import urlparse
parsed = urlparse(FRONTEND_URL)
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
'https://domena.cz', f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
"https://www.domena.cz",
"http://localhost:3000", #react docker "http://192.168.67.98",
"http://localhost:5173" #react dev "https://itsolutions.vontor.cz",
"https://react.vontor.cz",
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:9000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:9000",
# server
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
# nginx docker (local)
"http://localhost",
"http://localhost:80",
"http://127.0.0.1",
] ]
if DEBUG: if DEBUG:
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
"http://localhost:3000",
] "http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://localhost:9000",
"http://127.0.0.1:9000",
# server
"http://192.168.67.98",
"https://itsolutions.vontor.cz",
"https://react.vontor.cz",
# nginx docker (local)
"http://localhost",
"http://localhost:80",
"http://127.0.0.1",
]
else: else:
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"https://www.domena.cz", "http://192.168.67.98",
] "https://itsolutions.vontor.cz",
"https://react.vontor.cz",
"http://localhost:9000",
"http://127.0.0.1:9000",
]
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials
# Use Lax for http (local), None only when HTTPS is enabled
SESSION_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
CSRF_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS) print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS)
print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS) print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
print("ALLOWED_HOSTS =", ALLOWED_HOSTS) print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
#--------------------------------END CORS + HOSTs 🌐🔐--------------------------------- #--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
#--------------------------------------SSL 🧾------------------------------------ #--------------------------------------SSL 🧾------------------------------------
if os.getenv("SSL", "") == "True":
USE_SSL = True
else:
USE_SSL = False
if USE_SSL is True: if USE_SSL is True:
print("SSL turned on!") print("SSL turned on!")
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True SECURE_SSL_REDIRECT = True
SECURE_BROWSER_XSS_FILTER = True SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
else: else:
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False
SECURE_SSL_REDIRECT = False SECURE_SSL_REDIRECT = False
SECURE_BROWSER_XSS_FILTER = False SECURE_BROWSER_XSS_FILTER = False
SECURE_CONTENT_TYPE_NOSNIFF = False SECURE_CONTENT_TYPE_NOSNIFF = False
USE_X_FORWARDED_HOST = False
print(f"\nUsing SSL: {USE_SSL}\n") print(f"\nUsing SSL: {USE_SSL}\n")
#--------------------------------END-SSL 🧾--------------------------------- #--------------------------------END-SSL 🧾---------------------------------
@@ -218,54 +257,67 @@ print(f"\nUsing SSL: {USE_SSL}\n")
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------ #-------------------------------------REST FRAMEWORK 🛠️------------------------------------
# ⬇️ Základní lifetime konfigurace # ⬇️ Základní lifetime konfigurace
ACCESS_TOKEN_LIFETIME = timedelta(minutes=15) ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
REFRESH_TOKEN_LIFETIME = timedelta(days=1) REFRESH_TOKEN_LIFETIME = timedelta(days=5)
# ⬇️ Nastavení SIMPLE_JWT podle režimu # ⬇️ Nastavení SIMPLE_JWT podle režimu
if DEBUG: if DEBUG:
SIMPLE_JWT = { SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME, "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
"AUTH_COOKIE": "access_token", "AUTH_COOKIE": "access_token",
"AUTH_COOKIE_SECURE": False, # není HTTPS "AUTH_COOKIE_REFRESH": "refresh_token",
"AUTH_COOKIE_HTTP_ONLY": True,
"AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "Lax", # není cross-site
"ROTATE_REFRESH_TOKENS": True, "AUTH_COOKIE_DOMAIN": None,
"BLACKLIST_AFTER_ROTATION": True, "AUTH_COOKIE_SECURE": False,
} "AUTH_COOKIE_HTTP_ONLY": True,
"AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "Lax",
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
}
else: else:
SIMPLE_JWT = { SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME, "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
"AUTH_COOKIE": "access_token", "AUTH_COOKIE": "access_token",
"AUTH_COOKIE_SECURE": True, # HTTPS only "AUTH_COOKIE_REFRESH": "refresh_token",
"AUTH_COOKIE_HTTP_ONLY": True, "AUTH_COOKIE_DOMAIN": None,
"AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin
"ROTATE_REFRESH_TOKENS": True, # Secure/SameSite based on HTTPS availability
"BLACKLIST_AFTER_ROTATION": True, "AUTH_COOKIE_SECURE": USE_SSL,
} "AUTH_COOKIE_HTTP_ONLY": True,
"AUTH_COOKIE_PATH": "/",
"AUTH_COOKIE_SAMESITE": "None" if USE_SSL else "Lax",
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel "DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'account.tokens.CookieJWTAuthentication', # In DEBUG keep Session + JWT + your cookie class for convenience
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
), 'rest_framework_simplejwt.authentication.JWTAuthentication',
'DEFAULT_PERMISSION_CLASSES': ( 'account.tokens.CookieJWTAuthentication',
'rest_framework.permissions.AllowAny', ) if DEBUG else (
), 'account.tokens.CookieJWTAuthentication',
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', ),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], 'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour', # unauthenticated
'user': '2000/hour', # authenticated
}
} }
#--------------------------------END REST FRAMEWORK 🛠️------------------------------------- #--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
@@ -273,6 +325,14 @@ REST_FRAMEWORK = {
#-------------------------------------APPS 📦------------------------------------ #-------------------------------------APPS 📦------------------------------------
MY_CREATED_APPS = [ MY_CREATED_APPS = [
'account', 'account',
'commerce',
'social.chat',
'thirdparty.downloader',
'thirdparty.stripe', # register Stripe app so its models are recognized
'thirdparty.trading212',
'thirdparty.gopay', # add GoPay app
] ]
INSTALLED_APPS = [ INSTALLED_APPS = [
@@ -330,23 +390,17 @@ INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:]
# Middleware is a framework of hooks into Django's request/response processing. # Middleware is a framework of hooks into Django's request/response processing.
MIDDLEWARE = [ MIDDLEWARE = [
# Middleware that allows your backend to accept requests from other domains (CORS) "corsheaders.middleware.CorsMiddleware",
"corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.common.CommonMiddleware", 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
#CUSTOM
#'tools.middleware.CustomMaxUploadSizeMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files
] ]
#--------------------------------END MIDDLEWARE 🧩--------------------------------- #--------------------------------END MIDDLEWARE 🧩---------------------------------
@@ -400,56 +454,42 @@ else:
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️--------------------------------- #--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
#-------------------------------------CELERY 📅------------------------------------ #-------------------------------------CELERY 📅------------------------------------
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL") CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND")
# Control via env; default False in DEBUG, True otherwise
CELERY_ENABLED = _env_bool("CELERY_ENABLED", default=not DEBUG)
if DEBUG:
CELERY_ENABLED = False
try: try:
import redis import redis
# test connection r = redis.Redis(host='localhost', port=6379, db=0)
r = redis.Redis(host='localhost', port=6379, db=0) r.ping()
r.ping()
except Exception: except Exception:
CELERY_BROKER_URL = 'memory://' CELERY_BROKER_URL = 'memory://'
CELERY_ENABLED = False
CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT") def _env_list(key: str, default: list[str]) -> list[str]:
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER") v = os.getenv(key)
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE") if not v:
return default
try:
parsed = json.loads(v)
if isinstance(parsed, (list, tuple)):
return list(parsed)
if isinstance(parsed, str):
return [parsed]
except Exception:
pass
return [s.strip(" '\"") for s in v.strip("[]()").split(",") if s.strip()]
CELERY_ACCEPT_CONTENT = _env_list("CELERY_ACCEPT_CONTENT", ["json"])
CELERY_RESULT_ACCEPT_CONTENT = _env_list("CELERY_RESULT_ACCEPT_CONTENT", ["json"])
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER", "json")
CELERY_RESULT_SERIALIZER = os.getenv("CELERY_RESULT_SERIALIZER", "json")
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE", TIME_ZONE)
CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER") CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER")
# if DEBUG:
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
# try:
# import redis
# # test connection
# r = redis.Redis(host='localhost', port=6379, db=0)
# r.ping()
# except Exception:
# CELERY_BROKER_URL = 'memory://'
# CELERY_ACCEPT_CONTENT = ['json']
# CELERY_TASK_SERIALIZER = 'json'
# CELERY_TIMEZONE = 'Europe/Prague'
# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# from celery.schedules import crontab
# CELERY_BEAT_SCHEDULE = {
# 'hard_delete_soft_deleted_monthly': {
# 'task': 'vontor_cz.tasks.hard_delete_soft_deleted_records',
# 'schedule': crontab(minute=0, hour=0, day_of_month=1), # každý první den v měsíci o půlnoci
# },
# 'delete_old_reservations_monthly': {
# 'task': 'account.tasks.delete_old_reservations',
# 'schedule': crontab(minute=0, hour=1, day_of_month=1), # každý první den v měsíci v 1:00 ráno
# },
# }
# else:
# # Nebo nastav dummy broker, aby se úlohy neodesílaly
# CELERY_BROKER_URL = 'memory://' # broker v paměti, pro testování bez Redis
#-------------------------------------END CELERY 📅------------------------------------ #-------------------------------------END CELERY 📅------------------------------------
@@ -461,66 +501,57 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# říka že se úkladá do databáze, místo do cookie # říka že se úkladá do databáze, místo do cookie
SESSION_ENGINE = 'django.contrib.sessions.backends.db' SESSION_ENGINE = 'django.contrib.sessions.backends.db'
USE_PRODUCTION_DB = os.getenv("USE_PRODUCTION_DB", "False") == "True" USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True]
if USE_PRODUCTION_DB is False: if USE_DOCKER_DB is False:
# DEVELOPMENT # DEV
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', # Database engine 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file 'NAME': BASE_DIR / 'db.sqlite3',
} }
} }
else: else:
#PRODUCTION # DOCKER/POSTGRES
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': os.getenv('DATABASE_ENGINE'), 'ENGINE': os.getenv('DATABASE_ENGINE'),
'NAME': os.getenv('DATABASE_NAME'), 'NAME': os.getenv('POSTGRES_DB'),
'USER': os.getenv('DATABASE_USER'), 'USER': os.getenv('POSTGRES_USER'),
'PASSWORD': os.getenv('DATABASE_PASSWORD'), 'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
'HOST': os.getenv('DATABASE_HOST', "localhost"), 'HOST': os.getenv('DATABASE_HOST'),
'PORT': os.getenv('DATABASE_PORT'), 'PORT': os.getenv('DATABASE_PORT'),
} }
} }
print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n")
AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser
#--------------------------------END DATABASE 💾--------------------------------- #--------------------------------END DATABASE 💾---------------------------------
#--------------------------------------PAGE SETTINGS -------------------------------------
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
# Configuration for Constance(variables)
CONSTANCE_CONFIG = {
'BITCOIN_WALLET': ('', 'Public BTC wallet address'),
'SUPPORT_EMAIL': ('admin@example.com', 'Support email'),
}
#--------------------------------------EMAIL 📧-------------------------------------- #--------------------------------------EMAIL 📧--------------------------------------
if DEBUG: EMAIL_BACKEND = os.getenv(
# DEVELOPMENT "EMAIL_BACKEND",
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development 'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
# EMAILY SE BUDOU POSÍLAT DO KONZOLE!!! )
else:
# PRODUCTION
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = os.getenv("EMAIL_HOST_DEV") EMAIL_HOST = os.getenv("EMAIL_HOST")
EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465)) EMAIL_PORT = int(os.getenv("EMAIL_PORT", 465))
EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") in ["True", "true", "1", True]
EMAIL_USE_SSL = False # ✅ Must be True for port 465 EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "True") in ["True", "true", "1", True]
EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV") EMAIL_HOST_USER = os.getenv("EMAIL_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD")
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
EMAIL_TIMEOUT = 10 EMAIL_TIMEOUT = 30 # seconds
print("---------EMAIL----------\nEMAIL_HOST =", os.getenv("EMAIL_HOST_DEV"))
print("EMAIL_PORT =", os.getenv("EMAIL_PORT_DEV"))
print("EMAIL_USER =", os.getenv("EMAIL_USER_DEV"))
print("EMAIL_USER_PASSWORD =", os.getenv("EMAIL_USER_PASSWORD_DEV"), "\n------------------------")
print("---------EMAIL----------")
print("EMAIL_HOST =", EMAIL_HOST)
print("EMAIL_PORT =", EMAIL_PORT)
print("EMAIL_USE_TLS =", EMAIL_USE_TLS)
print("EMAIL_USE_SSL =", EMAIL_USE_SSL)
print("EMAIL_USER =", EMAIL_HOST_USER)
print("------------------------")
#----------------------------------EMAIL END 📧------------------------------------- #----------------------------------EMAIL END 📧-------------------------------------
@@ -569,66 +600,59 @@ else:
print(f"\n-------------- USE_AWS: {USE_AWS} --------------") print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
if USE_AWS is False: if USE_AWS is False:
# DEVELOPMENT # Development: Use local file system storage for static files
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Media and Static URL for local dev
MEDIA_URL = os.getenv("MEDIA_URL", "/media/")
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Development: Use local file system storage for static files STATIC_URL = '/static/'
STORAGES = { STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Media and Static URL for local dev
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
STATIC_URL = '/static/'
# Local folder for collected static files
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
elif USE_AWS: elif USE_AWS:
# PRODUCTION # PRODUCTION
AWS_LOCATION = "static" AWS_LOCATION = "static"
# Production: Use S3 storage # Production: Use S3 storage
STORAGES = { STORAGES = {
"default": { "default": {
"BACKEND" : "storages.backends.s3boto3.S3StaticStorage", "BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
}, },
"staticfiles": { "staticfiles": {
"BACKEND" : "storages.backends.s3boto3.S3StaticStorage", "BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
}, },
} }
# Media and Static URL for AWS S3 # Media and Static URL for AWS S3
MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/' MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/'
STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/' STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/'
CSRF_TRUSTED_ORIGINS.append(STATIC_URL) CSRF_TRUSTED_ORIGINS.append(STATIC_URL)
# Static files should be collected to a local directory and then uploaded to S3 # Static files should be collected to a local directory and then uploaded to S3
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set
AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4 AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4
AWS_S3_USE_SSL = True AWS_S3_USE_SSL = True
AWS_S3_FILE_OVERWRITE = True AWS_S3_FILE_OVERWRITE = True
AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------") print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️--------------------------------- #--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
@@ -893,3 +917,18 @@ SPECTACULAR_DEFAULTS: Dict[str, Any] = {
'OAUTH2_REFRESH_URL': None, 'OAUTH2_REFRESH_URL': None,
'OAUTH2_SCOPES': None, 'OAUTH2_SCOPES': None,
} }
# --- GoPay configuration (set in backend/.env) ---
GOPAY_GOID = os.getenv("GOPAY_GOID")
GOPAY_CLIENT_ID = os.getenv("GOPAY_CLIENT_ID")
GOPAY_CLIENT_SECRET = os.getenv("GOPAY_CLIENT_SECRET")
GOPAY_GATEWAY_URL = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api")
# New: absolute URL that GoPay calls (publicly reachable)
GOPAY_NOTIFICATION_URL = os.getenv("GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook")
# -------------------------------------DOWNLOADER LIMITS------------------------------------
DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap
DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024
DOWNLOADER_TIMEOUT = int(os.getenv("DOWNLOADER_TIMEOUT", "120")) # seconds
DOWNLOADER_TMP_DIR = os.getenv("DOWNLOADER_TMP_DIR", str(BASE_DIR / "tmp" / "downloader"))
# -------------------------------------END DOWNLOADER LIMITS--------------------------------

View File

@@ -32,8 +32,11 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/account/', include('account.urls')), path('api/account/', include('account.urls')),
#path('api/commerce/', include('commerce.urls')),
#path('api/advertisments/', include('advertisements.urls')),
path('api/stripe/', include('thirdparty.stripe.urls')), path('api/stripe/', include('thirdparty.stripe.urls')),
path('api/trading212/', include('thirdparty.trading212.urls')), path('api/trading212/', include('thirdparty.trading212.urls')),
path('api/downloader/', include('thirdparty.downloader.urls')),
] ]

View File

@@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<link rel="stylesheet" href="reset.css">
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/react-router": "^5.1.20", "@types/react-router": "^5.1.20",
"axios": "^1.13.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.8.1" "react-icons": "^5.5.0",
"react-router-dom": "^7.8.1",
"tailwindcss": "^4.1.16"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,10 +1,33 @@
import { useState } from 'react' import { BrowserRouter as Router, Routes, Route, Link, Outlet } from "react-router-dom"
import './App.css' import Home from "./pages/home/home";
import HomeLayout from "./layouts/HomeLayout";
import Downloader from "./pages/downloader/Downloader";
function App() { import PrivateRoute from "./routes/PrivateRoute";
import { UserContextProvider } from "./context/UserContext";
export default function App() {
return ( return (
/* */ <Router>
<UserContextProvider>
{/* Layout route */}
<Route path="/" element={<HomeLayout />}>
<Route index element={<Home />} />
<Route path="downloader" element={<Downloader />} />
</Route>
<Route element={<PrivateRoute />}>
{/* Protected routes go here */}
<Route path="/" element={<HomeLayout />} >
<Route path="protected-downloader" element={<Downloader />} />
</Route>
</Route>
</UserContextProvider>
</Router>
) )
} }
export default App

275
frontend/src/api/Client.ts Normal file
View File

@@ -0,0 +1,275 @@
import axios from "axios";
// --- ENV CONFIG ---
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
// --- ERROR EVENT BUS ---
const ERROR_EVENT = "api:error";
type ApiErrorDetail = {
message: string;
status?: number;
url?: string;
data?: unknown;
};
// Use interface instead of arrow function types for readability
interface ApiErrorHandler {
(e: CustomEvent<ApiErrorDetail>): void;
}
function notifyError(detail: ApiErrorDetail) {
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
// eslint-disable-next-line no-console
console.error("[API ERROR]", detail);
}
function onError(handler: ApiErrorHandler) {
const wrapped = handler as EventListener;
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
return () => window.removeEventListener(ERROR_EVENT, wrapped);
}
// --- AXIOS INSTANCES ---
// Always send cookies. Django will set auth cookies; browser will include them automatically.
function createAxios(baseURL: string): any {
const instance = axios.create({
baseURL,
withCredentials: true, // cookies
headers: {
"Content-Type": "application/json",
},
timeout: 20000,
});
return instance;
}
// Use a single behavior for both: cookies are always sent
const apiPublic = createAxios(API_BASE_URL);
const apiAuth = createAxios(API_BASE_URL);
// --- REQUEST INTERCEPTOR (PUBLIC) ---
// Ensure no Authorization header is ever sent by the public client
apiPublic.interceptors.request.use(function (config: any) {
if (config?.headers && (config.headers as any).Authorization) {
delete (config.headers as any).Authorization;
}
return config;
});
// --- REQUEST INTERCEPTOR (AUTH) ---
// Do not attach Authorization header; rely on cookies set by Django.
apiAuth.interceptors.request.use(function (config: any) {
(config as any)._retryCount = (config as any)._retryCount || 0;
return config;
});
// --- RESPONSE INTERCEPTOR (AUTH) ---
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
apiAuth.interceptors.response.use(
function (response: any) {
return response;
},
async function (error: any) {
if (!error.response) {
alert("Backend connection is unavailable. Please check your network.");
notifyError({
message: "Network error or backend unavailable",
url: error.config?.url,
});
return Promise.reject(error);
}
const status = error.response.status;
if (status === 401) {
ClearTokens();
window.location.assign(LOGIN_PATH);
return Promise.reject(error);
}
notifyError({
message:
(error.response.data as any)?.detail ||
(error.response.data as any)?.message ||
`Request failed with status ${status}`,
status,
url: error.config?.url,
data: error.response.data,
});
return Promise.reject(error);
}
);
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
apiPublic.interceptors.response.use(
function (response: any) {
return response;
},
async function (error: any) {
if (!error.response) {
alert("Backend connection is unavailable. Please check your network.");
notifyError({
message: "Network error or backend unavailable",
url: error.config?.url,
});
return Promise.reject(error);
}
notifyError({
message:
(error.response.data as any)?.detail ||
(error.response.data as any)?.message ||
`Request failed with status ${error.response.status}`,
status: error.response.status,
url: error.config?.url,
data: error.response.data,
});
return Promise.reject(error);
}
);
function Logout() {
try {
const LogOutResponse = apiAuth.post("/api/logout/");
if (LogOutResponse.body.detail != "Logout successful") {
throw new Error("Logout failed");
}
ClearTokens();
} catch (error) {
console.error("Error during logout:", error);
}
}
function ClearTokens(){
document.cookie = "access_token=; Max-Age=0; path=/";
document.cookie = "refresh_token=; Max-Age=0; path=/";
}
// --- EXPORT DEFAULT API WRAPPER ---
const Client = {
// Axios instances
auth: apiAuth,
public: apiPublic,
Logout,
// Error subscription
onError,
};
export default Client;
/**
USAGE EXAMPLES (TypeScript/React)
Import the client
--------------------------------------------------
import Client from "@/api/Client";
Login: obtain tokens and persist to cookies
--------------------------------------------------
async function login(username: string, password: string) {
// SimpleJWT default login endpoint (adjust if your backend differs)
// Example backend endpoint: POST /api/token/ -> { access, refresh }
const res = await Client.public.post("/api/token/", { username, password });
const { access, refresh } = res.data;
Client.setTokens(access, refresh);
// After this, Client.auth will automatically attach Authorization header
// and refresh when receiving a 401 (up to 2 retries).
}
Public request (no cookies, no Authorization)
--------------------------------------------------
// The public client does NOT send cookies or Authorization.
async function listPublicItems() {
const res = await Client.public.get("/api/public/items/");
return res.data;
}
Authenticated requests (auto Bearer header + refresh on 401)
--------------------------------------------------
async function fetchProfile() {
const res = await Client.auth.get("/api/users/me/");
return res.data;
}
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
const res = await Client.auth.patch("/api/users/me/", payload);
return res.data;
}
Global error handling (UI notifications)
--------------------------------------------------
import { useEffect } from "react";
function useApiErrors(showToast: (msg: string) => void) {
useEffect(function () {
const unsubscribe = Client.onError(function (e) {
const { message, status } = e.detail;
showToast(status ? String(status) + ": " + message : message);
});
return unsubscribe;
}, [showToast]);
}
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
// All errors are logged to console for developers.
Logout
--------------------------------------------------
function logout() {
Client.clearTokens();
window.location.assign("/login");
}
Route protection (PrivateRoute)
--------------------------------------------------
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
// Example:
// <Routes>
// <Route element={<PrivateRoute />} >
// <Route element={<MainLayout />}>
// <Route path="/" element={<Dashboard />} />
// <Route path="/profile" element={<Profile />} />
// </Route>
// </Route>
// <Route path="/login" element={<Login />} />
// </Routes>
Refresh and retry flow (what happens on 401)
--------------------------------------------------
// 1) Client.auth request receives 401 from backend
// 2) Client tries to refresh access token using refresh_token cookie
// 3) If refresh succeeds, original request is retried (max 2 times)
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
Environment variables (optional overrides)
--------------------------------------------------
// VITE_API_BASE_URL default: "http://localhost:8000"
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
// VITE_LOGIN_PATH default: "/login"
*/

View File

@@ -0,0 +1,114 @@
import Client from "../Client";
// Available output containers (must match backend)
export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
export type FormatExt = (typeof FORMAT_EXTS)[number];
export type InfoResponse = {
title: string | null;
duration: number | null;
thumbnail: string | null;
video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
};
// GET info for a URL
export async function fetchInfo(url: string): Promise<InfoResponse> {
const res = await Client.public.get("/api/downloader/download/", {
params: { url },
});
return res.data as InfoResponse;
}
// POST to stream binary immediately; returns { blob, filename }
export async function downloadImmediate(args: {
url: string;
ext: FormatExt;
videoResolution?: string | number; // "1080p" or 1080
audioResolution?: string | number; // "160kbps" or 160
}): Promise<{ blob: Blob; filename: string }> {
const video_quality = toHeight(args.videoResolution);
const audio_quality = toKbps(args.audioResolution);
if (video_quality == null || audio_quality == null) {
throw new Error("Please select both video and audio quality.");
}
const res = await Client.public.post(
"/api/downloader/download/",
{
url: args.url,
ext: args.ext,
video_quality,
audio_quality,
},
{ responseType: "blob" }
);
const cd = res.headers?.["content-disposition"] as string | undefined;
const xfn = res.headers?.["x-filename"] as string | undefined;
const filename =
parseContentDispositionFilename(cd) ||
(xfn && xfn.trim()) ||
inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
`download.${args.ext}`;
return { blob: res.data as Blob, filename };
}
// Helpers
export function parseContentDispositionFilename(cd?: string): string | null {
if (!cd) return null;
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
return plainMatch?.[1]?.trim() || null;
}
function inferFilenameFromUrl(url: string, contentType?: string): string {
try {
const u = new URL(url);
const last = u.pathname.split("/").filter(Boolean).pop();
if (last) return last;
} catch {
// ignore
}
if (contentType) {
const ext = contentTypeToExt(contentType);
return `download${ext ? `.${ext}` : ""}`;
}
return "download.bin";
}
function contentTypeToExt(ct?: string): string | null {
if (!ct) return null;
const map: Record<string, string> = {
"video/mp4": "mp4",
"video/x-matroska": "mkv",
"video/webm": "webm",
"video/x-flv": "flv",
"video/quicktime": "mov",
"video/x-msvideo": "avi",
"video/ogg": "ogg",
"application/octet-stream": "bin",
};
return map[ct] || null;
}
function toHeight(v?: string | number): number | undefined {
if (typeof v === "number") return v || undefined;
if (!v) return undefined;
const m = /^(\d{2,4})p$/i.exec(v.trim());
if (m) return parseInt(m[1], 10);
const n = Number(v);
return Number.isFinite(n) ? (n as number) : undefined;
}
function toKbps(v?: string | number): number | undefined {
if (typeof v === "number") return v || undefined;
if (!v) return undefined;
const m = /^(\d{2,4})\s*kbps$/i.exec(v.trim());
if (m) return parseInt(m[1], 10);
const n = Number(v);
return Number.isFinite(n) ? (n as number) : undefined;
}

View File

@@ -1,202 +0,0 @@
import axios from "axios";
const API_URL: string = `${import.meta.env.VITE_BACKEND_URL}/api`;
// Axios instance, můžeme používat místo globálního axios
const axios_instance = axios.create({
baseURL: API_URL,
withCredentials: true, // potřebné pro cookies
});
axios_instance.defaults.xsrfCookieName = "csrftoken";
axios_instance.defaults.xsrfHeaderName = "X-CSRFToken";
export default axios_instance;
// 🔐 Axios response interceptor: automatická obnova při 401
axios_instance.interceptors.request.use((config) => {
const getCookie = (name: string): string | null => {
let cookieValue: string | null = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + "=")) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
const csrfToken = getCookie("csrftoken");
if (csrfToken && config.method && ["post", "put", "patch", "delete"].includes(config.method)) {
if (!config.headers) config.headers = {};
config.headers["X-CSRFToken"] = csrfToken;
}
return config;
});
// Přidej globální response interceptor pro redirect na login při 401 s detail hláškou
axios_instance.interceptors.response.use(
(response) => response,
(error) => {
if (
error.response &&
error.response.status === 401 &&
error.response.data &&
error.response.data.detail === "Nebyly zadány přihlašovací údaje."
) {
window.location.href = "/login";
}
return Promise.reject(error);
}
);
// 🔄 Obnova access tokenu pomocí refresh cookie
export const refreshAccessToken = async (): Promise<{ access: string; refresh: string } | null> => {
try {
const res = await axios_instance.post(`/account/token/refresh/`);
return res.data as { access: string; refresh: string };
} catch (err) {
console.error("Token refresh failed", err);
await logout();
return null;
}
};
// ✅ Přihlášení
export const login = async (username: string, password: string): Promise<any> => {
await logout();
try {
const response = await axios_instance.post(`/account/token/`, { username, password });
return response.data;
} catch (err: any) {
if (err.response) {
// Server responded with a status code outside 2xx
console.log('Login error status:', err.response.status);
} else if (err.request) {
// Request was made but no response received
console.log('Login network error:', err.request);
} else {
// Something else happened
console.log('Login setup error:', err.message);
}
throw err;
}
};
// ❌ Odhlášení s CSRF tokenem
export const logout = async (): Promise<any> => {
try {
const getCookie = (name: string): string | null => {
let cookieValue: string | null = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + "=")) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
const csrfToken = getCookie("csrftoken");
const response = await axios_instance.post(
"/account/logout/",
{},
{
headers: {
"X-CSRFToken": csrfToken,
},
}
);
console.log(response.data);
return response.data; // např. { detail: "Logout successful" }
} catch (err) {
console.error("Logout failed", err);
throw err;
}
};
/**
* 📡 Obecný request pro API
*
* @param method - HTTP metoda (např. "get", "post", "put", "patch", "delete")
* @param endpoint - API endpoint (např. "/api/service-tickets/")
* @param data - data pro POST/PUT/DELETE requesty
* @param config - další konfigurace pro axios request
* @returns Promise<any> - vrací data z odpovědi
*/
export const apiRequest = async (
method: string,
endpoint: string,
data: Record<string, any> = {},
config: Record<string, any> = {}
): Promise<any> => {
const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
try {
const response = await axios_instance({
method,
url,
data: ["post", "put", "patch"].includes(method.toLowerCase()) ? data : undefined,
params: ["get", "delete"].includes(method.toLowerCase()) ? data : undefined,
...config,
});
return response.data;
} catch (err: any) {
if (err.response) {
// Server odpověděl s kódem mimo rozsah 2xx
console.error("API Error:", {
status: err.response.status,
data: err.response.data,
headers: err.response.headers,
});
} else if (err.request) {
// Request byl odeslán, ale nedošla odpověď
console.error("No response received:", err.request);
} else {
// Něco jiného se pokazilo při sestavování requestu
console.error("Request setup error:", err.message);
}
throw err;
}
};
// 👤 Funkce pro získání aktuálně přihlášeného uživatele
export async function getCurrentUser(): Promise<any> {
const response = await axios_instance.get(`${API_URL}/account/user/me/`);
return response.data; // vrací data uživatele
}
// 🔒 ✔️ Jednoduchá funkce, která kontroluje přihlášení - můžeš to upravit dle potřeby
export async function isAuthenticated(): Promise<boolean> {
try {
const user = await getCurrentUser();
return user != null;
} catch (err) {
return false; // pokud padne 401, není přihlášen
}
}
export { axios_instance, API_URL };

View File

@@ -1,26 +0,0 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
/**
* Makes a general external API call using axios.
*
* @param url - The full URL of the external API endpoint.
* @param method - HTTP method (GET, POST, PUT, PATCH, DELETE, etc.).
* @param data - Request body data (for POST, PUT, PATCH). Optional.
* @param config - Additional Axios request config (headers, params, etc.). Optional.
* @returns Promise resolving to AxiosResponse<any>.
*
* @example externalApiCall("https://api.example.com/data", "post", { foo: "bar" }, { headers: { Authorization: "Bearer token" } })
*/
export async function externalApiCall(
url: string,
method: AxiosRequestConfig["method"],
data?: any,
config?: AxiosRequestConfig
): Promise<AxiosResponse<any>> {
return axios({
url,
method,
data,
...config,
});
}

View File

@@ -1,4 +1,4 @@
import { apiRequest } from "./axios"; import Client from "./Client";
/** /**
* Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category). * Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category).
@@ -16,7 +16,7 @@ export async function fetchEnumFromSchemaJson(
schemaUrl: string = "/schema/?format=json" schemaUrl: string = "/schema/?format=json"
): Promise<Array<{ value: string; label: string }>> { ): Promise<Array<{ value: string; label: string }>> {
try { try {
const schema = await apiRequest("get", schemaUrl); const schema = await Client.public.get(schemaUrl);
const methodDef = schema.paths?.[path]?.[method]; const methodDef = schema.paths?.[path]?.[method];
if (!methodDef) { if (!methodDef) {

View File

@@ -0,0 +1,82 @@
// frontend/src/api/model/user.js
// User API model for searching users by username
// Structure matches other model files (see order.js for reference)
import Client from '../Client';
const API_BASE_URL = "/account/users";
const userAPI = {
/**
* Get current authenticated user
* @returns {Promise<User>}
*/
async getCurrentUser() {
const response = await Client.auth.get(`${API_BASE_URL}/me/`);
return response.data;
},
/**
* Get all users
* @returns {Promise<Array<User>>}
*/
async getUsers(params: Object) {
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
return response.data;
},
/**
* Get a single user by ID
* @param {number|string} id
* @returns {Promise<User>}
*/
async getUser(id: number) {
const response = await Client.auth.get(`${API_BASE_URL}/${id}/`);
return response.data;
},
/**
* Update a user by ID
* @param {number|string} id
* @param {Object} data
* @returns {Promise<User>}
*/
async updateUser(id: number, data: Object) {
const response = await Client.auth.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
},
/**
* Delete a user by ID
* @param {number|string} id
* @returns {Promise<void>}
*/
async deleteUser(id: number) {
const response = await Client.auth.delete(`${API_BASE_URL}/${id}/`);
return response.data;
},
/**
* Create a new user
* @param {Object} data
* @returns {Promise<User>}
*/
async createUser(data: Object) {
const response = await Client.auth.post(`${API_BASE_URL}/`, data);
return response.data;
},
/**
* Search users by username (partial match)
* @param {Object} params - { username: string }
* @returns {Promise<Array<User>>}
*/
async searchUsers(params: { username: string }) {
// Adjust the endpoint as needed for your backend
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
console.log("User search response:", response.data);
return response.data;
},
};
export default userAPI;

View File

@@ -0,0 +1,19 @@
const wsUri = "ws://127.0.0.1/";
const websocket = new WebSocket(wsUri);
websocket.onopen = function (event) {
console.log("WebSocket is open now.", event);
};
websocket.onmessage = function (event) {
console.log("WebSocket message received:", event.data);
};
websocket.onclose = function (event) {
console.log("WebSocket is closed now.", event.reason);
};
websocket.onerror = function (event) {
console.error("WebSocket error observed:", event);
};

View File

@@ -1,28 +0,0 @@
<footer id="contacts">
<div class="logo">
<h1>vontor.cz</h1>
</div>
<address>
Written by <b>David Bruno Vontor</b><br>
<p>Tel.: <a href="tel:+420 605 512 624"><u>+420 605 512 624</u></a></p>
<p>E-mail: <a href="mailto:brunovontor@gmail.com"><u>brunovontor@gmail.com</u></a></p>
<p>IČO: <a href="https://www.rzp.cz/verejne-udaje/cs/udaje/vyber-subjektu;ico=21613109;"><u>21613109</u></a></p>
</address>
<div class="contacts">
<a href="https://github.com/Brunobrno">
<i class="fa fa-github"></i>
</a>
<a href="https://www.instagram.com/brunovontor/">
<i class="fa fa-instagram"></i>
</a>
<a href="https://twitter.com/BVontor">
<i class="fa-brands fa-x-twitter"></i>
</a>
<a href="https://steamcommunity.com/id/Brunobrno/">
<i class="fa-brands fa-steam"></i>
</a>
<a href="www.youtube.com/@brunovontor">
<i class="fa-brands fa-youtube"></i>
</a>
</div>
</footer>

View File

@@ -0,0 +1,40 @@
footer a{
color: var(--c-text);
text-decoration: none;
}
footer a i{
color: white;
text-decoration: none;
}
footer a:hover i{
color: var(--c-text);
text-decoration: none;
}
footer{
font-family: "Roboto Mono", monospace;
background-color: var(--c-boxes);
margin-top: 2em;
display: flex;
color: white;
align-items: center;
justify-content: space-evenly;
}
footer address{
padding: 1em;
font-style: normal;
}
footer .contacts{
font-size: 2em;
}
@media only screen and (max-width: 990px){
footer{
flex-direction: column;
padding-bottom: 1em;
padding-top: 1em;
}
}

View File

@@ -0,0 +1,76 @@
import styles from "./footer.module.css"
export default function Footer() {
return (
<footer id="contacts">
<div>
<h1>vontor.cz</h1>
</div>
<address>
Written by <b>David Bruno Vontor | © 2025</b>
<br />
<p>
Tel.:{" "}
<a href="tel:+420605512624">
<u>+420 605 512 624</u>
</a>
</p>
<p>
E-mail:{" "}
<a href="mailto:brunovontor@gmail.com">
<u>brunovontor@gmail.com</u>
</a>
</p>
<p>
IČO:{" "}
<a
href="https://www.rzp.cz/verejne-udaje/cs/udaje/vyber-subjektu;ico=21613109;"
target="_blank"
rel="noopener noreferrer"
>
<u>21613109</u>
</a>
</p>
</address>
<div className="contacts">
<a
href="https://github.com/Brunobrno"
target="_blank"
rel="noopener noreferrer"
>
<i className="fa fa-github"></i>
</a>
<a
href="https://www.instagram.com/brunovontor/"
target="_blank"
rel="noopener noreferrer"
>
<i className="fa fa-instagram"></i>
</a>
<a
href="https://twitter.com/BVontor"
target="_blank"
rel="noopener noreferrer"
>
<i className="fa-brands fa-x-twitter"></i>
</a>
<a
href="https://steamcommunity.com/id/Brunobrno/"
target="_blank"
rel="noopener noreferrer"
>
<i className="fa-brands fa-steam"></i>
</a>
<a
href="https://www.youtube.com/@brunovontor"
target="_blank"
rel="noopener noreferrer"
>
<i className="fa-brands fa-youtube"></i>
</a>
</div>
</footer>
)
}

View File

@@ -1,99 +0,0 @@
/*TODO: Implement the contact form functionality*/
{% load static %}
<div class="contact-me">
<div class="opening">
<i class="fa-solid fa-arrow-pointer" aria-hidden="true"></i>
</div>
<div class="content">
<form method="post" id="contactme-form">
{% csrf_token %}
{{ contactme_form }}
<input type="submit" value="Submit">
</form>
</div>
<div class="cover"></div>
<div class="triangle"></div>
</div>
<script src="{% static 'home/js/global/contact-me.js' %}"></script>
contact-me.js:
$(document).ready(function () {
$("#contactme-form").submit(function (event) {
event.preventDefault(); // Prevent normal form submission
$.ajax({
url: "/submit-contactme/", // URL of the Django view
type: "POST",
data: $(this).serialize(), // Serialize form data
success: function (response) {
if (response.success) {
close_contact();
$("#contactme-form .success-form-alert").fadeIn();
$("#contactme-form")[0].reset(); // Clear the form
alert("Zpráva odeslaná!")
}
},
error: function (response) {
alert("Zpráva nebyla odeslaná, zkontrolujte si připojení k internetu nebo naskytl u nás problém :(")
}
});
});
$("#contactme-form .success-form .close").click(function () {
$("#contactme-form .success-form-alert").fadeOut();
});
var opened_contact = false;
$(document).on("click", ".contact-me .opening", function () {
console.log("toggle mail");
const opening = $(".contact-me .opening");
// Check if we're opening or closing
if (opened_contact === false) {
// Toggle rotation
opening.toggleClass('rotate-opening');
// Wait for the rotation to finish
setTimeout(function() {
$(".contact-me .content").addClass('content-moveup-index');
}, 500);
setTimeout(function() {
$(".contact-me .content")[0].offsetHeight;
$(".contact-me .content").addClass('content-moveup');
}, 1000); // Small delay to trigger transition
opened_contact = true;
} else {
close_contact();
}
});
function close_contact(){
$(".contact-me .content").removeClass('content-moveup');
setTimeout(function() {
$(".contact-me .content").toggleClass('content-moveup-index');
$(".contact-me .opening").toggleClass('rotate-opening');
}, 700);
opened_contact = false;
}
});

View File

@@ -0,0 +1,85 @@
import React, { useState, useRef } from "react"
import styles from "./contact-me.module.css"
import { LuMousePointerClick } from "react-icons/lu";
export default function ContactMeForm() {
const [opened, setOpened] = useState(false)
const [contentMoveUp, setContentMoveUp] = useState(false)
const [openingBehind, setOpeningBehind] = useState(false)
const [success, setSuccess] = useState(false)
const openingRef = useRef<HTMLDivElement>(null)
function handleSubmit() {
// form submission logic here
}
const toggleOpen = () => {
if (!opened) {
setOpened(true)
setOpeningBehind(false)
setContentMoveUp(false)
// Wait for the rotate-opening animation to finish before moving content up
// The actual moveUp will be handled in onTransitionEnd
} else {
setContentMoveUp(false)
setOpeningBehind(false)
setTimeout(() => setOpened(false), 1000) // match transition duration
}
}
const handleTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
if (opened && e.propertyName === "transform") {
setContentMoveUp(true)
// Move the opening behind after the animation
setTimeout(() => setOpeningBehind(true), 10)
}
if (!opened && e.propertyName === "transform") {
setOpeningBehind(false)
}
}
return (
<div className={styles["contact-me"]}>
<div
ref={openingRef}
className={
[
styles.opening,
opened ? styles["rotate-opening"] : "",
openingBehind ? styles["opening-behind"] : ""
].filter(Boolean).join(" ")
}
onClick={toggleOpen}
onTransitionEnd={handleTransitionEnd}
>
<LuMousePointerClick/>
</div>
<div
className={
contentMoveUp
? `${styles.content} ${styles["content-moveup"]}`
: styles.content
}
>
<form onSubmit={handleSubmit}>
<input
type="email"
name="email"
placeholder="Váš email"
required
/>
<textarea
name="message"
placeholder="Vaše zpráva"
required
/>
<input type="submit"/>
</form>
</div>
<div className={styles.cover}></div>
<div className={styles.triangle}></div>
</div>
)
}

View File

@@ -35,6 +35,11 @@
transform: rotateX(180deg); transform: rotateX(180deg);
} }
.opening svg{
margin: auto;
font-size: 3em;
margin-top: -0.5em;
}
.contact-me .content { .contact-me .content {
@@ -89,7 +94,7 @@
} }
.opening-behind { z-index: 0 !important; }
.contact-me .cover { .contact-me .cover {
position: absolute; position: absolute;

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useRef } from "react"
import styles from "./drone.module.css"
export default function Drone() {
const videoRef = useRef<HTMLVideoElement | null>(null)
const sourceRef = useRef<HTMLSourceElement | null>(null)
useEffect(() => {
function setVideoDroneQuality() {
if (!sourceRef.current || !videoRef.current) return
const videoSources = {
fullHD: "static/home/video/drone-background-video-1080p.mp4", // For desktops (1920x1080)
hd: "static/home/video/drone-background-video-720p.mp4", // For tablets/smaller screens (1280x720)
lowRes: "static/home/video/drone-background-video-480p.mp4" // For mobile devices or low performance (854x480)
}
const screenWidth = window.innerWidth
// Pick appropriate source
if (screenWidth >= 1920) {
sourceRef.current.src =
"https://vontor-cz.s3.eu-central-1.amazonaws.com/" + videoSources.fullHD
} else if (screenWidth >= 1280) {
sourceRef.current.src =
"https://vontor-cz.s3.eu-central-1.amazonaws.com/" + videoSources.hd
} else {
sourceRef.current.src =
"https://vontor-cz.s3.eu-central-1.amazonaws.com/" + videoSources.lowRes
}
// Reload video
videoRef.current.load()
console.log("Drone video set!")
}
// Run once on mount
setVideoDroneQuality()
// Optional: rerun on resize
window.addEventListener("resize", setVideoDroneQuality)
return () => {
window.removeEventListener("resize", setVideoDroneQuality)
}
}, [])
return (
<div className={`${styles.drone}`}>
<video
ref={videoRef}
id="drone-video"
className={styles.videoBackground}
autoPlay
muted
loop
playsInline
>
<source ref={sourceRef} id="video-source" type="video/mp4" />
Your browser does not support video.
</video>
<article>
<header>
<h1>Letecké záběry, co zaujmou</h1>
</header>
<main>
<section>
<h2>Oprávnění</h2>
<p>Oprávnění A1/A2/A3 + radiostanice. Bezpečný provoz i v omezených zónách, povolení zajistím.</p>
</section>
<section>
<h2>Cena</h2>
<p>Paušál 3000. Ostrava zdarma; mimo 10/km. Cena se může lišit dle povolení.</p>
</section>
<section>
<h2>Výstup</h2>
<p>Krátký sestřih nebo surové záběry podle potřeby.</p>
</section>
</main>
<div>
<a href="#contacts">Zájem?</a>
</div>
</article>
</div>
)
}

View File

@@ -13,7 +13,7 @@
} }
.drone .video-background { .drone .videoBackground {
height: 100%; height: 100%;
width: 100%; width: 100%;
position: absolute; position: absolute;

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -20,15 +20,26 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: #c2a67d; background-color: #c2a67d;
color: #5e5747;
border-radius: 1em; border-radius: 1em;
transform-origin: bottom; transform-origin: bottom;
transition: transform 0.5s ease-in-out; transition: transform 0.5s ease-in-out;
transform: skew(-5deg);
z-index: 3; z-index: 3;
box-shadow: #000000 5px 5px 15px;
} }
.portfolio div span svg{
font-size: 5em;
cursor: pointer;
animation: shake 0.4s ease-in-out infinite;
}
@keyframes shake { @keyframes shake {
0% { transform: translateX(0); } 0% { transform: translateX(0); }
@@ -48,14 +59,14 @@
} }
.portfolio .door-open{ .portfolio .door-open{
transform: rotateX(180deg); transform: rotateX(90deg) skew(-2deg) !important;
} }
.portfolio>header { .portfolio>header {
width: fit-content; width: fit-content;
position: absolute; position: absolute;
z-index: 5; z-index: 0;
top: -4.7em; top: -3.7em;
left: 0; left: 0;
padding: 1em 3em; padding: 1em 3em;
padding-bottom: 0; padding-bottom: 0;
@@ -112,6 +123,7 @@
.portfolio div { .portfolio div {
width: 100%;
padding: 3em; padding: 3em;
background-color: #cdc19c; background-color: #cdc19c;
display: flex; display: flex;
@@ -122,6 +134,8 @@
border-radius: 1em; border-radius: 1em;
border-top-left-radius: 0; border-top-left-radius: 0;
aspect-ratio: 16 / 9;
} }
.portfolio div article { .portfolio div article {
@@ -136,7 +150,6 @@
} }
.portfolio div article header a img { .portfolio div article header a img {
padding: 2em 0;
width: 80%; width: 80%;
margin: auto; margin: auto;
} }

View File

@@ -0,0 +1,86 @@
import React, { useState } from "react"
import styles from "./Portfolio.module.css"
import { LuMousePointerClick } from "react-icons/lu";
interface PortfolioItem {
href: string
src: string
alt: string
// Optional per-item styling (prefer Tailwind utility classes in className/imgClassName)
className?: string
imgClassName?: string
style?: React.CSSProperties
imgStyle?: React.CSSProperties
}
const portfolioItems: PortfolioItem[] = [
{
href: "https://davo1.cz",
src: "/portfolio/davo1.png",
alt: "davo1.cz logo",
imgClassName: "bg-black rounded-lg p-4",
//className: "bg-white/5 rounded-lg p-4",
},
{
href: "https://perlica.cz",
src: "/portfolio/perlica.png",
alt: "Perlica logo",
imgClassName: "rounded-lg",
// imgClassName: "max-h-12",
},
{
href: "http://epinger2.cz",
src: "/portfolio/epinger.png",
alt: "Epinger2 logo",
imgClassName: "bg-white rounded-lg",
// imgClassName: "max-h-12",
},
]
export default function Portfolio() {
const [doorOpen, setDoorOpen] = useState(false)
const toggleDoor = () => setDoorOpen((prev) => !prev)
return (
<div className={styles.portfolio} id="portfolio">
<header>
<h1>Portfolio</h1>
</header>
<div>
<span
className={
doorOpen
? `${styles.door} ${styles["door-open"]}`
: styles.door
}
onClick={toggleDoor}
>
<LuMousePointerClick/>
</span>
{portfolioItems.map((item, index) => (
<article
key={index}
className={`${styles.article} ${item.className ?? ""}`}
style={item.style}
>
<header>
<a href={item.href} target="_blank" rel="noopener noreferrer">
<img
src={item.src}
alt={item.alt}
className={item.imgClassName}
style={item.imgStyle}
/>
</a>
</header>
<main></main>
</article>
))}
</div>
</div>
)
}

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Some files were not shown because too many files have changed in this diff Show More