init
This commit is contained in:
0
backend/configuration/__init__.py
Normal file
0
backend/configuration/__init__.py
Normal file
22
backend/configuration/admin.py
Normal file
22
backend/configuration/admin.py
Normal 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)
|
||||
6
backend/configuration/apps.py
Normal file
6
backend/configuration/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ConfigurationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'configuration'
|
||||
28
backend/configuration/migrations/0001_initial.py
Normal file
28
backend/configuration/migrations/0001_initial.py
Normal 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.')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
0
backend/configuration/migrations/__init__.py
Normal file
0
backend/configuration/migrations/__init__.py
Normal file
88
backend/configuration/models.py
Normal file
88
backend/configuration/models.py
Normal 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)
|
||||
159
backend/configuration/serializers.py
Normal file
159
backend/configuration/serializers.py
Normal 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}
|
||||
3
backend/configuration/tests.py
Normal file
3
backend/configuration/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
backend/configuration/urls.py
Normal file
12
backend/configuration/urls.py
Normal 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'),
|
||||
]
|
||||
200
backend/configuration/views.py
Normal file
200
backend/configuration/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user