commit
This commit is contained in:
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<table style="background-color:#031D44; font-family:'Exo', Arial, sans-serif; width:100%;" align="center" border="0"
|
||||
cellspacing="0" cellpadding="0">
|
||||
<table style="background-color:#031D44; font-family:'Exo', Arial, sans-serif; width:100%;" align="center" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="padding:20px;">
|
||||
<table border="0" cellspacing="45" cellpadding="0" style="max-width:100%;" align="center">
|
||||
<table border="0" cellspacing="20" cellpadding="0" style="max-width:600px; width:100%;" align="center">
|
||||
<!-- Nadpis -->
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding:20px; color:#ffffff; font-size:30px; font-weight:bold; border-radius:8px; text-decoration:underline">
|
||||
<td align="center" style="padding:20px; color:#ffffff; font-size:30px; font-weight:bold; border-radius:8px; text-decoration:underline;">
|
||||
Nabídka tvorby webových stránek
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#CAF0F8; border-radius:8px; font-size:25px; line-height:1.6;">
|
||||
<td style="color:#CAF0F8; border-radius:8px; font-size:18px; line-height:1.6;">
|
||||
<p style="margin:0;">
|
||||
Jsme <strong>malý tým</strong>, který se snaží prorazit a přinášet moderní řešení za férové
|
||||
ceny.
|
||||
Jsme <strong>malý tým</strong>, který se snaží prorazit a přinášet moderní řešení za férové ceny.
|
||||
Nabízíme také <strong>levný hosting</strong> a <strong>SSL zabezpečení zdarma</strong>.
|
||||
</p>
|
||||
<p style="margin:10px 0 0;">
|
||||
@@ -23,115 +21,108 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Nadpis -->
|
||||
<!-- Balíčky Nadpis -->
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding-top:50px; color:#ffffff; font-size:35px; font-weight:bold; border-radius:8px; text-decoration:underline">
|
||||
<td align="center" style="padding-top:30px; color:#ffffff; font-size:28px; font-weight:bold; text-decoration:underline;">
|
||||
Balíčky
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- BASIC -->
|
||||
<!-- Balíčky (jednotlivé) -->
|
||||
<tr>
|
||||
<td
|
||||
style="padding:35px; background:#3a8bb7; color:#CAF0F8; border-radius:20px; margin:20px 0; line-height:1.6;font-size: 18px; width: 450px;">
|
||||
<td style="padding:20px; background:#3a8bb7; color:#CAF0F8; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
||||
<h2 style="margin:0; color:#CAF0F8;">BASIC</h2>
|
||||
<ul style="padding-left:20px; margin:10px 0;">
|
||||
<li>Jednoduchá prezentační webová stránka</li>
|
||||
<li>Moderní a responzivní design (PC, tablety, mobily)</li>
|
||||
<li>Maximalní počet stránek: 5</li>
|
||||
<li>Použítí vlastní domény a <span style="text-decoration: underline;">SSL certifikát zdarma</span></li>
|
||||
<li>Max. počet stránek: 5</li>
|
||||
<li>Seřízení vlastní domény, a k tomu<span style="text-decoration: underline;">SSL certifikát zdarma</span></li>
|
||||
</ul>
|
||||
<p
|
||||
style="font-size: 18px; border-radius:8px; width: fit-content;background-color:#24719f; padding:16px; color:#ffffff; font-weight:bold; margin:0;">
|
||||
Cena: 5 000 Kč
|
||||
(jednorázově) + 100 Kč / měsíc</p>
|
||||
<p style="font-size:16px; background-color:#24719f; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
||||
Cena: 5 000 Kč (jednorázově) + 100 Kč / měsíc
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- STANDARD -->
|
||||
<tr>
|
||||
<td
|
||||
style="padding:35px; background:#70A288; color:#ffffff; border-radius:20px; line-height:1.6; font-size:18px; width:450px;">
|
||||
<td style="padding:20px; background:#70A288; color:#ffffff; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
||||
<h2 style="margin:0; color:#ffffff;">STANDARD</h2>
|
||||
<ul style="padding-left:20px; margin:10px 0;">
|
||||
<li>Vše z balíčku BASIC</li>
|
||||
<li>Kontaktní formulář, který posílá pobídky na váš email</li>
|
||||
<li>Větší priorita při řešení problémů a rychlejší vývoj (cca 2 týdny)</li>
|
||||
<li>Kontaktní formulář (přijde vám poptávka na e-mail)</li>
|
||||
<li>Priorita při vývoji (cca 2 týdny)</li>
|
||||
<li>Základní SEO</li>
|
||||
<li>Maximální počet stránek: 10</li>
|
||||
<li>Max. počet stránek: 10</li>
|
||||
</ul>
|
||||
<p
|
||||
style="font-size:18px; border-radius:8px; width: fit-content; background-color:#508845; padding:16px; color:#ffffff; font-weight:bold; margin:0;">
|
||||
Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc</p>
|
||||
<p style="font-size:16px; background-color:#508845; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
||||
Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- PREMIUM -->
|
||||
<tr>
|
||||
<td
|
||||
style="padding:35px; background:#87a9da; color:#031D44; border-radius:20px; line-height:1.6; font-size:18px; width:450px;">
|
||||
<td style="padding:20px; background:#87a9da; color:#031D44; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
||||
<h2 style="margin:0; color:#031D44;">PREMIUM</h2>
|
||||
<ul style="padding-left:20px; margin:10px 0;">
|
||||
<li>Vše z balíčku STANDARD</li>
|
||||
<li>Registrace firmy do Google Business Profile</li>
|
||||
<li>Pokročilé SEO (klíčová slova, podpora pro slepce, čtečky)</li>
|
||||
<li>Měsíční report návštěvnosti webu</li>
|
||||
<li>Možnost drobných úprav (texty, fotky)</li>
|
||||
<li>Vaše firma na Google Maps díky plně nastavenému Google Business Profile</li>
|
||||
<li>Pokročilé SEO</li>
|
||||
<li>Měsíční report návštěvnosti</li>
|
||||
<li>Možnost drobných úprav</li>
|
||||
<li>Neomezený počet stránek</li>
|
||||
</ul>
|
||||
<p
|
||||
style="font-size:18px; border-radius:8px; width: fit-content; background-color:#4c7bbd; padding:16px; color:#ffffff; font-weight:bold; margin:0;">
|
||||
Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc</p>
|
||||
<p style="font-size:16px; background-color:#4c7bbd; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
||||
Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- CUSTOM -->
|
||||
<tr>
|
||||
<td
|
||||
style="padding:35px; background:#04395E; color:#CAF0F8; border-radius:20px; line-height:1.6; font-size:18px; width:450px;">
|
||||
<td style="padding:20px; background:#04395E; color:#CAF0F8; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
||||
<h2 style="margin:0; color:#CAF0F8;">CUSTOM</h2>
|
||||
<ul style="padding-left:20px; margin:10px 0;">
|
||||
<li>Kompletně na míru podle potřeb</li>
|
||||
<li>Možnost e-shopu, rezervačního systému, managment</li>
|
||||
<li>Integrace jakéhokoliv API</li>
|
||||
<li>Integrace platební brány (např. Stripe, Platba QR kódem, atd.)</li>
|
||||
<li>Pokročilé SEO</li>
|
||||
<li>Marketing skrz Google Ads</li>
|
||||
<li>Kompletně na míru</li>
|
||||
<li>Možnost e-shopu a rezervačních systémů</li>
|
||||
<li>Integrace API a platební brány</li>
|
||||
<li>Pokročilé SEO a marketing</li>
|
||||
</ul>
|
||||
<p
|
||||
style="font-size:18px; border-radius:8px; width: fit-content; background-color:#216085; padding:16px; color:#ffffff; font-weight:bold; margin:0;">
|
||||
Cena: dohodou</p>
|
||||
<p style="font-size:16px; background-color:#216085; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
||||
Cena: dohodou
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width: 100%;background-color:#031D44; font-family:'Exo', Arial, sans-serif;" align="center" border="0"
|
||||
cellspacing="50" cellpadding="0" style="color: #031D44;">
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center"
|
||||
style=" border-radius: 50px; background-color: hwb(201 23% 28% / 0); padding:30px; color:#CAF0F8;">
|
||||
<p style="margin:0; font-size:25px; font-weight:bold;">Máte zájem o některý z balíčků?</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style=" border-radius: 50px; padding:30px; color:#CAF0F8;">
|
||||
<p>Stačí odpovědět na tento e-mail nebo mě kontaktovat telefonicky:</p>
|
||||
<p>
|
||||
<a style="color:#CAF0F8; text-decoration:underline;" href="mailto:brunovontor@gmail.com">
|
||||
brunovontor@gmail.com
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<a style="color:#CAF0F8; text-decoration:underline;" href="tel:+420605512624">+420 605 512 624</a>
|
||||
</p>
|
||||
<p>
|
||||
<a style="color:#CAF0F8; text-decoration:underline;" href="https://vontor.cz">vontor.cz</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- Footer -->
|
||||
<table style="width:100%; background-color:#031D44; font-family:'Exo', Arial, sans-serif;" align="center" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="padding:20px; color:#CAF0F8;">
|
||||
<p style="margin:0; font-size:20px; font-weight:bold;">Máte zájem o některý z balíčků?</p>
|
||||
<p>Stačí odpovědět na tento e-mail nebo mě kontaktovat:</p>
|
||||
<p>
|
||||
<a href="mailto:brunovontor@gmail.com" style="color:#CAF0F8; text-decoration:underline;">brunovontor@gmail.com</a><br>
|
||||
<a href="tel:+420605512624" style="color:#CAF0F8; text-decoration:underline;">+420 605 512 624</a><br>
|
||||
<a href="https://vontor.cz" style="color:#CAF0F8; text-decoration:underline;">vontor.cz</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Responsivní CSS -->
|
||||
<style>
|
||||
@media only screen and (max-width: 600px) {
|
||||
table[class="responsive-table"] {
|
||||
width: 100% !important;
|
||||
}
|
||||
td {
|
||||
font-size: 16px !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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")
|
||||
|
||||
41
backend/commerce/migrations/0001_initial.py
Normal file
41
backend/commerce/migrations/0001_initial.py
Normal file
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
9
backend/thirdparty/downloader/admin.py
vendored
Normal file
9
backend/thirdparty/downloader/admin.py
vendored
Normal file
@@ -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",)
|
||||
10
backend/thirdparty/downloader/apps.py
vendored
Normal file
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"
|
||||
54
backend/thirdparty/downloader/migrations/0001_initial.py
vendored
Normal file
54
backend/thirdparty/downloader/migrations/0001_initial.py
vendored
Normal file
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/thirdparty/downloader/migrations/__init__.py
vendored
Normal file
0
backend/thirdparty/downloader/migrations/__init__.py
vendored
Normal file
92
backend/thirdparty/downloader/models.py
vendored
Normal file
92
backend/thirdparty/downloader/models.py
vendored
Normal file
@@ -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
|
||||
69
backend/thirdparty/downloader/serializers.py
vendored
Normal file
69
backend/thirdparty/downloader/serializers.py
vendored
Normal file
@@ -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
|
||||
)
|
||||
3
backend/thirdparty/downloader/tests.py
vendored
Normal file
3
backend/thirdparty/downloader/tests.py
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
backend/thirdparty/downloader/urls.py
vendored
Normal file
14
backend/thirdparty/downloader/urls.py
vendored
Normal file
@@ -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"),
|
||||
]
|
||||
539
backend/thirdparty/downloader/views.py
vendored
Normal file
539
backend/thirdparty/downloader/views.py
vendored
Normal file
@@ -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)
|
||||
3
backend/thirdparty/stripe/apps.py
vendored
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
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 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
71
backend/thirdparty/stripe/serializers.py
vendored
71
backend/thirdparty/stripe/serializers.py
vendored
@@ -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",)
|
||||
|
||||
3
backend/thirdparty/stripe/views.py
vendored
3
backend/thirdparty/stripe/views.py
vendored
@@ -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):
|
||||
|
||||
3
backend/thirdparty/trading212/apps.py
vendored
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
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'),
|
||||
]
|
||||
2
backend/thirdparty/trading212/views.py
vendored
2
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
|
||||
@@ -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",
|
||||
|
||||
@@ -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--------------------------------
|
||||
|
||||
Reference in New Issue
Block a user