Repository: cobbler/cobbler

Issue:
MongoDB: it is not possible to edit an item once it is created.
### Describe the bug

It is not possible to modify an item after creation when using MongoDB.

<!--- HINT: You can paste gist.github.com links for long logs or larger files -->

### Steps to reproduce

1. cobbler menu add --name=test1
2. systemctl restart cobblerd
3. cobbler menu edit --name=test1 --display-name="Test 1"
```
exception on server: 'Item test1 of collection menus was not found in MongoDB database cobbler!'
```

### Expected behavior

It should be possible to edit an item after creation when using MongoDB.

### Cobbler version

<!--- Paste output from `cobbler version` -->
````paste below
Cobbler 3.4.0
````

### Operating system

<!--- On which operating system do you use Cobbler? -->

### Cobbler log

<!--- Paste (partial) output from `/var/log/cobbler/cobbler.log` -->
````paste below
````

### Screenshots

<!--- If applicable, add screenshots to help explain your problem. -->

### Additional information

<!--- Add any other context about the problem here. -->



Discussion:

Resolving patch:
diff --git a/cobbler/modules/serializers/__init__.py b/cobbler/modules/serializers/__init__.py
--- a/cobbler/modules/serializers/__init__.py
+++ b/cobbler/modules/serializers/__init__.py
@@ -2,7 +2,7 @@
 This module contains code to persist the in memory state of Cobbler on a target. The name of the target should be the
 name of the Python file. Cobbler is currently only tested against the file serializer.
 """
-from typing import TYPE_CHECKING, Any, Dict, List
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
 
 if TYPE_CHECKING:
     from cobbler.api import CobblerAPI
@@ -49,7 +49,9 @@ def serialize(self, collection: "Collection[ITEM]") -> None:
             "The implementation for the configured serializer is missing!"
         )
 
-    def deserialize_raw(self, collection_type: str) -> List[Dict[str, Any]]:
+    def deserialize_raw(
+        self, collection_type: str
+    ) -> Union[List[Optional[Dict[str, Any]]], Dict[str, Any]]:
         """
         Get a collection from mongodb and parse it into an object.
 
diff --git a/cobbler/modules/serializers/mongodb.py b/cobbler/modules/serializers/mongodb.py
--- a/cobbler/modules/serializers/mongodb.py
+++ b/cobbler/modules/serializers/mongodb.py
@@ -8,7 +8,7 @@
 # SPDX-FileCopyrightText: James Cammarata <jimi@sngx.net>
 
 import logging
-from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional
+from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Union
 
 from cobbler import settings
 from cobbler.cexceptions import CX
@@ -21,7 +21,7 @@
     from cobbler.cobbler_collections.collection import ITEM, Collection
 
 try:
-    from pymongo.errors import ConfigurationError, ConnectionFailure
+    from pymongo.errors import ConfigurationError, ConnectionFailure, OperationFailure
     from pymongo.mongo_client import MongoClient
 
     PYMONGO_LOADED = True
@@ -86,10 +86,28 @@ def __connect(self) -> None:
             )
         self.mongodb_database = self.mongodb["cobbler"]  # type: ignore
 
+    def _rename_collection(self, old_collection: str, new_collection: str) -> None:
+        """
+        Rename a collection in database.
+
+        :param old_collection: Previous collection name.
+        :param old_collection: New collection name.
+        """
+        if (
+            old_collection != "setting"
+            and old_collection in self.mongodb_database.list_collection_names()  # type: ignore
+        ):
+            try:
+                self.mongodb_database[old_collection].rename(new_collection)  # type: ignore
+            except OperationFailure as error:
+                raise CX(
+                    f'Cannot rename MongoDB collection from "{old_collection}" to "{new_collection}": {error}.'
+                ) from error
+
     def serialize_item(self, collection: "Collection[ITEM]", item: "ITEM") -> None:
         if self.mongodb_database is None:
             raise ValueError("Database not available!")
-        mongodb_collection = self.mongodb_database[collection.collection_type()]
+        mongodb_collection = self.mongodb_database[collection.collection_types()]
         data = mongodb_collection.find_one({"name": item.name})
         if data:
             mongodb_collection.replace_one({"name": item.name}, item.serialize())  # type: ignore
@@ -99,17 +117,19 @@ def serialize_item(self, collection: "Collection[ITEM]", item: "ITEM") -> None:
     def serialize_delete(self, collection: "Collection[ITEM]", item: "ITEM") -> None:
         if self.mongodb_database is None:
             raise ValueError("Database not available!")
-        mongodb_collection = self.mongodb_database[collection.collection_type()]
+        mongodb_collection = self.mongodb_database[collection.collection_types()]
         mongodb_collection.delete_one({"name": item.name})  # type: ignore
 
     def serialize(self, collection: "Collection[ITEM]") -> None:
         # TODO: error detection
-        ctype = collection.collection_type()
+        ctype = collection.collection_types()
         if ctype != "settings":
             for item in collection:
                 self.serialize_item(collection, item)
 
-    def deserialize_raw(self, collection_type: str) -> List[Dict[str, Any]]:
+    def deserialize_raw(
+        self, collection_type: str
+    ) -> Union[List[Optional[Dict[str, Any]]], Dict[str, Any]]:
         if collection_type == "settings":
             return settings.read_settings_file()  # type: ignore
 
