diff --git a/open_schools_platform/marketplace_management/admin.py b/open_schools_platform/marketplace_management/admin.py
index 648cf6e5..3e7ad768 100644
--- a/open_schools_platform/marketplace_management/admin.py
+++ b/open_schools_platform/marketplace_management/admin.py
@@ -1,141 +1,211 @@
+from typing import Any, List, Tuple
from django.contrib import admin
-from django.utils.html import format_html
+from django.http import HttpRequest
from django.utils import timezone
-from typing import Any, List, Tuple
+from django.utils.html import format_html
from open_schools_platform.common.admin import admin_wrapper, BaseAdmin
-from open_schools_platform.marketplace_management.models import Installation, DeveloperProfile, Category, App, \
- AppRelease, Review
+from open_schools_platform.marketplace_management.models import (
+ DeveloperProfile,
+ Category,
+ App,
+ AppRelease,
+ Review,
+ Installation,
+ InstallationStatusLog,
+)
+from open_schools_platform.marketplace_management.services.installation_status import (
+ InstallationStatusService,
+)
@admin_wrapper(DeveloperProfile)
class DeveloperProfileModelAdmin(BaseAdmin):
- list_display = ("id", "user", "email", "github")
- field_to_highlight = "id"
+ list_display: Tuple[str, ...] = ("id", "user", "email", "github")
+ field_to_highlight: str = "id"
@admin_wrapper(Category)
class CategoryModelAdmin(BaseAdmin):
- list_display = ("id", "name")
+ list_display: Tuple[str, ...] = ("id", "name")
@admin_wrapper(App)
class AppModelAdmin(BaseAdmin):
- list_display = ("id", "name", "status", "developer_profile")
+ list_display: Tuple[str, ...] = ("id", "name", "status", "developer_profile")
@admin_wrapper(AppRelease)
class AppReleaseModelAdmin(BaseAdmin):
- list_display = ("id", "app", "version")
- field_to_highlight = "id"
+ list_display: Tuple[str, ...] = ("id", "app", "version")
+ field_to_highlight: str = "id"
@admin_wrapper(Review)
class ReviewModelAdmin(BaseAdmin):
- list_display = ("id", "user", "app", "rating")
- field_to_highlight = "app"
+ list_display: Tuple[str, ...] = ("id", "user", "app", "rating")
+ field_to_highlight: str = "app"
-class InstallationFilter(admin.SimpleListFilter):
- """Custom filter for the status"""
- title = 'Статус'
- parameter_name = 'status'
+class InstallationStatusLogInline(admin.TabularInline):
+ model = InstallationStatusLog
+ extra: int = 0
+ can_delete: bool = False
+ show_change_link: bool = False
+
+ readonly_fields: Tuple[str, ...] = (
+ "old_status",
+ "new_status",
+ "changed_by",
+ "changed_at",
+ )
+
+ fields: Tuple[str, ...] = readonly_fields
+ ordering = ("-changed_at",)
- def lookups(self, request: Any, model_admin: Any) -> List[Tuple[str, str]]:
+ def has_add_permission(self, request: HttpRequest, obj=None) -> bool:
+ return False
+
+ def has_change_permission(self, request: HttpRequest, obj=None) -> bool:
+ return False
+
+
+class InstallationStatusFilter(admin.SimpleListFilter):
+ title: str = "Статус"
+ parameter_name: str = "status"
+
+ def lookups(
+ self,
+ request: HttpRequest,
+ model_admin: admin.ModelAdmin,
+ ) -> List[Tuple[Any, str]]:
return [
- ('active', 'Активные'),
- ('inactive', 'Неактивные'),
+ (Installation.STATUS_ACTIVE, "Активные"),
+ (Installation.STATUS_DISABLED, "Отключённые"),
+ (Installation.STATUS_UNINSTALLED, "Удалённые"),
]
- def queryset(self, request: Any, queryset: Any) -> Any:
- if self.value() == 'active':
- return queryset.filter(active=True)
- if self.value() == 'inactive':
- return queryset.filter(active=False)
+ def queryset(self, request: HttpRequest, queryset):
+ if self.value():
+ return queryset.filter(status=self.value())
return queryset
@admin_wrapper(Installation)
class InstallationModelAdmin(BaseAdmin):
list_display: Tuple[str, ...] = (
- 'get_organization_name',
- 'get_app_name',
- 'get_installed_at',
- 'get_status_display',
- 'active',
- 'id'
+ "get_organization_name",
+ "get_app_name",
+ "get_installed_at",
+ "get_status_display",
+ "status",
+ "active",
+ "id",
)
- # Default sorting (new installations first)
- ordering = ('-installed_at',)
+ ordering: Tuple[str, ...] = ("-installed_at",)
- # Filters in the right panel
- list_filter = (
- 'organization',
- 'app',
- InstallationFilter,
+ list_filter: Tuple[Any, ...] = (
+ "organization",
+ "app",
+ InstallationStatusFilter,
)
- search_fields = (
- 'organization__name',
- 'app__name'
+ search_fields: Tuple[str, ...] = (
+ "organization__name",
+ "app__name",
)
- list_per_page = 20
+ list_per_page: int = 20
- # Fields for quick editing
- list_editable = ('active',)
+ readonly_fields: Tuple[str, ...] = (
+ "active",
+ "installed_at",
+ "disabled_at",
+ "re_activated_at",
+ "uninstalled_at",
+ )
- def get_organization_name(self, obj: Installation) -> str:
- return obj.organization.name if obj.organization else 'Нет школы'
+ field_to_highlight: str = "app"
+
+ inlines = (InstallationStatusLogInline,)
+
+ fields: Tuple[str, ...] = (
+ "organization",
+ "app",
+ "user",
+ "status",
+ "active",
+ "installed_at",
+ "disabled_at",
+ "re_activated_at",
+ "uninstalled_at",
+ "config_data",
+ )
- get_organization_name.short_description = 'Школа' # type: ignore[attr-defined]
- get_organization_name.admin_order_field = 'organization__name' # type: ignore[attr-defined]
+ @admin.display(description="Школа", ordering="organization__name")
+ def get_organization_name(self, obj: Installation) -> str:
+ return obj.organization.name if obj.organization else "Нет школы"
+ @admin.display(description="Приложение", ordering="app__name")
def get_app_name(self, obj: Installation) -> str:
- return obj.app.name if obj.app else 'Нет приложения'
-
- get_app_name.short_description = 'Приложение' # type: ignore[attr-defined]
- get_app_name.admin_order_field = 'app__name' # type: ignore[attr-defined]
+ return obj.app.name if obj.app else "Нет приложения"
+ @admin.display(description="Дата установки", ordering="installed_at")
def get_installed_at(self, obj: Installation) -> str:
- if obj.installed_at:
- local_time = timezone.localtime(obj.installed_at)
- return local_time.strftime('%d.%m.%Y %H:%M')
- return 'Нет даты'
-
- get_installed_at.short_description = 'Дата установки' # type: ignore[attr-defined]
- get_installed_at.admin_order_field = 'installed_at' # type: ignore[attr-defined]
+ if not obj.installed_at:
+ return "Нет даты"
+ local_time = timezone.localtime(obj.installed_at)
+ return local_time.strftime("%d.%m.%Y %H:%M")
+ @admin.display(description="Статус")
def get_status_display(self, obj: Installation) -> str:
- if obj.active:
- return format_html(
- '✓ Активен'
+ color_map: dict[str, str] = {
+ Installation.STATUS_ACTIVE: "green",
+ Installation.STATUS_DISABLED: "orange",
+ Installation.STATUS_UNINSTALLED: "red",
+ }
+ label_map: dict[str, str] = {
+ Installation.STATUS_ACTIVE: "Активен",
+ Installation.STATUS_DISABLED: "Отключён",
+ Installation.STATUS_UNINSTALLED: "Удалён",
+ }
+
+ return format_html(
+ '{}',
+ color_map.get(obj.status, "gray"),
+ label_map.get(obj.status, obj.status),
+ )
+
+ def get_queryset(self, request: HttpRequest):
+ return (
+ super()
+ .get_queryset(request)
+ .select_related("organization", "app")
+ )
+
+ def save_model(
+ self,
+ request: HttpRequest,
+ obj: Installation,
+ form,
+ change: bool,
+ ) -> None:
+ if not change:
+ super().save_model(request, obj, form, change)
+ return
+
+ old_status: str = Installation.objects.get(pk=obj.pk).status
+ new_status: str = form.cleaned_data.get("status")
+
+ if old_status != new_status:
+ # rollback in-memory value
+ obj.status = old_status
+
+ InstallationStatusService.change_status(
+ installation=obj,
+ new_status=new_status,
+ user=request.user,
)
else:
- return format_html(
- '✗ Неактивен'
- )
-
- get_status_display.short_description = 'Статус' # type: ignore[attr-defined]
-
- def get_queryset(self, request: Any) -> Any:
- qs = super().get_queryset(request)
- return qs.select_related('organization', 'app')
-
- # Adding custom actions
- actions = ('activate_installations', 'deactivate_installations',)
-
- def activate_installations(self, request: Any, queryset: Any) -> None:
- updated = queryset.update(active=True)
- self.message_user(request, f'{updated} установок активировано')
-
- activate_installations.short_description = "Активировать выбранные установки" # type: ignore[attr-defined]
-
- def deactivate_installations(self, request: Any, queryset: Any) -> None:
- updated = queryset.update(active=False)
- self.message_user(request, f'{updated} установок деактивировано')
-
- deactivate_installations.short_description = "Деактивировать выбранные установки" # type: ignore[attr-defined]
-
- field_to_highlight = "app"
+ super().save_model(request, obj, form, change)
diff --git a/open_schools_platform/marketplace_management/migrations/0002_auto_20260112_1930.py b/open_schools_platform/marketplace_management/migrations/0002_auto_20260112_1930.py
new file mode 100644
index 00000000..c345ed83
--- /dev/null
+++ b/open_schools_platform/marketplace_management/migrations/0002_auto_20260112_1930.py
@@ -0,0 +1,58 @@
+# Generated by Django 3.2.12 on 2026-01-12 14:30
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import rules.contrib.models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('marketplace_management', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='installation',
+ name='disabled_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='installation',
+ name='re_activated_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='installation',
+ name='status',
+ field=models.CharField(choices=[('active', 'Active'), ('disabled', 'Disabled'), ('uninstalled', 'Uninstalled')], db_index=True, default='active', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='installation',
+ name='uninstalled_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.CreateModel(
+ name='InstallationStatusLog',
+ fields=[
+ ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
+ ('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
+ ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
+ ('old_status', models.CharField(max_length=20)),
+ ('new_status', models.CharField(max_length=20)),
+ ('changed_at', models.DateTimeField(auto_now_add=True)),
+ ('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+ ('installation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_logs', to='marketplace_management.installation')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ bases=(rules.contrib.models.RulesModelMixin, models.Model),
+ ),
+ ]
diff --git a/open_schools_platform/marketplace_management/models.py b/open_schools_platform/marketplace_management/models.py
index fbe75f4c..8819090e 100644
--- a/open_schools_platform/marketplace_management/models.py
+++ b/open_schools_platform/marketplace_management/models.py
@@ -76,6 +76,16 @@ class Review(BaseModel):
class Installation(BaseModel):
+ STATUS_DISABLED = "disabled"
+ STATUS_ACTIVE = "active"
+ STATUS_UNINSTALLED = "uninstalled"
+
+ STATUS_CHOICES = (
+ (STATUS_ACTIVE, "Active"),
+ (STATUS_DISABLED, "Disabled"),
+ (STATUS_UNINSTALLED, "Uninstalled"),
+ )
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="installations")
organization = models.ForeignKey(
@@ -87,8 +97,35 @@ class Installation(BaseModel):
User, on_delete=models.CASCADE, related_name="installations"
)
installed_at = models.DateTimeField(auto_now_add=True)
- config_data = models.JSONField(default=dict)
+ status = models.CharField(
+ max_length=20,
+ choices=STATUS_CHOICES,
+ default=STATUS_ACTIVE,
+ db_index=True,
+ )
active = models.BooleanField(default=True)
+ disabled_at = models.DateTimeField(null=True, blank=True)
+ re_activated_at = models.DateTimeField(null=True, blank=True)
+ uninstalled_at = models.DateTimeField(null=True, blank=True)
+ config_data = models.JSONField(default=dict)
class Meta:
unique_together = ["app", "organization"]
+
+
+class InstallationStatusLog(BaseModel):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4)
+ installation = models.ForeignKey(
+ Installation,
+ on_delete=models.CASCADE,
+ related_name="status_logs",
+ )
+ old_status = models.CharField(max_length=20)
+ new_status = models.CharField(max_length=20)
+ changed_by = models.ForeignKey(
+ User,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ )
+ changed_at = models.DateTimeField(auto_now_add=True)
diff --git a/open_schools_platform/marketplace_management/serializers.py b/open_schools_platform/marketplace_management/serializers.py
index 20b7a2a7..0a8bcd41 100644
--- a/open_schools_platform/marketplace_management/serializers.py
+++ b/open_schools_platform/marketplace_management/serializers.py
@@ -70,3 +70,7 @@ def get_app(self, obj):
class Meta:
model = Installation
fields = ["id", "school", "app", "installed_at", "status"]
+
+
+class InstallationStatusUpdateSerializer(serializers.Serializer):
+ status = serializers.ChoiceField(choices=Installation.STATUS_CHOICES)
diff --git a/open_schools_platform/marketplace_management/services/installation_status.py b/open_schools_platform/marketplace_management/services/installation_status.py
new file mode 100644
index 00000000..7ac55119
--- /dev/null
+++ b/open_schools_platform/marketplace_management/services/installation_status.py
@@ -0,0 +1,70 @@
+from django.utils import timezone
+from django.db import transaction
+
+from open_schools_platform.marketplace_management.models import (
+ Installation,
+ InstallationStatusLog,
+)
+
+
+class InstallationStatusService:
+
+ @classmethod
+ @transaction.atomic
+ def change_status(cls, installation: Installation, new_status: str, user):
+ old_status = installation.status
+
+ if old_status == new_status:
+ return installation
+
+ # --- HOOKS ---
+ manifest = cls._get_manifest(installation)
+
+ if new_status == Installation.STATUS_DISABLED:
+ cls._call_hook(manifest, "disable_hook")
+ installation.disabled_at = timezone.now()
+ installation.active = False
+
+ elif new_status == Installation.STATUS_ACTIVE:
+ if old_status == Installation.STATUS_DISABLED:
+ cls._call_hook(manifest, "init_hook")
+ installation.re_activated_at = timezone.now()
+ installation.active = True
+
+ elif new_status == Installation.STATUS_UNINSTALLED:
+ cls._call_hook(manifest, "uninstall_hook")
+ installation.config_data = {}
+ installation.uninstalled_at = timezone.now()
+ installation.active = False
+
+ # --- SAVE ---
+ installation.status = new_status
+ installation.save(update_fields=[
+ "status",
+ "active",
+ "disabled_at",
+ "re_activated_at",
+ "uninstalled_at",
+ "config_data",
+ ])
+
+ # --- LOG ---
+ InstallationStatusLog.objects.create(
+ installation=installation,
+ old_status=old_status,
+ new_status=new_status,
+ changed_by=user,
+ )
+
+ return installation
+
+ @staticmethod
+ def _get_manifest(installation: Installation) -> dict:
+ release = installation.app.versions.order_by("-date").first()
+ return release.manifest if release else {}
+
+ @staticmethod
+ def _call_hook(manifest: dict, hook_name: str):
+ hook = manifest.get(hook_name)
+ if callable(hook):
+ hook()
diff --git a/open_schools_platform/marketplace_management/urls.py b/open_schools_platform/marketplace_management/urls.py
index 1ccc48c0..f85e4b1e 100644
--- a/open_schools_platform/marketplace_management/urls.py
+++ b/open_schools_platform/marketplace_management/urls.py
@@ -15,6 +15,11 @@
),
name="miniapps-installations-detail",
),
+ path(
+ "installations//status",
+ InstallationsViewSet.as_view({"patch": "change_status"}),
+ name="installations-change-status",
+ ),
path(
"installations",
InstallationsViewSet.as_view(
diff --git a/open_schools_platform/marketplace_management/views.py b/open_schools_platform/marketplace_management/views.py
index e4f4cb75..63702f58 100644
--- a/open_schools_platform/marketplace_management/views.py
+++ b/open_schools_platform/marketplace_management/views.py
@@ -2,6 +2,8 @@
from drf_yasg.utils import swagger_auto_schema
from rest_framework.exceptions import PermissionDenied, NotFound
from rest_framework.viewsets import ModelViewSet
+from rest_framework.decorators import action
+from rest_framework.response import Response
from open_schools_platform.api.mixins import ApiAuthMixin
from open_schools_platform.api.swagger_tags import SwaggerTags
@@ -30,6 +32,10 @@
InstallationCreateSerializer,
InstallationSerializer,
InstallationListSerializer,
+ InstallationStatusUpdateSerializer,
+)
+from open_schools_platform.marketplace_management.services.installation_status import (
+ InstallationStatusService,
)
from open_schools_platform.organization_management.employees.models import Employee
@@ -52,13 +58,29 @@ def list(self, request, *args, **kwargs):
class InstallationsViewSet(ApiAuthMixin, ModelViewSet):
+ queryset = Installation.objects.select_related("organization", "app")
serializer_class = InstallationSerializer
- queryset = Installation.objects.all()
def get_serializer_class(self):
if self.action == "create":
return InstallationCreateSerializer
- return self.serializer_class
+ if self.action == "change_status":
+ return InstallationStatusUpdateSerializer
+ return InstallationSerializer
+
+ @staticmethod
+ def _can_manage_installation(user, installation) -> bool:
+ if not user or not user.is_authenticated:
+ return False
+
+ if getattr(user, "is_admin", False):
+ return True
+
+ organization = installation.organization
+
+ return organization.teachers.filter(
+ teacher_profile__user_id=user.id
+ ).exists()
@swagger_auto_schema(
operation_description="Create new installation",
@@ -67,6 +89,34 @@ def get_serializer_class(self):
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
+ # PATCH /installations/{id}/status
+ @action(detail=True, methods=["patch"], url_path="status")
+ @swagger_auto_schema(
+ operation_description="Change installation status",
+ tags=[SwaggerTags.MARKETPLACE_MANAGEMENT],
+ request_body=InstallationStatusUpdateSerializer,
+ responses={200: InstallationSerializer},
+ )
+ def change_status(self, request, pk=None):
+ installation = self.get_object()
+
+ if not self._can_manage_installation(request.user, installation):
+ return Response(
+ {"detail": "No rights to manage the organization"},
+ status=403,
+ )
+
+ serializer = InstallationStatusUpdateSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ InstallationStatusService.change_status(
+ installation=installation,
+ new_status=serializer.validated_data["status"],
+ user=request.user,
+ )
+
+ return Response(InstallationSerializer(installation).data)
+
def perform_create(self, serializer):
if Installation.objects.filter(
app_id=serializer.data["app"],