This commit is contained in:
2025-10-02 00:54:34 +02:00
commit 84b34c9615
200 changed files with 42048 additions and 0 deletions

View File

View File

@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import AppConfig
from trznice.admin import custom_admin_site
class AppConfigAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
# Prevent adding more than one instance
return not AppConfig.objects.exists()
def has_delete_permission(self, request, obj=None):
# Prevent deletion
return False
readonly_fields = ('last_changed_by', 'last_changed_at',)
def save_model(self, request, obj, form, change):
obj.last_changed_by = request.user
super().save_model(request, obj, form, change)
custom_admin_site.register(AppConfig, AppConfigAdmin)

View File

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

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AppConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bank_account', models.CharField(blank=True, max_length=255, null=True, validators=[django.core.validators.RegexValidator(code='invalid_bank_account', message='Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.', regex='^(\\d{0,6}-)?\\d{10}/\\d{4}$')])),
('sender_email', models.EmailField(max_length=254)),
('last_changed_at', models.DateTimeField(auto_now=True, verbose_name='Kdy byly naposled udělany změny.')),
('last_changed_by', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='app_config', to=settings.AUTH_USER_MODEL, verbose_name='Kdo naposled udělal změny.')),
],
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.4 on 2025-09-25 14:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('configuration', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='appconfig',
name='background_image',
field=models.ImageField(blank=True, help_text='Obrázek pozadí webu (nepovinné).', null=True, upload_to='config/'),
),
migrations.AddField(
model_name='appconfig',
name='contact_email',
field=models.EmailField(blank=True, help_text='Kontaktní e-mail pro veřejnost (může se lišit od odesílací adresy).', max_length=254, null=True),
),
migrations.AddField(
model_name='appconfig',
name='contact_phone',
field=models.CharField(blank=True, help_text='Kontaktní telefon veřejně zobrazený na webu.', max_length=50, null=True),
),
migrations.AddField(
model_name='appconfig',
name='logo',
field=models.ImageField(blank=True, help_text='Logo webu (transparentní PNG doporučeno).', null=True, upload_to='config/'),
),
migrations.AddField(
model_name='appconfig',
name='max_reservations_per_event',
field=models.PositiveIntegerField(default=1, help_text='Maximální počet rezervací (slotů) povolených pro jednoho uživatele na jednu akci.'),
),
migrations.AddField(
model_name='appconfig',
name='variable_symbol',
field=models.PositiveIntegerField(blank=True, help_text='Výchozí variabilní symbol pro platby (pokud není specifikováno jinde).', null=True),
),
]

View File

@@ -0,0 +1,88 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.conf import settings
class AppConfig(models.Model):
bank_account = models.CharField(
max_length=255,
null=True,
blank=True,
validators=[
RegexValidator(
regex=r'^(\d{0,6}-)?\d{10}/\d{4}$',
message=(
"Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, "
"např. 1234567890/0100 nebo 123-4567890/0100."
),
code='invalid_bank_account'
)
],
)
sender_email = models.EmailField()
# ---- New configurable site settings ----
background_image = models.ImageField(
upload_to="config/",
null=True,
blank=True,
help_text="Obrázek pozadí webu (nepovinné)."
)
logo = models.ImageField(
upload_to="config/",
null=True,
blank=True,
help_text="Logo webu (transparentní PNG doporučeno)."
)
variable_symbol = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Výchozí variabilní symbol pro platby (pokud není specifikováno jinde)."
)
max_reservations_per_event = models.PositiveIntegerField(
default=1,
help_text="Maximální počet rezervací (slotů) povolených pro jednoho uživatele na jednu akci."
)
contact_phone = models.CharField(
max_length=50,
null=True,
blank=True,
help_text="Kontaktní telefon veřejně zobrazený na webu."
)
contact_email = models.EmailField(
null=True,
blank=True,
help_text="Kontaktní e-mail pro veřejnost (může se lišit od odesílací adresy)."
)
last_changed_by = models.OneToOneField(
settings.AUTH_USER_MODEL,
verbose_name="Kdo naposled udělal změny.",
on_delete=models.SET_NULL, # 🔄 Better than CASCADE to preserve data
related_name="app_config",
null=True,
blank=True
)
last_changed_at = models.DateTimeField(
auto_now=True, # 🔄 Use auto_now to update on every save
verbose_name="Kdy byly naposled udělany změny."
)
def save(self, *args, **kwargs):
if not self.pk and AppConfig.objects.exists():
raise ValidationError('Only one AppConfig instance allowed.')
return super().save(*args, **kwargs)
def __str__(self):
return "App Configuration"
@classmethod
def get_instance(cls):
return cls.objects.first()
# Usage:
# config = AppConfig.get_instance()
# if config:
# print(config.bank_account)

