264 lines
11 KiB
Python
264 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright 2018 Fabien Bourgeois <fabien@yaltik.com>
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
""" Odoo Radicale Storage Plugin """
|
|
|
|
# # PLAN
|
|
# 1. Implement readonly from Odoo only
|
|
# 2. Implement contacts first (and so vcf)
|
|
# 3. Implement unique events (.ics)
|
|
# 4. Implement recurrent events
|
|
# 5. Begin write (two way) for contacts
|
|
# 5. Begin write (two way) for calendar
|
|
|
|
|
|
from contextlib import contextmanager
|
|
from time import strftime, strptime
|
|
import vobject
|
|
from odoorpc import ODOO
|
|
from odoorpc.error import RPCError
|
|
from radicale import xmlutils
|
|
from radicale.storage import BaseCollection, Item, get_etag, get_uid_from_object
|
|
from radicale_odoo_auth import Auth
|
|
|
|
|
|
class Collection(BaseCollection):
|
|
""" BaseCollection implementation for Odoo Radicale Storage """
|
|
|
|
odoo = False
|
|
|
|
def __init__(self, path):
|
|
""" Init function """
|
|
self.__class__.odoo = Auth.odoo
|
|
if not self.__class__.odoo:
|
|
self.logger.error('No auth Odoo found...')
|
|
raise RuntimeError('No auth Odoo found')
|
|
self.__class__.odoo_connect()
|
|
# self.odoo_init()
|
|
|
|
attributes = path.strip('/').split('/')
|
|
self.tag = None
|
|
self.props = {}
|
|
if 'odoo-contact' in attributes:
|
|
self.tag = 'VADDRESSBOOK'
|
|
self.odoo_model = 'res.partner'
|
|
self.content_suffix = '.vcf'
|
|
self.props.update({'tag': 'VADDRESSBOOK',
|
|
'D:displayname': 'Odoo contacts',
|
|
'CR:addressbook-description': 'Contacts form your Odoo account'})
|
|
elif 'odoo-calendar' in attributes:
|
|
self.tag = 'VCALENDAR'
|
|
self.odoo_model = 'calendar.event'
|
|
self.content_suffix = '.ics'
|
|
self.props.update({'tag': 'VCALENDAR',
|
|
'D:displayname': 'Odoo calendar',
|
|
'C:calendar-description': 'Events form your Odoo calendar'})
|
|
|
|
self.path = path.strip('/')
|
|
self.owner = attributes[0]
|
|
self.is_principal = len(attributes) == 0
|
|
|
|
@classmethod
|
|
def odoo_connect(cls):
|
|
""" Global Odoo connection : server and admin account """
|
|
host = cls.configuration.get('storage', 'odoo_host', fallback='127.0.0.1')
|
|
port = cls.configuration.get('storage', 'odoo_port', fallback=8069)
|
|
admin = cls.configuration.get('storage', 'odoo_admin_username')
|
|
password = cls.configuration.get('storage', 'odoo_admin_password')
|
|
database = cls.configuration.get('storage', 'odoo_database')
|
|
try:
|
|
cls.odoo = ODOO(host, port=port)
|
|
except RPCError as rpcerr:
|
|
cls.logger.error(rpcerr)
|
|
raise RuntimeError(rpcerr)
|
|
try:
|
|
cls.odoo.login(database, admin, password)
|
|
cls.logger.info('Login successfull for {} on database {}'.format(admin, database))
|
|
except RPCError as rpcerr:
|
|
cls.logger.error('Login problem for {} on database {}'.format(cls, database))
|
|
cls.logger.error(rpcerr)
|
|
raise RuntimeError(rpcerr)
|
|
return True
|
|
|
|
def odoo_init(self):
|
|
""" Init Odoo collections if not found """
|
|
# TODO: disallow collection deletion ?
|
|
user_ids = self.odoo.env['res.users'].search([])
|
|
users = self.odoo.execute('res.users', 'read', user_ids, ['login', 'email'])
|
|
for user in users:
|
|
principal_path = user.get('login')
|
|
self.logger.debug('Check collections from Odoo for %s' % principal_path)
|
|
contact_path = '%s/odoo-contact' % principal_path
|
|
calendar_path = '%s/odoo-calendar' % principal_path
|
|
collections = self.discover(principal_path, depth='1')
|
|
paths = [coll.path for coll in collections]
|
|
if contact_path not in paths:
|
|
props = {'tag': 'VADDRESSBOOK',
|
|
'D:displayname': 'Odoo contacts',
|
|
'C:calendar-description': 'Contacts form your Odoo account'}
|
|
self.create_collection(contact_path, props=props)
|
|
self.logger.info('Collection creation for Odoo Sync : %s' % contact_path)
|
|
if calendar_path not in paths:
|
|
props = {'tag': 'VCALENDAR',
|
|
'D:displayname': 'Odoo calendar',
|
|
'C:calendar-description': 'Events form your Odoo calendar'}
|
|
self.create_collection(calendar_path, props=props)
|
|
self.logger.info('Collection creation for Odoo Sync : %s' % calendar_path)
|
|
|
|
|
|
@classmethod
|
|
@contextmanager
|
|
def acquire_lock(cls, mode, user=None):
|
|
cls.user = user
|
|
yield
|
|
|
|
@classmethod
|
|
def discover(cls, path, depth="0"):
|
|
"""Discover a list of collections under the given ``path``.
|
|
|
|
``path`` is sanitized.
|
|
|
|
If ``depth`` is "0", only the actual object under ``path`` is
|
|
returned.
|
|
|
|
If ``depth`` is anything but "0", it is considered as "1" and direct
|
|
children are included in the result.
|
|
|
|
The root collection "/" must always exist.
|
|
|
|
"""
|
|
attributes = path.strip('/').split('/') or []
|
|
if path and not cls.user:
|
|
cls.user = attributes[0]
|
|
|
|
cls.logger.warning('Discover : %s (path), %s (depth), %s (cls.user), %s (attributes)' %
|
|
(path, depth, cls.user, attributes))
|
|
yield cls(path)
|
|
if len(attributes) == 1: # Got all if root is needed
|
|
contact_path = '%s/odoo-contact' % path
|
|
calendar_path = '%s/odoo-calendar' % path
|
|
yield cls(contact_path)
|
|
yield cls(calendar_path)
|
|
|
|
def get_meta(self, key=None):
|
|
"""Get metadata value for collection """
|
|
if key:
|
|
return self.props.get(key)
|
|
else:
|
|
return self.props
|
|
|
|
@classmethod
|
|
def create_collection(cls, href, collection=None, props=None):
|
|
""" Create collection implementation : only warns ATM """
|
|
cls.logger.error('Attemmpt to create a new collection for %s' % href)
|
|
|
|
@classmethod
|
|
def get_contacts_from_odoo(cls, login):
|
|
""" Gets all contacts available from one Odoo login """
|
|
cls.logger.info('Get contacts for Odoo user %s' % login)
|
|
partner_ids = cls.odoo.env['res.partner'].search([])
|
|
cls.logger.debug(partner_ids)
|
|
return ['res.partner:%s' % pid for pid in partner_ids]
|
|
|
|
def list(self):
|
|
"""List collection items."""
|
|
# TODO : get all ICS from Odoo...
|
|
self.logger.warning('List collection %s' % self.path)
|
|
self.logger.warning('Collection tag %s' % self.tag)
|
|
if self.tag:
|
|
if self.tag == 'VADDRESSBOOK':
|
|
for oid in self.__class__.get_contacts_from_odoo(self.owner):
|
|
yield oid
|
|
# for item in self.collection.list():
|
|
# yield item.uid + self.content_suffix
|
|
|
|
def _get_with_metadata(self, href):
|
|
"""Fetch a single item from Odoo database"""
|
|
model, database_id = href.split(':')
|
|
fields = ['name', 'write_date', 'comment']
|
|
data = self.odoo.execute(model, 'read', [int(database_id)], fields)[0]
|
|
self.logger.warning(data)
|
|
if model == 'res.partner':
|
|
# last_modified = strftime("%a, %d %b %Y %H:%M:%S GMT",
|
|
# strptime(data.get('write_date'), '%Y-%m-%d %H:%M:%S'))
|
|
last_modified = strftime("%Y-%m-%dT%H:%M:%SZ",
|
|
strptime(data.get('write_date'), '%Y-%m-%d %H:%M:%S'))
|
|
self.logger.warning(last_modified)
|
|
vobject_item = vobject.vCard()
|
|
vobject_item.add('n')
|
|
vobject_item.n.value = vobject.vcard.Name(family=data.get('name'))
|
|
vobject_item.add('fn')
|
|
vobject_item.fn.value = data.get('name')
|
|
vobject_item.add('uid').value = database_id
|
|
vobject_item.add('rev').value = last_modified
|
|
self.logger.warning(vobject_item.name)
|
|
self.logger.warning([c for c in vobject_item.components()])
|
|
tag, start, end = xmlutils.find_tag_and_time_range(vobject_item)
|
|
self.logger.warning('Tag, start, end : %s %s %s' % (tag, start, end))
|
|
text = vobject_item.serialize()
|
|
etag = get_etag(text)
|
|
uid = get_uid_from_object(vobject_item)
|
|
self.logger.warning('Text, ETAG, UID : %s %s %s' % (text, etag, uid))
|
|
return Item(
|
|
self, href=href, last_modified=last_modified, etag=etag,
|
|
text=text, item=vobject_item, uid=href,
|
|
name=vobject_item.name, component_name=tag), (tag, start, end)
|
|
elif model == 'calendar.event':
|
|
raise NotImplementedError
|
|
else:
|
|
raise NotImplementedError
|
|
|
|
def get(self, href, verify_href=True):
|
|
item, metadata = self._get_with_metadata(href)
|
|
self.logger.warning(item)
|
|
self.logger.warning(item.serialize())
|
|
self.logger.warning(item.name)
|
|
self.logger.warning(item.last_modified)
|
|
self.logger.warning([c for c in item.components()])
|
|
self.logger.warning(metadata)
|
|
return item
|
|
|
|
def delete(self, href=None):
|
|
""" Can not delete collection but item, yes """
|
|
self.logger.warning(href)
|
|
if href is None:
|
|
# Delete the collection
|
|
self.logger.error('Attempt to delete collection %s' % self.path)
|
|
raise ValueError('Can not delete collection')
|
|
else:
|
|
# Delete an item
|
|
raise NotImplementedError
|
|
|
|
# def serialize(self):
|
|
# """ Get the whole collection unicode """
|
|
# # TODO: CALENDAR
|
|
# self.logger.warning('From serialize : %s' % self.tag)
|
|
# if self.tag == "VADDRESSBOOK":
|
|
# self.logger.warning('HERE')
|
|
# self.logger.warning([item.serialize() for item in self.get_all()])
|
|
# return ''.join((item.serialize() for item in self.get_all()))
|
|
# return ''
|
|
|
|
@property
|
|
def last_modified(self):
|
|
""" Return last modified """
|
|
last = self.odoo.env[self.odoo_model].search([], limit=1, order='write_date desc')
|
|
last_fields = self.odoo.execute(self.odoo_model, 'read', last, ['write_date'])[0]
|
|
self.logger.info(last_fields)
|
|
return strftime("%a, %d %b %Y %H:%M:%S GMT",
|
|
strptime(last_fields.get('write_date'), '%Y-%m-%d %H:%M:%S'))
|