Reimplement history state to allow edit by user.

This commit refs #16115
This commit is contained in:
Sergio Morillo 2021-01-14 16:58:40 +01:00
parent 43c8ed4714
commit e855d01812
10 changed files with 485 additions and 152 deletions

View File

@ -1,15 +1,24 @@
# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
from trytond.pool import Pool
from .location import Location, LocationHistory, Combined
from . import location
from . import unit_load
def register():
Pool.register(
Combined,
location.Location,
location.LocationState,
module='stock_location_history_state', type_='model')
Pool.register(
location.Combined,
module='stock_location_history_state', type_='model',
depends=['stock_location_combined'])
Pool.register(
Location,
LocationHistory,
module='stock_location_history_state', type_='model')
unit_load.UnitLoad,
module='stock_location_history_state', type_='model',
depends=['stock_unit_load'])
Pool.register(
unit_load.UnitLoadCombined,
module='stock_location_history_state', type_='model',
depends=['stock_unit_load_location_combined'])

View File

@ -2,13 +2,17 @@
msgid ""
msgstr "Content-Type: text/plain; charset=utf-8\n"
msgctxt "field:stock.location,history:"
msgid "History"
msgstr "Historial"
msgctxt "field:stock.location,states:"
msgid "History State"
msgstr "Historial de estados"
msgctxt "field:stock.location,history_state:"
msgid "History state"
msgstr "Estado histórico"
msgctxt "field:stock.location,last_states:"
msgid "Last History State"
msgstr "Historial de últimos estados"
msgctxt "field:stock.location,state_at:"
msgid "State at"
msgstr "Estado a fecha"
msgctxt "field:stock.location,state:"
msgid "State"
@ -18,58 +22,62 @@ msgctxt "field:stock.location,state_icon:"
msgid "State Icon"
msgstr "Icono estado"
msgctxt "field:stock.location.history,create_date:"
msgctxt "field:stock.location.state,create_date:"
msgid "Create Date"
msgstr "Fecha creación"
msgctxt "field:stock.location.history,create_uid:"
msgctxt "field:stock.location.state,create_uid:"
msgid "Create User"
msgstr "Usuario creación"
msgctxt "field:stock.location.history,date:"
msgctxt "field:stock.location.state,date:"
msgid "Change Date"
msgstr "Fecha modificación"
msgctxt "field:stock.location.history,id:"
msgctxt "field:stock.location.state,id:"
msgid "ID"
msgstr "Identificador"
msgctxt "field:stock.location.history,location:"
msgctxt "field:stock.location.state,location:"
msgid "Location"
msgstr "Ubicación"
msgctxt "field:stock.location.history,rec_name:"
msgctxt "field:stock.location.state,rec_name:"
msgid "Name"
msgstr "Nombre"
msgctxt "field:stock.location.history,state:"
msgctxt "field:stock.location.state,state:"
msgid "State"
msgstr "Estado"
msgctxt "field:stock.location.history,user:"
msgid "User"
msgstr "Usuario"
msgctxt "field:stock.location.history,write_date:"
msgctxt "field:stock.location.state,write_date:"
msgid "Write Date"
msgstr "Fecha modificación"
msgctxt "field:stock.location.history,write_uid:"
msgctxt "field:stock.location.state,write_uid:"
msgid "Write User"
msgstr "Usuario modificación"
msgctxt "model:stock.location.history,name:"
msgid "Stock location History"
msgstr "Historial ubicación"
msgctxt "model:stock.location.state,name:"
msgid "Stock location History State"
msgstr "Historial de estado de ubicación"
msgctxt "selection:stock.location,history_state:"
msgctxt "selection:stock.location,state_at:"
msgid ""
msgstr ""
msgctxt "selection:stock.location,state_at:"
msgid "Off"
msgstr "Parado"
msgctxt "selection:stock.location,history_state:"
msgctxt "selection:stock.location,state_at:"
msgid "On"
msgstr "En servicio"
msgctxt "selection:stock.location,state:"
msgid ""
msgstr ""
msgctxt "selection:stock.location,state:"
msgid "Off"
msgstr "Parado"
@ -78,18 +86,14 @@ msgctxt "selection:stock.location,state:"
msgid "On"
msgstr "En servicio"
msgctxt "selection:stock.location.history,state:"
msgctxt "selection:stock.location.state,state:"
msgid "Off"
msgstr "Parado"
msgctxt "selection:stock.location.history,state:"
msgctxt "selection:stock.location.state,state:"
msgid "On"
msgstr "En servicio"
msgctxt "view:stock.location:"
msgid "History"
msgstr "Historial"
msgctxt "model:ir.model.button,string:location_on_button"
msgid "Start"
msgstr "Iniciar"
@ -98,6 +102,10 @@ msgctxt "model:ir.model.button,string:location_off_button"
msgid "Stop"
msgstr "Parar"
msgctxt "view:stock.location.history:"
msgid "Change Time"
msgstr "Hora modificación"
msgctxt "view:stock.location.state:"
msgid "Time"
msgstr "Hora"
msgctxt "model:ir.action,name:act_location_state"
msgid "Histoy State"
msgstr "Historial de estados"

