No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

components.py 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. #
  2. # This file is part of Lodel 2 (https://github.com/OpenEdition)
  3. #
  4. # Copyright (C) 2015-2017 Cléo UMS-3287
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as published
  8. # by the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. #
  19. # @package lodel.editorial_model.components
  20. #@brief Defines all @ref lodel2_em "EM" components
  21. #@ingroup lodel2_em
  22. import itertools
  23. import warnings
  24. import copy
  25. import hashlib
  26. from lodel.context import LodelContext
  27. LodelContext.expose_modules(globals(), {
  28. 'lodel.utils.mlstring': ['MlString'],
  29. 'lodel.mlnamedobject.mlnamedobject': ['MlNamedObject'],
  30. 'lodel.settings': ['Settings'],
  31. 'lodel.editorial_model.exceptions': ['EditorialModelError', 'assert_edit'],
  32. 'lodel.leapi.leobject': ['CLASS_ID_FIELDNAME']})
  33. ## @brief Abstract class to represent editorial model components
  34. # @see EmClass EmField
  35. # @todo forbid '.' in uid
  36. #@ingroup lodel2_em
  37. class EmComponent(MlNamedObject):
  38. ## @brief Instanciate an EmComponent
  39. # @param uid str : uniq identifier
  40. # @param display_name MlString|str|dict : component display_name
  41. # @param help_text MlString|str|dict : help_text
  42. def __init__(self, uid, display_name=None, help_text=None, group=None):
  43. if self.__class__ == EmComponent:
  44. raise NotImplementedError('EmComponent is an abstract class')
  45. self.uid = uid
  46. self.group = group
  47. super().__init__(display_name, help_text)
  48. ## @brief Returns the display_name of the component if it is not None, its uid else
  49. def __str__(self):
  50. if self.display_name is None:
  51. return str(self.uid)
  52. return str(self.display_name)
  53. ## @brief Returns a hash code for the component
  54. def d_hash(self):
  55. m = hashlib.md5()
  56. for data in (
  57. self.uid,
  58. 'NODISPNAME' if self.display_name is None else str(self.display_name.d_hash()),
  59. 'NOHELP' if self.help_text is None else str(self.help_text.d_hash()),
  60. 'NOGROUP' if self.group is None else str(self.group.d_hash()),
  61. ):
  62. m.update(bytes(data, 'utf-8'))
  63. return int.from_bytes(m.digest(), byteorder='big')
  64. ## @brief Handles editorial model objects classes
  65. #@ingroup lodel2_em
  66. class EmClass(EmComponent):
  67. ## @brief Instanciates a new EmClass
  68. #@param uid str : uniq identifier
  69. #@param display_name MlString|str|dict : component display_name
  70. #@param abstract bool : set the class as asbtract if True
  71. #@param pure_abstract bool : if True the EmClass will not be represented in
  72. # leapi dyncode
  73. #@param parents list: parent EmClass list or uid list
  74. #@param help_text MlString|str|dict : help_text
  75. #@param datasources str|tuple|list : The datasource name ( see
  76. #@ref lodel2_datasources ) or two names (first is read_only datasource the
  77. # second is read write)
  78. def __init__(
  79. self, uid, display_name=None, help_text=None, abstract=False,
  80. parents=None, group=None, pure_abstract=False,
  81. datasources='default'):
  82. super().__init__(uid, display_name, help_text, group)
  83. self.abstract = bool(abstract)
  84. self.pure_abstract = bool(pure_abstract)
  85. self.__datasource = datasources
  86. if not isinstance(datasources, str) and len(datasources) != 2:
  87. raise ValueError("datasources argument can be a single datasource\
  88. name or two names in a tuple or a list")
  89. if self.pure_abstract:
  90. self.abtract = True
  91. if parents is not None:
  92. if not isinstance(parents, list):
  93. parents = [parents]
  94. for parent in parents:
  95. if not isinstance(parent, EmClass):
  96. raise ValueError(
  97. "<class EmClass> expected in parents list, but %s found" % type(parent))
  98. else:
  99. parents = list()
  100. self.parents = parents
  101. ## @brief Stores EmFields instances indexed by field uid
  102. self.__fields = dict()
  103. self.group = group
  104. if group is None:
  105. warnings.warn("NO GROUP FOR EMCLASS %s" % uid)
  106. else:
  107. group.add_components([self])
  108. # Adding common field
  109. if not self.abstract:
  110. self.new_field(
  111. CLASS_ID_FIELDNAME,
  112. display_name={
  113. 'eng': "LeObject subclass identifier",
  114. 'fre': "Identifiant de la class fille de LeObject"},
  115. help_text={
  116. 'eng': "Allow to create instance of the good class when\
  117. fetching arbitrary datas from DB"},
  118. data_handler='LeobjectSubclassIdentifier',
  119. internal=True,
  120. group=group)
  121. ## @brief Property that represents a dict of all fields
  122. # (the EmField objects defined in this class and all their parents)
  123. # @todo use Settings.editorialmodel.groups to determine which fields should be returned
  124. @property
  125. def __all_fields(self):
  126. res = dict()
  127. for pfields in [p.__all_fields for p in self.parents]:
  128. res.update(pfields)
  129. res.update(self.__fields)
  130. return res
  131. ## @brief RO access to datasource attribute
  132. @property
  133. def datasource(self):
  134. return self.__datasource
  135. ## @brief Returns the list of all dependencies
  136. #
  137. # Recursive parents listing
  138. @property
  139. def parents_recc(self):
  140. if len(self.parents) == 0:
  141. return set()
  142. res = set(self.parents)
  143. for parent in self.parents:
  144. res |= parent.parents_recc
  145. return res
  146. ## @brief EmField getter
  147. # @param uid None | str : If None returns an iterator on EmField instances else return an EmField instance
  148. # @param no_parents bool : If True returns only fields defined is this class and not the one defined in parents classes
  149. # @return A list on EmFields instances (if uid is None) else return an EmField instance
  150. # @todo use Settings.editorialmodel.groups to determine wich fields should be returned
  151. def fields(self, uid=None, no_parents=False):
  152. fields = self.__fields if no_parents else self.__all_fields
  153. try:
  154. return list(fields.values()) if uid is None else fields[uid]
  155. except KeyError:
  156. raise EditorialModelError("No such EmField '%s'" % uid)
  157. ## @brief Keeps in __fields only fields contained in active groups
  158. def _set_active_fields(self, active_groups):
  159. if not Settings.editorialmodel.editormode:
  160. active_fields = []
  161. for grp_name, agrp in active_groups.items():
  162. active_fields += [emc for emc in agrp.components()
  163. if isinstance(emc, EmField)]
  164. self.__fields = {fname: fdh for fname, fdh in self.__fields.items()
  165. if fdh in active_fields}
  166. ## @brief Adds a field to the EmClass
  167. # @param emfield EmField : an EmField instance
  168. # @warning do not add an EmField already in another class !
  169. # @throw EditorialModelException if an EmField with same uid already in this EmClass (overwriting allowed from parents)
  170. # @todo End the override checks (needs methods in data_handlers)
  171. def add_field(self, emfield):
  172. assert_edit()
  173. if emfield.uid in self.__fields:
  174. raise EditorialModelError(
  175. "Duplicated uid '%s' for EmField in this class ( %s )" % (emfield.uid, self))
  176. # Incomplete field override check
  177. if emfield.uid in self.__all_fields:
  178. parent_field = self.__all_fields[emfield.uid]
  179. if not emfield.data_handler_instance.can_override(parent_field.data_handler_instance):
  180. raise AttributeError(
  181. "'%s' field overrides a parent field, but data_handlers are not compatible" % emfield.uid)
  182. self.__fields[emfield.uid] = emfield
  183. return emfield
  184. ## @brief Creates a new EmField and adds it to the EmClass
  185. # @param data_handler str : A DataHandler name
  186. # @param uid str : the EmField uniq id
  187. # @param **field_kwargs : EmField constructor parameters ( see @ref EmField.__init__() )
  188. def new_field(self, uid, data_handler, **field_kwargs):
  189. assert_edit()
  190. return self.add_field(EmField(uid, data_handler, self, **field_kwargs))
  191. def d_hash(self):
  192. m = hashlib.md5()
  193. payload = str(super().d_hash()) + ("1" if self.abstract else "0")
  194. for p in sorted(self.parents):
  195. payload += str(p.d_hash())
  196. for fuid in sorted(self.__fields.keys()):
  197. payload += str(self.__fields[fuid].d_hash())
  198. m.update(bytes(payload, 'utf-8'))
  199. return int.from_bytes(m.digest(), byteorder='big')
  200. def __str__(self):
  201. return "<class EmClass %s>" % self.uid
  202. def __repr__(self):
  203. if not self.abstract:
  204. abstract = ''
  205. elif self.pure_abstract:
  206. abstract = 'PureAbstract'
  207. else:
  208. abstract = 'Abstract'
  209. return "<class %s EmClass uid=%s>" % (abstract, repr(self.uid))
  210. ## @brief Handles editorial model classes fields
  211. #@ingroup lodel2_em
  212. class EmField(EmComponent):
  213. ## @brief Instanciates a new EmField
  214. # @param uid str : uniq identifier
  215. # @param display_name MlString|str|dict : field display_name
  216. # @param data_handler str : A DataHandler name
  217. # @param help_text MlString|str|dict : help text
  218. # @param group EmGroup :
  219. # @param **handler_kwargs : data handler arguments
  220. def __init__(self, uid, data_handler, em_class=None, display_name=None, help_text=None, group=None, **handler_kwargs):
  221. from lodel.leapi.datahandlers.base_classes import DataHandler
  222. super().__init__(uid, display_name, help_text, group)
  223. ## @brief The data handler name
  224. self.data_handler_name = data_handler
  225. ## @brief The data handler class
  226. self.data_handler_cls = DataHandler.from_name(data_handler)
  227. ## @brief The data handler instance associated with this EmField
  228. self.data_handler_instance = self.data_handler_cls(**handler_kwargs)
  229. ## @brief Stores data handler instanciation options
  230. self.data_handler_options = handler_kwargs
  231. ## @brief Stores the emclass that contains this field (set by EmClass.add_field() method)
  232. self._emclass = em_class
  233. if self._emclass is None:
  234. warnings.warn("No EmClass for field %s" % uid)
  235. if group is None:
  236. warnings.warn("No EmGroup for field %s" % uid)
  237. else:
  238. group.add_components([self])
  239. ## @brief Returns data_handler_name attribute
  240. def get_data_handler_name(self):
  241. return copy.copy(self.data_handler_name)
  242. ## @brief Returns data_handler_cls attribute
  243. def get_data_handler_cls(self):
  244. return copy.copy(self.data_handler_cls)
  245. ##@brief Returns the uid of the emclass which contains this field
  246. def get_emclass_uid(self):
  247. return self._emclass.uid
  248. # @warning Not complete !
  249. # @todo Complete the hash when data handlers becomes available
  250. def d_hash(self):
  251. return int.from_bytes(hashlib.md5(
  252. bytes(
  253. "%s%s%s" % (super().d_hash(),
  254. self.data_handler_name,
  255. self.data_handler_options),
  256. 'utf-8')
  257. ).digest(), byteorder='big')
  258. ## @brief Handles functionnal group of EmComponents
  259. #@ingroup lodel2_em
  260. class EmGroup(MlNamedObject):
  261. ## @brief Creates a new EmGroup
  262. # @note you should NEVER call the constructor yourself. Use Model.add_group instead
  263. # @param uid str : Uniq identifier
  264. # @param depends list : A list of EmGroup dependencies
  265. # @param display_name MlString|str :
  266. # @param help_text MlString|str :
  267. def __init__(self, uid, depends=None, display_name=None, help_text=None):
  268. self.uid = uid
  269. ## @brief Stores the list of groups that depends on this EmGroup indexed by uid
  270. self.required_by = dict()
  271. ## @brief Stores the list of dependencies (EmGroup) indexed by uid
  272. self.require = dict()
  273. ## @brief Stores the list of EmComponent instances contained in this group
  274. self.__components = set()
  275. super().__init__(display_name, help_text)
  276. if depends is not None:
  277. for grp in depends:
  278. if not isinstance(grp, EmGroup):
  279. raise ValueError("EmGroup expected in depends argument but %s found" % grp)
  280. self.add_dependency(grp)
  281. ## @brief Returns EmGroup dependencies
  282. # @param recursive bool : if True returns all dependencies and their own dependencies
  283. # @return a dict of EmGroup identified by uid
  284. def dependencies(self, recursive=False):
  285. res = copy.copy(self.require)
  286. if not recursive:
  287. return res
  288. to_scan = list(res.values())
  289. while len(to_scan) > 0:
  290. cur_dep = to_scan.pop()
  291. for new_dep in cur_dep.require.values():
  292. if new_dep not in res:
  293. to_scan.append(new_dep)
  294. res[new_dep.uid] = new_dep
  295. return res
  296. ## @brief Returns EmGroup applicants
  297. # @param recursive bool : if True returns all dependencies and their dependencies
  298. # @returns a dict of EmGroup identified by uid
  299. def applicants(self, recursive=False):
  300. res = copy.copy(self.required_by)
  301. if not recursive:
  302. return res
  303. to_scan = list(res.values())
  304. while len(to_scan) > 0:
  305. cur_app = to_scan.pop()
  306. for new_app in cur_app.required_by.values():
  307. if new_app not in res:
  308. to_scan.append(new_app)
  309. res[new_app.uid] = new_app
  310. return res
  311. ## @brief Returns EmGroup components
  312. # @returns a copy of the set of components
  313. def components(self):
  314. return (self.__components).copy()
  315. ## @brief Returns EmGroup display_name
  316. # @param lang str | None : If None returns default lang translation
  317. # @returns None if display_name is None, a str for display_name else
  318. def get_display_name(self, lang=None):
  319. name = self.display_name
  320. if name is None:
  321. return None
  322. return name.get(lang)
  323. ## @brief Returns EmGroup help_text
  324. # @param lang str | None : If None returns default lang translation
  325. # @returns None if display_name is None, a str for display_name else
  326. def get_help_text(self, lang=None):
  327. help = self.help_text
  328. if help is None:
  329. return None
  330. return help.get(lang)
  331. ## @brief Adds components in a group
  332. # @param components list : EmComponent instances list
  333. def add_components(self, components):
  334. assert_edit()
  335. for component in components:
  336. if isinstance(component, EmField):
  337. if component._emclass is None:
  338. msg = "Adding an orphan EmField '%s' to EmGroup '%s'"
  339. msg %= (component, self)
  340. warnings.warn(msg)
  341. elif not isinstance(component, EmClass):
  342. raise EditorialModelError(
  343. "Expecting components to be a list of EmComponent, but %s found in the list" % type(component))
  344. self.__components |= set(components)
  345. ## @brief Add a dependency
  346. # @param em_group EmGroup|iterable : an EmGroup instance or list of instances
  347. def add_dependency(self, grp):
  348. assert_edit()
  349. try:
  350. for group in grp:
  351. self.add_dependency(group)
  352. return
  353. except TypeError:
  354. pass
  355. if grp.uid in self.require:
  356. return
  357. if self.__circular_dependency(grp):
  358. raise EditorialModelError("Circular dependencie detected, cannot add dependencie")
  359. self.require[grp.uid] = grp
  360. grp.required_by[self.uid] = self
  361. ## @brief Add a applicant
  362. # @param em_group EmGroup|iterable : an EmGroup instance or list of instance
  363. # Useless ???
  364. def add_applicant(self, grp):
  365. assert_edit()
  366. try:
  367. for group in grp:
  368. self.add_applicant(group)
  369. return
  370. except TypeError:
  371. pass
  372. if grp.uid in self.required_by:
  373. return
  374. if self.__circular_applicant(grp):
  375. raise EditorialModelError("Circular applicant detected, cannot add applicant")
  376. self.required_by[grp.uid] = grp
  377. grp.require[self.uid] = self
  378. ## @brief Search for circular dependency
  379. # @return True if circular dep found else False
  380. def __circular_dependency(self, new_dep):
  381. return self.uid in new_dep.dependencies(True)
  382. ## @brief Search for circular applicant
  383. # @return True if circular app found else False
  384. def __circular_applicant(self, new_app):
  385. return self.uid in new_app.applicants(True)
  386. ## @brief Fancy string representation of an EmGroup
  387. # @return a string
  388. def __str__(self):
  389. if self.display_name is None:
  390. return self.uid
  391. else:
  392. return self.display_name.get()
  393. ## @brief Computes a d-hash code for the EmGroup
  394. # @return a string
  395. def d_hash(self):
  396. payload = "%s%s%s" % (
  397. self.uid,
  398. 'NODNAME' if self.display_name is None else self.display_name.d_hash(),
  399. 'NOHELP' if self.help_text is None else self.help_text.d_hash()
  400. )
  401. for recurs in (False, True):
  402. deps = self.dependencies(recurs)
  403. for dep_uid in sorted(deps.keys()):
  404. payload += str(deps[dep_uid].d_hash())
  405. for req_by_uid in self.required_by:
  406. payload += req_by_uid
  407. return int.from_bytes(
  408. bytes(payload, 'utf-8'),
  409. byteorder='big'
  410. )
  411. ## @brief Complete string representation of an EmGroup
  412. # @return a string
  413. def __repr__(self):
  414. return "<class EmGroup '%s' depends : [%s]>" % (self.uid, ', '.join([duid for duid in self.dependencies(False)]))