Compare commits
9 Commits
main
...
a324a9cf49
| Author | SHA1 | Date | |
|---|---|---|---|
| a324a9cf49 | |||
| 47b9770a70 | |||
|
|
4791bbc92c | ||
| 8dd4f6e731 | |||
| dd9d076bd2 | |||
| 73da41b514 | |||
| 10796dcb31 | |||
| f5cf8bbaa7 | |||
| d0227e4539 |
119
.github/copilot-instructions.md
vendored
Normal 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
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,8 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt update && apt install ffmpeg -y
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
@@ -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 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')),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('user', 'Uživatel')], max_length=32, null=True)),
|
||||
('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)),
|
||||
('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)),
|
||||
('gdpr', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||
('city', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('street', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
||||
('gdpr', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
||||
],
|
||||
@@ -47,8 +47,8 @@ class Migration(migrations.Migration):
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', account.models.CustomUserActiveManager()),
|
||||
('all_objects', account.models.CustomUserAllManager()),
|
||||
('objects', account.models.CustomUserManager()),
|
||||
('active', account.models.ActiveUserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,9 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, Group, Permission
|
||||
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
|
||||
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@@ -16,16 +17,13 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Custom User Manager to handle soft deletion
|
||||
class CustomUserActiveManager(UserManager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_deleted=False)
|
||||
class CustomUserManager(UserManager):
|
||||
# Inherit get_by_natural_key and all auth behaviors
|
||||
use_in_migrations = True
|
||||
|
||||
# Custom User Manager to handle all users, including soft deleted
|
||||
class CustomUserAllManager(UserManager):
|
||||
class ActiveUserManager(CustomUserManager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset()
|
||||
|
||||
return super().get_queryset().filter(is_active=True)
|
||||
|
||||
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
groups = models.ManyToManyField(
|
||||
@@ -43,64 +41,45 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
related_query_name="customuser",
|
||||
)
|
||||
|
||||
ROLE_CHOICES = (
|
||||
('admin', 'Administrátor'),
|
||||
('user', 'Uživatel'),
|
||||
)
|
||||
role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True)
|
||||
class Role(models.TextChoices):
|
||||
ADMIN = "admin", "Admin"
|
||||
MANAGER = "mod", "Moderator"
|
||||
CUSTOMER = "regular", "Regular"
|
||||
|
||||
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(
|
||||
null=True,
|
||||
blank=True,
|
||||
|
||||
unique=True,
|
||||
max_length=16,
|
||||
blank=True,
|
||||
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)
|
||||
|
||||
# + 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)
|
||||
|
||||
|
||||
"""company_id = models.CharField(
|
||||
max_length=8,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r'^\d{8}$',
|
||||
message="Company ID must contain exactly 8 digits.",
|
||||
code='invalid_company_id'
|
||||
)
|
||||
]
|
||||
)"""
|
||||
|
||||
"""personal_id = models.CharField(
|
||||
max_length=11,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r'^\d{6}/\d{3,4}$',
|
||||
message="Personal ID must be in the format 123456/7890.",
|
||||
code='invalid_personal_id'
|
||||
)
|
||||
]
|
||||
)"""
|
||||
|
||||
city = models.CharField(null=True, blank=True, max_length=100)
|
||||
street = models.CharField(null=True, blank=True, max_length=200)
|
||||
|
||||
postal_code = models.CharField(
|
||||
max_length=5,
|
||||
blank=True,
|
||||
null=True,
|
||||
|
||||
max_length=5,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
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()
|
||||
all_objects = CustomUserAllManager()
|
||||
|
||||
REQUIRED_FIELDS = ['email', "username", "password"]
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
|
||||
# Ensure default manager has get_by_natural_key
|
||||
objects = CustomUserManager()
|
||||
# Optional convenience manager for active users only
|
||||
active = ActiveUserManager()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.is_active = False
|
||||
|
||||
#self.orders.all().update(is_deleted=True, deleted_at=timezone.now())
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None # check BEFORE saving
|
||||
is_new = self._state.adding # True if object hasn't been saved yet
|
||||
|
||||
# Pre-save flags for new users
|
||||
if is_new:
|
||||
|
||||
if self.is_superuser or self.role == "admin":
|
||||
# ensure admin flags are consistent
|
||||
self.is_active = True
|
||||
|
||||
if self.role == 'admin':
|
||||
self.is_staff = True
|
||||
self.is_superuser = True
|
||||
|
||||
if self.is_superuser:
|
||||
self.role = 'admin'
|
||||
|
||||
self.role = "admin"
|
||||
else:
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
from urllib import request
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework_api_key.permissions import HasAPIKey
|
||||
|
||||
|
||||
#Podle svého uvážení (NEPOUŽÍVAT!!!)
|
||||
class RolePermission(BasePermission):
|
||||
allowed_roles = []
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Je uživatel přihlášený a má roli z povolených?
|
||||
user_has_role = (
|
||||
request.user and
|
||||
request.user.is_authenticated and
|
||||
getattr(request.user, "role", None) in self.allowed_roles
|
||||
)
|
||||
|
||||
# Má API klíč?
|
||||
has_api_key = HasAPIKey().has_permission(request, view)
|
||||
|
||||
|
||||
return user_has_role or has_api_key
|
||||
|
||||
|
||||
#TOHLE POUŽÍT!!!
|
||||
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
||||
def RoleAllowed(*roles):
|
||||
"""
|
||||
Allows safe methods for any authenticated user.
|
||||
Allows unsafe methods only for users with specific roles.
|
||||
Allows access if a valid API key is provided.
|
||||
|
||||
Args:
|
||||
RolerAllowed('admin', 'user')
|
||||
RoleAllowed('admin', 'user')
|
||||
"""
|
||||
class SafeOrRolePermission(BasePermission):
|
||||
|
||||
|
||||
def has_permission(self, request, view):
|
||||
# Má API klíč?
|
||||
has_api_key = HasAPIKey().has_permission(request, view)
|
||||
|
||||
# Allow safe methods for any authenticated user
|
||||
if request.method in SAFE_METHODS:
|
||||
return IsAuthenticated().has_permission(request, view)
|
||||
|
||||
@@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"last_name",
|
||||
"email",
|
||||
"role",
|
||||
"account_type",
|
||||
"email_verified",
|
||||
"phone_number",
|
||||
"create_time",
|
||||
"var_symbol",
|
||||
"bank_account",
|
||||
"ICO",
|
||||
"RC",
|
||||
"city",
|
||||
"street",
|
||||
"PSC",
|
||||
"GDPR",
|
||||
"postal_code",
|
||||
"gdpr",
|
||||
"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):
|
||||
user = self.context["request"].user
|
||||
|
||||
@@ -10,76 +10,143 @@ from .models import CustomUser
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@shared_task
|
||||
def send_password_reset_email_task(user_id):
|
||||
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
|
||||
"""
|
||||
General function to send emails with a specific context.
|
||||
Supports rendering plain text and HTML templates.
|
||||
Converts `user` in context to a plain dict to avoid template access to the model.
|
||||
"""
|
||||
if isinstance(recipients, str):
|
||||
recipients = [recipients]
|
||||
|
||||
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:
|
||||
user = CustomUser.objects.get(pk=user_id)
|
||||
except CustomUser.DoesNotExist:
|
||||
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent."
|
||||
logger.error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||
token = password_reset_token.make_token(user)
|
||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||
html_message = render_to_string(
|
||||
'emails/password_reset.html',
|
||||
{'reset_url': reset_url}
|
||||
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("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
subject="Obnova hesla",
|
||||
message=None,
|
||||
html_message=html_message
|
||||
)
|
||||
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
|
||||
|
||||
# Only email verification for user registration
|
||||
|
||||
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
|
||||
def send_email_verification_task(user_id):
|
||||
try:
|
||||
user = CustomUser.objects.get(pk=user_id)
|
||||
except CustomUser.DoesNotExist:
|
||||
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent."
|
||||
logger.error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
||||
return 0
|
||||
|
||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||
token = account_activation_token.make_token(user)
|
||||
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||
html_message = render_to_string(
|
||||
'emails/email_verification.html',
|
||||
{'verification_url': verification_url}
|
||||
)
|
||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
||||
# {changed} generate and store a per-user token
|
||||
token = user.generate_email_verification_token()
|
||||
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||
|
||||
context = {
|
||||
"user": _build_user_template_ctx(user),
|
||||
"action_url": verify_url,
|
||||
"frontend_url": settings.FRONTEND_URL,
|
||||
"cta_label": "Ověřit e‑mail",
|
||||
}
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
subject="Ověření e-mailu",
|
||||
message=None,
|
||||
html_message=html_message
|
||||
subject="Ověření e‑mailu",
|
||||
template_name="email/email_verification.txt",
|
||||
html_template_name="email/email_verification.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def send_email_with_context(recipients, subject, message=None, html_message=None):
|
||||
"""
|
||||
General function to send emails with a specific context.
|
||||
"""
|
||||
if isinstance(recipients, str):
|
||||
recipients = [recipients]
|
||||
@shared_task
|
||||
def send_email_test_task(email):
|
||||
context = {
|
||||
"action_url": settings.FRONTEND_URL,
|
||||
"frontend_url": settings.FRONTEND_URL,
|
||||
"cta_label": "Otevřít aplikaci",
|
||||
}
|
||||
send_email_with_context(
|
||||
recipients=email,
|
||||
subject="Testovací e‑mail",
|
||||
template_name="email/test.txt",
|
||||
html_template_name="email/test.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_password_reset_email_task(user_id):
|
||||
try:
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message if message else '',
|
||||
from_email=None,
|
||||
recipient_list=recipients,
|
||||
fail_silently=False,
|
||||
html_message=html_message
|
||||
user = CustomUser.objects.get(pk=user_id)
|
||||
except CustomUser.DoesNotExist:
|
||||
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
|
||||
return 0
|
||||
|
||||
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}"
|
||||
|
||||
context = {
|
||||
"user": _build_user_template_ctx(user),
|
||||
"action_url": reset_url,
|
||||
"frontend_url": settings.FRONTEND_URL,
|
||||
"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,
|
||||
)
|
||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"E-mail se neodeslal: {e}")
|
||||
return False
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Ověření e-mailu</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Ověření e-mailu</h2>
|
||||
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p>
|
||||
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body 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;">
|
||||
Ověření e‑mailu
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||
{% endwith %}
|
||||
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou 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 e‑mail byl odeslán z aplikace e‑tržnice.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
7
backend/account/templates/emails/email_verification.txt
Normal 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 e‑mailovou adresu kliknutím na následující odkaz:
|
||||
|
||||
{{ action_url }}
|
||||
|
||||
Pokud jste účet nevytvořili vy, tento e‑mail ignorujte.
|
||||
@@ -1,19 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Obnova hesla</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Obnova hesla</h2>
|
||||
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p>
|
||||
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body 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;">
|
||||
Obnova hesla
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||
{% endwith %}
|
||||
<p style="margin:0 0 12px 0;">Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail 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 e‑mail byl odeslán z aplikace e‑tržnice.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
7
backend/account/templates/emails/password_reset.txt
Normal 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 e‑mail, protože byla požádána obnova hesla k vašemu účtu.
|
||||
Pokud jste o změnu nepožádali, tento e‑mail ignorujte.
|
||||
|
||||
Pro nastavení nového hesla použijte tento odkaz:
|
||||
{{ action_url }}
|
||||
44
backend/account/templates/emails/test.html
Normal 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í e‑mail
|
||||
</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í e‑mail z aplikace e‑trž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 e‑mail byl odeslán z aplikace e‑tržnice.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
6
backend/account/templates/emails/test.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Dobrý den,
|
||||
|
||||
Toto je testovací e‑mail z aplikace e‑tržnice.
|
||||
|
||||
Odkaz na aplikaci:
|
||||
{{ action_url }}
|
||||
@@ -1,3 +1,28 @@
|
||||
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)}")
|
||||
|
||||
@@ -160,7 +160,7 @@ class CookieTokenRefreshView(APIView):
|
||||
except TokenError:
|
||||
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
#---------------------------------------------LOGIN/LOGOUT------------------------------------------------
|
||||
#---------------------------------------------LOGOUT------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
@@ -229,12 +229,16 @@ class UserView(viewsets.ModelViewSet):
|
||||
|
||||
# Only admin or the user themselves can update or delete
|
||||
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")()]
|
||||
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()]
|
||||
else:
|
||||
# fallback - deny access
|
||||
|
||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
# Any authenticated user can retrieve (view) any user's profile
|
||||
|
||||
3
backend/advertisement/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/advertisement/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdvertisementConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'advertisement'
|
||||
3
backend/advertisement/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
2
backend/advertisement/tasks.py
Normal 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
|
||||
3
backend/advertisement/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
backend/advertisement/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
14
backend/commerce/admin.py
Normal 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
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommerceConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'commerce'
|
||||
41
backend/commerce/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
40
backend/commerce/models.py
Normal 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ř. "2–3 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č)"
|
||||
|
||||
26
backend/commerce/serializers.py
Normal 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__"
|
||||
3
backend/commerce/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
backend/commerce/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
59
backend/env
Normal 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
|
||||
@@ -78,7 +78,7 @@ django-celery-beat #slouží k plánování úkolů pro Celery
|
||||
#opencv-python #moviepy use this better instead of pillow
|
||||
#moviepy
|
||||
|
||||
#yt-dlp
|
||||
yt-dlp
|
||||
|
||||
weasyprint #tvoření PDFek z html dokumentu + css styly
|
||||
|
||||
|
||||
3
backend/social/chat/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
8
backend/social/chat/apps.py
Normal 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"
|
||||
27
backend/social/chat/consumers.py
Normal 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)
|
||||
3
backend/social/chat/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
8
backend/social/chat/routing.py
Normal 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()),
|
||||
]
|
||||
3
backend/social/chat/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
backend/social/chat/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
10
backend/thirdparty/downloader/admin.py
vendored
Normal 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
@@ -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"
|
||||
30
backend/thirdparty/downloader/migrations/0001_initial.py
vendored
Normal 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
@@ -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")
|
||||
|
||||
|
||||
9
backend/thirdparty/downloader/serializers.py
vendored
Normal 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)
|
||||
3
backend/thirdparty/downloader/tests.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
backend/thirdparty/downloader/urls.py
vendored
Normal 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
@@ -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)
|
||||
205
backend/thirdparty/gopay/models.py
vendored
@@ -1,3 +1,206 @@
|
||||
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"])
|
||||
|
||||
46
backend/thirdparty/gopay/serializers.py
vendored
@@ -1,37 +1,21 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GoPayCreatePaymentRequestSerializer(serializers.Serializer):
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
||||
currency = serializers.CharField(required=False, default="CZK")
|
||||
order_number = serializers.CharField(required=False, allow_blank=True, default="order-001")
|
||||
order_description = serializers.CharField(required=False, allow_blank=True, default="Example GoPay payment")
|
||||
return_url = serializers.URLField(required=False)
|
||||
notify_url = serializers.URLField(required=False)
|
||||
preauthorize = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class GoPayPaymentCreatedResponseSerializer(serializers.Serializer):
|
||||
class GoPayRefundROSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
state = serializers.CharField()
|
||||
gw_url = serializers.URLField(required=False, allow_null=True)
|
||||
amount_cents = serializers.IntegerField(allow_null=True)
|
||||
reason = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
provider_refund_id = serializers.CharField(allow_null=True)
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class GoPayStatusResponseSerializer(serializers.Serializer):
|
||||
class GoPayPaymentROSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
state = serializers.CharField()
|
||||
|
||||
|
||||
class GoPayRefundRequestSerializer(serializers.Serializer):
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, min_value=0.01)
|
||||
|
||||
|
||||
class GoPayCaptureRequestSerializer(serializers.Serializer):
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, min_value=0.01)
|
||||
|
||||
|
||||
class GoPayCreateRecurrenceRequestSerializer(serializers.Serializer):
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
||||
currency = serializers.CharField(required=False, default="CZK")
|
||||
order_number = serializers.CharField(required=False, allow_blank=True, default="recur-001")
|
||||
order_description = serializers.CharField(required=False, allow_blank=True, default="Recurring payment")
|
||||
order = serializers.IntegerField(allow_null=True)
|
||||
amount_cents = serializers.IntegerField()
|
||||
currency = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
refunded_amount_cents = serializers.IntegerField()
|
||||
gw_url = serializers.URLField(allow_null=True)
|
||||
provider_payment_id = serializers.IntegerField(allow_null=True)
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
23
backend/thirdparty/gopay/urls.py
vendored
@@ -1,20 +1,11 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
GoPayPaymentView,
|
||||
GoPayPaymentStatusView,
|
||||
GoPayRefundPaymentView,
|
||||
GoPayCaptureAuthorizationView,
|
||||
GoPayVoidAuthorizationView,
|
||||
GoPayCreateRecurrenceView,
|
||||
GoPayPaymentInstrumentsView,
|
||||
)
|
||||
from .views import PaymentStatusView, PaymentRefundListView, GoPayWebhookView
|
||||
|
||||
urlpatterns = [
|
||||
path('payment/', GoPayPaymentView.as_view(), name='gopay-payment'),
|
||||
path('payment/<int:payment_id>/status/', GoPayPaymentStatusView.as_view(), name='gopay-payment-status'),
|
||||
path('payment/<int:payment_id>/refund/', GoPayRefundPaymentView.as_view(), name='gopay-refund-payment'),
|
||||
path('payment/<int:payment_id>/capture/', GoPayCaptureAuthorizationView.as_view(), name='gopay-capture-authorization'),
|
||||
path('payment/<int:payment_id>/void/', GoPayVoidAuthorizationView.as_view(), name='gopay-void-authorization'),
|
||||
path('payment/<int:payment_id>/recurrence/', GoPayCreateRecurrenceView.as_view(), name='gopay-create-recurrence'),
|
||||
path('payment-instruments/', GoPayPaymentInstrumentsView.as_view(), name='gopay-payment-instruments'),
|
||||
# Dotaz na stav platby (GET)
|
||||
path('api/payments/payment/<int:pk>', PaymentStatusView.as_view(), name='gopay-payment-status'),
|
||||
# Historie refundací (GET)
|
||||
path('api/payments/payment/<int:pk>/refunds', PaymentRefundListView.as_view(), name='gopay-payment-refunds'),
|
||||
# Webhook od GoPay (HTTP GET s ?id=...)
|
||||
path('api/payments/gopay/webhook', GoPayWebhookView.as_view(), name='gopay-webhook'),
|
||||
]
|
||||
368
backend/thirdparty/gopay/views.py
vendored
@@ -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.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework import status, permissions, serializers
|
||||
|
||||
import gopay
|
||||
from gopay.enums import TokenScope, Language
|
||||
import os
|
||||
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
||||
from .serializers import (
|
||||
GoPayCreatePaymentRequestSerializer,
|
||||
GoPayPaymentCreatedResponseSerializer,
|
||||
GoPayStatusResponseSerializer,
|
||||
GoPayRefundRequestSerializer,
|
||||
GoPayCaptureRequestSerializer,
|
||||
GoPayCreateRecurrenceRequestSerializer,
|
||||
)
|
||||
|
||||
from .models import GoPayPayment
|
||||
|
||||
|
||||
class GoPayClientMixin:
|
||||
"""Shared helpers for configuring GoPay client and formatting responses."""
|
||||
def get_gopay_client(self):
|
||||
gateway_url = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api")
|
||||
def _gopay_api():
|
||||
# SDK handles token internally; credentials from settings/env
|
||||
return gopay.payments({
|
||||
"goid": os.getenv("GOPAY_GOID"),
|
||||
"client_id": os.getenv("GOPAY_CLIENT_ID"),
|
||||
"client_secret": os.getenv("GOPAY_CLIENT_SECRET"),
|
||||
"gateway_url": gateway_url,
|
||||
"scope": TokenScope.ALL,
|
||||
"language": Language.CZECH,
|
||||
"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 _to_response(self, sdk_response):
|
||||
# The GoPay SDK returns a response object with has_succeed(), json, errors, status_code
|
||||
|
||||
def _as_dict(resp):
|
||||
if resp is None:
|
||||
return None
|
||||
if hasattr(resp, "json") and not callable(getattr(resp, "json")):
|
||||
return resp.json
|
||||
if hasattr(resp, "json") and callable(getattr(resp, "json")):
|
||||
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)
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(resp, dict):
|
||||
return resp
|
||||
try:
|
||||
return dict(resp)
|
||||
except Exception:
|
||||
return {"raw": str(resp)}
|
||||
|
||||
|
||||
class GoPayPaymentView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
summary="Create GoPay payment",
|
||||
description="Creates a GoPay payment and returns gateway URL and payment info.",
|
||||
request=GoPayCreatePaymentRequestSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(response=GoPayPaymentCreatedResponseSerializer, description="Payment created"),
|
||||
400: OpenApiResponse(description="Validation error or SDK error"),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Create payment",
|
||||
value={
|
||||
"amount": 123.45,
|
||||
"currency": "CZK",
|
||||
"order_number": "order-001",
|
||||
"order_description": "Example GoPay payment",
|
||||
"return_url": "https://yourfrontend.com/success",
|
||||
"notify_url": "https://yourbackend.com/gopay/notify",
|
||||
"preauthorize": False,
|
||||
},
|
||||
request_only=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
def post(self, request):
|
||||
amount = request.data.get("amount")
|
||||
currency = request.data.get("currency", "CZK")
|
||||
order_number = request.data.get("order_number", "order-001")
|
||||
order_description = request.data.get("order_description", "Example GoPay payment")
|
||||
return_url = request.data.get("return_url", "https://yourfrontend.com/success")
|
||||
notify_url = request.data.get("notify_url", "https://yourbackend.com/gopay/notify")
|
||||
preauthorize = bool(request.data.get("preauthorize", False))
|
||||
|
||||
if not amount:
|
||||
return Response({"error": "Amount is required"}, status=400)
|
||||
|
||||
payments = self.get_gopay_client()
|
||||
|
||||
payment_data = {
|
||||
"payer": {
|
||||
"allowed_payment_instruments": ["PAYMENT_CARD"],
|
||||
"default_payment_instrument": "PAYMENT_CARD",
|
||||
"allowed_swifts": ["FIOB"],
|
||||
"contact": {
|
||||
"first_name": getattr(request.user, "first_name", ""),
|
||||
"last_name": getattr(request.user, "last_name", ""),
|
||||
"email": getattr(request.user, "email", ""),
|
||||
},
|
||||
},
|
||||
"amount": int(float(amount) * 100), # GoPay expects amount in cents
|
||||
"currency": currency,
|
||||
"order_number": order_number,
|
||||
"order_description": order_description,
|
||||
"items": [
|
||||
{"name": "Example Item", "amount": int(float(amount) * 100)}
|
||||
],
|
||||
"callback": {"return_url": return_url, "notify_url": notify_url},
|
||||
"preauthorize": preauthorize,
|
||||
}
|
||||
|
||||
resp = payments.create_payment(payment_data)
|
||||
return self._to_response(resp)
|
||||
def _map_status(provider_state: Optional[str]) -> str:
|
||||
if not provider_state:
|
||||
return 'UNKNOWN'
|
||||
state = provider_state.upper()
|
||||
if state == 'PAID':
|
||||
return 'PAID'
|
||||
if state in ('AUTHORIZED',):
|
||||
return 'AUTHORIZED'
|
||||
if state in ('PAYMENT_METHOD_CHOSEN',):
|
||||
return 'PAYMENT_METHOD_CHOSEN'
|
||||
if state in ('CREATED', 'CREATED_WITH_PAYMENT', 'PENDING'):
|
||||
return 'CREATED'
|
||||
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 GoPayPaymentStatusView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
summary="Get GoPay payment status",
|
||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||
responses={200: OpenApiResponse(response=GoPayStatusResponseSerializer, description="Payment status")},
|
||||
)
|
||||
def get(self, request, payment_id: int):
|
||||
payments = self.get_gopay_client()
|
||||
resp = payments.get_status(payment_id)
|
||||
return self._to_response(resp)
|
||||
# --- Serializers kept here (small and read-only) ---
|
||||
class GoPayRefundROSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
amount_cents = serializers.IntegerField(allow_null=True)
|
||||
reason = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
provider_refund_id = serializers.CharField(allow_null=True)
|
||||
created_at = serializers.DateTimeField()
|
||||
|
||||
|
||||
class GoPayRefundPaymentView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
class GoPayPaymentROSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
order = serializers.IntegerField(source='order_id', allow_null=True)
|
||||
amount_cents = serializers.IntegerField()
|
||||
currency = serializers.CharField()
|
||||
status = serializers.CharField()
|
||||
refunded_amount_cents = serializers.IntegerField()
|
||||
gw_url = serializers.URLField(allow_null=True)
|
||||
provider_payment_id = serializers.IntegerField(allow_null=True)
|
||||
created_at = serializers.DateTimeField()
|
||||
updated_at = serializers.DateTimeField()
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
summary="Refund GoPay payment",
|
||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||
request=GoPayRefundRequestSerializer,
|
||||
responses={200: OpenApiResponse(description="Refund processed")},
|
||||
)
|
||||
def post(self, request, payment_id: int):
|
||||
amount = request.data.get("amount") # optional for full refund
|
||||
payments = self.get_gopay_client()
|
||||
if amount is None or amount == "":
|
||||
# Full refund
|
||||
resp = payments.refund_payment(payment_id)
|
||||
|
||||
class PaymentStatusView(APIView):
|
||||
"""
|
||||
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]
|
||||
|
||||
def get(self, request, pk: int):
|
||||
payment = get_object_or_404(GoPayPayment, pk=pk)
|
||||
|
||||
if payment.provider_payment_id:
|
||||
api = _gopay_api()
|
||||
resp = api.get_status(payment.provider_payment_id)
|
||||
if getattr(resp, "success", False):
|
||||
data = getattr(resp, "json", None)
|
||||
payment.status = _map_status(data.get('state'))
|
||||
payment.raw_last_status = data
|
||||
payment.save(update_fields=['status', 'raw_last_status', 'updated_at'])
|
||||
else:
|
||||
resp = payments.refund_payment(payment_id, int(float(amount) * 100))
|
||||
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 fetch status', 'error': err}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
serialized = GoPayPaymentROSerializer(payment)
|
||||
return Response(serialized.data)
|
||||
|
||||
|
||||
class GoPayCaptureAuthorizationView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
class PaymentRefundListView(APIView):
|
||||
"""
|
||||
GET /api/payments/payment/{id}/refunds
|
||||
- List local refund records for a payment (read-only).
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
summary="Capture GoPay authorization",
|
||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||
request=GoPayCaptureRequestSerializer,
|
||||
responses={200: OpenApiResponse(description="Capture processed")},
|
||||
def get(self, request, pk: int):
|
||||
payment = get_object_or_404(GoPayPayment, pk=pk)
|
||||
refunds = payment.refunds.all().values(
|
||||
'id', 'amount_cents', 'reason', 'provider_refund_id', 'created_at'
|
||||
)
|
||||
def post(self, request, payment_id: int):
|
||||
amount = request.data.get("amount") # optional for partial capture
|
||||
payments = self.get_gopay_client()
|
||||
if amount is None or amount == "":
|
||||
resp = payments.capture_authorization(payment_id)
|
||||
else:
|
||||
resp = payments.capture_authorization(payment_id, int(float(amount) * 100))
|
||||
return self._to_response(resp)
|
||||
ser = GoPayRefundROSerializer(refunds, many=True)
|
||||
return Response(ser.data)
|
||||
|
||||
|
||||
class GoPayVoidAuthorizationView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
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(
|
||||
tags=["GoPay"],
|
||||
summary="Void GoPay authorization",
|
||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||
responses={200: OpenApiResponse(description="Authorization voided")},
|
||||
)
|
||||
def post(self, request, payment_id: int):
|
||||
payments = self.get_gopay_client()
|
||||
resp = payments.void_authorization(payment_id)
|
||||
return self._to_response(resp)
|
||||
|
||||
|
||||
class GoPayCreateRecurrenceView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
summary="Create GoPay recurrence",
|
||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
||||
request=GoPayCreateRecurrenceRequestSerializer,
|
||||
responses={200: OpenApiResponse(description="Recurrence created")},
|
||||
)
|
||||
def post(self, request, payment_id: int):
|
||||
amount = request.data.get("amount")
|
||||
currency = request.data.get("currency", "CZK")
|
||||
order_number = request.data.get("order_number", "recur-001")
|
||||
order_description = request.data.get("order_description", "Recurring payment")
|
||||
if not amount:
|
||||
return Response({"error": "Amount is required"}, status=400)
|
||||
payments = self.get_gopay_client()
|
||||
recurrence_payload = {
|
||||
"amount": int(float(amount) * 100),
|
||||
"currency": currency,
|
||||
"order_number": order_number,
|
||||
"order_description": order_description,
|
||||
}
|
||||
resp = payments.create_recurrence(payment_id, recurrence_payload)
|
||||
return self._to_response(resp)
|
||||
|
||||
|
||||
class GoPayPaymentInstrumentsView(GoPayClientMixin, APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
summary="Get GoPay payment instruments",
|
||||
parameters=[OpenApiParameter(name="currency", required=False, type=str, location=OpenApiParameter.QUERY)],
|
||||
responses={200: OpenApiResponse(description="Available payment instruments returned")},
|
||||
)
|
||||
def get(self, request):
|
||||
currency = request.query_params.get("currency", "CZK")
|
||||
goid = os.getenv("GOPAY_GOID")
|
||||
if not goid:
|
||||
return Response({"error": "GOPAY_GOID is not configured"}, status=500)
|
||||
payments = self.get_gopay_client()
|
||||
resp = payments.get_payment_instruments(goid, currency)
|
||||
return self._to_response(resp)
|
||||
provider_id = request.GET.get("id") or request.GET.get("payment_id")
|
||||
if not provider_id:
|
||||
return Response({"detail": "Missing payment id"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
provider_id_int = int(provider_id)
|
||||
except Exception:
|
||||
provider_id_int = provider_id # fallback
|
||||
|
||||
api = _gopay_api()
|
||||
resp = api.get_status(provider_id_int)
|
||||
if not getattr(resp, "success", False):
|
||||
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.
|
||||
|
||||
22
backend/thirdparty/stripe/admin.py
vendored
@@ -1,3 +1,23 @@
|
||||
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",)
|
||||
|
||||
3
backend/thirdparty/stripe/apps.py
vendored
@@ -3,4 +3,5 @@ from django.apps import AppConfig
|
||||
|
||||
class StripeConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'stripe'
|
||||
name = 'thirdparty.stripe'
|
||||
label = "stripe"
|
||||
|
||||
26
backend/thirdparty/stripe/migrations/0001_initial.py
vendored
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
backend/thirdparty/stripe/models.py
vendored
@@ -1,3 +1,21 @@
|
||||
from django.db import models
|
||||
|
||||
# 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}"
|
||||
31
backend/thirdparty/stripe/serializers.py
vendored
@@ -1,12 +1,29 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Order
|
||||
|
||||
|
||||
class StripeCheckoutRequestSerializer(serializers.Serializer):
|
||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
||||
product_name = serializers.CharField(required=False, default="Example Product")
|
||||
success_url = serializers.URLField(required=False)
|
||||
cancel_url = serializers.URLField(required=False)
|
||||
class OrderSerializer(serializers.ModelSerializer):
|
||||
# Nested read-only representations
|
||||
# product = ProductSerializer(read_only=True)
|
||||
# carrier = CarrierSerializer(read_only=True)
|
||||
|
||||
# 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):
|
||||
url = serializers.URLField()
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = [
|
||||
"id",
|
||||
"amount",
|
||||
"currency",
|
||||
"status",
|
||||
"stripe_session_id",
|
||||
"stripe_payment_intent",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ("created_at",)
|
||||
|
||||
4
backend/thirdparty/stripe/urls.py
vendored
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
from .views import StripeCheckoutCZKView
|
||||
from .views import CreateCheckoutSessionView
|
||||
|
||||
urlpatterns = [
|
||||
path('checkout/', StripeCheckoutCZKView.as_view(), name='stripe-checkout-czk'),
|
||||
path("orders/create-checkout/", CreateCheckoutSessionView.as_view(), name="create-checkout"),
|
||||
]
|
||||
129
backend/thirdparty/stripe/views.py
vendored
@@ -1,71 +1,78 @@
|
||||
import stripe
|
||||
import os
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse
|
||||
|
||||
from rest_framework import generics
|
||||
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 .serializers import (
|
||||
StripeCheckoutRequestSerializer,
|
||||
StripeCheckoutResponseSerializer,
|
||||
)
|
||||
from .models import Order
|
||||
from .serializers import OrderSerializer
|
||||
import os
|
||||
|
||||
import stripe
|
||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||
|
||||
|
||||
class StripeCheckoutCZKView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
class CreateCheckoutSessionView(APIView):
|
||||
@extend_schema(
|
||||
tags=["Stripe"],
|
||||
summary="Create Stripe Checkout session in CZK",
|
||||
description="Creates a Stripe Checkout session for payment in Czech Koruna (CZK). Requires authentication.",
|
||||
request=StripeCheckoutRequestSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(response=StripeCheckoutResponseSerializer, description="Stripe Checkout session URL returned successfully."),
|
||||
400: OpenApiResponse(description="Amount is required or invalid."),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Success",
|
||||
value={"url": "https://checkout.stripe.com/pay/cs_test_123456"},
|
||||
response_only=True,
|
||||
status_codes=["200"],
|
||||
),
|
||||
OpenApiExample(
|
||||
"Missing amount",
|
||||
value={"error": "Amount is required"},
|
||||
response_only=True,
|
||||
status_codes=["400"],
|
||||
),
|
||||
]
|
||||
tags=["stripe"],
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = StripeCheckoutRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=400)
|
||||
serializer = OrderSerializer(data=request.data) #obecný serializer
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
amount = serializer.validated_data.get("amount")
|
||||
product_name = serializer.validated_data.get("product_name", "Example Product")
|
||||
success_url = serializer.validated_data.get("success_url", "https://yourfrontend.com/success")
|
||||
cancel_url = serializer.validated_data.get("cancel_url", "https://yourfrontend.com/cancel")
|
||||
# Stripe expects amount in the smallest currency unit (haléř = 1/100 CZK)
|
||||
amount_in_haler = int(amount * 100)
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=['card'],
|
||||
line_items=[{
|
||||
'price_data': {
|
||||
'currency': 'czk',
|
||||
'product_data': {
|
||||
'name': product_name,
|
||||
},
|
||||
'unit_amount': amount_in_haler,
|
||||
},
|
||||
'quantity': 1,
|
||||
}],
|
||||
mode='payment',
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
customer_email=getattr(request.user, 'email', None)
|
||||
order = Order.objects.create(
|
||||
amount=serializer.validated_data["amount"],
|
||||
currency=serializer.validated_data.get("currency", "czk"),
|
||||
)
|
||||
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)
|
||||
|
||||
3
backend/thirdparty/trading212/apps.py
vendored
@@ -3,4 +3,5 @@ from django.apps import AppConfig
|
||||
|
||||
class Trading212Config(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'trading212'
|
||||
name = 'thirdparty.trading212'
|
||||
label = "trading212"
|
||||
|
||||
4
backend/thirdparty/trading212/urls.py
vendored
@@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
from .views import YourTrading212View # Replace with actual view class
|
||||
from .views import Trading212AccountCashView # Replace with actual view class
|
||||
|
||||
urlpatterns = [
|
||||
path('your-endpoint/', YourTrading212View.as_view(), name='trading212-endpoint'),
|
||||
path('your-endpoint/', Trading212AccountCashView.as_view(), name='trading212-endpoint'),
|
||||
]
|
||||
3
backend/thirdparty/trading212/views.py
vendored
@@ -1,7 +1,6 @@
|
||||
# thirdparty/trading212/views.py
|
||||
import os
|
||||
import requests
|
||||
from decouple import config
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -13,11 +12,13 @@ class Trading212AccountCashView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["trading212"],
|
||||
summary="Get Trading212 account cash",
|
||||
responses=Trading212AccountCashSerializer
|
||||
)
|
||||
def get(self, request):
|
||||
api_key = os.getenv("API_KEY_TRADING212")
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
|
||||
@@ -17,9 +17,9 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings')
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
#myapp.routing.websocket_urlpatterns
|
||||
)
|
||||
),
|
||||
# "websocket": AuthMiddlewareStack(
|
||||
# URLRouter(
|
||||
# #myapp.routing.websocket_urlpatterns
|
||||
# )
|
||||
# ),
|
||||
})
|
||||
|
||||
@@ -17,18 +17,23 @@ from django.core.management.utils import get_random_secret_key
|
||||
from django.db import OperationalError, connections
|
||||
|
||||
from datetime import timedelta
|
||||
import json
|
||||
|
||||
from dotenv import load_dotenv
|
||||
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--------------
|
||||
# v jiné app si to importneš skrz: from django.conf import settings
|
||||
# a použiješ takto: settings.FRONTEND_URL
|
||||
|
||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
||||
FRONTEND_URL_DEV = os.getenv("FRONTEND_URL_DEV", "http://localhost:5173")
|
||||
print(f"FRONTEND_URL: {FRONTEND_URL}\nFRONTEND_URL_DEV: {FRONTEND_URL_DEV}\n")
|
||||
|
||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:9000")
|
||||
print(f"FRONTEND_URL: {FRONTEND_URL}\n")
|
||||
#-------------------------BASE ⚙️------------------------
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
@@ -153,43 +158,81 @@ AUTHENTICATION_BACKENDS = [
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(FRONTEND_URL)
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
'https://domena.cz',
|
||||
"https://www.domena.cz",
|
||||
"http://localhost:3000", #react docker
|
||||
"http://localhost:5173" #react dev
|
||||
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
|
||||
|
||||
"http://192.168.67.98",
|
||||
"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:
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
|
||||
|
||||
"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:
|
||||
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_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("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
|
||||
print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
|
||||
|
||||
|
||||
|
||||
#--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
|
||||
|
||||
|
||||
#--------------------------------------SSL 🧾------------------------------------
|
||||
|
||||
if os.getenv("SSL", "") == "True":
|
||||
USE_SSL = True
|
||||
else:
|
||||
USE_SSL = False
|
||||
|
||||
|
||||
if USE_SSL is True:
|
||||
print("SSL turned on!")
|
||||
SESSION_COOKIE_SECURE = True
|
||||
@@ -197,7 +240,6 @@ if USE_SSL is True:
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
else:
|
||||
SESSION_COOKIE_SECURE = False
|
||||
@@ -205,10 +247,7 @@ else:
|
||||
SECURE_SSL_REDIRECT = False
|
||||
SECURE_BROWSER_XSS_FILTER = False
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = False
|
||||
USE_X_FORWARDED_HOST = False
|
||||
|
||||
print(f"\nUsing SSL: {USE_SSL}\n")
|
||||
|
||||
#--------------------------------END-SSL 🧾---------------------------------
|
||||
|
||||
|
||||
@@ -218,8 +257,8 @@ print(f"\nUsing SSL: {USE_SSL}\n")
|
||||
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------
|
||||
|
||||
# ⬇️ Základní lifetime konfigurace
|
||||
ACCESS_TOKEN_LIFETIME = timedelta(minutes=15)
|
||||
REFRESH_TOKEN_LIFETIME = timedelta(days=1)
|
||||
ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
|
||||
REFRESH_TOKEN_LIFETIME = timedelta(days=5)
|
||||
|
||||
# ⬇️ Nastavení SIMPLE_JWT podle režimu
|
||||
if DEBUG:
|
||||
@@ -228,13 +267,16 @@ if DEBUG:
|
||||
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
||||
|
||||
"AUTH_COOKIE": "access_token",
|
||||
"AUTH_COOKIE_SECURE": False, # není HTTPS
|
||||
"AUTH_COOKIE_REFRESH": "refresh_token",
|
||||
|
||||
"AUTH_COOKIE_DOMAIN": None,
|
||||
"AUTH_COOKIE_SECURE": False,
|
||||
"AUTH_COOKIE_HTTP_ONLY": True,
|
||||
"AUTH_COOKIE_PATH": "/",
|
||||
"AUTH_COOKIE_SAMESITE": "Lax", # není cross-site
|
||||
"AUTH_COOKIE_SAMESITE": "Lax",
|
||||
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
"ROTATE_REFRESH_TOKENS": False,
|
||||
"BLACKLIST_AFTER_ROTATION": False,
|
||||
}
|
||||
else:
|
||||
SIMPLE_JWT = {
|
||||
@@ -242,30 +284,40 @@ else:
|
||||
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
||||
|
||||
"AUTH_COOKIE": "access_token",
|
||||
"AUTH_COOKIE_SECURE": True, # HTTPS only
|
||||
"AUTH_COOKIE_REFRESH": "refresh_token",
|
||||
"AUTH_COOKIE_DOMAIN": None,
|
||||
|
||||
# Secure/SameSite based on HTTPS availability
|
||||
"AUTH_COOKIE_SECURE": USE_SSL,
|
||||
"AUTH_COOKIE_HTTP_ONLY": True,
|
||||
"AUTH_COOKIE_PATH": "/",
|
||||
"AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin
|
||||
"AUTH_COOKIE_SAMESITE": "None" if USE_SSL else "Lax",
|
||||
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
"BLACKLIST_AFTER_ROTATION": True,
|
||||
}
|
||||
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'account.tokens.CookieJWTAuthentication',
|
||||
# In DEBUG keep Session + JWT + your cookie class for convenience
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'account.tokens.CookieJWTAuthentication',
|
||||
) if DEBUG else (
|
||||
'account.tokens.CookieJWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.AllowAny',
|
||||
),
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
|
||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
||||
}
|
||||
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'anon': '100/hour', # unauthenticated
|
||||
'user': '2000/hour', # authenticated
|
||||
}
|
||||
}
|
||||
#--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
|
||||
|
||||
|
||||
@@ -273,6 +325,14 @@ REST_FRAMEWORK = {
|
||||
#-------------------------------------APPS 📦------------------------------------
|
||||
MY_CREATED_APPS = [
|
||||
'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 = [
|
||||
@@ -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 = [
|
||||
# Middleware that allows your backend to accept requests from other domains (CORS)
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
|
||||
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
|
||||
#CUSTOM
|
||||
#'tools.middleware.CustomMaxUploadSizeMiddleware',
|
||||
|
||||
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files
|
||||
]
|
||||
|
||||
#--------------------------------END MIDDLEWARE 🧩---------------------------------
|
||||
@@ -400,56 +454,42 @@ else:
|
||||
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
|
||||
|
||||
#-------------------------------------CELERY 📅------------------------------------
|
||||
|
||||
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
|
||||
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:
|
||||
import redis
|
||||
# test connection
|
||||
r = redis.Redis(host='localhost', port=6379, db=0)
|
||||
r.ping()
|
||||
except Exception:
|
||||
CELERY_BROKER_URL = 'memory://'
|
||||
CELERY_ENABLED = False
|
||||
|
||||
CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT")
|
||||
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER")
|
||||
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE")
|
||||
def _env_list(key: str, default: list[str]) -> list[str]:
|
||||
v = os.getenv(key)
|
||||
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")
|
||||
# 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 📅------------------------------------
|
||||
|
||||
|
||||
@@ -461,66 +501,57 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
# říka že se úkladá do databáze, místo do cookie
|
||||
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:
|
||||
# DEVELOPMENT
|
||||
if USE_DOCKER_DB is False:
|
||||
# DEV
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3', # Database engine
|
||||
'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
else:
|
||||
#PRODUCTION
|
||||
# DOCKER/POSTGRES
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': os.getenv('DATABASE_ENGINE'),
|
||||
'NAME': os.getenv('DATABASE_NAME'),
|
||||
'USER': os.getenv('DATABASE_USER'),
|
||||
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
||||
'HOST': os.getenv('DATABASE_HOST', "localhost"),
|
||||
'NAME': os.getenv('POSTGRES_DB'),
|
||||
'USER': os.getenv('POSTGRES_USER'),
|
||||
'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
|
||||
'HOST': os.getenv('DATABASE_HOST'),
|
||||
'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
|
||||
|
||||
#--------------------------------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 📧--------------------------------------
|
||||
|
||||
if DEBUG:
|
||||
# DEVELOPMENT
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development
|
||||
# EMAILY SE BUDOU POSÍLAT DO KONZOLE!!!
|
||||
else:
|
||||
# PRODUCTION
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
|
||||
)
|
||||
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST_DEV")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465))
|
||||
EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL
|
||||
EMAIL_USE_SSL = False # ✅ Must be True for port 465
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV")
|
||||
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
|
||||
EMAIL_TIMEOUT = 10
|
||||
|
||||
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------------------------")
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", 465))
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") in ["True", "true", "1", True]
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "True") in ["True", "true", "1", True]
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_USER")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
||||
EMAIL_TIMEOUT = 30 # seconds
|
||||
|
||||
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 📧-------------------------------------
|
||||
|
||||
|
||||
@@ -569,9 +600,6 @@ else:
|
||||
print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
|
||||
|
||||
if USE_AWS is False:
|
||||
# DEVELOPMENT
|
||||
|
||||
|
||||
# Development: Use local file system storage for static files
|
||||
STORAGES = {
|
||||
"default": {
|
||||
@@ -583,14 +611,11 @@ if USE_AWS is False:
|
||||
}
|
||||
|
||||
# Media and Static URL for local dev
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_URL = os.getenv("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:
|
||||
# PRODUCTION
|
||||
|
||||
@@ -628,7 +653,6 @@ elif USE_AWS:
|
||||
|
||||
|
||||
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
|
||||
|
||||
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
|
||||
|
||||
|
||||
@@ -893,3 +917,18 @@ SPECTACULAR_DEFAULTS: Dict[str, Any] = {
|
||||
'OAUTH2_REFRESH_URL': 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--------------------------------
|
||||
|
||||
@@ -32,8 +32,11 @@ urlpatterns = [
|
||||
|
||||
path('admin/', admin.site.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/trading212/', include('thirdparty.trading212.urls')),
|
||||
path('api/downloader/', include('thirdparty.downloader.urls')),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="reset.css">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
977
frontend/package-lock.json
generated
@@ -10,10 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"axios": "^1.13.0",
|
||||
"react": "^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": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
|
||||
BIN
frontend/public/portfolio/davo1.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/public/portfolio/epinger.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/portfolio/perlica.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -1,10 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import './App.css'
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from "react-router-dom"
|
||||
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 (
|
||||
/* */
|
||||
<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
@@ -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"
|
||||
*/
|
||||
114
frontend/src/api/apps/Downloader.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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).
|
||||
@@ -16,7 +16,7 @@ export async function fetchEnumFromSchemaJson(
|
||||
schemaUrl: string = "/schema/?format=json"
|
||||
): Promise<Array<{ value: string; label: string }>> {
|
||||
try {
|
||||
const schema = await apiRequest("get", schemaUrl);
|
||||
const schema = await Client.public.get(schemaUrl);
|
||||
|
||||
const methodDef = schema.paths?.[path]?.[method];
|
||||
if (!methodDef) {
|
||||
|
||||
82
frontend/src/api/models/User.ts
Normal 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;
|
||||
19
frontend/src/api/websockets/WebSocketClient.ts
Normal 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);
|
||||
};
|
||||
@@ -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>
|
||||
40
frontend/src/components/Footer/footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
|
||||
.opening svg{
|
||||
margin: auto;
|
||||
font-size: 3em;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
|
||||
.contact-me .content {
|
||||
@@ -89,7 +94,7 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
.opening-behind { z-index: 0 !important; }
|
||||
|
||||
.contact-me .cover {
|
||||
position: absolute;
|
||||
|
||||
90
frontend/src/components/ads/Drone/Drone.tsx
Normal 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 3 000 Kč. Ostrava zdarma; mimo 10 Kč/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>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
|
||||
.drone .video-background {
|
||||
.drone .videoBackground {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -20,15 +20,26 @@
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #c2a67d;
|
||||
color: #5e5747;
|
||||
|
||||
border-radius: 1em;
|
||||
|
||||
transform-origin: bottom;
|
||||
transition: transform 0.5s ease-in-out;
|
||||
|
||||
transform: skew(-5deg);
|
||||
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 {
|
||||
0% { transform: translateX(0); }
|
||||
@@ -48,14 +59,14 @@
|
||||
}
|
||||
|
||||
.portfolio .door-open{
|
||||
transform: rotateX(180deg);
|
||||
transform: rotateX(90deg) skew(-2deg) !important;
|
||||
}
|
||||
|
||||
.portfolio>header {
|
||||
width: fit-content;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: -4.7em;
|
||||
z-index: 0;
|
||||
top: -3.7em;
|
||||
left: 0;
|
||||
padding: 1em 3em;
|
||||
padding-bottom: 0;
|
||||
@@ -112,6 +123,7 @@
|
||||
|
||||
|
||||
.portfolio div {
|
||||
width: 100%;
|
||||
padding: 3em;
|
||||
background-color: #cdc19c;
|
||||
display: flex;
|
||||
@@ -122,6 +134,8 @@
|
||||
|
||||
border-radius: 1em;
|
||||
border-top-left-radius: 0;
|
||||
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.portfolio div article {
|
||||
@@ -136,7 +150,6 @@
|
||||
}
|
||||
|
||||
.portfolio div article header a img {
|
||||
padding: 2em 0;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
}
|
||||
86
frontend/src/components/ads/Portfolio/Portfolio.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |