Compare commits
3 Commits
05055415de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0f36e0bbd | |||
| b802011d7f | |||
| ed20c841ab |
119
.github/copilot-instructions.md
vendored
119
.github/copilot-instructions.md
vendored
@@ -1,119 +0,0 @@
|
|||||||
# Copilot Instructions for Vontor CZ
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This monorepo contains a Django backend and a Vite/React frontend, orchestrated via Docker Compose. The project is designed for a Czech e-marketplace, with custom payment integrations and real-time features.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
- **backend/**: Django project (`vontor_cz`), custom `account` app, and third-party payment integrations (`thirdparty/`).
|
|
||||||
- Uses Django REST Framework, Channels (ASGI), Celery, and S3/static/media via `django-storages`.
|
|
||||||
- Custom user model: `account.CustomUser`.
|
|
||||||
- API docs: DRF Spectacular (`/api/schema/`).
|
|
||||||
- **frontend/**: Vite + React + TypeScript app.
|
|
||||||
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
|
|
||||||
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`).
|
|
||||||
- Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS.
|
|
||||||
|
|
||||||
## Developer Workflows
|
|
||||||
- **Backend**
|
|
||||||
- Local dev: `python manage.py runserver` (or use Docker Compose)
|
|
||||||
- Migrations: `python manage.py makemigrations && python manage.py migrate`
|
|
||||||
- Celery: `celery -A vontor_cz worker -l info`
|
|
||||||
- Channels: Daphne/ASGI (see Docker Compose command)
|
|
||||||
- Env config: `.env` files in `backend/` (see `.gitignore` for secrets)
|
|
||||||
- **Frontend**
|
|
||||||
- Install: `npm install`
|
|
||||||
- Dev server: `npm run dev`
|
|
||||||
- Build: `npm run build`
|
|
||||||
- Preview: `npm run preview`
|
|
||||||
- Static assets: `src/assets/` (import in JS/CSS), `public/` (referenced in HTML)
|
|
||||||
|
|
||||||
## Conventions & Patterns
|
|
||||||
- **Backend**
|
|
||||||
- Use environment variables for secrets and config (see `settings.py`).
|
|
||||||
- Static/media files: S3 in production, local in dev (see `settings.py`).
|
|
||||||
- API versioning and docs: DRF Spectacular config in `settings.py`.
|
|
||||||
- Custom permissions, filters, and serializers in each app.
|
|
||||||
- **Frontend**
|
|
||||||
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
|
|
||||||
- API calls and JWT handling in `src/api/`.
|
|
||||||
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
|
|
||||||
- Use TypeScript strict mode (see `tsconfig.*.json`).
|
|
||||||
- Linting: ESLint config in `eslint.config.js`.
|
|
||||||
- Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary.
|
|
||||||
|
|
||||||
### Frontend API Client (required)
|
|
||||||
All frontend API calls must use the shared client at frontend/src/api/Client.ts.
|
|
||||||
|
|
||||||
- Client.public: no cookies, no Authorization header (for public Django endpoints).
|
|
||||||
- Client.auth: sends cookies and includes Bearer token; auto-refreshes on 401 (retries up to 2x).
|
|
||||||
- Centralized error handling: subscribe via Client.onError to show toasts/snackbars.
|
|
||||||
- Tokens are stored in cookies by Client.setTokens and cleared by Client.clearTokens.
|
|
||||||
|
|
||||||
Example usage (TypeScript)
|
|
||||||
```ts
|
|
||||||
import Client from "@/api/Client";
|
|
||||||
|
|
||||||
// Public request (no credentials)
|
|
||||||
async function listPublicItems() {
|
|
||||||
const res = await Client.public.get("/api/public/items/");
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login (obtain tokens and persist to cookies)
|
|
||||||
async function login(username: string, password: string) {
|
|
||||||
// Default SimpleJWT endpoint (adjust if your backend differs)
|
|
||||||
const res = await Client.public.post("/api/token/", { username, password });
|
|
||||||
const { access, refresh } = res.data;
|
|
||||||
Client.setTokens(access, refresh);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticated requests (auto Bearer + refresh on 401)
|
|
||||||
async function fetchProfile() {
|
|
||||||
const res = await Client.auth.get("/api/users/me/");
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
Client.clearTokens();
|
|
||||||
window.location.assign("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global error toasts
|
|
||||||
import { useEffect } from "react";
|
|
||||||
function useApiErrors(showToast: (msg: string) => void) {
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = Client.onError((e) => {
|
|
||||||
const { message, status } = e.detail;
|
|
||||||
showToast(status ? `${status}: ${message}` : message);
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [showToast]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Vite env used by the client:
|
|
||||||
- VITE_API_BASE_URL (default: http://localhost:8000)
|
|
||||||
- VITE_API_REFRESH_URL (default: /api/token/refresh/)
|
|
||||||
- VITE_LOGIN_PATH (default: /login)
|
|
||||||
|
|
||||||
Notes
|
|
||||||
- Public client never sends cookies or Authorization.
|
|
||||||
- Ensure Django CORS settings allow your frontend origin. See backend/vontor_cz/settings.py.
|
|
||||||
- Use React Router layouts and guards as documented in frontend/src/routes/ROUTES.md and frontend/src/layouts/LAYOUTS.md.
|
|
||||||
|
|
||||||
## Integration Points
|
|
||||||
- **Payments**: `thirdparty/` contains custom integrations for Stripe, GoPay, Trading212.
|
|
||||||
- **Real-time**: Django Channels (ASGI, Redis) for websockets.
|
|
||||||
- **Task queue**: Celery + Redis for async/background jobs.
|
|
||||||
- **API**: REST endpoints, JWT auth, API key support.
|
|
||||||
|
|
||||||
## References
|
|
||||||
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
|
|
||||||
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.
|
|
||||||
- [frontend/src/routes/ROUTES.md](../frontend/src/routes/ROUTES.md): Routing conventions.
|
|
||||||
- [backend/vontor_cz/settings.py](../backend/vontor_cz/settings.py): All backend config, env, and integration details.
|
|
||||||
- [docker-compose.yml](../docker-compose.yml): Service orchestration and dev workflow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**When in doubt, check the referenced markdown files and `settings.py` for project-specific logic and patterns.**
|
|
||||||
165
.gitignore copy
Normal file
165
.gitignore copy
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#
|
||||||
|
*.mp4
|
||||||
|
backups/
|
||||||
|
collectedstaticfiles/
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
// Use IntelliSense to learn about possible attributes.
|
|
||||||
// Hover to view descriptions of existing attributes.
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Launch Chrome against localhost",
|
|
||||||
"url": "http://localhost:8080",
|
|
||||||
"webRoot": "${workspaceFolder}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
23
README.md
23
README.md
@@ -0,0 +1,23 @@
|
|||||||
|
# vontor-cz
|
||||||
|
|
||||||
|
## venv
|
||||||
|
- windows
|
||||||
|
|
||||||
|
```
|
||||||
|
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
|
||||||
|
python -m venv venv
|
||||||
|
.\venv\Scripts\Activate
|
||||||
|
|
||||||
|
#start server
|
||||||
|
daphne -b localhost -p 8000 vontor_cz.asgi:application
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## docker compose
|
||||||
|
spuštění dockeru pro lokální hosting, s instantníma změnami během editace ve vscodu.
|
||||||
|
```docker-compose up --build```
|
||||||
|
|
||||||
|
## dns reset windows
|
||||||
|
```ipconfig /flushdns```
|
||||||
|
|||||||
37
X-Notes/admin(example).py
Normal file
37
X-Notes/admin(example).py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import MyModel # Importuj modely
|
||||||
|
|
||||||
|
# Příklad přizpůsobení zobrazení modelu v administrátorské sekci
|
||||||
|
class MyModelAdmin(admin.ModelAdmin):
|
||||||
|
# Určují se pole, která se zobrazí v seznamu (list view)
|
||||||
|
list_display = ('field1', 'field2', 'field3')
|
||||||
|
# Určuje, podle kterých polí lze vyhledávat
|
||||||
|
search_fields = ('field1', 'field2')
|
||||||
|
# Aktivuje filtrování podle hodnoty pole v pravém postranním panelu
|
||||||
|
list_filter = ('field1', 'field2')
|
||||||
|
# Určuje pole, která se zobrazí ve formuláři při detailním pohledu na model
|
||||||
|
fields = ('field1', 'field2', 'field3')
|
||||||
|
# Definuje rozložení polí ve formuláři
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('field1', 'field2'),
|
||||||
|
}),
|
||||||
|
('Další informace', {
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'fields': ('field3',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
# Nastavuje výchozí řazení záznamů při jejich zobrazení
|
||||||
|
ordering = ('field1',)
|
||||||
|
# Určuje počet záznamů zobrazených na jedné stránce
|
||||||
|
list_per_page = 10
|
||||||
|
# Definuje akce dostupné pro vybrané objekty
|
||||||
|
actions = ['custom_action']
|
||||||
|
|
||||||
|
# Příklad vlastní akce
|
||||||
|
def custom_action(self, request, queryset):
|
||||||
|
# Vlastní logika pro akci
|
||||||
|
queryset.update(field1='Updated Value')
|
||||||
|
|
||||||
|
# Registrování modelu s vlastními nastaveními administrátorského rozhraní
|
||||||
|
admin.site.register(MyModel, MyModelAdmin)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class GopayConfig(AppConfig):
|
class ApiConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'gopay'
|
name = 'api'
|
||||||
14
api/models.py
Normal file
14
api/models.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from django.db import models
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
|
# Add custom fields here
|
||||||
|
bio = models.TextField(blank=True)
|
||||||
|
birthdate = models.DateField(null=True, blank=True)
|
||||||
|
profile_picture = models.ImageField(upload_to='profile_pics/', null=True, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.username} Profile'
|
||||||
7
api/permissions.py
Normal file
7
api/permissions.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from rest_framework_api_key.permissions import HasAPIKey
|
||||||
|
|
||||||
|
class UserEditAPIKeyPermissions(HasAPIKey):
|
||||||
|
"""
|
||||||
|
Custom permision for restricting access using API key.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
21
api/serializers.py
Normal file
21
api/serializers.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
#Serializers are for what views can show fields of models
|
||||||
|
|
||||||
|
class PublicUserSerializers(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for public User fields
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username']
|
||||||
|
|
||||||
|
class SecureUserSerializers(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for all User fields
|
||||||
|
Requires API key
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'bio']
|
||||||
10
api/urls.py
Normal file
10
api/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import PublicUserView, SecureUserUpdateView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# URL for the public view to list users with public fields
|
||||||
|
path('users/', PublicUserView.as_view(), name='public-user-list'),
|
||||||
|
|
||||||
|
# URL for secure view to retrieve and update user with all fields
|
||||||
|
path('users/<int:pk>/', SecureUserUpdateView.as_view(), name='secure-user-update'),
|
||||||
|
]
|
||||||
20
api/views.py
Normal file
20
api/views.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
|
|
||||||
|
from rest_framework import generics, permissions
|
||||||
|
from .models import User
|
||||||
|
from .serializers import PublicUserSerializers, SecureUserSerializers
|
||||||
|
from .permissions import UserEditAPIKeyPermissions
|
||||||
|
|
||||||
|
#Public view: List users with only public fields
|
||||||
|
class PublicUserView(generics.ListAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = PublicUserSerializers
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
#Secure view for retrive/update user all fields (API key)
|
||||||
|
class SecureUserUpdateView(generics.RetrieveUpdateAPIView):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = SecureUserSerializers
|
||||||
|
permission_classes = [UserEditAPIKeyPermissions]
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt update && apt install ffmpeg -y
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
@admin.register(User)
|
|
||||||
class UserAdmin(admin.ModelAdmin):
|
|
||||||
list_display = (
|
|
||||||
"id", "email", "role", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified", "create_time"
|
|
||||||
)
|
|
||||||
list_filter = ("role", "gdpr", "is_active", "email_verified", "city", "postal_code")
|
|
||||||
search_fields = ("email", "phone_number", "city", "street", "postal_code")
|
|
||||||
ordering = ("-create_time",)
|
|
||||||
fieldsets = (
|
|
||||||
(None, {"fields": ("email", "password", "role")}),
|
|
||||||
(_("Personal info"), {"fields": ("phone_number", "city", "street", "postal_code")}),
|
|
||||||
(_("Permissions"), {"fields": ("gdpr", "is_active", "email_verified")}),
|
|
||||||
(_("Important dates"), {"fields": ("create_time",)}),
|
|
||||||
)
|
|
||||||
readonly_fields = ("create_time",)
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import django_filters
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
class UserFilter(django_filters.FilterSet):
|
|
||||||
role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
|
|
||||||
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
|
|
||||||
phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains")
|
|
||||||
city = django_filters.CharFilter(field_name="city", lookup_expr="icontains")
|
|
||||||
street = django_filters.CharFilter(field_name="street", lookup_expr="icontains")
|
|
||||||
postal_code = django_filters.CharFilter(field_name="postal_code", lookup_expr="exact")
|
|
||||||
gdpr = django_filters.BooleanFilter(field_name="gdpr")
|
|
||||||
is_active = django_filters.BooleanFilter(field_name="is_active")
|
|
||||||
email_verified = django_filters.BooleanFilter(field_name="email_verified")
|
|
||||||
create_time_after = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="gte")
|
|
||||||
create_time_before = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="lte")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified",
|
|
||||||
"create_time_after", "create_time_before"
|
|
||||||
]
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
|
||||||
|
|
||||||
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(choices=[('admin', 'Admin'), ('mod', 'Moderator'), ('regular', 'Regular')], default='regular', max_length=20)),
|
|
||||||
('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
|
|
||||||
('email_verified', models.BooleanField(default=False)),
|
|
||||||
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
|
||||||
('gdpr', models.BooleanField(default=False)),
|
|
||||||
('is_active', models.BooleanField(default=False)),
|
|
||||||
('create_time', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('city', models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
('street', models.CharField(blank=True, max_length=200, null=True)),
|
|
||||||
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
|
||||||
('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.CustomUserManager()),
|
|
||||||
('active', account.models.ActiveUserManager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-31 07:36
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('account', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='customuser',
|
|
||||||
name='email_verification_sent_at',
|
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='customuser',
|
|
||||||
name='email_verification_token',
|
|
||||||
field=models.CharField(blank=True, db_index=True, max_length=128, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from django.db import models
|
|
||||||
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
|
||||||
from django.core.validators import RegexValidator
|
|
||||||
|
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from vontor_cz.models import SoftDeleteModel
|
|
||||||
|
|
||||||
from django.contrib.auth.models import UserManager
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class CustomUserManager(UserManager):
|
|
||||||
# Inherit get_by_natural_key and all auth behaviors
|
|
||||||
use_in_migrations = True
|
|
||||||
|
|
||||||
class ActiveUserManager(CustomUserManager):
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(is_active=True)
|
|
||||||
|
|
||||||
class CustomUser(SoftDeleteModel, AbstractUser):
|
|
||||||
groups = models.ManyToManyField(
|
|
||||||
Group,
|
|
||||||
related_name="customuser_set", # <- přidáš related_name
|
|
||||||
blank=True,
|
|
||||||
help_text="The groups this user belongs to.",
|
|
||||||
related_query_name="customuser",
|
|
||||||
)
|
|
||||||
user_permissions = models.ManyToManyField(
|
|
||||||
Permission,
|
|
||||||
related_name="customuser_set", # <- přidáš related_name
|
|
||||||
blank=True,
|
|
||||||
help_text="Specific permissions for this user.",
|
|
||||||
related_query_name="customuser",
|
|
||||||
)
|
|
||||||
|
|
||||||
class Role(models.TextChoices):
|
|
||||||
ADMIN = "admin", "Admin"
|
|
||||||
MANAGER = "mod", "Moderator"
|
|
||||||
CUSTOMER = "regular", "Regular"
|
|
||||||
|
|
||||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
phone_number = models.CharField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
|
|
||||||
unique=True,
|
|
||||||
max_length=16,
|
|
||||||
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
|
|
||||||
)
|
|
||||||
|
|
||||||
email_verified = models.BooleanField(default=False)
|
|
||||||
email = models.EmailField(unique=True, db_index=True)
|
|
||||||
|
|
||||||
# + fields for email verification flow
|
|
||||||
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
|
|
||||||
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
gdpr = models.BooleanField(default=False)
|
|
||||||
is_active = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
create_time = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
|
|
||||||
city = models.CharField(null=True, blank=True, max_length=100)
|
|
||||||
street = models.CharField(null=True, blank=True, max_length=200)
|
|
||||||
|
|
||||||
postal_code = models.CharField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
|
|
||||||
max_length=5,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(
|
|
||||||
regex=r'^\d{5}$',
|
|
||||||
message="Postal code must contain exactly 5 digits.",
|
|
||||||
code='invalid_postal_code'
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
USERNAME_FIELD = "username"
|
|
||||||
REQUIRED_FIELDS = [
|
|
||||||
"email"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ensure default manager has get_by_natural_key
|
|
||||||
objects = CustomUserManager()
|
|
||||||
# Optional convenience manager for active users only
|
|
||||||
active = ActiveUserManager()
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
self.is_active = False
|
|
||||||
|
|
||||||
return super().delete(*args, **kwargs)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
is_new = self._state.adding # True if object hasn't been saved yet
|
|
||||||
|
|
||||||
# Pre-save flags for new users
|
|
||||||
if is_new:
|
|
||||||
if self.is_superuser or self.role == "admin":
|
|
||||||
# ensure admin flags are consistent
|
|
||||||
self.is_active = True
|
|
||||||
self.is_staff = True
|
|
||||||
self.is_superuser = True
|
|
||||||
self.role = "admin"
|
|
||||||
else:
|
|
||||||
self.is_staff = False
|
|
||||||
|
|
||||||
# First save to obtain a primary key
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
# Assign group after we have a PK
|
|
||||||
if is_new:
|
|
||||||
from django.contrib.auth.models import Group
|
|
||||||
group, _ = Group.objects.get_or_create(name=self.role)
|
|
||||||
# Use add/set now that PK exists
|
|
||||||
self.groups.set([group])
|
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
|
|
||||||
token = get_random_string(length=length)
|
|
||||||
self.email_verification_token = token
|
|
||||||
self.email_verification_sent_at = timezone.now()
|
|
||||||
if save:
|
|
||||||
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
|
|
||||||
return token
|
|
||||||
|
|
||||||
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
|
|
||||||
if not token or not self.email_verification_token:
|
|
||||||
return False
|
|
||||||
# optional expiry check
|
|
||||||
if self.email_verification_sent_at:
|
|
||||||
age = timezone.now() - self.email_verification_sent_at
|
|
||||||
if age > timedelta(hours=max_age_hours):
|
|
||||||
return False
|
|
||||||
if token != self.email_verification_token:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self.email_verified:
|
|
||||||
self.email_verified = True
|
|
||||||
# clear token after success
|
|
||||||
self.email_verification_token = None
|
|
||||||
self.email_verification_sent_at = None
|
|
||||||
if save:
|
|
||||||
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
from urllib import request
|
|
||||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from rest_framework_api_key.permissions import HasAPIKey
|
|
||||||
|
|
||||||
|
|
||||||
#TOHLE POUŽÍT!!!
|
|
||||||
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
|
||||||
def RoleAllowed(*roles):
|
|
||||||
"""
|
|
||||||
Allows safe methods for any authenticated user.
|
|
||||||
Allows unsafe methods only for users with specific roles.
|
|
||||||
Allows access if a valid API key is provided.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
RoleAllowed('admin', 'user')
|
|
||||||
"""
|
|
||||||
class SafeOrRolePermission(BasePermission):
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
# Má API klíč?
|
|
||||||
has_api_key = HasAPIKey().has_permission(request, view)
|
|
||||||
|
|
||||||
# Allow safe methods for any authenticated user
|
|
||||||
if request.method in SAFE_METHODS:
|
|
||||||
return IsAuthenticated().has_permission(request, view)
|
|
||||||
|
|
||||||
# Otherwise, check the user's role
|
|
||||||
user = request.user
|
|
||||||
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
|
||||||
|
|
||||||
return SafeOrRolePermission
|
|
||||||
|
|
||||||
|
|
||||||
def OnlyRolesAllowed(*roles):
|
|
||||||
class SafeOrRolePermission(BasePermission):
|
|
||||||
"""
|
|
||||||
Allows all methods only for users with specific roles.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
# Otherwise, check the user's role
|
|
||||||
user = request.user
|
|
||||||
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
|
||||||
|
|
||||||
return SafeOrRolePermission
|
|
||||||
|
|
||||||
|
|
||||||
# For Settings.py
|
|
||||||
class AdminOnly(BasePermission):
|
|
||||||
""" Allows access only to users with the 'admin' role.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
BasePermission (rest_framework.permissions.BasePermission): Base class for permission classes.
|
|
||||||
"""
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
|
||||||
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import re
|
|
||||||
from django.utils.text import slugify
|
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework.exceptions import NotFound
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django.utils.text import slugify
|
|
||||||
|
|
||||||
from .permissions import *
|
|
||||||
|
|
||||||
|
|
||||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
class CustomUserSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
"id",
|
|
||||||
"username",
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"email",
|
|
||||||
"role",
|
|
||||||
"email_verified",
|
|
||||||
"phone_number",
|
|
||||||
"create_time",
|
|
||||||
"city",
|
|
||||||
"street",
|
|
||||||
"postal_code",
|
|
||||||
"gdpr",
|
|
||||||
"is_active",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
user = self.context["request"].user
|
|
||||||
staff_only_fields = ["role", "email_verified", "var_symbol", "is_active"]
|
|
||||||
|
|
||||||
if user.role not in ["admin", "cityClerk"]:
|
|
||||||
unauthorized = [f for f in staff_only_fields if f in validated_data]
|
|
||||||
if unauthorized:
|
|
||||||
raise PermissionDenied(f"You are not allowed to modify: {', '.join(unauthorized)}")
|
|
||||||
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Token obtaining Default Serializer
|
|
||||||
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
|
||||||
username_field = User.USERNAME_FIELD
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
login = attrs.get("username")
|
|
||||||
password = attrs.get("password")
|
|
||||||
|
|
||||||
# Allow login by username or email
|
|
||||||
user = User.objects.filter(email__iexact=login).first() or \
|
|
||||||
User.objects.filter(username__iexact=login).first()
|
|
||||||
|
|
||||||
if user is None or not user.check_password(password):
|
|
||||||
raise serializers.ValidationError(_("No active account found with the given credentials"))
|
|
||||||
|
|
||||||
# Call the parent validation to create token
|
|
||||||
data = super().validate({
|
|
||||||
self.username_field: user.username,
|
|
||||||
"password": password
|
|
||||||
})
|
|
||||||
|
|
||||||
data["user_id"] = user.id
|
|
||||||
data["username"] = user.username
|
|
||||||
data["email"] = user.email
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# user creating section start ------------------------------------------
|
|
||||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
|
||||||
password = serializers.CharField(
|
|
||||||
write_only=True,
|
|
||||||
help_text="Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
'first_name', 'last_name', 'email', 'phone_number', 'password',
|
|
||||||
'city', 'street', 'postal_code', 'gdpr'
|
|
||||||
]
|
|
||||||
extra_kwargs = {
|
|
||||||
'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'},
|
|
||||||
'last_name': {'required': True, 'help_text': 'Příjmení uživatele'},
|
|
||||||
'email': {'required': True, 'help_text': 'Emailová adresa uživatele'},
|
|
||||||
'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'},
|
|
||||||
'city': {'required': True, 'help_text': 'Město uživatele'},
|
|
||||||
'street': {'required': True, 'help_text': 'Ulice uživatele'},
|
|
||||||
'postal_code': {'required': True, 'help_text': 'PSČ uživatele'},
|
|
||||||
'gdpr': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'},
|
|
||||||
}
|
|
||||||
|
|
||||||
def validate_password(self, value):
|
|
||||||
if len(value) < 8:
|
|
||||||
raise serializers.ValidationError("Password must be at least 8 characters long.")
|
|
||||||
if not re.search(r"[A-Z]", value):
|
|
||||||
raise serializers.ValidationError("Password must contain at least one uppercase letter.")
|
|
||||||
if not re.search(r"[a-z]", value):
|
|
||||||
raise serializers.ValidationError("Password must contain at least one lowercase letter.")
|
|
||||||
if not re.search(r"\d", value):
|
|
||||||
raise serializers.ValidationError("Password must contain at least one digit.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def validate(self, data):
|
|
||||||
email = data.get("email")
|
|
||||||
phone = data.get("phone_number")
|
|
||||||
dgpr = data.get("GDPR")
|
|
||||||
if not dgpr:
|
|
||||||
raise serializers.ValidationError({"GDPR": "You must agree to the GDPR to register."})
|
|
||||||
|
|
||||||
if User.objects.filter(email=email).exists():
|
|
||||||
raise serializers.ValidationError({"email": "Account with this email already exists."})
|
|
||||||
|
|
||||||
if phone and User.objects.filter(phone_number=phone).exists():
|
|
||||||
raise serializers.ValidationError({"phone_number": "Account with this phone number already exists."})
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
password = validated_data.pop("password")
|
|
||||||
username = validated_data.get("username", "")
|
|
||||||
user = User.objects.create(
|
|
||||||
username=username,
|
|
||||||
is_active=False, #uživatel je defaultně deaktivovaný
|
|
||||||
**validated_data
|
|
||||||
)
|
|
||||||
user.set_password(password)
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
class UserActivationSerializer(serializers.Serializer):
|
|
||||||
user_id = serializers.IntegerField()
|
|
||||||
var_symbol = serializers.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(9999999999)])
|
|
||||||
|
|
||||||
def save(self, **kwargs):
|
|
||||||
try:
|
|
||||||
user = User.objects.get(pk=self.validated_data['user_id'])
|
|
||||||
except User.DoesNotExist:
|
|
||||||
raise NotFound("User with this ID does not exist.")
|
|
||||||
user.var_symbol = self.validated_data['var_symbol']
|
|
||||||
user.is_active = True
|
|
||||||
user.save()
|
|
||||||
return user
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
|
||||||
return {
|
|
||||||
"id": instance.id,
|
|
||||||
"email": instance.email,
|
|
||||||
"var_symbol": instance.var_symbol,
|
|
||||||
"is_active": instance.is_active,
|
|
||||||
}
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = [
|
|
||||||
'user_id', 'var_symbol'
|
|
||||||
]
|
|
||||||
extra_kwargs = {
|
|
||||||
'user_id': {'required': True, 'help_text': 'ID uživatele'},
|
|
||||||
'var_symbol': {'required': True, 'help_text': 'Variablní symbol, zadán úředníkem'},
|
|
||||||
}
|
|
||||||
# user creating section end --------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetRequestSerializer(serializers.Serializer):
|
|
||||||
email = serializers.EmailField(
|
|
||||||
help_text="E-mail registrovaného a aktivního uživatele, na který bude zaslán reset hesla."
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_email(self, value):
|
|
||||||
if not User.objects.filter(email=value, is_active=True).exists():
|
|
||||||
raise serializers.ValidationError("Účet s tímto emailem neexistuje nebo není aktivní.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
class PasswordResetConfirmSerializer(serializers.Serializer):
|
|
||||||
password = serializers.CharField(
|
|
||||||
write_only=True,
|
|
||||||
help_text="Nové heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_password(self, value):
|
|
||||||
import re
|
|
||||||
if len(value) < 8:
|
|
||||||
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
|
|
||||||
if not re.search(r"[A-Z]", value):
|
|
||||||
raise serializers.ValidationError("Musí obsahovat velké písmeno.")
|
|
||||||
if not re.search(r"[a-z]", value):
|
|
||||||
raise serializers.ValidationError("Musí obsahovat malé písmeno.")
|
|
||||||
if not re.search(r"\d", value):
|
|
||||||
raise serializers.ValidationError("Musí obsahovat číslici.")
|
|
||||||
return value
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
from celery import shared_task
|
|
||||||
from celery.utils.log import get_task_logger
|
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.conf import settings
|
|
||||||
from django.utils.http import urlsafe_base64_encode
|
|
||||||
from django.utils.encoding import force_bytes
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from .tokens import *
|
|
||||||
from .models import CustomUser
|
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
|
||||||
|
|
||||||
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
|
|
||||||
"""
|
|
||||||
General function to send emails with a specific context.
|
|
||||||
Supports rendering plain text and HTML templates.
|
|
||||||
Converts `user` in context to a plain dict to avoid template access to the model.
|
|
||||||
"""
|
|
||||||
if isinstance(recipients, str):
|
|
||||||
recipients = [recipients]
|
|
||||||
|
|
||||||
html_message = None
|
|
||||||
if template_name or html_template_name:
|
|
||||||
# Best effort to resolve both templates if only one provided
|
|
||||||
if not template_name and html_template_name:
|
|
||||||
template_name = html_template_name.replace(".html", ".txt")
|
|
||||||
if not html_template_name and template_name:
|
|
||||||
html_template_name = template_name.replace(".txt", ".html")
|
|
||||||
|
|
||||||
ctx = dict(context or {})
|
|
||||||
# Sanitize user if someone passes the model by mistake
|
|
||||||
if "user" in ctx and not isinstance(ctx["user"], dict):
|
|
||||||
try:
|
|
||||||
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
|
||||||
except Exception:
|
|
||||||
ctx["user"] = {}
|
|
||||||
|
|
||||||
message = render_to_string(template_name, ctx)
|
|
||||||
html_message = render_to_string(html_template_name, ctx)
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=message or "",
|
|
||||||
from_email=None,
|
|
||||||
recipient_list=recipients,
|
|
||||||
fail_silently=False,
|
|
||||||
html_message=html_message,
|
|
||||||
)
|
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
|
||||||
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"E-mail se neodeslal: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _build_user_template_ctx(user: CustomUser) -> dict:
|
|
||||||
"""
|
|
||||||
Return a plain dict for templates instead of passing the DB model.
|
|
||||||
Provides aliases to avoid template errors (firstname vs first_name).
|
|
||||||
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
|
|
||||||
"""
|
|
||||||
first_name = getattr(user, "first_name", "") or ""
|
|
||||||
last_name = getattr(user, "last_name", "") or ""
|
|
||||||
full_name = f"{first_name} {last_name}".strip()
|
|
||||||
return {
|
|
||||||
"id": user.pk,
|
|
||||||
"email": getattr(user, "email", "") or "",
|
|
||||||
"first_name": first_name,
|
|
||||||
"firstname": first_name, # alias for templates using `firstname`
|
|
||||||
"last_name": last_name,
|
|
||||||
"lastname": last_name, # alias for templates using `lastname`
|
|
||||||
"full_name": full_name,
|
|
||||||
"get_full_name": full_name, # compatibility for templates using method-style access
|
|
||||||
}
|
|
||||||
|
|
||||||
#----------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# This function sends an email to the user for email verification after registration.
|
|
||||||
@shared_task
|
|
||||||
def send_email_verification_task(user_id):
|
|
||||||
try:
|
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
|
||||||
except CustomUser.DoesNotExist:
|
|
||||||
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
|
||||||
# {changed} generate and store a per-user token
|
|
||||||
token = user.generate_email_verification_token()
|
|
||||||
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"user": _build_user_template_ctx(user),
|
|
||||||
"action_url": verify_url,
|
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
|
||||||
"cta_label": "Ověřit e‑mail",
|
|
||||||
}
|
|
||||||
|
|
||||||
send_email_with_context(
|
|
||||||
recipients=user.email,
|
|
||||||
subject="Ověření e‑mailu",
|
|
||||||
template_name="email/email_verification.txt",
|
|
||||||
html_template_name="email/email_verification.html",
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def send_email_test_task(email):
|
|
||||||
context = {
|
|
||||||
"action_url": settings.FRONTEND_URL,
|
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
|
||||||
"cta_label": "Otevřít aplikaci",
|
|
||||||
}
|
|
||||||
send_email_with_context(
|
|
||||||
recipients=email,
|
|
||||||
subject="Testovací e‑mail",
|
|
||||||
template_name="email/test.txt",
|
|
||||||
html_template_name="email/test.html",
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def send_password_reset_email_task(user_id):
|
|
||||||
try:
|
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
|
||||||
except CustomUser.DoesNotExist:
|
|
||||||
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
|
||||||
token = password_reset_token.make_token(user)
|
|
||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"user": _build_user_template_ctx(user),
|
|
||||||
"action_url": reset_url,
|
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
|
||||||
"cta_label": "Obnovit heslo",
|
|
||||||
}
|
|
||||||
|
|
||||||
send_email_with_context(
|
|
||||||
recipients=user.email,
|
|
||||||
subject="Obnova hesla",
|
|
||||||
template_name="email/password_reset.txt",
|
|
||||||
html_template_name="email/password_reset.html",
|
|
||||||
context=context,
|
|
||||||
)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="cs">
|
|
||||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
|
||||||
Ověření e‑mailu
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
|
||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
|
||||||
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
|
||||||
{% endwith %}
|
|
||||||
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na tlačítko níže.</p>
|
|
||||||
|
|
||||||
{% if action_url and cta_label %}
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="#2563eb" style="border-radius:6px;">
|
|
||||||
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
|
||||||
{{ cta_label }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
|
||||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
|
||||||
|
|
||||||
Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na následující odkaz:
|
|
||||||
|
|
||||||
{{ action_url }}
|
|
||||||
|
|
||||||
Pokud jste účet nevytvořili vy, tento e‑mail ignorujte.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="cs">
|
|
||||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
|
||||||
Obnova hesla
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
|
||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
|
||||||
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
|
||||||
{% endwith %}
|
|
||||||
<p style="margin:0 0 12px 0;">Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail ignorujte.</p>
|
|
||||||
|
|
||||||
{% if action_url and cta_label %}
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="#2563eb" style="border-radius:6px;">
|
|
||||||
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
|
||||||
{{ cta_label }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
|
||||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
|
||||||
|
|
||||||
Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu.
|
|
||||||
Pokud jste o změnu nepožádali, tento e‑mail ignorujte.
|
|
||||||
|
|
||||||
Pro nastavení nového hesla použijte tento odkaz:
|
|
||||||
{{ action_url }}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="cs">
|
|
||||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
|
||||||
<tr>
|
|
||||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
|
||||||
Testovací e‑mail
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
|
||||||
<p style="margin:0 0 12px 0;">Dobrý den,</p>
|
|
||||||
<p style="margin:0 0 16px 0;">Toto je testovací e‑mail z aplikace e‑tržnice.</p>
|
|
||||||
|
|
||||||
{% if action_url and cta_label %}
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
|
||||||
<tr>
|
|
||||||
<td bgcolor="#2563eb" style="border-radius:6px;">
|
|
||||||
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
|
||||||
{{ cta_label }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
|
||||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
Dobrý den,
|
|
||||||
|
|
||||||
Toto je testovací e‑mail z aplikace e‑tržnice.
|
|
||||||
|
|
||||||
Odkaz na aplikaci:
|
|
||||||
{{ action_url }}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
|
|
||||||
class UserViewAnonymousTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = APIClient()
|
|
||||||
User = get_user_model()
|
|
||||||
self.target_user = User.objects.create_user(
|
|
||||||
username="target",
|
|
||||||
email="target@example.com",
|
|
||||||
password="pass1234",
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_anonymous_update_user_is_forbidden_and_does_not_crash(self):
|
|
||||||
url = f"/api/account/users/{self.target_user.id}/"
|
|
||||||
payload = {"username": "newname", "email": self.target_user.email}
|
|
||||||
resp = self.client.put(url, data=payload, format="json")
|
|
||||||
# Expect 403 Forbidden (permission denied), but most importantly no 500 error
|
|
||||||
self.assertEqual(resp.status_code, 403, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
|
|
||||||
|
|
||||||
def test_anonymous_retrieve_user_is_unauthorized(self):
|
|
||||||
url = f"/api/account/users/{self.target_user.id}/"
|
|
||||||
resp = self.client.get(url)
|
|
||||||
# Retrieve requires authentication per view; expect 401 Unauthorized
|
|
||||||
self.assertEqual(resp.status_code, 401, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
|
||||||
|
|
||||||
# Subclass PasswordResetTokenGenerator to create a separate token generator
|
|
||||||
# for account activation. This allows future customization specific to activation tokens,
|
|
||||||
# even though it currently behaves exactly like the base class.
|
|
||||||
class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
|
|
||||||
pass # No changes yet; inherits all behavior from PasswordResetTokenGenerator
|
|
||||||
|
|
||||||
# Create an instance of AccountActivationTokenGenerator to be used for generating
|
|
||||||
# and validating account activation tokens throughout the app.
|
|
||||||
account_activation_token = AccountActivationTokenGenerator()
|
|
||||||
|
|
||||||
# Create an instance of the base PasswordResetTokenGenerator to be used
|
|
||||||
# for password reset tokens.
|
|
||||||
password_reset_token = PasswordResetTokenGenerator()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
|
||||||
|
|
||||||
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
|
|
||||||
class CookieJWTAuthentication(JWTAuthentication):
|
|
||||||
def authenticate(self, request):
|
|
||||||
|
|
||||||
raw_token = request.COOKIES.get('access_token')
|
|
||||||
|
|
||||||
if not raw_token:
|
|
||||||
return None
|
|
||||||
|
|
||||||
validated_token = self.get_validated_token(raw_token)
|
|
||||||
return self.get_user(validated_token), validated_token
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from . import views
|
|
||||||
from django.urls import path, include
|
|
||||||
from rest_framework.routers import DefaultRouter
|
|
||||||
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register(r'users', views.UserView, basename='user')
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# Auth endpoints
|
|
||||||
path('login/', views.CookieTokenObtainPairView.as_view(), name='login'),
|
|
||||||
path('token/refresh/', views.CookieTokenRefreshView.as_view(), name='token-refresh'),
|
|
||||||
path('logout/', views.LogoutView.as_view(), name='logout'),
|
|
||||||
path('user/me/', views.CurrentUserView.as_view(), name='user-detail'),
|
|
||||||
|
|
||||||
# Registration & email endpoints
|
|
||||||
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
|
||||||
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
|
||||||
path('activate/', views.UserActivationViewSet.as_view(), name='activate-user'),
|
|
||||||
|
|
||||||
# Password reset endpoints
|
|
||||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
|
||||||
path('password-reset-confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
|
|
||||||
|
|
||||||
# User CRUD (list, retrieve, update, delete)
|
|
||||||
path('', include(router.urls)), #/users/
|
|
||||||
]
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
from django.contrib.auth import get_user_model, authenticate
|
|
||||||
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
|
|
||||||
from django.utils.encoding import force_bytes, force_str
|
|
||||||
|
|
||||||
from .serializers import *
|
|
||||||
from .permissions import *
|
|
||||||
from .models import CustomUser
|
|
||||||
from .tokens import *
|
|
||||||
from .tasks import send_password_reset_email_task
|
|
||||||
from django.conf import settings
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from .filters import UserFilter
|
|
||||||
|
|
||||||
from rest_framework import generics, permissions, status, viewsets
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
|
||||||
|
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
|
||||||
from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
|
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
#general user view API
|
|
||||||
|
|
||||||
|
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
|
||||||
|
|
||||||
#---------------------------------------------TOKENY------------------------------------------------
|
|
||||||
|
|
||||||
# Custom Token obtaining view
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Authentication"],
|
|
||||||
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
|
||||||
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
|
||||||
request=CustomTokenObtainPairSerializer,
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(response=CustomTokenObtainPairSerializer, description="Tokens returned successfully."),
|
|
||||||
401: OpenApiResponse(description="Invalid credentials or inactive user."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class CookieTokenObtainPairView(TokenObtainPairView):
|
|
||||||
serializer_class = CustomTokenObtainPairSerializer
|
|
||||||
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
response = super().post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
# Získáme tokeny z odpovědi
|
|
||||||
access = response.data.get("access")
|
|
||||||
refresh = response.data.get("refresh")
|
|
||||||
|
|
||||||
if not access or not refresh:
|
|
||||||
return response # Např. při chybě přihlášení
|
|
||||||
|
|
||||||
jwt_settings = settings.SIMPLE_JWT
|
|
||||||
|
|
||||||
# Access token cookie
|
|
||||||
response.set_cookie(
|
|
||||||
key=jwt_settings.get("AUTH_COOKIE", "access_token"),
|
|
||||||
value=access,
|
|
||||||
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
|
|
||||||
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
|
|
||||||
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
|
|
||||||
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
|
|
||||||
max_age=5 * 60, # 5 minut
|
|
||||||
)
|
|
||||||
|
|
||||||
# Refresh token cookie
|
|
||||||
response.set_cookie(
|
|
||||||
key="refresh_token",
|
|
||||||
value=refresh,
|
|
||||||
httponly=True,
|
|
||||||
secure=not settings.DEBUG,
|
|
||||||
samesite="Lax",
|
|
||||||
path="/",
|
|
||||||
max_age=7 * 24 * 60 * 60, # 7 dní
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
username = attrs.get("username")
|
|
||||||
password = attrs.get("password")
|
|
||||||
|
|
||||||
# Přihlaš uživatele ručně
|
|
||||||
user = authenticate(request=self.context.get('request'), username=username, password=password)
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
raise AuthenticationFailed("Špatné uživatelské jméno nebo heslo.")
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise AuthenticationFailed("Uživatel je deaktivován.")
|
|
||||||
|
|
||||||
# Nastav validní uživatele (přebere další logiku ze SimpleJWT)
|
|
||||||
self.user = user
|
|
||||||
|
|
||||||
# Vrátí access a refresh token jako obvykle
|
|
||||||
return super().validate(attrs)
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Authentication"],
|
|
||||||
summary="Refresh JWT token using cookie",
|
|
||||||
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(description="Tokens refreshed successfully."),
|
|
||||||
400: OpenApiResponse(description="Refresh token cookie not found."),
|
|
||||||
401: OpenApiResponse(description="Invalid refresh token."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class CookieTokenRefreshView(APIView):
|
|
||||||
def post(self, request):
|
|
||||||
refresh_token = request.COOKIES.get('refresh_token')
|
|
||||||
if not refresh_token:
|
|
||||||
return Response({"detail": "Refresh token cookie not found."}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
refresh = RefreshToken(refresh_token)
|
|
||||||
access_token = str(refresh.access_token)
|
|
||||||
new_refresh_token = str(refresh) # volitelně nový refresh token
|
|
||||||
|
|
||||||
response = Response({
|
|
||||||
"access": access_token,
|
|
||||||
"refresh": new_refresh_token,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Nastav nové HttpOnly cookies
|
|
||||||
# Access token cookie (např. 5 minut platnost)
|
|
||||||
response.set_cookie(
|
|
||||||
"access_token",
|
|
||||||
access_token,
|
|
||||||
httponly=True,
|
|
||||||
secure=not settings.DEBUG,
|
|
||||||
samesite="Lax",
|
|
||||||
max_age=5 * 60,
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Refresh token cookie (delší platnost, např. 7 dní)
|
|
||||||
response.set_cookie(
|
|
||||||
"refresh_token",
|
|
||||||
new_refresh_token,
|
|
||||||
httponly=True,
|
|
||||||
secure=not settings.DEBUG,
|
|
||||||
samesite="Lax",
|
|
||||||
max_age=7 * 24 * 60 * 60,
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except TokenError:
|
|
||||||
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
#---------------------------------------------LOGOUT------------------------------------------------
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["Authentication"],
|
|
||||||
summary="Logout user (delete access and refresh token cookies)",
|
|
||||||
description="Logs out the user by deleting access and refresh token cookies.",
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(description="Logout successful."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class LogoutView(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
# Smazání cookies
|
|
||||||
response.delete_cookie("access_token", path="/")
|
|
||||||
response.delete_cookie("refresh_token", path="/")
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
#--------------------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User"],
|
|
||||||
summary="List, retrieve, update, and delete users.",
|
|
||||||
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(response=CustomUserSerializer, description="User(s) retrieved successfully."),
|
|
||||||
403: OpenApiResponse(description="Permission denied."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class UserView(viewsets.ModelViewSet):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = CustomUserSerializer
|
|
||||||
filter_backends = [DjangoFilterBackend]
|
|
||||||
filterset_class = UserFilter
|
|
||||||
|
|
||||||
# Require authentication and role permission
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = CustomUser
|
|
||||||
extra_kwargs = {
|
|
||||||
"email": {"help_text": "Unikátní e-mailová adresa uživatele."},
|
|
||||||
"phone_number": {"help_text": "Telefonní číslo ve formátu +420123456789."},
|
|
||||||
"role": {"help_text": "Role uživatele určující jeho oprávnění v systému."},
|
|
||||||
"account_type": {"help_text": "Typ účtu – firma nebo fyzická osoba."},
|
|
||||||
"email_verified": {"help_text": "Určuje, zda je e-mail ověřen."},
|
|
||||||
"create_time": {"help_text": "Datum a čas registrace uživatele (pouze pro čtení).", "read_only": True},
|
|
||||||
"var_symbol": {"help_text": "Variabilní symbol pro platby, pokud je vyžadován."},
|
|
||||||
"bank_account": {"help_text": "Číslo bankovního účtu uživatele."},
|
|
||||||
"ICO": {"help_text": "IČO firmy, pokud se jedná o firemní účet."},
|
|
||||||
"RC": {"help_text": "Rodné číslo pro fyzické osoby."},
|
|
||||||
"city": {"help_text": "Město trvalého pobytu / sídla."},
|
|
||||||
"street": {"help_text": "Ulice a číslo popisné."},
|
|
||||||
"PSC": {"help_text": "PSČ místa pobytu / sídla."},
|
|
||||||
"GDPR": {"help_text": "Souhlas se zpracováním osobních údajů."},
|
|
||||||
"is_active": {"help_text": "Stav aktivace uživatele."},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_permissions(self):
|
|
||||||
# Only admin can list or create users
|
|
||||||
if self.action in ['list', 'create']:
|
|
||||||
return [OnlyRolesAllowed("admin")()]
|
|
||||||
|
|
||||||
# Only admin or the user themselves can update or delete
|
|
||||||
elif self.action in ['update', 'partial_update', 'destroy']:
|
|
||||||
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
|
|
||||||
# Admins can modify any user
|
|
||||||
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
|
|
||||||
return [OnlyRolesAllowed("admin")()]
|
|
||||||
|
|
||||||
# Users can modify their own record
|
|
||||||
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
|
|
||||||
return [IsAuthenticated()]
|
|
||||||
|
|
||||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
|
||||||
return [OnlyRolesAllowed("admin")()]
|
|
||||||
|
|
||||||
# Any authenticated user can retrieve (view) any user's profile
|
|
||||||
elif self.action == 'retrieve':
|
|
||||||
return [IsAuthenticated()]
|
|
||||||
|
|
||||||
return super().get_permissions()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Get current user data
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User"],
|
|
||||||
summary="Get current authenticated user",
|
|
||||||
description="Returns details of the currently authenticated user based on JWT token or session.",
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(response=CustomUserSerializer, description="Current user details."),
|
|
||||||
401: OpenApiResponse(description="Unauthorized, user is not authenticated."),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
class CurrentUserView(APIView):
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
serializer = CustomUserSerializer(request.user)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
|
|
||||||
#------------------------------------------------REGISTRACE--------------------------------------------------------------
|
|
||||||
|
|
||||||
#1. registration API
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User Registration"],
|
|
||||||
summary="Register a new user (company or individual)",
|
|
||||||
description="Register a new user (company or individual). The user will receive an email with a verification link.",
|
|
||||||
request=UserRegistrationSerializer,
|
|
||||||
responses={
|
|
||||||
201: OpenApiResponse(response=UserRegistrationSerializer, description="User registered successfully."),
|
|
||||||
400: OpenApiResponse(description="Invalid registration data."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class UserRegistrationViewSet(ModelViewSet):
|
|
||||||
queryset = CustomUser.objects.all()
|
|
||||||
serializer_class = UserRegistrationSerializer
|
|
||||||
http_method_names = ['post']
|
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
user = serializer.save()
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email_verification_task.delay(user.id) # posílaní emailu pro potvrzení registrace - CELERY TASK
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Celery not available, using fallback. Error: {e}")
|
|
||||||
send_email_verification_task(user.id) # posílaní emailu pro potvrzení registrace
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
headers = self.get_success_headers(serializer.data)
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
|
||||||
|
|
||||||
#2. confirming email
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User Registration"],
|
|
||||||
summary="Verify user email via link",
|
|
||||||
description="Verify user email using the link with uid and token.",
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH, description="User ID encoded in base64 from email link."),
|
|
||||||
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH, description="User token from email link."),
|
|
||||||
],
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(description="Email successfully verified."),
|
|
||||||
400: OpenApiResponse(description="Invalid or expired token."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class EmailVerificationView(APIView):
|
|
||||||
def get(self, request, uidb64, token):
|
|
||||||
try:
|
|
||||||
uid = force_str(urlsafe_base64_decode(uidb64))
|
|
||||||
user = User.objects.get(pk=uid)
|
|
||||||
except (User.DoesNotExist, ValueError, TypeError):
|
|
||||||
return Response({"error": "Neplatný odkaz."}, status=400)
|
|
||||||
|
|
||||||
if account_activation_token.check_token(user, token):
|
|
||||||
user.email_verified = True
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
|
|
||||||
else:
|
|
||||||
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
|
||||||
|
|
||||||
#3. seller activation API (var_symbol)
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User Registration"],
|
|
||||||
summary="Activate user and set variable symbol (admin/cityClerk only)",
|
|
||||||
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
|
|
||||||
request=UserActivationSerializer,
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
|
|
||||||
400: OpenApiResponse(description="Invalid activation data."),
|
|
||||||
404: OpenApiResponse(description="User not found."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class UserActivationViewSet(APIView):
|
|
||||||
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
|
|
||||||
|
|
||||||
def patch(self, request, *args, **kwargs):
|
|
||||||
serializer = UserActivationSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
user = serializer.save()
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Celery not available, using fallback. Error: {e}")
|
|
||||||
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
|
|
||||||
|
|
||||||
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
|
||||||
|
|
||||||
#1. PasswordReset + send Email
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User password reset"],
|
|
||||||
summary="Request password reset (send email)",
|
|
||||||
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
|
||||||
request=PasswordResetRequestSerializer,
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(description="Email with instructions sent."),
|
|
||||||
400: OpenApiResponse(description="Invalid email or request data."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class PasswordResetRequestView(APIView):
|
|
||||||
def post(self, request):
|
|
||||||
serializer = PasswordResetRequestSerializer(data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
try:
|
|
||||||
user = User.objects.get(email=serializer.validated_data['email'])
|
|
||||||
except User.DoesNotExist:
|
|
||||||
# Always return 200 even if user doesn't exist to avoid user enumeration
|
|
||||||
return Response({"detail": "E-mail s odkazem byl odeslán."})
|
|
||||||
try:
|
|
||||||
send_password_reset_email_task.delay(user.id) # posílaní emailu pro obnovení hesla - CELERY TASK
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Celery not available, using fallback. Error: {e}")
|
|
||||||
send_password_reset_email_task(user.id) # posílaní emailu pro obnovení hesla registrace
|
|
||||||
|
|
||||||
return Response({"detail": "E-mail s odkazem byl odeslán."})
|
|
||||||
|
|
||||||
return Response(serializer.errors, status=400)
|
|
||||||
|
|
||||||
#2. Confirming reset
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User password reset"],
|
|
||||||
summary="Confirm password reset via token",
|
|
||||||
description="Confirm password reset using token from email.",
|
|
||||||
request=PasswordResetConfirmSerializer,
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH, description="User ID encoded in base64 from email link."),
|
|
||||||
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH, description="Password reset token from email link."),
|
|
||||||
],
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(description="Password changed successfully."),
|
|
||||||
400: OpenApiResponse(description="Invalid token or request data."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class PasswordResetConfirmView(APIView):
|
|
||||||
def post(self, request, uidb64, token):
|
|
||||||
try:
|
|
||||||
uid = force_str(urlsafe_base64_decode(uidb64))
|
|
||||||
user = User.objects.get(pk=uid)
|
|
||||||
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
|
|
||||||
return Response({"error": "Neplatný odkaz."}, status=400)
|
|
||||||
|
|
||||||
if not password_reset_token.check_token(user, token):
|
|
||||||
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
|
||||||
|
|
||||||
serializer = PasswordResetConfirmSerializer(data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
user.set_password(serializer.validated_data['password'])
|
|
||||||
user.save()
|
|
||||||
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
|
||||||
return Response(serializer.errors, status=400)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AdvertisementConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'advertisement'
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
#udělat zasílaní reklamních emailů uživatelům.
|
|
||||||
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import Carrier, Product
|
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Carrier)
|
|
||||||
class CarrierAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "base_price", "is_active")
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "price", "currency", "stock", "is_active")
|
|
||||||
search_fields = ("name", "description")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class CommerceConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'commerce'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Carrier',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=100)),
|
|
||||||
('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
|
||||||
('delivery_time', models.CharField(blank=True, max_length=100)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
|
|
||||||
('external_id', models.CharField(blank=True, max_length=50, null=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Product',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=200)),
|
|
||||||
('description', models.TextField(blank=True)),
|
|
||||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
|
||||||
('currency', models.CharField(default='czk', max_length=10)),
|
|
||||||
('stock', models.PositiveIntegerField(default=0)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
class Product(models.Model):
|
|
||||||
name = models.CharField(max_length=200)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
currency = models.CharField(max_length=10, default="czk")
|
|
||||||
stock = models.PositiveIntegerField(default=0)
|
|
||||||
is_active = models.BooleanField(default=True)
|
|
||||||
default_carrier = models.ForeignKey(
|
|
||||||
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self):
|
|
||||||
return self.is_active and self.stock > 0
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} ({self.price} {self.currency.upper()})"
|
|
||||||
|
|
||||||
|
|
||||||
# Dopravci a způsoby dopravy
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
class Carrier(models.Model):
|
|
||||||
name = models.CharField(max_length=100) # název dopravce (Zásilkovna, Česká pošta…)
|
|
||||||
base_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) # základní cena dopravy
|
|
||||||
delivery_time = models.CharField(max_length=100, blank=True) # např. "2–3 pracovní dny"
|
|
||||||
is_active = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
# pole pro logo
|
|
||||||
logo = models.ImageField(upload_to="carriers/", blank=True, null=True)
|
|
||||||
|
|
||||||
# pole pro propojení s externím API (např. ID služby u Zásilkovny)
|
|
||||||
external_id = models.CharField(max_length=50, blank=True, null=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} ({self.base_price} Kč)"
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from .models import Carrier
|
|
||||||
|
|
||||||
class CarrierSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Carrier
|
|
||||||
fields = [
|
|
||||||
"id", "name", "base_price", "delivery_time",
|
|
||||||
"is_active", "logo", "external_id"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
from .models import Product, Carrier, Order
|
|
||||||
|
|
||||||
|
|
||||||
class ProductSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Product
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class CarrierSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = Carrier
|
|
||||||
fields = "__all__"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# -- BASE --
|
|
||||||
requests
|
|
||||||
|
|
||||||
pip
|
|
||||||
python-dotenv # .env support
|
|
||||||
virtualenv #venv
|
|
||||||
|
|
||||||
Django
|
|
||||||
|
|
||||||
numpy # NumPy je knihovna programovacího jazyka Python, která poskytuje infrastrukturu pro práci s vektory, maticemi a obecně vícerozměrnými poli.
|
|
||||||
|
|
||||||
|
|
||||||
# -- DATABASE --
|
|
||||||
sqlparse #non-validating SQL parser for Python. It provides support for parsing, splitting and formatting SQL statements.
|
|
||||||
tzdata #timezone
|
|
||||||
|
|
||||||
|
|
||||||
psycopg[binary] #PostgreSQL database adapter for the Python
|
|
||||||
|
|
||||||
django-filter
|
|
||||||
|
|
||||||
django-constance #allows you to store and manage settings of page in the Django admin interface!!!!
|
|
||||||
|
|
||||||
# -- OBJECT STORAGE --
|
|
||||||
Pillow #adds image processing capabilities to your Python interpreter
|
|
||||||
|
|
||||||
whitenoise #pomáha se spuštěním serveru a načítaní static files
|
|
||||||
|
|
||||||
|
|
||||||
django-cleanup #odstraní zbytečné media soubory které nejsou v databázi/modelu
|
|
||||||
django-storages # potřeba k S3 bucket storage
|
|
||||||
boto3
|
|
||||||
|
|
||||||
|
|
||||||
# -- PROTOCOLS (asgi, websockets) --
|
|
||||||
redis
|
|
||||||
|
|
||||||
channels_redis
|
|
||||||
|
|
||||||
channels #django channels
|
|
||||||
|
|
||||||
#channels requried package
|
|
||||||
uvicorn[standard]
|
|
||||||
daphne
|
|
||||||
|
|
||||||
gunicorn
|
|
||||||
|
|
||||||
# -- REST API --
|
|
||||||
djangorestframework #REST Framework
|
|
||||||
|
|
||||||
djangorestframework-api-key #API key
|
|
||||||
|
|
||||||
djangorestframework-simplejwt #JWT authentication for Django REST Framework
|
|
||||||
PyJWT #JSON Web Token implementation in Python
|
|
||||||
|
|
||||||
asgiref #ASGI reference implementation, to be used with Django Channels
|
|
||||||
pytz
|
|
||||||
# pytz brings the Olson tz database into Python and allows
|
|
||||||
# accurate and cross platform timezone calculations.
|
|
||||||
# It also solves the issue of ambiguous times at the end of daylight saving time.
|
|
||||||
|
|
||||||
#documentation for frontend dev
|
|
||||||
drf-spectacular
|
|
||||||
|
|
||||||
# -- APPS --
|
|
||||||
|
|
||||||
django-tinymce
|
|
||||||
|
|
||||||
django-cors-headers #csfr
|
|
||||||
|
|
||||||
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
|
|
||||||
django-celery-beat #slouží k plánování úkolů pro Celery
|
|
||||||
|
|
||||||
|
|
||||||
# -- EDITING photos, gifs, videos --
|
|
||||||
|
|
||||||
#aiofiles
|
|
||||||
#opencv-python #moviepy use this better instead of pillow
|
|
||||||
#moviepy
|
|
||||||
|
|
||||||
yt-dlp
|
|
||||||
|
|
||||||
weasyprint #tvoření PDFek z html dokumentu + css styly
|
|
||||||
|
|
||||||
## -- MISCELLANEOUS --
|
|
||||||
|
|
||||||
faker #generates fake data for testing purposes
|
|
||||||
|
|
||||||
## -- api --
|
|
||||||
stripe
|
|
||||||
gopay
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ChatConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'social.chat'
|
|
||||||
|
|
||||||
label = "chat"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# chat/consumers.py
|
|
||||||
import json
|
|
||||||
|
|
||||||
from account.models import UserProfile
|
|
||||||
|
|
||||||
from channels.db import database_sync_to_async
|
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
||||||
|
|
||||||
|
|
||||||
class ChatConsumer(AsyncWebsocketConsumer):
|
|
||||||
async def connect(self):
|
|
||||||
await self.accept()
|
|
||||||
|
|
||||||
async def disconnect(self, close_code):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def receive(self, text_data):
|
|
||||||
text_data_json = json.loads(text_data)
|
|
||||||
message = text_data_json["message"]
|
|
||||||
|
|
||||||
await self.send(text_data=json.dumps({"message": message}))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@database_sync_to_async
|
|
||||||
def get_user_profile(user_id):
|
|
||||||
return UserProfile.objects.get(pk=user_id)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# chat/routing.py
|
|
||||||
from django.urls import re_path
|
|
||||||
|
|
||||||
from . import consumers
|
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
|
||||||
re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
|
|
||||||
]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
10
backend/thirdparty/downloader/admin.py
vendored
10
backend/thirdparty/downloader/admin.py
vendored
@@ -1,10 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import DownloaderRecord
|
|
||||||
|
|
||||||
@admin.register(DownloaderRecord)
|
|
||||||
class DownloaderRecordAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("id", "url", "format", "length_of_media", "file_size", "download_time")
|
|
||||||
list_filter = ("format",)
|
|
||||||
search_fields = ("url",)
|
|
||||||
ordering = ("-download_time",)
|
|
||||||
readonly_fields = ("download_time",)
|
|
||||||
10
backend/thirdparty/downloader/apps.py
vendored
10
backend/thirdparty/downloader/apps.py
vendored
@@ -1,10 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 14:53
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='DownloaderRecord',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('is_deleted', models.BooleanField(default=False)),
|
|
||||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
|
||||||
('url', models.URLField()),
|
|
||||||
('download_time', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('format', models.CharField(max_length=50)),
|
|
||||||
('length_of_media', models.IntegerField(help_text='Length of media in seconds')),
|
|
||||||
('file_size', models.BigIntegerField(help_text='File size in bytes')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
15
backend/thirdparty/downloader/models.py
vendored
15
backend/thirdparty/downloader/models.py
vendored
@@ -1,15 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.conf import settings
|
|
||||||
from vontor_cz.models import SoftDeleteModel
|
|
||||||
|
|
||||||
# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu
|
|
||||||
class DownloaderRecord(SoftDeleteModel):
|
|
||||||
url = models.URLField()
|
|
||||||
download_time = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
format = models.CharField(max_length=50)
|
|
||||||
|
|
||||||
length_of_media = models.IntegerField(help_text="Length of media in seconds")
|
|
||||||
file_size = models.BigIntegerField(help_text="File size in bytes")
|
|
||||||
|
|
||||||
|
|
||||||
9
backend/thirdparty/downloader/serializers.py
vendored
9
backend/thirdparty/downloader/serializers.py
vendored
@@ -1,9 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
class DownloaderStatsSerializer(serializers.Serializer):
|
|
||||||
total_downloads = serializers.IntegerField()
|
|
||||||
avg_length_of_media = serializers.FloatField(allow_null=True)
|
|
||||||
avg_file_size = serializers.FloatField(allow_null=True)
|
|
||||||
total_length_of_media = serializers.IntegerField(allow_null=True)
|
|
||||||
total_file_size = serializers.IntegerField(allow_null=True)
|
|
||||||
most_common_format = serializers.CharField(allow_null=True)
|
|
||||||
3
backend/thirdparty/downloader/tests.py
vendored
3
backend/thirdparty/downloader/tests.py
vendored
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
9
backend/thirdparty/downloader/urls.py
vendored
9
backend/thirdparty/downloader/urls.py
vendored
@@ -1,9 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .views import Downloader, DownloaderStats
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# Probe formats for a URL (size-checked)
|
|
||||||
path("download/", Downloader.as_view(), name="downloader-download"),
|
|
||||||
|
|
||||||
path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
|
|
||||||
]
|
|
||||||
305
backend/thirdparty/downloader/views.py
vendored
305
backend/thirdparty/downloader/views.py
vendored
@@ -1,305 +0,0 @@
|
|||||||
# ---------------------- Inline serializers for documentation only ----------------------
|
|
||||||
# Using inline_serializer to avoid creating new files.
|
|
||||||
|
|
||||||
import yt_dlp
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import StreamingHttpResponse
|
|
||||||
from django.utils.text import slugify
|
|
||||||
# NEW: aggregations and timeseries helpers
|
|
||||||
from django.db import models
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.db.models.functions import TruncDay, TruncHour
|
|
||||||
from .models import DownloaderRecord
|
|
||||||
|
|
||||||
# Allowed container formats for output/remux
|
|
||||||
FORMAT_CHOICES = ("mp4", "mkv", "webm", "flv", "mov", "avi", "ogg")
|
|
||||||
FORMAT_HELP = (
|
|
||||||
"Choose container format: "
|
|
||||||
"mp4 (H.264 + AAC, most compatible), "
|
|
||||||
"mkv (flexible, lossless container), "
|
|
||||||
"webm (VP9/AV1 + Opus), "
|
|
||||||
"flv (legacy), mov (Apple-friendly), "
|
|
||||||
"avi (older), ogg (mostly obsolete)."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Minimal mime map by extension
|
|
||||||
MIME_BY_EXT = {
|
|
||||||
"mp4": "video/mp4",
|
|
||||||
"mkv": "video/x-matroska",
|
|
||||||
"webm": "video/webm",
|
|
||||||
"flv": "video/x-flv",
|
|
||||||
"mov": "video/quicktime",
|
|
||||||
"avi": "video/x-msvideo",
|
|
||||||
"ogg": "video/ogg",
|
|
||||||
}
|
|
||||||
|
|
||||||
class Downloader(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
authentication_classes = []
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["downloader"],
|
|
||||||
summary="Get video info from URL",
|
|
||||||
parameters=[
|
|
||||||
inline_serializer(
|
|
||||||
name="VideoInfoParams",
|
|
||||||
fields={
|
|
||||||
"url": serializers.URLField(help_text="Video URL to analyze"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
responses={
|
|
||||||
200: inline_serializer(
|
|
||||||
name="VideoInfoResponse",
|
|
||||||
fields={
|
|
||||||
"title": serializers.CharField(),
|
|
||||||
"duration": serializers.IntegerField(allow_null=True),
|
|
||||||
"thumbnail": serializers.URLField(allow_null=True),
|
|
||||||
"video_resolutions": serializers.ListField(child=serializers.CharField()),
|
|
||||||
"audio_resolutions": serializers.ListField(child=serializers.CharField()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
400: inline_serializer(
|
|
||||||
name="ErrorResponse",
|
|
||||||
fields={"error": serializers.CharField()},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
url = request.data.get("url") or request.query_params.get("url")
|
|
||||||
if not url:
|
|
||||||
return Response({"error": "URL is required"}, status=400)
|
|
||||||
|
|
||||||
ydl_options = {
|
|
||||||
"quiet": True,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
|
||||||
info = ydl.extract_info(url, download=False)
|
|
||||||
except Exception:
|
|
||||||
return Response({"error": "Failed to retrieve video info"}, status=400)
|
|
||||||
|
|
||||||
formats = info.get("formats", []) or []
|
|
||||||
|
|
||||||
# Video: collect unique heights and sort desc
|
|
||||||
heights = {
|
|
||||||
int(f.get("height"))
|
|
||||||
for f in formats
|
|
||||||
if f.get("vcodec") != "none" and isinstance(f.get("height"), int)
|
|
||||||
}
|
|
||||||
video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)]
|
|
||||||
|
|
||||||
# Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing
|
|
||||||
bitrates = set()
|
|
||||||
for f in formats:
|
|
||||||
if f.get("acodec") != "none" and f.get("vcodec") == "none":
|
|
||||||
abr = f.get("abr")
|
|
||||||
tbr = f.get("tbr")
|
|
||||||
val = None
|
|
||||||
if isinstance(abr, (int, float)):
|
|
||||||
val = int(abr)
|
|
||||||
elif isinstance(tbr, (int, float)):
|
|
||||||
val = int(tbr)
|
|
||||||
if val and val > 0:
|
|
||||||
bitrates.add(val)
|
|
||||||
|
|
||||||
audio_resolutions = [f"{b}kbps" for b in sorted(bitrates, reverse=True)]
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"title": info.get("title"),
|
|
||||||
"duration": info.get("duration"),
|
|
||||||
"thumbnail": info.get("thumbnail"),
|
|
||||||
"video_resolutions": video_resolutions,
|
|
||||||
"audio_resolutions": audio_resolutions,
|
|
||||||
},
|
|
||||||
status=200,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["downloader"],
|
|
||||||
summary="Download video from URL",
|
|
||||||
request=inline_serializer(
|
|
||||||
name="DownloadRequest",
|
|
||||||
fields={
|
|
||||||
"url": serializers.URLField(help_text="Video URL to download"),
|
|
||||||
"ext": serializers.ChoiceField(
|
|
||||||
choices=FORMAT_CHOICES,
|
|
||||||
required=False,
|
|
||||||
default="mp4",
|
|
||||||
help_text=FORMAT_HELP,
|
|
||||||
),
|
|
||||||
"format": serializers.ChoiceField(
|
|
||||||
choices=FORMAT_CHOICES,
|
|
||||||
required=False,
|
|
||||||
help_text="Alias of 'ext' (deprecated)."
|
|
||||||
),
|
|
||||||
"video_quality": serializers.IntegerField(
|
|
||||||
required=True,
|
|
||||||
help_text="Target max video height (e.g. 1080)."
|
|
||||||
),
|
|
||||||
"audio_quality": serializers.IntegerField(
|
|
||||||
required=True,
|
|
||||||
help_text="Target max audio bitrate in kbps (e.g. 160)."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
responses={
|
|
||||||
200: OpenApiTypes.BINARY,
|
|
||||||
400: inline_serializer(
|
|
||||||
name="DownloadErrorResponse",
|
|
||||||
fields={
|
|
||||||
"error": serializers.CharField(),
|
|
||||||
"allowed": serializers.ListField(child=serializers.CharField(), required=False),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
url = request.data.get("url")
|
|
||||||
# Accept ext or legacy format param
|
|
||||||
ext = (request.data.get("ext") or request.data.get("format") or "mp4").lower()
|
|
||||||
|
|
||||||
try:
|
|
||||||
video_quality = int(request.data.get("video_quality")) # height, e.g., 1080
|
|
||||||
audio_quality = int(request.data.get("audio_quality")) # abr kbps, e.g., 160
|
|
||||||
except Exception:
|
|
||||||
return Response({"error": "Invalid quality parameters, not integers!"}, status=400)
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return Response({"error": "URL is required"}, status=400)
|
|
||||||
if ext not in FORMAT_CHOICES:
|
|
||||||
return Response({"error": f"Unsupported extension '{ext}'", "allowed": FORMAT_CHOICES}, status=400)
|
|
||||||
|
|
||||||
# Ensure base tmp dir exists
|
|
||||||
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
|
|
||||||
tmpdir = tempfile.mkdtemp(prefix="downloader_", dir=settings.DOWNLOADER_TMP_DIR)
|
|
||||||
outtmpl = os.path.join(tmpdir, "download.%(ext)s")
|
|
||||||
|
|
||||||
# Build a format selector using requested quality caps
|
|
||||||
# Example: "bv[height<=1080]+ba[abr<=160]/b"
|
|
||||||
video_part = f"bv[height<={video_quality}]" if video_quality else "bv*"
|
|
||||||
audio_part = f"ba[abr<={audio_quality}]" if audio_quality else "ba"
|
|
||||||
|
|
||||||
format_selector = f"{video_part}+{audio_part}/b"
|
|
||||||
|
|
||||||
|
|
||||||
ydl_options = {
|
|
||||||
"format": format_selector, # select by requested quality
|
|
||||||
"merge_output_format": ext, # container
|
|
||||||
"outtmpl": outtmpl, # temp dir
|
|
||||||
"quiet": True,
|
|
||||||
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
|
|
||||||
"socket_timeout": settings.DOWNLOADER_TIMEOUT,
|
|
||||||
# remux to container without re-encoding where possible
|
|
||||||
"postprocessors": [
|
|
||||||
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
file_path = ""
|
|
||||||
try:
|
|
||||||
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
|
||||||
info = ydl.extract_info(url, download=True)
|
|
||||||
base = ydl.prepare_filename(info)
|
|
||||||
file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}"
|
|
||||||
|
|
||||||
# Stats before streaming
|
|
||||||
duration = int((info or {}).get("duration") or 0)
|
|
||||||
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
|
||||||
|
|
||||||
DownloaderRecord.objects.create(
|
|
||||||
url=url,
|
|
||||||
format=ext,
|
|
||||||
length_of_media=duration,
|
|
||||||
file_size=size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Streaming generator that deletes file & temp dir after send (or on abort)
|
|
||||||
def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192):
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(chunk_size)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if os.path.exists(path):
|
|
||||||
os.remove(path)
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
safe_title = slugify(info.get("title") or "video")
|
|
||||||
filename = f"{safe_title}.{ext}"
|
|
||||||
content_type = MIME_BY_EXT.get(ext, "application/octet-stream")
|
|
||||||
|
|
||||||
response = StreamingHttpResponse(
|
|
||||||
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
if size:
|
|
||||||
response["Content-Length"] = str(size)
|
|
||||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
return Response({"error": str(e)}, status=400)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- STATS FOR GRAPHS ----------------
|
|
||||||
|
|
||||||
from .serializers import DownloaderStatsSerializer
|
|
||||||
from django.db.models import Count, Avg, Sum
|
|
||||||
|
|
||||||
class DownloaderStats(APIView):
|
|
||||||
"""
|
|
||||||
Vrací agregované statistiky z tabulky DownloaderRecord.
|
|
||||||
"""
|
|
||||||
authentication_classes = []
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
@extend_schema(
|
|
||||||
tags=["downloader"],
|
|
||||||
summary="Get aggregated downloader statistics",
|
|
||||||
responses={200: DownloaderStatsSerializer},
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
# agregace číselných polí
|
|
||||||
agg = DownloaderRecord.objects.aggregate(
|
|
||||||
total_downloads=Count("id"),
|
|
||||||
avg_length_of_media=Avg("length_of_media"),
|
|
||||||
avg_file_size=Avg("file_size"),
|
|
||||||
total_length_of_media=Sum("length_of_media"),
|
|
||||||
total_file_size=Sum("file_size"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# zjištění nejčastějšího formátu
|
|
||||||
most_common = (
|
|
||||||
DownloaderRecord.objects.values("format")
|
|
||||||
.annotate(count=Count("id"))
|
|
||||||
.order_by("-count")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
agg["most_common_format"] = most_common["format"] if most_common else None
|
|
||||||
|
|
||||||
serializer = DownloaderStatsSerializer(agg)
|
|
||||||
return Response(serializer.data)
|
|
||||||
20
backend/thirdparty/gopay/admin.py
vendored
20
backend/thirdparty/gopay/admin.py
vendored
@@ -1,20 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import GoPayPayment, GoPayRefund, GoPaySubscription
|
|
||||||
|
|
||||||
@admin.register(GoPayPayment)
|
|
||||||
class GoPayPaymentAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("gopay_id", "status", "amount", "currency", "user", "created_at")
|
|
||||||
search_fields = ("gopay_id", "order_number", "status")
|
|
||||||
list_filter = ("status", "currency", "preauthorized")
|
|
||||||
|
|
||||||
@admin.register(GoPayRefund)
|
|
||||||
class GoPayRefundAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("payment", "gopay_refund_id", "amount", "status", "created_at")
|
|
||||||
search_fields = ("gopay_refund_id", "payment__gopay_id")
|
|
||||||
list_filter = ("status",)
|
|
||||||
|
|
||||||
@admin.register(GoPaySubscription)
|
|
||||||
class GoPaySubscriptionAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("parent_payment", "recurrence_id", "status", "canceled", "created_at")
|
|
||||||
search_fields = ("recurrence_id", "parent_payment__gopay_id")
|
|
||||||
list_filter = ("status", "canceled")
|
|
||||||
60
backend/thirdparty/gopay/models.py
vendored
60
backend/thirdparty/gopay/models.py
vendored
@@ -1,60 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayPayment(models.Model):
|
|
||||||
# Optional user association
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="gopay_payments"
|
|
||||||
)
|
|
||||||
# External identifiers and core attributes
|
|
||||||
gopay_id = models.CharField(max_length=64, unique=True, db_index=True)
|
|
||||||
order_number = models.CharField(max_length=128, blank=True, default="")
|
|
||||||
amount = models.BigIntegerField(help_text="Amount in minor units (e.g., CZK in haléř).")
|
|
||||||
currency = models.CharField(max_length=10)
|
|
||||||
status = models.CharField(max_length=64, db_index=True, default="")
|
|
||||||
preauthorized = models.BooleanField(default=False)
|
|
||||||
captured_amount = models.BigIntegerField(default=0)
|
|
||||||
|
|
||||||
# Raw payloads for traceability
|
|
||||||
request_payload = models.JSONField(default=dict, blank=True)
|
|
||||||
response_payload = models.JSONField(default=dict, blank=True)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"GoPayPayment(id={self.gopay_id}, status={self.status}, amount={self.amount} {self.currency})"
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayRefund(models.Model):
|
|
||||||
payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, null=True, blank=True, related_name="refunds")
|
|
||||||
gopay_refund_id = models.CharField(max_length=64, blank=True, default="")
|
|
||||||
amount = models.BigIntegerField(help_text="Amount in minor units.")
|
|
||||||
status = models.CharField(max_length=64, blank=True, default="")
|
|
||||||
payload = models.JSONField(default=dict, blank=True)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"GoPayRefund(payment={self.payment_id}, amount={self.amount}, status={self.status})"
|
|
||||||
|
|
||||||
|
|
||||||
class GoPaySubscription(models.Model):
|
|
||||||
parent_payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, related_name="subscriptions")
|
|
||||||
recurrence_id = models.CharField(max_length=64, blank=True, default="")
|
|
||||||
status = models.CharField(max_length=64, blank=True, default="")
|
|
||||||
interval = models.CharField(max_length=64, blank=True, default="")
|
|
||||||
next_payment_on = models.DateTimeField(null=True, blank=True)
|
|
||||||
payload = models.JSONField(default=dict, blank=True)
|
|
||||||
|
|
||||||
canceled = models.BooleanField(default=False)
|
|
||||||
canceled_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"GoPaySubscription(parent={self.parent_payment_id}, status={self.status})"
|
|
||||||
88
backend/thirdparty/gopay/serializers.py
vendored
88
backend/thirdparty/gopay/serializers.py
vendored
@@ -1,88 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
|
|
||||||
class ContactSerializer(serializers.Serializer):
|
|
||||||
email = serializers.EmailField()
|
|
||||||
first_name = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
last_name = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
phone_number = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
city = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
street = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
postal_code = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
country_code = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
|
|
||||||
|
|
||||||
class PayerSerializer(serializers.Serializer):
|
|
||||||
contact = ContactSerializer()
|
|
||||||
|
|
||||||
# Optional controls – keep everything optional, no card numbers allowed (SDK handles UI)
|
|
||||||
allowed_payment_instruments = serializers.ListField(
|
|
||||||
child=serializers.CharField(), required=False
|
|
||||||
)
|
|
||||||
default_payment_instrument = serializers.CharField(required=False)
|
|
||||||
allowed_swifts = serializers.ListField(child=serializers.CharField(), required=False)
|
|
||||||
default_swift = serializers.CharField(required=False)
|
|
||||||
|
|
||||||
|
|
||||||
class CallbackSerializer(serializers.Serializer):
|
|
||||||
return_url = serializers.URLField()
|
|
||||||
notification_url = serializers.URLField(required=False)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
# Default notification_url from settings if not provided
|
|
||||||
if not attrs.get("notification_url"):
|
|
||||||
attrs["notification_url"] = getattr(
|
|
||||||
settings, "GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook"
|
|
||||||
)
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class ItemSerializer(serializers.Serializer):
|
|
||||||
name = serializers.CharField()
|
|
||||||
amount = serializers.IntegerField(min_value=1, help_text="Minor units")
|
|
||||||
type = serializers.CharField(required=False, default="ITEM")
|
|
||||||
count = serializers.IntegerField(required=False, min_value=1, default=1)
|
|
||||||
|
|
||||||
|
|
||||||
class AdditionalParamSerializer(serializers.Serializer):
|
|
||||||
name = serializers.CharField()
|
|
||||||
value = serializers.CharField()
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentBodySerializer(serializers.Serializer):
|
|
||||||
# Minimal required
|
|
||||||
amount = serializers.IntegerField(min_value=1, help_text="Minor units (e.g. 100 CZK = 10000)")
|
|
||||||
currency = serializers.CharField()
|
|
||||||
order_number = serializers.CharField()
|
|
||||||
|
|
||||||
# Optional
|
|
||||||
order_description = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
payer = PayerSerializer()
|
|
||||||
callback = CallbackSerializer()
|
|
||||||
items = ItemSerializer(many=True, required=False)
|
|
||||||
additional_params = AdditionalParamSerializer(many=True, required=False)
|
|
||||||
lang = serializers.CharField(required=False)
|
|
||||||
preauthorize = serializers.BooleanField(required=False)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
# Explicitly reject any card details if someone tries to sneak them in
|
|
||||||
forbidden_keys = {"card", "card_data", "pan", "cvv", "expiry", "card_number"}
|
|
||||||
for key in list(attrs.keys()):
|
|
||||||
if key in forbidden_keys:
|
|
||||||
raise serializers.ValidationError({key: "Card details must not be sent to the server."})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentCreateSerializer(serializers.Serializer):
|
|
||||||
# Frontend posts { payment: { ... } }
|
|
||||||
payment = PaymentBodySerializer()
|
|
||||||
|
|
||||||
# Optional: local metadata only (not sent to GoPay)
|
|
||||||
user_id = serializers.IntegerField(required=False)
|
|
||||||
|
|
||||||
|
|
||||||
class RefundSerializer(serializers.Serializer):
|
|
||||||
amount = serializers.IntegerField(min_value=1, help_text="Minor units")
|
|
||||||
3
backend/thirdparty/gopay/tests.py
vendored
3
backend/thirdparty/gopay/tests.py
vendored
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
12
backend/thirdparty/gopay/urls.py
vendored
12
backend/thirdparty/gopay/urls.py
vendored
@@ -1,12 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
app_name = "gopay"
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# Payments
|
|
||||||
path("create/", views.CreatePaymentView.as_view(), name="create"),
|
|
||||||
path("<str:payment_id>/status/", views.PaymentStatusView.as_view(), name="status"),
|
|
||||||
path("<str:payment_id>/refund/", views.RefundPaymentView.as_view(), name="refund"),
|
|
||||||
]
|
|
||||||
242
backend/thirdparty/gopay/views.py
vendored
242
backend/thirdparty/gopay/views.py
vendored
@@ -1,242 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
# Tato aplikace poskytuje samostatné HTTP API pro GoPay, připravené k připojení do e‑shopu.
|
|
||||||
# Pohledy volají GoPay SDK a ukládají minimální data o životním cyklu (platby/refundy).
|
|
||||||
# Koncové body jsou dokumentovány pro Swagger/Redoc pomocí drf-spectacular.
|
|
||||||
|
|
||||||
from rest_framework import permissions, status
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from drf_spectacular.utils import (
|
|
||||||
extend_schema,
|
|
||||||
OpenApiParameter,
|
|
||||||
OpenApiResponse,
|
|
||||||
OpenApiExample,
|
|
||||||
OpenApiTypes,
|
|
||||||
)
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
import gopay
|
|
||||||
from gopay.enums import TokenScope, Language
|
|
||||||
|
|
||||||
|
|
||||||
def payments_client():
|
|
||||||
"""Create a fresh GoPay payments client from settings.
|
|
||||||
|
|
||||||
Keeps it simple and explicit; no shared global state.
|
|
||||||
"""
|
|
||||||
required = [
|
|
||||||
("GOPAY_GOID", settings.GOPAY_GOID),
|
|
||||||
("GOPAY_CLIENT_ID", settings.GOPAY_CLIENT_ID),
|
|
||||||
("GOPAY_CLIENT_SECRET", settings.GOPAY_CLIENT_SECRET),
|
|
||||||
("GOPAY_GATEWAY_URL", settings.GOPAY_GATEWAY_URL),
|
|
||||||
]
|
|
||||||
missing = [name for name, val in required if not val]
|
|
||||||
if missing:
|
|
||||||
raise ImproperlyConfigured(f"Missing GoPay settings: {', '.join(missing)}")
|
|
||||||
|
|
||||||
cfg = {
|
|
||||||
"goid": settings.GOPAY_GOID,
|
|
||||||
"client_id": settings.GOPAY_CLIENT_ID,
|
|
||||||
"client_secret": settings.GOPAY_CLIENT_SECRET,
|
|
||||||
"gateway_url": settings.GOPAY_GATEWAY_URL,
|
|
||||||
# reasonable defaults; can be changed later via settings if desired
|
|
||||||
"scope": TokenScope.ALL,
|
|
||||||
"language": Language.CZECH,
|
|
||||||
}
|
|
||||||
return gopay.payments(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
from .models import GoPayPayment, GoPayRefund
|
|
||||||
from .serializers import (
|
|
||||||
PaymentCreateSerializer,
|
|
||||||
RefundSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePaymentView(APIView):
|
|
||||||
"""Vytvoří novou platbu v GoPay a uloží odpověď lokálně.
|
|
||||||
|
|
||||||
Typický e‑shop flow:
|
|
||||||
- Frontend zavolá tento endpoint s GoPay payloadem.
|
|
||||||
- Backend předá payload GoPay SDK a vrátí upstream odpověď.
|
|
||||||
- Vznikne lokální záznam GoPayPayment pro pozdější sledování stavu.
|
|
||||||
"""
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["GoPay"],
|
|
||||||
operation_id="gopay_create_payment",
|
|
||||||
summary="Vytvořit platbu (minimální vstup)",
|
|
||||||
description=(
|
|
||||||
"Vytvoří platbu v GoPay s minimálními povinnými poli. Citlivé údaje o kartě se neposílají,"
|
|
||||||
" platbu obslouží GoPay stránka (gw_url). Pokud není zadán notification_url, použije se"
|
|
||||||
" hodnota ze settings.GOPAY_NOTIFICATION_URL."
|
|
||||||
),
|
|
||||||
request=PaymentCreateSerializer,
|
|
||||||
responses={
|
|
||||||
201: OpenApiResponse(
|
|
||||||
response=OpenApiTypes.OBJECT,
|
|
||||||
description="Platba vytvořena. Vrací gw_url pro přesměrování na platební bránu."
|
|
||||||
),
|
|
||||||
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
|
||||||
},
|
|
||||||
examples=[
|
|
||||||
OpenApiExample(
|
|
||||||
"Minimální platba",
|
|
||||||
value={
|
|
||||||
"payment": {
|
|
||||||
"amount": 10000,
|
|
||||||
"currency": "CZK",
|
|
||||||
"order_number": "123456",
|
|
||||||
"payer": {"contact": {"email": "john.doe@example.com"}},
|
|
||||||
"callback": {
|
|
||||||
"return_url": "https://example.com/your-return-url",
|
|
||||||
"notification_url": "https://example.com/your-notify-url"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
ser = PaymentCreateSerializer(data=request.data)
|
|
||||||
ser.is_valid(raise_exception=True)
|
|
||||||
payload = ser.validated_data["payment"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = payments_client().create_payment(payload) # Expecting dict-like/dict response
|
|
||||||
except Exception as e:
|
|
||||||
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
# Map fields defensively
|
|
||||||
as_dict = res if isinstance(res, dict) else getattr(res, "__dict__", {}) or {}
|
|
||||||
gopay_id = str(as_dict.get("id", ""))
|
|
||||||
status_text = str(as_dict.get("state", ""))
|
|
||||||
amount = int(as_dict.get("amount", payload.get("amount", 0)) or 0)
|
|
||||||
currency = as_dict.get("currency", payload.get("currency", ""))
|
|
||||||
gw_url = as_dict.get("gw_url") or as_dict.get("gwUrl") or as_dict.get("gw-url")
|
|
||||||
|
|
||||||
payment = GoPayPayment.objects.create(
|
|
||||||
user=request.user if request.user and request.user.is_authenticated else None,
|
|
||||||
gopay_id=gopay_id or payload.get("id", ""),
|
|
||||||
order_number=str(payload.get("order_number", "")),
|
|
||||||
amount=amount,
|
|
||||||
currency=currency or "",
|
|
||||||
status=status_text,
|
|
||||||
preauthorized=bool(payload.get("preauthorize", False)),
|
|
||||||
request_payload=payload,
|
|
||||||
response_payload=as_dict or {"raw": str(res)},
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
{
|
|
||||||
"payment_id": payment.gopay_id,
|
|
||||||
"state": payment.status,
|
|
||||||
"gw_url": gw_url,
|
|
||||||
"raw": payment.response_payload,
|
|
||||||
},
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentStatusView(APIView):
|
|
||||||
"""Načte aktuální stav platby GoPay a případně synchronizuje lokální záznam."""
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["GoPay"],
|
|
||||||
operation_id="gopay_get_status",
|
|
||||||
summary="Získat stav platby",
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(
|
|
||||||
name="payment_id",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
location=OpenApiParameter.PATH,
|
|
||||||
description="ID platby GoPay",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(OpenApiTypes.OBJECT, description="Aktuální stav platby"),
|
|
||||||
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def get(self, request, payment_id: str | int):
|
|
||||||
try:
|
|
||||||
res = payments_client().get_status(payment_id)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
# Normalize GoPay SDK response into a dict body
|
|
||||||
body = None
|
|
||||||
if hasattr(res, "success") and hasattr(res, "json"):
|
|
||||||
# Official SDK-style: check success and extract JSON body
|
|
||||||
ok = bool(getattr(res, "success", False))
|
|
||||||
if not ok:
|
|
||||||
return Response({"detail": "GoPay upstream error", "success": False}, status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
json_attr = getattr(res, "json")
|
|
||||||
body = json_attr() if callable(json_attr) else json_attr
|
|
||||||
elif isinstance(res, dict):
|
|
||||||
body = res
|
|
||||||
else:
|
|
||||||
body = getattr(res, "__dict__", {}) or {"raw": str(res)}
|
|
||||||
|
|
||||||
state_val = body.get("state") if isinstance(body, dict) else None
|
|
||||||
|
|
||||||
# Update local status if we have it
|
|
||||||
try:
|
|
||||||
local = GoPayPayment.objects.get(gopay_id=str(payment_id))
|
|
||||||
if state_val:
|
|
||||||
local.status = state_val
|
|
||||||
local.response_payload = body if isinstance(body, dict) else {"raw": str(body)}
|
|
||||||
local.save(update_fields=["status", "response_payload", "updated_at"])
|
|
||||||
except GoPayPayment.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return Response(body, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class RefundPaymentView(APIView):
|
|
||||||
"""Provede refundaci platby v GoPay a uloží záznam refundace."""
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["GoPay"],
|
|
||||||
operation_id="gopay_refund_payment",
|
|
||||||
summary="Refundovat platbu",
|
|
||||||
parameters=[
|
|
||||||
OpenApiParameter(
|
|
||||||
name="payment_id",
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
location=OpenApiParameter.PATH,
|
|
||||||
description="ID platby GoPay k refundaci",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
request=RefundSerializer,
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(OpenApiTypes.OBJECT, description="Výsledek refundace"),
|
|
||||||
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
def post(self, request, payment_id: str | int):
|
|
||||||
ser = RefundSerializer(data=request.data)
|
|
||||||
ser.is_valid(raise_exception=True)
|
|
||||||
amount = ser.validated_data["amount"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = payments_client().refund_payment(payment_id, amount)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
|
||||||
|
|
||||||
payment = GoPayPayment.objects.filter(gopay_id=str(payment_id)).first()
|
|
||||||
|
|
||||||
ref = GoPayRefund.objects.create(
|
|
||||||
payment=payment,
|
|
||||||
gopay_refund_id=str((res.get("id") if isinstance(res, dict) else getattr(res, "id", "")) or ""),
|
|
||||||
amount=amount,
|
|
||||||
status=(res.get("state") if isinstance(res, dict) else getattr(res, "state", "")) or "",
|
|
||||||
payload=res if isinstance(res, dict) else {"raw": str(res)},
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({"refund": ref.payload}, status=status.HTTP_200_OK)
|
|
||||||
23
backend/thirdparty/stripe/admin.py
vendored
23
backend/thirdparty/stripe/admin.py
vendored
@@ -1,23 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import Order
|
|
||||||
|
|
||||||
@admin.register(Order)
|
|
||||||
class OrderAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("id", "amount", "currency", "status", "created_at")
|
|
||||||
list_filter = ("status", "currency", "created_at")
|
|
||||||
search_fields = ("id", "stripe_session_id", "stripe_payment_intent")
|
|
||||||
readonly_fields = ("created_at", "stripe_session_id", "stripe_payment_intent")
|
|
||||||
|
|
||||||
fieldsets = (
|
|
||||||
(None, {
|
|
||||||
"fields": ("amount", "currency", "status")
|
|
||||||
}),
|
|
||||||
("Stripe info", {
|
|
||||||
"fields": ("stripe_session_id", "stripe_payment_intent"),
|
|
||||||
"classes": ("collapse",),
|
|
||||||
}),
|
|
||||||
("Metadata", {
|
|
||||||
"fields": ("created_at",),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
ordering = ("-created_at",)
|
|
||||||
7
backend/thirdparty/stripe/apps.py
vendored
7
backend/thirdparty/stripe/apps.py
vendored
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class StripeConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'thirdparty.stripe'
|
|
||||||
label = "stripe"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Order',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
|
|
||||||
('currency', models.CharField(default='czk', max_length=10)),
|
|
||||||
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
|
||||||
('stripe_session_id', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('stripe_payment_intent', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
21
backend/thirdparty/stripe/models.py
vendored
21
backend/thirdparty/stripe/models.py
vendored
@@ -1,21 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
|
|
||||||
class Order(models.Model):
|
|
||||||
STATUS_CHOICES = [
|
|
||||||
("pending", "Pending"),
|
|
||||||
("paid", "Paid"),
|
|
||||||
("failed", "Failed"),
|
|
||||||
("cancelled", "Cancelled"),
|
|
||||||
]
|
|
||||||
|
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
|
||||||
currency = models.CharField(max_length=10, default="czk")
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
|
||||||
stripe_session_id = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
stripe_payment_intent = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Order {self.id} - {self.status}"
|
|
||||||
29
backend/thirdparty/stripe/serializers.py
vendored
29
backend/thirdparty/stripe/serializers.py
vendored
@@ -1,29 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from .models import Order
|
|
||||||
|
|
||||||
|
|
||||||
class OrderSerializer(serializers.ModelSerializer):
|
|
||||||
# Nested read-only representations
|
|
||||||
# product = ProductSerializer(read_only=True)
|
|
||||||
# carrier = CarrierSerializer(read_only=True)
|
|
||||||
|
|
||||||
# Write-only foreign keys
|
|
||||||
# product_id = serializers.PrimaryKeyRelatedField(
|
|
||||||
# queryset=Product.objects.all(), source="product", write_only=True
|
|
||||||
# )
|
|
||||||
# carrier_id = serializers.PrimaryKeyRelatedField(
|
|
||||||
# queryset=Carrier.objects.all(), source="carrier", write_only=True
|
|
||||||
# )
|
|
||||||
|
|
||||||
class 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/tests.py
vendored
3
backend/thirdparty/stripe/tests.py
vendored
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
6
backend/thirdparty/stripe/urls.py
vendored
6
backend/thirdparty/stripe/urls.py
vendored
@@ -1,6 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .views import CreateCheckoutSessionView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("orders/create-checkout/", CreateCheckoutSessionView.as_view(), name="create-checkout"),
|
|
||||||
]
|
|
||||||
78
backend/thirdparty/stripe/views.py
vendored
78
backend/thirdparty/stripe/views.py
vendored
@@ -1,78 +0,0 @@
|
|||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import HttpResponse
|
|
||||||
|
|
||||||
from rest_framework import generics
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
|
|
||||||
from .models import Order
|
|
||||||
from .serializers import OrderSerializer
|
|
||||||
import os
|
|
||||||
|
|
||||||
import stripe
|
|
||||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
|
||||||
|
|
||||||
class CreateCheckoutSessionView(APIView):
|
|
||||||
@extend_schema(
|
|
||||||
tags=["stripe"],
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
serializer = OrderSerializer(data=request.data) #obecný serializer
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
order = Order.objects.create(
|
|
||||||
amount=serializer.validated_data["amount"],
|
|
||||||
currency=serializer.validated_data.get("currency", "czk"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Vytvoření Stripe Checkout Session
|
|
||||||
session = stripe.checkout.Session.create(
|
|
||||||
payment_method_types=["card"],
|
|
||||||
line_items=[{
|
|
||||||
"price_data": {
|
|
||||||
"currency": order.currency,
|
|
||||||
"product_data": {"name": f"Order {order.id}"},
|
|
||||||
"unit_amount": int(order.amount * 100), # v centech
|
|
||||||
},
|
|
||||||
"quantity": 1,
|
|
||||||
}],
|
|
||||||
mode="payment",
|
|
||||||
success_url=request.build_absolute_uri(f"/payment/success/{order.id}"),
|
|
||||||
cancel_url=request.build_absolute_uri(f"/payment/cancel/{order.id}"),
|
|
||||||
)
|
|
||||||
|
|
||||||
order.stripe_session_id = session.id
|
|
||||||
order.stripe_payment_intent = session.payment_intent
|
|
||||||
order.save()
|
|
||||||
|
|
||||||
data = OrderSerializer(order).data
|
|
||||||
data["checkout_url"] = session.url
|
|
||||||
return Response(data)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
def stripe_webhook(request):
|
|
||||||
payload = request.body
|
|
||||||
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
|
|
||||||
event = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
event = stripe.Webhook.construct_event(
|
|
||||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
|
||||||
)
|
|
||||||
except stripe.error.SignatureVerificationError:
|
|
||||||
return HttpResponse(status=400)
|
|
||||||
|
|
||||||
if event["type"] == "checkout.session.completed":
|
|
||||||
session = event["data"]["object"]
|
|
||||||
order = Order.objects.filter(stripe_session_id=session.get("id")).first()
|
|
||||||
if order:
|
|
||||||
order.status = "paid"
|
|
||||||
order.save()
|
|
||||||
|
|
||||||
return HttpResponse(status=200)
|
|
||||||
3
backend/thirdparty/trading212/admin.py
vendored
3
backend/thirdparty/trading212/admin.py
vendored
@@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
7
backend/thirdparty/trading212/apps.py
vendored
7
backend/thirdparty/trading212/apps.py
vendored
@@ -1,7 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class Trading212Config(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'thirdparty.trading212'
|
|
||||||
label = "trading212"
|
|
||||||
3
backend/thirdparty/trading212/models.py
vendored
3
backend/thirdparty/trading212/models.py
vendored
@@ -1,3 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
11
backend/thirdparty/trading212/serializers.py
vendored
11
backend/thirdparty/trading212/serializers.py
vendored
@@ -1,11 +0,0 @@
|
|||||||
# thirdparty/trading212/serializers.py
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
class Trading212AccountCashSerializer(serializers.Serializer):
|
|
||||||
blocked = serializers.FloatField()
|
|
||||||
free = serializers.FloatField()
|
|
||||||
invested = serializers.FloatField()
|
|
||||||
pieCash = serializers.FloatField()
|
|
||||||
ppl = serializers.FloatField()
|
|
||||||
result = serializers.FloatField()
|
|
||||||
total = serializers.FloatField()
|
|
||||||
3
backend/thirdparty/trading212/tests.py
vendored
3
backend/thirdparty/trading212/tests.py
vendored
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
6
backend/thirdparty/trading212/urls.py
vendored
6
backend/thirdparty/trading212/urls.py
vendored
@@ -1,6 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .views import Trading212AccountCashView # Replace with actual view class
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('your-endpoint/', Trading212AccountCashView.as_view(), name='trading212-endpoint'),
|
|
||||||
]
|
|
||||||
38
backend/thirdparty/trading212/views.py
vendored
38
backend/thirdparty/trading212/views.py
vendored
@@ -1,38 +0,0 @@
|
|||||||
# thirdparty/trading212/views.py
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from .serializers import Trading212AccountCashSerializer
|
|
||||||
|
|
||||||
from drf_spectacular.utils import extend_schema
|
|
||||||
|
|
||||||
class Trading212AccountCashView(APIView):
|
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["trading212"],
|
|
||||||
summary="Get Trading212 account cash",
|
|
||||||
responses=Trading212AccountCashSerializer
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
api_key = os.getenv("API_KEY_TRADING212")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
url = "https://api.trading212.com/api/v0/equity/account/cash"
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, headers=headers, timeout=10)
|
|
||||||
resp.raise_for_status()
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
return Response({"error": str(exc)}, status=400)
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
serializer = Trading212AccountCashSerializer(data=data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from .celery import app as celery_app
|
|
||||||
|
|
||||||
__all__ = ["celery_app"]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import os
|
|
||||||
from celery import Celery
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
|
||||||
|
|
||||||
app = Celery("backend")
|
|
||||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
|
||||||
app.autodiscover_tasks()
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
class ActiveManager(models.Manager):
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset().filter(is_deleted=False)
|
|
||||||
|
|
||||||
class AllManager(models.Manager):
|
|
||||||
def get_queryset(self):
|
|
||||||
return super().get_queryset()
|
|
||||||
|
|
||||||
# How to use custom object Managers: add these fields to your model, to override objects behaviour and all_objects behaviour
|
|
||||||
# objects = ActiveManager()
|
|
||||||
# all_objects = AllManager()
|
|
||||||
|
|
||||||
|
|
||||||
class SoftDeleteModel(models.Model):
|
|
||||||
is_deleted = models.BooleanField(default=False)
|
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
def delete(self, using=None, keep_parents=False):
|
|
||||||
self.is_deleted = True
|
|
||||||
self.deleted_at = timezone.now()
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
objects = ActiveManager()
|
|
||||||
all_objects = AllManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
# Soft delete self
|
|
||||||
self.is_deleted = True
|
|
||||||
self.deleted_at = timezone.now()
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def hard_delete(self, using=None, keep_parents=False):
|
|
||||||
super().delete(using=using, keep_parents=keep_parents)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# SiteSettings model for managing site-wide settings
|
|
||||||
"""class SiteSettings(models.Model):
|
|
||||||
bank = models.CharField(max_length=100, blank=True)
|
|
||||||
support_email = models.EmailField(blank=True)
|
|
||||||
logo = models.ImageField(upload_to='settings/', blank=True, null=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Site Settings"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Site Settings"
|
|
||||||
verbose_name_plural = "Site Settings"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_solo(cls):
|
|
||||||
obj, created = cls.objects.get_or_create(id=1)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
"""
|
|
||||||
@@ -1,934 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for vontor_cz project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.1.3.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.1/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.1/ref/settings/
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from typing import Dict, Any
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.core.management.utils import get_random_secret_key
|
|
||||||
from django.db import OperationalError, connections
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import json
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné
|
|
||||||
|
|
||||||
# Robust boolean parser and SSL flag
|
|
||||||
def _env_bool(key: str, default: bool = False) -> bool:
|
|
||||||
return os.getenv(key, str(default)).strip().lower() in ("true", "1", "yes", "on")
|
|
||||||
|
|
||||||
USE_SSL = _env_bool("SSL", False)
|
|
||||||
|
|
||||||
#---------------- ENV VARIABLES USECASE--------------
|
|
||||||
# v jiné app si to importneš skrz: from django.conf import settings
|
|
||||||
# a použiješ takto: settings.FRONTEND_URL
|
|
||||||
|
|
||||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:9000")
|
|
||||||
print(f"FRONTEND_URL: {FRONTEND_URL}\n")
|
|
||||||
#-------------------------BASE ⚙️------------------------
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
# Pavel
|
|
||||||
# from django.conf.locale.en import formats as en_formats
|
|
||||||
|
|
||||||
DATETIME_INPUT_FORMATS = [
|
|
||||||
"%Y-%m-%d", # '2025-07-25'
|
|
||||||
"%Y-%m-%d %H:%M", # '2025-07-25 14:30'
|
|
||||||
"%Y-%m-%d %H:%M:%S", # '2025-07-25 14:30:59'
|
|
||||||
"%Y-%m-%dT%H:%M", # '2025-07-25T14:30'
|
|
||||||
"%Y-%m-%dT%H:%M:%S", # '2025-07-25T14:30:59'
|
|
||||||
]
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'cs'
|
|
||||||
|
|
||||||
TIME_ZONE = 'Europe/Prague'
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
if os.getenv("DEBUG", "") == "True":
|
|
||||||
DEBUG = True
|
|
||||||
else:
|
|
||||||
DEBUG = False
|
|
||||||
|
|
||||||
print(f"\nDEBUG state: {str(DEBUG)}\nDEBUG .env raw: {os.getenv('DEBUG', '')}\n")
|
|
||||||
|
|
||||||
#-----------------------BASE END⚙️--------------------------
|
|
||||||
|
|
||||||
#--------------- URLS 🌐 -------------------
|
|
||||||
|
|
||||||
ASGI_APPLICATION = 'vontor_cz.asgi.application' #daphne
|
|
||||||
ROOT_URLCONF = 'vontor_cz.urls'
|
|
||||||
LOGIN_URL = '/admin' #nastavení Login adresy
|
|
||||||
|
|
||||||
#-----------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#----------------------------------- LOGS -------------------------------------------
|
|
||||||
#slouží pro tisknutí do konzole v dockeru skrz: logger.debug("content")
|
|
||||||
LOGGING = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "verbose",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"formatters": {
|
|
||||||
"verbose": {
|
|
||||||
"format": "{levelname} {asctime} {name}: {message}",
|
|
||||||
"style": "{",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"handlers": ["console"],
|
|
||||||
"level": "DEBUG" if DEBUG else "INFO",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# Vytvoř si logger podle názvu souboru (modulu)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
logger.debug("Ladicí zpráva – vidíš jen když je DEBUG = True")
|
|
||||||
logger.info("Informace – např. že uživatel klikl na tlačítko")
|
|
||||||
logger.warning("Varování – něco nečekaného, ale ne kritického")
|
|
||||||
logger.error("Chyba – něco se pokazilo, ale aplikace jede dál")
|
|
||||||
logger.critical("Kritická chyba – selhání systému, třeba pád služby")
|
|
||||||
"""
|
|
||||||
|
|
||||||
#---------------------------------- END LOGS ---------------------------------------
|
|
||||||
|
|
||||||
#-------------------------------------SECURITY 🔐------------------------------------
|
|
||||||
|
|
||||||
if DEBUG:
|
|
||||||
SECRET_KEY = 'pernament'
|
|
||||||
else:
|
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", get_random_secret_key())
|
|
||||||
|
|
||||||
|
|
||||||
SESSION_COOKIE_AGE = 86400 # one day
|
|
||||||
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
|
||||||
#'vontor_cz.backend.EmailOrUsernameModelBackend', #custom backend z authentication aplikace
|
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
|
||||||
]
|
|
||||||
|
|
||||||
#--------------------------------END SECURITY 🔐-------------------------------------
|
|
||||||
|
|
||||||
#-------------------------------------CORS + HOSTs 🌐🔐------------------------------------
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(FRONTEND_URL)
|
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
|
||||||
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
|
|
||||||
|
|
||||||
"http://192.168.67.98",
|
|
||||||
"https://itsolutions.vontor.cz",
|
|
||||||
"https://react.vontor.cz",
|
|
||||||
|
|
||||||
"http://localhost:5173",
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:9000",
|
|
||||||
|
|
||||||
"http://127.0.0.1:5173",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://127.0.0.1:9000",
|
|
||||||
|
|
||||||
# server
|
|
||||||
"http://192.168.67.98",
|
|
||||||
"https://itsolutions.vontor.cz",
|
|
||||||
"https://react.vontor.cz",
|
|
||||||
|
|
||||||
# nginx docker (local)
|
|
||||||
"http://localhost",
|
|
||||||
"http://localhost:80",
|
|
||||||
"http://127.0.0.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
if DEBUG:
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
|
||||||
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
|
|
||||||
|
|
||||||
"http://localhost:5173",
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://127.0.0.1:5173",
|
|
||||||
"http://127.0.0.1:3000",
|
|
||||||
"http://localhost:9000",
|
|
||||||
"http://127.0.0.1:9000",
|
|
||||||
|
|
||||||
# server
|
|
||||||
"http://192.168.67.98",
|
|
||||||
"https://itsolutions.vontor.cz",
|
|
||||||
"https://react.vontor.cz",
|
|
||||||
|
|
||||||
# nginx docker (local)
|
|
||||||
"http://localhost",
|
|
||||||
"http://localhost:80",
|
|
||||||
"http://127.0.0.1",
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
|
||||||
"http://192.168.67.98",
|
|
||||||
"https://itsolutions.vontor.cz",
|
|
||||||
"https://react.vontor.cz",
|
|
||||||
|
|
||||||
"http://localhost:9000",
|
|
||||||
"http://127.0.0.1:9000",
|
|
||||||
]
|
|
||||||
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials
|
|
||||||
|
|
||||||
# Use Lax for http (local), None only when HTTPS is enabled
|
|
||||||
SESSION_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
|
|
||||||
CSRF_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
|
|
||||||
|
|
||||||
print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS)
|
|
||||||
print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
|
|
||||||
print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
|
|
||||||
|
|
||||||
#--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
#--------------------------------------SSL 🧾------------------------------------
|
|
||||||
if USE_SSL is True:
|
|
||||||
print("SSL turned on!")
|
|
||||||
SESSION_COOKIE_SECURE = True
|
|
||||||
CSRF_COOKIE_SECURE = True
|
|
||||||
SECURE_SSL_REDIRECT = True
|
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
|
||||||
else:
|
|
||||||
SESSION_COOKIE_SECURE = False
|
|
||||||
CSRF_COOKIE_SECURE = False
|
|
||||||
SECURE_SSL_REDIRECT = False
|
|
||||||
SECURE_BROWSER_XSS_FILTER = False
|
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = False
|
|
||||||
print(f"\nUsing SSL: {USE_SSL}\n")
|
|
||||||
#--------------------------------END-SSL 🧾---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------
|
|
||||||
|
|
||||||
# ⬇️ Základní lifetime konfigurace
|
|
||||||
ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
|
|
||||||
REFRESH_TOKEN_LIFETIME = timedelta(days=5)
|
|
||||||
|
|
||||||
# ⬇️ Nastavení SIMPLE_JWT podle režimu
|
|
||||||
if DEBUG:
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
"ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
|
|
||||||
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
|
||||||
|
|
||||||
"AUTH_COOKIE": "access_token",
|
|
||||||
"AUTH_COOKIE_REFRESH": "refresh_token",
|
|
||||||
|
|
||||||
"AUTH_COOKIE_DOMAIN": None,
|
|
||||||
"AUTH_COOKIE_SECURE": False,
|
|
||||||
"AUTH_COOKIE_HTTP_ONLY": True,
|
|
||||||
"AUTH_COOKIE_PATH": "/",
|
|
||||||
"AUTH_COOKIE_SAMESITE": "Lax",
|
|
||||||
|
|
||||||
"ROTATE_REFRESH_TOKENS": False,
|
|
||||||
"BLACKLIST_AFTER_ROTATION": False,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
"ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME,
|
|
||||||
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
|
||||||
|
|
||||||
"AUTH_COOKIE": "access_token",
|
|
||||||
"AUTH_COOKIE_REFRESH": "refresh_token",
|
|
||||||
"AUTH_COOKIE_DOMAIN": None,
|
|
||||||
|
|
||||||
# Secure/SameSite based on HTTPS availability
|
|
||||||
"AUTH_COOKIE_SECURE": USE_SSL,
|
|
||||||
"AUTH_COOKIE_HTTP_ONLY": True,
|
|
||||||
"AUTH_COOKIE_PATH": "/",
|
|
||||||
"AUTH_COOKIE_SAMESITE": "None" if USE_SSL else "Lax",
|
|
||||||
|
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
|
||||||
"BLACKLIST_AFTER_ROTATION": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
|
||||||
# In DEBUG keep Session + JWT + your cookie class for convenience
|
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
|
||||||
'account.tokens.CookieJWTAuthentication',
|
|
||||||
) if DEBUG else (
|
|
||||||
'account.tokens.CookieJWTAuthentication',
|
|
||||||
),
|
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
|
||||||
'rest_framework.permissions.AllowAny',
|
|
||||||
),
|
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
|
||||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
|
||||||
|
|
||||||
'DEFAULT_THROTTLE_RATES': {
|
|
||||||
'anon': '100/hour', # unauthenticated
|
|
||||||
'user': '2000/hour', # authenticated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------APPS 📦------------------------------------
|
|
||||||
MY_CREATED_APPS = [
|
|
||||||
'account',
|
|
||||||
'commerce',
|
|
||||||
|
|
||||||
'social.chat',
|
|
||||||
|
|
||||||
'thirdparty.downloader',
|
|
||||||
'thirdparty.stripe', # register Stripe app so its models are recognized
|
|
||||||
'thirdparty.trading212',
|
|
||||||
'thirdparty.gopay', # add GoPay app
|
|
||||||
]
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'daphne', #asgi bude fungovat lokálně (musí být na začátku)
|
|
||||||
|
|
||||||
'django.contrib.admin',
|
|
||||||
'django.contrib.auth',
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'django.contrib.sessions',
|
|
||||||
'django.contrib.messages',
|
|
||||||
'django.contrib.staticfiles',
|
|
||||||
|
|
||||||
'corsheaders', #cors
|
|
||||||
|
|
||||||
'django_celery_beat', #slouží k plánování úkolů pro Celery
|
|
||||||
|
|
||||||
|
|
||||||
#'chat.apps.GlobalChatCheck', #tohle se spusti při každé django inicializaci (migration, createmigration, runserver)
|
|
||||||
|
|
||||||
#'authentication',
|
|
||||||
|
|
||||||
'storages',# Adds support for external storage services like Amazon S3 via django-storages
|
|
||||||
'django_filters',
|
|
||||||
|
|
||||||
'channels' ,# django channels
|
|
||||||
|
|
||||||
'rest_framework',
|
|
||||||
'rest_framework_api_key',
|
|
||||||
|
|
||||||
'drf_spectacular', #rest framework, grafické zobrazení
|
|
||||||
|
|
||||||
#Nastavení stránky
|
|
||||||
#'constance',
|
|
||||||
#'constance.backends.database',
|
|
||||||
|
|
||||||
'django.contrib.sitemaps',
|
|
||||||
|
|
||||||
'tinymce',
|
|
||||||
|
|
||||||
|
|
||||||
#kvůli bugum je lepší to dát na poslední místo v INSTALLED_APPS
|
|
||||||
'django_cleanup.apps.CleanupConfig', #app která maže nepoužité soubory(media) z databáze na S3
|
|
||||||
]
|
|
||||||
|
|
||||||
#skládaní dohromady INSTALLED_APPS
|
|
||||||
INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:]
|
|
||||||
|
|
||||||
# -------------------------------------END APPS 📦------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------MIDDLEWARE 🧩------------------------------------
|
|
||||||
# Middleware is a framework of hooks into Django's request/response processing.
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
|
||||||
|
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
|
||||||
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
|
||||||
|
|
||||||
#--------------------------------END MIDDLEWARE 🧩---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------CACHE + CHANNELS(ws) 📡🗄️------------------------------------
|
|
||||||
|
|
||||||
# Caching settings for Redis (using Docker's internal network name for Redis)
|
|
||||||
if DEBUG is False:
|
|
||||||
#PRODUCTION
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
|
||||||
'LOCATION': 'redis://redis:6379/0', # Using the service name `redis` from Docker Compose
|
|
||||||
'OPTIONS': {
|
|
||||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
|
||||||
'PASSWORD': os.getenv('REDIS_PASSWORD'), # Make sure to set REDIS_PASSWORD in your environment
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSockets Channel Layers (using Redis in production)
|
|
||||||
CHANNEL_LAYERS = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
|
||||||
'CONFIG': {
|
|
||||||
'hosts': [('redis', 6379)], # Use `redis` service in Docker Compose
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else:
|
|
||||||
#DEVELOPMENT
|
|
||||||
# Use in-memory channel layer for development (when DEBUG is True)
|
|
||||||
CHANNEL_LAYERS = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'channels.layers.InMemoryChannelLayer',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use in-memory cache for development (when DEBUG is True)
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
|
|
||||||
|
|
||||||
#-------------------------------------CELERY 📅------------------------------------
|
|
||||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
|
|
||||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND")
|
|
||||||
# Control via env; default False in DEBUG, True otherwise
|
|
||||||
CELERY_ENABLED = _env_bool("CELERY_ENABLED", default=not DEBUG)
|
|
||||||
|
|
||||||
if DEBUG:
|
|
||||||
CELERY_ENABLED = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
import redis
|
|
||||||
r = redis.Redis(host='localhost', port=6379, db=0)
|
|
||||||
r.ping()
|
|
||||||
except Exception:
|
|
||||||
CELERY_BROKER_URL = 'memory://'
|
|
||||||
CELERY_ENABLED = False
|
|
||||||
|
|
||||||
def _env_list(key: str, default: list[str]) -> list[str]:
|
|
||||||
v = os.getenv(key)
|
|
||||||
if not v:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
parsed = json.loads(v)
|
|
||||||
if isinstance(parsed, (list, tuple)):
|
|
||||||
return list(parsed)
|
|
||||||
if isinstance(parsed, str):
|
|
||||||
return [parsed]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return [s.strip(" '\"") for s in v.strip("[]()").split(",") if s.strip()]
|
|
||||||
|
|
||||||
CELERY_ACCEPT_CONTENT = _env_list("CELERY_ACCEPT_CONTENT", ["json"])
|
|
||||||
CELERY_RESULT_ACCEPT_CONTENT = _env_list("CELERY_RESULT_ACCEPT_CONTENT", ["json"])
|
|
||||||
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER", "json")
|
|
||||||
CELERY_RESULT_SERIALIZER = os.getenv("CELERY_RESULT_SERIALIZER", "json")
|
|
||||||
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE", TIME_ZONE)
|
|
||||||
CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER")
|
|
||||||
#-------------------------------------END CELERY 📅------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------DATABASE 💾------------------------------------
|
|
||||||
|
|
||||||
# Nastavuje výchozí typ primárního klíče pro modely.
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
||||||
|
|
||||||
# říka že se úkladá do databáze, místo do cookie
|
|
||||||
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
|
||||||
|
|
||||||
USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True]
|
|
||||||
|
|
||||||
if USE_DOCKER_DB is False:
|
|
||||||
# DEV
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# DOCKER/POSTGRES
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': os.getenv('DATABASE_ENGINE'),
|
|
||||||
'NAME': os.getenv('POSTGRES_DB'),
|
|
||||||
'USER': os.getenv('POSTGRES_USER'),
|
|
||||||
'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
|
|
||||||
'HOST': os.getenv('DATABASE_HOST'),
|
|
||||||
'PORT': os.getenv('DATABASE_PORT'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n")
|
|
||||||
AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser
|
|
||||||
|
|
||||||
#--------------------------------END DATABASE 💾---------------------------------
|
|
||||||
|
|
||||||
#--------------------------------------EMAIL 📧--------------------------------------
|
|
||||||
|
|
||||||
EMAIL_BACKEND = os.getenv(
|
|
||||||
"EMAIL_BACKEND",
|
|
||||||
'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
|
|
||||||
)
|
|
||||||
|
|
||||||
EMAIL_HOST = os.getenv("EMAIL_HOST")
|
|
||||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", 465))
|
|
||||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") in ["True", "true", "1", True]
|
|
||||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "True") in ["True", "true", "1", True]
|
|
||||||
EMAIL_HOST_USER = os.getenv("EMAIL_USER")
|
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD")
|
|
||||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
|
||||||
EMAIL_TIMEOUT = 30 # seconds
|
|
||||||
|
|
||||||
print("---------EMAIL----------")
|
|
||||||
print("EMAIL_HOST =", EMAIL_HOST)
|
|
||||||
print("EMAIL_PORT =", EMAIL_PORT)
|
|
||||||
print("EMAIL_USE_TLS =", EMAIL_USE_TLS)
|
|
||||||
print("EMAIL_USE_SSL =", EMAIL_USE_SSL)
|
|
||||||
print("EMAIL_USER =", EMAIL_HOST_USER)
|
|
||||||
print("------------------------")
|
|
||||||
#----------------------------------EMAIL END 📧-------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------TEMPLATES 🗂️------------------------------------
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
"DIRS": [BASE_DIR / 'templates'],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
'OPTIONS': {
|
|
||||||
'context_processors': [
|
|
||||||
'django.template.context_processors.debug',
|
|
||||||
'django.template.context_processors.request',
|
|
||||||
'django.contrib.auth.context_processors.auth',
|
|
||||||
'django.contrib.messages.context_processors.messages',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
#--------------------------------END TEMPLATES 🗂️---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------MEDIA + STATIC 🖼️, AWS ☁️------------------------------------
|
|
||||||
|
|
||||||
# nastavení složky pro globalstaticfiles (static složky django hledá samo)
|
|
||||||
STATICFILES_DIRS = [
|
|
||||||
BASE_DIR / 'globalstaticfiles',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if os.getenv("USE_AWS", "") == "True":
|
|
||||||
USE_AWS = True
|
|
||||||
else:
|
|
||||||
USE_AWS = False
|
|
||||||
|
|
||||||
print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
|
|
||||||
|
|
||||||
if USE_AWS is False:
|
|
||||||
# Development: Use local file system storage for static files
|
|
||||||
STORAGES = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
||||||
},
|
|
||||||
"staticfiles": {
|
|
||||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Media and Static URL for local dev
|
|
||||||
MEDIA_URL = os.getenv("MEDIA_URL", "/media/")
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
|
||||||
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
|
|
||||||
elif USE_AWS:
|
|
||||||
# PRODUCTION
|
|
||||||
|
|
||||||
AWS_LOCATION = "static"
|
|
||||||
|
|
||||||
# Production: Use S3 storage
|
|
||||||
STORAGES = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
|
|
||||||
},
|
|
||||||
|
|
||||||
"staticfiles": {
|
|
||||||
"BACKEND" : "storages.backends.s3boto3.S3StaticStorage",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Media and Static URL for AWS S3
|
|
||||||
MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/'
|
|
||||||
STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/'
|
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS.append(STATIC_URL)
|
|
||||||
|
|
||||||
# Static files should be collected to a local directory and then uploaded to S3
|
|
||||||
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
|
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID')
|
|
||||||
AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY')
|
|
||||||
AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME')
|
|
||||||
AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set
|
|
||||||
AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4
|
|
||||||
AWS_S3_USE_SSL = True
|
|
||||||
AWS_S3_FILE_OVERWRITE = True
|
|
||||||
AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
|
|
||||||
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------TINY MCE ✍️------------------------------------
|
|
||||||
|
|
||||||
TINYMCE_JS_URL = 'https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js'
|
|
||||||
|
|
||||||
TINYMCE_DEFAULT_CONFIG = {
|
|
||||||
"height": "320px",
|
|
||||||
"width": "960px",
|
|
||||||
"menubar": "file edit view insert format tools table help",
|
|
||||||
"plugins": "advlist autolink lists link image charmap print preview anchor searchreplace visualblocks code "
|
|
||||||
"fullscreen insertdatetime media table paste code help wordcount spellchecker",
|
|
||||||
"toolbar": "undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft "
|
|
||||||
"aligncenter alignright alignjustify | outdent indent | numlist bullist checklist | forecolor "
|
|
||||||
"backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap emoticons | "
|
|
||||||
"fullscreen preview save print | insertfile image media pageembed template link anchor codesample | "
|
|
||||||
"a11ycheck ltr rtl | showcomments addcomment code",
|
|
||||||
"custom_undo_redo_levels": 10,
|
|
||||||
}
|
|
||||||
TINYMCE_SPELLCHECKER = True
|
|
||||||
TINYMCE_COMPRESSOR = True
|
|
||||||
|
|
||||||
#--------------------------------END-TINY-MCE-SECTION ✍️---------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#-------------------------------------DRF SPECTACULAR 📊------------------------------------
|
|
||||||
|
|
||||||
SPECTACULAR_DEFAULTS: Dict[str, Any] = {
|
|
||||||
# A regex specifying the common denominator for all operation paths. If
|
|
||||||
# SCHEMA_PATH_PREFIX is set to None, drf-spectacular will attempt to estimate
|
|
||||||
# a common prefix. Use '' to disable.
|
|
||||||
# Mainly used for tag extraction, where paths like '/api/v1/albums' with
|
|
||||||
# a SCHEMA_PATH_PREFIX regex '/api/v[0-9]' would yield the tag 'albums'.
|
|
||||||
'SCHEMA_PATH_PREFIX': None,
|
|
||||||
|
|
||||||
# Remove matching SCHEMA_PATH_PREFIX from operation path. Usually used in
|
|
||||||
# conjunction with appended prefixes in SERVERS.
|
|
||||||
'SCHEMA_PATH_PREFIX_TRIM': False,
|
|
||||||
|
|
||||||
# Insert a manual path prefix to the operation path, e.g. '/service/backend'.
|
|
||||||
# Use this for example to align paths when the API is mounted as a sub-resource
|
|
||||||
# behind a proxy and Django is not aware of that. Alternatively, prefixes can
|
|
||||||
# also specified via SERVERS, but this makes the operation path more explicit.
|
|
||||||
'SCHEMA_PATH_PREFIX_INSERT': '',
|
|
||||||
|
|
||||||
# Coercion of {pk} to {id} is controlled by SCHEMA_COERCE_PATH_PK. Additionally,
|
|
||||||
# some libraries (e.g. drf-nested-routers) use "_pk" suffixed path variables.
|
|
||||||
# This setting globally coerces path variables like "{user_pk}" to "{user_id}".
|
|
||||||
'SCHEMA_COERCE_PATH_PK_SUFFIX': False,
|
|
||||||
|
|
||||||
# Schema generation parameters to influence how components are constructed.
|
|
||||||
# Some schema features might not translate well to your target.
|
|
||||||
# Demultiplexing/modifying components might help alleviate those issues.
|
|
||||||
'DEFAULT_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator',
|
|
||||||
|
|
||||||
# Create separate components for PATCH endpoints (without required list)
|
|
||||||
'COMPONENT_SPLIT_PATCH': True,
|
|
||||||
|
|
||||||
# Split components into request and response parts where appropriate
|
|
||||||
# This setting is highly recommended to achieve the most accurate API
|
|
||||||
# description, however it comes at the cost of having more components.
|
|
||||||
'COMPONENT_SPLIT_REQUEST': True,
|
|
||||||
|
|
||||||
# Aid client generator targets that have trouble with read-only properties.
|
|
||||||
'COMPONENT_NO_READ_ONLY_REQUIRED': False,
|
|
||||||
|
|
||||||
# Adds "minLength: 1" to fields that do not allow blank strings. Deactivated
|
|
||||||
# by default because serializers do not strictly enforce this on responses and
|
|
||||||
# so "minLength: 1" may not always accurately describe API behavior.
|
|
||||||
# Gets implicitly enabled by COMPONENT_SPLIT_REQUEST, because this can be
|
|
||||||
# accurately modeled when request and response components are separated.
|
|
||||||
'ENFORCE_NON_BLANK_FIELDS': False,
|
|
||||||
|
|
||||||
# This version string will end up the in schema header. The default OpenAPI
|
|
||||||
# version is 3.0.3, which is heavily tested. We now also support 3.1.0,
|
|
||||||
# which contains the same features and a few mandatory, but minor changes.
|
|
||||||
'OAS_VERSION': '3.0.3',
|
|
||||||
|
|
||||||
# Configuration for serving a schema subset with SpectacularAPIView
|
|
||||||
'SERVE_URLCONF': None,
|
|
||||||
|
|
||||||
# complete public schema or a subset based on the requesting user
|
|
||||||
'SERVE_PUBLIC': True,
|
|
||||||
|
|
||||||
# include schema endpoint into schema
|
|
||||||
'SERVE_INCLUDE_SCHEMA': True,
|
|
||||||
|
|
||||||
# list of authentication/permission classes for spectacular's views.
|
|
||||||
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], #account.permissions.AdminOnly
|
|
||||||
|
|
||||||
# None will default to DRF's AUTHENTICATION_CLASSES
|
|
||||||
'SERVE_AUTHENTICATION': None,
|
|
||||||
|
|
||||||
# Dictionary of general configuration to pass to the SwaggerUI({ ... })
|
|
||||||
# https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
|
|
||||||
# The settings are serialized with json.dumps(). If you need customized JS, use a
|
|
||||||
# string instead. The string must then contain valid JS and is passed unchanged.
|
|
||||||
'SWAGGER_UI_SETTINGS': {
|
|
||||||
'deepLinking': True,
|
|
||||||
},
|
|
||||||
|
|
||||||
# Initialize SwaggerUI with additional OAuth2 configuration.
|
|
||||||
# https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/
|
|
||||||
'SWAGGER_UI_OAUTH2_CONFIG': {},
|
|
||||||
|
|
||||||
# Dictionary of general configuration to pass to the Redoc.init({ ... })
|
|
||||||
# https://redocly.com/docs/redoc/config/#functional-settings
|
|
||||||
# The settings are serialized with json.dumps(). If you need customized JS, use a
|
|
||||||
# string instead. The string must then contain valid JS and is passed unchanged.
|
|
||||||
'REDOC_UI_SETTINGS': {},
|
|
||||||
|
|
||||||
# CDNs for swagger and redoc. You can change the version or even host your
|
|
||||||
# own depending on your requirements. For self-hosting, have a look at
|
|
||||||
# the sidecar option in the README.
|
|
||||||
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest',
|
|
||||||
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/favicon-32x32.png',
|
|
||||||
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@latest',
|
|
||||||
|
|
||||||
# Append OpenAPI objects to path and components in addition to the generated objects
|
|
||||||
'APPEND_PATHS': {},
|
|
||||||
'APPEND_COMPONENTS': {},
|
|
||||||
|
|
||||||
|
|
||||||
# Postprocessing functions that run at the end of schema generation.
|
|
||||||
# must satisfy interface result = hook(generator, request, public, result)
|
|
||||||
'POSTPROCESSING_HOOKS': [
|
|
||||||
'drf_spectacular.hooks.postprocess_schema_enums'
|
|
||||||
],
|
|
||||||
|
|
||||||
# Preprocessing functions that run before schema generation.
|
|
||||||
# must satisfy interface result = hook(endpoints=result) where result
|
|
||||||
# is a list of Tuples (path, path_regex, method, callback).
|
|
||||||
# Example: 'drf_spectacular.hooks.preprocess_exclude_path_format'
|
|
||||||
'PREPROCESSING_HOOKS': [],
|
|
||||||
|
|
||||||
# Determines how operations should be sorted. If you intend to do sorting with a
|
|
||||||
# PREPROCESSING_HOOKS, be sure to disable this setting. If configured, the sorting
|
|
||||||
# is applied after the PREPROCESSING_HOOKS. Accepts either
|
|
||||||
# True (drf-spectacular's alpha-sorter), False, or a callable for sort's key arg.
|
|
||||||
'SORT_OPERATIONS': True,
|
|
||||||
|
|
||||||
# enum name overrides. dict with keys "YourEnum" and their choice values "field.choices"
|
|
||||||
# e.g. {'SomeEnum': ['A', 'B'], 'OtherEnum': 'import.path.to.choices'}
|
|
||||||
'ENUM_NAME_OVERRIDES': {},
|
|
||||||
|
|
||||||
# Adds "blank" and "null" enum choices where appropriate. disable on client generation issues
|
|
||||||
'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': True,
|
|
||||||
|
|
||||||
# Add/Append a list of (``choice value`` - choice name) to the enum description string.
|
|
||||||
'ENUM_GENERATE_CHOICE_DESCRIPTION': True,
|
|
||||||
|
|
||||||
# Optional suffix for generated enum.
|
|
||||||
# e.g. {'ENUM_SUFFIX': "Type"} would produce an enum name 'StatusType'.
|
|
||||||
'ENUM_SUFFIX': 'Enum',
|
|
||||||
|
|
||||||
# function that returns a list of all classes that should be excluded from doc string extraction
|
|
||||||
'GET_LIB_DOC_EXCLUDES': 'drf_spectacular.plumbing.get_lib_doc_excludes',
|
|
||||||
|
|
||||||
# Function that returns a mocked request for view processing. For CLI usage
|
|
||||||
# original_request will be None.
|
|
||||||
# interface: request = build_mock_request(method, path, view, original_request, **kwargs)
|
|
||||||
'GET_MOCK_REQUEST': 'drf_spectacular.plumbing.build_mock_request',
|
|
||||||
|
|
||||||
# Camelize names like "operationId" and path parameter names
|
|
||||||
# Camelization of the operation schema itself requires the addition of
|
|
||||||
# 'drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields'
|
|
||||||
# to POSTPROCESSING_HOOKS. Please note that the hook depends on
|
|
||||||
# ``djangorestframework_camel_case``, while CAMELIZE_NAMES itself does not.
|
|
||||||
'CAMELIZE_NAMES': False,
|
|
||||||
|
|
||||||
# Changes the location of the action/method on the generated OperationId. For example,
|
|
||||||
# "POST": "group_person_list", "group_person_create"
|
|
||||||
# "PRE": "list_group_person", "create_group_person"
|
|
||||||
'OPERATION_ID_METHOD_POSITION': 'POST',
|
|
||||||
|
|
||||||
# Determines if and how free-form 'additionalProperties' should be emitted in the schema. Some
|
|
||||||
# code generator targets are sensitive to this. None disables generic 'additionalProperties'.
|
|
||||||
# allowed values are 'dict', 'bool', None
|
|
||||||
'GENERIC_ADDITIONAL_PROPERTIES': 'dict',
|
|
||||||
|
|
||||||
# Path converter schema overrides (e.g. <int:foo>). Can be used to either modify default
|
|
||||||
# behavior or provide a schema for custom converters registered with register_converter(...).
|
|
||||||
# Takes converter labels as keys and either basic python types, OpenApiType, or raw schemas
|
|
||||||
# as values. Example: {'aint': OpenApiTypes.INT, 'bint': str, 'cint': {'type': ...}}
|
|
||||||
'PATH_CONVERTER_OVERRIDES': {},
|
|
||||||
|
|
||||||
# Determines whether operation parameters should be sorted alphanumerically or just in
|
|
||||||
# the order they arrived. Accepts either True, False, or a callable for sort's key arg.
|
|
||||||
'SORT_OPERATION_PARAMETERS': True,
|
|
||||||
|
|
||||||
# @extend_schema allows to specify status codes besides 200. This functionality is usually used
|
|
||||||
# to describe error responses, which rarely make use of list mechanics. Therefore, we suppress
|
|
||||||
# listing (pagination and filtering) on non-2XX status codes by default. Toggle this to enable
|
|
||||||
# list responses with ListSerializers/many=True irrespective of the status code.
|
|
||||||
'ENABLE_LIST_MECHANICS_ON_NON_2XX': False,
|
|
||||||
|
|
||||||
# This setting allows you to deviate from the default manager by accessing a different model
|
|
||||||
# property. We use "objects" by default for compatibility reasons. Using "_default_manager"
|
|
||||||
# will likely fix most issues, though you are free to choose any name.
|
|
||||||
"DEFAULT_QUERY_MANAGER": 'objects',
|
|
||||||
|
|
||||||
# Controls which authentication methods are exposed in the schema. If not None, will hide
|
|
||||||
# authentication classes that are not contained in the whitelist. Use full import paths
|
|
||||||
# like ['rest_framework.authentication.TokenAuthentication', ...].
|
|
||||||
# Empty list ([]) will hide all authentication methods. The default None will show all.
|
|
||||||
'AUTHENTICATION_WHITELIST': None,
|
|
||||||
# Controls which parsers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST.
|
|
||||||
# List of allowed parsers or None to allow all.
|
|
||||||
'PARSER_WHITELIST': None,
|
|
||||||
# Controls which renderers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST.
|
|
||||||
# rest_framework.renderers.BrowsableAPIRenderer is ignored by default if whitelist is None
|
|
||||||
'RENDERER_WHITELIST': None,
|
|
||||||
|
|
||||||
# Option for turning off error and warn messages
|
|
||||||
'DISABLE_ERRORS_AND_WARNINGS': False,
|
|
||||||
|
|
||||||
# Runs exemplary schema generation and emits warnings as part of "./manage.py check --deploy"
|
|
||||||
'ENABLE_DJANGO_DEPLOY_CHECK': True,
|
|
||||||
|
|
||||||
# General schema metadata. Refer to spec for valid inputs
|
|
||||||
# https://spec.openapis.org/oas/v3.0.3#openapi-object
|
|
||||||
'TITLE': 'e-Tržnice API',
|
|
||||||
'DESCRIPTION': 'This is the API documentation for e-Tržnice.',
|
|
||||||
'TOS': None,
|
|
||||||
# Optional: MAY contain "name", "url", "email"
|
|
||||||
'CONTACT': {},
|
|
||||||
# Optional: MUST contain "name", MAY contain URL
|
|
||||||
|
|
||||||
'LICENSE': {},
|
|
||||||
# Statically set schema version. May also be an empty string. When used together with
|
|
||||||
# view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests.
|
|
||||||
# Set VERSION to None if only the request version should be rendered.
|
|
||||||
'VERSION': '1.0.0',
|
|
||||||
# Optional list of servers.
|
|
||||||
# Each entry MUST contain "url", MAY contain "description", "variables"
|
|
||||||
# e.g. [{'url': 'https://example.com/v1', 'description': 'Text'}, ...]
|
|
||||||
'SERVERS': [],
|
|
||||||
# Tags defined in the global scope
|
|
||||||
'TAGS': [],
|
|
||||||
# Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an
|
|
||||||
# OpenApiWebhook instance.
|
|
||||||
'WEBHOOKS': [],
|
|
||||||
# Optional: MUST contain 'url', may contain "description"
|
|
||||||
'EXTERNAL_DOCS': {},
|
|
||||||
|
|
||||||
# Arbitrary specification extensions attached to the schema's info object.
|
|
||||||
# https://swagger.io/specification/#specification-extensions
|
|
||||||
'EXTENSIONS_INFO': {},
|
|
||||||
|
|
||||||
# Arbitrary specification extensions attached to the schema's root object.
|
|
||||||
# https://swagger.io/specification/#specification-extensions
|
|
||||||
'EXTENSIONS_ROOT': {},
|
|
||||||
|
|
||||||
# Oauth2 related settings. used for example by django-oauth2-toolkit.
|
|
||||||
# https://spec.openapis.org/oas/v3.0.3#oauth-flows-object
|
|
||||||
'OAUTH2_FLOWS': [],
|
|
||||||
'OAUTH2_AUTHORIZATION_URL': None,
|
|
||||||
'OAUTH2_TOKEN_URL': None,
|
|
||||||
'OAUTH2_REFRESH_URL': None,
|
|
||||||
'OAUTH2_SCOPES': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- GoPay configuration (set in backend/.env) ---
|
|
||||||
GOPAY_GOID = os.getenv("GOPAY_GOID")
|
|
||||||
GOPAY_CLIENT_ID = os.getenv("GOPAY_CLIENT_ID")
|
|
||||||
GOPAY_CLIENT_SECRET = os.getenv("GOPAY_CLIENT_SECRET")
|
|
||||||
GOPAY_GATEWAY_URL = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api")
|
|
||||||
# New: absolute URL that GoPay calls (publicly reachable)
|
|
||||||
GOPAY_NOTIFICATION_URL = os.getenv("GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook")
|
|
||||||
|
|
||||||
# -------------------------------------DOWNLOADER LIMITS------------------------------------
|
|
||||||
DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap
|
|
||||||
DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024
|
|
||||||
DOWNLOADER_TIMEOUT = int(os.getenv("DOWNLOADER_TIMEOUT", "120")) # seconds
|
|
||||||
DOWNLOADER_TMP_DIR = os.getenv("DOWNLOADER_TMP_DIR", str(BASE_DIR / "tmp" / "downloader"))
|
|
||||||
# -------------------------------------END DOWNLOADER LIMITS--------------------------------
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""
|
|
||||||
URL configuration for vontor_cz project.
|
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
|
||||||
Examples:
|
|
||||||
Function views
|
|
||||||
1. Add an import: from my_app import views
|
|
||||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
||||||
Class-based views
|
|
||||||
1. Add an import: from other_app.views import Home
|
|
||||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
||||||
Including another URLconf
|
|
||||||
1. Import the include() function: from django.urls import include, path
|
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
||||||
"""
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import path, include
|
|
||||||
|
|
||||||
from drf_spectacular.views import (
|
|
||||||
SpectacularAPIView,
|
|
||||||
SpectacularSwaggerView,
|
|
||||||
SpectacularRedocView,
|
|
||||||
)
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
#rest framework, map of api
|
|
||||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
|
||||||
path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
|
||||||
path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
|
|
||||||
|
|
||||||
|
|
||||||
path('admin/', admin.site.urls),
|
|
||||||
path('api/account/', include('account.urls')),
|
|
||||||
#path('api/commerce/', include('commerce.urls')),
|
|
||||||
#path('api/advertisments/', include('advertisements.urls')),
|
|
||||||
|
|
||||||
path('api/stripe/', include('thirdparty.stripe.urls')),
|
|
||||||
path('api/trading212/', include('thirdparty.trading212.urls')),
|
|
||||||
path('api/downloader/', include('thirdparty.downloader.urls')),
|
|
||||||
path("api/payments/gopay/", include("thirdparty.gopay.urls", namespace="gopay")),
|
|
||||||
]
|
|
||||||
965
backups/backup-20241217-110110.sql
Normal file
965
backups/backup-20241217-110110.sql
Normal file
File diff suppressed because one or more lines are too long
965
backups/backup-20241217-110756.sql
Normal file
965
backups/backup-20241217-110756.sql
Normal file
File diff suppressed because one or more lines are too long
965
backups/backup-20241217-111229.sql
Normal file
965
backups/backup-20241217-111229.sql
Normal file
File diff suppressed because one or more lines are too long
965
backups/backup-20241217-112831.sql
Normal file
965
backups/backup-20241217-112831.sql
Normal file
File diff suppressed because one or more lines are too long
965
backups/backup-20241217-150502.sql
Normal file
965
backups/backup-20241217-150502.sql
Normal file
File diff suppressed because one or more lines are too long
965
backups/backup-20241217-150858.sql
Normal file
965
backups/backup-20241217-150858.sql
Normal file
File diff suppressed because one or more lines are too long
1042
backups/backup-20241217-152711.sql
Normal file
1042
backups/backup-20241217-152711.sql
Normal file
File diff suppressed because one or more lines are too long
1042
backups/backup-20241218-091113.sql
Normal file
1042
backups/backup-20241218-091113.sql
Normal file
File diff suppressed because one or more lines are too long
1042
backups/backup-20241218-091616.sql
Normal file
1042
backups/backup-20241218-091616.sql
Normal file
File diff suppressed because one or more lines are too long
1042
backups/backup-20241218-092546.sql
Normal file
1042
backups/backup-20241218-092546.sql
Normal file
File diff suppressed because one or more lines are too long
1042
backups/backup-20241218-162730.sql
Normal file
1042
backups/backup-20241218-162730.sql
Normal file
File diff suppressed because one or more lines are too long
1043
backups/backup-20241218-165946.sql
Normal file
1043
backups/backup-20241218-165946.sql
Normal file
File diff suppressed because one or more lines are too long
1043
backups/backup-20241219-165948.sql
Normal file
1043
backups/backup-20241219-165948.sql
Normal file
File diff suppressed because one or more lines are too long
1043
backups/backup-20241220-165949.sql
Normal file
1043
backups/backup-20241220-165949.sql
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user