Code Coverage

Cobertura Coverage Report > osm_nbi >

base_topic.py

Trend

Classes100%
 
Lines78%
   
Conditionals100%
 

File Coverage summary

NameClassesLinesConditionals
base_topic.py
100%
1/1
78%
255/326
100%
0/0

Coverage Breakdown by Class

NameLinesConditionals
base_topic.py
78%
255/326
N/A

Source

osm_nbi/base_topic.py
1 # -*- coding: utf-8 -*-
2
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 #    http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12 # implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15
16 1 import logging
17 1 from uuid import uuid4
18 1 from http import HTTPStatus
19 1 from time import time
20 1 from osm_common.dbbase import deep_update_rfc7396, DbException
21 1 from osm_nbi.validation import validate_input, ValidationError, is_valid_uuid
22 1 from yaml import safe_load, YAMLError
23
24 1 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
25
26
27 1 class EngineException(Exception):
28 1     def __init__(self, message, http_code=HTTPStatus.BAD_REQUEST):
29 1         self.http_code = http_code
30 1         super(Exception, self).__init__(message)
31
32 1 class NBIBadArgumentsException(Exception):
33     """
34     Bad argument values exception
35     """
36
37 1     def __init__(self, message: str = "", bad_args: list = None):
38 1         Exception.__init__(self, message)
39 1         self.message = message
40 1         self.bad_args = bad_args
41
42 1     def __str__(self):
43 1         return "{}, Bad arguments: {}".format(
44             self.message, self.bad_args
45         )
46
47 1 def deep_get(target_dict, key_list):
48     """
49     Get a value from target_dict entering in the nested keys. If keys does not exist, it returns None
50     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
51     :param target_dict: dictionary to be read
52     :param key_list: list of keys to read from  target_dict
53     :return: The wanted value if exist, None otherwise
54     """
55 1     for key in key_list:
56 1         if not isinstance(target_dict, dict) or key not in target_dict:
57 1             return None
58 0         target_dict = target_dict[key]
59 0     return target_dict
60
61
62 1 def detect_descriptor_usage(
63     descriptor: dict, db_collection: str, db: object
64 ) -> bool:
65     """Detect the descriptor usage state.
66
67     Args:
68         descriptor (dict):   VNF or NS Descriptor as dictionary
69         db_collection (str):   collection name which is looked for in DB
70         db (object):   name of db object
71
72     Returns:
73         True if descriptor is in use else None
74
75     """
76 1     try:
77 1         if not descriptor:
78 1             raise NBIBadArgumentsException(
79                 "Argument is mandatory and can not be empty", "descriptor"
80             )
81
82 1         if not db:
83 1             raise NBIBadArgumentsException("A valid DB object should be provided", "db")
84
85 1         search_dict = {
86             "vnfds": ("vnfrs", "vnfd-id"),
87             "nsds": ("nsrs", "nsd-id"),
88         }
89
90 1         if db_collection not in search_dict:
91 1             raise NBIBadArgumentsException("db_collection should be equal to vnfds or nsds", "db_collection")
92
93 1         record_list = db.get_list(
94             search_dict[db_collection][0],
95             {search_dict[db_collection][1]: descriptor["_id"]},
96         )
97
98 1         if record_list:
99 1             return True
100
101 1     except (DbException, KeyError, NBIBadArgumentsException) as error:
102 1         raise EngineException(f"Error occured while detecting the descriptor usage: {error}")
103
104
105 1 def update_descriptor_usage_state(
106     descriptor: dict, db_collection: str, db: object
107 ) -> None:
108     """Updates the descriptor usage state.
109
110     Args:
111         descriptor (dict):   VNF or NS Descriptor as dictionary
112         db_collection (str):   collection name which is looked for in DB
113         db (object):   name of db object
114
115     Returns:
116         None
117
118     """
119 1     try:
120 1         descriptor_update = {
121             "_admin.usageState": "NOT_IN_USE",
122         }
123
124 1         if detect_descriptor_usage(descriptor, db_collection, db):
125 1             descriptor_update = {
126                 "_admin.usageState": "IN_USE",
127             }
128
129 1         db.set_one(db_collection, {"_id": descriptor["_id"]}, update_dict=descriptor_update)
130
131 1     except (DbException, KeyError, NBIBadArgumentsException) as error:
132 1         raise EngineException(f"Error occured while updating the descriptor usage state: {error}")
133
134
135 1 def get_iterable(input_var):
136     """
137     Returns an iterable, in case input_var is None it just returns an empty tuple
138     :param input_var: can be a list, tuple or None
139     :return: input_var or () if it is None
140     """
141 1     if input_var is None:
142 1         return ()
143 1     return input_var
144
145
146 1 def versiontuple(v):
147     """utility for compare dot separate versions. Fills with zeros to proper number comparison"""
148 0     filled = []
149 0     for point in v.split("."):
150 0         filled.append(point.zfill(8))
151 0     return tuple(filled)
152
153
154 1 def increment_ip_mac(ip_mac, vm_index=1):
155 1     if not isinstance(ip_mac, str):
156 0         return ip_mac
157 1     try:
158         # try with ipv4 look for last dot
159 1         i = ip_mac.rfind(".")
160 1         if i > 0:
161 1             i += 1
162 1             return "{}{}".format(ip_mac[:i], int(ip_mac[i:]) + vm_index)
163         # try with ipv6 or mac look for last colon. Operate in hex
164 0         i = ip_mac.rfind(":")
165 0         if i > 0:
166 0             i += 1
167             # format in hex, len can be 2 for mac or 4 for ipv6
168 0             return ("{}{:0" + str(len(ip_mac) - i) + "x}").format(
169                 ip_mac[:i], int(ip_mac[i:], 16) + vm_index
170             )
171 0     except Exception:
172 0         pass
173 0     return None
174
175
176 1 class BaseTopic:
177     # static variables for all instance classes
178 1     topic = None  # to_override
179 1     topic_msg = None  # to_override
180 1     quota_name = None  # to_override. If not provided topic will be used for quota_name
181 1     schema_new = None  # to_override
182 1     schema_edit = None  # to_override
183 1     multiproject = True  # True if this Topic can be shared by several projects. Then it contains _admin.projects_read
184
185 1     default_quota = 500
186
187     # Alternative ID Fields for some Topics
188 1     alt_id_field = {"projects": "name", "users": "username", "roles": "name"}
189
190 1     def __init__(self, db, fs, msg, auth):
191 1         self.db = db
192 1         self.fs = fs
193 1         self.msg = msg
194 1         self.logger = logging.getLogger("nbi.engine")
195 1         self.auth = auth
196
197 1     @staticmethod
198 1     def id_field(topic, value):
199         """Returns ID Field for given topic and field value"""
200 1         if topic in BaseTopic.alt_id_field.keys() and not is_valid_uuid(value):
201 0             return BaseTopic.alt_id_field[topic]
202         else:
203 1             return "_id"
204
205 1     @staticmethod
206 1     def _remove_envelop(indata=None):
207 1         if not indata:
208 0             return {}
209 1         return indata
210
211 1     def check_quota(self, session):
212         """
213         Check whether topic quota is exceeded by the given project
214         Used by relevant topics' 'new' function to decide whether or not creation of the new item should be allowed
215         :param session[project_id]: projects (tuple) for which quota should be checked
216         :param session[force]: boolean. If true, skip quota checking
217         :return: None
218         :raise:
219             DbException if project not found
220             ValidationError if quota exceeded in one of the projects
221         """
222 1         if session["force"]:
223 1             return
224 0         projects = session["project_id"]
225 0         for project in projects:
226 0             proj = self.auth.get_project(project)
227 0             pid = proj["_id"]
228 0             quota_name = self.quota_name or self.topic
229 0             quota = proj.get("quotas", {}).get(quota_name, self.default_quota)
230 0             count = self.db.count(self.topic, {"_admin.projects_read": pid})
231 0             if count >= quota:
232 0                 name = proj["name"]
233 0                 raise ValidationError(
234                     "quota ({}={}) exceeded for project {} ({})".format(
235                         quota_name, quota, name, pid
236                     ),
237                     http_code=HTTPStatus.UNPROCESSABLE_ENTITY,
238                 )
239
240 1     def _validate_input_new(self, input, force=False):
241         """
242         Validates input user content for a new entry. It uses jsonschema. Some overrides will use pyangbind
243         :param input: user input content for the new topic
244         :param force: may be used for being more tolerant
245         :return: The same input content, or a changed version of it.
246         """
247 1         if self.schema_new:
248 1             validate_input(input, self.schema_new)
249 1         return input
250
251 1     def _validate_input_edit(self, input, content, force=False):
252         """
253         Validates input user content for an edition. It uses jsonschema. Some overrides will use pyangbind
254         :param input: user input content for the new topic
255         :param force: may be used for being more tolerant
256         :return: The same input content, or a changed version of it.
257         """
258 1         if self.schema_edit:
259 1             validate_input(input, self.schema_edit)
260 1         return input
261
262 1     @staticmethod
263 1     def _get_project_filter(session):
264         """
265         Generates a filter dictionary for querying database, so that only allowed items for this project can be
266         addressed. Only proprietary or public can be used. Allowed projects are at _admin.project_read/write. If it is
267         not present or contains ANY mean public.
268         :param session: contains:
269             project_id: project list this session has rights to access. Can be empty, one or several
270             set_project: items created will contain this project list
271             force: True or False
272             public: True, False or None
273             method: "list", "show", "write", "delete"
274             admin: True or False
275         :return: dictionary with project filter
276         """
277 1         p_filter = {}
278 1         project_filter_n = []
279 1         project_filter = list(session["project_id"])
280
281 1         if session["method"] not in ("list", "delete"):
282 1             if project_filter:
283 1                 project_filter.append("ANY")
284 1         elif session["public"] is not None:
285 1             if session["public"]:
286 0                 project_filter.append("ANY")
287             else:
288 1                 project_filter_n.append("ANY")
289
290 1         if session.get("PROJECT.ne"):
291 0             project_filter_n.append(session["PROJECT.ne"])
292
293 1         if project_filter:
294 1             if session["method"] in ("list", "show", "delete") or session.get(
295                 "set_project"
296             ):
297 1                 p_filter["_admin.projects_read.cont"] = project_filter
298             else:
299 1                 p_filter["_admin.projects_write.cont"] = project_filter
300 1         if project_filter_n:
301 1             if session["method"] in ("list", "show", "delete") or session.get(
302                 "set_project"
303             ):
304 1                 p_filter["_admin.projects_read.ncont"] = project_filter_n
305             else:
306 0                 p_filter["_admin.projects_write.ncont"] = project_filter_n
307
308 1         return p_filter
309
310 1     def check_conflict_on_new(self, session, indata):
311         """
312         Check that the data to be inserted is valid
313         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
314         :param indata: data to be inserted
315         :return: None or raises EngineException
316         """
317 0         pass
318
319 1     def check_conflict_on_edit(self, session, final_content, edit_content, _id):
320         """
321         Check that the data to be edited/uploaded is valid
322         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
323         :param final_content: data once modified. This method may change it.
324         :param edit_content: incremental data that contains the modifications to apply
325         :param _id: internal _id
326         :return: final_content or raises EngineException
327         """
328 1         if not self.multiproject:
329 0             return final_content
330         # Change public status
331 1         if session["public"] is not None:
332 1             if (
333                 session["public"]
334                 and "ANY" not in final_content["_admin"]["projects_read"]
335             ):
336 0                 final_content["_admin"]["projects_read"].append("ANY")
337 0                 final_content["_admin"]["projects_write"].clear()
338 1             if (
339                 not session["public"]
340                 and "ANY" in final_content["_admin"]["projects_read"]
341             ):
342 0                 final_content["_admin"]["projects_read"].remove("ANY")
343
344         # Change project status
345 1         if session.get("set_project"):
346 0             for p in session["set_project"]:
347 0                 if p not in final_content["_admin"]["projects_read"]:
348 0                     final_content["_admin"]["projects_read"].append(p)
349
350 1         return final_content
351
352 1     def check_unique_name(self, session, name, _id=None):
353         """
354         Check that the name is unique for this project
355         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
356         :param name: name to be checked
357         :param _id: If not None, ignore this entry that are going to change
358         :return: None or raises EngineException
359         """
360 1         if not self.multiproject:
361 0             _filter = {}
362         else:
363 1             _filter = self._get_project_filter(session)
364 1         _filter["name"] = name
365 1         if _id:
366 1             _filter["_id.neq"] = _id
367 1         if self.db.get_one(
368             self.topic, _filter, fail_on_empty=False, fail_on_more=False
369         ):
370 1             raise EngineException(
371                 "name '{}' already exists for {}".format(name, self.topic),
372                 HTTPStatus.CONFLICT,
373             )
374
375 1     @staticmethod
376 1     def format_on_new(content, project_id=None, make_public=False):
377         """
378         Modifies content descriptor to include _admin
379         :param content: descriptor to be modified
380         :param project_id: if included, it add project read/write permissions. Can be None or a list
381         :param make_public: if included it is generated as public for reading.
382         :return: op_id: operation id on asynchronous operation, None otherwise. In addition content is modified
383         """
384 1         now = time()
385 1         if "_admin" not in content:
386 1             content["_admin"] = {}
387 1         if not content["_admin"].get("created"):
388 1             content["_admin"]["created"] = now
389 1         content["_admin"]["modified"] = now
390 1         if not content.get("_id"):
391 1             content["_id"] = str(uuid4())
392 1         if project_id is not None:
393 1             if not content["_admin"].get("projects_read"):
394 1                 content["_admin"]["projects_read"] = list(project_id)
395 1                 if make_public:
396 0                     content["_admin"]["projects_read"].append("ANY")
397 1             if not content["_admin"].get("projects_write"):
398 1                 content["_admin"]["projects_write"] = list(project_id)
399 1         return None
400
401 1     @staticmethod
402 1     def format_on_edit(final_content, edit_content):
403         """
404         Modifies final_content to admin information upon edition
405         :param final_content: final content to be stored at database
406         :param edit_content: user requested update content
407         :return: operation id, if this edit implies an asynchronous operation; None otherwise
408         """
409 1         if final_content.get("_admin"):
410 1             now = time()
411 1             final_content["_admin"]["modified"] = now
412 1         return None
413
414 1     def _send_msg(self, action, content, not_send_msg=None):
415 1         if self.topic_msg and not_send_msg is not False:
416 1             content = content.copy()
417 1             content.pop("_admin", None)
418 1             if isinstance(not_send_msg, list):
419 0                 not_send_msg.append((self.topic_msg, action, content))
420             else:
421 1                 self.msg.write(self.topic_msg, action, content)
422
423 1     def check_conflict_on_del(self, session, _id, db_content):
424         """
425         Check if deletion can be done because of dependencies if it is not force. To override
426         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
427         :param _id: internal _id
428         :param db_content: The database content of this item _id
429         :return: None if ok or raises EngineException with the conflict
430         """
431 1         pass
432
433 1     @staticmethod
434 1     def _update_input_with_kwargs(desc, kwargs, yaml_format=False):
435         """
436         Update descriptor with the kwargs. It contains dot separated keys
437         :param desc: dictionary to be updated
438         :param kwargs: plain dictionary to be used for updating.
439         :param yaml_format: get kwargs values as yaml format.
440         :return: None, 'desc' is modified. It raises EngineException.
441         """
442 1         if not kwargs:
443 1             return
444 1         try:
445 1             for k, v in kwargs.items():
446 1                 update_content = desc
447 1                 kitem_old = None
448 1                 klist = k.split(".")
449 1                 for kitem in klist:
450 1                     if kitem_old is not None:
451 1                         update_content = update_content[kitem_old]
452 1                     if isinstance(update_content, dict):
453 1                         kitem_old = kitem
454 1                         if not isinstance(update_content.get(kitem_old), (dict, list)):
455 1                             update_content[kitem_old] = {}
456 1                     elif isinstance(update_content, list):
457                         # key must be an index of the list, must be integer
458 1                         kitem_old = int(kitem)
459                         # if index greater than list, extend the list
460 1                         if kitem_old >= len(update_content):
461 1                             update_content += [None] * (
462                                 kitem_old - len(update_content) + 1
463                             )
464 1                         if not isinstance(update_content[kitem_old], (dict, list)):
465 1                             update_content[kitem_old] = {}
466                     else:
467 0                         raise EngineException(
468                             "Invalid query string '{}'. Descriptor is not a list nor dict at '{}'".format(
469                                 k, kitem
470                             )
471                         )
472 1                 if v is None:
473 1                     del update_content[kitem_old]
474                 else:
475 1                     update_content[kitem_old] = v if not yaml_format else safe_load(v)
476 1         except KeyError:
477 0             raise EngineException(
478                 "Invalid query string '{}'. Descriptor does not contain '{}'".format(
479                     k, kitem_old
480                 )
481             )
482 1         except ValueError:
483 1             raise EngineException(
484                 "Invalid query string '{}'. Expected integer index list instead of '{}'".format(
485                     k, kitem
486                 )
487             )
488 0         except IndexError:
489 0             raise EngineException(
490                 "Invalid query string '{}'. Index '{}' out of  range".format(
491                     k, kitem_old
492                 )
493             )
494 0         except YAMLError:
495 0             raise EngineException("Invalid query string '{}' yaml format".format(k))
496
497 1     def sol005_projection(self, data):
498         # Projection was moved to child classes
499 0         return data
500
501 1     def show(self, session, _id, filter_q=None, api_req=False):
502         """
503         Get complete information on an topic
504         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
505         :param _id: server internal id
506         :param filter_q: dict: query parameter
507         :param api_req: True if this call is serving an external API request. False if serving internal request.
508         :return: dictionary, raise exception if not found.
509         """
510 1         if not self.multiproject:
511 0             filter_db = {}
512         else:
513 1             filter_db = self._get_project_filter(session)
514         # To allow project&user addressing by name AS WELL AS _id
515 1         filter_db[BaseTopic.id_field(self.topic, _id)] = _id
516 1         data = self.db.get_one(self.topic, filter_db)
517
518         # Only perform SOL005 projection if we are serving an external request
519 1         if api_req:
520 0             self.sol005_projection(data)
521
522 1         return data
523
524         # TODO transform data for SOL005 URL requests
525         # TODO remove _admin if not admin
526
527 1     def get_file(self, session, _id, path=None, accept_header=None):
528         """
529         Only implemented for descriptor topics. Return the file content of a descriptor
530         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
531         :param _id: Identity of the item to get content
532         :param path: artifact path or "$DESCRIPTOR" or None
533         :param accept_header: Content of Accept header. Must contain applition/zip or/and text/plain
534         :return: opened file or raises an exception
535         """
536 0         raise EngineException(
537             "Method get_file not valid for this topic", HTTPStatus.INTERNAL_SERVER_ERROR
538         )
539
540 1     def list(self, session, filter_q=None, api_req=False):
541         """
542         Get a list of the topic that matches a filter
543         :param session: contains the used login username and working project
544         :param filter_q: filter of data to be applied
545         :param api_req: True if this call is serving an external API request. False if serving internal request.
546         :return: The list, it can be empty if no one match the filter.
547         """
548 0         if not filter_q:
549 0             filter_q = {}
550 0         if self.multiproject:
551 0             filter_q.update(self._get_project_filter(session))
552
553         # TODO transform data for SOL005 URL requests. Transform filtering
554         # TODO implement "field-type" query string SOL005
555 0         data = self.db.get_list(self.topic, filter_q)
556
557         # Only perform SOL005 projection if we are serving an external request
558 0         if api_req:
559 0             data = [self.sol005_projection(inst) for inst in data]
560
561 0         return data
562
563 1     def new(self, rollback, session, indata=None, kwargs=None, headers=None):
564         """
565         Creates a new entry into database.
566         :param rollback: list to append created items at database in case a rollback may to be done
567         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
568         :param indata: data to be inserted
569         :param kwargs: used to override the indata descriptor
570         :param headers: http request headers
571         :return: _id, op_id:
572             _id: identity of the inserted data.
573              op_id: operation id if this is asynchronous, None otherwise
574         """
575 1         try:
576 1             if self.multiproject:
577 1                 self.check_quota(session)
578
579 1             content = self._remove_envelop(indata)
580
581             # Override descriptor with query string kwargs
582 1             self._update_input_with_kwargs(content, kwargs)
583 1             content = self._validate_input_new(content, force=session["force"])
584 1             self.check_conflict_on_new(session, content)
585 1             op_id = self.format_on_new(
586                 content, project_id=session["project_id"], make_public=session["public"]
587             )
588 1             _id = self.db.create(self.topic, content)
589 1             rollback.append({"topic": self.topic, "_id": _id})
590 1             if op_id:
591 1                 content["op_id"] = op_id
592 1             self._send_msg("created", content)
593 1             return _id, op_id
594 1         except ValidationError as e:
595 0             raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)
596
597 1     def upload_content(self, session, _id, indata, kwargs, headers):
598         """
599         Only implemented for descriptor topics.  Used for receiving content by chunks (with a transaction_id header
600         and/or gzip file. It will store and extract)
601         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
602         :param _id : the database id of entry to be updated
603         :param indata: http body request
604         :param kwargs: user query string to override parameters. NOT USED
605         :param headers:  http request headers
606         :return: True package has is completely uploaded or False if partial content has been uplodaed.
607             Raise exception on error
608         """
609 0         raise EngineException(
610             "Method upload_content not valid for this topic",
611             HTTPStatus.INTERNAL_SERVER_ERROR,
612         )
613
614 1     def delete_list(self, session, filter_q=None):
615         """
616         Delete a several entries of a topic. This is for internal usage and test only, not exposed to NBI API
617         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
618         :param filter_q: filter of data to be applied
619         :return: The deleted list, it can be empty if no one match the filter.
620         """
621         # TODO add admin to filter, validate rights
622 0         if not filter_q:
623 0             filter_q = {}
624 0         if self.multiproject:
625 0             filter_q.update(self._get_project_filter(session))
626 0         return self.db.del_list(self.topic, filter_q)
627
628 1     def delete_extra(self, session, _id, db_content, not_send_msg=None):
629         """
630         Delete other things apart from database entry of a item _id.
631         e.g.: other associated elements at database and other file system storage
632         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
633         :param _id: server internal id
634         :param db_content: The database content of the _id. It is already deleted when reached this method, but the
635             content is needed in same cases
636         :param not_send_msg: To not send message (False) or store content (list) instead
637         :return: None if ok or raises EngineException with the problem
638         """
639 0         pass
640
641 1     def delete(self, session, _id, dry_run=False, not_send_msg=None):
642         """
643         Delete item by its internal _id
644         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
645         :param _id: server internal id
646         :param dry_run: make checking but do not delete
647         :param not_send_msg: To not send message (False) or store content (list) instead
648         :return: operation id (None if there is not operation), raise exception if error or not found, conflict, ...
649         """
650
651         # To allow addressing projects and users by name AS WELL AS by _id
652 1         if not self.multiproject:
653 0             filter_q = {}
654         else:
655 1             filter_q = self._get_project_filter(session)
656 1         filter_q[self.id_field(self.topic, _id)] = _id
657 1         item_content = self.db.get_one(self.topic, filter_q)
658
659 1         self.check_conflict_on_del(session, _id, item_content)
660 1         if dry_run:
661 0             return None
662
663 1         if self.multiproject and session["project_id"]:
664             # remove reference from project_read if there are more projects referencing it. If it last one,
665             # do not remove reference, but delete
666 1             other_projects_referencing = next(
667                 (
668                     p
669                     for p in item_content["_admin"]["projects_read"]
670                     if p not in session["project_id"] and p != "ANY"
671                 ),
672                 None,
673             )
674
675             # check if there are projects referencing it (apart from ANY, that means, public)....
676 1             if other_projects_referencing:
677                 # remove references but not delete
678 1                 update_dict_pull = {
679                     "_admin.projects_read": session["project_id"],
680                     "_admin.projects_write": session["project_id"],
681                 }
682 1                 self.db.set_one(
683                     self.topic, filter_q, update_dict=None, pull_list=update_dict_pull
684                 )
685 1                 return None
686             else:
687 1                 can_write = next(
688                     (
689                         p
690                         for p in item_content["_admin"]["projects_write"]
691                         if p == "ANY" or p in session["project_id"]
692                     ),
693                     None,
694                 )
695 1                 if not can_write:
696 0                     raise EngineException(
697                         "You have not write permission to delete it",
698                         http_code=HTTPStatus.UNAUTHORIZED,
699                     )
700
701         # delete
702 1         self.db.del_one(self.topic, filter_q)
703 1         self.delete_extra(session, _id, item_content, not_send_msg=not_send_msg)
704 1         self._send_msg("deleted", {"_id": _id}, not_send_msg=not_send_msg)
705 1         return None
706
707 1     def edit(self, session, _id, indata=None, kwargs=None, content=None):
708         """
709         Change the content of an item
710         :param session: contains "username", "admin", "force", "public", "project_id", "set_project"
711         :param _id: server internal id
712         :param indata: contains the changes to apply
713         :param kwargs: modifies indata
714         :param content: original content of the item
715         :return: op_id: operation id if this is processed asynchronously, None otherwise
716         """
717 1         indata = self._remove_envelop(indata)
718
719         # Override descriptor with query string kwargs
720 1         if kwargs:
721 0             self._update_input_with_kwargs(indata, kwargs)
722 1         try:
723 1             if indata and session.get("set_project"):
724 0                 raise EngineException(
725                     "Cannot edit content and set to project (query string SET_PROJECT) at same time",
726                     HTTPStatus.UNPROCESSABLE_ENTITY,
727                 )
728             # TODO self._check_edition(session, indata, _id, force)
729 1             if not content:
730 1                 content = self.show(session, _id)
731 1             indata = self._validate_input_edit(indata, content, force=session["force"])
732 1             deep_update_rfc7396(content, indata)
733
734             # To allow project addressing by name AS WELL AS _id. Get the _id, just in case the provided one is a name
735 1             _id = content.get("_id") or _id
736
737 1             content = self.check_conflict_on_edit(session, content, indata, _id=_id)
738 1             op_id = self.format_on_edit(content, indata)
739
740 1             self.db.replace(self.topic, _id, content)
741
742 1             indata.pop("_admin", None)
743 1             if op_id:
744 1                 indata["op_id"] = op_id
745 1             indata["_id"] = _id
746 1             self._send_msg("edited", indata)
747 1             return op_id
748 1         except ValidationError as e:
749 1             raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)