View File

@ -1,39 +1,55 @@
# The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from datetime import datetime
from sql import Column
from sql.aggregate import Min, Max
from datetime import datetime, timedelta
from sql.aggregate import Max
from sql.conditionals import Coalesce
from sql.functions import DateTrunc
from trytond.model import ModelSQL, ModelView, fields
from sql.functions import RowNumber
from sql import Table, Null, Window
from trytond.model import ModelSQL, ModelView, fields, Unique
from trytond.pool import PoolMeta, Pool
from trytond.pyson import Eval, Or
from trytond.pyson import Eval, Or, If
from trytond.transaction import Transaction
from trytond import backend
from itertools import groupby
__all__ = ['Location', 'LocationHistory', 'Combined']
STATES = [('on', 'On'),
('off', 'Off')]
STATES = [
(None, ''),
('on', 'On'),
('off', 'Off')
]
class Location(metaclass=PoolMeta):
__name__ = 'stock.location'
_history = True
state = fields.Selection(STATES, 'State',
readonly=True,
state = fields.Selection(STATES, 'State', readonly=True,
domain=[If(Eval('type') == 'production',
('state', '!=', None), ())],
states={
'readonly': ~Eval('active'),
'invisible': Eval('type') != 'production'},
'invisible': Eval('type') != 'production',
},
depends=['active', 'type'])
state_icon = fields.Function(
fields.Char('State Icon'), 'get_state_icon')
history_state = fields.Function(
fields.Selection(STATES, 'History state'),
'get_history_state', searcher='search_history_state')
history = fields.One2Many('stock.location.history', 'location',
'History', readonly=True, loading='lazy')
state_at = fields.Function(
fields.Selection(STATES, 'State at'),
'get_state_at', searcher='search_state_at')
states = fields.One2Many('stock.location.state', 'location',
'History State', loading='lazy', states={
'invisible': Eval('type') != 'production',
},
context={'create_history_state': False},
order=[('date', 'DESC')], depends=['type'])
last_states = fields.Function(
fields.One2Many('stock.location.state', 'location',
'History State', states={
'invisible': Eval('type') != 'production',
},
context={'create_history_state': False},
order=[('date', 'DESC')], depends=['type']),
'get_last_states', setter='set_last_states')
_last_states_size = 10
@classmethod
def __setup__(cls):
@ -66,7 +82,7 @@ class Location(metaclass=PoolMeta):
if default is None:
default = {}
default = default.copy()
default.setdefault('history', None)
default.setdefault('states', None)
return super(Location, cls).copy(records, default=default)
@classmethod
@ -79,7 +95,7 @@ class Location(metaclass=PoolMeta):
return None
@classmethod
def get_history_state(cls, records, name=None):
def get_state_at(cls, records, name=None):
values = {r.id: r.state for r in records}
at_date = Transaction().context.get('state_at_date', None)
@ -87,35 +103,38 @@ class Location(metaclass=PoolMeta):
return values
for record in records:
_history = sorted(record.history, key=lambda h: h.date)
for _hist in _history:
if _hist.date > at_date:
for state in record.states:
if state.date <= at_date:
values[record.id] = state.state
break
values[record.id] = _hist.state
return values
@classmethod
def search_history_state(cls, name, clause):
def search_state_at(cls, name, clause):
State = Pool().get('stock.location.state')
at_date = Transaction().context.get('state_at_date', None)
if not at_date:
return [('state', ) + tuple(clause[1:])]
location_history = cls.__table_history__()
location_state = State.__table__()
location_state2 = State.__table__()
Operator = fields.SQL_OPERATORS[clause[1]]
columns = [location_history.id.as_('location'),
location_history.state.as_('state'),
Max(Coalesce(location_history.write_date,
location_history.create_date)).as_('date')]
columns = [
location_state.location,
Max(location_state.id).as_('state_id'),
Max(location_state.date).as_('date'),
]
# Gets state of allowed max date
query = location_history.select(
query = location_state.select(
*columns,
where=(Coalesce(location_history.write_date,
location_history.create_date) <= at_date),
group_by=[location_history.id, location_history.state])
query = query.select(
query.location,
where=(Operator(query.state, clause[2])))
where=(location_state.date <= at_date),
group_by=[location_state.location])
query = query.join(location_state2, condition=(
query.state_id == location_state2.id)
).select(
query.location,
where=(Operator(location_state2.state, clause[2])))
return [('id', 'in', query)]
@classmethod
@ -162,55 +181,261 @@ class Location(metaclass=PoolMeta):
('//page[@id="history"]', 'states', {
'invisible': Eval('type') != 'production'})]
@classmethod
def get_last_states(cls, records, name):
State = Pool().get('stock.location.state')
location_state = State.__table__()
cursor = Transaction().connection.cursor()
class LocationHistory(ModelSQL, ModelView):
"""Stock location History"""
__name__ = 'stock.location.history'
# Gets last states
query = location_state.select(
RowNumber(window=Window(partition=[location_state.location])
).as_('counter'),
location_state.location,
location_state.id,
order_by=(location_state.location, location_state.date.desc),
)
cursor.execute(*query.select(
query.location,
query.id,
where=(query.counter <= cls._last_states_size))
)
values = cursor.fetchall()
res = {r.id: [] for r in records}
for location_id, state_ids in groupby(values, key=lambda v: v[0]):
res[location_id] = [s[1] for s in state_ids]
return res
date_ = fields.DateTime('Change Date')
date = fields.Function(fields.DateTime('Change Date'), 'get_date')
location = fields.Many2One('stock.location', 'Location')
user = fields.Many2One('res.user', 'User')
state = fields.Selection(STATES, 'State')
@classmethod
def set_last_states(cls, records, name, value):
with Transaction().set_context(create_history_state=False):
cls.write(records, {
'states': value,
})
@classmethod
def create(cls, vlist):
records = super().create(vlist)
states = {}
for record in records:
if record.state:
states.setdefault(record.state, []).append(record)
for state, stated_records in states.items():
cls.create_history_state(stated_records, state)
return cls.browse(records)
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
states = {}
for records, values in zip(actions, actions):
if values.get('state', None) and \
Transaction().context.get('create_history_state', True):
states.setdefault(values['state'], []).extend(records)
args.extend((records, values))
super().write(*args)
for state, stated_records in states.items():
cls.create_history_state(stated_records, state)
@classmethod
def create_history_state(cls, records, state):
State = Pool().get('stock.location.state')
if not state:
return
if not Transaction().context.get('create_history_state', True):
# avoid create another record when editing history
return
values = []
for record in records:
values.append({
'location': record.id,
'state': state,
'date': datetime.now()
})
State.create(values)
@classmethod
def set_current_state(cls, records):
records = cls.browse(records)
to_update = {}
for record in records:
if record.states and record.state != record.states[0].state:
to_update.setdefault(record.states[0].state, []).append(record)
if to_update:
changes = []
for k, v in to_update.items():
changes.extend([v, {'state': k}])
with Transaction().set_context(create_history_state=False):
# do not use save due to context is lost
cls.write(*changes)
def get_state_time(self, from_date, to_date, state):
# get first state outside from_date
State = Pool().get('stock.location.state')
first_state = State.search([
('location', '=', self.id),
('date', '<=', from_date)],
order=[('date', 'DESC')],
limit=1)
history_states = State.search([
('location', '=', self.id),
('date', '>=', from_date),
('date', '<=', to_date)],
order=[('date', 'ASC')])
result = timedelta(0)
if first_state:
first_state, = first_state
elif history_states:
first_state = history_states[0]
else:
return None
last_date = first_state.date
last_state = first_state.state
for hstate in history_states:
if last_state == state and last_state != hstate.state:
result += (hstate.date - last_date)
last_date = hstate.date
last_state = hstate.state
if last_state == state and last_date < to_date:
result += (to_date - last_date)
return result - timedelta(microseconds=result.microseconds)
def get_state_time_on(self, from_date, to_date):
return self.get_state_time(from_date, to_date, 'on')
def get_state_time_off(self, from_date, to_date):
return self.get_state_time(from_date, to_date, 'off')
class LocationState(ModelSQL, ModelView):
"""Stock location History State"""
__name__ = 'stock.location.state'
date = fields.DateTime('Date', required=True)
location = fields.Many2One('stock.location', 'Location', select=True,
required=True, ondelete='CASCADE')
state = fields.Selection([
('on', 'On'),
('off', 'Off')], 'State', required=True)
@classmethod
def __setup__(cls):
super(LocationHistory, cls).__setup__()
cls._order.insert(0, ('date_', 'DESC'))
super().__setup__()
cls._order.insert(0, ('date', 'DESC'))
t = cls.__table__()
cls._sql_constraints += [
('date_location_uniq', Unique(t, t.date, t.location),
'Combination of Date and Location must be unique.'),
]
@classmethod
def table_query(cls):
_Location = Pool().get('stock.location')
location_history = _Location.__table_history__()
columns = [
Min(Column(location_history, '__id')).as_('id'),
location_history.id.as_('location'),
Min(Coalesce(location_history.write_date,
location_history.create_date)).as_('date_'),
Coalesce(location_history.write_uid,
location_history.create_uid).as_('user'),
]
group_by = [
location_history.id,
Coalesce(location_history.write_uid,
location_history.create_uid),
]
for name, field in cls._fields.items():
if name in ('id', 'location', 'date_', 'date', 'user'):
continue
if hasattr(field, 'set'):
continue
column = Column(location_history, name)
columns.append(column.as_(name))
group_by.append(column)
def __register__(cls, module_name):
TableHandler = backend.get('TableHandler')
Location = Pool().get('stock.location')
cursor = Transaction().connection.cursor()
table = cls.__table__()
location = Location.__table__()
return location_history.select(*columns, group_by=group_by)
super().__register__(module_name)
def get_date(self, name):
_date = self.date_
if not isinstance(_date, datetime):
_date = datetime.strptime(_date, '%Y-%m-%d %H:%M:%S.%f')
return _date
cursor.execute(*table.select(table.id, limit=1))
if TableHandler.table_exist('stock_location__history') and \
not cursor.fetchone():
history = Table('stock_location__history')
history2 = Table('stock_location__history')
query = history.join(location, condition=(
location.id == history.id)
).select(
history.id,
Max(Coalesce(history.write_date, history.create_date)
).as_('date'),
where=(
(history.state != Null) &
(location.type == 'production')
),
group_by=history.id
)
cursor.execute(*table.insert([
table.create_uid,
table.write_uid,
table.create_date,
table.write_date,
table.location,
table.date,
table.state
],
history2.join(query, condition=(
(history2.id == query.id) &
(Coalesce(history2.write_date, history2.create_date
) == query.date))
).select(
history2.create_uid,
history2.write_uid,
history2.create_date,
history2.write_date,
history2.id,
query.date,
history2.state))
)
@classmethod
def default_date(cls):
return datetime.now()
@fields.depends('location', '_parent_location.state')
def on_change_location(self):
mapping = {
'on': 'off',
'off': 'on'
}
if self.location and self.location.state:
self.state = mapping[self.location.state]
@classmethod
def create(cls, vlist):
Location = Pool().get('stock.location')
records = super().create(vlist)
locations = set([r.location for r in records])
if locations:
Location.set_current_state(locations)
return records
@classmethod
def write(cls, *args):
Location = Pool().get('stock.location')
actions = iter(args)
args = []
locations = []
for records, values in zip(actions, actions):
locations.extend([r.location for r in records])
args.extend((records, values))
super().write(*args)
if locations:
locations = set(locations)
Location.set_current_state(locations)
@classmethod
def delete(cls, records):
Location = Pool().get('stock.location')
locations = set([r.location for r in records])
super().delete(records)
if locations:
Location.set_current_state(locations)
class Combined(metaclass=PoolMeta):