@@ -131,15 +151,20 @@ def deserialize_raw(self, collection_type: str) -> List[Dict[str, Any]]:
             results.append(result)  # type: ignore
         return results  # type: ignore
 
-    def deserialize(self, collection: "Collection[ITEM]", topological: bool = True):
-        datastruct = self.deserialize_raw(collection.collection_type())
+    def deserialize(
+        self, collection: "Collection[ITEM]", topological: bool = True
+    ) -> None:
+        self._rename_collection(
+            collection.collection_type(), collection.collection_types()
+        )
+        datastruct = self.deserialize_raw(collection.collection_types())
         if topological and isinstance(datastruct, list):  # type: ignore
-            datastruct.sort(key=lambda x: x.get("depth", 1))
+            datastruct.sort(key=lambda x: x.get("depth", 1))  # type: ignore
         if isinstance(datastruct, dict):
             # This is currently the corner case for the settings type.
             collection.from_dict(datastruct)  # type: ignore
         elif isinstance(datastruct, list):  # type: ignore
-            collection.from_list(datastruct)
+            collection.from_list(datastruct)  # type: ignore
 
     def deserialize_item(self, collection_type: str, name: str) -> Dict[str, Any]:
         """


Test patch:
diff --git a/tests/modules/serializer/mongodb_test.py b/tests/modules/serializer/mongodb_test.py
--- a/tests/modules/serializer/mongodb_test.py
+++ b/tests/modules/serializer/mongodb_test.py
@@ -3,6 +3,7 @@
 
 import pytest
 
+from cobbler.api import CobblerAPI
 from cobbler.cexceptions import CX
 from cobbler.modules.serializers import mongodb
 
@@ -10,7 +11,7 @@
 
 
 @pytest.fixture
-def mongodb_obj(cobbler_api):
+def mongodb_obj(cobbler_api: CobblerAPI):
     return mongodb.storage_factory(cobbler_api)
 
 
@@ -27,7 +28,7 @@ def test_what():
     assert mongodb.what() == "serializer/mongodb"
 
 
-def test_storage_factory(cobbler_api):
+def test_storage_factory(cobbler_api: CobblerAPI):
     # Arrange & Act
     result = mongodb.storage_factory(cobbler_api)
 
@@ -38,7 +39,7 @@ def test_storage_factory(cobbler_api):
 def test_serialize_item(mocker, mongodb_obj):
     # Arrange
     mock_collection = mocker.MagicMock()
-    mock_collection.collection_type.return_value = "distro"
+    mock_collection.collection_types.return_value = "distros"
     mock_item = mocker.MagicMock()
     mock_item.name = "testitem"
     mock_item.arch = "x86_64"
@@ -48,13 +49,13 @@ def test_serialize_item(mocker, mongodb_obj):
     mongodb_obj.serialize_item(mock_collection, mock_item)
 
     # Assert
-    assert len(list(mongodb_obj.mongodb["cobbler"]["distro"].find())) == 1
+    assert len(list(mongodb_obj.mongodb["cobbler"]["distros"].find())) == 1
 
 
 def test_serialize_delete(mocker, mongodb_obj):
     # Arrange
     mock_collection = mocker.MagicMock()
-    mock_collection.collection_type.return_value = "distro"
+    mock_collection.collection_types.return_value = "distros"
     mock_item = mocker.MagicMock()
     mock_item.name = "testitem"
 
@@ -62,13 +63,13 @@ def test_serialize_delete(mocker, mongodb_obj):
     mongodb_obj.serialize_delete(mock_collection, mock_item)
 
     # Assert
-    assert len(list(mongodb_obj.mongodb["cobbler"]["distro"].find())) == 0
+    assert len(list(mongodb_obj.mongodb["cobbler"]["distros"].find())) == 0
 
 
 def test_serialize(mocker, mongodb_obj):
     # Arrange
     mock_collection = mocker.MagicMock()
-    mock_collection.collection_type.return_value = "distro"
+    mock_collection.collection_types.return_value = "distros"
     mock_serialize_item = mocker.patch.object(mongodb_obj, "serialize_item")
     mock_collection.__iter__ = mocker.Mock(return_value=iter(["item1"]))
 
@@ -81,7 +82,7 @@ def test_serialize(mocker, mongodb_obj):
 
 def test_deserialize_raw(mongodb_obj):
     # Arrange
-    collection_type = "distro"
+    collection_type = "distros"
     mongodb_obj.mongodb["cobbler"][collection_type].insert_one(
         {"name": "testitem", "arch": "x86_64"}
     )
@@ -100,7 +101,7 @@ def test_deserialize_raw(mongodb_obj):
 def test_deserialize(mocker, mongodb_obj):
     # Arrange
     mock_collection = mocker.MagicMock()
-    mock_collection.collection_type.return_value = "distro"
+    mock_collection.collection_types.return_value = "distros"
     input_topological = True
     return_deserialize_raw = []
     mock_deserialize_raw = mocker.patch.object(
@@ -133,7 +134,7 @@ def test_deserialize_item(
     mongodb_obj: mongodb.MongoDBSerializer, item_name: str, expected_exception
 ):
     # Arrange
-    collection_type = "distro"
+    collection_type = "distros"
     input_value = {"name": item_name, "arch": "x86_64"}
     test_item = copy.deepcopy(input_value)
     if item_name is not None: