From 73da41b514664b58d0c592e563e475676ff96208 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Tue, 28 Oct 2025 03:21:01 +0100 Subject: [PATCH] commit --- .github/copilot-instructions.md | 60 ++ backend/account/migrations/0001_initial.py | 54 -- backend/account/models.py | 44 +- .../templates/emails/advertisment.html | 145 +++-- backend/account/views.py | 2 +- backend/commerce/admin.py | 15 +- backend/commerce/migrations/0001_initial.py | 41 ++ .../downloader}/__init__.py | 0 backend/thirdparty/downloader/admin.py | 9 + backend/thirdparty/downloader/apps.py | 10 + .../downloader/migrations/0001_initial.py | 54 ++ .../downloader/migrations/__init__.py | 0 backend/thirdparty/downloader/models.py | 92 +++ backend/thirdparty/downloader/serializers.py | 69 +++ backend/thirdparty/downloader/tests.py | 3 + backend/thirdparty/downloader/urls.py | 14 + backend/thirdparty/downloader/views.py | 539 ++++++++++++++++++ backend/thirdparty/stripe/apps.py | 3 +- .../stripe/migrations/0001_initial.py | 26 + backend/thirdparty/stripe/serializers.py | 71 +-- backend/thirdparty/stripe/views.py | 3 +- backend/thirdparty/trading212/apps.py | 3 +- backend/thirdparty/trading212/urls.py | 4 +- backend/thirdparty/trading212/views.py | 2 +- backend/vontor_cz/settings.py | 17 + frontend/package-lock.json | 286 +++++++++- frontend/package.json | 1 + frontend/src/App.tsx | 8 +- frontend/src/api/Client.ts | 268 +++++++++ frontend/src/api/apps/Downloader.ts | 57 ++ frontend/src/api/axios.ts | 202 ------- frontend/src/api/external.ts | 26 - frontend/src/api/get_chocies.ts | 4 +- .../ads/Drone/Drone.tsx | 0 .../ads/Drone/drone.module.css | 0 .../ads/Drone/readme.png | Bin .../ads/Portfolio/Portfolio.module.css | 0 .../ads/Portfolio/Portfolio.tsx | 0 .../ads/Portfolio/readme.png | Bin .../auth/LogOut.tsx} | 0 .../auth/LoginForm.tsx} | 0 frontend/src/layouts/HomeLayout.tsx | 6 +- frontend/src/pages/downloader/Downloader.tsx | 160 ++++++ frontend/src/routes/PrivateRoute.tsx | 22 + 44 files changed, 1868 insertions(+), 452 deletions(-) delete mode 100644 backend/account/migrations/0001_initial.py create mode 100644 backend/commerce/migrations/0001_initial.py rename backend/{account/migrations => thirdparty/downloader}/__init__.py (100%) create mode 100644 backend/thirdparty/downloader/admin.py create mode 100644 backend/thirdparty/downloader/apps.py create mode 100644 backend/thirdparty/downloader/migrations/0001_initial.py rename frontend/src/features/auth/LogOut.tsx => backend/thirdparty/downloader/migrations/__init__.py (100%) create mode 100644 backend/thirdparty/downloader/models.py create mode 100644 backend/thirdparty/downloader/serializers.py create mode 100644 backend/thirdparty/downloader/tests.py create mode 100644 backend/thirdparty/downloader/urls.py create mode 100644 backend/thirdparty/downloader/views.py create mode 100644 backend/thirdparty/stripe/migrations/0001_initial.py create mode 100644 frontend/src/api/Client.ts create mode 100644 frontend/src/api/apps/Downloader.ts delete mode 100644 frontend/src/api/axios.ts delete mode 100644 frontend/src/api/external.ts rename frontend/src/{features => components}/ads/Drone/Drone.tsx (100%) rename frontend/src/{features => components}/ads/Drone/drone.module.css (100%) rename frontend/src/{features => components}/ads/Drone/readme.png (100%) rename frontend/src/{features => components}/ads/Portfolio/Portfolio.module.css (100%) rename frontend/src/{features => components}/ads/Portfolio/Portfolio.tsx (100%) rename frontend/src/{features => components}/ads/Portfolio/readme.png (100%) rename frontend/src/{features/auth/LoginForm.tsx => components/auth/LogOut.tsx} (100%) rename frontend/src/{routes/AuthenticatedRoute.tsx => components/auth/LoginForm.tsx} (100%) create mode 100644 frontend/src/pages/downloader/Downloader.tsx create mode 100644 frontend/src/routes/PrivateRoute.tsx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8b32d12..175e089 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,6 +40,66 @@ This monorepo contains a Django backend and a Vite/React frontend, orchestrated - Use TypeScript strict mode (see `tsconfig.*.json`). - Linting: ESLint config in `eslint.config.js`. +### 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. diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py deleted file mode 100644 index 514492d..0000000 --- a/backend/account/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.2.5 on 2025-08-13 23:19 - -import account.models -import django.contrib.auth.validators -import django.core.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='CustomUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('is_deleted', models.BooleanField(default=False)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('user', 'Uživatel')], max_length=32, null=True)), - ('email_verified', models.BooleanField(default=False)), - ('phone_number', models.CharField(blank=True, max_length=16, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])), - ('email', models.EmailField(db_index=True, max_length=254, unique=True)), - ('create_time', models.DateTimeField(auto_now_add=True)), - ('city', models.CharField(blank=True, max_length=100, null=True)), - ('street', models.CharField(blank=True, max_length=200, null=True)), - ('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])), - ('gdpr', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=False)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')), - ], - options={ - 'abstract': False, - }, - managers=[ - ('objects', account.models.CustomUserActiveManager()), - ('all_objects', account.models.CustomUserAllManager()), - ], - ), - ] diff --git a/backend/account/models.py b/backend/account/models.py index 226f3a2..54e6928 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -34,37 +34,41 @@ 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" - """ACCOUNT_TYPES = ( - ('company', 'Firma'), - ('individual', 'Fyzická osoba') - ) - account_type = models.CharField(max_length=32, choices=ACCOUNT_TYPES, null=True, blank=True)""" + role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER) - 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) + + gdpr = models.BooleanField(default=False) + is_active = models.BooleanField(default=False) + create_time = models.DateTimeField(auto_now_add=True) + 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}$', @@ -73,11 +77,11 @@ class CustomUser(SoftDeleteModel, AbstractUser): ) ] ) - gdpr = models.BooleanField(default=False) - is_active = models.BooleanField(default=False) - - REQUIRED_FIELDS = ['email', "username", "password"] + USERNAME_FIELD = "username" + REQUIRED_FIELDS = [ + "email" + ] def __str__(self): @@ -91,6 +95,10 @@ class CustomUser(SoftDeleteModel, AbstractUser): def save(self, *args, **kwargs): if self.pk is None: # if newely created user + from django.contrib.auth.models import Group + group, _ = Group.objects.get_or_create(name=self.role) + self.groups.set([group]) + if self.is_superuser or self.role == "admin": self.is_active = True @@ -105,5 +113,5 @@ class CustomUser(SoftDeleteModel, AbstractUser): self.is_staff = False return super().save(*args, **kwargs) - + diff --git a/backend/account/templates/emails/advertisment.html b/backend/account/templates/emails/advertisment.html index 0ee4e75..c6d4c58 100644 --- a/backend/account/templates/emails/advertisment.html +++ b/backend/account/templates/emails/advertisment.html @@ -1,19 +1,17 @@ - +
- +
+ - - - + - - + - - - - - - -
+ Nabídka tvorby webových stránek
+

