351 lines
10 KiB
Python
351 lines
10 KiB
Python
from dateutil.relativedelta import relativedelta
|
|
from dateutil.rrule import rrule, DAILY, MO, TU, WE, TH, FR
|
|
|
|
from datetime import datetime, time
|
|
|
|
from trytond.model import Workflow, ModelSQL, ModelSingleton, ModelView, fields
|
|
from trytond.modules.calendar.calendar_ import Event
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.pyson import Eval, If, In
|
|
from trytond.transaction import Transaction
|
|
|
|
__all__ = ['ResourceConfiguration', 'ResourceConfigIrModel', 'Company',
|
|
'Resource', 'ResourceBooking']
|
|
|
|
|
|
class ResourceConfiguration(ModelSingleton, ModelSQL, ModelView):
|
|
'Resource Configuration'
|
|
__name__ = 'resource.configuration'
|
|
|
|
documents = fields.Many2Many('resource.configuration-ir.model',
|
|
'configuration', 'document', 'Documents')
|
|
|
|
|
|
class ResourceConfigIrModel(ModelSQL):
|
|
'ResourceConfig - Ir Model'
|
|
__name__ = 'resource.configuration-ir.model'
|
|
|
|
document = fields.Many2One('ir.model', 'Document', ondelete='CASCADE',
|
|
required=True, select=True)
|
|
configuration = fields.Many2One('resource.configuration', 'Configuration',
|
|
ondelete='CASCADE', required=True, select=True)
|
|
|
|
|
|
class Company:
|
|
__name__ = 'company.company'
|
|
__metaclass__ = PoolMeta
|
|
|
|
day_starts = fields.Time('Day Start', help='The hour on which the working '
|
|
'day starts.', required=True)
|
|
day_ends = fields.Time('Day Ends', help='The hour on which the working '
|
|
'day ends.', required=True)
|
|
|
|
@staticmethod
|
|
def default_day_starts():
|
|
return time(9, 00)
|
|
|
|
@staticmethod
|
|
def default_day_ends():
|
|
return time(17, 00)
|
|
|
|
|
|
class Resource(ModelSQL, ModelView):
|
|
'Resource'
|
|
__name__ = 'resource.resource'
|
|
|
|
name = fields.Char('Name', required=True)
|
|
active = fields.Boolean('Active')
|
|
employee = fields.Many2One('company.employee', 'Employee', select=True)
|
|
calendar = fields.Many2One('calendar.calendar', 'Calendar', required=True,
|
|
select=True, ondelete='CASCADE')
|
|
bookings = fields.One2Many('resource.booking', 'resource', 'Bookings',
|
|
context={
|
|
'calendar': Eval('calendar'),
|
|
},
|
|
depends=['calendar'])
|
|
type = fields.Selection([
|
|
('human', 'Human'),
|
|
], 'Type', required=True, select=True,)
|
|
company = fields.Many2One('company.company', 'Company', required=True,
|
|
domain=[
|
|
('id', If(In('company', Eval('context', {})), '=', '!='),
|
|
Eval('context', {}).get('company', -1)),
|
|
])
|
|
|
|
@staticmethod
|
|
def default_active():
|
|
return True
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@staticmethod
|
|
def default_type():
|
|
return 'human'
|
|
|
|
def default_daily_schedule(self, date):
|
|
start = datetime.combine(date, self.company.day_starts)
|
|
end = datetime.combine(date, self.company.day_ends)
|
|
return (start, end, self.company.hours_per_work_day)
|
|
|
|
def get_busy_hours(self, start, end):
|
|
pool = Pool()
|
|
Booking = pool.get('resource.booking')
|
|
start = datetime.combine(start, datetime.min.time())
|
|
end = datetime.combine(end, datetime.max.time())
|
|
events = Booking.search([
|
|
('dtstart', '>=', start),
|
|
('dtend', '<=', end),
|
|
('resource', '=', self.id),
|
|
('state', '!=', 'cancel'),
|
|
])
|
|
|
|
dates = list(rrule(DAILY, bysetpos=1, dtstart=start, until=end,
|
|
byweekday=[MO, TU, WE, TH, FR]))
|
|
res = {}
|
|
for date in dates:
|
|
res[date.date()] = []
|
|
|
|
for event in events:
|
|
date = event.dtstart.date()
|
|
res[date] += [event.interval_values()]
|
|
|
|
dates = res.keys()
|
|
dates.sort()
|
|
return res
|
|
|
|
def fill_timetable(self, interval1, interval2, kind=0):
|
|
sd1, fd1, _ = interval1
|
|
sd2, fd2, _ = interval2
|
|
|
|
missing_interval = []
|
|
if kind == 1:
|
|
if sd2 > sd1:
|
|
hours = (sd2 - sd1).total_seconds() / 3600
|
|
missing_interval = [(sd1, sd2, hours)]
|
|
elif kind == 2:
|
|
if fd2 < fd1:
|
|
hours = (fd1 - fd2).total_seconds() / 3600
|
|
missing_interval = [(fd2, fd1, hours)]
|
|
elif fd1 != sd2:
|
|
hours = (fd2 - sd1).total_seconds() / 3600
|
|
missing_interval = [(fd2, sd1, hours)]
|
|
|
|
return missing_interval
|
|
|
|
def timetable(self, date, busy_intervals):
|
|
|
|
schedule = self.default_daily_schedule(date)
|
|
if busy_intervals == []:
|
|
return [schedule], [schedule]
|
|
busy_intervals.sort(key=lambda r: r[0])
|
|
|
|
init = self.fill_timetable(schedule, busy_intervals[0], 1)
|
|
free = init
|
|
timetable = init
|
|
current = busy_intervals[0]
|
|
for event in busy_intervals[1:]:
|
|
miss = self.fill_timetable(current, event)
|
|
free += miss
|
|
timetable += miss
|
|
current = event
|
|
|
|
finish = self.fill_timetable(schedule, current, 2)
|
|
free = finish
|
|
timetable += finish
|
|
return timetable, free
|
|
|
|
def find_free_time(self, start, end, min_hours=None):
|
|
busy = self.get_busy_hours(start, end)
|
|
res = {}
|
|
for day in res.keys():
|
|
res[day] = []
|
|
res = {}.fromkeys(busy.keys())
|
|
|
|
dates = busy.keys()
|
|
dates.sort()
|
|
for bdate in dates:
|
|
busy_interval = busy[bdate]
|
|
timetable, free = self.timetable(bdate, busy_interval)
|
|
res[bdate] = free
|
|
return res
|
|
|
|
def default_ahead(self):
|
|
return relativedelta(days=7)
|
|
|
|
def book_hours(self, date, hours, ahead=None, min_hours=None):
|
|
ahead_search = ahead or self.default_ahead()
|
|
ahead_date = date + ahead_search
|
|
free = self.find_free_time(date, ahead_date, min_hours)
|
|
pending_hours = hours
|
|
bookings = []
|
|
|
|
free_time = []
|
|
dates = free.keys()
|
|
dates.sort()
|
|
for k in dates:
|
|
free_time += free[k]
|
|
|
|
for t in free_time:
|
|
start, end, free_hours = t
|
|
assign = free_hours
|
|
if pending_hours <= free_hours:
|
|
assign = pending_hours
|
|
|
|
pending_hours -= assign
|
|
bookings.append((start, start + relativedelta(hours=assign),
|
|
assign))
|
|
|
|
if pending_hours == 0:
|
|
break
|
|
return bookings
|
|
|
|
def book_interval(self, bookings):
|
|
if not bookings:
|
|
return (None, None)
|
|
|
|
start = min([x[0] for x in bookings])
|
|
end = max([x[1] for x in bookings])
|
|
return (start, end)
|
|
|
|
def unbook(self, start, end):
|
|
pass
|
|
|
|
def book(self, intervals, document, status='tentative'):
|
|
pool = Pool()
|
|
Event = pool.get('resource.booking')
|
|
create_bookings = []
|
|
for interval in intervals:
|
|
i = {
|
|
'dtstart': interval[0],
|
|
'dtend': interval[1],
|
|
'calendar': self.calendar.id,
|
|
'resource': self.id,
|
|
'status': status,
|
|
'state': 'draft',
|
|
'document': document,
|
|
}
|
|
create_bookings.append(i)
|
|
return Event.create(create_bookings)
|
|
|
|
def get_bookings(self, start, end):
|
|
pass
|
|
|
|
def load(self, start, end):
|
|
pass
|
|
|
|
|
|
class ResourceBooking(Workflow, Event):
|
|
'Resource Book'
|
|
__name__ = 'resource.booking'
|
|
|
|
resource = fields.Many2One('resource.resource', 'Resource', required=True,
|
|
select=True, ondelete='CASCADE')
|
|
document = fields.Reference('Document', selection='get_document',
|
|
select=True)
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('confirmed', 'Confirmed'),
|
|
('canceled', 'Canceled'),
|
|
], 'State', required=True, select=True, readonly=True)
|
|
|
|
hours = fields.Function(fields.Float('Hours', digits=(16, 2)),
|
|
'get_hours')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(ResourceBooking, cls).__setup__()
|
|
cls._order.insert(0, ('sequence', 'ASC'))
|
|
cls._error_messages.update({
|
|
'delete_cancel': ('Booking "%s" must be cancelled before '
|
|
'deletion.'),
|
|
})
|
|
cls._transitions |= set([
|
|
('draft', 'confirmed'),
|
|
('draft', 'canceled'),
|
|
('confirmed', 'canceled'),
|
|
('canceled', 'draft'),
|
|
])
|
|
cls._buttons.update({
|
|
'draft': {
|
|
'invisible': Eval('state') != 'canceled',
|
|
'icon': 'tryton-clear',
|
|
},
|
|
'confirm': {
|
|
'invisible': Eval('state') != 'draft',
|
|
'icon': 'tryton-ok',
|
|
},
|
|
'cancel': {
|
|
'invisible': Eval('state') == 'canceled',
|
|
'icon': 'tryton-cancel',
|
|
},
|
|
})
|
|
|
|
@staticmethod
|
|
def order_sequence(tables):
|
|
table, _ = tables[None]
|
|
return [table.sequence == None, table.sequence]
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@staticmethod
|
|
def default_calendar():
|
|
return Transaction().context.get('calendar')
|
|
|
|
def get_rec_name(self, name=None):
|
|
if self.dtend:
|
|
dates = '(%s - %s)' % (self.dtstart, self.dtend)
|
|
else:
|
|
dates = '(%s)' % (self.dtstart)
|
|
name = '%s %s' % (self.resource.rec_name, dates)
|
|
return name
|
|
|
|
def get_hours(self, name=None):
|
|
if not self.dtend:
|
|
return 0
|
|
return (self.dtend - self.dtstart).total_seconds() / 3600
|
|
|
|
def interval_values(self):
|
|
start = self.dtstart
|
|
end = self.dtend
|
|
hours = self.hours
|
|
return (start, end, hours)
|
|
|
|
@classmethod
|
|
def get_document(cls):
|
|
pool = Pool()
|
|
Config = pool.get('resource.configuration')
|
|
config, = Config.search([])
|
|
res = [('', '')]
|
|
for document in config.documents:
|
|
res.append((document.model, document.name))
|
|
return res
|
|
|
|
@classmethod
|
|
def delete(cls, bookings):
|
|
for book in bookings:
|
|
if book.state != 'canceled':
|
|
cls.raise_user_error('delete_cancel', (book.rec_name,))
|
|
super(ResourceBooking, cls).delete(bookings)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, bookings):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('confirmed')
|
|
def confirm(cls, bookings):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('canceled')
|
|
def cancel(cls, bookings):
|
|
pass
|