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"],