[MIG] google_cal: migration to new API

This commit is contained in:
Jérome Maes
2016-08-08 17:12:56 +02:00
16 changed files with 1084 additions and 1119 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
import res_config
import google_calendar
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import controllers
import models
+5 -5
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Google Calendar',
'version': '1.0',
@@ -14,11 +13,12 @@ The module adds the possibility to synchronize Google Calendar with Odoo
'depends': ['google_account', 'calendar'],
'qweb': ['static/src/xml/*.xml'],
'data': [
'res_config_view.xml',
'data/google_calendar_data.xml',
'data/google_calendar_data.xml',
'security/ir.model.access.csv',
'views/google_calendar.xml',
'views/res_users.xml',
'google_calendar.xml',
'views/res_config_views.xml',
'views/res_users_views.xml',
'views/google_calendar_templates.xml',
],
'demo': [],
'installable': True,
@@ -1 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import main
+22 -23
View File
@@ -1,59 +1,58 @@
import openerp.http as http
from openerp.http import request
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class google_calendar_controller(http.Controller):
class GoogleCalendarController(http.Controller):
@http.route('/google_calendar/sync_data', type='json', auth='user')
def sync_data(self, arch, fields, model, **kw):
"""
This route/function is called when we want to synchronize openERP calendar with Google Calendar
""" This route/function is called when we want to synchronize openERP calendar with Google Calendar
Function return a dictionary with the status : need_config_from_admin, need_auth, need_refresh, success if not calendar_event
The dictionary may contains an url, to allow OpenERP Client to redirect user on this URL for authorization for example
"""
if model == 'calendar.event':
gs_obj = request.registry['google.service']
gc_obj = request.registry['google.calendar']
GoogleService = request.env['google.service']
GoogleCal = request.env['google.calendar']
# Checking that admin have already configured Google API for google synchronization !
client_id = gs_obj.get_client_id(request.cr, request.uid, 'calendar', context=kw.get('local_context'))
context = kw.get('local_context', {})
client_id = GoogleService.with_context(context).get_client_id('calendar')
if not client_id or client_id == '':
action = ''
if gc_obj.can_authorize_google(request.cr, request.uid):
dummy, action = request.registry.get('ir.model.data').get_object_reference(request.cr, request.uid,
'google_calendar', 'action_config_settings_google_calendar')
action_id = ''
if GoogleCal.can_authorize_google():
action_id = request.env.ref('google_calendar.action_config_settings_google_calendar').id
return {
"status": "need_config_from_admin",
"url": '',
"action": action
"action": action_id
}
# Checking that user have already accepted OpenERP to access his calendar !
if gc_obj.need_authorize(request.cr, request.uid, context=kw.get('local_context')):
url = gc_obj.authorize_google_uri(request.cr, request.uid, from_url=kw.get('fromurl'), context=kw.get('local_context'))
if GoogleCal.need_authorize():
url = GoogleCal.with_context(context).authorize_google_uri(from_url=kw.get('fromurl'))
return {
"status": "need_auth",
"url": url
}
# If App authorized, and user access accepted, We launch the synchronization
return gc_obj.synchronize_events(request.cr, request.uid, [], context=kw.get('local_context'))
return GoogleCal.with_context(context).synchronize_events()
return {"status": "success"}
@http.route('/google_calendar/remove_references', type='json', auth='user')
def remove_references(self, model, **kw):
"""
This route/function is called when we want to remove all the references between one calendar OpenERP and one Google Calendar
"""
""" This route/function is called when we want to remove all the references between one calendar OpenERP and one Google Calendar """
status = "NOP"
if model == 'calendar.event':
gc_obj = request.registry['google.calendar']
GoogleCal = request.env['google.calendar']
# Checking that user have already accepted OpenERP to access his calendar !
if gc_obj.remove_references(request.cr, request.uid, context=kw.get('local_context')):
context = kw.get('local_context', {})
if GoogleCal.with_context(context).remove_references():
status = "OK"
else:
status = "KO"
@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<odoo>
<data>
<record forcecreate="True" id="ir_cron_sync_all_cals" model="ir.cron">
<field name="name">Calendar synchronization</field>
@@ -14,6 +12,5 @@
<field eval="'google.calendar'" name="model" />
<field eval="'synchronize_events_cron'" name="function" />
</record>
</data>
</openerp>
</odoo>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import calendar
import res_users
import google_calendar
import res_config
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class Meeting(models.Model):
_inherit = "calendar.event"
oe_update_date = fields.Datetime('Odoo Update Date')
@api.model
def get_fields_need_update_google(self):
recurrent_fields = self._get_recurrent_fields()
return recurrent_fields + ['name', 'description', 'allday', 'start', 'date_end', 'stop',
'attendee_ids', 'alarm_ids', 'location', 'privacy', 'active',
'start_date', 'start_datetime', 'stop_date', 'stop_datetime']
@api.multi
def write(self, values):
sync_fields = set(self.get_fields_need_update_google())
if (set(values.keys()) and sync_fields) and 'oe_update_date' not in values.keys() and 'NewMeeting' not in self._context:
values['oe_update_date'] = fields.Datetime.now()
return super(Meeting, self).write(values)
@api.multi
def copy(self, default=None):
default = default or {}
if default.get('write_type', False):
del default['write_type']
elif default.get('recurrent_id', False):
default['oe_update_date'] = fields.Datetime.now()
else:
default['oe_update_date'] = False
return super(Meeting, self).copy(default)
@api.multi
def unlink(self, can_be_deleted=False):
return super(Meeting, self).unlink(can_be_deleted=can_be_deleted)
class Attendee(models.Model):
_inherit = 'calendar.attendee'
google_internal_event_id = fields.Char('Google Calendar Event Id')
oe_synchro_date = fields.Datetime('Odoo Synchro Date')
_sql_constraints = [
('google_id_uniq', 'unique(google_internal_event_id,partner_id,event_id)', 'Google ID should be unique!')
]
@api.multi
def write(self, values):
for attendee in self:
meeting_id_to_update = values.get('event_id', attendee.event_id.id)
# If attendees are updated, we need to specify that next synchro need an action
# Except if it come from an update_from_google
if not self._context.get('curr_attendee', False) and not self._context.get('NewMeeting', False):
self.env['calendar.event'].browse(meeting_id_to_update).write({'oe_update_date': fields.Datetime.now()})
return super(Attendee, self).write(values)
@@ -0,0 +1,919 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from dateutil import parser
import json
import logging
import operator
import pytz
import urllib2
from odoo import api, fields, models, tools, _
from odoo.tools import exception_to_unicode
_logger = logging.getLogger(__name__)
def status_response(status):
return int(str(status)[0]) == 2
class Meta(type):
""" This Meta class allow to define class as a structure, and so instancied variable
in __init__ to avoid to have side effect alike 'static' variable """
def __new__(typ, name, parents, attrs):
methods = dict((k, v) for k, v in attrs.iteritems()
if callable(v))
attrs = dict((k, v) for k, v in attrs.iteritems()
if not callable(v))
def init(self, **kw):
for key, val in attrs.iteritems():
setattr(self, key, val)
for key, val in kw.iteritems():
assert key in attrs
setattr(self, key, val)
methods['__init__'] = init
methods['__getitem__'] = getattr
return type.__new__(typ, name, parents, methods)
class Struct(object):
__metaclass__ = Meta
class OdooEvent(Struct):
event = False
found = False
event_id = False
isRecurrence = False
isInstance = False
update = False
status = False
attendee_id = False
synchro = False
class GmailEvent(Struct):
event = False
found = False
isRecurrence = False
isInstance = False
update = False
status = False
class SyncEvent(object):
def __init__(self):
self.OE = OdooEvent()
self.GG = GmailEvent()
self.OP = None
def __getitem__(self, key):
return getattr(self, key)
def compute_OP(self, modeFull=True):
#If event are already in Gmail and in Odoo
if self.OE.found and self.GG.found:
is_owner = self.OE.event.env.user.id == self.OE.event.user_id.id
#If the event has been deleted from one side, we delete on other side !
if self.OE.status != self.GG.status and is_owner:
self.OP = Delete((self.OE.status and "OE") or (self.GG.status and "GG"),
'The event has been deleted from one side, we delete on other side !')
#If event is not deleted !
elif self.OE.status and (self.GG.status or not is_owner):
if self.OE.update.split('.')[0] != self.GG.update.split('.')[0]:
if self.OE.update < self.GG.update:
tmpSrc = 'GG'
elif self.OE.update > self.GG.update:
tmpSrc = 'OE'
assert tmpSrc in ['GG', 'OE']
if self[tmpSrc].isRecurrence:
if self[tmpSrc].status:
self.OP = Update(tmpSrc, 'Only need to update, because i\'m active')
else:
self.OP = Exclude(tmpSrc, 'Need to Exclude (Me = First event from recurrence) from recurrence')
elif self[tmpSrc].isInstance:
self.OP = Update(tmpSrc, 'Only need to update, because already an exclu')
else:
self.OP = Update(tmpSrc, 'Simply Update... I\'m a single event')
else:
if not self.OE.synchro or self.OE.synchro.split('.')[0] < self.OE.update.split('.')[0]:
self.OP = Update('OE', 'Event already updated by another user, but not synchro with my google calendar')
else:
self.OP = NothingToDo("", 'Not update needed')
else:
self.OP = NothingToDo("", "Both are already deleted")
# New in Odoo... Create on create_events of synchronize function
elif self.OE.found and not self.GG.found:
if self.OE.status:
self.OP = Delete('OE', 'Update or delete from GOOGLE')
else:
if not modeFull:
self.OP = Delete('GG', 'Deleted from Odoo, need to delete it from Gmail if already created')
else:
self.OP = NothingToDo("", "Already Deleted in gmail and unlinked in Odoo")
elif self.GG.found and not self.OE.found:
tmpSrc = 'GG'
if not self.GG.status and not self.GG.isInstance:
# don't need to make something... because event has been created and deleted before the synchronization
self.OP = NothingToDo("", 'Nothing to do... Create and Delete directly')
else:
if self.GG.isInstance:
if self[tmpSrc].status:
self.OP = Exclude(tmpSrc, 'Need to create the new exclu')
else:
self.OP = Exclude(tmpSrc, 'Need to copy and Exclude')
else:
self.OP = Create(tmpSrc, 'New EVENT CREATE from GMAIL')
def __str__(self):
return self.__repr__()
def __repr__(self):
event_str = "\n\n---- A SYNC EVENT ---"
event_str += "\n ID OE: %s " % (self.OE.event and self.OE.event.id)
event_str += "\n ID GG: %s " % (self.GG.event and self.GG.event.get('id', False))
event_str += "\n Name OE: %s " % (self.OE.event and self.OE.event.name.encode('utf8'))
event_str += "\n Name GG: %s " % (self.GG.event and self.GG.event.get('summary', '').encode('utf8'))
event_str += "\n Found OE:%5s vs GG: %5s" % (self.OE.found, self.GG.found)
event_str += "\n Recurrence OE:%5s vs GG: %5s" % (self.OE.isRecurrence, self.GG.isRecurrence)
event_str += "\n Instance OE:%5s vs GG: %5s" % (self.OE.isInstance, self.GG.isInstance)
event_str += "\n Synchro OE: %10s " % (self.OE.synchro)
event_str += "\n Update OE: %10s " % (self.OE.update)
event_str += "\n Update GG: %10s " % (self.GG.update)
event_str += "\n Status OE:%5s vs GG: %5s" % (self.OE.status, self.GG.status)
if (self.OP is None):
event_str += "\n Action %s" % "---!!!---NONE---!!!---"
else:
event_str += "\n Action %s" % type(self.OP).__name__
event_str += "\n Source %s" % (self.OP.src)
event_str += "\n comment %s" % (self.OP.info)
return event_str
class SyncOperation(object):
def __init__(self, src, info, **kw):
self.src = src
self.info = info
for key, val in kw.items():
setattr(self, key, val)
def __str__(self):
return 'in__STR__'
class Create(SyncOperation):
pass
class Update(SyncOperation):
pass
class Delete(SyncOperation):
pass
class NothingToDo(SyncOperation):
pass
class Exclude(SyncOperation):
pass
class GoogleCalendar(models.AbstractModel):
STR_SERVICE = 'calendar'
_name = 'google.%s' % STR_SERVICE
def generate_data(self, event, isCreating=False):
if event.allday:
start_date = fields.Datetime.context_timestamp(self, fields.Datetime.from_string(event.start)).isoformat('T').split('T')[0]
final_date = fields.Datetime.context_timestamp(self, fields.Datetime.from_string(event.stop) + timedelta(days=1)).isoformat('T').split('T')[0]
type = 'date'
vstype = 'dateTime'
else:
start_date = fields.Datetime.context_timestamp(self, fields.Datetime.from_string(event.start)).isoformat('T')
final_date = fields.Datetime.context_timestamp(self, fields.Datetime.from_string(event.stop)).isoformat('T')
type = 'dateTime'
vstype = 'date'
attendee_list = []
for attendee in event.attendee_ids:
email = tools.email_split(attendee.email)
email = email[0] if email else 'NoEmail@mail.com'
attendee_list.append({
'email': email,
'displayName': attendee.partner_id.name,
'responseStatus': attendee.state or 'needsAction',
})
reminders = []
for alarm in event.alarm_ids:
reminders.append({
"method": "email" if alarm.type == "email" else "popup",
"minutes": alarm.duration_minutes
})
data = {
"summary": event.name or '',
"description": event.description or '',
"start": {
type: start_date,
vstype: None,
'timeZone': self.env.context.get('tz') or 'UTC',
},
"end": {
type: final_date,
vstype: None,
'timeZone': self.env.context.get('tz') or 'UTC',
},
"attendees": attendee_list,
"reminders": {
"overrides": reminders,
"useDefault": "false"
},
"location": event.location or '',
"visibility": event['privacy'] or 'public',
}
if event.recurrency and event.rrule:
data["recurrence"] = ["RRULE:" + event.rrule]
if not event.active:
data["state"] = "cancelled"
if not self.get_need_synchro_attendee():
data.pop("attendees")
if isCreating:
other_google_ids = [other_att.google_internal_event_id for other_att in event.attendee_ids if other_att.google_internal_event_id]
if other_google_ids:
data["id"] = other_google_ids[0]
return data
def create_an_event(self, event):
""" Create a new event in google calendar from the given event in Odoo.
:param event : record of calendar.event to export to google calendar
"""
data = self.generate_data(event, isCreating=True)
url = "/calendar/v3/calendars/%s/events?fields=%s&access_token=%s" % ('primary', urllib2.quote('id,updated'), self.get_token())
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
data_json = json.dumps(data)
return self.env['google.service']._do_request(url, data_json, headers, type='POST')
def delete_an_event(self, event_id):
""" Delete the given event in primary calendar of google cal.
:param event_id : google cal identifier of the event to delete
"""
params = {
'access_token': self.get_token()
}
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', event_id)
return self.env['google.service']._do_request(url, params, headers, type='DELETE')
def get_calendar_primary_id(self):
""" In google calendar, you can have multiple calendar. But only one is
the 'primary' one. This Calendar identifier is 'primary'.
"""
params = {
'fields': 'id',
'access_token': self.get_token()
}
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/primary"
try:
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
except urllib2.HTTPError, e:
if e.code == 401: # Token invalid / Acces unauthorized
error_msg = _("Your token is invalid or has been revoked !")
self.env.user.write({'google_calendar_token': False, 'google_calendar_token_validity': False})
self.env.cr.commit()
raise self.env['res.config.settings'].get_config_warning(error_msg)
raise
return (status_response(status), content['id'] or False, ask_time)
def get_event_synchro_dict(self, lastSync=False, token=False, nextPageToken=False):
""" Returns events on the 'primary' calendar from google cal.
:returns dict where the key is the google_cal event id, and the value the details of the event,
defined at https://developers.google.com/google-apps/calendar/v3/reference/events/list
"""
if not token:
token = self.get_token()
params = {
'fields': 'items,nextPageToken',
'access_token': token,
'maxResults': 1000,
}
if lastSync:
params['updatedMin'] = lastSync.strftime("%Y-%m-%dT%H:%M:%S.%fz")
params['showDeleted'] = True
else:
params['timeMin'] = self.get_minTime().strftime("%Y-%m-%dT%H:%M:%S.%fz")
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/%s/events" % 'primary'
if nextPageToken:
params['pageToken'] = nextPageToken
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
google_events_dict = {}
for google_event in content['items']:
google_events_dict[google_event['id']] = google_event
if content.get('nextPageToken'):
google_events_dict.update(
self.get_event_synchro_dict(lastSync=lastSync, token=token, nextPageToken=content['nextPageToken'])
)
return google_events_dict
def get_one_event_synchro(self, google_id):
token = self.get_token()
params = {
'access_token': token,
'maxResults': 1000,
'showDeleted': True,
}
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', google_id)
try:
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
except Exception, e:
_logger.info("Calendar Synchro - In except of get_one_event_synchro")
_logger.info(exception_to_unicode(e))
return False
return status_response(status) and content or False
def update_to_google(self, oe_event, google_event):
url = "/calendar/v3/calendars/%s/events/%s?fields=%s&access_token=%s" % ('primary', google_event['id'], 'id,updated', self.get_token())
headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
data = self.generate_data(oe_event)
data['sequence'] = google_event.get('sequence', 0)
data_json = json.dumps(data)
status, content, ask_time = self.env['google.service']._do_request(url, data_json, headers, type='PATCH')
update_date = datetime.strptime(content['updated'], "%Y-%m-%dT%H:%M:%S.%fz")
oe_event.write({'oe_update_date': update_date})
if self.env.context.get('curr_attendee'):
self.env['calendar.attendee'].browse(self.env.context['curr_attendee']).write({'oe_synchro_date': update_date})
def update_an_event(self, event):
data = self.generate_data(event)
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', event.google_internal_event_id)
headers = {}
data['access_token'] = self.get_token()
status, response, ask_time = self.env['google.service']._do_request(url, data, headers, type='GET')
#TO_CHECK : , if http fail, no event, do DELETE ?
return response
def update_recurrent_event_exclu(self, instance_id, event_ori_google_id, event_new):
""" Update event on google calendar
:param instance_id : new google cal identifier
:param event_ori_google_id : origin google cal identifier
:param event_new : record of calendar.event to modify
"""
data = self.generate_data(event_new)
url = "/calendar/v3/calendars/%s/events/%s?access_token=%s" % ('primary', instance_id, self.get_token())
headers = {'Content-type': 'application/json'}
data.update(recurringEventId=event_ori_google_id, originalStartTime=event_new.recurrent_id_date, sequence=self.get_sequence(instance_id))
data_json = json.dumps(data)
return self.env['google.service']._do_request(url, data_json, headers, type='PUT')
def update_from_google(self, event, single_event_dict, type):
""" Update an event in Odoo with information from google calendar
:param event : record od calendar.event to update
:param single_event_dict : dict of google cal event data
"""
CalendarEvent = self.env['calendar.event'].with_context(no_mail_to_attendees=True)
ResPartner = self.env['res.partner']
CalendarAlarm = self.env['calendar.alarm']
attendee_record = []
alarm_record = set()
partner_record = [(4, self.env.user.partner_id.id)]
result = {}
if self.get_need_synchro_attendee():
for google_attendee in single_event_dict.get('attendees', []):
partner_email = google_attendee.get('email')
if type == "write":
for oe_attendee in event['attendee_ids']:
if oe_attendee.email == google_attendee['email']:
oe_attendee.write({'state': google_attendee['responseStatus'], 'google_internal_event_id': single_event_dict.get('id')})
google_attendee['found'] = True
continue
if google_attendee.get('found'):
continue
attendee = ResPartner.search([('email', '=', google_attendee['email'])], limit=1)
if not attendee:
data = {
'email': partner_email,
'customer': False,
'name': google_attendee.get("displayName", False) or partner_email
}
attendee = ResPartner.create(data)
attendee = attendee.read(['email'])[0]
partner_record.append((4, attendee.get('id')))
attendee['partner_id'] = attendee.pop('id')
attendee['state'] = google_attendee['responseStatus']
attendee_record.append((0, 0, attendee))
for google_alarm in single_event_dict.get('reminders', {}).get('overrides', []):
alarm = CalendarAlarm.search(
[
('type', '=', google_alarm['method'] if google_alarm['method'] == 'email' else 'notification'),
('duration_minutes', '=', google_alarm['minutes'])
], limit=1
)
if not alarm:
data = {
'type': google_alarm['method'] if google_alarm['method'] == 'email' else 'notification',
'duration': google_alarm['minutes'],
'interval': 'minutes',
'name': "%s minutes - %s" % (google_alarm['minutes'], google_alarm['method'])
}
alarm = CalendarAlarm.create(data)
alarm_record.add(alarm.id)
UTC = pytz.timezone('UTC')
if single_event_dict.get('start') and single_event_dict.get('end'): # If not cancelled
if single_event_dict['start'].get('dateTime', False) and single_event_dict['end'].get('dateTime', False):
date = parser.parse(single_event_dict['start']['dateTime'])
stop = parser.parse(single_event_dict['end']['dateTime'])
date = str(date.astimezone(UTC))[:-6]
stop = str(stop.astimezone(UTC))[:-6]
allday = False
else:
date = single_event_dict['start']['date']
stop = single_event_dict['end']['date']
d_end = fields.Date.from_string(stop)
allday = True
d_end = d_end + timedelta(days=-1)
stop = fields.Date.to_string(d_end)
update_date = datetime.strptime(single_event_dict['updated'], "%Y-%m-%dT%H:%M:%S.%fz")
result.update({
'start': date,
'stop': stop,
'allday': allday
})
result.update({
'attendee_ids': attendee_record,
'partner_ids': list(set(partner_record)),
'alarm_ids': [(6, 0, list(alarm_record))],
'name': single_event_dict.get('summary', 'Event'),
'description': single_event_dict.get('description', False),
'location': single_event_dict.get('location', False),
'privacy': single_event_dict.get('visibility', 'public'),
'oe_update_date': update_date,
})
if single_event_dict.get("recurrence", False):
rrule = [rule for rule in single_event_dict["recurrence"] if rule.startswith("RRULE:")][0][6:]
result['rrule'] = rrule
if type == "write":
res = CalendarEvent.browse(event['id']).write(result)
elif type == "copy":
result['recurrence'] = True
res = CalendarEvent.browse([event['id']]).write(result)
elif type == "create":
res = CalendarEvent.create(result).id
if self.env.context.get('curr_attendee'):
self.env['calendar.attendee'].with_context(no_mail_to_attendees=True).browse([self.env.context['curr_attendee']]).write({'oe_synchro_date': update_date, 'google_internal_event_id': single_event_dict.get('id', False)})
return res
def remove_references(self):
current_user = self.env.user
reset_data = {
'google_calendar_rtoken': False,
'google_calendar_token': False,
'google_calendar_token_validity': False,
'google_calendar_last_sync_date': False,
'google_calendar_cal_id': False,
}
all_my_attendees = self.env['calendar.attendee'].search([('partner_id', '=', current_user.partner_id.id)])
all_my_attendees.write({'oe_synchro_date': False, 'google_internal_event_id': False})
return current_user.write(reset_data)
@api.model
def synchronize_events_cron(self):
""" Call by the cron. """
users = self.env['res.users'].search([('google_calendar_last_sync_date', '!=', False)])
_logger.info("Calendar Synchro - Started by cron")
for user_to_sync in users.ids:
_logger.info("Calendar Synchro - Starting synchronization for a new user [%s]", user_to_sync)
try:
resp = self.sudo(user_to_sync).synchronize_events(lastSync=True)
if resp.get("status") == "need_reset":
_logger.info("[%s] Calendar Synchro - Failed - NEED RESET !", user_to_sync)
else:
_logger.info("[%s] Calendar Synchro - Done with status : %s !", user_to_sync, resp.get("status"))
except Exception, e:
_logger.info("[%s] Calendar Synchro - Exception : %s !", user_to_sync, exception_to_unicode(e))
_logger.info("Calendar Synchro - Ended by cron")
def synchronize_events(self, lastSync=True):
""" This method should be called as the user to sync. """
user_to_sync = self.ids and self.ids[0] or self.env.uid
current_user = self.env['res.users'].sudo().browse(user_to_sync)
recs = self.sudo(user_to_sync)
status, current_google, ask_time = recs.get_calendar_primary_id()
if current_user.google_calendar_cal_id:
if current_google != current_user.google_calendar_cal_id:
return {
"status": "need_reset",
"info": {
"old_name": current_user.google_calendar_cal_id,
"new_name": current_google
},
"url": ''
}
if lastSync and recs.get_last_sync_date() and not recs.get_disable_since_synchro():
lastSync = recs.get_last_sync_date()
_logger.info("[%s] Calendar Synchro - MODE SINCE_MODIFIED : %s !", user_to_sync, fields.Datetime.to_string(lastSync))
else:
lastSync = False
_logger.info("[%s] Calendar Synchro - MODE FULL SYNCHRO FORCED", user_to_sync)
else:
current_user.write({'google_calendar_cal_id': current_google})
lastSync = False
_logger.info("[%s] Calendar Synchro - MODE FULL SYNCHRO - NEW CAL ID", user_to_sync)
new_ids = []
new_ids += recs.create_new_events()
new_ids += recs.bind_recurring_events_to_google()
res = recs.update_events(lastSync)
current_user.write({'google_calendar_last_sync_date': ask_time})
return {
"status": res and "need_refresh" or "no_new_event_from_google",
"url": ''
}
def create_new_events(self):
""" Create event in google calendar for the event not already
synchronized, for the current user.
:returns list of new created event identifier in google calendar
"""
new_ids = []
my_partner_id = self.env.user.partner_id.id
my_attendees = self.env['calendar.attendee'].with_context(virtual_id=False).search([('partner_id', '=', my_partner_id),
('google_internal_event_id', '=', False),
'|',
('event_id.stop', '>', fields.Datetime.to_string(self.get_minTime())),
('event_id.final_date', '>', fields.Datetime.to_string(self.get_minTime())),
])
for att in my_attendees:
other_google_ids = [other_att.google_internal_event_id for other_att in att.event_id.attendee_ids if other_att.google_internal_event_id and other_att.id != att.id]
for other_google_id in other_google_ids:
if self.get_one_event_synchro(other_google_id):
att.write({'google_internal_event_id': other_google_id})
break
else:
if not att.event_id.recurrent_id or att.event_id.recurrent_id == 0:
status, response, ask_time = self.create_an_event(att.event_id)
if status_response(status):
update_date = datetime.strptime(response['updated'], "%Y-%m-%dT%H:%M:%S.%fz")
att.event_id.write({'oe_update_date': update_date})
new_ids.append(response['id'])
att.write({'google_internal_event_id': response['id'], 'oe_synchro_date': update_date})
self.env.cr.commit()
else:
_logger.warning("Impossible to create event %s. [%s] Enable DEBUG for response detail.", att.event_id.id, status)
_logger.debug("Response : %s", response)
return new_ids
def get_context_no_virtual(self):
""" get the current context modified to prevent virtual ids and active test. """
return dict(self.env.context, virtual_id=False, active_test=False)
def bind_recurring_events_to_google(self):
new_ids = []
CalendarAttendee = self.env['calendar.attendee']
my_partner_id = self.env.user.partner_id.id
context_norecurrent = self.get_context_no_virtual()
my_attendees = CalendarAttendee.with_context(context_norecurrent).search([('partner_id', '=', my_partner_id), ('google_internal_event_id', '=', False)])
for att in my_attendees:
new_google_internal_event_id = False
source_event_record = self.env['calendar.event'].browse(att.event_id.recurrent_id)
source_attendee_record = CalendarAttendee.search([('partner_id', '=', my_partner_id), ('event_id', '=', source_event_record.id)], limit=1)
if not source_attendee_record:
continue
if att.event_id.recurrent_id_date and source_event_record.allday and source_attendee_record.google_internal_event_id:
new_google_internal_event_id = source_attendee_record.google_internal_event_id + '_' + att.event_id.recurrent_id_date.split(' ')[0].replace('-', '')
elif att.event_id.recurrent_id_date and source_attendee_record.google_internal_event_id:
new_google_internal_event_id = source_attendee_record.google_internal_event_id + '_' + att.event_id.recurrent_id_date.replace('-', '').replace(' ', 'T').replace(':', '') + 'Z'
if new_google_internal_event_id:
#TODO WARNING, NEED TO CHECK THAT EVENT and ALL instance NOT DELETE IN GMAIL BEFORE !
try:
status, response, ask_time = self.update_recurrent_event_exclu(new_google_internal_event_id, source_attendee_record.google_internal_event_id, att.event_id)
if status_response(status):
att.write({'google_internal_event_id': new_google_internal_event_id})
new_ids.append(new_google_internal_event_id)
self.env.cr.commit()
else:
_logger.warning("Impossible to create event %s. [%s]", att.event_id.id, status)
_logger.debug("Response : %s", response)
except:
pass
return new_ids
def update_events(self, lastSync=False):
""" Synchronze events with google calendar : fetching, creating, updating, deleting, ... """
CalendarEvent = self.env['calendar.event']
CalendarAttendee = self.env['calendar.attendee']
my_partner_id = self.env.user.partner_id.id
context_novirtual = self.get_context_no_virtual()
if lastSync:
try:
all_event_from_google = self.get_event_synchro_dict(lastSync=lastSync)
except urllib2.HTTPError, e:
if e.code == 410: # GONE, Google is lost.
# we need to force the rollback from this cursor, because it locks my res_users but I need to write in this tuple before to raise.
self.env.cr.rollback()
self.env.user.write({'google_calendar_last_sync_date': False})
self.env.cr.commit()
error_key = json.loads(str(e))
error_key = error_key.get('error', {}).get('message', 'nc')
error_msg = _("Google is lost... the next synchro will be a full synchro. \n\n %s") % error_key
raise self.env['res.config.settings'].get_config_warning(error_msg)
my_google_attendees = CalendarAttendee.with_context(context_novirtual).search([
('partner_id', '=', my_partner_id),
('google_internal_event_id', 'in', all_event_from_google.keys())
])
my_google_att_ids = my_google_attendees.ids
my_odoo_attendees = CalendarAttendee.with_context(context_novirtual).search([
('partner_id', '=', my_partner_id),
('event_id.oe_update_date', '>', lastSync and fields.Datetime.to_string(lastSync) or self.get_minTime().fields.Datetime.to_string()),
('google_internal_event_id', '!=', False),
])
my_odoo_googleinternal_records = my_odoo_attendees.read(['google_internal_event_id', 'event_id'])
if self.get_print_log():
_logger.info("Calendar Synchro - \n\nUPDATE IN GOOGLE\n%s\n\nRETRIEVE FROM OE\n%s\n\nUPDATE IN OE\n%s\n\nRETRIEVE FROM GG\n%s\n\n", all_event_from_google, my_google_att_ids, my_odoo_attendees.ids, my_odoo_googleinternal_records)
for gi_record in my_odoo_googleinternal_records:
active = True # if not sure, we request google
if gi_record.get('event_id'):
active = CalendarEvent.with_context(context_novirtual).browse(int(gi_record.get('event_id')[0])).active
if gi_record.get('google_internal_event_id') and not all_event_from_google.get(gi_record.get('google_internal_event_id')) and active:
one_event = self.get_one_event_synchro(gi_record.get('google_internal_event_id'))
if one_event:
all_event_from_google[one_event['id']] = one_event
my_attendees = (my_google_attendees | my_odoo_attendees)
else:
domain = [
('partner_id', '=', my_partner_id),
('google_internal_event_id', '!=', False),
'|',
('event_id.stop', '>', fields.Datetime.to_string(self.get_minTime())),
('event_id.final_date', '>', fields.Datetime.to_string(self.get_minTime())),
]
# Select all events from Odoo which have been already synchronized in gmail
my_attendees = CalendarAttendee.with_context(context_novirtual).search(domain)
all_event_from_google = self.get_event_synchro_dict(lastSync=False)
event_to_synchronize = {}
for att in my_attendees:
event = att.event_id
base_event_id = att.google_internal_event_id.rsplit('_', 1)[0]
if base_event_id not in event_to_synchronize:
event_to_synchronize[base_event_id] = {}
if att.google_internal_event_id not in event_to_synchronize[base_event_id]:
event_to_synchronize[base_event_id][att.google_internal_event_id] = SyncEvent()
ev_to_sync = event_to_synchronize[base_event_id][att.google_internal_event_id]
ev_to_sync.OE.attendee_id = att.id
ev_to_sync.OE.event = event
ev_to_sync.OE.found = True
ev_to_sync.OE.event_id = event.id
ev_to_sync.OE.isRecurrence = event.recurrency
ev_to_sync.OE.isInstance = bool(event.recurrent_id and event.recurrent_id > 0)
ev_to_sync.OE.update = event.oe_update_date
ev_to_sync.OE.status = event.active
ev_to_sync.OE.synchro = att.oe_synchro_date
for event in all_event_from_google.values():
event_id = event.get('id')
base_event_id = event_id.rsplit('_', 1)[0]
if base_event_id not in event_to_synchronize:
event_to_synchronize[base_event_id] = {}
if event_id not in event_to_synchronize[base_event_id]:
event_to_synchronize[base_event_id][event_id] = SyncEvent()
ev_to_sync = event_to_synchronize[base_event_id][event_id]
ev_to_sync.GG.event = event
ev_to_sync.GG.found = True
ev_to_sync.GG.isRecurrence = bool(event.get('recurrence', ''))
ev_to_sync.GG.isInstance = bool(event.get('recurringEventId', 0))
ev_to_sync.GG.update = event.get('updated', None) # if deleted, no date without browse event
if ev_to_sync.GG.update:
ev_to_sync.GG.update = ev_to_sync.GG.update.replace('T', ' ').replace('Z', '')
ev_to_sync.GG.status = (event.get('status') != 'cancelled')
######################
# PRE-PROCESSING #
######################
for base_event in event_to_synchronize:
for current_event in event_to_synchronize[base_event]:
event_to_synchronize[base_event][current_event].compute_OP(modeFull=not lastSync)
if self.get_print_log():
if not isinstance(event_to_synchronize[base_event][current_event].OP, NothingToDo):
_logger.info(event_to_synchronize[base_event])
######################
# DO ACTION #
######################
for base_event in event_to_synchronize:
event_to_synchronize[base_event] = sorted(event_to_synchronize[base_event].iteritems(), key=operator.itemgetter(0))
for current_event in event_to_synchronize[base_event]:
self.env.cr.commit()
event = current_event[1] # event is an Sync Event !
actToDo = event.OP
actSrc = event.OP.src
recs = self.with_context(curr_attendee=event.OE.attendee_id)
if isinstance(actToDo, NothingToDo):
continue
elif isinstance(actToDo, Create):
context_tmp = {'newMeeting': True}
if actSrc == 'GG':
res = recs.with_context(context_tmp).update_from_google(False, event.GG.event, "create")
event.OE.event_id = res
meeting = CalendarEvent.browse(res)
attendee_records = CalendarAttendee.search([('partner_id', '=', my_partner_id), ('event_id', '=', res)])
attendee_records.with_context(context_tmp).write({'oe_synchro_date': meeting.oe_update_date, 'google_internal_event_id': event.GG.event['id']})
elif actSrc == 'OE':
raise "Should be never here, creation for OE is done before update !"
#TODO Add to batch
elif isinstance(actToDo, Update):
if actSrc == 'GG':
recs.update_from_google(event.OE.event, event.GG.event, 'write')
elif actSrc == 'OE':
recs.update_to_google(event.OE.event, event.GG.event)
elif isinstance(actToDo, Exclude):
if actSrc == 'OE':
recs.delete_an_event(current_event[0])
elif actSrc == 'GG':
new_google_event_id = event.GG.event['id'].rsplit('_', 1)[1]
if 'T' in new_google_event_id:
new_google_event_id = new_google_event_id.replace('T', '')[:-1]
else:
new_google_event_id = new_google_event_id + "000000"
if event.GG.status:
parent_event = {}
if not event_to_synchronize[base_event][0][1].OE.event_id:
main_ev = CalendarAttendee.with_context(context_novirtual).search([('google_internal_event_id', '=', event.GG.event['id'].rsplit('_', 1)[0])], limit=1)
event_to_synchronize[base_event][0][1].OE.event_id = main_ev.event_id.id
parent_event['id'] = "%s-%s" % (event_to_synchronize[base_event][0][1].OE.event_id, new_google_event_id)
res = recs.update_from_google(parent_event, event.GG.event, "copy")
else:
parent_oe_id = event_to_synchronize[base_event][0][1].OE.event_id
if parent_oe_id:
CalendarEvent.browse("%s-%s" % (parent_oe_id, new_google_event_id)).with_context(curr_attendee=event.OE.attendee_id).unlink(can_be_deleted=True)
elif isinstance(actToDo, Delete):
if actSrc == 'GG':
try:
# if already deleted from gmail or never created
recs.delete_an_event(current_event[0])
except Exception, e:
if e.code in (401, 410,):
pass
else:
raise e
elif actSrc == 'OE':
CalendarEvent.browse(event.OE.event_id).unlink(can_be_deleted=False)
return True
def check_and_sync(self, oe_event, google_event):
if datetime.strptime(oe_event.oe_update_date, "%Y-%m-%d %H:%M:%S.%f") > datetime.strptime(google_event['updated'], "%Y-%m-%dT%H:%M:%S.%fz"):
self.update_to_google(oe_event, google_event)
elif datetime.strptime(oe_event.oe_update_date, "%Y-%m-%d %H:%M:%S.%f") < datetime.strptime(google_event['updated'], "%Y-%m-%dT%H:%M:%S.%fz"):
self.update_from_google(oe_event, google_event, 'write')
def get_sequence(self, instance_id):
params = {
'fields': 'sequence',
'access_token': self.get_token()
}
headers = {'Content-type': 'application/json'}
url = "/calendar/v3/calendars/%s/events/%s" % ('primary', instance_id)
status, content, ask_time = self.env['google.service']._do_request(url, params, headers, type='GET')
return content.get('sequence', 0)
#################################
## MANAGE CONNEXION TO GMAIL ##
#################################
def get_token(self):
current_user = self.env.user
if not current_user.google_calendar_token_validity or \
fields.Datetime.from_string(current_user.google_calendar_token_validity.split('.')[0]) < (datetime.now() + timedelta(minutes=1)):
self.do_refresh_token()
current_user.refresh()
return current_user.google_calendar_token
def get_last_sync_date(self):
current_user = self.env.user
return current_user.google_calendar_last_sync_date and fields.Datetime.from_string(current_user.google_calendar_last_sync_date) + timedelta(minutes=0) or False
def do_refresh_token(self):
current_user = self.env.user
all_token = self.env['google.service']._refresh_google_token_json(current_user.google_calendar_rtoken, self.STR_SERVICE)
vals = {}
vals['google_%s_token_validity' % self.STR_SERVICE] = datetime.now() + timedelta(seconds=all_token.get('expires_in'))
vals['google_%s_token' % self.STR_SERVICE] = all_token.get('access_token')
self.env.user.sudo().write(vals)
def need_authorize(self):
current_user = self.env.user
return current_user.google_calendar_rtoken is False
def get_calendar_scope(self, RO=False):
readonly = '.readonly' if RO else ''
return 'https://www.googleapis.com/auth/calendar%s' % (readonly)
def authorize_google_uri(self, from_url='http://www.odoo.com'):
url = self.env['google.service']._get_authorize_uri(from_url, self.STR_SERVICE, scope=self.get_calendar_scope())
return url
def can_authorize_google(self):
return self.env['res.users'].has_group('base.group_erp_manager')
@api.model
def set_all_tokens(self, authorization_code):
all_token = self.env['google.service']._get_google_token_json(authorization_code, self.STR_SERVICE)
vals = {}
vals['google_%s_rtoken' % self.STR_SERVICE] = all_token.get('refresh_token')
vals['google_%s_token_validity' % self.STR_SERVICE] = datetime.now() + timedelta(seconds=all_token.get('expires_in'))
vals['google_%s_token' % self.STR_SERVICE] = all_token.get('access_token')
self.env.user.sudo().write(vals)
def get_minTime(self):
number_of_week = self.env['ir.config_parameter'].get_param('calendar.week_synchro', default=13)
return datetime.now() - timedelta(weeks=number_of_week)
def get_need_synchro_attendee(self):
return self.env['ir.config_parameter'].get_param('calendar.block_synchro_attendee', default=True)
def get_disable_since_synchro(self):
return self.env['ir.config_parameter'].get_param('calendar.block_since_synchro', default=False)
def get_print_log(self):
return self.env['ir.config_parameter'].get_param('calendar.debug_print', default=False)
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class CalendarSettings(models.TransientModel):
_inherit = 'base.config.settings'
google_cal_sync = fields.Boolean("Show Tutorial")
cal_client_id = fields.Char("Client_id")
cal_client_secret = fields.Char("Client_key")
server_uri = fields.Char('URI for tuto')
def set_calset(self):
self.env['ir.config_parameter'].set_param('google_calendar_client_id', (self.cal_client_id or '').strip(), groups=['base.group_system'])
self.env['ir.config_parameter'].set_param('google_calendar_client_secret', (self.cal_client_secret or '').strip(), groups=['base.group_system'])
def get_default_all(self, fields):
cal_client_id = self.env['ir.config_parameter'].get_param('google_calendar_client_id', default='')
cal_client_secret = self.env['ir.config_parameter'].get_param('google_calendar_client_secret', default='')
server_uri = "%s/google_account/authentication" % self.env['ir.config_parameter'].get_param('web.base.url', default="http://yourcompany.odoo.com")
return dict(cal_client_id=cal_client_id, cal_client_secret=cal_client_secret, server_uri=server_uri)
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class User(models.Model):
_inherit = 'res.users'
google_calendar_rtoken = fields.Char('Refresh Token', copy=False)
google_calendar_token = fields.Char('User token', copy=False)
google_calendar_token_validity = fields.Datetime('Token Validity', copy=False)
google_calendar_last_sync_date = fields.Datetime('Last synchro date', copy=False)
google_calendar_cal_id = fields.Char('Calendar ID', copy=False, help='Last Calendar ID who has been synchronized. If it is changed, we remove all links between GoogleID and Odoo Google Internal ID')
-29
View File
@@ -1,29 +0,0 @@
from openerp.osv import fields, osv
class calendar_config_settings(osv.TransientModel):
_inherit = 'base.config.settings'
_columns = {
'google_cal_sync': fields.boolean("Show Tutorial"),
'cal_client_id': fields.char("Client_id"),
'cal_client_secret': fields.char("Client_key"),
'server_uri': fields.char('URI for tuto')
}
def set_calset(self, cr, uid, ids, context=None) :
params = self.pool['ir.config_parameter']
myself = self.browse(cr, uid, ids[0], context=context)
params.set_param(cr, uid, 'google_calendar_client_id', (myself.cal_client_id or '').strip(), groups=['base.group_system'], context=None)
params.set_param(cr, uid, 'google_calendar_client_secret', (myself.cal_client_secret or '').strip(), groups=['base.group_system'], context=None)
def get_default_all(self, cr, uid, fields, context=None):
params = self.pool.get('ir.config_parameter')
cal_client_id = params.get_param(cr, uid, 'google_calendar_client_id',default='',context=context)
cal_client_secret = params.get_param(cr, uid, 'google_calendar_client_secret',default='',context=context)
server_uri= "%s/google_account/authentication" % params.get_param(cr, uid, 'web.base.url',default="http://yourcompany.odoo.com",context=context)
return dict(cal_client_id=cal_client_id,cal_client_secret=cal_client_secret,server_uri=server_uri)
@@ -3,7 +3,7 @@
<template id="assets_backend" name="google_calendar assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/google_calendar/static/src/less/google_calendar.less"/>
<script type="text/javascript" src="/google_calendar/static/src/js/calendar_sync.js"></script>
<script type="text/javascript" src="/google_calendar/static/src/js/google_calendar.js"></script>
</xpath>
</template>
</odoo>
@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<record id="view_calendar_config_settings" model="ir.ui.view">
<odoo>
<record id="view_calendar_config_settings" model="ir.ui.view">
<field name="name">Calendar_settings_grgrgrgt</field>
<field name="model">base.config.settings</field>
<field name="inherit_id" ref="base_setup.view_general_configuration"/>
@@ -12,9 +11,9 @@
<field name="google_cal_sync"/>
<div attrs="{'invisible':[('google_cal_sync','=',False)]}">
<br/><h2>To setup the signin process with Google, first you have to perform the following steps</h2>
<ul>
<ul>
<li> Connect on your google account and go to <a href='https://console.developers.google.com/' target='_blank'>https://console.developers.google.com/</a> </li>
<li>
<li>
Click on <b>'Create a project...'</b> and enter a project name and change your id if you want. Don't forget to accept the Terms of Services
<br/><br/><img src='/google_calendar/static/src/img/setup_01.png' class='calendar_img_tuto'/>
</li>
@@ -24,8 +23,8 @@
<br/><br/> <img src='/google_calendar/static/src/img/setup_03.png' class='calendar_img_tuto'/>
<br/> <img src='/google_calendar/static/src/img/setup_04.png' class='calendar_img_tuto'/>
<br/> <img src='/google_calendar/static/src/img/setup_05.png' class='calendar_img_tuto'/>
</li>
<li>
</li>
<li>
In the menu on left side, select the sub menu <b>'Credentials'</b> (from menu APIs and auth) and click on button <b>'Create new Client ID'</b>
<br/><br/> <img src='/google_calendar/static/src/img/setup_06.png' class='calendar_img_tuto'/>
</li>
@@ -42,13 +41,13 @@
<li>Once done, you will have the both informations (<b>Client ID</b> and <b>Client Secret</b>) that you need to insert in the 2 fields below!
<br/><br/> <img src='/google_calendar/static/src/img/setup_08.png' class='calendar_img_tuto'/>
</li>
<a href="#" class="oe_link">Return at Top</a>
<a href="#" class="oe_link">Return at Top</a>
</ul>
</div>
<div>
<label for="cal_client_id" string="Google Client ID"/>
<field name="cal_client_id" nolabel="1" class="oe_inline"/>
<field name="cal_client_id" nolabel="1" class="oe_inline"/>
</div>
<div>
<label for="cal_client_secret" string="Google Client Secret"/>
@@ -67,6 +66,10 @@
<field name="target">inline</field>
</record>
<menuitem id="menu_calendar_google_tech_config" name="API Credentials" parent="calendar.menu_calendar_configuration" groups="base.group_no_one" action="action_config_settings_google_calendar"/>
</data>
</openerp>
<menuitem id="menu_calendar_google_tech_config"
name="API Credentials"
parent="calendar.menu_calendar_configuration"
groups="base.group_no_one"
action="action_config_settings_google_calendar"/>
</odoo>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<odoo>
<record id="view_users_form" model="ir.ui.view">
<field name="name">res.users.form</field>
<field name="model">res.users</field>
@@ -19,6 +19,6 @@
</notebook>
</field>
</record>
</data>
</openerp>
</odoo>