Fixes 1367 by preventing pyang discard repeated constituent-base-element-id
[osm/NBI.git] / osm_nbi / base_topic.py
index 095e265..bbe6337 100644 (file)
@@ -18,7 +18,8 @@ from uuid import uuid4
 from http import HTTPStatus
 from time import time
 from osm_common.dbbase import deep_update_rfc7396
 from http import HTTPStatus
 from time import time
 from osm_common.dbbase import deep_update_rfc7396
-from validation import validate_input, ValidationError, is_valid_uuid
+from osm_nbi.validation import validate_input, ValidationError, is_valid_uuid
+from yaml import safe_load, YAMLError
 
 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 
 
 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 
@@ -27,7 +28,22 @@ class EngineException(Exception):
 
     def __init__(self, message, http_code=HTTPStatus.BAD_REQUEST):
         self.http_code = http_code
 
     def __init__(self, message, http_code=HTTPStatus.BAD_REQUEST):
         self.http_code = http_code
-        Exception.__init__(self, message)
+        super(Exception, self).__init__(message)
+
+
+def deep_get(target_dict, key_list):
+    """
+    Get a value from target_dict entering in the nested keys. If keys does not exist, it returns None
+    Example target_dict={a: {b: 5}}; key_list=[a,b] returns 5; both key_list=[a,b,c] and key_list=[f,h] return None
+    :param target_dict: dictionary to be read
+    :param key_list: list of keys to read from  target_dict
+    :return: The wanted value if exist, None otherwise
+    """
+    for key in key_list:
+        if not isinstance(target_dict, dict) or key not in target_dict:
+            return None
+        target_dict = target_dict[key]
+    return target_dict
 
 
 def get_iterable(input_var):
 
 
 def get_iterable(input_var):
@@ -49,29 +65,55 @@ def versiontuple(v):
     return tuple(filled)
 
 
     return tuple(filled)
 
 
+def increment_ip_mac(ip_mac, vm_index=1):
+    if not isinstance(ip_mac, str):
+        return ip_mac
+    try:
+        # try with ipv4 look for last dot
+        i = ip_mac.rfind(".")
+        if i > 0:
+            i += 1
+            return "{}{}".format(ip_mac[:i], int(ip_mac[i:]) + vm_index)
+        # try with ipv6 or mac look for last colon. Operate in hex
+        i = ip_mac.rfind(":")
+        if i > 0:
+            i += 1
+            # format in hex, len can be 2 for mac or 4 for ipv6
+            return ("{}{:0" + str(len(ip_mac) - i) + "x}").format(ip_mac[:i], int(ip_mac[i:], 16) + vm_index)
+    except Exception:
+        pass
+    return None
+
+
 class BaseTopic:
     # static variables for all instance classes
     topic = None        # to_override
     topic_msg = None    # to_override
 class BaseTopic:
     # static variables for all instance classes
     topic = None        # to_override
     topic_msg = None    # to_override
+    quota_name = None   # to_override. If not provided topic will be used for quota_name
     schema_new = None   # to_override
     schema_edit = None  # to_override
     schema_new = None   # to_override
     schema_edit = None  # to_override
+    multiproject = True  # True if this Topic can be shared by several projects. Then it contains _admin.projects_read
+
+    default_quota = 500
 
     # Alternative ID Fields for some Topics
     alt_id_field = {
         "projects": "name",
 
     # Alternative ID Fields for some Topics
     alt_id_field = {
         "projects": "name",
-        "users": "username"
+        "users": "username",
+        "roles": "name"
     }
 
     }
 
-    def __init__(self, db, fs, msg):
+    def __init__(self, db, fs, msg, auth):
         self.db = db
         self.fs = fs
         self.msg = msg
         self.logger = logging.getLogger("nbi.engine")
         self.db = db
         self.fs = fs
         self.msg = msg
         self.logger = logging.getLogger("nbi.engine")
+        self.auth = auth
 
     @staticmethod
     def id_field(topic, value):
 
     @staticmethod
     def id_field(topic, value):