- Jsme malý tým, který se snaží prorazit a přinášet moderní řešení za férové - ceny. + Jsme malý tým, který se snaží prorazit a přinášet moderní řešení za férové ceny. Nabízíme také levný hosting a SSL zabezpečení zdarma.

@@ -23,115 +21,108 @@

+ Balíčky
+

BASIC

  • Jednoduchá prezentační webová stránka
  • Moderní a responzivní design (PC, tablety, mobily)
  • -
  • Maximalní počet stránek: 5
  • -
  • Použítí vlastní domény a SSL certifikát zdarma
  • +
  • Max. počet stránek: 5
  • +
  • Seřízení vlastní domény, a k tomuSSL certifikát zdarma
-

- Cena: 5 000 Kč - (jednorázově) + 100 Kč / měsíc

+

+ Cena: 5 000 Kč (jednorázově) + 100 Kč / měsíc +

+

STANDARD

  • Vše z balíčku BASIC
  • -
  • Kontaktní formulář, který posílá pobídky na váš email
  • -
  • Větší priorita při řešení problémů a rychlejší vývoj (cca 2 týdny)
  • +
  • Kontaktní formulář (přijde vám poptávka na e-mail)
  • +
  • Priorita při vývoji (cca 2 týdny)
  • Základní SEO
  • -
  • Maximální počet stránek: 10
  • +
  • Max. počet stránek: 10
-

- Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc

+

+ Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc +

+

PREMIUM

  • Vše z balíčku STANDARD
  • -
  • Registrace firmy do Google Business Profile
  • -
  • Pokročilé SEO (klíčová slova, podpora pro slepce, čtečky)
  • -
  • Měsíční report návštěvnosti webu
  • -
  • Možnost drobných úprav (texty, fotky)
  • +
  • Vaše firma na Google Maps díky plně nastavenému Google Business Profile
  • +
  • Pokročilé SEO
  • +
  • Měsíční report návštěvnosti
  • +
  • Možnost drobných úprav
  • Neomezený počet stránek
-

- Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc

+

+ Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc +

+

CUSTOM

    -
  • Kompletně na míru podle potřeb
  • -
  • Možnost e-shopu, rezervačního systému, managment
  • -
  • Integrace jakéhokoliv API
  • -
  • Integrace platební brány (např. Stripe, Platba QR kódem, atd.)
  • -
  • Pokročilé SEO
  • -
  • Marketing skrz Google Ads
  • +
  • Kompletně na míru
  • +
  • Možnost e-shopu a rezervačních systémů
  • +
  • Integrace API a platební brány
  • +
  • Pokročilé SEO a marketing
-

- Cena: dohodou

+

+ Cena: dohodou +

- - - - - - - - -
-

Máte zájem o některý z balíčků?

-
-

Stačí odpovědět na tento e-mail nebo mě kontaktovat telefonicky:

-

- - brunovontor@gmail.com - -

-

- +420 605 512 624 -

-

- vontor.cz -

-
\ No newline at end of file + + + + + +
+

Máte zájem o některý z balíčků?

+

Stačí odpovědět na tento e-mail nebo mě kontaktovat:

+

+ brunovontor@gmail.com
+ +420 605 512 624
+ vontor.cz +

