diff --git a/__manifest__.py b/__manifest__.py index 5d837254cedf7afd5e357b53c5e0667838b3b919..ef49febc490c3e3b148d043434252aa4bdb9a2cd 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -9,17 +9,11 @@ "calendar", ], "data": [ - # "security/ir.model.access.csv", # datas "data/cron_data.xml", # views "views/res_company_views.xml", "views/res_users_views.xml", - # views menu - # wizard - ], - "qweb": [ - # "static/src/xml/*.xml", ], "installable": True, "auto_install": False, diff --git a/models/calendar_event.py b/models/calendar_event.py index 8c2354ceee6c1ab9849db97a96749608f84011f9..46220a1a955976b44f5b0140050cc0c03a451bd0 100644 --- a/models/calendar_event.py +++ b/models/calendar_event.py @@ -50,10 +50,20 @@ PRIVACY_CONVERTER_O2B = { class CalendarEvent(models.Model): _inherit = "calendar.event" + """ + This inheriting class adds 1 field to calendar.event table : + * bluemind_id = Unique identifier of event in Bluemind + It also adds a number of methods to transform events between Bluemind and Odoo formats + """ + bluemind_id = fields.Char("Bluemind Event ID") def _bm_to_odoo_values(self, bm_event): - # General fields + """ + This method converts a bluemind event (given as a parameter) into + a dict that can later be used to create/update an event in Odoo + """ + # Generic fields data = { "name": bm_event.value.main.summary, "bluemind_id": bm_event.uid, @@ -67,7 +77,7 @@ class CalendarEvent(models.Model): "create_uid": self.env.user.id, } - # Dates handling + # Dates handling, with timezones if bm_event.value.main.dtstart.precision.value == "Date": start = bm_event.value.main.dtstart.iso8601 stop = bm_event.value.main.dtend.iso8601 @@ -122,9 +132,15 @@ class CalendarEvent(models.Model): rrule.byDay[0].day.lower(): True, } ) + # Returns data dictionary return data def _odoo_to_bm_values(self, event): + """ + This method converts an Odoo event (given as a parameter) into + a Bluemind event object that can then be used to create/update an event in Bluemind + """ + # Event object initialization bm_event = VEventSeries() bm_event.main = VEvent() bm_event.main.classification = ICalendarElementClassification( @@ -143,12 +159,14 @@ class CalendarEvent(models.Model): bm_event.main.categories = [] bm_event.main.attachments = [] + # Specific case for full day events if event.allday: bm_event.main.dtstart.iso8601 = event.start_date.isoformat() bm_event.main.dtstart.precision = BmDateTimePrecision("Date") stop_date = event.stop_date + relativedelta(days=1) bm_event.main.dtend.iso8601 = stop_date.isoformat() bm_event.main.dtend.precision = BmDateTimePrecision("Date") + # All other cases else: tz = timezone(self.env.context.get("tz")) bm_event.main.dtstart.iso8601 = event.start.astimezone(tz).isoformat( @@ -207,24 +225,45 @@ class CalendarEvent(models.Model): return uid.decode() def update_odoo_event_from_bm(self, odoo_event, bm_event): + """ + Update an Odoo event (odoo_event) with fields from a Bluemind event (bm_event) + """ new_odoo_fields = self._bm_to_odoo_values(bm_event) odoo_event.write(new_odoo_fields) # Take caution about attendees (only one calendar event in Odoo for all attendees, # when in Bluemind you get a different one per attendee) def write(self, values, from_bluemind=False): - # For now this function pushes again the full event to bluemind, + """ + Overwrite of write() default method to update Bluemind event everytime + an event is updated in Odoo + A parameter is added "from_bluemind" to avoid updating a Bluemind event + when change is already coming from Bluemind + """ + # Calls base write() function first result = super().write(values) + + # AFAICT Bluemind needs all the fields to be provided, + # you cannot send only the updated ones if result and not from_bluemind: + # Create a list of Bluemind events to be updated bm_events = VEventChanges() bm_events.modify = [] + # Only update events with a bluemind_id for event in self.filtered("bluemind_id"): if event.bluemind_id: + # Create a Bluemind event object and fills it with values + # from Odoo event event_change = VEventChangesItemModify() event_change.uid = event.bluemind_id event_change.value = self._odoo_to_bm_values(event) + # Avoids sending notification from Bluemind + # (since assumed already sent from Odoo) event_change.sendNotification = False + # Add Bluemind event to list bm_events.modify.append(event_change) + # If list of Bluemind events to be updated is not empty, + # update these events in Bluemind if bm_events.modify: try: bm_calendar = self.env.user.bluemind_auth().calendar( @@ -241,17 +280,31 @@ class CalendarEvent(models.Model): @api.model_create_multi def create(self, vals_list): + """ + Overwrite of create() default method to create Bluemind event everytime + an event is created in Odoo + """ + # Calls base create() function first odoo_events = super().create(vals_list) + # Create a list of Bluemind events to be created bm_events = VEventChanges() bm_events.add = [] for odoo_event in odoo_events: if not odoo_event.bluemind_id: + # Create a Bluemind event object and fills it with values + # from Odoo event event_add = VEventChangesItemAdd() event_add.uid = self.generate_uid_bluemind() event_add.value = self._odoo_to_bm_values(odoo_event) + # Avoids sending notification from Bluemind + # (since assumed already sent from Odoo) event_add.sendNotification = False + # Add Bluemind event to list bm_events.add.append(event_add) + # Set bluemind_id on Odoo event (without pushing to Bluemind) odoo_event.write({"bluemind_id": event_add.uid}, from_bluemind=True) + # If list of Bluemind events to be created is not empty, + # create these events in Bluemind if bm_events.add: try: bm_calendar = self.env.user.bluemind_auth().calendar( @@ -267,9 +320,15 @@ class CalendarEvent(models.Model): return odoo_events def unlink(self, from_bluemind=False): - """Unlink override, deletes an event from Odoo to BlueMind""" + """ + Overwrite of unlink() default method to delete Bluemind event everytime + an event is deleted in Odoo + A parameter is added "from_bluemind" to avoid deleting a Bluemind event + when deletion is already coming from Bluemind + """ if not from_bluemind: for event in self: + # If Odoo event is linked to a Bluemind event if event.bluemind_id: bm_calendar = self.env.user.bluemind_auth().calendar( self.env.user.bluemind_calendar_id @@ -278,5 +337,8 @@ class CalendarEvent(models.Model): bm_calendar.delete(event.bluemind_id, False) except (ServerFault): # TODO: better manage exceptions + # If deletion fails, keep event in Odoo but archive it event.active = False + # Calls base unlink() function last only on active events + # (to avoid deleting the Odoo event in case it was just archived) return super(CalendarEvent, self.filtered(lambda e: e.active is True)).unlink() diff --git a/models/res_company.py b/models/res_company.py index 1358a9ff9b187c729729ac565cb85f7e333909d5..8424464135f0fbf36e8666d5bfe8043597ead854 100644 --- a/models/res_company.py +++ b/models/res_company.py @@ -1,11 +1,16 @@ # Copyright 2021 Le Filament (https://le-filament.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) -from odoo import api, fields, models +from odoo import fields, models class ResCompany(models.Model): - _inherit = 'res.company' - + _inherit = "res.company" + """ + This inheriting class adds 2 fields on company : + * bluemind_url used to contact Bluemind server + * bluemind_domain e-mail domain (part after @ in any e-mail from the company) + This domain is needed to retrieve correct objects from Bluemind + """ bluemind_url = fields.Char(string="Bluemind URL") bluemind_domain = fields.Char(string="Bluemind Domain") diff --git a/models/res_users.py b/models/res_users.py index 7d12c2c7a9eff51bf683b5f1e558b706b6c9bfbf..bed8e98b97ac534b87eca086d09d333b48fa0d7b 100644 --- a/models/res_users.py +++ b/models/res_users.py @@ -15,6 +15,16 @@ _logger = logging.getLogger(__name__) class ResUser(models.Model): _inherit = "res.users" + """ + This inheriting class adds some fields and methods to res.users in order to store + information for connecting user to Bluemind : + * bluemind_login / bluemind_password + * is_bm_connection_ok : boolean set when connection was successful + * bluemind_user_id : to store the identifier of the user in Bluemind + * bluemind_calendar_id : to store the main calendar id linked to user + * last_sync_version : last version of calendar synced + (Bluemind increments a versionning sequence at each change on calendar) + """ bluemind_login = fields.Char("Bluemind e-mail") bluemind_password = fields.Char("Bluemind password or API key") is_bm_connection_ok = fields.Boolean("Bluemind Connection OK", default=False) @@ -26,9 +36,18 @@ class ResUser(models.Model): ) last_sync_version = fields.Integer("Last sync version", readonly=True, default=0) - # Authentication to Bluemind @api.depends("company_id.bluemind_url", "bluemind_login", "bluemind_password") def bluemind_auth(self, force=False): + """ + This method connect to Bluemind API and returns the client object + This authentication is only performed if all the above conditions are met: + * bluemind_url is set on company + * bluemind_login and bluemind_password are set on user + * connection was already successful with provided creds or force + parameter is given (allowing to make first auth) - this last condition + is here to avoid locking the account in case the creds are incorrect, + since this method is used in most other methods to retrieve client object + """ self.ensure_one() if ( self.company_id.bluemind_url @@ -54,11 +73,18 @@ class ResUser(models.Model): return False def force_auth(self): - # TODO : add notification that it works or not ! + """ + This methods force authentication (or re-auth) mechanism with creds stored on user + It is called from button on user view + """ + # TODO : add notification that it works or not return self.bluemind_auth(force=True) @api.depends("company_id.bluemind_domain", "bluemind_login", "bluemind_password") def _compute_bluemind_user_id(self): + """ + This method retrieves the user id from Bluemind client object + """ for user in self: if ( user.company_id.bluemind_url @@ -74,22 +100,39 @@ class ResUser(models.Model): @api.depends("bluemind_user_id") def _compute_bluemind_calendar_uid(self): + """ + This method computes default Bluemind calendar_id for the current user + by concatenating calendar:Default: and Bluemind user_id + """ for user in self: if user.bluemind_user_id: user.bluemind_calendar_id = "calendar:Default:" + user.bluemind_user_id def sync_bluemind_calendar(self): + """ + This methods retrieves last calendar changes from Bluemind + These charges are grouped in 3 objects : + * created : all events created since last sync + * updated : all events updated since last sync + * deleted : all events deleted since last sync + Then it creates/updates/deletes Odoo events accordingly + The parti pris here is the following : every event in Bluemind calendar + is recreated in Odoo with organizer = user + """ self.ensure_one() # TODO: add checks and error handling # Retrieve all events modified since last sync (followed by self.bluemind_id_version) bm_calendar = self.bluemind_auth().calendar(self.bluemind_calendar_id) # TODO : avoid retrieving all events from beginning, limit to 3 months in past ? + # The following function actually retrieves only Blemind event ids, not the full events bm_changeset = bm_calendar.changeset(self.last_sync_version) bm_created_uids = bm_changeset.created bm_updated_uids = bm_changeset.updated bm_deleted_uids = bm_changeset.deleted bm_last_version = bm_changeset.version + # Retrieve all events from Odoo organized by the current user + # and with a bluemind_id (= already synced at least once with Bluemind) Calendar = self.env["calendar.event"] odoo_events_bm_linked = Calendar.search( [("user_id", "=", self.id), ("bluemind_id", "!=", False)] @@ -97,24 +140,34 @@ class ResUser(models.Model): odoo_events_bm_uids = odoo_events_bm_linked.mapped("bluemind_id") # Calendar entries created on Bluemind side, not already in Odoo - # TODO: move events already in Odoo to updated section ? - # May need to distinguish between : first sync ever, new sync after unsync + # The events already in Odoo are ignored (probably already synced previously events_to_create = [] bm_events_to_create_uids = [ e for e in bm_created_uids if e not in odoo_events_bm_uids ] + # Retrieve remaining full events from Bluemind bm_events_to_create = bm_calendar.multipleGet(bm_events_to_create_uids) + # Create corresponding events in Odoo for bm_event in bm_events_to_create: events_to_create.append(Calendar._bm_to_odoo_values(bm_event)) Calendar.create(events_to_create) - # Calendar entries that have been modified on Bluemind + # Calendar entries that have been updated on Bluemind + # Retrieve full events from Bluemind bm_events_to_update = bm_calendar.multipleGet(bm_updated_uids) + # Update corresponding events for bm_event in bm_events_to_update: odoo_event = odoo_events_bm_linked.filtered( [("bluemind_id", "=", bm_event.uid)] ) - Calendar.update_odoo_event_from_bm(odoo_event, bm_event) + # If event exists in Odoo update it + if odoo_event: + Calendar.update_odoo_event_from_bm(odoo_event, bm_event) + # Otherwise log an error + else: + _logger.error( + "Event %s updated in Bluemind did not exist in Odoo", bm_event.uid + ) # Calendar entries that have been deleted on Bluemind to be deleted on Odoo odoo_events_to_delete = odoo_events_bm_linked.filtered( @@ -122,16 +175,22 @@ class ResUser(models.Model): ) odoo_events_to_delete.unlink(from_bluemind=True) + # Update user last_sync_version with the latest one retrieved from Bluemind self.last_sync_version = bm_last_version @api.model def _sync_all_bm_calendar(self): - """ Cron job """ + """ + Cron job to sync calendars for all users with is_bm_connection_ok + """ users = self.env["res.users"].search([("is_bm_connection_ok", "!=", False)]) for user in users: _logger.info("Calendar Synchro - Starting synchronization for %s", user) try: user.with_user(user).sudo().sync_bluemind_calendar() + _logger.info( + "Calendar Synchro - Completed synchronization for %s", user + ) except Exception as e: _logger.exception( "[%s] Calendar Synchro - Exception : %s !",