-        "Returns ID Field for given topic and field value"
-        if topic in ["projects", "users"] and not is_valid_uuid(value):
+        """Returns ID Field for given topic and field value"""
+        if topic in BaseTopic.alt_id_field.keys() and not is_valid_uuid(value):
             return BaseTopic.alt_id_field[topic]
         else:
             return "_id"
             return BaseTopic.alt_id_field[topic]
         else:
             return "_id"
@@ -82,6 +124,31 @@ class BaseTopic:
             return {}
         return indata
 
             return {}
         return indata
 
+    def check_quota(self, session):
+        """
+        Check whether topic quota is exceeded by the given project
+        Used by relevant topics' 'new' function to decide whether or not creation of the new item should be allowed
+        :param session[project_id]: projects (tuple) for which quota should be checked
+        :param session[force]: boolean. If true, skip quota checking
+        :return: None
+        :raise:
+            DbException if project not found
+            ValidationError if quota exceeded in one of the projects
+        """
+        if session["force"]:
+            return
+        projects = session["project_id"]
+        for project in projects:
+            proj = self.auth.get_project(project)
+            pid = proj["_id"]
+            quota_name = self.quota_name or self.topic
+            quota = proj.get("quotas", {}).get(quota_name, self.default_quota)
+            count = self.db.count(self.topic, {"_admin.projects_read": pid})
+            if count >= quota:
+                name = proj["name"]
+                raise ValidationError("quota ({}={}) exceeded for project {} ({})".format(quota_name, quota, name, pid),
+                                      http_code=HTTPStatus.UNPROCESSABLE_ENTITY)
+
     def _validate_input_new(self, input, force=False):
         """
         Validates input user content for a new entry. It uses jsonschema. Some overrides will use pyangbind
     def _validate_input_new(self, input, force=False):
         """
         Validates input user content for a new entry. It uses jsonschema. Some overrides will use pyangbind
@@ -93,7 +160,7 @@ class BaseTopic:
             validate_input(input, self.schema_new)
         return input
 
             validate_input(input, self.schema_new)
         return input
 
-    def _validate_input_edit(self, input, force=False):
+    def _validate_input_edit(self, input, content, force=False):
         """
         Validates input user content for an edition. It uses jsonschema. Some overrides will use pyangbind
         :param input: user input content for the new topic
         """
         Validates input user content for an edition. It uses jsonschema. Some overrides will use pyangbind
         :param input: user input content for the new topic
@@ -105,58 +172,95 @@ class BaseTopic:
         return input
 
     @staticmethod
         return input
 
     @staticmethod
-    def _get_project_filter(session, write=False, show_all=True):
+    def _get_project_filter(session):
         """
         Generates a filter dictionary for querying database, so that only allowed items for this project can be
         """
         Generates a filter dictionary for querying database, so that only allowed items for this project can be
-        addressed. Only propietary or public can be used. Allowed projects are at _admin.project_read/write. If it is
+        addressed. Only proprietary or public can be used. Allowed projects are at _admin.project_read/write. If it is
         not present or contains ANY mean public.
         not present or contains ANY mean public.
