201 lines
8.1 KiB
Python
201 lines
8.1 KiB
Python
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)
|