Skip to content

Commit

Permalink
Frontend - actions buttons for playbooks and pivots (#2451)
Browse files Browse the repository at this point in the history
* added EditPlaybookConfig modal

* frontend tests

* added plugins  dropdown menu

* create buttons

* added pivot actions buttons

* adjusted pivot field

* fix

* prettier

* fix test

* adjusted useOrganizationStore

* adjusted PivotConfigForm

* prettier

* info icon

* fix tests

* fix

* added note

* fix

* fixes

* fixes

* refactor

* prettier
  • Loading branch information
carellamartina committed Sep 17, 2024
1 parent 6e37a2b commit f278b09
Show file tree
Hide file tree
Showing 40 changed files with 3,549 additions and 762 deletions.
10 changes: 10 additions & 0 deletions api_app/pivots_manager/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ def has_object_permission(request, view, obj):
obj.starting_job.user.pk == request.user.pk
and obj.ending_job.user.pk == request.user.pk
)


class PivotActionsPermission(BasePermission):
@staticmethod
def has_object_permission(request, view, obj):
# only an admin or superuser can update or delete pivots
if request.user.has_membership():
return request.user.membership.is_admin
else:
return request.user.is_superuser
50 changes: 47 additions & 3 deletions api_app/pivots_manager/serializers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from rest_framework import serializers as rfs
from rest_framework.exceptions import ValidationError

from api_app.models import Job
from api_app.analyzers_manager.models import AnalyzerConfig
from api_app.connectors_manager.models import ConnectorConfig
from api_app.models import Job, PythonModule
from api_app.pivots_manager.models import PivotConfig, PivotMap, PivotReport
from api_app.playbooks_manager.models import PlaybookConfig
from api_app.serializers.plugin import (
PluginConfigSerializer,
PythonConfigSerializer,
PythonConfigSerializerForMigration,
)
Expand Down Expand Up @@ -57,15 +60,56 @@ class PivotConfigSerializer(PythonConfigSerializer):
queryset=PlaybookConfig.objects.all(), slug_field="name", many=True
)

name = rfs.CharField(read_only=True)
description = rfs.CharField(read_only=True)
related_configs = rfs.SlugRelatedField(read_only=True, many=True, slug_field="name")
related_analyzer_configs = rfs.SlugRelatedField(
slug_field="name",
queryset=AnalyzerConfig.objects.all(),
many=True,
required=False,
)
related_connector_configs = rfs.SlugRelatedField(
slug_field="name",
queryset=ConnectorConfig.objects.all(),
many=True,
required=False,
)
python_module = rfs.SlugRelatedField(
queryset=PythonModule.objects.all(), slug_field="module"
)
plugin_config = rfs.DictField(write_only=True, required=False)

class Meta:
model = PivotConfig
exclude = ["related_analyzer_configs", "related_connector_configs"]
fields = rfs.ALL_FIELDS
list_serializer_class = PythonConfigSerializer.Meta.list_serializer_class

def validate(self, attrs):
related_analyzer_configs = attrs.get("related_analyzer_configs", [])
related_connector_configs = attrs.get("related_connector_configs", [])
if (
not self.instance
and not related_analyzer_configs
and not related_connector_configs
):
raise ValidationError(
{"detail": "No Analyzers and Connectors attached to pivot"}
)
return attrs

def create(self, validated_data):
plugin_config = validated_data.pop("plugin_config", {})
pc = super().create(validated_data)

# create plugin config
if plugin_config:
plugin_config_serializer = PluginConfigSerializer(
data=plugin_config, context={"request": self.context["request"]}
)
plugin_config_serializer.is_valid(raise_exception=True)
plugin_config_serializer.save()
return pc


