From 7f96a17463bf05670ffdd58bfb72bf4718e34009 Mon Sep 17 00:00:00 2001 From: Anton M <anton2008m@gmail.com> Date: Thu, 26 Dec 2024 00:24:35 +0100 Subject: [PATCH] backup backend + crud --- requirements/base.txt | 1 + validity/api/helpers.py | 2 +- validity/api/serializers.py | 26 +++++++ validity/api/urls.py | 1 + validity/api/views.py | 6 ++ validity/choices.py | 5 ++ validity/data_backends.py | 1 + validity/data_backup/__init__.py | 2 + validity/data_backup/backend.py | 26 +++++++ validity/data_backup/backupers.py | 74 ++++++++++++++++++ validity/data_backup/entities.py | 23 ++++++ validity/data_backup/parameters.py | 13 ++++ validity/dependencies.py | 43 +++++++++- validity/fields/encrypted.py | 67 ++++++++++++---- validity/filtersets.py | 9 +++ validity/forms/__init__.py | 3 + validity/forms/bulk_import.py | 16 ++++ validity/forms/fields.py | 8 +- validity/forms/filterset.py | 15 ++++ validity/forms/general.py | 11 +++ validity/integrations/errors.py | 4 + validity/integrations/git.py | 63 +++++++++++++++ validity/integrations/s3.py | 62 +++++++++++++++ validity/migrations/0012_backuppoint.py | 44 +++++++++++ validity/models/__init__.py | 1 + validity/models/backup.py | 78 +++++++++++++++++++ validity/models/base.py | 10 ++- validity/models/polling.py | 12 ++- validity/models/serializer.py | 13 +++- validity/navigation.py | 1 + validity/search.py | 6 ++ validity/settings.py | 15 ++++ validity/subforms.py | 49 +++++++++++- validity/tables.py | 11 +++ validity/templates/validity/backuppoint.html | 54 +++++++++++++ validity/templates/validity/command.html | 2 +- .../templates/validity/inc/parameters.html | 8 +- validity/templates/validity/serializer.html | 2 +- validity/templatetags/validity.py | 2 +- validity/urls.py | 5 ++ validity/utils/filesystem.py | 18 +++++ validity/views/__init__.py | 8 ++ validity/views/backup.py | 33 ++++++++ validity/views/bulk_import.py | 8 +- 44 files changed, 824 insertions(+), 37 deletions(-) create mode 100644 validity/data_backup/__init__.py create mode 100644 validity/data_backup/backend.py create mode 100644 validity/data_backup/backupers.py create mode 100644 validity/data_backup/entities.py create mode 100644 validity/data_backup/parameters.py create mode 100644 validity/integrations/errors.py create mode 100644 validity/integrations/git.py create mode 100644 validity/integrations/s3.py create mode 100644 validity/migrations/0012_backuppoint.py create mode 100644 validity/models/backup.py create mode 100644 validity/templates/validity/backuppoint.html create mode 100644 validity/utils/filesystem.py create mode 100644 validity/views/backup.py diff --git a/requirements/base.txt b/requirements/base.txt index 007573e..4d18c23 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,3 +1,4 @@ +boto3<2 deepdiff>=6.2.0,<7 dimi >=1.3.0,< 2 django-bootstrap5 >=24.2,<25 diff --git a/validity/api/helpers.py b/validity/api/helpers.py index bd2cb34..1ae8189 100644 --- a/validity/api/helpers.py +++ b/validity/api/helpers.py @@ -103,7 +103,7 @@ def _validate(self, attrs): setattr(instance, field, field_value) if not instance.subform_type: return - subform = instance.subform_cls(instance.subform_json) + subform = instance.get_subform() if not subform.is_valid(): errors = [ ": ".join((field, err[0])) if field != "__all__" else err for field, err in subform.errors.items() diff --git a/validity/api/serializers.py b/validity/api/serializers.py index 4216072..a52ff46 100644 --- a/validity/api/serializers.py +++ b/validity/api/serializers.py @@ -377,6 +377,32 @@ def validate(self, data, command_types: Annotated[dict[str, list[str]], "PollerC NestedPollerSerializer = nested_factory(PollerSerializer, nb_version=config.netbox_version) +class BackupPointSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name="plugins-api:validity-api:backuppoint-detail") + data_source = NestedDataSourceSerializer() + parameters = EncryptedDictField() + + class Meta: + model = models.BackupPoint + fields = ( + "id", + "url", + "display", + "name", + "data_source", + "backup_after_sync", + "method", + "url", + "ignore_rules", + "parameters", + "last_uploaded", + "tags", + "custom_fields", + "created", + "last_updated", + ) + + class SerializedStateItemSerializer(FieldsMixin, serializers.Serializer): name = serializers.CharField(read_only=True) serializer = NestedSerializerSerializer(read_only=True) diff --git a/validity/api/urls.py b/validity/api/urls.py index d6195d2..913fa5b 100644 --- a/validity/api/urls.py +++ b/validity/api/urls.py @@ -15,6 +15,7 @@ router.register("reports", views.ComplianceReportViewSet) router.register("pollers", views.PollerViewSet) router.register("commands", views.CommandViewSet) +router.register("backup-points", views.BackupPointViewSet) urlpatterns = [ diff --git a/validity/api/views.py b/validity/api/views.py index 24370a1..9e05eca 100644 --- a/validity/api/views.py +++ b/validity/api/views.py @@ -101,6 +101,12 @@ class PollerViewSet(NetBoxModelViewSet): filterset_class = filtersets.PollerFilterSet +class BackupPointViewSet(NetBoxModelViewSet): + queryset = models.BackupPoint.objects.select_related("data_source") + serializer_class = serializers.BackupPointSerializer + filterset_class = filtersets.BackupPointFilterSet + + class CommandViewSet(NetBoxModelViewSet): queryset = models.Command.objects.select_related("serializer").prefetch_related("tags") serializer_class = serializers.CommandSerializer diff --git a/validity/choices.py b/validity/choices.py index f530610..9650c21 100644 --- a/validity/choices.py +++ b/validity/choices.py @@ -127,3 +127,8 @@ class JSONAPIMethodChoices(TextChoices): POST = "POST" PATCH = "PATCH" PUT = "PUT" + + +class BackupMethodChoices(TextChoices, metaclass=ColoredChoiceMeta): + git = "git", "blue" + S3 = "S3", "Amazon S3", "yellow" diff --git a/validity/data_backends.py b/validity/data_backends.py index 7c8216a..e8ad769 100644 --- a/validity/data_backends.py +++ b/validity/data_backends.py @@ -27,6 +27,7 @@ class PollingBackend(DataBackend): "datasource_id": forms.IntegerField( label=_("Data Source ID"), widget=forms.TextInput(attrs={"class": "form-control"}), + help_text=_("Must match the primary key of the data source"), ) } diff --git a/validity/data_backup/__init__.py b/validity/data_backup/__init__.py new file mode 100644 index 0000000..63aa6af --- /dev/null +++ b/validity/data_backup/__init__.py @@ -0,0 +1,2 @@ +from .backend import BackupBackend +from .backupers import Backuper, GitBackuper, S3Backuper diff --git a/validity/data_backup/backend.py b/validity/data_backup/backend.py new file mode 100644 index 0000000..6b9af05 --- /dev/null +++ b/validity/data_backup/backend.py @@ -0,0 +1,26 @@ +from contextlib import contextmanager +from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from validity.models import BackupPoint + from .backupers import Backuper + + +class BackupBackend: + def __init__(self, backupers: dict[str, "Backuper"]): + self.backupers = backupers + + @contextmanager + def _datasource_in_filesytem(self, backup_point: "BackupPoint"): + with TemporaryDirectory() as datasource_dir: + for file in backup_point.data_source.datafiles.all(): + if not backup_point.ignore_file(file.path): + file.write_to_disk(datasource_dir, overwrite=True) + yield datasource_dir + + def __call__(self, backup_point: "BackupPoint") -> None: + backuper = self.backupers[backup_point.method] + with self._datasource_in_filesytem(backup_point) as datasource_dir: + backuper(backup_point.url, backup_point.parameters, datasource_dir) diff --git a/validity/data_backup/backupers.py b/validity/data_backup/backupers.py new file mode 100644 index 0000000..801ee52 --- /dev/null +++ b/validity/data_backup/backupers.py @@ -0,0 +1,74 @@ +import shutil +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, ClassVar + +from pydantic import BaseModel + +from validity.integrations.git import GitClient +from validity.integrations.s3 import S3Client +from validity.utils.filesystem import merge_directories +from .entities import RemoteGitRepo +from .parameters import GitParams, S3Params + + +class Backuper(ABC): + parameters_cls: type[BaseModel] + + def __call__(self, url: str, parameters: dict[str, Any], datasource_dir: Path) -> None: + validated_params = self.parameters_cls.model_validate(parameters) + self._do_backup(url, validated_params, datasource_dir) + + @abstractmethod + def _do_backup(self, url: str, parameters: BaseModel, datasource_dir: Path) -> None: ... + + +@dataclass +class GitBackuper(Backuper): + message: str + author_username: str + author_email: str + git_client: GitClient + + parameters_cls: ClassVar[type[BaseModel]] = GitParams + + def _do_backup(self, url: str, parameters: GitParams, datasource_dir: Path) -> None: + with TemporaryDirectory() as repo_dir: + repo = RemoteGitRepo( + local_path=repo_dir, + remote_url=url, + active_branch=parameters.branch, + username=parameters.username, + password=parameters.password, + client=self.git_client, + ) + repo.download() + merge_directories(datasource_dir, repo.local_path) + repo.save_changes(self.author_username, self.author_email, message=self.message) + repo.upload() + + +@dataclass +class S3Backuper(Backuper): + s3_client: S3Client + + parameters_cls: ClassVar[type[BaseModel]] = S3Params + + def _backup_archive(self, url: str, parameters: S3Params, datasource_dir: Path) -> None: + with TemporaryDirectory() as backup_dir: + archive = Path(backup_dir) / "a.zip" + shutil.make_archive(archive, "zip", datasource_dir) + self.s3_client.upload_file(archive, url, parameters.aws_access_key_id, parameters.aws_secret_access_key) + + def _backup_dir(self, url: str, parameters: S3Params, datasource_dir: Path) -> None: + self.s3_client.upload_folder( + datasource_dir, url, parameters.aws_access_key_id, parameters.aws_secret_access_key + ) + + def _do_backup(self, url: str, parameters: S3Params, datasource_dir: Path): + if parameters.archive: + self._backup_archive(url, parameters, datasource_dir) + else: + self._backup_dir(url, parameters, datasource_dir) diff --git a/validity/data_backup/entities.py b/validity/data_backup/entities.py new file mode 100644 index 0000000..80666de --- /dev/null +++ b/validity/data_backup/entities.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + +from validity.integrations.git import GitClient + + +@dataclass(slots=True, kw_only=True) +class RemoteGitRepo: + local_path: str + remote_url: str + active_branch: str + username: str = "" + password: str = "" + client: GitClient + + def save_changes(self, author_username: str, author_email: str, message: str = ""): + self.client.stage_all(self.local_path) + self.client.commit(self.local_path, author_username, author_email, message) + + def download(self): + self.client.clone(self.local_path, self.remote_url, self.active_branch, self.username, self.password, depth=1) + + def upload(self): + self.client.push(self.local_path, self.remote_url, self.active_branch, self.username, self.password) diff --git a/validity/data_backup/parameters.py b/validity/data_backup/parameters.py new file mode 100644 index 0000000..757415d --- /dev/null +++ b/validity/data_backup/parameters.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class GitParams(BaseModel): + username: str + password: str + branch: str | None = None + + +class S3Params(BaseModel): + aws_access_key_id: str + aws_secret_access_key: str + archive: bool diff --git a/validity/dependencies.py b/validity/dependencies.py index 58144cb..0dbb857 100644 --- a/validity/dependencies.py +++ b/validity/dependencies.py @@ -8,6 +8,17 @@ from rq.job import Job from validity import di +from validity.compliance.serialization import ( + SerializationBackend, + serialize_ros, + serialize_textfsm, + serialize_ttp, + serialize_xml, + serialize_yaml, +) +from validity.data_backup import BackupBackend, GitBackuper, S3Backuper +from validity.integrations.git import DulwichGitClient +from validity.integrations.s3 import BotoS3Client from validity.pollers import NetmikoPoller, RequestsPoller, ScrapliNetconfPoller from validity.settings import PollerInfo, ValiditySettings from validity.utils.misc import null_request @@ -18,11 +29,39 @@ def django_settings(): return settings -@di.dependency(scope=Singleton) -def validity_settings(django_settings: Annotated[LazySettings, django_settings]): +@di.dependency(scope=Singleton, add_return_alias=True) +def validity_settings(django_settings: Annotated[LazySettings, django_settings]) -> ValiditySettings: return ValiditySettings.model_validate(django_settings.PLUGINS_CONFIG.get("validity", {})) +@di.dependency(scope=Singleton, add_return_alias=True) +def backup_backend(vsettings: Annotated[ValiditySettings, ...]) -> BackupBackend: + return BackupBackend( + backupers={ + "git": GitBackuper( + message="", + author_username=vsettings.integrations.git.author, + author_email=vsettings.integrations.git.email, + git_client=DulwichGitClient(), + ), + "S3": S3Backuper(s3_client=BotoS3Client(max_threads=vsettings.integrations.s3.threads)), + } + ) + + +@di.dependency(scope=Singleton, add_return_alias=True) +def serialization_backend() -> SerializationBackend: + return SerializationBackend( + extraction_methods={ + "YAML": serialize_yaml, + "ROUTEROS": serialize_ros, + "TTP": serialize_ttp, + "TEXTFSM": serialize_textfsm, + "XML": serialize_xml, + } + ) + + @di.dependency(scope=Singleton) def pollers_info(custom_pollers: Annotated[list[PollerInfo], "validity_settings.custom_pollers"]) -> list[PollerInfo]: return [ diff --git a/validity/fields/encrypted.py b/validity/fields/encrypted.py index 0b3f6df..fbe0b3e 100644 --- a/validity/fields/encrypted.py +++ b/validity/fields/encrypted.py @@ -2,7 +2,7 @@ import os import pickle from dataclasses import dataclass, field -from typing import Any, ClassVar +from typing import Any, ClassVar, Protocol, runtime_checkable from cryptography.fernet import Fernet from cryptography.hazmat.backends import default_backend @@ -12,6 +12,15 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db.models import Field, JSONField +from validity.utils.misc import partialcls + + +@runtime_checkable +class EncryptedValueProtocol(Protocol): + def decrypt(self) -> Any: ... + + def serialize(self) -> str: ... + @dataclass class EncryptedString: @@ -64,16 +73,39 @@ def decrypt(self) -> Any: return pickle.loads(self._fernet.decrypt(self.cipher)) +class NotEncryptedObject: + def __init__(self, obj) -> None: + self.obj = obj + + def serialize(self): + return self.obj + + def decrypt(self): + return self.obj + + class EncryptedDict(dict): - def __init__(self, iterable=()): + def __init__(self, iterable=(), do_not_encrypt=()): + self.do_not_encrypt = set(do_not_encrypt) super().__init__() if isinstance(iterable, dict): iterable = iterable.items() for k, v in iterable: - constructor = EncryptedObject.from_object - if isinstance(v, str) and len(v) > 3 and v.startswith("$") and v.endswith("$"): - constructor = EncryptedObject.deserialize - self[k] = constructor(v) + self[k] = v + + def _encrypted_obj(self, key, value): + if isinstance(value, EncryptedValueProtocol): + return value + constructor = EncryptedObject.from_object + if key in self.do_not_encrypt: + constructor = NotEncryptedObject + elif isinstance(value, str) and len(value) > 3 and value.startswith("$") and value.endswith("$"): + constructor = EncryptedObject.deserialize + return constructor(value) + + def __setitem__(self, key, value): + encrypted_obj = self._encrypted_obj(key, value) + super().__setitem__(key, encrypted_obj) @property def encrypted(self) -> dict: @@ -86,13 +118,14 @@ def decrypted(self) -> dict: class EncryptedFieldEncoder(DjangoJSONEncoder): def default(self, o: Any) -> Any: - if isinstance(o, EncryptedString): + if isinstance(o, EncryptedValueProtocol): return o.serialize() return super().default(o) class EncryptedDictField(JSONField): - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: Any, do_not_encrypt=(), **kwargs: Any) -> None: + self.do_not_encrypt = do_not_encrypt kwargs.setdefault("default", EncryptedDict) kwargs["encoder"] = EncryptedFieldEncoder super().__init__(*args, **kwargs) @@ -102,24 +135,29 @@ def deconstruct(self) -> Any: if kwargs.get("default") == EncryptedDict: del kwargs["default"] del kwargs["encoder"] + if self.do_not_encrypt != (): + kwargs["do_not_encrypt"] = self.do_not_encrypt return name, path, args, kwargs + def encrypted_dict(self, value): + return EncryptedDict(value, do_not_encrypt=self.do_not_encrypt) + def from_db_value(self, value, expression, connection): value = super().from_db_value(value, expression, connection) if isinstance(value, dict): - return EncryptedDict(value) + return self.encrypted_dict(value) def get_prep_value(self, value: dict | None) -> dict | None: if isinstance(value, EncryptedDict): value = value.encrypted if isinstance(value, dict): - value = EncryptedDict(value).encrypted + value = self.encrypted_dict(value).encrypted return super().get_prep_value(value) def to_python(self, value): if value is None or isinstance(value, EncryptedDict): return value - return EncryptedDict(value) + return self.encrypted_dict(value) def formfield(self, **kwargs): from validity.forms.fields import EncryptedDictField as EncryptedDictFormField @@ -127,7 +165,7 @@ def formfield(self, **kwargs): return Field.formfield( self, **{ - "form_class": EncryptedDictFormField, + "form_class": partialcls(EncryptedDictFormField, do_not_encrypt=self.do_not_encrypt), "encoder": self.encoder, "decoder": self.decoder, **kwargs, @@ -135,4 +173,7 @@ def formfield(self, **kwargs): ) def value_to_string(self, obj: Any) -> Any: - return super().value_to_string(obj).encrypted + obj = super().value_to_string(obj) + if isinstance(obj, EncryptedDict): + obj = obj.encrypted + return obj diff --git a/validity/filtersets.py b/validity/filtersets.py index faf2fea..4010739 100644 --- a/validity/filtersets.py +++ b/validity/filtersets.py @@ -138,3 +138,12 @@ class Meta: model = models.Command fields = ("id", "name", "label", "type", "retrieves_config", "serializer_id", "poller_id") search_fields = ("name", "label") + + +class BackupPointFilterSet(SearchMixin, NetBoxModelFilterSet): + datasource_id = ModelMultipleChoiceFilter(field_name="data_source", queryset=models.VDataSource.objects.all()) + + class Meta: + model = models.BackupPoint + fields = ("id", "name", "method", "datasource_id", "backup_after_sync", "last_uploaded") + search_fields = ("name",) diff --git a/validity/forms/__init__.py b/validity/forms/__init__.py index 12632fb..7ecfe49 100644 --- a/validity/forms/__init__.py +++ b/validity/forms/__init__.py @@ -1,4 +1,5 @@ from .bulk_import import ( + BackupPointImportForm, CommandImportForm, ComplianceSelectorImportForm, ComplianceTestImportForm, @@ -7,6 +8,7 @@ SerializerImportForm, ) from .filterset import ( + BackupPointFilterForm, CommandFilterForm, ComplianceReportFilerForm, ComplianceSelectorFilterForm, @@ -22,6 +24,7 @@ TestResultFilterForm, ) from .general import ( + BackupPointForm, CommandForm, ComplianceSelectorForm, ComplianceTestForm, diff --git a/validity/forms/bulk_import.py b/validity/forms/bulk_import.py index 50de098..b9f32e9 100644 --- a/validity/forms/bulk_import.py +++ b/validity/forms/bulk_import.py @@ -207,3 +207,19 @@ class PollerImportForm(PollerCleanMixin, NetBoxModelImportForm): class Meta: model = models.Poller fields = ("name", "connection_type", "commands", "public_credentials", "private_credentials") + + +class BackupPointImportForm(NetBoxModelImportForm): + data_source = CSVModelChoiceField( + queryset=models.VDataSource.objects.all(), to_field_name="name", help_text=_("Data Source") + ) + parameters = JSONField( + help_text=_( + "JSON-encoded backup parameters depending on Method value. See REST API to check for specific keys/values" + ) + ) + method = CSVChoiceField(choices=choices.BackupMethodChoices.choices, help_text=_("Backup Method")) + + class Meta: + model = models.BackupPoint + fields = ("name", "data_source", "backup_after_sync", "url", "method", "ignore_rules", "parameters") diff --git a/validity/forms/fields.py b/validity/forms/fields.py index 3b78170..7d81e55 100644 --- a/validity/forms/fields.py +++ b/validity/forms/fields.py @@ -17,10 +17,14 @@ def to_python(self, value: Any | None) -> Any | None: class EncryptedDictField(JSONField): + def __init__(self, *, do_not_encrypt=(), **kwargs: Any) -> None: + self.do_not_encrypt = do_not_encrypt + super().__init__(**kwargs) + def to_python(self, value: Any) -> Any: value = super().to_python(value) - if isinstance(value, dict): - value = EncryptedDict(value) + if isinstance(value, dict) and not isinstance(value, EncryptedDict): + value = EncryptedDict(value, do_not_encrypt=self.do_not_encrypt) return value diff --git a/validity/forms/filterset.py b/validity/forms/filterset.py index 6099719..61081e9 100644 --- a/validity/forms/filterset.py +++ b/validity/forms/filterset.py @@ -13,6 +13,7 @@ from validity import di, models from validity.choices import ( + BackupMethodChoices, BoolOperationChoices, CommandTypeChoices, DeviceGroupByChoices, @@ -191,3 +192,17 @@ class CommandFilterForm(NetBoxModelFilterSetForm): label=_("Serializer"), queryset=models.Serializer.objects.all(), required=False ) poller_id = DynamicModelMultipleChoiceField(label=_("Poller"), queryset=models.Poller.objects.all(), required=False) + + +class BackupPointFilterForm(NetBoxModelFilterSetForm): + model = models.BackupPoint + name = CharField(required=False) + datasource_id = DynamicModelMultipleChoiceField( + label=_("Data Source"), queryset=DataSource.objects.all(), required=False + ) + backup_after_sync = NullBooleanField( + label=_("Backup After Sync"), required=False, widget=Select(choices=BOOLEAN_WITH_BLANK_CHOICES) + ) + method = PlaceholderChoiceField(required=False, label=_("Backup Method"), choices=BackupMethodChoices.choices) + last_uploaded__lte = DateTimeField(required=False, widget=DateTimePicker(), label=_("Last Uploaded Before")) + last_uploaded__gte = DateTimeField(required=False, widget=DateTimePicker(), label=_("Last Uploaded After")) diff --git a/validity/forms/general.py b/validity/forms/general.py index b2628dd..9ba8c41 100644 --- a/validity/forms/general.py +++ b/validity/forms/general.py @@ -164,6 +164,17 @@ class Meta: widgets = {"type": HTMXSelect()} +class BackupPointForm(SubformMixin, NetBoxModelForm): + class Meta: + model = models.BackupPoint + fields = ("name", "data_source", "backup_after_sync", "url", "method", "ignore_rules") + widgets = {"method": HTMXSelect()} + + main_fieldsets = [ + FieldSet("name", "data_source", "backup_after_sync", "url", "method", "ignore_rules", name=_("Backup Point")), + ] + + class RunTestsForm(ScriptForm): sync_datasources = BooleanField( required=False, diff --git a/validity/integrations/errors.py b/validity/integrations/errors.py new file mode 100644 index 0000000..44ab33e --- /dev/null +++ b/validity/integrations/errors.py @@ -0,0 +1,4 @@ +class IntegrationError(Exception): + """ + Error happening during network communication with an integration + """ diff --git a/validity/integrations/git.py b/validity/integrations/git.py new file mode 100644 index 0000000..fb5dcb1 --- /dev/null +++ b/validity/integrations/git.py @@ -0,0 +1,63 @@ +from abc import ABC, abstractmethod +from itertools import chain + +from dulwich import porcelain +from dulwich.ignore import IgnoreFilterManager +from dulwich.index import get_unstaged_changes +from dulwich.repo import Repo + + +class GitClient(ABC): + @abstractmethod + def clone( + self, local_path: str, remote_url: str, branch: str = "", username: str = "", password: str = "", depth: int = 0 + ) -> None: ... + + @abstractmethod + def stage_all(self, repo_path: str) -> None: ... + + @abstractmethod + def commit(self, repo_path: str, username: str, email: str, message: str) -> str: + """ + Returns SHA1 hash of the new commit + """ + + @abstractmethod + def push( + self, local_path: str, remote_url: str, branch: str, username: str, password: str, force: bool = False + ) -> None: ... + + +class DulwichGitClient(GitClient): + def clone( + self, local_path: str, remote_url: str, branch: str = "", username: str = "", password: str = "", depth: int = 0 + ) -> None: + optional_args = {} + if branch: + optional_args["branch"] = branch + if username: + optional_args["username"] = username + if password: + optional_args["password"] = password + optional_args["depth"] = depth or None + porcelain.clone(remote_url, local_path, **optional_args) + + def stage_all(self, repo_path: str) -> None: + repo = Repo(repo_path) + ignore_mgr = IgnoreFilterManager.from_repo(repo) + unstaged_files = (fn.decode("utf-8") for fn in get_unstaged_changes(repo.open_index(), repo_path)) + untracked_files = porcelain.get_untracked_paths(repo_path, repo_path, repo.open_index()) + files = (file for file in chain(unstaged_files, untracked_files) if not ignore_mgr.is_ignored(file)) + repo.stage(files) + + def commit(self, repo_path: str, username: str, email: str, message: str) -> str: + author = f"{username} <{email}>".encode("utf-8") + commit_hash = porcelain.commit(repo=repo_path, author=author, committer=author, message=message.encode("utf-8")) + return commit_hash.decode("utf-8") + + def push( + self, local_path: str, remote_url: str, branch: str, username: str, password: str, force: bool = False + ) -> None: + porcelain.push( + local_path, remote_url, refspecs=branch.encode("utf-8"), force=force, username=username, password=password + ) diff --git a/validity/integrations/s3.py b/validity/integrations/s3.py new file mode 100644 index 0000000..9be2d8b --- /dev/null +++ b/validity/integrations/s3.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from functools import partial +from pathlib import Path +from urllib.parse import urlparse + +import boto3 +from boto3.exceptions import Boto3Error + +from validity.utils.misc import reraise +from .errors import IntegrationError + + +@dataclass(slots=True, frozen=True) +class S3URL: + endpoint: str + bucket: str + key: str + + @classmethod + def parse(cls, url: str): + parsed_url = urlparse(url) + path = parsed_url.path.lstrip("/") + bucket, key = path.split("/", maxsplit=1) if "/" in path else path, "" + return cls(parsed_url.netloc, bucket, key) + + +class S3Client(ABC): + @abstractmethod + def upload_file(self, path: Path, url: str, access_key_id: str, secret_access_key: str) -> None: ... + + @abstractmethod + def upload_folder(self, path: Path, url: str, access_key_id: str, secret_access_key: str) -> None: ... + + +@dataclass +class BotoS3Client(S3Client): + max_threads: int + + def _get_s3_client(self, endpoint: str, access_key_id: str, secret_access_key: str): + return boto3.client( + "s3", endpoint_url=endpoint, aws_access_key_id=access_key_id, aws_secret_access_key=secret_access_key + ) + + def upload_file(self, path: Path, url: str | S3URL, access_key_id: str, secret_access_key: str) -> None: + if not isinstance(url, S3URL): + url = S3URL.parse(url) + client = self._get_s3_client(url.endpoint, access_key_id, secret_access_key) + with reraise(Boto3Error, IntegrationError): + client.upload_file(path, url.bucket, url.key) + + def upload_folder(self, path: Path, url: str, access_key_id: str, secret_access_key: str) -> None: + folder_url = S3URL.parse(url) + futures = [] + with ThreadPoolExecutor(max_workers=self.max_threads) as executor: + for fs_obj in path.rglob("*"): + if fs_obj.is_file(): + file_url = replace(folder_url, key=str(fs_obj.relative_to(folder_url))) + fn = partial(self.upload_file(fs_obj, file_url, access_key_id, secret_access_key)) + futures.append(executor.submit(fn)) + any(fut.result() for fut in futures) diff --git a/validity/migrations/0012_backuppoint.py b/validity/migrations/0012_backuppoint.py new file mode 100644 index 0000000..eea4104 --- /dev/null +++ b/validity/migrations/0012_backuppoint.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.11 on 2024-12-23 23:44 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.json +import validity.fields.encrypted +import validity.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0107_cachedvalue_extras_cachedvalue_object'), + ('validity', '0011_delete_scripts'), + ] + + operations = [ + migrations.CreateModel( + name='BackupPoint', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('name', models.CharField(max_length=255, unique=True)), + ('backup_after_sync', models.BooleanField()), + ('method', models.CharField(max_length=20)), + ('url', models.CharField(max_length=255, validators=[django.core.validators.URLValidator(schemes=['http', 'https'])])), + ('ignore_rules', models.TextField(blank=True)), + ('parameters', validity.fields.encrypted.EncryptedDictField(do_not_encrypt=('username', 'branch', 'aws_access_key_id'))), + ('last_uploaded', models.DateTimeField(blank=True, editable=False, null=True)), + ('data_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='validity.vdatasource')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'Backup Point', + 'verbose_name_plural': 'Backup Points', + 'ordering': ('name',), + }, + bases=(validity.models.base.SubformMixin, validity.models.base.URLMixin, models.Model), + ), + ] diff --git a/validity/models/__init__.py b/validity/models/__init__.py index 79dfa88..121bf1c 100644 --- a/validity/models/__init__.py +++ b/validity/models/__init__.py @@ -1,3 +1,4 @@ +from .backup import BackupPoint from .data import VDataFile, VDataSource from .device import VDevice from .nameset import NameSet diff --git a/validity/models/backup.py b/validity/models/backup.py new file mode 100644 index 0000000..d2b38fe --- /dev/null +++ b/validity/models/backup.py @@ -0,0 +1,78 @@ +from fnmatch import fnmatchcase +from typing import Annotated, Any + +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from validity import di +from validity.choices import BackupMethodChoices +from validity.data_backup import BackupBackend +from validity.fields import EncryptedDictField +from validity.subforms import GitBackupForm, S3BackupForm +from .base import BaseModel, SubformMixin +from .data import VDataSource + + +class BackupPoint(SubformMixin, BaseModel): + name = models.CharField(_("Name"), max_length=255, unique=True) + data_source = models.ForeignKey(VDataSource, verbose_name=_("Data Source"), on_delete=models.CASCADE) + backup_after_sync = models.BooleanField( + _("Backup after sync"), help_text=_("Perform a backup every time the linked Data Source is synced") + ) + method = models.CharField(_("Backup Method"), choices=BackupMethodChoices.choices, max_length=20) + # TODO: add link to the docs scpecifying possible URLs + url = models.CharField(_("URL"), max_length=255, validators=[URLValidator(schemes=["http", "https"])]) + ignore_rules = models.TextField( + verbose_name=_("Ignore Rules"), + blank=True, + help_text=_("Patterns (one per line) matching files to ignore when uploading"), + ) + parameters = EncryptedDictField( + _("Parameters"), do_not_encrypt=("username", "branch", "aws_access_key_id", "archive") + ) + last_uploaded = models.DateTimeField(_("Last Uploaded"), editable=False, blank=True, null=True) + + subform_type_field = "method" + subform_json_field = "parameters" + subforms = {"git": GitBackupForm, "S3": S3BackupForm} + + class Meta: + verbose_name = _("Backup Point") + verbose_name_plural = _("Backup Points") + ordering = ("name",) + + @di.inject + def __init__(self, *args: Any, backup_backend: Annotated[BackupBackend, ...], **kwargs: Any) -> None: + self._backup_backend = backup_backend + super().__init__(*args, **kwargs) + + def __str__(self) -> str: + return self.name + + def clean(self): + if self.data_source.type != "device_polling": + raise ValidationError( + {"data_source": _('Backups are supported for Data Sources with type "Device Polling" only')} + ) + if self.method == "S3" and self.parameters.get("archive") and not self.url.endswith(".zip"): + raise ValidationError(_('URL must end with ".zip" if archiving is chosen')) + + def get_method_color(self): + return BackupMethodChoices.colors.get(self.method) + + def do_backup(self) -> None: + """ + Perform backup depending on chosen method + Raises: IntegrationError + """ + self._backup_backend(self) + self.last_uploaded = timezone.now() + + def ignore_file(self, path: str) -> bool: + for rule in self.ignore_rules.splitlines(): + if fnmatchcase(path, rule): + return True + return False diff --git a/validity/models/base.py b/validity/models/base.py index d1b3470..65131e1 100644 --- a/validity/models/base.py +++ b/validity/models/base.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models +from django.forms import Form from django.urls import reverse from django.utils.translation import gettext_lazy as _ from netbox.models import ( @@ -94,9 +95,13 @@ class Meta: class SubformMixin: + """ + Supports rendering HTMX-backed dynamic django forms depending on subform_type_field value + """ + subform_json_field: str subform_type_field: str - subforms: dict + subforms: dict[str, Form] @property def subform_type(self): @@ -117,3 +122,6 @@ def subform_json(self): @subform_json.setter def subform_json(self, value): setattr(self, self.subform_json_field, value) + + def get_subform(self): + return self.subform_cls(self.subform_json) diff --git a/validity/models/polling.py b/validity/models/polling.py index 65c7acd..707fd15 100644 --- a/validity/models/polling.py +++ b/validity/models/polling.py @@ -1,5 +1,5 @@ from functools import cached_property -from typing import TYPE_CHECKING, Annotated, Collection +from typing import TYPE_CHECKING, Annotated, Any, Collection from dcim.models import Device from django.core.validators import RegexValidator @@ -99,6 +99,11 @@ class Poller(BaseModel): class Meta: ordering = ("name",) + @di.inject + def __init__(self, *args: Any, poller_factory: Annotated["PollerFactory", ...], **kwargs: Any) -> None: + self._poller_factory = poller_factory + super().__init__(*args, **kwargs) + def __str__(self) -> str: return self.name @@ -122,9 +127,8 @@ def config_command(self) -> Command | None: """ return next((cmd for cmd in self.commands.all() if cmd.retrieves_config), None) - @di.inject - def get_backend(self, poller_factory: Annotated["PollerFactory", ...]): - return poller_factory(self.connection_type, self.credentials, self.commands.all()) + def get_backend(self): + return self._poller_factory(self.connection_type, self.credentials, self.commands.all()) @staticmethod def validate_commands(commands: Collection[Command], command_types: dict[str, list[str]], connection_type: str): diff --git a/validity/models/serializer.py b/validity/models/serializer.py index 89f1fa4..bd78757 100644 --- a/validity/models/serializer.py +++ b/validity/models/serializer.py @@ -1,10 +1,13 @@ +from typing import Annotated, Any + from dcim.models import Device from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ +from validity import di from validity.choices import ExtractionMethodChoices -from validity.compliance.serialization import serialize +from validity.compliance.serialization import SerializationBackend from validity.subforms import ( RouterOSSerializerForm, TEXTFSMSerializerForm, @@ -24,7 +27,6 @@ class Serializer(SubformMixin, DataSourceMixin, BaseModel): clone_fields = ("template", "extraction_method", "data_source", "data_file") text_db_field_name = "template" requires_template = {"TTP", "TEXTFSM"} - _serialize = serialize subform_json_field = "parameters" subform_type_field = "extraction_method" subforms = { @@ -39,6 +41,11 @@ class Meta: ordering = ("name",) default_permissions = () + @di.inject + def __init__(self, *args: Any, backend: Annotated[SerializationBackend, ...], **kwargs: Any) -> None: + self._backend = backend + super().__init__(*args, **kwargs) + def __str__(self) -> str: return self.name @@ -75,4 +82,4 @@ def effective_template(self) -> str: return self.effective_text_field() def serialize(self, data: str) -> dict: - return self._serialize(self, data) + return self._backend(self, data) diff --git a/validity/navigation.py b/validity/navigation.py index 9662cb1..e1b719f 100644 --- a/validity/navigation.py +++ b/validity/navigation.py @@ -50,6 +50,7 @@ def model_menu_item(entity, title, buttons=()): polling_menu_items = ( model_menu_item("command", "Commands", [model_add_button, model_import_button]), model_menu_item("poller", "Pollers", [model_add_button, model_import_button]), + model_menu_item("backuppoint", "Backups", [model_add_button, model_import_button]), ) menu = plugins.PluginMenu( diff --git a/validity/search.py b/validity/search.py index 235ef14..e54f651 100644 --- a/validity/search.py +++ b/validity/search.py @@ -43,3 +43,9 @@ class PollerIndex(SearchIndex): class CommandIndex(SearchIndex): model = models.Command fields = (("name", 100), ("label", 110)) + + +@register_search +class BackupPointSearch(SearchIndex): + model = models.BackupPoint + fields = (("name", 100),) diff --git a/validity/settings.py b/validity/settings.py index c2602d9..a12bcfb 100644 --- a/validity/settings.py +++ b/validity/settings.py @@ -33,6 +33,20 @@ def validate_verbose_name(cls, value, info): return " ".join(part.title() for part in info.data["name"].split("_")) +class GitSettings(BaseModel): + author: str = "netbox-validity" + email: str = "validity@netbox.local" + + +class S3Settings(BaseModel): + threads: int = 10 + + +class IntegrationSettings(BaseModel): + s3: S3Settings = S3Settings() + git: GitSettings = GitSettings() + + class ValiditySettings(BaseModel): store_reports: int = Field(default=5, gt=0, lt=1001) result_batch_size: int = Field(default=500, ge=1) @@ -40,6 +54,7 @@ class ValiditySettings(BaseModel): runtests_queue: str = "default" script_timeouts: ScriptTimeouts = ScriptTimeouts() custom_pollers: list[PollerInfo] = [] + integrations: IntegrationSettings = IntegrationSettings() class ValiditySettingsMixin: diff --git a/validity/subforms.py b/validity/subforms.py index afa1d61..e5fee1c 100644 --- a/validity/subforms.py +++ b/validity/subforms.py @@ -11,12 +11,38 @@ from django.utils.translation import gettext_lazy as _ from validity.choices import JSONAPIMethodChoices +from validity.fields.encrypted import EncryptedDict from validity.netbox_changes import BootstrapMixin from validity.utils.json import jq from validity.utils.misc import reraise -class BaseSubform(BootstrapMixin, forms.Form): +class SensitiveMixin: + """ + Allows to hide encrypted values in DetailView + """ + + sensitive_fields: set[str] = {} + + placeholder: str = "*********" + + @classmethod + def _sensitive_value(cls, field): + if field.name in cls.sensitive_fields: + return cls.placeholder + return field.value() + + def rendered_parameters(self): + for field in self: + yield field.label, self._sensitive_value(field) + + +class BaseSubform(SensitiveMixin, BootstrapMixin, forms.Form): + def __init__(self, data=None, *args, **kwargs): + if isinstance(data, EncryptedDict): + data = data.encrypted + super().__init__(data, *args, **kwargs) + def clean(self): if self.data.keys() - self.base_fields.keys(): allowed_fields = ", ".join(self.base_fields.keys()) @@ -104,3 +130,24 @@ class RouterOSSerializerForm(BaseSubform): class YAMLSerializerForm(SerializerBaseForm): requires_template = False + + +# Backup Forms + + +class GitBackupForm(BaseSubform): + username = forms.CharField(label=_("Username"), help_text=_("Required for HTTP authentication")) + password = forms.CharField( + label=_("Password"), help_text=_("Required for HTTP authentication. Will be encrypted before saving") + ) + branch = forms.CharField(required=False, label=_("Branch")) + + sensitive_fields = {"password"} + + +class S3BackupForm(BaseSubform): + aws_access_key_id = forms.CharField(label=_("AWS access key ID")) + aws_secret_access_key = forms.CharField(label=_("AWS secret access key. Will be encrypted before saving")) + archive = forms.BooleanField(required=False, help_text=_("Compress the repo into zip archive before uploading")) + + sensitive_fields = {"aws_secret_access_key"} diff --git a/validity/tables.py b/validity/tables.py index d584e20..0387b35 100644 --- a/validity/tables.py +++ b/validity/tables.py @@ -308,3 +308,14 @@ class Meta(BaseTable.Meta): def render_time(self, value): return isodatetime(datetime.datetime.fromisoformat(value)) + + +class BackupPointTable(NetBoxTable): + name = Column(linkify=True) + data_source = Column(linkify=True) + method = ChoiceFieldColumn() + + class Meta(NetBoxTable.Meta): + model = models.BackupPoint + fields = ("name", "method", "backup_after_sync", "data_source", "last_uploaded") + default_columns = fields diff --git a/validity/templates/validity/backuppoint.html b/validity/templates/validity/backuppoint.html new file mode 100644 index 0000000..693ca1a --- /dev/null +++ b/validity/templates/validity/backuppoint.html @@ -0,0 +1,54 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load validity %} +{% block content %} + <div class="row mb-3"> + <div class="col col-md-5"> + <div class="card"> + <h5 class="card-header">Backup Point</h5> + <div class="card-body"> + <table class="table table-hover attr-table"> + <tr> + <th scope="row">Name</th> + <td>{{ object.name }}</td> + </tr> + <tr> + <th scope="row">Data Source</th> + <td>{{ object.data_source | linkify }}</td> + </tr> + <tr> + <th scope="row">Do backup after Data Source sync</th> + <td>{{ object.backup_after_sync | checkmark }}</td> + </tr> + <tr> + <th scope="row">Backup Method</th> + <td>{{ object | colored_choice:"method" }}</td> + </tr> + <tr> + <th scope="row">URL</th> + <td><a href="{{ object.url }}">{{ object.url }}</a></td> + </tr> + <tr> + <th scope="row">Ignore rules</th> + <td> + {% if object.ignore_rules %} + <pre>{{ object.ignore_rules }}</pre> + {% else %} + {{ ''|placeholder }} + {% endif %} + </td> + </tr> + <tr> + <th scope="row">Last Uploaded</th> + <td>{{ object.last_uploaded | date:"Y-m-d G:i:s" }}</td> + </tr> + </table> + </div> + </div> + {% include 'inc/panels/tags.html' %} + </div> + <div class="col col-md-7"> + {% include 'validity/inc/parameters.html' with title='Parameters' form=object.get_subform only %} + </div> + </div> +{% endblock content %} diff --git a/validity/templates/validity/command.html b/validity/templates/validity/command.html index 9524398..8088d05 100644 --- a/validity/templates/validity/command.html +++ b/validity/templates/validity/command.html @@ -39,7 +39,7 @@ <h5 class="card-header">Command</h5> {% include 'inc/panels/tags.html' %} </div> <div class="col col-md-7"> - {% include 'validity/inc/parameters.html' with title='Parameters' parameters=object.parameters form=object.subform_cls only %} + {% include 'validity/inc/parameters.html' with title='Parameters' form=object.get_subform only %} </div> </div> {% endblock content %} diff --git a/validity/templates/validity/inc/parameters.html b/validity/templates/validity/inc/parameters.html index 14aea57..3d616ce 100644 --- a/validity/templates/validity/inc/parameters.html +++ b/validity/templates/validity/inc/parameters.html @@ -3,15 +3,15 @@ <h5 class="card-header">{{ title | capfirst }}</h5> <div class="card-body"> <table class="table table-hover attr-table"> - {% for field_name, field in form.base_fields.items %} + {% for label, value in form.rendered_parameters %} <tr> - <th scope="row">{{ field.label }}</th> - <td>{{ parameters | get_key:field_name | placeholder }}</td> + <th scope="row">{{ label }}</th> + <td>{{ value | placeholder }}</td> </tr> {% empty %} <tr> <td colspan="2" class="text-muted"> - No {{ title }} defined + No {{ title | capfirst }} defined </td> </tr> {% endfor %} diff --git a/validity/templates/validity/serializer.html b/validity/templates/validity/serializer.html index 920a039..30fa1f2 100644 --- a/validity/templates/validity/serializer.html +++ b/validity/templates/validity/serializer.html @@ -23,7 +23,7 @@ <h5 class="card-header">Serializer</h5> </div> </div> <div class="row mb-3"> - {% include 'validity/inc/parameters.html' with title='Parameters' parameters=object.parameters form=object.subform_cls only %} + {% include 'validity/inc/parameters.html' with title='Parameters' form=object.get_subform only %} </div> <div class="row mb-3"> {% include 'inc/panels/related_objects.html' %} diff --git a/validity/templatetags/validity.py b/validity/templatetags/validity.py index b17de12..64417f3 100644 --- a/validity/templatetags/validity.py +++ b/validity/templatetags/validity.py @@ -95,7 +95,7 @@ def render_fieldset(form, fieldset): return {"group": name, "fields": items, "form": form} -@register.filter() +@register.filter def isodatetime(value, spec="seconds"): # backport of the native isodatetime in 4.0 value = localtime(value) if value.tzinfo else value diff --git a/validity/urls.py b/validity/urls.py index 90d7334..028beb6 100644 --- a/validity/urls.py +++ b/validity/urls.py @@ -43,4 +43,9 @@ path("commands/import/", views.CommandBulkImportView.as_view(), name="command_import"), path("commands/<int:pk>/", include(get_model_urls("validity", "command"))), path("scripts/results/<int:pk>/", views.ScriptResultView.as_view(), name="script_result"), + path("backup-points/", views.BackupPointListView.as_view(), name="backuppoint_list"), + path("backup-points/add/", views.BackupPointEditView.as_view(), name="backuppoint_add"), + path("backup-points/delete/", views.BackupPointBulkDeleteView.as_view(), name="backuppoint_bulk_delete"), + path("backup-points/import/", views.BackupPointBulkImportView.as_view(), name="backuppoint_import"), + path("backup-points/<int:pk>/", include(get_model_urls("validity", "backuppoint"))), ] diff --git a/validity/utils/filesystem.py b/validity/utils/filesystem.py new file mode 100644 index 0000000..89f8801 --- /dev/null +++ b/validity/utils/filesystem.py @@ -0,0 +1,18 @@ +from pathlib import Path + + +def merge_directories(src: Path, dst: Path) -> None: + """ + Move all files from src to dst with rewriting + """ + for item in src.rglob("*"): + target = dst / item.relative_to(src) + + if item.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + item.rename(target) + elif item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + + if not any(src.iterdir()): + src.rmdir() diff --git a/validity/views/__init__.py b/validity/views/__init__.py index 38fd7e3..77eb795 100644 --- a/validity/views/__init__.py +++ b/validity/views/__init__.py @@ -1,4 +1,12 @@ +from .backup import ( + BackupPointBulkDeleteView, + BackupPointDeleteView, + BackupPointEditView, + BackupPointListView, + BackupPointView, +) from .bulk_import import ( + BackupPointBulkImportView, CommandBulkImportView, ComplianceSelectorBulkImportView, ComplianceTestBulkImportView, diff --git a/validity/views/backup.py b/validity/views/backup.py new file mode 100644 index 0000000..2356e38 --- /dev/null +++ b/validity/views/backup.py @@ -0,0 +1,33 @@ +from netbox.views import generic +from utilities.views import register_model_view + +from validity import filtersets, forms, models, tables + + +class BackupPointListView(generic.ObjectListView): + queryset = models.BackupPoint.objects.select_related("data_source") + table = tables.BackupPointTable + filterset = filtersets.BackupPointFilterSet + filterset_form = forms.BackupPointFilterForm + + +@register_model_view(models.BackupPoint) +class BackupPointView(generic.ObjectView): + queryset = models.BackupPoint.objects.all() + + +@register_model_view(models.BackupPoint, "delete") +class BackupPointDeleteView(generic.ObjectDeleteView): + queryset = models.BackupPoint.objects.all() + + +class BackupPointBulkDeleteView(generic.BulkDeleteView): + queryset = models.BackupPoint.objects.select_related("data_source") + filterset = filtersets.BackupPointFilterSet + table = tables.BackupPointTable + + +@register_model_view(models.BackupPoint, "edit") +class BackupPointEditView(generic.ObjectEditView): + queryset = models.BackupPoint.objects.all() + form = forms.BackupPointForm diff --git a/validity/views/bulk_import.py b/validity/views/bulk_import.py index d7f8d0f..b68650e 100644 --- a/validity/views/bulk_import.py +++ b/validity/views/bulk_import.py @@ -12,9 +12,6 @@ class NameSetBulkImportView(BulkImportView): queryset = models.NameSet.objects.all() model_form = forms.NameSetImportForm - def post(self, request): - return super().post(request) - class SerializerBulkImportView(BulkImportView): queryset = models.Serializer.objects.all() @@ -34,3 +31,8 @@ class CommandBulkImportView(BulkImportView): class PollerBulkImportView(BulkImportView): queryset = models.Poller.objects.all() model_form = forms.PollerImportForm + + +class BackupPointBulkImportView(BulkImportView): + queryset = models.BackupPoint.objects.all() + model_form = forms.BackupPointImportForm