View File

@@ -0,0 +1,159 @@
from django.apps import apps
from django.conf import settings
from django.db.models.fields.related import ForeignObjectRel
from rest_framework import serializers
from trznice.utils import RoundedDateTimeField # noqa: F401 (kept if used elsewhere later)
from .models import AppConfig
class AppConfigSerializer(serializers.ModelSerializer):
class Meta:
model = AppConfig
fields = "__all__"
read_only_fields = ["last_changed_by", "last_changed_at"]
class AppConfigPublicSerializer(serializers.ModelSerializer):
"""Public-facing limited subset used for navbar assets and basic contact info."""
class Meta:
model = AppConfig
fields = [
"id",
"logo",
"background_image",
"contact_email",
"contact_phone",
"max_reservations_per_event",
]
class TrashItemSerializer(serializers.Serializer):
"""Represents a single soft-deleted instance across any model.
Fields:
model: <app_label.model_name>
id: primary key value
deleted_at: timestamp (if model defines it)
data: remaining field values (excluding soft-delete bookkeeping fields)
"""
model = serializers.CharField()
id = serializers.CharField() # CharField to allow UUIDs as well
deleted_at = serializers.DateTimeField(allow_null=True, required=False)
data = serializers.DictField(child=serializers.CharField(allow_blank=True, allow_null=True))
class TrashSerializer(serializers.Serializer):
"""Aggregates all soft-deleted objects (is_deleted=True) from selected apps.
This dynamically inspects registered models and collects those that:
* Have a concrete field named `is_deleted`
* (Optional) Have a manager named `all_objects`; otherwise fall back to default `objects`
Usage: Serialize with `TrashSerializer()` (no instance needed) and access `.data`.
Optionally you can pass a context key `apps` with an iterable of app labels to restrict search
(default: account, booking, commerce, product, servicedesk).
"""
items = serializers.SerializerMethodField()
SETTINGS_APPS = set(getattr(settings, "MY_CREATED_APPS", []))
EXCLUDE_FIELD_NAMES = {"is_deleted", "deleted_at"}
def get_items(self, _obj): # _obj unused (serializer acts as a data provider)
# Allow overriding via context['apps']; otherwise use all custom apps from settings
target_apps = set(self.context.get("apps", self.SETTINGS_APPS))
results = []
for model in apps.get_models():
app_label = model._meta.app_label
if app_label not in target_apps:
continue
# Fast check for is_deleted field
field_names = {f.name for f in model._meta.get_fields() if not isinstance(f, ForeignObjectRel)}
if "is_deleted" not in field_names:
continue
manager = getattr(model, "all_objects", model._default_manager)
queryset = manager.filter(is_deleted=True)
if not queryset.exists():
continue
# Prepare list of simple (non-relational) field objects for extraction
concrete_fields = [
f for f in model._meta.get_fields()
if not isinstance(f, ForeignObjectRel) and getattr(f, "concrete", False)
]
for instance in queryset:
data = {}
for f in concrete_fields:
if f.name in self.EXCLUDE_FIELD_NAMES:
continue
try:
value = f.value_from_object(instance)
# Represent related FK by its PK only
if f.is_relation and hasattr(value, "pk"):
value = value.pk
except Exception: # noqa: BLE001 - defensive; skip problematic field
value = None
data[f.name] = None if value == "" else value
results.append({
"model": f"{app_label}.{model._meta.model_name}",
"id": instance.pk,
"deleted_at": getattr(instance, "deleted_at", None),
"data": data,
})
# Optional: sort by deleted_at descending if available
results.sort(key=lambda i: (i.get("deleted_at") is None, i.get("deleted_at")), reverse=True)
return results
def to_representation(self, instance): # instance unused
all_items = self.get_items(instance)
request = self.context.get("request")
# ---- Pagination params ----
def _to_int(val, default):
try:
return max(1, int(val))
except Exception:
return default
if request is not None:
page = _to_int(request.query_params.get("page", 1), 1)
page_size = _to_int(request.query_params.get("page_size") or request.query_params.get("limit", 20), 20)
else:
# Fallback when no request in context (e.g., manual usage)
page = 1
page_size = 20
# Enforce reasonable upper bound
MAX_PAGE_SIZE = 200
if page_size > MAX_PAGE_SIZE:
page_size = MAX_PAGE_SIZE
total_items = len(all_items)
total_pages = (total_items + page_size - 1) // page_size if page_size else 1
if page > total_pages and total_pages != 0:
page = total_pages
start = (page - 1) * page_size
end = start + page_size
page_items = all_items[start:end]
pagination = {
"page": page,
"page_size": page_size,
"total_items": total_items,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_previous": page > 1,
}
return {"trash": page_items, "pagination": pagination}