class PivotConfigSerializerForMigration(PythonConfigSerializerForMigration):
related_analyzer_configs = rfs.SlugRelatedField(
Expand Down
17 changes: 17 additions & 0 deletions api_app/pivots_manager/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,20 @@ def m2m_changed_pivot_config_connector_config(
if action.startswith("post"):
instance.description = instance._generate_full_description()
instance.save()


@receiver(m2m_changed, sender=PivotConfig.playbooks_choice.through)
def m2m_changed_pivot_config_playbooks_choice(
sender,
instance: PivotConfig,
action: str,
reverse,
model,
pk_set,
using,
*args,
**kwargs,
):
if action.startswith("post"):
instance.description = instance._generate_full_description()
instance.save()
40 changes: 36 additions & 4 deletions api_app/pivots_manager/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
from rest_framework import viewsets
from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticated

from api_app.pivots_manager.models import PivotMap, PivotReport
from api_app.pivots_manager.permissions import PivotOwnerPermission
from api_app.pivots_manager.models import PivotConfig, PivotMap, PivotReport
from api_app.pivots_manager.permissions import (
PivotActionsPermission,
PivotOwnerPermission,
)
from api_app.pivots_manager.serializers import PivotConfigSerializer, PivotMapSerializer
from api_app.views import PythonConfigViewSet, PythonReportActionViewSet


class PivotConfigViewSet(PythonConfigViewSet):
class PivotConfigViewSet(
PythonConfigViewSet,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
serializer_class = PivotConfigSerializer
queryset = PivotConfig.objects.all()

def get_queryset(self):
return (
super()
.get_queryset()
.prefetch_related(
"related_analyzer_configs",
"related_connector_configs",
"playbooks_choice",
)
)

def get_permissions(self):
permissions = super().get_permissions()
if self.action in ["destroy", "update", "partial_update"]:
permissions.append(PivotActionsPermission())
return permissions

def perform_destroy(self, instance: PivotConfig):
for pivot_map in PivotMap.objects.filter(pivot_config=instance):
pivot_map.pivot_config = None
pivot_map.save()
return super().perform_destroy(instance)


class PivotActionViewSet(PythonReportActionViewSet):
Expand Down
16 changes: 11 additions & 5 deletions api_app/playbooks_manager/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from api_app.serializers import ModelWithOwnershipSerializer
from api_app.serializers.job import TagSerializer
from api_app.serializers.plugin import AbstractConfigSerializerForMigration
from api_app.visualizers_manager.models import VisualizerConfig


class PlaybookConfigSerializerForMigration(AbstractConfigSerializerForMigration):
Expand All @@ -31,7 +32,7 @@ class Meta:
model = PlaybookConfig
fields = rfs.ALL_FIELDS

type = rfs.ListField(child=rfs.CharField(read_only=True), read_only=True)
type = rfs.ListField(child=rfs.CharField(), required=False)
analyzers = rfs.SlugRelatedField(
many=True,
queryset=AnalyzerConfig.objects.all(),
Expand All @@ -48,7 +49,12 @@ class Meta:
pivots = rfs.SlugRelatedField(
many=True, queryset=PivotConfig.objects.all(), required=True, slug_field="name"
)
visualizers = rfs.SlugRelatedField(read_only=True, many=True, slug_field="name")
visualizers = rfs.SlugRelatedField(
many=True,
queryset=VisualizerConfig.objects.all(),
required=False,
slug_field="name",
)

runtime_configuration = rfs.DictField(required=True)

Expand All @@ -57,18 +63,18 @@ class Meta:
tags = TagSerializer(required=False, allow_empty=True, many=True, read_only=True)
tlp = rfs.CharField(read_only=True)
weight = rfs.IntegerField(read_only=True, required=False, allow_null=True)
is_deletable = rfs.SerializerMethodField()
is_editable = rfs.SerializerMethodField()
tags_labels = rfs.ListField(
child=rfs.CharField(required=True),
default=list,
required=False,
write_only=True,
)

def get_is_deletable(self, instance: PlaybookConfig):
def get_is_editable(self, instance: PlaybookConfig):
# if the playbook is not a default one
if instance.owner:
# it is deletable by the owner of the playbook
# it is editable/deletable by the owner of the playbook
# or by an admin of the same organization
if instance.owner == self.context["request"].user or (
self.context["request"].user.membership.is_admin
Expand Down
2 changes: 1 addition & 1 deletion api_app/playbooks_manager/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
from typing import Type

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from rest_framework.exceptions import ValidationError

from api_app.pivots_manager.models import PivotConfig
from api_app.playbooks_manager.models import PlaybookConfig
Expand Down
10 changes: 5 additions & 5 deletions api_app/serializers/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from api_app.connectors_manager.models import ConnectorConfig
from api_app.ingestors_manager.models import IngestorConfig
from api_app.models import Parameter, PluginConfig, PythonConfig, PythonModule
from api_app.pivots_manager.models import PivotConfig
from api_app.serializers import ModelWithOwnershipSerializer
from api_app.serializers.celery import CrontabScheduleSerializer
from api_app.visualizers_manager.models import VisualizerConfig
Expand Down Expand Up @@ -79,7 +80,7 @@ def to_representation(self, value):
return json.dumps(result)
return result

type = rfs.ChoiceField(choices=["1", "2", "3", "4"]) # retrocompatibility
type = rfs.ChoiceField(choices=["1", "2", "3", "4", "5"]) # retrocompatibility
config_type = rfs.ChoiceField(choices=["1", "2"]) # retrocompatibility
attribute = rfs.CharField()
plugin_name = rfs.CharField()
Expand Down Expand Up @@ -113,6 +114,8 @@ def validate(self, attrs):
class_ = VisualizerConfig
elif _type == "4":
class_ = IngestorConfig
elif _type == "5":
class_ = PivotConfig
else:
raise RuntimeError("Not configured")
# we set the pointers allowing retro-compatibility from the frontend
Expand Down Expand Up @@ -265,7 +268,7 @@ class AbstractConfigSerializer(rfs.ModelSerializer): ...


class PythonConfigSerializer(AbstractConfigSerializer):
parameters = ParameterSerializer(write_only=True, many=True)
parameters = ParameterSerializer(write_only=True, many=True, required=False)

class Meta:
exclude = [
Expand All @@ -277,9 +280,6 @@ class Meta:
]
list_serializer_class = PythonConfigListSerializer

def to_internal_value(self, data):
raise NotImplementedError()

def to_representation(self, instance: PythonConfig):
result = super().to_representation(instance)
result["disabled"] = result["disabled"] | instance.health_check_status
Expand Down
6 changes: 6 additions & 0 deletions api_app/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def post_save_python_config_cache(sender, instance, *args, **kwargs):
"""
Signal receiver for the post_save signal.
Deletes class cache keys for instances of ListCachable models.
Refreshes cache keys associated with the PythonConfig instance.
Args:
sender (Model): The model class sending the signal.
Expand All @@ -270,13 +271,16 @@ def post_save_python_config_cache(sender, instance, *args, **kwargs):
"""
if issubclass(sender, ListCachable):
instance.delete_class_cache_keys()
if issubclass(sender, PythonConfig):
instance.refresh_cache_keys()


@receiver(models.signals.post_delete)
def post_delete_python_config_cache(sender, instance, *args, **kwargs):
"""
Signal receiver for the post_delete signal.
Deletes class cache keys for instances of ListCachable models after deletion.
Refreshes cache keys associated with the PythonConfig instance after deletion.
Args:
sender (Model): The model class sending the signal.
Expand All @@ -288,6 +292,8 @@ def post_delete_python_config_cache(sender, instance, *args, **kwargs):
"""
if issubclass(sender, ListCachable):
instance.delete_class_cache_keys()
if issubclass(sender, PythonConfig):
instance.refresh_cache_keys()


@receiver(models.signals.post_save, sender=LogEntry)
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/components/common/form/ScanConfigSelectInput.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from "react";
import PropTypes from "prop-types";
import { FormGroup, Input, Label, UncontrolledTooltip } from "reactstrap";
import { MdInfoOutline } from "react-icons/md";

import { ScanModesNumeric } from "../../../constants/advancedSettingsConst";

export function ScanConfigSelectInput(props) {
const { formik } = props;
console.debug("ScanConfigSelectInput - formik:");
console.debug(formik);

return (
<div>
<FormGroup
check
key="checkchoice__check_all"
className="d-flex align-items-center justify-content-between"
>
<div>
<Input
id="checkchoice__check_all"
type="radio"
name="scan_mode"
value={ScanModesNumeric.CHECK_PREVIOUS_ANALYSIS}
onChange={formik.handleChange}
checked={
formik.values.scan_mode ===
ScanModesNumeric.CHECK_PREVIOUS_ANALYSIS
}
/>
<Label check for="checkchoice__check_all">
Do not execute if a similar analysis is currently running or
reported without fails
</Label>
</div>
<div className="col-3 d-flex align-items-center">
H:
<div className="col-8 mx-1">
<Input
id="checkchoice__check_all__minutes_ago"
type="number"
name="scan_check_time"
value={formik.values.scan_check_time}
onChange={formik.handleChange}
/>
</div>
<div className="col-2">
<MdInfoOutline id="minutes-ago-info-icon" />
<UncontrolledTooltip
target="minutes-ago-info-icon"
placement="right"
fade={false}
innerClassName="p-2 border border-info text-start text-nowrap md-fit-content"
>
<span>
Max age (in hours) for the similar analysis.
<br />
The default value is 24 hours (1 day).
<br />
Empty value takes all the previous analysis.
</span>
</UncontrolledTooltip>
</div>
</div>
</FormGroup>

<FormGroup check key="checkchoice__force_new">
<Input
id="checkchoice__force_new"
type="radio"
name="scan_mode"
value={ScanModesNumeric.FORCE_NEW_ANALYSIS}
onChange={formik.handleChange}
checked={
formik.values.scan_mode === ScanModesNumeric.FORCE_NEW_ANALYSIS
}
/>
<Label check for="checkchoice__force_new">
Force new analysis
</Label>
</FormGroup>
</div>
);
}

ScanConfigSelectInput.propTypes = {
formik: PropTypes.object.isRequired,
};
Loading

0 comments on commit f278b09

Please sign in to comment.