-        :param session: contains "username", if user is "admin" and the working "project_id"
-        :param write: if operation is for reading (False) or writing (True)
-        :param show_all:  if True it will show public or
-        :return:
-        """
-        if write:
-            k = "_admin.projects_write.cont"
-        else:
-            k = "_admin.projects_read.cont"
-        if not show_all:
-            return {k: session["project_id"]}
-        elif session["admin"]:   # and show_all:  # allows all
-            return {}
-        else:
-            return {k: ["ANY", session["project_id"], None]}
-
-    def check_conflict_on_new(self, session, indata, force=False):
+        :param session: contains:
+            project_id: project list this session has rights to access. Can be empty, one or several
+            set_project: items created will contain this project list  
+            force: True or False
+            public: True, False or None
+            method: "list", "show", "write", "delete"
+            admin: True or False
+        :return: dictionary with project filter
+        """
+        p_filter = {}
+        project_filter_n = []
+        project_filter = list(session["project_id"])
+
+        if session["method"] not in ("list", "delete"):
+            if project_filter:
+                project_filter.append("ANY")
+        elif session["public"] is not None:
+            if session["public"]:
+                project_filter.append("ANY")
+            else:
+                project_filter_n.append("ANY")
+
+        if session.get("PROJECT.ne"):
+            project_filter_n.append(session["PROJECT.ne"])
+
+        if project_filter:
+            if session["method"] in ("list", "show", "delete") or session.get("set_project"):
+                p_filter["_admin.projects_read.cont"] = project_filter
+            else:
+                p_filter["_admin.projects_write.cont"] = project_filter
+        if project_filter_n:
+            if session["method"] in ("list", "show", "delete") or session.get("set_project"):
+                p_filter["_admin.projects_read.ncont"] = project_filter_n
+            else:
+                p_filter["_admin.projects_write.ncont"] = project_filter_n
+
+        return p_filter
+
+    def check_conflict_on_new(self, session, indata):
         """
         Check that the data to be inserted is valid
         """
         Check that the data to be inserted is valid
-        :param session: contains "username", if user is "admin" and the working "project_id"
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param indata: data to be inserted
         :param indata: data to be inserted
-        :param force: boolean. With force it is more tolerant
         :return: None or raises EngineException
         """
         pass
 
         :return: None or raises EngineException
         """
         pass
 
-    def check_conflict_on_edit(self, session, final_content, edit_content, _id, force=False):
+    def check_conflict_on_edit(self, session, final_content, edit_content, _id):
         """
         Check that the data to be edited/uploaded is valid
         """
         Check that the data to be edited/uploaded is valid
-        :param session: contains "username", if user is "admin" and the working "project_id"
-        :param final_content: data once modified
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
+        :param final_content: data once modified. This method may change it.
         :param edit_content: incremental data that contains the modifications to apply
         :param _id: internal _id
         :param edit_content: incremental data that contains the modifications to apply
         :param _id: internal _id
-        :param force: boolean. With force it is more tolerant
         :return: None or raises EngineException
         """
         :return: None or raises EngineException
         """
-        pass
+        if not self.multiproject:
+            return
+        # Change public status
+        if session["public"] is not None:
+            if session["public"] and "ANY" not in final_content["_admin"]["projects_read"]:
+                final_content["_admin"]["projects_read"].append("ANY")
+                final_content["_admin"]["projects_write"].clear()
+            if not session["public"] and "ANY" in final_content["_admin"]["projects_read"]:
+                final_content["_admin"]["projects_read"].remove("ANY")
+
+        # Change project status
+        if session.get("set_project"):
+            for p in session["set_project"]:
+                if p not in final_content["_admin"]["projects_read"]:
+                    final_content["_admin"]["projects_read"].append(p)
 
     def check_unique_name(self, session, name, _id=None):
         """
         Check that the name is unique for this project
 
     def check_unique_name(self, session, name, _id=None):
         """
         Check that the name is unique for this project
-        :param session: contains "username", if user is "admin" and the working "project_id"
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param name: name to be checked
         :param _id: If not None, ignore this entry that are going to change
         :return: None or raises EngineException
         """
         :param name: name to be checked
         :param _id: If not None, ignore this entry that are going to change
         :return: None or raises EngineException
         """
-        _filter = self._get_project_filter(session, write=False, show_all=False)
+        if not self.multiproject:
+            _filter = {}
+        else:
+            _filter = self._get_project_filter(session)
         _filter["name"] = name
         if _id:
             _filter["_id.neq"] = _id
         _filter["name"] = name
         if _id:
             _filter["_id.neq"] = _id
@@ -168,9 +272,9 @@ class BaseTopic:
         """
         Modifies content descriptor to include _admin
         :param content: descriptor to be modified
         """
         Modifies content descriptor to include _admin
         :param content: descriptor to be modified
-        :param project_id: if included, it add project read/write permissions
+        :param project_id: if included, it add project read/write permissions. Can be None or a list
         :param make_public: if included it is generated as public for reading.
         :param make_public: if included it is generated as public for reading.
-        :return: None, but content is modified
+        :return: op_id: operation id on asynchronous operation, None otherwise. In addition content is modified
         """
         now = time()
         if "_admin" not in content:
         """
         now = time()
         if "_admin" not in content:
@@ -180,41 +284,54 @@ class BaseTopic:
         content["_admin"]["modified"] = now
         if not content.get("_id"):
             content["_id"] = str(uuid4())
         content["_admin"]["modified"] = now
         if not content.get("_id"):
             content["_id"] = str(uuid4())
-        if project_id:
+        if project_id is not None:
             if not content["_admin"].get("projects_read"):
             if not content["_admin"].get("projects_read"):
-                content["_admin"]["projects_read"] = [project_id]
+                content["_admin"]["projects_read"] = list(project_id)
                 if make_public:
                     content["_admin"]["projects_read"].append("ANY")
             if not content["_admin"].get("projects_write"):
                 if make_public:
                     content["_admin"]["projects_read"].append("ANY")
             if not content["_admin"].get("projects_write"):
-                content["_admin"]["projects_write"] = [project_id]
+                content["_admin"]["projects_write"] = list(project_id)
+        return None
 
     @staticmethod
     def format_on_edit(final_content, edit_content):
 
     @staticmethod
     def format_on_edit(final_content, edit_content):
+        """
+        Modifies final_content to admin information upon edition
+        :param final_content: final content to be stored at database
+        :param edit_content: user requested update content
+        :return: operation id, if this edit implies an asynchronous operation; None otherwise
+        """
         if final_content.get("_admin"):
             now = time()
             final_content["_admin"]["modified"] = now
         if final_content.get("_admin"):
             now = time()
             final_content["_admin"]["modified"] = now
+        return None
 
 
-    def _send_msg(self, action, content):
-        if self.topic_msg:
+    def _send_msg(self, action, content, not_send_msg=None):
+        if self.topic_msg and not_send_msg is not False:
+            content = content.copy()
             content.pop("_admin", None)
             content.pop("_admin", None)
-            self.msg.write(self.topic_msg, action, content)
+            if isinstance(not_send_msg, list):
+                not_send_msg.append((self.topic_msg, action, content))
+            else:
+                self.msg.write(self.topic_msg, action, content)
 
 
-    def check_conflict_on_del(self, session, _id, force=False):
+    def check_conflict_on_del(self, session, _id, db_content):
         """
         Check if deletion can be done because of dependencies if it is not force. To override
         """
         Check if deletion can be done because of dependencies if it is not force. To override
-        :param session: contains "username", if user is "admin" and the working "project_id"
-        :param _id: itnernal _id
-        :param force: Avoid this checking
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
+        :param _id: internal _id
+        :param db_content: The database content of this item _id
         :return: None if ok or raises EngineException with the conflict
         """
         pass
 
     @staticmethod
         :return: None if ok or raises EngineException with the conflict
         """
         pass
 
     @staticmethod
-    def _update_input_with_kwargs(desc, kwargs):
+    def _update_input_with_kwargs(desc, kwargs, yaml_format=False):
         """
         Update descriptor with the kwargs. It contains dot separated keys
         :param desc: dictionary to be updated
         :param kwargs: plain dictionary to be used for updating.
         """
         Update descriptor with the kwargs. It contains dot separated keys
         :param desc: dictionary to be updated
         :param kwargs: plain dictionary to be used for updating.
+        :param yaml_format: get kwargs values as yaml format.
         :return: None, 'desc' is modified. It raises EngineException.
         """
         if not kwargs:
         :return: None, 'desc' is modified. It raises EngineException.
         """
         if not kwargs:
@@ -229,12 +346,23 @@ class BaseTopic:
                         update_content = update_content[kitem_old]
                     if isinstance(update_content, dict):
                         kitem_old = kitem
                         update_content = update_content[kitem_old]
                     if isinstance(update_content, dict):
                         kitem_old = kitem
+                        if not isinstance(update_content.get(kitem_old), (dict, list)):
+                            update_content[kitem_old] = {}
                     elif isinstance(update_content, list):
                     elif isinstance(update_content, list):
+                        # key must be an index of the list, must be integer
                         kitem_old = int(kitem)
                         kitem_old = int(kitem)
+                        # if index greater than list, extend the list
+                        if kitem_old >= len(update_content):
+                            update_content += [None] * (kitem_old - len(update_content) + 1)
+                        if not isinstance(update_content[kitem_old], (dict, list)):
+                            update_content[kitem_old] = {}
                     else:
                         raise EngineException(
                             "Invalid query string '{}'. Descriptor is not a list nor dict at '{}'".format(k, kitem))
                     else:
                         raise EngineException(
                             "Invalid query string '{}'. Descriptor is not a list nor dict at '{}'".format(k, kitem))
-                update_content[kitem_old] = v
+                if v is None:
+                    del update_content[kitem_old]
+                else:
+                    update_content[kitem_old] = v if not yaml_format else safe_load(v)
         except KeyError:
             raise EngineException(
                 "Invalid query string '{}'. Descriptor does not contain '{}'".format(k, kitem_old))
         except KeyError:
             raise EngineException(
                 "Invalid query string '{}'. Descriptor does not contain '{}'".format(k, kitem_old))
@@ -244,25 +372,42 @@ class BaseTopic:
         except IndexError:
             raise EngineException(
                 "Invalid query string '{}'. Index '{}' out of  range".format(k, kitem_old))
         except IndexError:
             raise EngineException(
                 "Invalid query string '{}'. Index '{}' out of  range".format(k, kitem_old))
+        except YAMLError:
+            raise EngineException("Invalid query string '{}' yaml format".format(k))
+
+    def sol005_projection(self, data):
+        # Projection was moved to child classes
+        return data
 
 
-    def show(self, session, _id):
+    def show(self, session, _id, api_req=False):
         """
         Get complete information on an topic
         """
         Get complete information on an topic
-        :param session: contains the used login username and working project
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param _id: server internal id
         :param _id: server internal id
+        :param api_req: True if this call is serving an external API request. False if serving internal request.
         :return: dictionary, raise exception if not found.
         """
         :return: dictionary, raise exception if not found.
         """
-        filter_db = self._get_project_filter(session, write=False, show_all=True)
+        if not self.multiproject:
+            filter_db = {}
+        else:
+            filter_db = self._get_project_filter(session)
         # To allow project&user addressing by name AS WELL AS _id
         filter_db[BaseTopic.id_field(self.topic, _id)] = _id
         # To allow project&user addressing by name AS WELL AS _id
         filter_db[BaseTopic.id_field(self.topic, _id)] = _id
-        return self.db.get_one(self.topic, filter_db)
+        data = self.db.get_one(self.topic, filter_db)
+
+        # Only perform SOL005 projection if we are serving an external request
+        if api_req:
+            self.sol005_projection(data)
+
+        return data
+        
         # TODO transform data for SOL005 URL requests
         # TODO remove _admin if not admin
 
     def get_file(self, session, _id, path=None, accept_header=None):
         """
         Only implemented for descriptor topics. Return the file content of a descriptor
         # TODO transform data for SOL005 URL requests
         # TODO remove _admin if not admin
 
     def get_file(self, session, _id, path=None, accept_header=None):
         """
         Only implemented for descriptor topics. Return the file content of a descriptor
-        :param session: contains the used login username and working project
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param _id: Identity of the item to get content
         :param path: artifact path or "$DESCRIPTOR" or None
         :param accept_header: Content of Accept header. Must contain applition/zip or/and text/plain
         :param _id: Identity of the item to get content
         :param path: artifact path or "$DESCRIPTOR" or None
         :param accept_header: Content of Accept header. Must contain applition/zip or/and text/plain
