calendar.py 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. import caldav
  2. import os
  3. import re
  4. import transaction
  5. from caldav.lib.error import PutError
  6. from icalendar import Event as iCalendarEvent
  7. from sqlalchemy.orm.exc import NoResultFound
  8. from tg import tmpl_context
  9. from tg.i18n import ugettext as _
  10. from tracim.lib.content import ContentApi
  11. from tracim.lib.exceptions import UnknownCalendarType
  12. from tracim.lib.exceptions import NotFound
  13. from tracim.lib.user import UserApi
  14. from tracim.lib.workspace import UnsafeWorkspaceApi
  15. from tracim.lib.workspace import WorkspaceApi
  16. from tracim.model import User
  17. from tracim.model import DBSession
  18. from tracim.model import new_revision
  19. from tracim.model.data import ActionDescription
  20. from tracim.model.data import Content
  21. from tracim.model.data import ContentType
  22. from tracim.model.organisational import Calendar
  23. from tracim.model.organisational import UserCalendar
  24. from tracim.model.organisational import WorkspaceCalendar
  25. CALENDAR_USER_PATH_RE = 'user\/([0-9]+).ics'
  26. CALENDAR_WORKSPACE_PATH_RE = 'workspace\/([0-9]+).ics'
  27. CALENDAR_TYPE_USER = UserCalendar
  28. CALENDAR_TYPE_WORKSPACE = WorkspaceCalendar
  29. CALENDAR_USER_URL_TEMPLATE = 'user/{id}.ics/'
  30. CALENDAR_WORKSPACE_URL_TEMPLATE = 'workspace/{id}.ics/'
  31. CALENDAR_USER_BASE_URL = '/user/'
  32. CALENDAR_WORKSPACE_BASE_URL = '/workspace/'
  33. class CalendarManager(object):
  34. @classmethod
  35. def get_personal_calendar_description(cls) -> str:
  36. return _('My personal calendar')
  37. @classmethod
  38. def get_base_url(cls, low_level: bool=False) -> str:
  39. """
  40. :param low_level: If True, use local ip address with radicale port.
  41. :return: Radical address base url.
  42. """
  43. from tracim.config.app_cfg import CFG
  44. cfg = CFG.get_instance()
  45. if not low_level:
  46. return cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE
  47. return 'http://127.0.0.1:{0}'.format(cfg.RADICALE_SERVER_PORT)
  48. @classmethod
  49. def get_user_base_url(cls):
  50. from tracim.config.app_cfg import CFG
  51. cfg = CFG.get_instance()
  52. return os.path.join(cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE, 'user/')
  53. @classmethod
  54. def get_workspace_base_url(cls):
  55. from tracim.config.app_cfg import CFG
  56. cfg = CFG.get_instance()
  57. return os.path.join(cfg.RADICALE_CLIENT_BASE_URL_TEMPLATE, 'workspace/')
  58. @classmethod
  59. def get_user_calendar_url(
  60. cls,
  61. user_id: int,
  62. low_level: bool=False,
  63. ):
  64. user_path = CALENDAR_USER_URL_TEMPLATE.format(id=str(user_id))
  65. return os.path.join(
  66. cls.get_base_url(low_level=low_level),
  67. user_path,
  68. )
  69. @classmethod
  70. def get_workspace_calendar_url(
  71. cls,
  72. workspace_id: int,
  73. low_level: bool=False,
  74. ):
  75. workspace_path = CALENDAR_WORKSPACE_URL_TEMPLATE.format(
  76. id=str(workspace_id)
  77. )
  78. return os.path.join(
  79. cls.get_base_url(low_level=low_level),
  80. workspace_path,
  81. )
  82. def __init__(self, user: User):
  83. self._user = user
  84. def get_type_for_path(self, path: str) -> str:
  85. """
  86. Return calendar type for given path. Raise
  87. tracim.lib.exceptions.UnknownCalendarType if unknown type.
  88. :param path: path representation like user/42--foo.ics
  89. :return: Type of calendar, can be one of CALENDAR_TYPE_USER,
  90. CALENDAR_TYPE_WORKSPACE
  91. """
  92. if re.match(CALENDAR_USER_PATH_RE, path):
  93. return CALENDAR_TYPE_USER
  94. if re.match(CALENDAR_WORKSPACE_PATH_RE, path):
  95. return CALENDAR_TYPE_WORKSPACE
  96. raise UnknownCalendarType(
  97. 'No match for calendar path "{0}"'.format(path)
  98. )
  99. def get_id_for_path(self, path: str, type: str) -> int:
  100. """
  101. Return related calendar id for given path. Raise
  102. tracim.lib.exceptions.UnknownCalendarType if unknown type.
  103. :param path: path representation like user/42--foo.ics
  104. :param type: Type of calendar, can be one of CALENDAR_TYPE_USER,
  105. CALENDAR_TYPE_WORKSPACE
  106. :return: ID of related calendar object. For UserCalendar it will be
  107. user id, for WorkspaceCalendar it will be Workspace id.
  108. """
  109. if type == CALENDAR_TYPE_USER:
  110. return re.search(CALENDAR_USER_PATH_RE, path).group(1)
  111. elif type == CALENDAR_TYPE_WORKSPACE:
  112. return re.search(CALENDAR_WORKSPACE_PATH_RE, path).group(1)
  113. raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))
  114. def find_calendar_with_path(self, path: str) -> Calendar:
  115. """
  116. Return calendar for given path. Raise tracim.lib.exceptions.NotFound if
  117. calendar cannot be found.
  118. :param path: path representation like user/42--foo.ics
  119. :return: Calendar corresponding to path
  120. """
  121. try:
  122. type = self.get_type_for_path(path)
  123. id = self.get_id_for_path(path, type)
  124. except UnknownCalendarType as exc:
  125. raise NotFound(str(exc))
  126. try:
  127. return self.get_calendar(type, id, path)
  128. except NoResultFound as exc:
  129. raise NotFound(str(exc))
  130. def get_calendar(self, type: str, id: str, path: str) -> Calendar:
  131. """
  132. Return tracim.model.organisational.Calendar instance for given
  133. parameters.
  134. :param type: Type of calendar, can be one of CALENDAR_TYPE_USER,
  135. CALENDAR_TYPE_WORKSPACE
  136. :param id: related calendar object id
  137. :param path: path representation like user/42--foo.ics
  138. :return: a calendar.
  139. """
  140. if type == CALENDAR_TYPE_USER:
  141. user = UserApi(self._user).get_one_by_id(id)
  142. return UserCalendar(user, path=path)
  143. if type == CALENDAR_TYPE_WORKSPACE:
  144. workspace = UnsafeWorkspaceApi(self._user).get_one(id)
  145. return WorkspaceCalendar(workspace, path=path)
  146. raise UnknownCalendarType('Type "{0}" is not implemented'.format(type))
  147. def add_event(
  148. self,
  149. calendar: Calendar,
  150. event: iCalendarEvent,
  151. event_name: str,
  152. owner: User,
  153. ) -> Content:
  154. """
  155. Create Content event type.
  156. :param calendar: Event calendar owner
  157. :param event: ICS event
  158. :param event_name: Event name (ID) like
  159. 20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
  160. :param owner: Event Owner
  161. :return: Created Content
  162. """
  163. workspace = None
  164. if isinstance(calendar, WorkspaceCalendar):
  165. workspace = calendar.related_object
  166. elif isinstance(calendar, UserCalendar):
  167. pass
  168. else:
  169. raise UnknownCalendarType('Type "{0}" is not implemented'
  170. .format(type(calendar)))
  171. content = ContentApi(owner).create(
  172. content_type=ContentType.Event,
  173. workspace=workspace,
  174. do_save=False
  175. )
  176. self.populate_content_with_event(
  177. content,
  178. event,
  179. event_name
  180. )
  181. content.revision_type = ActionDescription.CREATION
  182. DBSession.add(content)
  183. DBSession.flush()
  184. transaction.commit()
  185. return content
  186. def update_event(
  187. self,
  188. calendar: Calendar,
  189. event: iCalendarEvent,
  190. event_name: str,
  191. current_user: User,
  192. ) -> Content:
  193. """
  194. Update Content Event
  195. :param calendar: Event calendar owner
  196. :param event: ICS event
  197. :param event_name: Event name (ID) like
  198. 20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
  199. :param current_user: Current modification asking user
  200. :return: Updated Content
  201. """
  202. workspace = None
  203. if isinstance(calendar, WorkspaceCalendar):
  204. workspace = calendar.related_object
  205. elif isinstance(calendar, UserCalendar):
  206. pass
  207. else:
  208. raise UnknownCalendarType('Type "{0}" is not implemented'
  209. .format(type(calendar)))
  210. content_api = ContentApi(
  211. current_user,
  212. force_show_all_types=True,
  213. disable_user_workspaces_filter=True
  214. )
  215. content = content_api.find_one_by_unique_property(
  216. property_name='name',
  217. property_value=event_name,
  218. workspace=workspace
  219. )
  220. with new_revision(content):
  221. self.populate_content_with_event(
  222. content,
  223. event,
  224. event_name
  225. )
  226. content.revision_type = ActionDescription.EDITION
  227. DBSession.flush()
  228. transaction.commit()
  229. return content
  230. def delete_event_with_name(self, event_name: str, current_user: User)\
  231. -> Content:
  232. """
  233. Delete Content Event
  234. :param event_name: Event name (ID) like
  235. 20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
  236. :param current_user: Current deletion asking user
  237. :return: Deleted Content
  238. """
  239. content_api = ContentApi(current_user, force_show_all_types=True)
  240. content = content_api.find_one_by_unique_property(
  241. property_name='name',
  242. property_value=event_name,
  243. workspace=None
  244. )
  245. with new_revision(content):
  246. content_api.delete(content)
  247. DBSession.flush()
  248. transaction.commit()
  249. return content
  250. def populate_content_with_event(
  251. self,
  252. content: Content,
  253. event: iCalendarEvent,
  254. event_name: str,
  255. ) -> None:
  256. """
  257. Populate Content content instance from iCalendarEvent event attributes.
  258. :param content: content to populate
  259. :param event: event with data to insert in content
  260. :param event_name: Event name (ID) like
  261. 20160602T083511Z-18100-1001-1-71_Bastien-20160602T083516Z.ics
  262. :return: given content
  263. """
  264. content.label = event.get('summary')
  265. content.description = event.get('description')
  266. content.properties = {
  267. 'name': event_name,
  268. 'location': event.get('location'),
  269. 'raw': event.to_ical().decode("utf-8"),
  270. 'start': event.get('dtend').dt.strftime('%Y-%m-%d %H:%M:%S%z'),
  271. 'end': event.get('dtstart').dt.strftime('%Y-%m-%d %H:%M:%S%z'),
  272. }
  273. @classmethod
  274. def get_workspace_readable_calendars_urls_for_user(cls, user: User)\
  275. -> [str]:
  276. calendar_urls = []
  277. for workspace in cls.get_workspace_readable_calendars_for_user(user):
  278. calendar_urls.append(cls.get_workspace_calendar_url(
  279. workspace_id=workspace.workspace_id,
  280. ))
  281. return calendar_urls
  282. @classmethod
  283. def get_workspace_readable_calendars_for_user(cls, user: User)\
  284. -> ['Workspace']:
  285. workspaces = []
  286. workspace_api = WorkspaceApi(user)
  287. for workspace in workspace_api.get_all():
  288. if workspace.calendar_enabled:
  289. workspaces.append(workspace)
  290. return workspaces
  291. def is_discovery_path(self, path: str) -> bool:
  292. """
  293. If collection url in one of them, Caldav client is tring to discover
  294. collections.
  295. :param path: collection path
  296. :return: True if given collection path is an discover path
  297. """
  298. return path in ('user', 'workspace')
  299. def create_then_remove_fake_event(
  300. self,
  301. calendar_class,
  302. related_object_id,
  303. ) -> None:
  304. radicale_base_url = self.get_base_url(low_level=True)
  305. client = caldav.DAVClient(
  306. radicale_base_url,
  307. username=self._user.email,
  308. password=self._user.auth_token,
  309. )
  310. if calendar_class == WorkspaceCalendar:
  311. calendar_url = self.get_workspace_calendar_url(
  312. related_object_id,
  313. low_level=True,
  314. )
  315. elif calendar_class == UserCalendar:
  316. calendar_url = self.get_user_calendar_url(
  317. related_object_id,
  318. low_level=True,
  319. )
  320. else:
  321. raise Exception('Unknown calendar type {0}'.format(calendar_class))
  322. user_calendar = caldav.Calendar(
  323. parent=client,
  324. client=client,
  325. url=calendar_url
  326. )
  327. event_ics = """BEGIN:VCALENDAR
  328. VERSION:2.0
  329. PRODID:-//Example Corp.//CalDAV Client//EN
  330. BEGIN:VEVENT
  331. UID:{uid}
  332. DTSTAMP:20100510T182145Z
  333. DTSTART:20100512T170000Z
  334. DTEND:20100512T180000Z
  335. SUMMARY:This is an event
  336. LOCATION:Here
  337. END:VEVENT
  338. END:VCALENDAR
  339. """.format(uid='{0}FAKEEVENT'.format(related_object_id))
  340. try:
  341. event = user_calendar.add_event(event_ics)
  342. event.delete()
  343. except PutError:
  344. pass # TODO BS 20161128: Radicale is down. Record this event ?
  345. def get_enabled_calendar_file_path(
  346. self,
  347. calendar_class,
  348. related_object_id,
  349. ) -> str:
  350. from tracim.config.app_cfg import CFG
  351. cfg = CFG.get_instance()
  352. calendar_file_name = '{}.ics'.format(related_object_id)
  353. if calendar_class == WorkspaceCalendar:
  354. calendar_type_folder = 'workspace'
  355. elif calendar_class == UserCalendar:
  356. calendar_type_folder = 'user'
  357. else:
  358. raise NotImplementedError()
  359. current_calendar_file_path = os.path.join(
  360. cfg.RADICALE_SERVER_FILE_SYSTEM_FOLDER,
  361. calendar_type_folder,
  362. calendar_file_name,
  363. )
  364. return current_calendar_file_path
  365. def get_deleted_calendar_file_path(
  366. self,
  367. calendar_class,
  368. related_object_id,
  369. ) -> str:
  370. from tracim.config.app_cfg import CFG
  371. cfg = CFG.get_instance()
  372. calendar_file_name = '{}.ics'.format(related_object_id)
  373. if calendar_class == WorkspaceCalendar:
  374. calendar_type_folder = 'workspace'
  375. elif calendar_class == UserCalendar:
  376. calendar_type_folder = 'user'
  377. else:
  378. raise NotImplementedError()
  379. deleted_calendar_file_path = os.path.join(
  380. cfg.RADICALE_SERVER_FILE_SYSTEM_FOLDER,
  381. calendar_type_folder,
  382. 'deleted',
  383. calendar_file_name,
  384. )
  385. return deleted_calendar_file_path
  386. def disable_calendar_file(
  387. self,
  388. calendar_class,
  389. related_object_id: int,
  390. raise_: bool=False,
  391. ) -> None:
  392. enabled_calendar_file_path = self.get_enabled_calendar_file_path(
  393. calendar_class,
  394. related_object_id,
  395. )
  396. deleted_calendar_file_path = self.get_deleted_calendar_file_path(
  397. calendar_class,
  398. related_object_id,
  399. )
  400. deleted_folder = os.path.dirname(deleted_calendar_file_path)
  401. if not os.path.exists(deleted_folder):
  402. os.makedirs(deleted_folder)
  403. try:
  404. os.rename(enabled_calendar_file_path, deleted_calendar_file_path)
  405. except FileNotFoundError:
  406. if raise_:
  407. raise
  408. def enable_calendar_file(
  409. self,
  410. calendar_class,
  411. related_object_id: int,
  412. raise_: bool=False,
  413. ) -> None:
  414. enabled_calendar_file_path = self.get_enabled_calendar_file_path(
  415. calendar_class,
  416. related_object_id,
  417. )
  418. deleted_calendar_file_path = self.get_deleted_calendar_file_path(
  419. calendar_class,
  420. related_object_id,
  421. )
  422. try:
  423. os.rename(deleted_calendar_file_path, enabled_calendar_file_path)
  424. except FileNotFoundError:
  425. if raise_:
  426. raise