data.py 46KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386
  1. # -*- coding: utf-8 -*-
  2. import datetime as datetime_root
  3. import json
  4. import os
  5. from datetime import datetime
  6. import tg
  7. from babel.dates import format_timedelta
  8. from bs4 import BeautifulSoup
  9. from sqlalchemy import Column, inspect, Index
  10. from sqlalchemy import ForeignKey
  11. from sqlalchemy import Sequence
  12. from sqlalchemy.ext.associationproxy import association_proxy
  13. from sqlalchemy.ext.hybrid import hybrid_property
  14. from sqlalchemy.orm import backref
  15. from sqlalchemy.orm import deferred
  16. from sqlalchemy.orm import relationship
  17. from sqlalchemy.orm.attributes import InstrumentedAttribute
  18. from sqlalchemy.orm.collections import attribute_mapped_collection
  19. from sqlalchemy.types import Boolean
  20. from sqlalchemy.types import DateTime
  21. from sqlalchemy.types import Integer
  22. from sqlalchemy.types import LargeBinary
  23. from sqlalchemy.types import Text
  24. from sqlalchemy.types import Unicode
  25. from depot.fields.sqlalchemy import UploadedFileField
  26. from depot.fields.upload import UploadedFile
  27. from depot.io.utils import FileIntent
  28. from tracim.lib.utils import lazy_ugettext as l_
  29. from tracim.lib.exception import ContentRevisionUpdateError
  30. from tracim.model import DeclarativeBase, RevisionsIntegrity
  31. from tracim.model.auth import User
  32. DEFAULT_PROPERTIES = dict(
  33. allowed_content=dict(
  34. folder=True,
  35. file=True,
  36. page=True,
  37. thread=True,
  38. ),
  39. )
  40. class BreadcrumbItem(object):
  41. def __init__(self, icon_string: str, label: str, url: str, is_active: bool = False):
  42. """
  43. A BreadcrumbItem contains minimal information required to build a breadcrumb
  44. icon_string: this is the Tango related id, eg 'places/remote-folder'
  45. """
  46. self.icon = icon_string
  47. self.label = label
  48. self.url = url
  49. self.is_active =is_active
  50. class Workspace(DeclarativeBase):
  51. __tablename__ = 'workspaces'
  52. workspace_id = Column(Integer, Sequence('seq__workspaces__workspace_id'), autoincrement=True, primary_key=True)
  53. label = Column(Unicode(1024), unique=False, nullable=False, default='')
  54. description = Column(Text(), unique=False, nullable=False, default='')
  55. calendar_enabled = Column(Boolean, unique=False, nullable=False, default=False)
  56. # Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
  57. created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
  58. # Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
  59. updated = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
  60. is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
  61. revisions = relationship("ContentRevisionRO")
  62. @hybrid_property
  63. def contents(self) -> ['Content']:
  64. # Return a list of unique revisions parent content
  65. contents = []
  66. for revision in self.revisions:
  67. # TODO BS 20161209: This ``revision.node.workspace`` make a lot
  68. # of SQL queries !
  69. if revision.node.workspace == self and revision.node not in contents:
  70. contents.append(revision.node)
  71. return contents
  72. @property
  73. def calendar_url(self) -> str:
  74. # TODO - 20160531 - Bastien: Cyclic import if import in top of file
  75. from tracim.lib.calendar import CalendarManager
  76. calendar_manager = CalendarManager(None)
  77. return calendar_manager.get_workspace_calendar_url(self.workspace_id)
  78. def get_user_role(self, user: User) -> int:
  79. for role in user.roles:
  80. if role.workspace.workspace_id==self.workspace_id:
  81. return role.role
  82. return UserRoleInWorkspace.NOT_APPLICABLE
  83. def get_label(self):
  84. """ this method is for interoperability with Content class"""
  85. return self.label
  86. def get_allowed_content_types(self):
  87. # @see Content.get_allowed_content_types()
  88. return [ContentType('folder')]
  89. def get_valid_children(
  90. self,
  91. content_types: list=None,
  92. show_deleted: bool=False,
  93. show_archived: bool=False,
  94. ):
  95. for child in self.contents:
  96. # we search only direct children
  97. if not child.parent \
  98. and (show_deleted or not child.is_deleted) \
  99. and (show_archived or not child.is_archived):
  100. if not content_types or child.type in content_types:
  101. yield child
  102. class UserRoleInWorkspace(DeclarativeBase):
  103. __tablename__ = 'user_workspace'
  104. user_id = Column(Integer, ForeignKey('users.user_id'), nullable=False, default=None, primary_key=True)
  105. workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), nullable=False, default=None, primary_key=True)
  106. role = Column(Integer, nullable=False, default=0, primary_key=False)
  107. do_notify = Column(Boolean, unique=False, nullable=False, default=False)
  108. workspace = relationship('Workspace', remote_side=[Workspace.workspace_id], backref='roles', lazy='joined')
  109. user = relationship('User', remote_side=[User.user_id], backref='roles')
  110. NOT_APPLICABLE = 0
  111. READER = 1
  112. CONTRIBUTOR = 2
  113. CONTENT_MANAGER = 4
  114. WORKSPACE_MANAGER = 8
  115. LABEL = dict()
  116. LABEL[0] = l_('N/A')
  117. LABEL[1] = l_('Reader')
  118. LABEL[2] = l_('Contributor')
  119. LABEL[4] = l_('Content Manager')
  120. LABEL[8] = l_('Workspace Manager')
  121. STYLE = dict()
  122. STYLE[0] = ''
  123. STYLE[1] = 'color: #1fdb11;'
  124. STYLE[2] = 'color: #759ac5;'
  125. STYLE[4] = 'color: #ea983d;'
  126. STYLE[8] = 'color: #F00;'
  127. ICON = dict()
  128. ICON[0] = ''
  129. ICON[1] = 'fa-eye'
  130. ICON[2] = 'fa-pencil'
  131. ICON[4] = 'fa-graduation-cap'
  132. ICON[8] = 'fa-legal'
  133. @property
  134. def icon(self):
  135. return UserRoleInWorkspace.ICON[self.role]
  136. @property
  137. def style(self):
  138. return UserRoleInWorkspace.STYLE[self.role]
  139. def role_as_label(self):
  140. return UserRoleInWorkspace.LABEL[self.role]
  141. @classmethod
  142. def get_all_role_values(self):
  143. return [
  144. UserRoleInWorkspace.READER,
  145. UserRoleInWorkspace.CONTRIBUTOR,
  146. UserRoleInWorkspace.CONTENT_MANAGER,
  147. UserRoleInWorkspace.WORKSPACE_MANAGER
  148. ]
  149. class RoleType(object):
  150. def __init__(self, role_id):
  151. self.role_type_id = role_id
  152. self.icon = UserRoleInWorkspace.ICON[role_id]
  153. self.role_label = UserRoleInWorkspace.LABEL[role_id]
  154. self.css_style = UserRoleInWorkspace.STYLE[role_id]
  155. class LinkItem(object):
  156. def __init__(self, href, label):
  157. self.href = href
  158. self.label = label
  159. class ActionDescription(object):
  160. """
  161. Allowed status are:
  162. - open
  163. - closed-validated
  164. - closed-invalidated
  165. - closed-deprecated
  166. """
  167. ARCHIVING = 'archiving'
  168. COMMENT = 'content-comment'
  169. CREATION = 'creation'
  170. DELETION = 'deletion'
  171. EDITION = 'edition' # Default action if unknow
  172. REVISION = 'revision'
  173. STATUS_UPDATE = 'status-update'
  174. UNARCHIVING = 'unarchiving'
  175. UNDELETION = 'undeletion'
  176. MOVE = 'move'
  177. _ICONS = {
  178. 'archiving': 'fa fa-archive',
  179. 'content-comment': 'fa-comment-o',
  180. 'creation': 'fa-magic',
  181. 'deletion': 'fa-trash',
  182. 'edition': 'fa-edit',
  183. 'revision': 'fa-history',
  184. 'status-update': 'fa-random',
  185. 'unarchiving': 'fa-file-archive-o',
  186. 'undeletion': 'fa-trash-o',
  187. 'move': 'fa-arrows'
  188. }
  189. _LABELS = {
  190. 'archiving': l_('archive'),
  191. 'content-comment': l_('Item commented'),
  192. 'creation': l_('Item created'),
  193. 'deletion': l_('Item deleted'),
  194. 'edition': l_('item modified'),
  195. 'revision': l_('New revision'),
  196. 'status-update': l_('New status'),
  197. 'unarchiving': l_('Item unarchived'),
  198. 'undeletion': l_('Item undeleted'),
  199. 'move': l_('Item moved')
  200. }
  201. def __init__(self, id):
  202. assert id in ActionDescription.allowed_values()
  203. self.id = id
  204. self.label = ActionDescription._LABELS[id]
  205. self.icon = ActionDescription._ICONS[id]
  206. self.css = ''
  207. @classmethod
  208. def allowed_values(cls):
  209. return [cls.ARCHIVING,
  210. cls.COMMENT,
  211. cls.CREATION,
  212. cls.DELETION,
  213. cls.EDITION,
  214. cls.REVISION,
  215. cls.STATUS_UPDATE,
  216. cls.UNARCHIVING,
  217. cls.UNDELETION,
  218. cls.MOVE]
  219. class ContentStatus(object):
  220. """
  221. Allowed status are:
  222. - open
  223. - closed-validated
  224. - closed-invalidated
  225. - closed-deprecated
  226. """
  227. OPEN = 'open'
  228. CLOSED_VALIDATED = 'closed-validated'
  229. CLOSED_UNVALIDATED = 'closed-unvalidated'
  230. CLOSED_DEPRECATED = 'closed-deprecated'
  231. _LABELS = {'open': l_('work in progress'),
  232. 'closed-validated': l_('closed — validated'),
  233. 'closed-unvalidated': l_('closed — cancelled'),
  234. 'closed-deprecated': l_('deprecated')}
  235. _LABELS_THREAD = {'open': l_('subject in progress'),
  236. 'closed-validated': l_('subject closed — resolved'),
  237. 'closed-unvalidated': l_('subject closed — cancelled'),
  238. 'closed-deprecated': l_('deprecated')}
  239. _LABELS_FILE = {'open': l_('work in progress'),
  240. 'closed-validated': l_('closed — validated'),
  241. 'closed-unvalidated': l_('closed — cancelled'),
  242. 'closed-deprecated': l_('deprecated')}
  243. _ICONS = {
  244. 'open': 'fa fa-square-o',
  245. 'closed-validated': 'fa fa-check-square-o',
  246. 'closed-unvalidated': 'fa fa-close',
  247. 'closed-deprecated': 'fa fa-warning',
  248. }
  249. _CSS = {
  250. 'open': 'tracim-status-open',
  251. 'closed-validated': 'tracim-status-closed-validated',
  252. 'closed-unvalidated': 'tracim-status-closed-unvalidated',
  253. 'closed-deprecated': 'tracim-status-closed-deprecated',
  254. }
  255. def __init__(self, id, type=''):
  256. self.id = id
  257. self.icon = ContentStatus._ICONS[id]
  258. self.css = ContentStatus._CSS[id]
  259. if type==ContentType.Thread:
  260. self.label = ContentStatus._LABELS_THREAD[id]
  261. elif type==ContentType.File:
  262. self.label = ContentStatus._LABELS_FILE[id]
  263. else:
  264. self.label = ContentStatus._LABELS[id]
  265. @classmethod
  266. def all(cls, type='') -> ['ContentStatus']:
  267. all = []
  268. all.append(ContentStatus('open', type))
  269. all.append(ContentStatus('closed-validated', type))
  270. all.append(ContentStatus('closed-unvalidated', type))
  271. all.append(ContentStatus('closed-deprecated', type))
  272. return all
  273. @classmethod
  274. def allowed_values(cls):
  275. return ContentStatus._LABELS.keys()
  276. class ContentType(object):
  277. Any = 'any'
  278. Folder = 'folder'
  279. File = 'file'
  280. Comment = 'comment'
  281. Thread = 'thread'
  282. Page = 'page'
  283. Event = 'event'
  284. # Fake types, used for breadcrumb only
  285. FAKE_Dashboard = 'dashboard'
  286. FAKE_Workspace = 'workspace'
  287. _STRING_LIST_SEPARATOR = ','
  288. _ICONS = { # Deprecated
  289. 'dashboard': 'fa-home',
  290. 'workspace': 'fa-bank',
  291. 'folder': 'fa fa-folder-open-o',
  292. 'file': 'fa fa-paperclip',
  293. 'page': 'fa fa-file-text-o',
  294. 'thread': 'fa fa-comments-o',
  295. 'comment': 'fa fa-comment-o',
  296. 'event': 'fa fa-calendar-o',
  297. }
  298. _CSS_ICONS = {
  299. 'dashboard': 'fa fa-home',
  300. 'workspace': 'fa fa-bank',
  301. 'folder': 'fa fa-folder-open-o',
  302. 'file': 'fa fa-paperclip',
  303. 'page': 'fa fa-file-text-o',
  304. 'thread': 'fa fa-comments-o',
  305. 'comment': 'fa fa-comment-o',
  306. 'event': 'fa fa-calendar-o',
  307. }
  308. _CSS_COLORS = {
  309. 'dashboard': 't-dashboard-color',
  310. 'workspace': 't-less-visible',
  311. 'folder': 't-folder-color',
  312. 'file': 't-file-color',
  313. 'page': 't-page-color',
  314. 'thread': 't-thread-color',
  315. 'comment': 't-thread-color',
  316. 'event': 't-event-color',
  317. }
  318. _ORDER_WEIGHT = {
  319. 'folder': 0,
  320. 'page': 1,
  321. 'thread': 2,
  322. 'file': 3,
  323. 'comment': 4,
  324. 'event': 5,
  325. }
  326. _LABEL = {
  327. 'dashboard': '',
  328. 'workspace': l_('workspace'),
  329. 'folder': l_('folder'),
  330. 'file': l_('file'),
  331. 'page': l_('page'),
  332. 'thread': l_('thread'),
  333. 'comment': l_('comment'),
  334. 'event': l_('event'),
  335. }
  336. _DELETE_LABEL = {
  337. 'dashboard': '',
  338. 'workspace': l_('Delete this workspace'),
  339. 'folder': l_('Delete this folder'),
  340. 'file': l_('Delete this file'),
  341. 'page': l_('Delete this page'),
  342. 'thread': l_('Delete this thread'),
  343. 'comment': l_('Delete this comment'),
  344. 'event': l_('Delete this event'),
  345. }
  346. @classmethod
  347. def get_icon(cls, type: str):
  348. assert(type in ContentType._ICONS) # DYN_REMOVE
  349. return ContentType._ICONS[type]
  350. @classmethod
  351. def all(cls):
  352. return cls.allowed_types()
  353. @classmethod
  354. def allowed_types(cls):
  355. return [cls.Folder, cls.File, cls.Comment, cls.Thread, cls.Page,
  356. cls.Event]
  357. @classmethod
  358. def allowed_types_for_folding(cls):
  359. # This method is used for showing only "main" types in the left-side treeview
  360. return [cls.Folder, cls.File, cls.Thread, cls.Page]
  361. @classmethod
  362. def allowed_types_from_str(cls, allowed_types_as_string: str):
  363. allowed_types = []
  364. # HACK - THIS
  365. for item in allowed_types_as_string.split(ContentType._STRING_LIST_SEPARATOR):
  366. if item and item in ContentType.allowed_types_for_folding():
  367. allowed_types.append(item)
  368. return allowed_types
  369. @classmethod
  370. def fill_url(cls, content: 'Content'):
  371. # TODO - DYNDATATYPE - D.A. - 2014-12-02
  372. # Make this code dynamic loading data types
  373. if content.type==ContentType.Folder:
  374. return '/workspaces/{}/folders/{}'.format(content.workspace_id, content.content_id)
  375. elif content.type==ContentType.File:
  376. return '/workspaces/{}/folders/{}/files/{}'.format(content.workspace_id, content.parent_id, content.content_id)
  377. elif content.type==ContentType.Thread:
  378. return '/workspaces/{}/folders/{}/threads/{}'.format(content.workspace_id, content.parent_id, content.content_id)
  379. elif content.type==ContentType.Page:
  380. return '/workspaces/{}/folders/{}/pages/{}'.format(content.workspace_id, content.parent_id, content.content_id)
  381. @classmethod
  382. def fill_url_for_workspace(cls, workspace: Workspace):
  383. # TODO - DYNDATATYPE - D.A. - 2014-12-02
  384. # Make this code dynamic loading data types
  385. return '/workspaces/{}'.format(workspace.workspace_id)
  386. @classmethod
  387. def sorted(cls, types: ['ContentType']) -> ['ContentType']:
  388. return sorted(types, key=lambda content_type: content_type.priority)
  389. @property
  390. def type(self):
  391. return self.id
  392. def __init__(self, type):
  393. self.id = type
  394. self.icon = ContentType._CSS_ICONS[type]
  395. self.color = ContentType._CSS_COLORS[type] # deprecated
  396. self.css = ContentType._CSS_COLORS[type]
  397. self.label = ContentType._LABEL[type]
  398. self.priority = ContentType._ORDER_WEIGHT[type]
  399. def toDict(self):
  400. return dict(id=self.type,
  401. type=self.type,
  402. icon=self.icon,
  403. color=self.color,
  404. label=self.label,
  405. priority=self.priority)
  406. class ContentChecker(object):
  407. @classmethod
  408. def check_properties(cls, item):
  409. if item.type==ContentType.Folder:
  410. properties = item.properties
  411. if 'allowed_content' not in properties.keys():
  412. return False
  413. if 'folders' not in properties['allowed_content']:
  414. return False
  415. if 'files' not in properties['allowed_content']:
  416. return False
  417. if 'pages' not in properties['allowed_content']:
  418. return False
  419. if 'threads' not in properties['allowed_content']:
  420. return False
  421. return True
  422. if item.type == ContentType.Event:
  423. properties = item.properties
  424. if 'name' not in properties.keys():
  425. return False
  426. if 'raw' not in properties.keys():
  427. return False
  428. if 'start' not in properties.keys():
  429. return False
  430. if 'end' not in properties.keys():
  431. return False
  432. return True
  433. raise NotImplementedError
  434. @classmethod
  435. def reset_properties(cls, item):
  436. if item.type==ContentType.Folder:
  437. item.properties = DEFAULT_PROPERTIES
  438. return
  439. raise NotImplementedError
  440. class ContentRevisionRO(DeclarativeBase):
  441. """
  442. Revision of Content. It's immutable, update or delete an existing ContentRevisionRO will throw
  443. ContentRevisionUpdateError errors.
  444. """
  445. __tablename__ = 'content_revisions'
  446. revision_id = Column(Integer, primary_key=True)
  447. content_id = Column(Integer, ForeignKey('content.id'), nullable=False)
  448. owner_id = Column(Integer, ForeignKey('users.user_id'), nullable=True)
  449. label = Column(Unicode(1024), unique=False, nullable=False)
  450. description = Column(Text(), unique=False, nullable=False, default='')
  451. file_extension = Column(
  452. Unicode(255),
  453. unique=False,
  454. nullable=False,
  455. server_default='',
  456. )
  457. file_mimetype = Column(Unicode(255), unique=False, nullable=False, default='')
  458. # INFO - A.P - 2017-07-03 - Depot Doc
  459. # http://depot.readthedocs.io/en/latest/#attaching-files-to-models
  460. # http://depot.readthedocs.io/en/latest/api.html#module-depot.fields
  461. depot_file = Column(UploadedFileField, unique=False, nullable=True)
  462. properties = Column('properties', Text(), unique=False, nullable=False, default='')
  463. type = Column(Unicode(32), unique=False, nullable=False)
  464. status = Column(Unicode(32), unique=False, nullable=False, default=ContentStatus.OPEN)
  465. created = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
  466. updated = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
  467. is_deleted = Column(Boolean, unique=False, nullable=False, default=False)
  468. is_archived = Column(Boolean, unique=False, nullable=False, default=False)
  469. is_temporary = Column(Boolean, unique=False, nullable=False, default=False)
  470. revision_type = Column(Unicode(32), unique=False, nullable=False, default='')
  471. workspace_id = Column(Integer, ForeignKey('workspaces.workspace_id'), unique=False, nullable=True)
  472. workspace = relationship('Workspace', remote_side=[Workspace.workspace_id])
  473. parent_id = Column(Integer, ForeignKey('content.id'), nullable=True, default=None)
  474. parent = relationship("Content", foreign_keys=[parent_id], back_populates="children_revisions")
  475. node = relationship("Content", foreign_keys=[content_id], back_populates="revisions")
  476. owner = relationship('User', remote_side=[User.user_id])
  477. """ List of column copied when make a new revision from another """
  478. _cloned_columns = (
  479. 'content_id',
  480. 'created',
  481. 'description',
  482. 'file_mimetype',
  483. 'file_extension',
  484. 'is_archived',
  485. 'is_deleted',
  486. 'label',
  487. 'node',
  488. 'owner',
  489. 'owner_id',
  490. 'parent',
  491. 'parent_id',
  492. 'properties',
  493. 'revision_type',
  494. 'status',
  495. 'type',
  496. 'updated',
  497. 'workspace',
  498. 'workspace_id',
  499. 'is_temporary',
  500. )
  501. # Read by must be used like this:
  502. # read_datetime = revision.ready_by[<User instance>]
  503. # if user did not read the content, then a key error is raised
  504. read_by = association_proxy(
  505. 'revision_read_statuses', # name of the attribute
  506. 'view_datetime', # attribute the value is taken from
  507. creator=lambda k, v: \
  508. RevisionReadStatus(user=k, view_datetime=v)
  509. )
  510. @property
  511. def file_name(self):
  512. return '{0}{1}'.format(
  513. self.label,
  514. self.file_extension,
  515. )
  516. @classmethod
  517. def new_from(cls, revision: 'ContentRevisionRO') -> 'ContentRevisionRO':
  518. """
  519. Return new instance of ContentRevisionRO where properties are copied from revision parameter.
  520. Look at ContentRevisionRO._cloned_columns to see what columns are copieds.
  521. :param revision: revision to copy
  522. :type revision: ContentRevisionRO
  523. :return: new revision from revision parameter
  524. :rtype: ContentRevisionRO
  525. """
  526. new_rev = cls()
  527. for column_name in cls._cloned_columns:
  528. column_value = getattr(revision, column_name)
  529. setattr(new_rev, column_name, column_value)
  530. new_rev.updated = datetime.utcnow()
  531. if revision.depot_file:
  532. new_rev.depot_file = FileIntent(
  533. revision.depot_file.file.read(),
  534. revision.file_name,
  535. revision.file_mimetype,
  536. )
  537. return new_rev
  538. def __setattr__(self, key: str, value: 'mixed'):
  539. """
  540. ContentRevisionUpdateError is raised if tried to update column and revision own identity
  541. :param key: attribute name
  542. :param value: attribute value
  543. :return:
  544. """
  545. if key in ('_sa_instance_state', ): # Prevent infinite loop from SQLAlchemy code and altered set
  546. return super().__setattr__(key, value)
  547. if inspect(self).has_identity \
  548. and key in self._cloned_columns \
  549. and not RevisionsIntegrity.is_updatable(self):
  550. raise ContentRevisionUpdateError(
  551. "Can't modify revision. To work on new revision use tracim.model.new_revision " +
  552. "context manager.")
  553. super().__setattr__(key, value)
  554. def get_status(self) -> ContentStatus:
  555. return ContentStatus(self.status)
  556. def get_label(self) -> str:
  557. return self.label or self.file_name or ''
  558. def get_last_action(self) -> ActionDescription:
  559. return ActionDescription(self.revision_type)
  560. def has_new_information_for(self, user: User) -> bool:
  561. """
  562. :param user: the session current user
  563. :return: bool, True if there is new information for given user else False
  564. False if the user is None
  565. """
  566. if not user:
  567. return False
  568. if user not in self.read_by.keys():
  569. return True
  570. return False
  571. def get_label_as_file(self):
  572. file_extension = self.file_extension or ''
  573. if self.type == ContentType.Thread:
  574. file_extension = '.html'
  575. elif self.type == ContentType.Page:
  576. file_extension = '.html'
  577. return '{0}{1}'.format(
  578. self.label,
  579. file_extension,
  580. )
  581. Index('idx__content_revisions__owner_id', ContentRevisionRO.owner_id)
  582. Index('idx__content_revisions__parent_id', ContentRevisionRO.parent_id)
  583. class Content(DeclarativeBase):
  584. """
  585. Content is used as a virtual representation of ContentRevisionRO.
  586. content.PROPERTY (except for content.id, content.revisions, content.children_revisions) will return
  587. value of most recent revision of content.
  588. # UPDATE A CONTENT
  589. To update an existing Content, you must use tracim.model.new_revision context manager:
  590. content = my_sontent_getter_method()
  591. with new_revision(content):
  592. content.description = 'foo bar baz'
  593. DBSession.flush()
  594. # QUERY CONTENTS
  595. To query contents you will need to join your content query with ContentRevisionRO. Join
  596. condition is available at tracim.lib.content.ContentApi#get_revision_join:
  597. content = DBSession.query(Content).join(ContentRevisionRO, ContentApi.get_revision_join())
  598. .filter(Content.label == 'foo')
  599. .one()
  600. ContentApi provide also prepared Content at tracim.lib.content.ContentApi#get_canonical_query:
  601. content = ContentApi.get_canonical_query()
  602. .filter(Content.label == 'foo')
  603. .one()
  604. """
  605. __tablename__ = 'content'
  606. revision_to_serialize = -0 # This flag allow to serialize a given revision if required by the user
  607. id = Column(Integer, primary_key=True)
  608. # TODO - A.P - 2017-09-05 - revisions default sorting
  609. # The only sorting that makes sens is ordering by "updated" field. But:
  610. # - its content will soon replace the one of "created",
  611. # - this "updated" field will then be dropped.
  612. # So for now, we order by "revision_id" explicitly, but remember to switch
  613. # to "created" once "updated" removed.
  614. # https://github.com/tracim/tracim/issues/336
  615. revisions = relationship("ContentRevisionRO",
  616. foreign_keys=[ContentRevisionRO.content_id],
  617. back_populates="node",
  618. order_by="ContentRevisionRO.revision_id")
  619. children_revisions = relationship("ContentRevisionRO",
  620. foreign_keys=[ContentRevisionRO.parent_id],
  621. back_populates="parent")
  622. @hybrid_property
  623. def content_id(self) -> int:
  624. return self.revision.content_id
  625. @content_id.setter
  626. def content_id(self, value: int) -> None:
  627. self.revision.content_id = value
  628. @content_id.expression
  629. def content_id(cls) -> InstrumentedAttribute:
  630. return ContentRevisionRO.content_id
  631. @hybrid_property
  632. def revision_id(self) -> int:
  633. return self.revision.revision_id
  634. @revision_id.setter
  635. def revision_id(self, value: int):
  636. self.revision.revision_id = value
  637. @revision_id.expression
  638. def revision_id(cls) -> InstrumentedAttribute:
  639. return ContentRevisionRO.revision_id
  640. @hybrid_property
  641. def owner_id(self) -> int:
  642. return self.revision.owner_id
  643. @owner_id.setter
  644. def owner_id(self, value: int) -> None:
  645. self.revision.owner_id = value
  646. @owner_id.expression
  647. def owner_id(cls) -> InstrumentedAttribute:
  648. return ContentRevisionRO.owner_id
  649. @hybrid_property
  650. def label(self) -> str:
  651. return self.revision.label
  652. @label.setter
  653. def label(self, value: str) -> None:
  654. self.revision.label = value
  655. @label.expression
  656. def label(cls) -> InstrumentedAttribute:
  657. return ContentRevisionRO.label
  658. @hybrid_property
  659. def description(self) -> str:
  660. return self.revision.description
  661. @description.setter
  662. def description(self, value: str) -> None:
  663. self.revision.description = value
  664. @description.expression
  665. def description(cls) -> InstrumentedAttribute:
  666. return ContentRevisionRO.description
  667. @hybrid_property
  668. def file_name(self) -> str:
  669. return '{0}{1}'.format(
  670. self.revision.label,
  671. self.revision.file_extension,
  672. )
  673. @file_name.setter
  674. def file_name(self, value: str) -> None:
  675. file_name, file_extension = os.path.splitext(value)
  676. if not self.revision.label:
  677. self.revision.label = file_name
  678. self.revision.file_extension = file_extension
  679. @file_name.expression
  680. def file_name(cls) -> InstrumentedAttribute:
  681. return ContentRevisionRO.file_name + ContentRevisionRO.file_extension
  682. @hybrid_property
  683. def file_extension(self) -> str:
  684. return self.revision.file_extension
  685. @file_extension.setter
  686. def file_extension(self, value: str) -> None:
  687. self.revision.file_extension = value
  688. @file_extension.expression
  689. def file_extension(cls) -> InstrumentedAttribute:
  690. return ContentRevisionRO.file_extension
  691. @hybrid_property
  692. def file_mimetype(self) -> str:
  693. return self.revision.file_mimetype
  694. @file_mimetype.setter
  695. def file_mimetype(self, value: str) -> None:
  696. self.revision.file_mimetype = value
  697. @file_mimetype.expression
  698. def file_mimetype(cls) -> InstrumentedAttribute:
  699. return ContentRevisionRO.file_mimetype
  700. @hybrid_property
  701. def _properties(self) -> str:
  702. return self.revision.properties
  703. @_properties.setter
  704. def _properties(self, value: str) -> None:
  705. self.revision.properties = value
  706. @_properties.expression
  707. def _properties(cls) -> InstrumentedAttribute:
  708. return ContentRevisionRO.properties
  709. @hybrid_property
  710. def type(self) -> str:
  711. return self.revision.type
  712. @type.setter
  713. def type(self, value: str) -> None:
  714. self.revision.type = value
  715. @type.expression
  716. def type(cls) -> InstrumentedAttribute:
  717. return ContentRevisionRO.type
  718. @hybrid_property
  719. def status(self) -> str:
  720. return self.revision.status
  721. @status.setter
  722. def status(self, value: str) -> None:
  723. self.revision.status = value
  724. @status.expression
  725. def status(cls) -> InstrumentedAttribute:
  726. return ContentRevisionRO.status
  727. @hybrid_property
  728. def created(self) -> datetime:
  729. return self.revision.created
  730. @created.setter
  731. def created(self, value: datetime) -> None:
  732. self.revision.created = value
  733. @created.expression
  734. def created(cls) -> InstrumentedAttribute:
  735. return ContentRevisionRO.created
  736. @hybrid_property
  737. def updated(self) -> datetime:
  738. return self.revision.updated
  739. @updated.setter
  740. def updated(self, value: datetime) -> None:
  741. self.revision.updated = value
  742. @updated.expression
  743. def updated(cls) -> InstrumentedAttribute:
  744. return ContentRevisionRO.updated
  745. @hybrid_property
  746. def is_deleted(self) -> bool:
  747. return self.revision.is_deleted
  748. @is_deleted.setter
  749. def is_deleted(self, value: bool) -> None:
  750. self.revision.is_deleted = value
  751. @is_deleted.expression
  752. def is_deleted(cls) -> InstrumentedAttribute:
  753. return ContentRevisionRO.is_deleted
  754. @hybrid_property
  755. def is_archived(self) -> bool:
  756. return self.revision.is_archived
  757. @is_archived.setter
  758. def is_archived(self, value: bool) -> None:
  759. self.revision.is_archived = value
  760. @is_archived.expression
  761. def is_archived(cls) -> InstrumentedAttribute:
  762. return ContentRevisionRO.is_archived
  763. @hybrid_property
  764. def is_temporary(self) -> bool:
  765. return self.revision.is_temporary
  766. @is_temporary.setter
  767. def is_temporary(self, value: bool) -> None:
  768. self.revision.is_temporary = value
  769. @is_temporary.expression
  770. def is_temporary(cls) -> InstrumentedAttribute:
  771. return ContentRevisionRO.is_temporary
  772. @hybrid_property
  773. def revision_type(self) -> str:
  774. return self.revision.revision_type
  775. @revision_type.setter
  776. def revision_type(self, value: str) -> None:
  777. self.revision.revision_type = value
  778. @revision_type.expression
  779. def revision_type(cls) -> InstrumentedAttribute:
  780. return ContentRevisionRO.revision_type
  781. @hybrid_property
  782. def workspace_id(self) -> int:
  783. return self.revision.workspace_id
  784. @workspace_id.setter
  785. def workspace_id(self, value: int) -> None:
  786. self.revision.workspace_id = value
  787. @workspace_id.expression
  788. def workspace_id(cls) -> InstrumentedAttribute:
  789. return ContentRevisionRO.workspace_id
  790. @hybrid_property
  791. def workspace(self) -> Workspace:
  792. return self.revision.workspace
  793. @workspace.setter
  794. def workspace(self, value: Workspace) -> None:
  795. self.revision.workspace = value
  796. @workspace.expression
  797. def workspace(cls) -> InstrumentedAttribute:
  798. return ContentRevisionRO.workspace
  799. @hybrid_property
  800. def parent_id(self) -> int:
  801. return self.revision.parent_id
  802. @parent_id.setter
  803. def parent_id(self, value: int) -> None:
  804. self.revision.parent_id = value
  805. @parent_id.expression
  806. def parent_id(cls) -> InstrumentedAttribute:
  807. return ContentRevisionRO.parent_id
  808. @hybrid_property
  809. def parent(self) -> 'Content':
  810. return self.revision.parent
  811. @parent.setter
  812. def parent(self, value: 'Content') -> None:
  813. self.revision.parent = value
  814. @parent.expression
  815. def parent(cls) -> InstrumentedAttribute:
  816. return ContentRevisionRO.parent
  817. @hybrid_property
  818. def node(self) -> 'Content':
  819. return self.revision.node
  820. @node.setter
  821. def node(self, value: 'Content') -> None:
  822. self.revision.node = value
  823. @node.expression
  824. def node(cls) -> InstrumentedAttribute:
  825. return ContentRevisionRO.node
  826. @hybrid_property
  827. def owner(self) -> User:
  828. return self.revision.owner
  829. @owner.setter
  830. def owner(self, value: User) -> None:
  831. self.revision.owner = value
  832. @owner.expression
  833. def owner(cls) -> InstrumentedAttribute:
  834. return ContentRevisionRO.owner
  835. @hybrid_property
  836. def children(self) -> ['Content']:
  837. """
  838. :return: list of children Content
  839. :rtype Content
  840. """
  841. # Return a list of unique revisions parent content
  842. return list(set([revision.node for revision in self.children_revisions]))
  843. @property
  844. def revision(self) -> ContentRevisionRO:
  845. return self.get_current_revision()
  846. @property
  847. def first_revision(self) -> ContentRevisionRO:
  848. return self.revisions[0] # FIXME
  849. @property
  850. def last_revision(self) -> ContentRevisionRO:
  851. return self.revisions[-1]
  852. @property
  853. def is_editable(self) -> bool:
  854. return not self.is_archived and not self.is_deleted
  855. @property
  856. def depot_file(self) -> UploadedFile:
  857. return self.revision.depot_file
  858. @depot_file.setter
  859. def depot_file(self, value):
  860. self.revision.depot_file = value
  861. def get_current_revision(self) -> ContentRevisionRO:
  862. if not self.revisions:
  863. return self.new_revision()
  864. # If last revisions revision don't have revision_id, return it we just add it.
  865. if self.revisions[-1].revision_id is None:
  866. return self.revisions[-1]
  867. # Revisions should be ordred by revision_id but we ensure that here
  868. revisions = sorted(self.revisions, key=lambda revision: revision.revision_id)
  869. return revisions[-1]
  870. def new_revision(self) -> None:
  871. """
  872. Return and assign to this content a new revision.
  873. If it's a new content, revision is totally new.
  874. If this content already own revision, revision is build from last revision.
  875. :return:
  876. """
  877. if not self.revisions:
  878. self.revisions.append(ContentRevisionRO())
  879. return self.revisions[0]
  880. new_rev = ContentRevisionRO.new_from(self.get_current_revision())
  881. self.revisions.append(new_rev)
  882. return new_rev
  883. def get_valid_children(self, content_types: list=None) -> ['Content']:
  884. for child in self.children:
  885. if not child.is_deleted and not child.is_archived:
  886. if not content_types or child.type in content_types:
  887. yield child.node
  888. @hybrid_property
  889. def properties(self) -> dict:
  890. """ return a structure decoded from json content of _properties """
  891. if not self._properties:
  892. return DEFAULT_PROPERTIES
  893. return json.loads(self._properties)
  894. @properties.setter
  895. def properties(self, properties_struct: dict) -> None:
  896. """ encode a given structure into json and store it in _properties attribute"""
  897. self._properties = json.dumps(properties_struct)
  898. ContentChecker.check_properties(self)
  899. def created_as_delta(self, delta_from_datetime:datetime=None):
  900. if not delta_from_datetime:
  901. delta_from_datetime = datetime.utcnow()
  902. return format_timedelta(delta_from_datetime - self.created,
  903. locale=tg.i18n.get_lang()[0])
  904. def datetime_as_delta(self, datetime_object,
  905. delta_from_datetime:datetime=None):
  906. if not delta_from_datetime:
  907. delta_from_datetime = datetime.utcnow()
  908. return format_timedelta(delta_from_datetime - datetime_object,
  909. locale=tg.i18n.get_lang()[0])
  910. def get_child_nb(self, content_type: ContentType, content_status = ''):
  911. child_nb = 0
  912. for child in self.get_valid_children():
  913. if child.type == content_type or content_type == ContentType.Any:
  914. if not content_status:
  915. child_nb = child_nb+1
  916. elif content_status==child.status:
  917. child_nb = child_nb+1
  918. return child_nb
  919. def get_label(self):
  920. return self.label or self.file_name or ''
  921. def get_label_as_file(self) -> str:
  922. """
  923. :return: Return content label in file representation context
  924. """
  925. return self.revision.get_label_as_file()
  926. def get_status(self) -> ContentStatus:
  927. return ContentStatus(self.status, self.type.__str__())
  928. def get_last_action(self) -> ActionDescription:
  929. return ActionDescription(self.revision_type)
  930. def get_last_activity_date(self) -> datetime_root.datetime:
  931. last_revision_date = self.updated
  932. for revision in self.revisions:
  933. if revision.updated > last_revision_date:
  934. last_revision_date = revision.updated
  935. for child in self.children:
  936. if child.updated > last_revision_date:
  937. last_revision_date = child.updated
  938. return last_revision_date
  939. def has_new_information_for(self, user: User) -> bool:
  940. """
  941. :param user: the session current user
  942. :return: bool, True if there is new information for given user else False
  943. False if the user is None
  944. """
  945. revision = self.get_current_revision()
  946. if not user:
  947. return False
  948. if user not in revision.read_by.keys():
  949. # The user did not read this item, so yes!
  950. return True
  951. for child in self.get_valid_children():
  952. if child.has_new_information_for(user):
  953. return True
  954. return False
  955. def get_comments(self):
  956. children = []
  957. for child in self.children:
  958. if ContentType.Comment==child.type and not child.is_deleted and not child.is_archived:
  959. children.append(child.node)
  960. return children
  961. def get_last_comment_from(self, user: User) -> 'Content':
  962. # TODO - Make this more efficient
  963. last_comment_updated = None
  964. last_comment = None
  965. for comment in self.get_comments():
  966. if user.user_id==comment.owner.user_id:
  967. if not last_comment or last_comment_updated<comment.updated:
  968. # take only the latest comment !
  969. last_comment = comment
  970. last_comment_updated = comment.updated
  971. return last_comment
  972. def get_previous_revision(self) -> 'ContentRevisionRO':
  973. rev_ids = [revision.revision_id for revision in self.revisions]
  974. rev_ids.sort()
  975. if len(rev_ids)>=2:
  976. revision_rev_id = rev_ids[-2]
  977. for revision in self.revisions:
  978. if revision.revision_id == revision_rev_id:
  979. return revision
  980. return None
  981. def description_as_raw_text(self):
  982. # 'html.parser' fixes a hanging bug
  983. # see http://stackoverflow.com/questions/12618567/problems-running-beautifulsoup4-within-apache-mod-python-django
  984. return BeautifulSoup(self.description, 'html.parser').text
  985. def get_allowed_content_types(self):
  986. types = []
  987. try:
  988. allowed_types = self.properties['allowed_content']
  989. for type_label, is_allowed in allowed_types.items():
  990. if is_allowed:
  991. types.append(ContentType(type_label))
  992. except Exception as e:
  993. print(e.__str__())
  994. print('----- /*\ *****')
  995. raise ValueError('Not allowed content property')
  996. return ContentType.sorted(types)
  997. def get_history(self, drop_empty_revision=False) -> '[VirtualEvent]':
  998. events = []
  999. for comment in self.get_comments():
  1000. events.append(VirtualEvent.create_from_content(comment))
  1001. revisions = sorted(self.revisions, key=lambda rev: rev.revision_id)
  1002. for revision in revisions:
  1003. # INFO - G.M - 09-03-2018 - Do not show file revision with empty
  1004. # file to have a more clear view of revision.
  1005. # Some webdav client create empty file before uploading, we must
  1006. # have possibility to not show the related revision
  1007. if drop_empty_revision:
  1008. if revision.depot_file and revision.depot_file.file.content_length == 0: # nopep8
  1009. # INFO - G.M - 12-03-2018 -Always show the last and
  1010. # first revision.
  1011. if revision != revisions[-1] and revision != revisions[0]:
  1012. continue
  1013. events.append(VirtualEvent.create_from_content_revision(revision))
  1014. sorted_events = sorted(events,
  1015. key=lambda event: event.created, reverse=True)
  1016. return sorted_events
  1017. @classmethod
  1018. def format_path(cls, url_template: str, content: 'Content') -> str:
  1019. wid = content.workspace.workspace_id
  1020. fid = content.parent_id # May be None if no parent
  1021. ctype = content.type
  1022. cid = content.content_id
  1023. return url_template.format(wid=wid, fid=fid, ctype=ctype, cid=cid)
  1024. class RevisionReadStatus(DeclarativeBase):
  1025. __tablename__ = 'revision_read_status'
  1026. revision_id = Column(Integer, ForeignKey('content_revisions.revision_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
  1027. user_id = Column(Integer, ForeignKey('users.user_id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True)
  1028. # Default value datetime.utcnow, see: http://stackoverflow.com/a/13370382/801924 (or http://pastebin.com/VLyWktUn)
  1029. view_datetime = Column(DateTime, unique=False, nullable=False, default=datetime.utcnow)
  1030. content_revision = relationship(
  1031. 'ContentRevisionRO',
  1032. backref=backref(
  1033. 'revision_read_statuses',
  1034. collection_class=attribute_mapped_collection('user'),
  1035. cascade='all, delete-orphan'
  1036. ))
  1037. user = relationship('User', backref=backref(
  1038. 'revision_readers',
  1039. collection_class=attribute_mapped_collection('view_datetime'),
  1040. cascade='all, delete-orphan'
  1041. ))
  1042. class NodeTreeItem(object):
  1043. """
  1044. This class implements a model that allow to simply represents the left-panel menu items
  1045. This model is used by dbapi but is not directly related to sqlalchemy and database
  1046. """
  1047. def __init__(self, node: Content, children: list('NodeTreeItem'), is_selected = False):
  1048. self.node = node
  1049. self.children = children
  1050. self.is_selected = is_selected
  1051. class VirtualEvent(object):
  1052. @classmethod
  1053. def create_from(cls, object):
  1054. if Content == object.__class__:
  1055. return cls.create_from_content(object)
  1056. elif ContentRevisionRO == object.__class__:
  1057. return cls.create_from_content_revision(object)
  1058. @classmethod
  1059. def create_from_content(cls, content: Content):
  1060. content_type = ContentType(content.type)
  1061. label = content.get_label()
  1062. if content.type==ContentType.Comment:
  1063. # todo :voir le _('.... si le _ est utile
  1064. label = ('<strong>{}</strong> wrote:').format(content.owner.get_display_name())
  1065. return VirtualEvent(id=content.content_id,
  1066. created=content.created,
  1067. owner=content.owner,
  1068. type=content_type,
  1069. label=label,
  1070. content=content.description,
  1071. ref_object=content)
  1072. @classmethod
  1073. def create_from_content_revision(cls, revision: ContentRevisionRO):
  1074. action_description = ActionDescription(revision.revision_type)
  1075. return VirtualEvent(id=revision.revision_id,
  1076. created=revision.updated,
  1077. owner=revision.owner,
  1078. type=action_description,
  1079. label=action_description.label,
  1080. content='',
  1081. ref_object=revision)
  1082. def __init__(self, id, created, owner, type, label, content, ref_object):
  1083. self.id = id
  1084. self.created = created
  1085. self.owner = owner
  1086. self.type = type
  1087. self.label = label
  1088. self.content = content
  1089. self.ref_object = ref_object
  1090. # todo moi ? print(type)
  1091. assert hasattr(type, 'id')
  1092. assert hasattr(type, 'css')
  1093. assert hasattr(type, 'icon')
  1094. assert hasattr(type, 'label')
  1095. def created_as_delta(self, delta_from_datetime:datetime=None):
  1096. if not delta_from_datetime:
  1097. delta_from_datetime = datetime.utcnow()
  1098. return format_timedelta(delta_from_datetime - self.created,
  1099. locale=tg.i18n.get_lang()[0])
  1100. def create_readable_date(self, delta_from_datetime:datetime=None):
  1101. aff = ''
  1102. if not delta_from_datetime:
  1103. delta_from_datetime = datetime.utcnow()
  1104. delta = delta_from_datetime - self.created
  1105. if delta.days > 0:
  1106. if delta.days >= 365:
  1107. aff = '%d year%s ago' % (delta.days/365, 's' if delta.days/365>=2 else '')
  1108. elif delta.days >= 30:
  1109. aff = '%d month%s ago' % (delta.days/30, 's' if delta.days/30>=2 else '')
  1110. else:
  1111. aff = '%d day%s ago' % (delta.days, 's' if delta.days>=2 else '')
  1112. else:
  1113. if delta.seconds < 60:
  1114. aff = '%d second%s ago' % (delta.seconds, 's' if delta.seconds>1 else '')
  1115. elif delta.seconds/60 < 60:
  1116. aff = '%d minute%s ago' % (delta.seconds/60, 's' if delta.seconds/60>=2 else '')
  1117. else:
  1118. aff = '%d hour%s ago' % (delta.seconds/3600, 's' if delta.seconds/3600>=2 else '')
  1119. return aff