@@ -270,59 +415,70 @@ class BaseTopic:
         """
         raise EngineException("Method get_file not valid for this topic", HTTPStatus.INTERNAL_SERVER_ERROR)
 
         """
         raise EngineException("Method get_file not valid for this topic", HTTPStatus.INTERNAL_SERVER_ERROR)
 
-    def list(self, session, filter_q=None):
+    def list(self, session, filter_q=None, api_req=False):
         """
         Get a list of the topic that matches a filter
         :param session: contains the used login username and working project
         :param filter_q: filter of data to be applied
         """
         Get a list of the topic that matches a filter
         :param session: contains the used login username and working project
         :param filter_q: filter of data to be applied
+        :param api_req: True if this call is serving an external API request. False if serving internal request.
         :return: The list, it can be empty if no one match the filter.
         """
         if not filter_q:
             filter_q = {}
         :return: The list, it can be empty if no one match the filter.
         """
         if not filter_q:
             filter_q = {}
-
-        filter_q.update(self._get_project_filter(session, write=False, show_all=True))
+        if self.multiproject:
+            filter_q.update(self._get_project_filter(session))
 
         # TODO transform data for SOL005 URL requests. Transform filtering
         # TODO implement "field-type" query string SOL005
 
         # TODO transform data for SOL005 URL requests. Transform filtering
         # TODO implement "field-type" query string SOL005
-        return self.db.get_list(self.topic, filter_q)
+        data = self.db.get_list(self.topic, filter_q)
+
+        # Only perform SOL005 projection if we are serving an external request
+        if api_req:
+            data = [self.sol005_projection(inst) for inst in data]
+                
+        return data
 
 
-    def new(self, rollback, session, indata=None, kwargs=None, headers=None, force=False, make_public=False):
+    def new(self, rollback, session, indata=None, kwargs=None, headers=None):
         """
         Creates a new entry into database.
         :param rollback: list to append created items at database in case a rollback may to be done
         """
         Creates a new entry into database.
         :param rollback: list to append created items at database in case a rollback may to be done
-        :param session: contains the used login username and working project
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param indata: data to be inserted
         :param kwargs: used to override the indata descriptor
         :param headers: http request headers
         :param indata: data to be inserted
         :param kwargs: used to override the indata descriptor
         :param headers: http request headers
-        :param force: If True avoid some dependence checks
-        :param make_public: Make the created item public to all projects
-        :return: _id: identity of the inserted data.
+        :return: _id, op_id:
+            _id: identity of the inserted data.
+             op_id: operation id if this is asynchronous, None otherwise
         """
         try:
         """
         try:
+            if self.multiproject:
+                self.check_quota(session)
+
             content = self._remove_envelop(indata)
 
             # Override descriptor with query string kwargs
             self._update_input_with_kwargs(content, kwargs)
             content = self._remove_envelop(indata)
 
             # Override descriptor with query string kwargs
             self._update_input_with_kwargs(content, kwargs)
-            content = self._validate_input_new(content, force=force)
-            self.check_conflict_on_new(session, content, force=force)
-            self.format_on_new(content, project_id=session["project_id"], make_public=make_public)
+            content = self._validate_input_new(content, force=session["force"])
+            self.check_conflict_on_new(session, content)
+            op_id = self.format_on_new(content, project_id=session["project_id"], make_public=session["public"])
             _id = self.db.create(self.topic, content)
             rollback.append({"topic": self.topic, "_id": _id})
             _id = self.db.create(self.topic, content)
             rollback.append({"topic": self.topic, "_id": _id})
-            self._send_msg("create", content)
-            return _id
+            if op_id:
+                content["op_id"] = op_id
+            self._send_msg("created", content)
+            return _id, op_id
         except ValidationError as e:
             raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)
 
         except ValidationError as e:
             raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)
 