View File

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

View File

@@ -0,0 +1,12 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import AppConfigViewSet, TrashView, AppConfigPublicView
router = DefaultRouter()
router.register(r'', AppConfigViewSet, basename='app_config') # handles /api/config/
urlpatterns = [
path('', include(router.urls)),
path('trash/', TrashView.as_view(), name='trash'),
path('public/', AppConfigPublicView.as_view(), name='app-config-public'),
]

View File

@@ -0,0 +1,200 @@
from rest_framework import viewsets
from rest_framework.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
from rest_framework.views import APIView
from rest_framework.response import Response
from django.utils import timezone
from django.apps import apps as django_apps
from .models import AppConfig
from .serializers import AppConfigSerializer, TrashSerializer, AppConfigPublicSerializer
from account.permissions import OnlyRolesAllowed
@extend_schema(
tags=["AppConfig"],
description=(
"Globální konfigurace aplikace správa bankovního účtu, e-mailu odesílatele a dalších nastavení. "
"Umožňuje úpravu přes administrační rozhraní nebo API.\n\n"
"🛠️ **Singleton model** lze vytvořit pouze jednu instanci konfigurace.\n\n"
"📌 **Přístup pouze pro administrátory** (`role=admin`).\n\n"
"**Dostupné akce:**\n"
"- `GET /api/config/` Získání aktuální konfigurace (singleton)\n"
"- `PUT /api/config/` Úprava konfigurace\n\n"
"**Poznámka:** pokus o vytvoření více než jedné konfigurace vrací chybu 400."
)
)
class AppConfigViewSet(viewsets.ModelViewSet):
queryset = AppConfig.objects.all()
serializer_class = AppConfigSerializer
permission_classes = [OnlyRolesAllowed("admin")]
def get_object(self):
# Always return the singleton instance
return AppConfig.get_instance()
def perform_update(self, serializer):
serializer.save(last_changed_by=self.request.user)
def perform_create(self, serializer):
if AppConfig.objects.exists():
raise ValidationError("Only one AppConfig instance allowed.")
serializer.save(last_changed_by=self.request.user)
class AppConfigPublicView(APIView):
"""Read-only public endpoint with limited AppConfig data (logo, background, contact info).
Returns 404 if no configuration exists yet.
"""
authentication_classes = [] # allow anonymous
permission_classes = []
ALLOWED_FIELDS = {
"id",
"logo",
"background_image",
"contact_email",
"contact_phone",
"max_reservations_per_event",
}
def get(self, request):
cfg = AppConfig.get_instance()
if not cfg:
return Response({"detail": "Not configured"}, status=404)
fields_param = request.query_params.get("fields")
if fields_param:
requested = {f.strip() for f in fields_param.split(",") if f.strip()}
valid = [f for f in requested if f in self.ALLOWED_FIELDS]
if not valid:
return Response({
"detail": "No valid fields requested. Allowed: " + ", ".join(sorted(self.ALLOWED_FIELDS))
}, status=400)
data = {}
for f in valid:
data[f] = getattr(cfg, f, None)
return Response(data)
# default full public subset
return Response(AppConfigPublicSerializer(cfg).data)
@extend_schema(
tags=["Trash"],
description=(
"Agregovaný seznam všech soft-smazaných (is_deleted=True) objektů napříč aplikacemi definovanými v `settings.MY_CREATED_APPS`.\n\n"
"Pagination params:\n"
"- `page` (int, default=1)\n"
"- `page_size` nebo `limit` (int, default=20, max=200)\n\n"
"Volitelné parametry do budoucna: `apps` (comma-separated) pokud bude přidána filtrace.\n\n"
"Response obsahuje pole `trash` a objekt `pagination`. Každá položka má strukturu:\n"
"`{ model: 'app_label.model', id: <pk>, deleted_at: <datetime|null>, data: { ...fields } }`."
),
parameters=[
OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Číslo stránky (>=1)"),
OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Počet záznamů na stránce (default 20, max 200)"),
OpenApiParameter(name="limit", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Alias pro page_size"),
],
)
class TrashView(APIView):
permission_classes = [OnlyRolesAllowed("admin")]
def get(self, request):
# Optional filtering by apps (?apps=account,booking)
ctx = {"request": request}
apps_param = request.query_params.get("apps")
if apps_param:
ctx["apps"] = [a.strip() for a in apps_param.split(",") if a.strip()]
serializer = TrashSerializer(context=ctx)
return Response(serializer.data)
@extend_schema(
request={
'application/json': {
'type': 'object',
'properties': {
'model': {'type': 'string', 'example': 'booking.event', 'description': 'app_label.model_name (lowercase)'},
'id': {'type': 'string', 'example': '5', 'description': 'Primární klíč objektu'},
},
'required': ['model', 'id']
}
},
responses={200: dict, 400: dict, 404: dict},
methods=["PATCH"],
description=(
"Obnovení (undelete) jednoho objektu dle model labelu a ID. Nastaví `is_deleted=False` a `deleted_at=None`.\n\n"
"Body JSON:\n"
"{ 'model': 'booking.event', 'id': '5' }\n\n"
"Pokud už objekt není smazaný, operace je idempotentní a jen vrátí informaci, že je aktivní."
),
)
def patch(self, request):
model_label = request.data.get("model")
obj_id = request.data.get("id")
if not model_label or not obj_id:
return Response({
"success": False,
"error": "Missing 'model' or 'id' in request body"
}, status=400)
if "." not in model_label:
return Response({"success": False, "error": "'model' must be in format app_label.model_name"}, status=400)
app_label, model_name = model_label.split(".", 1)
try:
model = django_apps.get_model(app_label, model_name)
except LookupError:
return Response({"success": False, "error": f"Model '{model_label}' not found"}, status=404)
# Ensure model has is_deleted
if not hasattr(model, 'is_deleted') and 'is_deleted' not in [f.name for f in model._meta.fields]:
return Response({"success": False, "error": f"Model '{model_label}' is not soft-deletable"}, status=400)
manager = getattr(model, 'all_objects', model._default_manager)
try:
instance = manager.get(pk=obj_id)
except model.DoesNotExist:
return Response({"success": False, "error": f"Object with id={obj_id} not found"}, status=404)
current_state = getattr(instance, 'is_deleted', False)
if current_state:
# Restore
setattr(instance, 'is_deleted', False)
if hasattr(instance, 'deleted_at'):
setattr(instance, 'deleted_at', None)
instance.save(update_fields=[f.name for f in instance._meta.fields if f.name in ('is_deleted', 'deleted_at')])
state_changed = True
message = "Object restored"
else:
state_changed = False
message = "No state change already active"
# Build minimal representation
data_repr = {}
for f in instance._meta.fields:
if f.name in ('is_deleted', 'deleted_at'):
continue
try:
val = getattr(instance, f.name)
if f.is_relation and hasattr(val, 'pk'):
val = val.pk
except Exception:
val = None
data_repr[f.name] = val
return Response({
"success": True,
"changed": state_changed,
"message": message,
"item": {
"model": model_label.lower(),
"id": instance.pk,
"is_deleted": getattr(instance, 'is_deleted', False),
"deleted_at": getattr(instance, 'deleted_at', None),
"data": data_repr,
}
}, status=200)