View File

@ -14,26 +14,44 @@ this repository contains the full copyright notices and license terms. -->
<field name="name">location_form</field>
<field name="inherit" ref="stock.location_view_form"/>
</record>
<!-- Location history -->
<record model="ir.ui.view" id="location_history_view_tree">
<field name="model">stock.location.history</field>
<!-- Location state -->
<record model="ir.ui.view" id="location_state_view_tree">
<field name="model">stock.location.state</field>
<field name="type">tree</field>
<field name="name">location_history_tree</field>
<field name="name">location_state_tree</field>
</record>
<record model="ir.model.access" id="access_location_history">
<field name="model" search="[('model', '=', 'stock.location.history')]"/>
<record model="ir.model.access" id="access_location_state">
<field name="model" search="[('model', '=', 'stock.location.state')]"/>
<field name="perm_read" eval="False"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
</record>
<record model="ir.model.access" id="access_location_history_stock">
<field name="model" search="[('model', '=', 'stock.location.history')]"/>
<field name="group" ref="stock.group_stock"/>
<record model="ir.model.access" id="access_location_state_stock">
<field name="model" search="[('model', '=', 'stock.location.state')]"/>
<field name="group" ref="stock.group_stock_admin"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_delete" eval="False"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_delete" eval="True"/>
</record>
<!-- Form relate -->
<record model="ir.action.act_window" id="act_location_state">
<field name="name">Histoy State</field>
<field name="res_model">stock.location.state</field>
<field name="domain" eval="[If(Eval('active_ids', []) == [Eval('active_id')], ('location', '=', Eval('active_id')), ('location', 'in', Eval('active_ids')))]" pyson="1"/>
</record>
<record model="ir.action.act_window.view" id="act_location_state_view1">
<field name="sequence" eval="10"/>
<field name="view" ref="location_state_view_tree"/>
<field name="act_window" ref="act_location_state"/>
</record>
<record model="ir.action.keyword" id="act_open_location_states_keyword1">
<field name="keyword">form_relate</field>
<field name="model">stock.location,-1</field>
<field name="action" ref="act_location_state"/>
</record>
<!-- Buttons -->