-    def upload_content(self, session, _id, indata, kwargs, headers, force=False):
+    def upload_content(self, session, _id, indata, kwargs, headers):
         """
         Only implemented for descriptor topics.  Used for receiving content by chunks (with a transaction_id header
         and/or gzip file. It will store and extract)
         """
         Only implemented for descriptor topics.  Used for receiving content by chunks (with a transaction_id header
         and/or gzip file. It will store and extract)
-        :param session: session
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param _id : the database id of entry to be updated
         :param indata: http body request
         :param kwargs: user query string to override parameters. NOT USED
         :param headers:  http request headers
         :param _id : the database id of entry to be updated
         :param indata: http body request
         :param kwargs: user query string to override parameters. NOT USED
         :param headers:  http request headers
-        :param force: to be more tolerant with validation
         :return: True package has is completely uploaded or False if partial content has been uplodaed.
             Raise exception on error
         """
         :return: True package has is completely uploaded or False if partial content has been uplodaed.
             Raise exception on error
         """
@@ -331,60 +487,119 @@ class BaseTopic:
     def delete_list(self, session, filter_q=None):
         """
         Delete a several entries of a topic. This is for internal usage and test only, not exposed to NBI API
     def delete_list(self, session, filter_q=None):
         """
         Delete a several entries of a topic. This is for internal usage and test only, not exposed to NBI API
-        :param session: contains the used login username and working project
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param filter_q: filter of data to be applied
         :return: The deleted list, it can be empty if no one match the filter.
         """
         # TODO add admin to filter, validate rights
         if not filter_q:
             filter_q = {}
         :param filter_q: filter of data to be applied
         :return: The deleted list, it can be empty if no one match the filter.
         """
         # TODO add admin to filter, validate rights
         if not filter_q:
             filter_q = {}
-        filter_q.update(self._get_project_filter(session, write=True, show_all=True))
+        if self.multiproject:
+            filter_q.update(self._get_project_filter(session))
         return self.db.del_list(self.topic, filter_q)
 
         return self.db.del_list(self.topic, filter_q)
 
-    def delete(self, session, _id, force=False, dry_run=False):
+    def delete_extra(self, session, _id, db_content, not_send_msg=None):
+        """
+        Delete other things apart from database entry of a item _id.
+        e.g.: other associated elements at database and other file system storage
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
+        :param _id: server internal id
+        :param db_content: The database content of the _id. It is already deleted when reached this method, but the
+            content is needed in same cases
+        :param not_send_msg: To not send message (False) or store content (list) instead
+        :return: None if ok or raises EngineException with the problem
+        """
+        pass
+
+    def delete(self, session, _id, dry_run=False, not_send_msg=None):
         """
         Delete item by its internal _id
         """
         Delete item by its internal _id
-        :param session: contains the used login username, working project, and admin rights
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
         :param _id: server internal id
         :param _id: server internal id
-        :param force: indicates if deletion must be forced in case of conflict
         :param dry_run: make checking but do not delete
         :param dry_run: make checking but do not delete
-        :return: dictionary with deleted item _id. It raises EngineException on error: not found, conflict, ...
+        :param not_send_msg: To not send message (False) or store content (list) instead
+        :return: operation id (None if there is not operation), raise exception if error or not found, conflict, ...
         """
         """
