[MOV] calendar: Split models in their own file
Apply Odoo guidelines[1]. Each model should be in a different file. Task 2126717 PR #42031 PR Enterprise odoo/enterprise#8006 [1] https://www.odoo.com/documentation/13.0/reference/guidelines.html#file-naming
This commit is contained in:
@@ -5,6 +5,12 @@ from . import ir_attachment
|
||||
from . import ir_http
|
||||
from . import res_partner
|
||||
from . import mail_message
|
||||
from . import calendar
|
||||
from . import calendar_event
|
||||
from . import calendar_alarm
|
||||
from . import calendar_alarm_manager
|
||||
from . import calendar_attendee
|
||||
from . import calendar_contact
|
||||
from . import calendar_event_type
|
||||
from . import calendar_event
|
||||
from . import mail_activity
|
||||
from . import res_users
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Alarm(models.Model):
|
||||
_name = 'calendar.alarm'
|
||||
_description = 'Event Alarm'
|
||||
|
||||
@api.depends('interval', 'duration')
|
||||
def _compute_duration_minutes(self):
|
||||
for alarm in self:
|
||||
if alarm.interval == "minutes":
|
||||
alarm.duration_minutes = alarm.duration
|
||||
elif alarm.interval == "hours":
|
||||
alarm.duration_minutes = alarm.duration * 60
|
||||
elif alarm.interval == "days":
|
||||
alarm.duration_minutes = alarm.duration * 60 * 24
|
||||
else:
|
||||
alarm.duration_minutes = 0
|
||||
|
||||
_interval_selection = {'minutes': 'Minutes', 'hours': 'Hours', 'days': 'Days'}
|
||||
|
||||
name = fields.Char('Name', translate=True, required=True)
|
||||
alarm_type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], string='Type', required=True, default='email')
|
||||
duration = fields.Integer('Remind Before', required=True, default=1)
|
||||
interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours')
|
||||
duration_minutes = fields.Integer('Duration in minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes")
|
||||
|
||||
@api.onchange('duration', 'interval', 'alarm_type')
|
||||
def _onchange_duration_interval(self):
|
||||
display_interval = self._interval_selection.get(self.interval, '')
|
||||
display_alarm_type = {key: value for key, value in self._fields['alarm_type']._description_selection(self.env)}[self.alarm_type]
|
||||
self.name = "%s - %s %s" % (display_alarm_type, self.duration, display_interval)
|
||||
|
||||
def _update_cron(self):
|
||||
try:
|
||||
cron = self.env['ir.model.data'].sudo().get_object('calendar', 'ir_cron_scheduler_alarm')
|
||||
except ValueError:
|
||||
return False
|
||||
return cron.toggle(model=self._name, domain=[('alarm_type', '=', 'email')])
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
result = super(Alarm, self).create(values)
|
||||
self._update_cron()
|
||||
return result
|
||||
|
||||
def write(self, values):
|
||||
result = super(Alarm, self).write(values)
|
||||
self._update_cron()
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
result = super(Alarm, self).unlink()
|
||||
self._update_cron()
|
||||
return result
|
||||
@@ -0,0 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlarmManager(models.AbstractModel):
|
||||
|
||||
_name = 'calendar.alarm_manager'
|
||||
_description = 'Event Alarm Manager'
|
||||
|
||||
def _get_next_potential_limit_alarm(self, alarm_type, seconds=None, partner_id=None):
|
||||
result = {}
|
||||
delta_request = """
|
||||
SELECT
|
||||
rel.calendar_event_id, max(alarm.duration_minutes) AS max_delta,min(alarm.duration_minutes) AS min_delta
|
||||
FROM
|
||||
calendar_alarm_calendar_event_rel AS rel
|
||||
LEFT JOIN calendar_alarm AS alarm ON alarm.id = rel.calendar_alarm_id
|
||||
WHERE alarm.alarm_type = %s
|
||||
GROUP BY rel.calendar_event_id
|
||||
"""
|
||||
base_request = """
|
||||
SELECT
|
||||
cal.id,
|
||||
cal.start - interval '1' minute * calcul_delta.max_delta AS first_alarm,
|
||||
CASE
|
||||
WHEN cal.recurrency THEN cal.final_date - interval '1' minute * calcul_delta.min_delta
|
||||
ELSE cal.stop - interval '1' minute * calcul_delta.min_delta
|
||||
END as last_alarm,
|
||||
cal.start as first_event_date,
|
||||
CASE
|
||||
WHEN cal.recurrency THEN cal.final_date
|
||||
ELSE cal.stop
|
||||
END as last_event_date,
|
||||
calcul_delta.min_delta,
|
||||
calcul_delta.max_delta,
|
||||
cal.rrule AS rule
|
||||
FROM
|
||||
calendar_event AS cal
|
||||
RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
|
||||
"""
|
||||
|
||||
filter_user = """
|
||||
RIGHT JOIN calendar_event_res_partner_rel AS part_rel ON part_rel.calendar_event_id = cal.id
|
||||
AND part_rel.res_partner_id = %s
|
||||
"""
|
||||
|
||||
# Add filter on alarm type
|
||||
tuple_params = (alarm_type,)
|
||||
|
||||
# Add filter on partner_id
|
||||
if partner_id:
|
||||
base_request += filter_user
|
||||
tuple_params += (partner_id, )
|
||||
|
||||
# Upper bound on first_alarm of requested events
|
||||
first_alarm_max_value = ""
|
||||
if seconds is None:
|
||||
# first alarm in the future + 3 minutes if there is one, now otherwise
|
||||
first_alarm_max_value = """
|
||||
COALESCE((SELECT MIN(cal.start - interval '1' minute * calcul_delta.max_delta)
|
||||
FROM calendar_event cal
|
||||
RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
|
||||
WHERE cal.start - interval '1' minute * calcul_delta.max_delta > now() at time zone 'utc'
|
||||
) + interval '3' minute, now() at time zone 'utc')"""
|
||||
else:
|
||||
# now + given seconds
|
||||
first_alarm_max_value = "(now() at time zone 'utc' + interval '%s' second )"
|
||||
tuple_params += (seconds,)
|
||||
|
||||
self._cr.execute("""
|
||||
WITH calcul_delta AS (%s)
|
||||
SELECT *
|
||||
FROM ( %s WHERE cal.active = True ) AS ALL_EVENTS
|
||||
WHERE ALL_EVENTS.first_alarm < %s
|
||||
AND ALL_EVENTS.last_event_date > (now() at time zone 'utc')
|
||||
""" % (delta_request, base_request, first_alarm_max_value), tuple_params)
|
||||
|
||||
for event_id, first_alarm, last_alarm, first_meeting, last_meeting, min_duration, max_duration, rule in self._cr.fetchall():
|
||||
result[event_id] = {
|
||||
'event_id': event_id,
|
||||
'first_alarm': first_alarm,
|
||||
'last_alarm': last_alarm,
|
||||
'first_meeting': first_meeting,
|
||||
'last_meeting': last_meeting,
|
||||
'min_duration': min_duration,
|
||||
'max_duration': max_duration,
|
||||
'rrule': rule
|
||||
}
|
||||
|
||||
# determine accessible events
|
||||
events = self.env['calendar.event'].browse(result)
|
||||
result = {
|
||||
key: result[key]
|
||||
for key in set(events._filter_access_rules('read').ids)
|
||||
}
|
||||
return result
|
||||
|
||||
def do_check_alarm_for_one_date(self, one_date, event, event_maxdelta, in_the_next_X_seconds, alarm_type, after=False, missing=False):
|
||||
""" Search for some alarms in the interval of time determined by some parameters (after, in_the_next_X_seconds, ...)
|
||||
:param one_date: date of the event to check (not the same that in the event browse if recurrent)
|
||||
:param event: Event browse record
|
||||
:param event_maxdelta: biggest duration from alarms for this event
|
||||
:param in_the_next_X_seconds: looking in the future (in seconds)
|
||||
:param after: if not False: will return alert if after this date (date as string - todo: change in master)
|
||||
:param missing: if not False: will return alert even if we are too late
|
||||
:param notif: Looking for type notification
|
||||
:param mail: looking for type email
|
||||
"""
|
||||
result = []
|
||||
# TODO: remove event_maxdelta and if using it
|
||||
if one_date - timedelta(minutes=(missing and 0 or event_maxdelta)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds): # if an alarm is possible for this date
|
||||
for alarm in event.alarm_ids:
|
||||
if alarm.alarm_type == alarm_type and \
|
||||
one_date - timedelta(minutes=(missing and 0 or alarm.duration_minutes)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \
|
||||
(not after or one_date - timedelta(minutes=alarm.duration_minutes) > fields.Datetime.from_string(after)):
|
||||
alert = {
|
||||
'alarm_id': alarm.id,
|
||||
'event_id': event.id,
|
||||
'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
|
||||
}
|
||||
result.append(alert)
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def get_next_mail(self):
|
||||
last_notif_mail = fields.Datetime.to_string(self.env.context.get('lastcall') or fields.Datetime.now())
|
||||
|
||||
cron = self.env.ref('calendar.ir_cron_scheduler_alarm', raise_if_not_found=False)
|
||||
if not cron:
|
||||
_logger.error("Cron for " + self._name + " can not be identified !")
|
||||
return False
|
||||
|
||||
interval_to_second = {
|
||||
"weeks": 7 * 24 * 60 * 60,
|
||||
"days": 24 * 60 * 60,
|
||||
"hours": 60 * 60,
|
||||
"minutes": 60,
|
||||
"seconds": 1
|
||||
}
|
||||
|
||||
if cron.interval_type not in interval_to_second:
|
||||
_logger.error("Cron delay can not be computed !")
|
||||
return False
|
||||
|
||||
cron_interval = cron.interval_number * interval_to_second[cron.interval_type]
|
||||
|
||||
all_meetings = self._get_next_potential_limit_alarm('email', seconds=cron_interval)
|
||||
|
||||
for meeting in self.env['calendar.event'].browse(all_meetings):
|
||||
max_delta = all_meetings[meeting.id]['max_duration']
|
||||
|
||||
if meeting.recurrency:
|
||||
at_least_one = False
|
||||
last_found = False
|
||||
for one_date in meeting._get_recurrent_date_by_event():
|
||||
in_date_format = one_date.replace(tzinfo=None)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, 0, 'email', after=last_notif_mail, missing=True)
|
||||
for alert in last_found:
|
||||
self.do_mail_reminder(alert)
|
||||
at_least_one = True # if it's the first alarm for this recurrent event
|
||||
if at_least_one and not last_found: # if the precedent event had an alarm but not this one, we can stop the search for this event
|
||||
break
|
||||
else:
|
||||
in_date_format = meeting.start
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, 0, 'email', after=last_notif_mail, missing=True)
|
||||
for alert in last_found:
|
||||
self.do_mail_reminder(alert)
|
||||
|
||||
@api.model
|
||||
def get_next_notif(self):
|
||||
partner = self.env.user.partner_id
|
||||
all_notif = []
|
||||
|
||||
if not partner:
|
||||
return []
|
||||
|
||||
all_meetings = self._get_next_potential_limit_alarm('notification', partner_id=partner.id)
|
||||
time_limit = 3600 * 24 # return alarms of the next 24 hours
|
||||
for event_id in all_meetings:
|
||||
max_delta = all_meetings[event_id]['max_duration']
|
||||
meeting = self.env['calendar.event'].browse(event_id)
|
||||
if meeting.recurrency:
|
||||
b_found = False
|
||||
last_found = False
|
||||
for one_date in meeting._get_recurrent_date_by_event():
|
||||
in_date_format = one_date.replace(tzinfo=None)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, time_limit, 'notification', after=partner.calendar_last_notif_ack)
|
||||
if last_found:
|
||||
for alert in last_found:
|
||||
all_notif.append(self.do_notif_reminder(alert))
|
||||
if not b_found: # if it's the first alarm for this recurrent event
|
||||
b_found = True
|
||||
if b_found and not last_found: # if the precedent event had alarm but not this one, we can stop the search fot this event
|
||||
break
|
||||
else:
|
||||
in_date_format = fields.Datetime.from_string(meeting.start)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, time_limit, 'notification', after=partner.calendar_last_notif_ack)
|
||||
if last_found:
|
||||
for alert in last_found:
|
||||
all_notif.append(self.do_notif_reminder(alert))
|
||||
return all_notif
|
||||
|
||||
def do_mail_reminder(self, alert):
|
||||
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
||||
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
||||
|
||||
result = False
|
||||
if alarm.alarm_type == 'email':
|
||||
result = meeting.attendee_ids.filtered(lambda r: r.state != 'declined')._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True, force_event_id=meeting)
|
||||
return result
|
||||
|
||||
def do_notif_reminder(self, alert):
|
||||
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
||||
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
||||
|
||||
if alarm.alarm_type == 'notification':
|
||||
message = meeting.display_time
|
||||
|
||||
delta = alert['notify_at'] - datetime.datetime.now()
|
||||
delta = delta.seconds + delta.days * 3600 * 24
|
||||
|
||||
return {
|
||||
'alarm_id': alarm.id,
|
||||
'event_id': meeting.id,
|
||||
'title': meeting.name,
|
||||
'message': message,
|
||||
'timer': delta,
|
||||
'notify_at': fields.Datetime.to_string(alert['notify_at']),
|
||||
}
|
||||
|
||||
def _notify_next_alarm(self, partner_ids):
|
||||
""" Sends through the bus the next alarm of given partners """
|
||||
notifications = []
|
||||
users = self.env['res.users'].search([('partner_id', 'in', tuple(partner_ids))])
|
||||
for user in users:
|
||||
notif = self.with_user(user).get_next_notif()
|
||||
notifications.append([(self._cr.dbname, 'calendar.alarm', user.partner_id.id), notif])
|
||||
if len(notifications) > 0:
|
||||
self.env['bus.bus'].sendmany(notifications)
|
||||
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import uuid
|
||||
import base64
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class Attendee(models.Model):
|
||||
""" Calendar Attendee Information """
|
||||
|
||||
_name = 'calendar.attendee'
|
||||
_rec_name = 'common_name'
|
||||
_description = 'Calendar Attendee Information'
|
||||
|
||||
def _default_access_token(self):
|
||||
return uuid.uuid4().hex
|
||||
|
||||
STATE_SELECTION = [
|
||||
('needsAction', 'Needs Action'),
|
||||
('tentative', 'Uncertain'),
|
||||
('declined', 'Declined'),
|
||||
('accepted', 'Accepted'),
|
||||
]
|
||||
|
||||
state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction',
|
||||
help="Status of the attendee's participation")
|
||||
common_name = fields.Char('Common name', compute='_compute_common_name', store=True)
|
||||
partner_id = fields.Many2one('res.partner', 'Contact', readonly=True)
|
||||
email = fields.Char('Email', help="Email of Invited Person")
|
||||
availability = fields.Selection([('free', 'Free'), ('busy', 'Busy')], 'Free/Busy', readonly=True)
|
||||
access_token = fields.Char('Invitation Token', default=_default_access_token)
|
||||
event_id = fields.Many2one('calendar.event', 'Meeting linked', ondelete='cascade')
|
||||
|
||||
@api.depends('partner_id', 'partner_id.name', 'email')
|
||||
def _compute_common_name(self):
|
||||
for attendee in self:
|
||||
attendee.common_name = attendee.partner_id.name or attendee.email
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
""" Make entry on email and availability on change of partner_id field. """
|
||||
self.email = self.partner_id.email
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
if not values.get("email") and values.get("common_name"):
|
||||
common_nameval = values.get("common_name").split(':')
|
||||
email = [x for x in common_nameval if '@' in x] # TODO JEM : should be refactored
|
||||
values['email'] = email and email[0] or ''
|
||||
values['common_name'] = values.get("common_name")
|
||||
return super(Attendee, self).create(vals_list)
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
raise UserError(_('You cannot duplicate a calendar attendee.'))
|
||||
|
||||
def _send_mail_to_attendees(self, template_xmlid, force_send=False, force_event_id=None):
|
||||
""" Send mail for event invitation to event attendees.
|
||||
:param template_xmlid: xml id of the email template to use to send the invitation
|
||||
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
|
||||
"""
|
||||
res = False
|
||||
|
||||
if self.env['ir.config_parameter'].sudo().get_param('calendar.block_mail') or self._context.get("no_mail_to_attendees"):
|
||||
return res
|
||||
|
||||
calendar_view = self.env.ref('calendar.view_calendar_event_calendar')
|
||||
invitation_template = self.env.ref(template_xmlid)
|
||||
|
||||
# get ics file for all meetings
|
||||
ics_files = force_event_id._get_ics_file() if force_event_id else self.mapped('event_id')._get_ics_file()
|
||||
|
||||
# prepare rendering context for mail template
|
||||
colors = {
|
||||
'needsAction': 'grey',
|
||||
'accepted': 'green',
|
||||
'tentative': '#FFFF00',
|
||||
'declined': 'red'
|
||||
}
|
||||
rendering_context = dict(self._context)
|
||||
rendering_context.update({
|
||||
'color': colors,
|
||||
'action_id': self.env['ir.actions.act_window'].search([('view_id', '=', calendar_view.id)], limit=1).id,
|
||||
'dbname': self._cr.dbname,
|
||||
'base_url': self.env['ir.config_parameter'].sudo().get_param('web.base.url', default='http://localhost:8069'),
|
||||
'force_event_id': force_event_id,
|
||||
})
|
||||
invitation_template = invitation_template.with_context(rendering_context)
|
||||
|
||||
# send email with attachments
|
||||
mail_ids = []
|
||||
for attendee in self:
|
||||
if attendee.email or attendee.partner_id.email:
|
||||
# FIXME: is ics_file text or bytes?
|
||||
event_id = force_event_id.id if force_event_id else attendee.event_id.id
|
||||
ics_file = ics_files.get(event_id)
|
||||
|
||||
email_values = {
|
||||
'model': None, # We don't want to have the mail in the tchatter while in queue!
|
||||
'res_id': None,
|
||||
}
|
||||
if ics_file:
|
||||
email_values['attachment_ids'] = [
|
||||
(0, 0, {'name': 'invitation.ics',
|
||||
'mimetype': 'text/calendar',
|
||||
'datas': base64.b64encode(ics_file)})
|
||||
]
|
||||
mail_ids.append(invitation_template.with_context(no_document=True).send_mail(attendee.id, email_values=email_values, notif_layout='mail.mail_notification_light'))
|
||||
else:
|
||||
mail_ids.append(invitation_template.send_mail(attendee.id, email_values=email_values, notif_layout='mail.mail_notification_light'))
|
||||
|
||||
if force_send and mail_ids:
|
||||
res = self.env['mail.mail'].browse(mail_ids).send()
|
||||
|
||||
return res
|
||||
|
||||
def do_tentative(self):
|
||||
""" Makes event invitation as Tentative. """
|
||||
return self.write({'state': 'tentative'})
|
||||
|
||||
def do_accept(self):
|
||||
""" Marks event invitation as Accepted. """
|
||||
result = self.write({'state': 'accepted'})
|
||||
for attendee in self:
|
||||
if attendee.event_id:
|
||||
attendee.event_id.message_post(body=_("%s has accepted invitation") % (attendee.common_name), subtype_xmlid="calendar.subtype_invitation")
|
||||
return result
|
||||
|
||||
def do_decline(self):
|
||||
""" Marks event invitation as Declined. """
|
||||
res = self.write({'state': 'declined'})
|
||||
for attendee in self:
|
||||
if attendee.event_id:
|
||||
attendee.event_id.message_post(body=_("%s has declined invitation") % (attendee.common_name), subtype_xmlid="calendar.subtype_invitation")
|
||||
return res
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Contacts(models.Model):
|
||||
_name = 'calendar.contacts'
|
||||
_description = 'Calendar Contacts'
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user)
|
||||
partner_id = fields.Many2one('res.partner', 'Employee', required=True)
|
||||
active = fields.Boolean('Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('user_id_partner_id_unique', 'UNIQUE(user_id, partner_id)', 'A user cannot have twice the same contact.')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def unlink_from_partner_id(self, partner_id):
|
||||
return self.search([('partner_id', '=', partner_id)]).unlink()
|
||||
@@ -1,23 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
|
||||
import babel.dates
|
||||
import collections
|
||||
import datetime
|
||||
from datetime import timedelta, MAXYEAR
|
||||
from dateutil import rrule
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
import pytz
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import tools
|
||||
from odoo.addons.base.models.res_partner import _tz_get
|
||||
from odoo.addons.calendar.models.calendar_attendee import Attendee
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.translate import _
|
||||
from odoo.tools.misc import get_lang
|
||||
@@ -61,6 +58,7 @@ def get_real_ids(ids):
|
||||
def real_id2calendar_id(record_id, date):
|
||||
return '%s-%s' % (record_id, date.strftime(VIRTUALID_DATETIME_FORMAT))
|
||||
|
||||
|
||||
def any_id2key(record_id):
|
||||
""" Creates a (real_id: int, thing: str) pair which allows ordering mixed
|
||||
collections of real and virtual events.
|
||||
@@ -88,461 +86,11 @@ SORT_ALIASES = {
|
||||
'start_date': 'sort_start',
|
||||
'start_datetime': 'sort_start',
|
||||
}
|
||||
|
||||
def sort_remap(f):
|
||||
return SORT_ALIASES.get(f, f)
|
||||
|
||||
|
||||
class Contacts(models.Model):
|
||||
_name = 'calendar.contacts'
|
||||
_description = 'Calendar Contacts'
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user)
|
||||
partner_id = fields.Many2one('res.partner', 'Employee', required=True)
|
||||
active = fields.Boolean('Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('user_id_partner_id_unique', 'UNIQUE(user_id,partner_id)', 'An user cannot have twice the same contact.')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def unlink_from_partner_id(self, partner_id):
|
||||
return self.search([('partner_id', '=', partner_id)]).unlink()
|
||||
|
||||
|
||||
class Attendee(models.Model):
|
||||
""" Calendar Attendee Information """
|
||||
|
||||
_name = 'calendar.attendee'
|
||||
_rec_name = 'common_name'
|
||||
_description = 'Calendar Attendee Information'
|
||||
|
||||
def _default_access_token(self):
|
||||
return uuid.uuid4().hex
|
||||
|
||||
STATE_SELECTION = [
|
||||
('needsAction', 'Needs Action'),
|
||||
('tentative', 'Uncertain'),
|
||||
('declined', 'Declined'),
|
||||
('accepted', 'Accepted'),
|
||||
]
|
||||
|
||||
state = fields.Selection(STATE_SELECTION, string='Status', readonly=True, default='needsAction',
|
||||
help="Status of the attendee's participation")
|
||||
common_name = fields.Char('Common name', compute='_compute_common_name', store=True)
|
||||
partner_id = fields.Many2one('res.partner', 'Contact', readonly=True)
|
||||
email = fields.Char('Email', help="Email of Invited Person")
|
||||
availability = fields.Selection([('free', 'Free'), ('busy', 'Busy')], 'Free/Busy', readonly=True)
|
||||
access_token = fields.Char('Invitation Token', default=_default_access_token)
|
||||
event_id = fields.Many2one('calendar.event', 'Meeting linked', ondelete='cascade')
|
||||
|
||||
@api.depends('partner_id', 'partner_id.name', 'email')
|
||||
def _compute_common_name(self):
|
||||
for attendee in self:
|
||||
attendee.common_name = attendee.partner_id.name or attendee.email
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
""" Make entry on email and availability on change of partner_id field. """
|
||||
self.email = self.partner_id.email
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
if not values.get("email") and values.get("common_name"):
|
||||
common_nameval = values.get("common_name").split(':')
|
||||
email = [x for x in common_nameval if '@' in x] # TODO JEM : should be refactored
|
||||
values['email'] = email and email[0] or ''
|
||||
values['common_name'] = values.get("common_name")
|
||||
return super(Attendee, self).create(vals_list)
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
raise UserError(_('You cannot duplicate a calendar attendee.'))
|
||||
|
||||
def _send_mail_to_attendees(self, template_xmlid, force_send=False, force_event_id=None):
|
||||
""" Send mail for event invitation to event attendees.
|
||||
:param template_xmlid: xml id of the email template to use to send the invitation
|
||||
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
|
||||
"""
|
||||
res = False
|
||||
|
||||
if self.env['ir.config_parameter'].sudo().get_param('calendar.block_mail') or self._context.get("no_mail_to_attendees"):
|
||||
return res
|
||||
|
||||
calendar_view = self.env.ref('calendar.view_calendar_event_calendar')
|
||||
invitation_template = self.env.ref(template_xmlid)
|
||||
|
||||
# get ics file for all meetings
|
||||
ics_files = force_event_id._get_ics_file() if force_event_id else self.mapped('event_id')._get_ics_file()
|
||||
|
||||
# prepare rendering context for mail template
|
||||
colors = {
|
||||
'needsAction': 'grey',
|
||||
'accepted': 'green',
|
||||
'tentative': '#FFFF00',
|
||||
'declined': 'red'
|
||||
}
|
||||
rendering_context = dict(self._context)
|
||||
rendering_context.update({
|
||||
'color': colors,
|
||||
'action_id': self.env['ir.actions.act_window'].search([('view_id', '=', calendar_view.id)], limit=1).id,
|
||||
'dbname': self._cr.dbname,
|
||||
'base_url': self.env['ir.config_parameter'].sudo().get_param('web.base.url', default='http://localhost:8069'),
|
||||
'force_event_id': force_event_id,
|
||||
})
|
||||
invitation_template = invitation_template.with_context(rendering_context)
|
||||
|
||||
# send email with attachments
|
||||
mail_ids = []
|
||||
for attendee in self:
|
||||
if attendee.email or attendee.partner_id.email:
|
||||
# FIXME: is ics_file text or bytes?
|
||||
event_id = force_event_id.id if force_event_id else attendee.event_id.id
|
||||
ics_file = ics_files.get(event_id)
|
||||
|
||||
email_values = {
|
||||
'model': None, # We don't want to have the mail in the tchatter while in queue!
|
||||
'res_id': None,
|
||||
}
|
||||
if ics_file:
|
||||
email_values['attachment_ids'] = [
|
||||
(0, 0, {'name': 'invitation.ics',
|
||||
'mimetype': 'text/calendar',
|
||||
'datas': base64.b64encode(ics_file)})
|
||||
]
|
||||
mail_ids.append(invitation_template.with_context(no_document=True).send_mail(attendee.id, email_values=email_values, notif_layout='mail.mail_notification_light'))
|
||||
else:
|
||||
mail_ids.append(invitation_template.send_mail(attendee.id, email_values=email_values, notif_layout='mail.mail_notification_light'))
|
||||
|
||||
if force_send and mail_ids:
|
||||
res = self.env['mail.mail'].sudo().browse(mail_ids).send()
|
||||
|
||||
return res
|
||||
|
||||
def do_tentative(self):
|
||||
""" Makes event invitation as Tentative. """
|
||||
return self.write({'state': 'tentative'})
|
||||
|
||||
def do_accept(self):
|
||||
""" Marks event invitation as Accepted. """
|
||||
result = self.write({'state': 'accepted'})
|
||||
for attendee in self:
|
||||
if attendee.event_id:
|
||||
attendee.event_id.message_post(body=_("%s has accepted invitation") % (attendee.common_name), subtype_xmlid="calendar.subtype_invitation")
|
||||
return result
|
||||
|
||||
def do_decline(self):
|
||||
""" Marks event invitation as Declined. """
|
||||
res = self.write({'state': 'declined'})
|
||||
for attendee in self:
|
||||
if attendee.event_id:
|
||||
attendee.event_id.message_post(body=_("%s has declined invitation") % (attendee.common_name), subtype_xmlid="calendar.subtype_invitation")
|
||||
return res
|
||||
|
||||
|
||||
class AlarmManager(models.AbstractModel):
|
||||
|
||||
_name = 'calendar.alarm_manager'
|
||||
_description = 'Event Alarm Manager'
|
||||
|
||||
def _get_next_potential_limit_alarm(self, alarm_type, seconds=None, partner_id=None):
|
||||
result = {}
|
||||
delta_request = """
|
||||
SELECT
|
||||
rel.calendar_event_id, max(alarm.duration_minutes) AS max_delta,min(alarm.duration_minutes) AS min_delta
|
||||
FROM
|
||||
calendar_alarm_calendar_event_rel AS rel
|
||||
LEFT JOIN calendar_alarm AS alarm ON alarm.id = rel.calendar_alarm_id
|
||||
WHERE alarm.alarm_type = %s
|
||||
GROUP BY rel.calendar_event_id
|
||||
"""
|
||||
base_request = """
|
||||
SELECT
|
||||
cal.id,
|
||||
cal.start - interval '1' minute * calcul_delta.max_delta AS first_alarm,
|
||||
CASE
|
||||
WHEN cal.recurrency THEN cal.final_date - interval '1' minute * calcul_delta.min_delta
|
||||
ELSE cal.stop - interval '1' minute * calcul_delta.min_delta
|
||||
END as last_alarm,
|
||||
cal.start as first_event_date,
|
||||
CASE
|
||||
WHEN cal.recurrency THEN cal.final_date
|
||||
ELSE cal.stop
|
||||
END as last_event_date,
|
||||
calcul_delta.min_delta,
|
||||
calcul_delta.max_delta,
|
||||
cal.rrule AS rule
|
||||
FROM
|
||||
calendar_event AS cal
|
||||
RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
|
||||
"""
|
||||
|
||||
filter_user = """
|
||||
RIGHT JOIN calendar_event_res_partner_rel AS part_rel ON part_rel.calendar_event_id = cal.id
|
||||
AND part_rel.res_partner_id = %s
|
||||
"""
|
||||
|
||||
# Add filter on alarm type
|
||||
tuple_params = (alarm_type,)
|
||||
|
||||
# Add filter on partner_id
|
||||
if partner_id:
|
||||
base_request += filter_user
|
||||
tuple_params += (partner_id, )
|
||||
|
||||
# Upper bound on first_alarm of requested events
|
||||
first_alarm_max_value = ""
|
||||
if seconds is None:
|
||||
# first alarm in the future + 3 minutes if there is one, now otherwise
|
||||
first_alarm_max_value = """
|
||||
COALESCE((SELECT MIN(cal.start - interval '1' minute * calcul_delta.max_delta)
|
||||
FROM calendar_event cal
|
||||
RIGHT JOIN calcul_delta ON calcul_delta.calendar_event_id = cal.id
|
||||
WHERE cal.start - interval '1' minute * calcul_delta.max_delta > now() at time zone 'utc'
|
||||
) + interval '3' minute, now() at time zone 'utc')"""
|
||||
else:
|
||||
# now + given seconds
|
||||
first_alarm_max_value = "(now() at time zone 'utc' + interval '%s' second )"
|
||||
tuple_params += (seconds,)
|
||||
|
||||
self._cr.execute("""
|
||||
WITH calcul_delta AS (%s)
|
||||
SELECT *
|
||||
FROM ( %s WHERE cal.active = True ) AS ALL_EVENTS
|
||||
WHERE ALL_EVENTS.first_alarm < %s
|
||||
AND ALL_EVENTS.last_event_date > (now() at time zone 'utc')
|
||||
""" % (delta_request, base_request, first_alarm_max_value), tuple_params)
|
||||
|
||||
for event_id, first_alarm, last_alarm, first_meeting, last_meeting, min_duration, max_duration, rule in self._cr.fetchall():
|
||||
result[event_id] = {
|
||||
'event_id': event_id,
|
||||
'first_alarm': first_alarm,
|
||||
'last_alarm': last_alarm,
|
||||
'first_meeting': first_meeting,
|
||||
'last_meeting': last_meeting,
|
||||
'min_duration': min_duration,
|
||||
'max_duration': max_duration,
|
||||
'rrule': rule
|
||||
}
|
||||
|
||||
# determine accessible events
|
||||
events = self.env['calendar.event'].browse(result)
|
||||
result = {
|
||||
key: result[key]
|
||||
for key in set(events._filter_access_rules('read').ids)
|
||||
}
|
||||
return result
|
||||
|
||||
def do_check_alarm_for_one_date(self, one_date, event, event_maxdelta, in_the_next_X_seconds, alarm_type, after=False, missing=False):
|
||||
""" Search for some alarms in the interval of time determined by some parameters (after, in_the_next_X_seconds, ...)
|
||||
:param one_date: date of the event to check (not the same that in the event browse if recurrent)
|
||||
:param event: Event browse record
|
||||
:param event_maxdelta: biggest duration from alarms for this event
|
||||
:param in_the_next_X_seconds: looking in the future (in seconds)
|
||||
:param after: if not False: will return alert if after this date (date as string - todo: change in master)
|
||||
:param missing: if not False: will return alert even if we are too late
|
||||
:param notif: Looking for type notification
|
||||
:param mail: looking for type email
|
||||
"""
|
||||
result = []
|
||||
# TODO: remove event_maxdelta and if using it
|
||||
if one_date - timedelta(minutes=(missing and 0 or event_maxdelta)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds): # if an alarm is possible for this date
|
||||
for alarm in event.alarm_ids:
|
||||
if alarm.alarm_type == alarm_type and \
|
||||
one_date - timedelta(minutes=(missing and 0 or alarm.duration_minutes)) < datetime.datetime.now() + timedelta(seconds=in_the_next_X_seconds) and \
|
||||
(not after or one_date - timedelta(minutes=alarm.duration_minutes) > fields.Datetime.from_string(after)):
|
||||
alert = {
|
||||
'alarm_id': alarm.id,
|
||||
'event_id': event.id,
|
||||
'notify_at': one_date - timedelta(minutes=alarm.duration_minutes),
|
||||
}
|
||||
result.append(alert)
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def get_next_mail(self):
|
||||
last_notif_mail = fields.Datetime.to_string(self.env.context.get('lastcall') or fields.Datetime.now())
|
||||
|
||||
cron = self.env.ref('calendar.ir_cron_scheduler_alarm', raise_if_not_found=False)
|
||||
if not cron:
|
||||
_logger.error("Cron for " + self._name + " can not be identified !")
|
||||
return False
|
||||
|
||||
interval_to_second = {
|
||||
"weeks": 7 * 24 * 60 * 60,
|
||||
"days": 24 * 60 * 60,
|
||||
"hours": 60 * 60,
|
||||
"minutes": 60,
|
||||
"seconds": 1
|
||||
}
|
||||
|
||||
if cron.interval_type not in interval_to_second:
|
||||
_logger.error("Cron delay can not be computed !")
|
||||
return False
|
||||
|
||||
cron_interval = cron.interval_number * interval_to_second[cron.interval_type]
|
||||
|
||||
all_meetings = self._get_next_potential_limit_alarm('email', seconds=cron_interval)
|
||||
|
||||
for meeting in self.env['calendar.event'].browse(all_meetings):
|
||||
max_delta = all_meetings[meeting.id]['max_duration']
|
||||
|
||||
if meeting.recurrency:
|
||||
at_least_one = False
|
||||
last_found = False
|
||||
for one_date in meeting._get_recurrent_date_by_event():
|
||||
in_date_format = one_date.replace(tzinfo=None)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, 0, 'email', after=last_notif_mail, missing=True)
|
||||
for alert in last_found:
|
||||
self.do_mail_reminder(alert)
|
||||
at_least_one = True # if it's the first alarm for this recurrent event
|
||||
if at_least_one and not last_found: # if the precedent event had an alarm but not this one, we can stop the search for this event
|
||||
break
|
||||
else:
|
||||
in_date_format = meeting.start
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, 0, 'email', after=last_notif_mail, missing=True)
|
||||
for alert in last_found:
|
||||
self.do_mail_reminder(alert)
|
||||
|
||||
@api.model
|
||||
def get_next_notif(self):
|
||||
partner = self.env.user.partner_id
|
||||
all_notif = []
|
||||
|
||||
if not partner:
|
||||
return []
|
||||
|
||||
all_meetings = self._get_next_potential_limit_alarm('notification', partner_id=partner.id)
|
||||
time_limit = 3600 * 24 # return alarms of the next 24 hours
|
||||
for event_id in all_meetings:
|
||||
max_delta = all_meetings[event_id]['max_duration']
|
||||
meeting = self.env['calendar.event'].browse(event_id)
|
||||
if meeting.recurrency:
|
||||
b_found = False
|
||||
last_found = False
|
||||
for one_date in meeting._get_recurrent_date_by_event():
|
||||
in_date_format = one_date.replace(tzinfo=None)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, time_limit, 'notification', after=partner.calendar_last_notif_ack)
|
||||
if last_found:
|
||||
for alert in last_found:
|
||||
all_notif.append(self.do_notif_reminder(alert))
|
||||
if not b_found: # if it's the first alarm for this recurrent event
|
||||
b_found = True
|
||||
if b_found and not last_found: # if the precedent event had alarm but not this one, we can stop the search fot this event
|
||||
break
|
||||
else:
|
||||
in_date_format = fields.Datetime.from_string(meeting.start)
|
||||
last_found = self.do_check_alarm_for_one_date(in_date_format, meeting, max_delta, time_limit, 'notification', after=partner.calendar_last_notif_ack)
|
||||
if last_found:
|
||||
for alert in last_found:
|
||||
all_notif.append(self.do_notif_reminder(alert))
|
||||
return all_notif
|
||||
|
||||
def do_mail_reminder(self, alert):
|
||||
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
||||
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
||||
|
||||
result = False
|
||||
if alarm.alarm_type == 'email':
|
||||
result = meeting.attendee_ids.filtered(lambda r: r.state != 'declined')._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True, force_event_id=meeting)
|
||||
return result
|
||||
|
||||
def do_notif_reminder(self, alert):
|
||||
alarm = self.env['calendar.alarm'].browse(alert['alarm_id'])
|
||||
meeting = self.env['calendar.event'].browse(alert['event_id'])
|
||||
|
||||
if alarm.alarm_type == 'notification':
|
||||
message = meeting.display_time
|
||||
|
||||
delta = alert['notify_at'] - datetime.datetime.now()
|
||||
delta = delta.seconds + delta.days * 3600 * 24
|
||||
|
||||
return {
|
||||
'alarm_id': alarm.id,
|
||||
'event_id': meeting.id,
|
||||
'title': meeting.name,
|
||||
'message': message,
|
||||
'timer': delta,
|
||||
'notify_at': fields.Datetime.to_string(alert['notify_at']),
|
||||
}
|
||||
|
||||
def _notify_next_alarm(self, partner_ids):
|
||||
""" Sends through the bus the next alarm of given partners """
|
||||
notifications = []
|
||||
users = self.env['res.users'].search([('partner_id', 'in', tuple(partner_ids))])
|
||||
for user in users:
|
||||
notif = self.with_user(user).get_next_notif()
|
||||
notifications.append([(self._cr.dbname, 'calendar.alarm', user.partner_id.id), notif])
|
||||
if len(notifications) > 0:
|
||||
self.env['bus.bus'].sendmany(notifications)
|
||||
|
||||
|
||||
class Alarm(models.Model):
|
||||
_name = 'calendar.alarm'
|
||||
_description = 'Event Alarm'
|
||||
|
||||
@api.depends('interval', 'duration')
|
||||
def _compute_duration_minutes(self):
|
||||
for alarm in self:
|
||||
if alarm.interval == "minutes":
|
||||
alarm.duration_minutes = alarm.duration
|
||||
elif alarm.interval == "hours":
|
||||
alarm.duration_minutes = alarm.duration * 60
|
||||
elif alarm.interval == "days":
|
||||
alarm.duration_minutes = alarm.duration * 60 * 24
|
||||
else:
|
||||
alarm.duration_minutes = 0
|
||||
|
||||
_interval_selection = {'minutes': 'Minutes', 'hours': 'Hours', 'days': 'Days'}
|
||||
|
||||
name = fields.Char('Name', translate=True, required=True)
|
||||
alarm_type = fields.Selection([('notification', 'Notification'), ('email', 'Email')], string='Type', required=True, default='email')
|
||||
duration = fields.Integer('Remind Before', required=True, default=1)
|
||||
interval = fields.Selection(list(_interval_selection.items()), 'Unit', required=True, default='hours')
|
||||
duration_minutes = fields.Integer('Duration in minutes', compute='_compute_duration_minutes', store=True, help="Duration in minutes")
|
||||
|
||||
@api.onchange('duration', 'interval', 'alarm_type')
|
||||
def _onchange_duration_interval(self):
|
||||
display_interval = self._interval_selection.get(self.interval, '')
|
||||
display_alarm_type = {key: value for key, value in self._fields['alarm_type']._description_selection(self.env)}[self.alarm_type]
|
||||
self.name = "%s - %s %s" % (display_alarm_type, self.duration, display_interval)
|
||||
|
||||
def _update_cron(self):
|
||||
try:
|
||||
cron = self.env['ir.model.data'].sudo().get_object('calendar', 'ir_cron_scheduler_alarm')
|
||||
except ValueError:
|
||||
return False
|
||||
return cron.toggle(model=self._name, domain=[('alarm_type', '=', 'email')])
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
result = super(Alarm, self).create(values)
|
||||
self._update_cron()
|
||||
return result
|
||||
|
||||
def write(self, values):
|
||||
result = super(Alarm, self).write(values)
|
||||
self._update_cron()
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
result = super(Alarm, self).unlink()
|
||||
self._update_cron()
|
||||
return result
|
||||
|
||||
|
||||
class MeetingType(models.Model):
|
||||
|
||||
_name = 'calendar.event.type'
|
||||
_description = 'Event Meeting Type'
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', "Tag name already exists !"),
|
||||
]
|
||||
|
||||
|
||||
class Meeting(models.Model):
|
||||
""" Model for Calendar Event
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MeetingType(models.Model):
|
||||
|
||||
_name = 'calendar.event.type'
|
||||
_description = 'Event Meeting Type'
|
||||
|
||||
name = fields.Char('Name', required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq', 'unique (name)', "Tag name already exists !"),
|
||||
]
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
from odoo.addons.calendar.models.calendar import get_real_ids
|
||||
from odoo.addons.calendar.models.calendar_event import get_real_ids
|
||||
|
||||
|
||||
class Attachment(models.Model):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
from odoo.addons.calendar.models.calendar import get_real_ids
|
||||
from odoo.addons.calendar.models.calendar_event import get_real_ids
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.calendar.models.calendar import get_real_ids
|
||||
from odoo.addons.calendar.models.calendar_event import get_real_ids
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import common
|
||||
from odoo.addons.calendar.models.calendar import calendar_id2real_id
|
||||
from odoo.addons.calendar.models.calendar_event import calendar_id2real_id
|
||||
|
||||
|
||||
class TestRecurrentEvent(common.TransactionCase):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
from odoo.addons.calendar.models.calendar import get_real_ids
|
||||
from odoo.addons.calendar.models.calendar_event import get_real_ids
|
||||
|
||||
|
||||
class MailInvite(models.TransientModel):
|
||||
|
||||
Reference in New Issue
Block a user