context_models.py 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885
  1. # coding=utf-8
  2. import typing
  3. from datetime import datetime
  4. from enum import Enum
  5. from slugify import slugify
  6. from sqlalchemy.orm import Session
  7. from tracim_backend.config import CFG
  8. from tracim_backend.config import PreviewDim
  9. from tracim_backend.extensions import app_list
  10. from tracim_backend.lib.core.application import ApplicationApi
  11. from tracim_backend.lib.utils.utils import get_root_frontend_url
  12. from tracim_backend.lib.utils.utils import password_generator
  13. from tracim_backend.lib.utils.utils import CONTENT_FRONTEND_URL_SCHEMA
  14. from tracim_backend.lib.utils.utils import WORKSPACE_FRONTEND_URL_SCHEMA
  15. from tracim_backend.models import User
  16. from tracim_backend.models.auth import Profile
  17. from tracim_backend.models.auth import Group
  18. from tracim_backend.models.data import Content
  19. from tracim_backend.models.data import ContentRevisionRO
  20. from tracim_backend.models.data import Workspace
  21. from tracim_backend.models.data import UserRoleInWorkspace
  22. from tracim_backend.models.roles import WorkspaceRoles
  23. from tracim_backend.app_models.workspace_menu_entries import WorkspaceMenuEntry
  24. from tracim_backend.app_models.contents import CONTENT_TYPES
  25. class PreviewAllowedDim(object):
  26. def __init__(
  27. self,
  28. restricted:bool,
  29. dimensions: typing.List[PreviewDim]
  30. ) -> None:
  31. self.restricted = restricted
  32. self.dimensions = dimensions
  33. class MoveParams(object):
  34. """
  35. Json body params for move action model
  36. """
  37. def __init__(self, new_parent_id: str, new_workspace_id: str = None) -> None: # nopep8
  38. self.new_parent_id = new_parent_id
  39. self.new_workspace_id = new_workspace_id
  40. class LoginCredentials(object):
  41. """
  42. Login credentials model for login model
  43. """
  44. def __init__(self, email: str, password: str) -> None:
  45. self.email = email
  46. self.password = password
  47. class SetEmail(object):
  48. """
  49. Just an email
  50. """
  51. def __init__(self, loggedin_user_password: str, email: str) -> None:
  52. self.loggedin_user_password = loggedin_user_password
  53. self.email = email
  54. class SetPassword(object):
  55. """
  56. Just an password
  57. """
  58. def __init__(self,
  59. loggedin_user_password: str,
  60. new_password: str,
  61. new_password2: str
  62. ) -> None:
  63. self.loggedin_user_password = loggedin_user_password
  64. self.new_password = new_password
  65. self.new_password2 = new_password2
  66. class UserInfos(object):
  67. """
  68. Just some user infos
  69. """
  70. def __init__(self, timezone: str, public_name: str, lang: str) -> None:
  71. self.timezone = timezone
  72. self.public_name = public_name
  73. self.lang = lang
  74. class UserProfile(object):
  75. """
  76. Just some user infos
  77. """
  78. def __init__(self, profile: str) -> None:
  79. self.profile = profile
  80. class UserCreation(object):
  81. """
  82. Just some user infos
  83. """
  84. def __init__(
  85. self,
  86. email: str,
  87. password: str = None,
  88. public_name: str = None,
  89. timezone: str = None,
  90. profile: str = None,
  91. lang: str = None,
  92. email_notification: bool = True,
  93. ) -> None:
  94. self.email = email
  95. # INFO - G.M - 2018-08-16 - cleartext password, default value
  96. # is auto-generated.
  97. self.password = password or password_generator()
  98. self.public_name = public_name or None
  99. self.timezone = timezone or ''
  100. self.lang = lang or None
  101. self.profile = profile or Group.TIM_USER_GROUPNAME
  102. self.email_notification = email_notification
  103. class WorkspaceAndContentPath(object):
  104. """
  105. Paths params with workspace id and content_id model
  106. """
  107. def __init__(self, workspace_id: int, content_id: int) -> None:
  108. self.content_id = content_id
  109. self.workspace_id = workspace_id
  110. class WorkspaceAndContentRevisionPath(object):
  111. """
  112. Paths params with workspace id and content_id model
  113. """
  114. def __init__(self, workspace_id: int, content_id: int, revision_id) -> None:
  115. self.content_id = content_id
  116. self.revision_id = revision_id
  117. self.workspace_id = workspace_id
  118. class ContentPreviewSizedPath(object):
  119. """
  120. Paths params with workspace id and content_id, width, heigth
  121. """
  122. def __init__(self, workspace_id: int, content_id: int, width: int, height: int) -> None: # nopep8
  123. self.content_id = content_id
  124. self.workspace_id = workspace_id
  125. self.width = width
  126. self.height = height
  127. class RevisionPreviewSizedPath(object):
  128. """
  129. Paths params with workspace id and content_id, revision_id width, heigth
  130. """
  131. def __init__(self, workspace_id: int, content_id: int, revision_id: int, width: int, height: int) -> None: # nopep8
  132. self.content_id = content_id
  133. self.revision_id = revision_id
  134. self.workspace_id = workspace_id
  135. self.width = width
  136. self.height = height
  137. class WorkspaceAndUserPath(object):
  138. """
  139. Paths params with workspace id and user_id
  140. """
  141. def __init__(self, workspace_id: int, user_id: int):
  142. self.workspace_id = workspace_id
  143. self.user_id = workspace_id
  144. class UserWorkspaceAndContentPath(object):
  145. """
  146. Paths params with user_id, workspace id and content_id model
  147. """
  148. def __init__(self, user_id: int, workspace_id: int, content_id: int) -> None: # nopep8
  149. self.content_id = content_id
  150. self.workspace_id = workspace_id
  151. self.user_id = user_id
  152. class CommentPath(object):
  153. """
  154. Paths params with workspace id and content_id and comment_id model
  155. """
  156. def __init__(
  157. self,
  158. workspace_id: int,
  159. content_id: int,
  160. comment_id: int
  161. ) -> None:
  162. self.content_id = content_id
  163. self.workspace_id = workspace_id
  164. self.comment_id = comment_id
  165. class AutocompleteQuery(object):
  166. """
  167. Autocomplete query model
  168. """
  169. def __init__(self, acp: str):
  170. self.acp = acp
  171. class PageQuery(object):
  172. """
  173. Page query model
  174. """
  175. def __init__(
  176. self,
  177. page: int = 0
  178. ):
  179. self.page = page
  180. class ContentFilter(object):
  181. """
  182. Content filter model
  183. """
  184. def __init__(
  185. self,
  186. workspace_id: int = None,
  187. parent_id: int = None,
  188. show_archived: int = 0,
  189. show_deleted: int = 0,
  190. show_active: int = 1,
  191. content_type: str = None,
  192. offset: int = None,
  193. limit: int = None,
  194. ) -> None:
  195. self.parent_id = parent_id
  196. self.workspace_id = workspace_id
  197. self.show_archived = bool(show_archived)
  198. self.show_deleted = bool(show_deleted)
  199. self.show_active = bool(show_active)
  200. self.limit = limit
  201. self.offset = offset
  202. self.content_type = content_type
  203. class ActiveContentFilter(object):
  204. def __init__(
  205. self,
  206. limit: int = None,
  207. before_content_id: datetime = None,
  208. ):
  209. self.limit = limit
  210. self.before_content_id = before_content_id
  211. class ContentIdsQuery(object):
  212. def __init__(
  213. self,
  214. contents_ids: typing.List[int] = None,
  215. ):
  216. self.contents_ids = contents_ids
  217. class RoleUpdate(object):
  218. """
  219. Update role
  220. """
  221. def __init__(
  222. self,
  223. role: str,
  224. do_notify: bool,
  225. ):
  226. self.role = role
  227. self.do_notify = do_notify
  228. class WorkspaceMemberInvitation(object):
  229. """
  230. Workspace Member Invitation
  231. """
  232. def __init__(
  233. self,
  234. user_id: int,
  235. user_email_or_public_name: str,
  236. role: str,
  237. do_notify: str,
  238. ):
  239. self.role = role
  240. self.user_email_or_public_name = user_email_or_public_name
  241. self.user_id = user_id
  242. self.do_notify = do_notify
  243. class WorkspaceUpdate(object):
  244. """
  245. Update workspace
  246. """
  247. def __init__(
  248. self,
  249. label: str,
  250. description: str,
  251. ):
  252. self.label = label
  253. self.description = description
  254. class ContentCreation(object):
  255. """
  256. Content creation model
  257. """
  258. def __init__(
  259. self,
  260. label: str,
  261. content_type: str,
  262. parent_id: typing.Optional[int] = None,
  263. ) -> None:
  264. self.label = label
  265. self.content_type = content_type
  266. self.parent_id = parent_id or None
  267. class CommentCreation(object):
  268. """
  269. Comment creation model
  270. """
  271. def __init__(
  272. self,
  273. raw_content: str,
  274. ) -> None:
  275. self.raw_content = raw_content
  276. class SetContentStatus(object):
  277. """
  278. Set content status
  279. """
  280. def __init__(
  281. self,
  282. status: str,
  283. ) -> None:
  284. self.status = status
  285. class TextBasedContentUpdate(object):
  286. """
  287. TextBasedContent update model
  288. """
  289. def __init__(
  290. self,
  291. label: str,
  292. raw_content: str,
  293. ) -> None:
  294. self.label = label
  295. self.raw_content = raw_content
  296. class FolderContentUpdate(object):
  297. """
  298. Folder Content update model
  299. """
  300. def __init__(
  301. self,
  302. label: str,
  303. raw_content: str,
  304. sub_content_types: typing.List[str],
  305. ) -> None:
  306. self.label = label
  307. self.raw_content = raw_content
  308. self.sub_content_types = sub_content_types
  309. class TypeUser(Enum):
  310. """Params used to find user"""
  311. USER_ID = 'found_id'
  312. EMAIL = 'found_email'
  313. PUBLIC_NAME = 'found_public_name'
  314. class UserInContext(object):
  315. """
  316. Interface to get User data and User data related to context.
  317. """
  318. def __init__(self, user: User, dbsession: Session, config: CFG):
  319. self.user = user
  320. self.dbsession = dbsession
  321. self.config = config
  322. # Default
  323. @property
  324. def email(self) -> str:
  325. return self.user.email
  326. @property
  327. def user_id(self) -> int:
  328. return self.user.user_id
  329. @property
  330. def public_name(self) -> str:
  331. return self.display_name
  332. @property
  333. def display_name(self) -> str:
  334. return self.user.display_name
  335. @property
  336. def created(self) -> datetime:
  337. return self.user.created
  338. @property
  339. def is_active(self) -> bool:
  340. return self.user.is_active
  341. @property
  342. def timezone(self) -> str:
  343. return self.user.timezone
  344. @property
  345. def lang(self) -> str:
  346. return self.user.lang
  347. @property
  348. def profile(self) -> Profile:
  349. return self.user.profile.name
  350. @property
  351. def is_deleted(self) -> bool:
  352. return self.user.is_deleted
  353. # Context related
  354. @property
  355. def calendar_url(self) -> typing.Optional[str]:
  356. # TODO - G-M - 20-04-2018 - [Calendar] Replace calendar code to get
  357. # url calendar url.
  358. #
  359. # from tracim.lib.calendar import CalendarManager
  360. # calendar_manager = CalendarManager(None)
  361. # return calendar_manager.get_workspace_calendar_url(self.workspace_id)
  362. return None
  363. @property
  364. def avatar_url(self) -> typing.Optional[str]:
  365. # TODO - G-M - 20-04-2018 - [Avatar] Add user avatar feature
  366. return None
  367. class WorkspaceInContext(object):
  368. """
  369. Interface to get Workspace data and Workspace data related to context.
  370. """
  371. def __init__(self, workspace: Workspace, dbsession: Session, config: CFG):
  372. self.workspace = workspace
  373. self.dbsession = dbsession
  374. self.config = config
  375. @property
  376. def workspace_id(self) -> int:
  377. """
  378. numeric id of the workspace.
  379. """
  380. return self.workspace.workspace_id
  381. @property
  382. def id(self) -> int:
  383. """
  384. alias of workspace_id
  385. """
  386. return self.workspace_id
  387. @property
  388. def label(self) -> str:
  389. """
  390. get workspace label
  391. """
  392. return self.workspace.label
  393. @property
  394. def description(self) -> str:
  395. """
  396. get workspace description
  397. """
  398. return self.workspace.description
  399. @property
  400. def slug(self) -> str:
  401. """
  402. get workspace slug
  403. """
  404. return slugify(self.workspace.label)
  405. @property
  406. def is_deleted(self) -> bool:
  407. """
  408. Is the workspace deleted ?
  409. """
  410. return self.workspace.is_deleted
  411. @property
  412. def sidebar_entries(self) -> typing.List[WorkspaceMenuEntry]:
  413. """
  414. get sidebar entries, those depends on activated apps.
  415. """
  416. # TODO - G.M - 22-05-2018 - Rework on this in
  417. # order to not use hardcoded list
  418. # list should be able to change (depending on activated/disabled
  419. # apps)
  420. app_api = ApplicationApi(
  421. app_list
  422. )
  423. return app_api.get_default_workspace_menu_entry(self.workspace)
  424. @property
  425. def frontend_url(self):
  426. root_frontend_url = get_root_frontend_url(self.config)
  427. workspace_frontend_url = WORKSPACE_FRONTEND_URL_SCHEMA.format(
  428. workspace_id=self.workspace_id,
  429. )
  430. return root_frontend_url + workspace_frontend_url
  431. class UserRoleWorkspaceInContext(object):
  432. """
  433. Interface to get UserRoleInWorkspace data and related content
  434. """
  435. def __init__(
  436. self,
  437. user_role: UserRoleInWorkspace,
  438. dbsession: Session,
  439. config: CFG,
  440. # Extended params
  441. newly_created: bool = None,
  442. email_sent: bool = None
  443. )-> None:
  444. self.user_role = user_role
  445. self.dbsession = dbsession
  446. self.config = config
  447. # Extended params
  448. self.newly_created = newly_created
  449. self.email_sent = email_sent
  450. @property
  451. def user_id(self) -> int:
  452. """
  453. User who has the role has this id
  454. :return: user id as integer
  455. """
  456. return self.user_role.user_id
  457. @property
  458. def workspace_id(self) -> int:
  459. """
  460. This role apply only on the workspace with this workspace_id
  461. :return: workspace id as integer
  462. """
  463. return self.user_role.workspace_id
  464. # TODO - G.M - 23-05-2018 - Check the API spec for this this !
  465. @property
  466. def role_id(self) -> int:
  467. """
  468. role as int id, each value refer to a different role.
  469. """
  470. return self.user_role.role
  471. @property
  472. def role(self) -> str:
  473. return self.role_slug
  474. @property
  475. def role_slug(self) -> str:
  476. """
  477. simple name of the role of the user.
  478. can be anything from UserRoleInWorkspace SLUG, like
  479. 'not_applicable', 'reader',
  480. 'contributor', 'content-manager', 'workspace-manager'
  481. :return: user workspace role as slug.
  482. """
  483. return WorkspaceRoles.get_role_from_level(self.user_role.role).slug
  484. @property
  485. def is_active(self) -> bool:
  486. return self.user.is_active
  487. @property
  488. def do_notify(self) -> bool:
  489. return self.user_role.do_notify
  490. @property
  491. def user(self) -> UserInContext:
  492. """
  493. User who has this role, with context data
  494. :return: UserInContext object
  495. """
  496. return UserInContext(
  497. self.user_role.user,
  498. self.dbsession,
  499. self.config
  500. )
  501. @property
  502. def workspace(self) -> WorkspaceInContext:
  503. """
  504. Workspace related to this role, with his context data
  505. :return: WorkspaceInContext object
  506. """
  507. return WorkspaceInContext(
  508. self.user_role.workspace,
  509. self.dbsession,
  510. self.config
  511. )
  512. class ContentInContext(object):
  513. """
  514. Interface to get Content data and Content data related to context.
  515. """
  516. def __init__(self, content: Content, dbsession: Session, config: CFG, user: User=None): # nopep8
  517. self.content = content
  518. self.dbsession = dbsession
  519. self.config = config
  520. self._user = user
  521. # Default
  522. @property
  523. def content_id(self) -> int:
  524. return self.content.content_id
  525. @property
  526. def parent_id(self) -> int:
  527. """
  528. Return parent_id of the content
  529. """
  530. return self.content.parent_id
  531. @property
  532. def workspace_id(self) -> int:
  533. return self.content.workspace_id
  534. @property
  535. def label(self) -> str:
  536. return self.content.label
  537. @property
  538. def content_type(self) -> str:
  539. content_type = CONTENT_TYPES.get_one_by_slug(self.content.type)
  540. return content_type.slug
  541. @property
  542. def sub_content_types(self) -> typing.List[str]:
  543. return [_type.slug for _type in self.content.get_allowed_content_types()] # nopep8
  544. @property
  545. def status(self) -> str:
  546. return self.content.status
  547. @property
  548. def is_archived(self):
  549. return self.content.is_archived
  550. @property
  551. def is_deleted(self):
  552. return self.content.is_deleted
  553. @property
  554. def raw_content(self):
  555. return self.content.description
  556. @property
  557. def author(self):
  558. return UserInContext(
  559. dbsession=self.dbsession,
  560. config=self.config,
  561. user=self.content.first_revision.owner
  562. )
  563. @property
  564. def current_revision_id(self):
  565. return self.content.revision_id
  566. @property
  567. def created(self):
  568. return self.content.created
  569. @property
  570. def modified(self):
  571. return self.updated
  572. @property
  573. def updated(self):
  574. return self.content.updated
  575. @property
  576. def last_modifier(self):
  577. return UserInContext(
  578. dbsession=self.dbsession,
  579. config=self.config,
  580. user=self.content.last_revision.owner
  581. )
  582. # Context-related
  583. @property
  584. def show_in_ui(self):
  585. # TODO - G.M - 31-05-2018 - Enable Show_in_ui params
  586. # if false, then do not show content in the treeview.
  587. # This may his maybe used for specific contents or for sub-contents.
  588. # Default is True.
  589. # In first version of the API, this field is always True
  590. return True
  591. @property
  592. def slug(self):
  593. return slugify(self.content.label)
  594. @property
  595. def read_by_user(self):
  596. assert self._user
  597. return not self.content.has_new_information_for(self._user)
  598. @property
  599. def frontend_url(self):
  600. root_frontend_url = get_root_frontend_url(self.config)
  601. content_frontend_url = CONTENT_FRONTEND_URL_SCHEMA.format(
  602. workspace_id=self.workspace_id,
  603. content_type=self.content_type,
  604. content_id=self.content_id,
  605. )
  606. return root_frontend_url + content_frontend_url
  607. class RevisionInContext(object):
  608. """
  609. Interface to get Content data and Content data related to context.
  610. """
  611. def __init__(self, content_revision: ContentRevisionRO, dbsession: Session, config: CFG):
  612. assert content_revision is not None
  613. self.revision = content_revision
  614. self.dbsession = dbsession
  615. self.config = config
  616. # Default
  617. @property
  618. def content_id(self) -> int:
  619. return self.revision.content_id
  620. @property
  621. def parent_id(self) -> int:
  622. """
  623. Return parent_id of the content
  624. """
  625. return self.revision.parent_id
  626. @property
  627. def workspace_id(self) -> int:
  628. return self.revision.workspace_id
  629. @property
  630. def label(self) -> str:
  631. return self.revision.label
  632. @property
  633. def revision_type(self) -> str:
  634. return self.revision.revision_type
  635. @property
  636. def content_type(self) -> str:
  637. return CONTENT_TYPES.get_one_by_slug(self.revision.type).slug
  638. @property
  639. def sub_content_types(self) -> typing.List[str]:
  640. return [_type.slug for _type
  641. in self.revision.node.get_allowed_content_types()]
  642. @property
  643. def status(self) -> str:
  644. return self.revision.status
  645. @property
  646. def is_archived(self) -> bool:
  647. return self.revision.is_archived
  648. @property
  649. def is_deleted(self) -> bool:
  650. return self.revision.is_deleted
  651. @property
  652. def raw_content(self) -> str:
  653. return self.revision.description
  654. @property
  655. def author(self) -> UserInContext:
  656. return UserInContext(
  657. dbsession=self.dbsession,
  658. config=self.config,
  659. user=self.revision.owner
  660. )
  661. @property
  662. def revision_id(self) -> int:
  663. return self.revision.revision_id
  664. @property
  665. def created(self) -> datetime:
  666. return self.updated
  667. @property
  668. def modified(self) -> datetime:
  669. return self.updated
  670. @property
  671. def updated(self) -> datetime:
  672. return self.revision.updated
  673. @property
  674. def next_revision(self) -> typing.Optional[ContentRevisionRO]:
  675. """
  676. Get next revision (later revision)
  677. :return: next_revision
  678. """
  679. next_revision = None
  680. revisions = self.revision.node.revisions
  681. # INFO - G.M - 2018-06-177 - Get revisions more recent that
  682. # current one
  683. next_revisions = [
  684. revision for revision in revisions
  685. if revision.revision_id > self.revision.revision_id
  686. ]
  687. if next_revisions:
  688. # INFO - G.M - 2018-06-177 -sort revisions by date
  689. sorted_next_revisions = sorted(
  690. next_revisions,
  691. key=lambda revision: revision.updated
  692. )
  693. # INFO - G.M - 2018-06-177 - return only next revision
  694. return sorted_next_revisions[0]
  695. else:
  696. return None
  697. @property
  698. def comment_ids(self) -> typing.List[int]:
  699. """
  700. Get list of ids of all current revision related comments
  701. :return: list of comments ids
  702. """
  703. comments = self.revision.node.get_comments()
  704. # INFO - G.M - 2018-06-177 - Get comments more recent than revision.
  705. revision_comments = [
  706. comment for comment in comments
  707. if comment.created > self.revision.updated
  708. ]
  709. if self.next_revision:
  710. # INFO - G.M - 2018-06-177 - if there is a revision more recent
  711. # than current remove comments from theses rev (comments older
  712. # than next_revision.)
  713. revision_comments = [
  714. comment for comment in revision_comments
  715. if comment.created < self.next_revision.updated
  716. ]
  717. sorted_revision_comments = sorted(
  718. revision_comments,
  719. key=lambda revision: revision.created
  720. )
  721. comment_ids = []
  722. for comment in sorted_revision_comments:
  723. comment_ids.append(comment.content_id)
  724. return comment_ids
  725. # Context-related
  726. @property
  727. def show_in_ui(self) -> bool:
  728. # TODO - G.M - 31-05-2018 - Enable Show_in_ui params
  729. # if false, then do not show content in the treeview.
  730. # This may his maybe used for specific contents or for sub-contents.
  731. # Default is True.
  732. # In first version of the API, this field is always True
  733. return True
  734. @property
  735. def slug(self) -> str:
  736. return slugify(self.revision.label)