-        # TODO add admin to filter, validate rights
-        # data = self.get_item(topic, _id)
-        self.check_conflict_on_del(session, _id, force)
-        filter_q = self._get_project_filter(session, write=True, show_all=True)
-        # To allow project addressing by name AS WELL AS _id
-        filter_q[BaseTopic.id_field(self.topic, _id)] = _id
-        if not dry_run:
-            v = self.db.del_one(self.topic, filter_q)
-            self._send_msg("deleted", {"_id": _id})
-            return v
+
+        # To allow addressing projects and users by name AS WELL AS by _id
+        if not self.multiproject:
+            filter_q = {}
+        else:
+            filter_q = self._get_project_filter(session)
+        filter_q[self.id_field(self.topic, _id)] = _id
+        item_content = self.db.get_one(self.topic, filter_q)
+
+        self.check_conflict_on_del(session, _id, item_content)
+        if dry_run:
+            return None
+        
+        if self.multiproject and session["project_id"]:
+            # remove reference from project_read if there are more projects referencing it. If it last one,
+            # do not remove reference, but delete
+            other_projects_referencing = next((p for p in item_content["_admin"]["projects_read"]
+                                               if p not in session["project_id"] and p != "ANY"), None)
+
+            # check if there are projects referencing it (apart from ANY, that means, public)....
+            if other_projects_referencing:
+                # remove references but not delete
+                update_dict_pull = {"_admin.projects_read": session["project_id"],
+                                    "_admin.projects_write": session["project_id"]}
+                self.db.set_one(self.topic, filter_q, update_dict=None, pull_list=update_dict_pull)
+                return None
+            else:
+                can_write = next((p for p in item_content["_admin"]["projects_write"] if p == "ANY" or
+                                  p in session["project_id"]), None)
+                if not can_write:
+                    raise EngineException("You have not write permission to delete it",
+                                          http_code=HTTPStatus.UNAUTHORIZED)
+
+        # delete
+        self.db.del_one(self.topic, filter_q)
+        self.delete_extra(session, _id, item_content, not_send_msg=not_send_msg)
+        self._send_msg("deleted", {"_id": _id}, not_send_msg=not_send_msg)
         return None
 
         return None
 
-    def edit(self, session, _id, indata=None, kwargs=None, force=False, content=None):
+    def edit(self, session, _id, indata=None, kwargs=None, content=None):
+        """
+        Change the content of an item
+        :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
+        :param _id: server internal id
+        :param indata: contains the changes to apply
+        :param kwargs: modifies indata
+        :param content: original content of the item
+        :return: op_id: operation id if this is processed asynchronously, None otherwise
+        """
         indata = self._remove_envelop(indata)
 
         # Override descriptor with query string kwargs
         if kwargs:
             self._update_input_with_kwargs(indata, kwargs)
         try:
         indata = self._remove_envelop(indata)
 
         # Override descriptor with query string kwargs
         if kwargs:
             self._update_input_with_kwargs(indata, kwargs)
         try:
-            indata = self._validate_input_edit(indata, force=force)
-
+            if indata and session.get("set_project"):
+                raise EngineException("Cannot edit content and set to project (query string SET_PROJECT) at same time",
+                                      HTTPStatus.UNPROCESSABLE_ENTITY)
+            
             # TODO self._check_edition(session, indata, _id, force)
             if not content:
                 content = self.show(session, _id)
             # TODO self._check_edition(session, indata, _id, force)
             if not content:
                 content = self.show(session, _id)
+            
+            indata = self._validate_input_edit(indata, content, force=session["force"])
+            
             deep_update_rfc7396(content, indata)
             deep_update_rfc7396(content, indata)
-            self.check_conflict_on_edit(session, content, indata, _id=_id, force=force)
-            self.format_on_edit(content, indata)
-            # To allow project addressing by name AS WELL AS _id
-            # self.db.replace(self.topic, _id, content)
-            cid = content.get("_id")
-            self.db.replace(self.topic, cid if cid else _id, content)
+
+            # To allow project addressing by name AS WELL AS _id. Get the _id, just in case the provided one is a name
+            _id = content.get("_id") or _id
+
+            self.check_conflict_on_edit(session, content, indata, _id=_id)
+            op_id = self.format_on_edit(content, indata)
+
+            self.db.replace(self.topic, _id, content)
 
             indata.pop("_admin", None)
 
             indata.pop("_admin", None)
+            if op_id:
+                indata["op_id"] = op_id
             indata["_id"] = _id
             indata["_id"] = _id
-            self._send_msg("edit", indata)
-            return id
+            self._send_msg("edited", indata)
+            return op_id
         except ValidationError as e:
             raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)
         except ValidationError as e:
             raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)