+
+ + + diff --git a/backend/account/views.py b/backend/account/views.py index 9744196..ae9d49a 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -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"], diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py index d76afc4..8093833 100644 --- a/backend/commerce/admin.py +++ b/backend/commerce/admin.py @@ -1,17 +1,14 @@ from django.contrib import admin -from .models import Carrier, Order +from .models import Carrier, Product # Register your models here. @admin.register(Carrier) class CarrierAdmin(admin.ModelAdmin): - list_display = ("name", "price", "api_id") - search_fields = ("name", "api_id") + list_display = ("name", "base_price", "is_active") -@admin.register(Order) -class OrderAdmin(admin.ModelAdmin): - list_display = ("id", "product", "carrier", "quantity", "total_price", "status", "created_at") - list_filter = ("status", "created_at") - search_fields = ("stripe_session_id",) - readonly_fields = ("total_price", "status", "stripe_session_id", "created_at", "updated_at") +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ("name", "price", "currency", "stock", "is_active") + search_fields = ("name", "description") diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py new file mode 100644 index 0000000..28cc9c2 --- /dev/null +++ b/backend/commerce/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.7 on 2025-10-28 01:24 + +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')), + ], + ), + ] diff --git a/backend/account/migrations/__init__.py b/backend/thirdparty/downloader/__init__.py similarity index 100% rename from backend/account/migrations/__init__.py rename to backend/thirdparty/downloader/__init__.py diff --git a/backend/thirdparty/downloader/admin.py b/backend/thirdparty/downloader/admin.py new file mode 100644 index 0000000..1d24b47 --- /dev/null +++ b/backend/thirdparty/downloader/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import DownloaderModel + +@admin.register(DownloaderModel) +class DownloaderModelAdmin(admin.ModelAdmin): + list_display = ("id", "status", "ext", "requested_format", "vcodec", "acodec", "is_audio_only", "download_time") + list_filter = ("status", "ext", "vcodec", "acodec", "is_audio_only", "extractor") + search_fields = ("title", "video_id", "url") + readonly_fields = ("download_time",) diff --git a/backend/thirdparty/downloader/apps.py b/backend/thirdparty/downloader/apps.py new file mode 100644 index 0000000..25fcc01 --- /dev/null +++ b/backend/thirdparty/downloader/apps.py @@ -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" diff --git a/backend/thirdparty/downloader/migrations/0001_initial.py b/backend/thirdparty/downloader/migrations/0001_initial.py new file mode 100644 index 0000000..16f6b26 --- /dev/null +++ b/backend/thirdparty/downloader/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.7 on 2025-10-28 00:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DownloaderModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField()), + ('download_time', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(max_length=50)), + ('requested_format', models.CharField(blank=True, db_index=True, max_length=100, null=True)), + ('format_id', models.CharField(blank=True, db_index=True, max_length=50, null=True)), + ('ext', models.CharField(blank=True, db_index=True, max_length=20, null=True)), + ('vcodec', models.CharField(blank=True, db_index=True, max_length=50, null=True)), + ('acodec', models.CharField(blank=True, db_index=True, max_length=50, null=True)), + ('width', models.IntegerField(blank=True, null=True)), + ('height', models.IntegerField(blank=True, null=True)), + ('fps', models.FloatField(blank=True, null=True)), + ('abr', models.FloatField(blank=True, null=True)), + ('vbr', models.FloatField(blank=True, null=True)), + ('tbr', models.FloatField(blank=True, null=True)), + ('asr', models.IntegerField(blank=True, null=True)), + ('filesize', models.BigIntegerField(blank=True, null=True)), + ('duration', models.FloatField(blank=True, null=True)), + ('title', models.CharField(blank=True, max_length=512, null=True)), + ('extractor', models.CharField(blank=True, db_index=True, max_length=100, null=True)), + ('extractor_key', models.CharField(blank=True, max_length=100, null=True)), + ('video_id', models.CharField(blank=True, db_index=True, max_length=128, null=True)), + ('webpage_url', models.URLField(blank=True, null=True)), + ('is_audio_only', models.BooleanField(db_index=True, default=False)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('raw_info', models.JSONField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['download_time'], name='downloader__downloa_ef522e_idx'), models.Index(fields=['ext', 'is_audio_only'], name='downloader__ext_2aa7af_idx'), models.Index(fields=['requested_format'], name='downloader__request_f4048b_idx'), models.Index(fields=['extractor'], name='downloader__extract_b39777_idx')], + }, + ), + ] diff --git a/frontend/src/features/auth/LogOut.tsx b/backend/thirdparty/downloader/migrations/__init__.py similarity index 100% rename from frontend/src/features/auth/LogOut.tsx rename to backend/thirdparty/downloader/migrations/__init__.py diff --git a/backend/thirdparty/downloader/models.py b/backend/thirdparty/downloader/models.py new file mode 100644 index 0000000..b1052a9 --- /dev/null +++ b/backend/thirdparty/downloader/models.py @@ -0,0 +1,92 @@ +from django.db import models +from django.conf import settings + +# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu +class DownloaderModel(models.Model): + url = models.URLField() + download_time = models.DateTimeField(auto_now_add=True) + status = models.CharField(max_length=50) + + # yt-dlp metadata (flattened for stats) + requested_format = models.CharField(max_length=100, blank=True, null=True, db_index=True) + format_id = models.CharField(max_length=50, blank=True, null=True, db_index=True) + ext = models.CharField(max_length=20, blank=True, null=True, db_index=True) + vcodec = models.CharField(max_length=50, blank=True, null=True, db_index=True) + acodec = models.CharField(max_length=50, blank=True, null=True, db_index=True) + width = models.IntegerField(blank=True, null=True) + height = models.IntegerField(blank=True, null=True) + fps = models.FloatField(blank=True, null=True) + abr = models.FloatField(blank=True, null=True) # audio bitrate + vbr = models.FloatField(blank=True, null=True) # video bitrate + tbr = models.FloatField(blank=True, null=True) # total bitrate + asr = models.IntegerField(blank=True, null=True) # audio sample rate + filesize = models.BigIntegerField(blank=True, null=True) + duration = models.FloatField(blank=True, null=True) + title = models.CharField(max_length=512, blank=True, null=True) + extractor = models.CharField(max_length=100, blank=True, null=True, db_index=True) + extractor_key = models.CharField(max_length=100, blank=True, null=True) + video_id = models.CharField(max_length=128, blank=True, null=True, db_index=True) + webpage_url = models.URLField(blank=True, null=True) + is_audio_only = models.BooleanField(default=False, db_index=True) + + # client/context + user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), on_delete=models.SET_NULL, null=True, blank=True) + ip_address = models.GenericIPAddressField(blank=True, null=True) + user_agent = models.TextField(blank=True, null=True) + + # diagnostics + error_message = models.TextField(blank=True, null=True) + + # full raw yt-dlp info for future analysis + raw_info = models.JSONField(blank=True, null=True) + + class Meta: + indexes = [ + models.Index(fields=["download_time"]), + models.Index(fields=["ext", "is_audio_only"]), + models.Index(fields=["requested_format"]), + models.Index(fields=["extractor"]), + ] + + def __str__(self): + return f"DownloaderModel {self.id} - {self.status} at {self.download_time.strftime('%d-%m-%Y %H:%M:%S')}" + + @classmethod + def from_ydl_info(cls, *, info: dict, requested_format: str | None = None, status: str = "success", + url: str | None = None, user=None, ip_address: str | None = None, + user_agent: str | None = None, error_message: str | None = None): + # Safe getters + def g(k, default=None): + return info.get(k, default) + + instance = cls( + url=url or g("webpage_url") or g("original_url"), + status=status, + requested_format=requested_format, + format_id=g("format_id"), + ext=g("ext"), + vcodec=g("vcodec"), + acodec=g("acodec"), + width=g("width") if isinstance(g("width"), int) else None, + height=g("height") if isinstance(g("height"), int) else None, + fps=g("fps"), + abr=g("abr"), + vbr=g("vbr"), + tbr=g("tbr"), + asr=g("asr"), + filesize=g("filesize") or g("filesize_approx"), + duration=g("duration"), + title=g("title"), + extractor=g("extractor"), + extractor_key=g("extractor_key"), + video_id=g("id"), + webpage_url=g("webpage_url"), + is_audio_only=(g("vcodec") in (None, "none")), + user=user if getattr(user, "is_authenticated", False) else None, + ip_address=ip_address, + user_agent=user_agent, + error_message=error_message, + raw_info=info, + ) + instance.save() + return instance \ No newline at end of file diff --git a/backend/thirdparty/downloader/serializers.py b/backend/thirdparty/downloader/serializers.py new file mode 100644 index 0000000..e2ba20f --- /dev/null +++ b/backend/thirdparty/downloader/serializers.py @@ -0,0 +1,69 @@ +from rest_framework import serializers +from .models import DownloaderModel + +class DownloaderLogSerializer(serializers.ModelSerializer): + # Optional raw yt-dlp info dict + info = serializers.DictField(required=False) + + class Meta: + model = DownloaderModel + fields = ( + "id", + "url", + "status", + "requested_format", + "format_id", + "ext", + "vcodec", + "acodec", + "width", + "height", + "fps", + "abr", + "vbr", + "tbr", + "asr", + "filesize", + "duration", + "title", + "extractor", + "extractor_key", + "video_id", + "webpage_url", + "is_audio_only", + "error_message", + "raw_info", + "info", # virtual input + ) + read_only_fields = ("id", "raw_info") + + def create(self, validated_data): + info = validated_data.pop("info", None) + request = self.context.get("request") + user = getattr(request, "user", None) if request else None + ip_address = None + user_agent = None + if request: + xff = request.META.get("HTTP_X_FORWARDED_FOR") + ip_address = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR")) + user_agent = request.META.get("HTTP_USER_AGENT") + + if info: + return DownloaderModel.from_ydl_info( + info=info, + requested_format=validated_data.get("requested_format"), + status=validated_data.get("status", "success"), + url=validated_data.get("url"), + user=user, + ip_address=ip_address, + user_agent=user_agent, + error_message=validated_data.get("error_message"), + ) + # Fallback: create from flattened fields only + return DownloaderModel.objects.create( + user=user, + ip_address=ip_address, + user_agent=user_agent, + raw_info=None, + **validated_data + ) diff --git a/backend/thirdparty/downloader/tests.py b/backend/thirdparty/downloader/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/thirdparty/downloader/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/thirdparty/downloader/urls.py b/backend/thirdparty/downloader/urls.py new file mode 100644 index 0000000..542e675 --- /dev/null +++ b/backend/thirdparty/downloader/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from .views import DownloaderLogView, DownloaderStatsView +from .views import DownloaderFormatsView, DownloaderFileView + +urlpatterns = [ + # Probe formats for a URL (size-checked) + path("api/downloader/formats/", DownloaderFormatsView.as_view(), name="downloader-formats"), + # Download selected format (enforces size limit) + path("api/downloader/download/", DownloaderFileView.as_view(), name="downloader-download"), + # Aggregated statistics + path("api/downloader/stats/", DownloaderStatsView.as_view(), name="downloader-stats"), + # Legacy helper + path("api/downloader/logs/", DownloaderLogView.as_view(), name="downloader-log"), +] diff --git a/backend/thirdparty/downloader/views.py b/backend/thirdparty/downloader/views.py new file mode 100644 index 0000000..719d854 --- /dev/null +++ b/backend/thirdparty/downloader/views.py @@ -0,0 +1,539 @@ +from django.shortcuts import render +from django.db.models import Count +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from rest_framework import status +from django.conf import settings +from django.http import StreamingHttpResponse, JsonResponse +from django.utils.text import slugify +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator + +# docs + schema helpers +from rest_framework import serializers +from drf_spectacular.utils import ( + extend_schema, + OpenApiExample, + OpenApiParameter, + OpenApiTypes, + OpenApiResponse, + inline_serializer, +) + +import os +import math +import json +import tempfile +from typing import Any, Dict, List, Optional, Tuple + +from .models import DownloaderModel +from .serializers import DownloaderLogSerializer + +# ---------------------- Inline serializers for documentation only ---------------------- +# Using inline_serializer to avoid creating new files. + +FormatOptionSchema = inline_serializer( + name="FormatOption", + fields={ + "format_id": serializers.CharField(allow_null=True), + "ext": serializers.CharField(allow_null=True), + "vcodec": serializers.CharField(allow_null=True), + "acodec": serializers.CharField(allow_null=True), + "fps": serializers.FloatField(allow_null=True), + "tbr": serializers.FloatField(allow_null=True), + "abr": serializers.FloatField(allow_null=True), + "vbr": serializers.FloatField(allow_null=True), + "asr": serializers.IntegerField(allow_null=True), + "filesize": serializers.IntegerField(allow_null=True), + "filesize_approx": serializers.IntegerField(allow_null=True), + "estimated_size_bytes": serializers.IntegerField(allow_null=True), + "size_ok": serializers.BooleanField(), + "format_note": serializers.CharField(allow_null=True), + "resolution": serializers.CharField(allow_null=True), + "audio_only": serializers.BooleanField(), + }, +) + +FormatsRequestSchema = inline_serializer( + name="FormatsRequest", + fields={"url": serializers.URLField()}, +) + +FormatsResponseSchema = inline_serializer( + name="FormatsResponse", + fields={ + "title": serializers.CharField(allow_null=True), + "duration": serializers.FloatField(allow_null=True), + "extractor": serializers.CharField(allow_null=True), + "video_id": serializers.CharField(allow_null=True), + "max_size_bytes": serializers.IntegerField(), + "options": serializers.ListField(child=FormatOptionSchema), + }, +) + +DownloadRequestSchema = inline_serializer( + name="DownloadRequest", + fields={ + "url": serializers.URLField(), + "format_id": serializers.CharField(), + }, +) + +ErrorResponseSchema = inline_serializer( + name="ErrorResponse", + fields={ + "detail": serializers.CharField(), + "error": serializers.CharField(required=False), + "estimated_size_bytes": serializers.IntegerField(required=False), + "max_bytes": serializers.IntegerField(required=False), + }, +) +# --------------------------------------------------------------------------------------- + + +def _estimate_size_bytes(fmt: Dict[str, Any], duration: Optional[float]) -> Optional[int]: + """Estimate (or return exact) size in bytes for a yt-dlp format.""" + # Prefer exact sizes from yt-dlp + if fmt.get("filesize"): + return int(fmt["filesize"]) + if fmt.get("filesize_approx"): + return int(fmt["filesize_approx"]) + # Estimate via total bitrate (tbr is in Kbps) + if duration and fmt.get("tbr"): + try: + kbps = float(fmt["tbr"]) + return int((kbps * 1000 / 8) * float(duration)) + except Exception: + return None + return None + +def _format_option(fmt: Dict[str, Any], duration: Optional[float], max_bytes: int) -> Dict[str, Any]: + """Project yt-dlp format dict to a compact option object suitable for UI.""" + est = _estimate_size_bytes(fmt, duration) + w = fmt.get("width") + h = fmt.get("height") + resolution = f"{w}x{h}" if w and h else None + return { + "format_id": fmt.get("format_id"), + "ext": fmt.get("ext"), + "vcodec": fmt.get("vcodec"), + "acodec": fmt.get("acodec"), + "fps": fmt.get("fps"), + "tbr": fmt.get("tbr"), + "abr": fmt.get("abr"), + "vbr": fmt.get("vbr"), + "asr": fmt.get("asr"), + "filesize": fmt.get("filesize"), + "filesize_approx": fmt.get("filesize_approx"), + "estimated_size_bytes": est, + "size_ok": (est is not None and est <= max_bytes), + "format_note": fmt.get("format_note"), + "resolution": resolution, + "audio_only": (fmt.get("vcodec") in (None, "none")), + } + +def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]: + """Extract current user, client IP and User-Agent.""" + xff = request.META.get("HTTP_X_FORWARDED_FOR") + ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR")) + ua = request.META.get("HTTP_USER_AGENT") + user = getattr(request, "user", None) + return user, ip, ua + +class DownloaderFormatsView(APIView): + """Probe media URL and return available formats with estimated sizes and limit flags.""" + permission_classes = [AllowAny] + + @extend_schema( + tags=["downloader"], + operation_id="downloader_formats", + summary="List available formats for a media URL", + description="Uses yt-dlp to extract formats and estimates size. Applies max size policy.", + request=FormatsRequestSchema, + responses={ + 200: FormatsResponseSchema, + 400: OpenApiResponse(response=ErrorResponseSchema), + 500: OpenApiResponse(response=ErrorResponseSchema), + }, + examples=[ + OpenApiExample( + "Formats request", + value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, + request_only=True, + ), + OpenApiExample( + "Formats response (excerpt)", + value={ + "title": "Example Title", + "duration": 213.0, + "extractor": "youtube", + "video_id": "dQw4w9WgXcQ", + "max_size_bytes": 209715200, + "options": [ + { + "format_id": "140", + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "fps": None, + "tbr": 128.0, + "abr": 128.0, + "vbr": None, + "asr": 44100, + "filesize": None, + "filesize_approx": 3342334, + "estimated_size_bytes": 3400000, + "size_ok": True, + "format_note": "tiny", + "resolution": None, + "audio_only": True, + } + ], + }, + response_only=True, + ), + ], + ) + def post(self, request): + """POST to probe a media URL and list available formats.""" + try: + import yt_dlp + except Exception: + return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500) + + url = request.data.get("url") + if not url: + return Response({"detail": "Missing 'url'."}, status=400) + + max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024) + ydl_opts = { + "skip_download": True, + "quiet": True, + "no_warnings": True, + "noprogress": True, + "ignoreerrors": True, + "socket_timeout": getattr(settings, "DOWNLOADER_TIMEOUT", 120), + "extract_flat": False, + "allow_playlist": False, + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + except Exception as e: + # log probe error + user, ip, ua = _client_meta(request) + DownloaderModel.from_ydl_info( + info={"webpage_url": url}, + requested_format=None, + status="probe_error", + url=url, + user=user, + ip_address=ip, + user_agent=ua, + error_message=str(e), + ) + return Response({"detail": "Failed to extract formats", "error": str(e)}, status=400) + + duration = info.get("duration") + formats = info.get("formats") or [] + options: List[Dict[str, Any]] = [] + for f in formats: + options.append(_format_option(f, duration, max_bytes)) + + # optional: sort by size then by resolution desc + def sort_key(o): + size = o["estimated_size_bytes"] if o["estimated_size_bytes"] is not None else math.inf + res = 0 + if o["resolution"]: + try: + w, h = o["resolution"].split("x") + res = int(w) * int(h) + except Exception: + res = 0 + return (size, -res) + + options_sorted = sorted(options, key=sort_key)[:50] + + # Log probe + user, ip, ua = _client_meta(request) + DownloaderModel.from_ydl_info( + info=info, + requested_format=None, + status="probe_ok", + url=url, + user=user, + ip_address=ip, + user_agent=ua, + ) + + return Response({ + "title": info.get("title"), + "duration": duration, + "extractor": info.get("extractor"), + "video_id": info.get("id"), + "max_size_bytes": max_bytes, + "options": options_sorted, + }) + +class DownloaderFileView(APIView): + """Download selected format if under max size, then stream the file back.""" + permission_classes = [AllowAny] + + @extend_schema( + tags=["downloader"], + operation_id="downloader_download", + summary="Download a selected format and stream file", + description="Downloads with a strict max filesize guard and streams as application/octet-stream.", + request=DownloadRequestSchema, + responses={ + 200: OpenApiResponse(response=OpenApiTypes.BINARY, media_type="application/octet-stream"), + 400: OpenApiResponse(response=ErrorResponseSchema), + 413: OpenApiResponse(response=ErrorResponseSchema), + 500: OpenApiResponse(response=ErrorResponseSchema), + }, + examples=[ + OpenApiExample( + "Download request", + value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "format_id": "140"}, + request_only=True, + ), + ], + ) + def post(self, request): + """POST to download a media URL in the selected format.""" + try: + import yt_dlp + except Exception: + return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500) + + url = request.data.get("url") + fmt_id = request.data.get("format_id") + if not url or not fmt_id: + return Response({"detail": "Missing 'url' or 'format_id'."}, status=400) + + max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024) + timeout = getattr(settings, "DOWNLOADER_TIMEOUT", 120) + tmp_dir = getattr(settings, "DOWNLOADER_TMP_DIR", os.path.join(settings.BASE_DIR, "tmp", "downloader")) + os.makedirs(tmp_dir, exist_ok=True) + + # First, extract info to check/estimate size + probe_opts = { + "skip_download": True, + "quiet": True, + "no_warnings": True, + "noprogress": True, + "ignoreerrors": True, + "socket_timeout": timeout, + "extract_flat": False, + "allow_playlist": False, + } + try: + with yt_dlp.YoutubeDL(probe_opts) as ydl: + info = ydl.extract_info(url, download=False) + except Exception as e: + user, ip, ua = _client_meta(request) + DownloaderModel.from_ydl_info( + info={"webpage_url": url}, + requested_format=fmt_id, + status="precheck_error", + url=url, + user=user, + ip_address=ip, + user_agent=ua, + error_message=str(e), + ) + return Response({"detail": "Failed to analyze media", "error": str(e)}, status=400) + + duration = info.get("duration") + selected = None + for f in (info.get("formats") or []): + if str(f.get("format_id")) == str(fmt_id): + selected = f + break + if not selected: + return Response({"detail": f"format_id '{fmt_id}' not found"}, status=400) + # Enforce size policy + est_size = _estimate_size_bytes(selected, duration) + if est_size is not None and est_size > max_bytes: + user, ip, ua = _client_meta(request) + DownloaderModel.from_ydl_info( + info=selected, + requested_format=fmt_id, + status="blocked_by_size", + url=url, + user=user, + ip_address=ip, + user_agent=ua, + error_message=f"Estimated size {est_size} > max {max_bytes}", + ) + return Response( + {"detail": "File too large for this server", "estimated_size_bytes": est_size, "max_bytes": max_bytes}, + status=413, + ) + + # Now download with strict max_filesize guard + ydl_opts = { + "format": str(fmt_id), + "quiet": True, + "no_warnings": True, + "noprogress": True, + "socket_timeout": timeout, + "retries": 3, + "outtmpl": os.path.join(tmp_dir, "%(id)s.%(ext)s"), + "max_filesize": max_bytes, # hard cap during download + "concurrent_fragment_downloads": 1, + "http_chunk_size": 1024 * 1024, # 1MB chunks to reduce memory + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + # Will raise if max_filesize exceeded during transfer + result = ydl.extract_info(url, download=True) + # yt-dlp returns result for entries or single; get final info + if "requested_downloads" in result and result["requested_downloads"]: + rd = result["requested_downloads"][0] + filepath = rd.get("filepath") or rd.get("__final_filename") + else: + # fallback + filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename") + except Exception as e: + user, ip, ua = _client_meta(request) + DownloaderModel.from_ydl_info( + info=selected, + requested_format=fmt_id, + status="download_error", + url=url, + user=user, + ip_address=ip, + user_agent=ua, + error_message=str(e), + ) + return Response({"detail": "Download failed", "error": str(e)}, status=400) + + if not filepath or not os.path.exists(filepath): + return Response({"detail": "Downloaded file not found"}, status=500) + + # Build a safe filename + base_title = info.get("title") or "video" + ext = os.path.splitext(filepath)[1].lstrip(".") or (selected.get("ext") or "bin") + safe_name = f"{slugify(base_title)[:80]}.{ext}" + + # Log success + user, ip, ua = _client_meta(request) + try: + selected_info = dict(selected) + selected_info["filesize"] = os.path.getsize(filepath) + DownloaderModel.from_ydl_info( + info=selected_info, + requested_format=fmt_id, + status="success", + url=url, + user=user, + ip_address=ip, + user_agent=ua, + ) + except Exception: + pass + + # Stream file and remove after sending + def file_generator(path: str): + with open(path, "rb") as f: + while True: + chunk = f.read(8192) + if not chunk: + break + yield chunk + try: + os.remove(path) + except Exception: + pass + + resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream") + resp["Content-Disposition"] = f'attachment; filename="{safe_name}"' + try: + resp["Content-Length"] = str(os.path.getsize(filepath)) + except Exception: + pass + resp["X-Content-Type-Options"] = "nosniff" + return resp + + +# Simple stats view (aggregations for UI charts) +class DownloaderStatsView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + tags=["downloader"], + operation_id="downloader_stats", + summary="Aggregated downloader statistics", + description="Returns top extensions, requested formats, codecs and audio/video split.", + responses={ + 200: inline_serializer( + name="DownloaderStats", + fields={ + "top_ext": serializers.ListField( + child=inline_serializer(name="ExtCount", fields={ + "ext": serializers.CharField(allow_null=True), + "count": serializers.IntegerField(), + }) + ), + "top_requested_format": serializers.ListField( + child=inline_serializer(name="RequestedFormatCount", fields={ + "requested_format": serializers.CharField(allow_null=True), + "count": serializers.IntegerField(), + }) + ), + "top_vcodec": serializers.ListField( + child=inline_serializer(name="VCodecCount", fields={ + "vcodec": serializers.CharField(allow_null=True), + "count": serializers.IntegerField(), + }) + ), + "top_acodec": serializers.ListField( + child=inline_serializer(name="ACodecCount", fields={ + "acodec": serializers.CharField(allow_null=True), + "count": serializers.IntegerField(), + }) + ), + "audio_vs_video": serializers.ListField( + child=inline_serializer(name="AudioVsVideo", fields={ + "is_audio_only": serializers.BooleanField(), + "count": serializers.IntegerField(), + }) + ), + }, + ) + }, + ) + def get(self, request): + """GET to retrieve aggregated downloader statistics.""" + top_ext = list(DownloaderModel.objects.values("ext").annotate(count=Count("id")).order_by("-count")[:10]) + top_formats = list(DownloaderModel.objects.values("requested_format").annotate(count=Count("id")).order_by("-count")[:10]) + top_vcodec = list(DownloaderModel.objects.values("vcodec").annotate(count=Count("id")).order_by("-count")[:10]) + top_acodec = list(DownloaderModel.objects.values("acodec").annotate(count=Count("id")).order_by("-count")[:10]) + audio_vs_video = list(DownloaderModel.objects.values("is_audio_only").annotate(count=Count("id")).order_by("-count")) + return Response({ + "top_ext": top_ext, + "top_requested_format": top_formats, + "top_vcodec": top_vcodec, + "top_acodec": top_acodec, + "audio_vs_video": audio_vs_video, + }) + + +# Minimal placeholder so existing URL doesn't break; prefer using automatic logs above. +class DownloaderLogView(APIView): + permission_classes = [AllowAny] + + @extend_schema( + tags=["downloader"], + operation_id="downloader_log_helper", + summary="Deprecated helper", + description="Use /api/downloader/formats/ then /api/downloader/download/.", + responses={200: inline_serializer(name="LogHelper", fields={"detail": serializers.CharField()})}, + ) + def post(self, request): + """POST to the deprecated log helper endpoint.""" + return Response({"detail": "Use /api/downloader/formats/ then /api/downloader/download/."}, status=200) diff --git a/backend/thirdparty/stripe/apps.py b/backend/thirdparty/stripe/apps.py index 793395d..43baef9 100644 --- a/backend/thirdparty/stripe/apps.py +++ b/backend/thirdparty/stripe/apps.py @@ -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" diff --git a/backend/thirdparty/stripe/migrations/0001_initial.py b/backend/thirdparty/stripe/migrations/0001_initial.py new file mode 100644 index 0000000..b8d70c0 --- /dev/null +++ b/backend/thirdparty/stripe/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.7 on 2025-10-28 00:13 + +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)), + ], + ), + ] diff --git a/backend/thirdparty/stripe/serializers.py b/backend/thirdparty/stripe/serializers.py index f88ce06..b14fd0b 100644 --- a/backend/thirdparty/stripe/serializers.py +++ b/backend/thirdparty/stripe/serializers.py @@ -1,54 +1,29 @@ from rest_framework import serializers - -from rest_framework import serializers -from .models import Product, Carrier, Order - -from ...commerce.serializers import ProductSerializer, CarrierSerializer +from .models import Order class OrderSerializer(serializers.ModelSerializer): - product = ProductSerializer(read_only=True) - product_id = serializers.PrimaryKeyRelatedField( - queryset=Product.objects.all(), source="product", write_only=True - ) - carrier = CarrierSerializer(read_only=True) - carrier_id = serializers.PrimaryKeyRelatedField( - queryset=Carrier.objects.all(), source="carrier", write_only=True - ) + # Nested read-only representations + # product = ProductSerializer(read_only=True) + # carrier = CarrierSerializer(read_only=True) - class Meta: - model = Order - fields = [ - "id", - "product", "product_id", - "carrier", "carrier_id", - "quantity", - "total_price", - "status", - "stripe_session_id", - "created_at", - "updated_at", - ] - read_only_fields = ("total_price", "status", "stripe_session_id", "created_at", "updated_at") + # 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 + # ) - queryset=Product.objects.all(), source="product", write_only=True - - carrier = CarrierSerializer(read_only=True) - carrier_id = serializers.PrimaryKeyRelatedField( - queryset=Carrier.objects.all(), source="carrier", write_only=True - ) - - class Meta: - model = Order - fields = [ - "id", - "product", "product_id", - "carrier", "carrier_id", - "quantity", - "total_price", - "status", - "stripe_session_id", - "created_at", - "updated_at", - ] - read_only_fields = ("total_price", "status", "stripe_session_id", "created_at", "updated_at") + class Meta: + model = Order + fields = [ + "id", + "amount", + "currency", + "status", + "stripe_session_id", + "stripe_payment_intent", + "created_at", + ] + read_only_fields = ("created_at",) diff --git a/backend/thirdparty/stripe/views.py b/backend/thirdparty/stripe/views.py index 278bf0c..f51939b 100644 --- a/backend/thirdparty/stripe/views.py +++ b/backend/thirdparty/stripe/views.py @@ -8,9 +8,10 @@ from rest_framework.views import APIView from .models import Order from .serializers import OrderSerializer +import os import stripe -stripe.api_key = settings.STRIPE_SECRET_KEY # uložený v .env +stripe.api_key = os.getenv("STRIPE_SECRET_KEY") class CreateCheckoutSessionView(APIView): def post(self, request): diff --git a/backend/thirdparty/trading212/apps.py b/backend/thirdparty/trading212/apps.py index 6e47900..833ba03 100644 --- a/backend/thirdparty/trading212/apps.py +++ b/backend/thirdparty/trading212/apps.py @@ -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" diff --git a/backend/thirdparty/trading212/urls.py b/backend/thirdparty/trading212/urls.py index 4475ca9..07a8468 100644 --- a/backend/thirdparty/trading212/urls.py +++ b/backend/thirdparty/trading212/urls.py @@ -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'), ] \ No newline at end of file diff --git a/backend/thirdparty/trading212/views.py b/backend/thirdparty/trading212/views.py index 67580be..2071929 100644 --- a/backend/thirdparty/trading212/views.py +++ b/backend/thirdparty/trading212/views.py @@ -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 @@ -18,6 +17,7 @@ class Trading212AccountCashView(APIView): ) def get(self, request): api_key = os.getenv("API_KEY_TRADING212") + headers = { "Authorization": f"Bearer {api_key}", "Accept": "application/json", diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 316c223..1e389bc 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -264,6 +264,11 @@ REST_FRAMEWORK = { '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 +278,11 @@ REST_FRAMEWORK = { #-------------------------------------APPS 📦------------------------------------ MY_CREATED_APPS = [ 'account', + 'commerce', + + 'thirdparty.downloader', + 'thirdparty.stripe', # register Stripe app so its models are recognized + 'thirdparty.trading212', ] INSTALLED_APPS = [ @@ -893,3 +903,10 @@ SPECTACULAR_DEFAULTS: Dict[str, Any] = { 'OAUTH2_REFRESH_URL': None, 'OAUTH2_SCOPES': None, } + +# -------------------------------------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-------------------------------- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e2e50a5..8e9b1a1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@types/react-router": "^5.1.20", + "axios": "^1.13.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", @@ -1807,6 +1808,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz", + "integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1871,6 +1889,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1939,6 +1970,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2008,6 +2051,29 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.200", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", @@ -2015,6 +2081,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -2383,6 +2494,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2398,6 +2545,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2408,6 +2564,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2434,6 +2627,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2451,6 +2656,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2652,6 +2896,15 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2676,6 +2929,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2871,6 +3145,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3313,9 +3593,9 @@ } }, "node_modules/vite": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", - "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 8351fea..f9c435c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@types/react-router": "^5.1.20", + "axios": "^1.13.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-icons": "^5.5.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f8ddc0..4dbb4f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,19 @@ 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() { +export default function App() { return ( {/* Layout route */} }> } /> + } /> ) -} - -export default App \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/api/Client.ts b/frontend/src/api/Client.ts new file mode 100644 index 0000000..7fd36a2 --- /dev/null +++ b/frontend/src/api/Client.ts @@ -0,0 +1,268 @@ +import axios from "axios"; + +// --- ENV CONFIG --- +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +const REFRESH_URL = + import.meta.env.VITE_API_REFRESH_URL || "/api/token/refresh/"; +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): void; +} + +function notifyError(detail: ApiErrorDetail) { + window.dispatchEvent(new CustomEvent(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, // <-- always true + 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(); // optional: clear cookies client-side + 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); + } +); + +// --- TOKEN HELPERS (NO-OPS) --- +// Django sets/rotates cookies server-side. Keep API surface to avoid breaking imports. +function setTokens(_access?: string, _refresh?: string) { + // no-op: cookies are managed by Django +} +function clearTokens() { + // optional: try to clear auth cookies client-side; server should also clear on logout + try { + document.cookie = "access_token=; Max-Age=0; path=/"; + document.cookie = "refresh_token=; Max-Age=0; path=/"; + } catch { + // ignore + } +} +function getAccessToken(): string | null { + // no Authorization header is used; rely purely on cookies + return null; +} + +// --- EXPORT DEFAULT API WRAPPER --- +const Client = { + // Axios instances + auth: apiAuth, + public: apiPublic, + + // Token helpers (kept for compatibility; now no-ops) + setTokens, + clearTokens, + getAccessToken, + + // 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: +// +// } > +// }> +// } /> +// } /> +// +// +// } /> +// + + +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" +*/ diff --git a/frontend/src/api/apps/Downloader.ts b/frontend/src/api/apps/Downloader.ts new file mode 100644 index 0000000..ab72e11 --- /dev/null +++ b/frontend/src/api/apps/Downloader.ts @@ -0,0 +1,57 @@ +import Client from "../Client"; + +export type Choices = { + file_types: string[]; + qualities: string[]; +}; + +export type DownloadPayload = { + url: string; + file_type?: string; + quality?: string; +}; + +export type DownloadJobResponse = { + id: string; + status: "pending" | "running" | "finished" | "failed"; + detail?: string; + download_url?: string; + progress?: number; // 0-100 +}; + +// Fallback when choices endpoint is unavailable or models are hardcoded +const FALLBACK_CHOICES: Choices = { + file_types: ["auto", "video", "audio"], + qualities: ["best", "good", "worst"], +}; + +/** + * Fetch dropdown choices from backend (adjust path to match your Django views). + * Expected response shape: + * { file_types: string[], qualities: string[] } + */ +export async function getChoices(): Promise { + try { + const res = await Client.auth.get("/api/downloader/choices/"); + return res.data as Choices; + } catch { + return FALLBACK_CHOICES; + } +} + +/** + * Submit a new download job (adjust path/body to your viewset). + * Example payload: { url, file_type, quality } + */ +export async function submitDownload(payload: DownloadPayload): Promise { + const res = await Client.auth.post("/api/downloader/jobs/", payload); + return res.data as DownloadJobResponse; +} + +/** + * Get job status by ID. Returns progress, status, and download_url when finished. + */ +export async function getJobStatus(id: string): Promise { + const res = await Client.auth.get(`/api/downloader/jobs/${id}/`); + return res.data as DownloadJobResponse; +} diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts deleted file mode 100644 index f7cd6be..0000000 --- a/frontend/src/api/axios.ts +++ /dev/null @@ -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 => { - 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 => { - 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 - vrací data z odpovědi - */ -export const apiRequest = async ( - method: string, - endpoint: string, - data: Record = {}, - config: Record = {} -): Promise => { - 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 { - 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 { - try { - const user = await getCurrentUser(); - return user != null; - } catch (err) { - return false; // pokud padne 401, není přihlášen - } -} - - - -export { axios_instance, API_URL }; \ No newline at end of file diff --git a/frontend/src/api/external.ts b/frontend/src/api/external.ts deleted file mode 100644 index cc4face..0000000 --- a/frontend/src/api/external.ts +++ /dev/null @@ -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. - * - * @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> { - return axios({ - url, - method, - data, - ...config, - }); -} diff --git a/frontend/src/api/get_chocies.ts b/frontend/src/api/get_chocies.ts index 8e014e3..5a7dd69 100644 --- a/frontend/src/api/get_chocies.ts +++ b/frontend/src/api/get_chocies.ts @@ -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> { try { - const schema = await apiRequest("get", schemaUrl); + const schema = await Client.public.get(schemaUrl); const methodDef = schema.paths?.[path]?.[method]; if (!methodDef) { diff --git a/frontend/src/features/ads/Drone/Drone.tsx b/frontend/src/components/ads/Drone/Drone.tsx similarity index 100% rename from frontend/src/features/ads/Drone/Drone.tsx rename to frontend/src/components/ads/Drone/Drone.tsx diff --git a/frontend/src/features/ads/Drone/drone.module.css b/frontend/src/components/ads/Drone/drone.module.css similarity index 100% rename from frontend/src/features/ads/Drone/drone.module.css rename to frontend/src/components/ads/Drone/drone.module.css diff --git a/frontend/src/features/ads/Drone/readme.png b/frontend/src/components/ads/Drone/readme.png similarity index 100% rename from frontend/src/features/ads/Drone/readme.png rename to frontend/src/components/ads/Drone/readme.png diff --git a/frontend/src/features/ads/Portfolio/Portfolio.module.css b/frontend/src/components/ads/Portfolio/Portfolio.module.css similarity index 100% rename from frontend/src/features/ads/Portfolio/Portfolio.module.css rename to frontend/src/components/ads/Portfolio/Portfolio.module.css diff --git a/frontend/src/features/ads/Portfolio/Portfolio.tsx b/frontend/src/components/ads/Portfolio/Portfolio.tsx similarity index 100% rename from frontend/src/features/ads/Portfolio/Portfolio.tsx rename to frontend/src/components/ads/Portfolio/Portfolio.tsx diff --git a/frontend/src/features/ads/Portfolio/readme.png b/frontend/src/components/ads/Portfolio/readme.png similarity index 100% rename from frontend/src/features/ads/Portfolio/readme.png rename to frontend/src/components/ads/Portfolio/readme.png diff --git a/frontend/src/features/auth/LoginForm.tsx b/frontend/src/components/auth/LogOut.tsx similarity index 100% rename from frontend/src/features/auth/LoginForm.tsx rename to frontend/src/components/auth/LogOut.tsx diff --git a/frontend/src/routes/AuthenticatedRoute.tsx b/frontend/src/components/auth/LoginForm.tsx similarity index 100% rename from frontend/src/routes/AuthenticatedRoute.tsx rename to frontend/src/components/auth/LoginForm.tsx diff --git a/frontend/src/layouts/HomeLayout.tsx b/frontend/src/layouts/HomeLayout.tsx index 2a9836b..2d428ab 100644 --- a/frontend/src/layouts/HomeLayout.tsx +++ b/frontend/src/layouts/HomeLayout.tsx @@ -1,9 +1,10 @@ import Footer from "../components/Footer/footer"; import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm"; import HomeNav from "../components/navbar/HomeNav"; -import Drone from "../features/ads/Drone/Drone"; -import Portfolio from "../features/ads/Portfolio/Portfolio"; +import Drone from "../components/ads/Drone/Drone"; +import Portfolio from "../components/ads/Portfolio/Portfolio"; import Home from "../pages/home/home"; +import { Outlet } from "react-router"; export default function HomeLayout(){ return( @@ -12,6 +13,7 @@ export default function HomeLayout(){ {/*page*/} +
diff --git a/frontend/src/pages/downloader/Downloader.tsx b/frontend/src/pages/downloader/Downloader.tsx new file mode 100644 index 0000000..5d19c29 --- /dev/null +++ b/frontend/src/pages/downloader/Downloader.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useState } from "react"; +import { + getChoices, + submitDownload, + getJobStatus, + type Choices, + type DownloadJobResponse, +} from "../../api/apps/Downloader"; + +export default function Downloader() { + const [choices, setChoices] = useState({ file_types: [], qualities: [] }); + const [loadingChoices, setLoadingChoices] = useState(true); + + const [url, setUrl] = useState(""); + const [fileType, setFileType] = useState(""); + const [quality, setQuality] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [job, setJob] = useState(null); + const [error, setError] = useState(null); + + // Load dropdown choices once + useEffect(() => { + let mounted = true; + (async () => { + setLoadingChoices(true); + try { + const data = await getChoices(); + if (!mounted) return; + setChoices(data); + // preselect first option + if (!fileType && data.file_types.length > 0) setFileType(data.file_types[0]); + if (!quality && data.qualities.length > 0) setQuality(data.qualities[0]); + } catch (e: any) { + setError(e?.message || "Failed to load choices."); + } finally { + setLoadingChoices(false); + } + })(); + return () => { + mounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const canSubmit = useMemo(() => { + return !!url && !!fileType && !!quality && !submitting; + }, [url, fileType, quality, submitting]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const created = await submitDownload({ url, file_type: fileType, quality }); + setJob(created); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || "Submission failed."); + } finally { + setSubmitting(false); + } + } + + async function refreshStatus() { + if (!job?.id) return; + try { + const updated = await getJobStatus(job.id); + setJob(updated); + } catch (e: any) { + setError(e?.response?.data?.detail || e?.message || "Failed to refresh status."); + } + } + + return ( +
+

Downloader

+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+ + + +
+ +
+ +
+
+ + {job && ( +
+

Job

+
ID: {job.id}
+
Status: {job.status}
+ {typeof job.progress === "number" &&
Progress: {job.progress}%
} + {job.detail &&
Detail: {job.detail}
} + {job.download_url ? ( + + ) : ( + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/routes/PrivateRoute.tsx b/frontend/src/routes/PrivateRoute.tsx new file mode 100644 index 0000000..1b3a3c5 --- /dev/null +++ b/frontend/src/routes/PrivateRoute.tsx @@ -0,0 +1,22 @@ +import { Navigate, Outlet, useLocation } from "react-router-dom"; + +function getCookie(name: string): string | null { + const nameEQ = name + "="; + const ca = document.cookie.split(";").map((c) => c.trim()); + for (const c of ca) { + if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length)); + } + return null; +} + +const ACCESS_COOKIE = "access_token"; + +export default function PrivateRoute() { + const location = useLocation(); + const isLoggedIn = !!getCookie(ACCESS_COOKIE); + + if (!isLoggedIn) { + return ; + } + return ; +}