View File

@ -2,6 +2,7 @@
# copyright notices and license terms.
import unittest
import time
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
import trytond.tests.test_tryton
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
@ -17,14 +18,18 @@ class LocationStateHistoryTestCase(ModuleTestCase):
def test_change_state(self):
"""Change location state"""
Location = Pool().get('stock.location')
State = Pool().get('stock.location.state')
transaction = Transaction()
production, = Location.create([
{'code': 'PL',
'name': 'Production location',
'type': 'production'}])
production, = Location.create([{
'code': 'PL',
'name': 'Production location',
'type': 'production'
}])
self.assertEqual(production.state, 'on')
transaction.commit()
from_date = datetime.now().replace(microsecond=0
) - relativedelta(seconds=5)
time.sleep(2)
Location.off([production])
@ -32,35 +37,70 @@ class LocationStateHistoryTestCase(ModuleTestCase):
transaction.commit()
time.sleep(2)
child, = Location.create([
{'code': 'PL2',
'name': 'Prod. location 2',
'type': 'production',
'parent': production.id,
'state': 'off'}])
child, = Location.create([{
'code': 'PL2',
'name': 'Prod. location 2',
'type': 'production',
'parent': production.id,
'state': 'off'
}])
time.sleep(1)
Location.on([production])
transaction.commit()
self.assert_(production.state == 'on' and child.state == 'on')
self.assertTrue(production.state == 'on' and child.state == 'on')
self.assertEqual(len(production.history), 3)
self.assertEqual(len(production.states), 3)
values = {}
for line in production.history:
for line in production.states:
values.setdefault(line.state, 0)
values[line.state] += 1
self.assertEqual(values, {'on': 2, 'off': 1})
self.assertEqual(production.history_state, 'on')
self.assertEqual(production.state_at, 'on')
at_date, = [h.date for h in production.history if h.state == 'off']
at_date, = [h.date for h in production.states if h.state == 'off']
at_date += relativedelta(seconds=0.5)
with Transaction().set_context(state_at_date=at_date):
other_loc, = Location.search([
('code', '=', 'PL'),
('history_state', '=', 'off')])
('state_at', '=', 'off')])
self.assertEqual(production.id, other_loc.id)
self.assertEqual(production.get_history_state(
self.assertEqual(production.get_state_at(
[production])[production.id], 'off')
self.assertEqual(Location.search([
('code', '=', 'PL'),
('state_at', '=', 'on')]) or [], [])
# check time in on state
self.assertEqual(production.get_state_time_on(from_date,
from_date + relativedelta(seconds=9)), timedelta(seconds=2))
self.assertEqual(production.get_state_time_on(
from_date + relativedelta(seconds=8),
from_date + relativedelta(seconds=12)), timedelta(seconds=2))
self.assertEqual(
production.get_state_time_on(
from_date + relativedelta(seconds=5),
from_date + relativedelta(seconds=12)
) +
production.get_state_time_off(
from_date + relativedelta(seconds=5),
from_date + relativedelta(seconds=12)
), timedelta(seconds=7))
# check update current state
self.assertEqual(len(production.states), 3)
State.delete([production.states[0]])
transaction.commit()
self.assertEqual(production.state, 'off')
self.assertEqual(len(production.states), 2)
Location.write([production], {
'last_states': [('delete', [production.states[-1]])]
})
transaction.commit()
production = Location(production.id)
self.assertEqual(production.state, 'off')
self.assertEqual(len(production.states), 1)
def suite():

View File

@ -7,6 +7,7 @@ depends:
extras_depend:
stock_location_combined
stock_unit_load
xml:
location.xml

32
unit_load.py Normal file
View File

@ -0,0 +1,32 @@
# The COPYRIGHT file at the top level of this repository contains the full
# copyright notices and license terms.
from trytond.pool import PoolMeta
class UnitLoad(metaclass=PoolMeta):
__name__ = 'stock.unit_load'
def get_production_time(self, name=None):
result = None
if self.production_location:
result = self.production_location.get_state_time_on(
self.start_date, self.end_date)
if result is not None:
return result
return super().get_production_time(name)
class UnitLoadCombined(metaclass=PoolMeta):
__name__ = 'stock.unit_load'
def get_production_time(self, name=None):
if self.location_combined and self.production_locations:
times = []
for location in self.production_locations:
time_ = location.get_state_time_on(
self.start_date, self.end_date)
if time_ is not None:
times.append(time_)
if times:
return min(times)
return super().get_production_time(name)

View File

@ -3,8 +3,8 @@
this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/notebook" position="inside">
<page id="history" string="History">
<field name="history" colspan="4"/>
<page name="states">
<field name="last_states" colspan="4"/>
</page>
</xpath>
<xpath expr="/form" position="inside">

View File

@ -1,9 +1,9 @@
<?xml version="1.0"?>
<!-- The COPYRIGHT file at the top level of this repository contains the full
copyright notices and license terms. -->
<tree>
<tree editable="top">
<field name="location"/>
<field name="date" widget="date"/>
<field name="date" widget="time" string="Change Time"/>
<field name="user"/>
<field name="date" widget="time" string="Time"/>
<field name="state"/>
</tree>

View File

@ -4,7 +4,7 @@ this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/tree" position="inside">
<field name="state" icon="state_icon"/>
<button string="Start" name="on"/>
<button string="Stop" name="off"/>
<button name="on"/>
<button name="off"/>
</xpath>
</data>