Moved to new query condition syntax to support is, is not, contains, contains not, ...

This commit is contained in:
Martin Edenhofer 2015-09-17 20:39:51 +02:00
parent 3d47199ec5
commit d825cc288b
16 changed files with 618 additions and 333 deletions

View file

@ -1,111 +1,58 @@
class App.UiElement.ticket_selector class App.UiElement.ticket_selector
@render: (attribute, params = {}) -> @defaults: ->
defaults = ['ticket.state_id']
# list of attributes
groups = groups =
tickets: ticket:
name: 'Ticket' name: 'Ticket'
model: 'Ticket' model: 'Ticket'
users: customer:
name: 'Customer' name: 'Customer'
model: 'User' model: 'User'
organizations: organization:
name: 'Organization' name: 'Organization'
model: 'Organization' model: 'Organization'
elements = operators_type =
tickets: '^datetime$': ['before (absolute)', 'after (absolute)', 'before (relative)', 'after (relative)']
title: '^timestamp$': ['before (absolute)', 'after (absolute)', 'before (relative)', 'after (relative)']
tag: 'input' 'boolean$': ['is', 'is not']
operator: ['contains', 'contains not'] '^input$': ['contains', 'contains not']
number: '^textarea$': ['contains', 'contains not']
tag: 'input'
operator: ['contains', 'contains not'] operators_name =
group_id: '_id$': ['is', 'is not']
relation: 'Group' '_ids$': ['is', 'is not']
tag: 'select'
multible: true
operator: ['is', 'is not']
priority_id:
relation: 'Priority'
tag: 'select'
multible: true
operator: ['is', 'is not']
state_id:
relation: 'State'
tag: 'select'
multible: true
operator: ['is', 'is not']
owner_id:
tag: 'user_selection'
relation: 'User'
operator: ['is', 'is not']
customer_id:
tag: 'user_selection'
relation: 'User'
operator: ['is', 'is not']
organization_id:
tag: ''
relation: 'Organization'
operator: ['is', 'is not']
tag:
tag: 'tag'
multible: true
operator: ['is', 'is not']
created_at:
tag: 'timestamp'
operator: ['before', 'after']
updated_at:
tag: 'timestamp'
operator: ['before', 'after']
escalation_time:
tag: 'timestamp'
operator: ['before', 'after']
users:
firstname:
tag: 'input'
operator: ['contains', 'contains not']
lastname:
tag: 'input'
operator: ['contains', 'contains not']
email:
tag: 'input'
operator: ['contains', 'contains not']
login:
tag: 'input'
operator: ['contains', 'contains not']
created_at:
tag: 'time_selector_enhanced'
operator: ['before', 'after']
updated_at:
tag: 'time_selector_enhanced'
operator: ['before', 'after']
organizations:
name:
tag: 'input'
operator: ['contains', 'contains not']
shared:
tag: 'boolean'
operator: ['is', 'is not']
created_at:
tag: 'time_selector_enhanced'
operator: ['before', 'after']
updated_at:
tag: 'time_selector_enhanced'
operator: ['before', 'after']
# megre config # megre config
elements = {}
for groupKey, groupMeta of groups for groupKey, groupMeta of groups
for elementKey, elementGroup of elements for row in App[groupMeta.model].configure_attributes
if elementKey is groupKey
configure_attributes = App[groupMeta.model].configure_attributes # ignore passwords and relations
for attributeName, attributeConfig of elementGroup if row.type isnt 'password' && row.name.substr(row.name.length-4,4) isnt '_ids'
for attribute in configure_attributes config = _.clone(row)
if attribute.name is attributeName for operatorRegEx, operator of operators_type
attributeConfig.config = attribute myRegExp = new RegExp(operatorRegEx, 'i')
if config.tag && config.tag.match(myRegExp)
config.operator = operator
elements["#{groupKey}.#{config.name}"] = config
for operatorRegEx, operator of operators_name
myRegExp = new RegExp(operatorRegEx, 'i')
if config.name && config.name.match(myRegExp)
config.operator = operator
elements["#{groupKey}.#{config.name}"] = config
[defaults, groups, elements]
@render: (attribute, params = {}) ->
[defaults, groups, elements] = @defaults()
selector = @buildAttributeSelector(groups, elements) selector = @buildAttributeSelector(groups, elements)
search = =>
@preview(item)
# return item # return item
item = $( App.view('generic/ticket_selector')( attribute: attribute ) ) item = $( App.view('generic/ticket_selector')( attribute: attribute ) )
item.find('.js-attributeSelector').prepend(selector) item.find('.js-attributeSelector').prepend(selector)
@ -116,12 +63,14 @@ class App.UiElement.ticket_selector
elementClone = element.clone(true) elementClone = element.clone(true)
element.after(elementClone) element.after(elementClone)
elementClone.find('.js-attributeSelector select').trigger('change') elementClone.find('.js-attributeSelector select').trigger('change')
@preview(item)
) )
# remove filter # remove filter
item.find('.js-remove').bind('click', (e) => item.find('.js-remove').bind('click', (e) =>
$(e.target).closest('.js-filterElement').remove() $(e.target).closest('.js-filterElement').remove()
@rebuildAttributeSelectors(item) @rebuildAttributeSelectors(item)
@preview(item)
) )
# change filter # change filter
@ -158,9 +107,20 @@ class App.UiElement.ticket_selector
if selectorExists if selectorExists
item.find('.js-filterElement').first().remove() item.find('.js-filterElement').first().remove()
else
for default_row in defaults
# get selector rows
elementFirst = item.find('.js-filterElement').first()
elementLast = item.find('.js-filterElement').last()
# clone, rebuild and append
elementClone = elementFirst.clone(true)
@rebuildAttributeSelectors(item, elementClone, default_row)
elementLast.after(elementClone)
item.find('.js-filterElement').first().remove()
# bind for preview # bind for preview
search = =>
@preview(item)
item.on('change', 'select.form-control', (e) => item.on('change', 'select.form-control', (e) =>
App.Delay.set( App.Delay.set(
search, search,
@ -168,7 +128,7 @@ class App.UiElement.ticket_selector
'preview', 'preview',
) )
) )
item.on('keyup', 'input.form-control', (e) => item.on('change keyup', 'input.form-control', (e) =>
App.Delay.set( App.Delay.set(
search, search,
600, 600,
@ -200,13 +160,6 @@ class App.UiElement.ticket_selector
ticket_ids: ticket_ids ticket_ids: ticket_ids
) )
@getElementConfig: (groupAndAttribute, elements) ->
for elementGroup, elementConfig of elements
for elementKey, elementItem of elementConfig
if "#{elementGroup}.#{elementKey}" is groupAndAttribute
return elementItem
false
@buildValue: (elementFull, elementRow, groupAndAttribute, elements, value) -> @buildValue: (elementFull, elementRow, groupAndAttribute, elements, value) ->
# do nothing if item already exists # do nothing if item already exists
@ -214,16 +167,32 @@ class App.UiElement.ticket_selector
return if elementRow.find("[name=\"#{name}\"]").get(0) return if elementRow.find("[name=\"#{name}\"]").get(0)
# build new item # build new item
attributeConfig = @getElementConfig(groupAndAttribute, elements) attributeConfig = elements[groupAndAttribute]
config = _.clone(attributeConfig)
# force to use auto compition on user lookup
if config.relation is 'User'
config.tag = 'user_autocompletion'
# render ui element
item = '' item = ''
if attributeConfig && attributeConfig.config && App.UiElement[attributeConfig.config.tag] if config && App.UiElement[config.tag]
config = _.clone(attributeConfig.config)
config['name'] = name config['name'] = name
config['value'] = value config['value'] = value
if 'multiple' of config if 'multiple' of config
config.multiple = true config.multiple = true
config.nulloption = false config.nulloption = false
item = App.UiElement[attributeConfig.config.tag].render(config, {}) if config.tag is 'checkbox'
config.tag = 'select'
#config.type = 'datetime-local'
#if config.tag is 'datetime'
# config.tag = 'input'
# config.type = 'datetime-local'
tagSearch = "#{config.tag}_search"
if App.UiElement[tagSearch]
item = App.UiElement[tagSearch].render(config, {})
else
item = App.UiElement[config.tag].render(config, {})
elementRow.find('.js-value').html(item) elementRow.find('.js-value').html(item)
@buildAttributeSelector: (groups, elements) -> @buildAttributeSelector: (groups, elements) ->
@ -233,13 +202,12 @@ class App.UiElement.ticket_selector
selection.closest('select').append("<optgroup label=\"#{displayName}\" class=\"js-#{groupKey}\"></optgroup>") selection.closest('select').append("<optgroup label=\"#{displayName}\" class=\"js-#{groupKey}\"></optgroup>")
optgroup = selection.find("optgroup.js-#{groupKey}") optgroup = selection.find("optgroup.js-#{groupKey}")
for elementKey, elementGroup of elements for elementKey, elementGroup of elements
if elementKey is groupKey spacer = elementKey.split(/\./)
for attributeName, attributeConfig of elementGroup if spacer[0] is groupKey
if attributeConfig.config && attributeConfig.config.display attributeConfig = elements[elementKey]
displayName = App.i18n.translateInline(attributeConfig.config.display) if attributeConfig.operator
else displayName = App.i18n.translateInline(attributeConfig.display)
displayName = App.i18n.translateInline(attributeName) optgroup.append("<option value=\"#{elementKey}\">#{displayName}</option>")
optgroup.append("<option value=\"#{groupKey}.#{attributeName}\">#{displayName}</option>")
selection selection
@rebuildAttributeSelectors: (elementFull, elementRow, groupAndAttribute) -> @rebuildAttributeSelectors: (elementFull, elementRow, groupAndAttribute) ->
@ -267,7 +235,9 @@ class App.UiElement.ticket_selector
@buildOperator: (elementFull, elementRow, groupAndAttribute, elements, current_operator) -> @buildOperator: (elementFull, elementRow, groupAndAttribute, elements, current_operator) ->
selection = $("<select class=\"form-control\" name=\"condition::#{groupAndAttribute}::operator\"></select>") selection = $("<select class=\"form-control\" name=\"condition::#{groupAndAttribute}::operator\"></select>")
attributeConfig = @getElementConfig(groupAndAttribute, elements)
attributeConfig = elements[groupAndAttribute]
if attributeConfig.operator
for operator in attributeConfig.operator for operator in attributeConfig.operator
operatorName = App.i18n.translateInline(operator) operatorName = App.i18n.translateInline(operator)
selected = '' selected = ''
@ -290,16 +260,43 @@ class App.UiElement.ticket_selector
@humanText: (condition) -> @humanText: (condition) ->
none = App.i18n.translateContent('No filter.') none = App.i18n.translateContent('No filter.')
return [none] if _.isEmpty(condition) return [none] if _.isEmpty(condition)
[defaults, groups, elements] = @defaults()
rules = [] rules = []
for position of condition.attribute for attribute, meta of condition
objectAttribute = attribute.split(/\./)
# get stored params # get stored params
groupAndAttribute = condition.attribute[position] if meta && objectAttribute[1]
if condition[groupAndAttribute]
selectorExists = true selectorExists = true
operator = condition[groupAndAttribute].operator operator = meta.operator
value = condition[groupAndAttribute].value value = meta.value
rules.push "#{App.i18n.translateContent('Where')} <b>#{App.i18n.translateContent(groupAndAttribute)}</b> #{App.i18n.translateContent(operator)} <b>#{App.i18n.translateContent(value)}</b>." model = toCamelCase(objectAttribute[0])
modelAttribute = objectAttribute[1]
config = elements[attribute]
if modelAttribute.substr(modelAttribute.length-4,4) is '_ids'
modelAttribute = modelAttribute.substr(0, modelAttribute.length-4)
if modelAttribute.substr(modelAttribute.length-3,3) is '_id'
modelAttribute = modelAttribute.substr(0, modelAttribute.length-3)
valueHuman = []
if _.isArray(value)
for data in value
r = @humanTextLookup(config, data)
valueHuman.push r
else
valueHuman.push @humanTextLookup(config, value)
rules.push "#{App.i18n.translateContent('Where')} <b>#{App.i18n.translateContent(model)} -> #{App.i18n.translateContent(toCamelCase(modelAttribute))}</b> #{App.i18n.translateContent(operator)} <b>#{valueHuman}</b>."
return [none] if _.isEmpty(rules) return [none] if _.isEmpty(rules)
rules rules
@humanTextLookup: (config, value) ->
return value if !App[config.relation]
return value if !App[config.relation].exists(value)
data = App[config.relation].fullLocal(value)
return value if !data
if data.displayName
return App.i18n.translateContent( data.displayName() )
valueHuman.push App.i18n.translateContent( data.name )

View file

@ -22,6 +22,7 @@ class Index extends App.ControllerContent
{ name: 'New Overview', 'data-type': 'new', class: 'btn--success' } { name: 'New Overview', 'data-type': 'new', class: 'btn--success' }
] ]
container: @el.closest('.content') container: @el.closest('.content')
large: true,
) )
App.Config.set( 'Overview', { prio: 2300, name: 'Overviews', parent: '#manage', target: '#manage/overviews', controller: Index, role: ['Admin'] }, 'NavBarAdmin' ) App.Config.set( 'Overview', { prio: 2300, name: 'Overviews', parent: '#manage', target: '#manage/overviews', controller: Index, role: ['Admin'] }, 'NavBarAdmin' )

View file

@ -1,16 +1,15 @@
class App.Overview extends App.Model class App.Overview extends App.Model
@configure 'Overview', 'name', 'link', 'prio', 'condition', 'order', 'group_by', 'view', 'user_id', 'organization_shared', 'role_id', 'order', 'group_by', 'active', 'updated_at' @configure 'Overview', 'name', 'prio', 'condition', 'order', 'group_by', 'view', 'user_id', 'organization_shared', 'role_id', 'order', 'group_by', 'active', 'updated_at'
@extend Spine.Model.Ajax @extend Spine.Model.Ajax
@url: @apiPath + '/overviews' @url: @apiPath + '/overviews'
@configure_attributes = [ @configure_attributes = [
{ name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, 'null': false, 'class': 'span4' }, { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, 'null': false },
{ name: 'link', display: 'URL', tag: 'input', type: 'text', limit: 100, 'null': false, 'class': 'span4' }, { name: 'role_id', display: 'Available for Role', tag: 'select', multiple: false, nulloption: true, null: false, relation: 'Role', translate: true },
{ name: 'role_id', display: 'Available for Role', tag: 'select', multiple: false, nulloption: true, null: false, relation: 'Role', translate: true, class: 'span4' }, { name: 'user_id', display: 'Available for User', tag: 'select', multiple: true, nulloption: true, null: true, relation: 'User', sortBy: 'firstname' },
{ name: 'user_id', display: 'Available for User', tag: 'select', multiple: true, nulloption: true, null: true, relation: 'User', sortBy: 'firstname', class: 'span4' }, { name: 'organization_shared', display: 'Only available for Users with shared Organization', tag: 'select', options: { true: 'yes', false: 'no' }, default: false, null: true },
{ name: 'organization_shared', display: 'Only available for Users with shared Organization', tag: 'select', options: { true: 'yes', false: 'no' }, default: false, null: true, 'class': 'span4' }, # { name: 'content', display: 'Content', tag: 'textarea', limit: 250, 'null': false },
# { name: 'content', display: 'Content', tag: 'textarea', limit: 250, 'null': false, 'class': 'span4' }, { name: 'condition', display: 'Conditions for shown Tickets', tag: 'ticket_selector', null: false },
{ name: 'condition', display: 'Conditions for shown Tickets', tag: 'ticket_attribute_selection', null: true, class: 'span4' }, { name: 'prio', display: 'Prio', tag: 'input', type: 'text', limit: 10, 'null': false },
{ name: 'prio', display: 'Prio', tag: 'input', type: 'text', limit: 10, 'null': false, 'class': 'span4' },
{ {
name: 'view::s' name: 'view::s'
display: 'Attributes' display: 'Attributes'

View file

@ -11,18 +11,18 @@ class App.Ticket extends App.Model
{ name: 'title', display: 'Title', tag: 'input', type: 'text', limit: 100, null: false, parentClass: 'noTruncate' }, { name: 'title', display: 'Title', tag: 'input', type: 'text', limit: 100, null: false, parentClass: 'noTruncate' },
{ name: 'state_id', display: 'State', tag: 'select', multiple: false, null: false, relation: 'TicketState', default: 'new', style: 'width: 12%', edit: true, customer: true, }, { name: 'state_id', display: 'State', tag: 'select', multiple: false, null: false, relation: 'TicketState', default: 'new', style: 'width: 12%', edit: true, customer: true, },
{ name: 'priority_id', display: 'Priority', tag: 'select', multiple: false, null: false, relation: 'TicketPriority', default: '2 normal', style: 'width: 12%', edit: true, customer: true, }, { name: 'priority_id', display: 'Priority', tag: 'select', multiple: false, null: false, relation: 'TicketPriority', default: '2 normal', style: 'width: 12%', edit: true, customer: true, },
{ name: 'article_count', display: 'Article#', style: 'width: 12%' },
{ name: 'escalation_time', display: 'Escalation', tag: 'datetime', null: true, style: 'width: 12%', class: 'escalation', parentClass: 'noTruncate' },
{ name: 'last_contact', display: 'Last contact', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' }, { name: 'last_contact', display: 'Last contact', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' },
{ name: 'last_contact_agent', display: 'Last contact (Agent)', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' }, { name: 'last_contact_agent', display: 'Last contact (Agent)', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' },
{ name: 'last_contact_customer', display: 'Last contact (Customer)', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' }, { name: 'last_contact_customer', display: 'Last contact (Customer)', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' },
{ name: 'first_response', display: 'First response', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' }, { name: 'first_response', display: 'First response', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' },
{ name: 'close_time', display: 'Close time', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' }, { name: 'close_time', display: 'Close time', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' },
{ name: 'pending_time', display: 'Pending Time', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' }, { name: 'pending_time', display: 'Pending Time', tag: 'datetime', null: true, style: 'width: 12%', parentClass: 'noTruncate' },
{ name: 'escalation_time', display: 'Escalation', tag: 'datetime', null: true, style: 'width: 12%', class: 'escalation', parentClass: 'noTruncate' },
{ name: 'article_count', display: 'Article#', style: 'width: 12%' },
{ name: 'created_by_id', display: 'Created by', relation: 'User', readonly: 1 }, { name: 'created_by_id', display: 'Created by', relation: 'User', readonly: 1 },
{ name: 'created_at', display: 'Created', tag: 'datetime', style: 'width: 120px', readonly: 1, parentClass: 'noTruncate' }, { name: 'created_at', display: 'Created at', tag: 'datetime', style: 'width: 120px', readonly: 1, parentClass: 'noTruncate' },
{ name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 }, { name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 },
{ name: 'updated_at', display: 'Updated', tag: 'datetime', style: 'width: 120px', readonly: 1, parentClass: 'noTruncate' }, { name: 'updated_at', display: 'Updated at', tag: 'datetime', style: 'width: 120px', readonly: 1, parentClass: 'noTruncate' },
] ]
uiUrl: -> uiUrl: ->

View file

@ -9,15 +9,7 @@ class App.User extends App.Model
{ name: 'firstname', display: 'Firstname', tag: 'input', type: 'text', limit: 100, null: false, signup: true, info: true, invite_agent: true }, { name: 'firstname', display: 'Firstname', tag: 'input', type: 'text', limit: 100, null: false, signup: true, info: true, invite_agent: true },
{ name: 'lastname', display: 'Lastname', tag: 'input', type: 'text', limit: 100, null: false, signup: true, info: true, invite_agent: true }, { name: 'lastname', display: 'Lastname', tag: 'input', type: 'text', limit: 100, null: false, signup: true, info: true, invite_agent: true },
{ name: 'email', display: 'Email', tag: 'input', type: 'email', limit: 100, null: false, signup: true, info: true, invite_agent: true }, { name: 'email', display: 'Email', tag: 'input', type: 'email', limit: 100, null: false, signup: true, info: true, invite_agent: true },
{ name: 'web', display: 'Web', tag: 'input', type: 'url', limit: 100, null: true, signup: false, info: true },
{ name: 'phone', display: 'Phone', tag: 'input', type: 'phone', limit: 100, null: true, signup: false, info: true },
{ name: 'mobile', display: 'Mobile', tag: 'input', type: 'phone', limit: 100, null: true, signup: false, info: true },
{ name: 'fax', display: 'Fax', tag: 'input', type: 'phone', limit: 100, null: true, signup: false, info: true },
{ name: 'organization_id', display: 'Organization', tag: 'select', multiple: false, nulloption: true, null: true, relation: 'Organization', signup: false, info: true }, { name: 'organization_id', display: 'Organization', tag: 'select', multiple: false, nulloption: true, null: true, relation: 'Organization', signup: false, info: true },
{ name: 'department', display: 'Department', tag: 'input', type: 'text', limit: 200, null: true, signup: false, info: true },
{ name: 'street', display: 'Street', tag: 'input', type: 'text', limit: 100, null: true, signup: false, info: true },
{ name: 'zip', display: 'Zip', tag: 'input', type: 'text', limit: 100, null: true, signup: false, info: true },
{ name: 'city', display: 'City', tag: 'input', type: 'text', limit: 100, null: true, signup: false, info: true },
{ name: 'password', display: 'Password', tag: 'input', type: 'password', limit: 50, null: true, autocomplete: 'off', signup: true, }, { name: 'password', display: 'Password', tag: 'input', type: 'password', limit: 50, null: true, autocomplete: 'off', signup: true, },
{ name: 'note', display: 'Note', tag: 'textarea', note: 'Notes are visible to agents only, never to customers.', limit: 250, null: true, info: true }, { name: 'note', display: 'Note', tag: 'textarea', note: 'Notes are visible to agents only, never to customers.', limit: 250, null: true, info: true },
{ name: 'role_ids', display: 'Roles', tag: 'checkbox', multiple: true, null: false, relation: 'Role' }, { name: 'role_ids', display: 'Roles', tag: 'checkbox', multiple: true, null: false, relation: 'Role' },

View file

@ -1,5 +0,0 @@
<div class="sub_attribute well">
<div class="ticket_attribute_item"></div>
<hr>
<div class="ticket_attribute_list"></div>
</div>

View file

@ -208,6 +208,13 @@ function underscored (str) {
return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase(); return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase();
} }
function toCamelCase (str) {
return str
.replace(/\s(.)/g, function($1) { return $1.toUpperCase(); })
.replace(/\s/g, '')
.replace(/^(.)/, function($1) { return $1.toUpperCase(); });
};
jQuery.event.special.remove = { jQuery.event.special.remove = {
remove: function(e) { remove: function(e) {
if (e.handler) e.handler(); if (e.handler) e.handler();

View file

@ -60,9 +60,33 @@ curl http://localhost/api/v1/slas.json -v -u #{login}:#{password}
# slas # slas
sla_ids = [] sla_ids = []
models = Models.all
Sla.all.order(:name).each {|sla| Sla.all.order(:name).each {|sla|
sla_ids.push sla.id sla_ids.push sla.id
assets = sla.assets(assets) assets = sla.assets(assets)
# get assets of condition
sla.condition.each {|item, content|
attribute = item.split(/\./)
next if !attribute[1]
attribute_class = attribute[0].to_classname.constantize
reflection = attribute[1].sub(/_id$/, '')
reflection = reflection.to_sym
next if !models[attribute_class]
next if !models[attribute_class][:reflections]
next if !models[attribute_class][:reflections][reflection]
next if !models[attribute_class][:reflections][reflection].klass
attribute_ref_class = models[attribute_class][:reflections][reflection].klass
if content['value'].class == Array
content['value'].each {|item_id|
attribute_object = attribute_ref_class.find_by(id: item_id)
assets = attribute_object.assets(assets)
}
else
attribute_object = attribute_ref_class.find_by(id: content['value'])
assets = attribute_object.assets(assets)
end
}
} }
render json: { render json: {

View file

@ -389,8 +389,14 @@ class TicketsController < ApplicationController
if params[:user_id] if params[:user_id]
user = User.find( params[:user_id] ) user = User.find( params[:user_id] )
condition = { condition = {
'tickets.state_id' => Ticket::State.by_category('open'), 'tickets.state_id' => {
'tickets.customer_id' => user.id, operator: 'is',
value: Ticket::State.by_category('open').map(&:id),
},
'tickets.customer_id' => {
operator: 'is',
value: user.id,
},
} }
user_tickets_open = Ticket.search( user_tickets_open = Ticket.search(
limit: limit, limit: limit,
@ -401,8 +407,14 @@ class TicketsController < ApplicationController
# lookup closed user tickets # lookup closed user tickets
condition = { condition = {
'tickets.state_id' => Ticket::State.by_category('closed'), 'tickets.state_id' => {
'tickets.customer_id' => user.id, operator: 'is',
value: Ticket::State.by_category('closed').map(&:id),
},
'tickets.customer_id' => {
operator: 'is',
value: user.id,
},
} }
user_tickets_closed = Ticket.search( user_tickets_closed = Ticket.search(
limit: limit, limit: limit,
@ -451,8 +463,14 @@ class TicketsController < ApplicationController
if params[:organization_id] && !params[:organization_id].empty? if params[:organization_id] && !params[:organization_id].empty?
condition = { condition = {
'tickets.state_id' => Ticket::State.by_category('open'), 'tickets.state_id' => {
'tickets.organization_id' => params[:organization_id], operator: 'is',
value: Ticket::State.by_category('open').map(&:id),
},
'tickets.organization_id' => {
operator: 'is',
value: params[:organization_id],
},
} }
org_tickets_open = Ticket.search( org_tickets_open = Ticket.search(
limit: limit, limit: limit,
@ -463,8 +481,14 @@ class TicketsController < ApplicationController
# lookup closed org tickets # lookup closed org tickets
condition = { condition = {
'tickets.state_id' => Ticket::State.by_category('closed'), 'tickets.state_id' => {
'tickets.organization_id' => params[:organization_id], operator: 'is',
value: Ticket::State.by_category('closed').map(&:id),
},
'tickets.organization_id' => {
operator: 'is',
value: params[:organization_id],
},
} }
org_tickets_closed = Ticket.search( org_tickets_closed = Ticket.search(
limit: limit, limit: limit,

View file

@ -6,5 +6,19 @@ class Overview < ApplicationModel
store :view store :view
validates :name, presence: true validates :name, presence: true
validates :prio, presence: true validates :prio, presence: true
validates :link, presence: true
before_create :fill_link
before_update :fill_link
private
# fill link
def fill_link
return true if link && !link.empty?
self.link = name.downcase
link.gsub!(/\s/, '_')
link.gsub!(/[^0-9a-z]/i, '_')
link.gsub!(/_+/, '_')
end
end end

View file

@ -270,9 +270,10 @@ returns
def self.selectors(selectors, limit = 10) def self.selectors(selectors, limit = 10)
return if !selectors return if !selectors
query, bind_params = _selectors(selectors) query, bind_params, tables = _selectors(selectors)
ticket_count = Ticket.where(query, *bind_params).count return [] if !query
tickets = Ticket.where(query, *bind_params).limit(limit) ticket_count = Ticket.where(query, *bind_params).joins(tables).count
tickets = Ticket.where(query, *bind_params).joins(tables).limit(limit)
[ticket_count, tickets] [ticket_count, tickets]
end end
@ -281,6 +282,15 @@ returns
query = '' query = ''
bind_params = [] bind_params = []
tables = []
selectors.each {|attribute, selector|
selector = attribute.split(/\./)
next if !selector[1]
next if selector[0] == 'ticket'
next if tables.include?(selector[0])
tables.push selector[0].to_sym
}
selectors.each {|attribute, selector| selectors.each {|attribute, selector|
if query != '' if query != ''
query += ' AND ' query += ' AND '
@ -289,7 +299,9 @@ returns
next if !selector.respond_to?(:key?) next if !selector.respond_to?(:key?)
next if !selector['operator'] next if !selector['operator']
return nil if !selector['value'] return nil if !selector['value']
return nil if selector['value'].respond_to?(:key?) && selector['value'].empty? return nil if selector['value'].respond_to?(:empty?) && selector['value'].empty?
attributes = attribute.split(/\./)
attribute = "#{attributes[0]}s.#{attributes[1]}"
if selector['operator'] == 'is' if selector['operator'] == 'is'
query += "#{attribute} IN (?)" query += "#{attribute} IN (?)"
bind_params.push selector['value'] bind_params.push selector['value']
@ -304,14 +316,23 @@ returns
query += "#{attribute} NOT LIKE (?)" query += "#{attribute} NOT LIKE (?)"
value = "%#{selector['value']}%" value = "%#{selector['value']}%"
bind_params.push value bind_params.push value
elsif selector['operator'] == 'before' elsif selector['operator'] == 'before (absolute)'
query += "#{attribute} <= (?)" query += "#{attribute} <= ?"
bind_params.push selector['value'] bind_params.push selector['value']
elsif selector['operator'] == 'after (absolute)'
query += "#{attribute} >= ?"
bind_params.push selector['value']
elsif selector['operator'] == 'before (relative)'
query += "#{attribute} <= ?"
bind_params.push Time.zone.now - selector['value'].to_i.minutes
elsif selector['operator'] == 'after (relative)'
query += "#{attribute} >= ?"
bind_params.push Time.zone.now + selector['value'].to_i.minutes
else else
fail "Invalid operator '#{selector['operator']}' for '#{selector['value'].inspect}'" fail "Invalid operator '#{selector['operator']}' for '#{selector['value'].inspect}'"
end end
} }
[query, bind_params] [query, bind_params, tables]
end end
private private

View file

@ -39,16 +39,16 @@ returns
selected overview by user selected overview by user
result = Ticket::Overviews.list( result = Ticket::Overviews.list(
:current_user => User.find(123), current_user: User.find(123),
:view => 'some_view_url', view: 'some_view_url',
) )
returns returns
result = { result = {
:tickets => tickets, # [ticket1, ticket2, ticket3] tickets: tickets, # [ticket1, ticket2, ticket3]
:tickets_count => tickets_count, # count of tickets tickets_count: tickets_count, # count of tickets
:overview => overview_selected_raw, # overview attributes overview: overview_selected_raw, # overview attributes
} }
=end =end
@ -71,18 +71,29 @@ returns
end end
# replace e.g. 'current_user.id' with current_user.id # replace e.g. 'current_user.id' with current_user.id
overview.condition.each { |item, value| overview.condition.each { |attribute, content|
next if !content
next if !value next if !content.respond_to?(:key?)
next if value.class.to_s != 'String' next if !content['value']
next if content['value'].class != String && content['value'].class != Array
parts = value.split( '.', 2 )
if content['value'].class == String
parts = content['value'].split( '.', 2 )
next if !parts[0] next if !parts[0]
next if !parts[1] next if !parts[1]
next if parts[0] != 'current_user' next if parts[0] != 'current_user'
overview.condition[attribute]['value'] = data[:current_user][parts[1].to_sym]
next
end
overview.condition[item] = data[:current_user][parts[1].to_sym] content['value'].each {|item|
next if item.class != String
parts = item.split('.', 2)
next if !parts[0]
next if !parts[1]
next if parts[0] != 'current_user'
item = data[:current_user][parts[1].to_sym]
}
} }
} }
@ -90,37 +101,8 @@ returns
fail "No such view '#{data[:view]}'" fail "No such view '#{data[:view]}'"
end end
# sortby
# prio
# state
# group
# customer
# order
# asc
# desc
# groupby
# prio
# state
# group
# customer
# all = attributes[:myopenassigned]
# all.merge( { :group_id => groups } )
# @tickets = Ticket.where(:group_id => groups, attributes[:myopenassigned] ).limit(params[:limit])
# get only tickets with permissions # get only tickets with permissions
if data[:current_user].role?('Customer') access_condition = Ticket.access_condition( data[:current_user] )
group_ids = Group.select( 'groups.id' )
.where( 'groups.active = ?', true )
.map( &:id )
else
group_ids = Group.select( 'groups.id' ).joins(:users)
.where( 'groups_users.user_id = ?', [ data[:current_user].id ] )
.where( 'groups.active = ?', true )
.map( &:id )
end
# overview meta for navbar # overview meta for navbar
if !overview_selected if !overview_selected
@ -129,8 +111,10 @@ returns
result = [] result = []
overviews.each { |overview| overviews.each { |overview|
query_condition, bind_condition = Ticket._selectors(overview.condition)
# get count # get count
count = Ticket.where( group_id: group_ids ).where( _condition( overview.condition ) ).count() count = Ticket.where( access_condition ).where( query_condition, *bind_condition ).count()
# get meta info # get meta info
all = { all = {
@ -151,9 +135,12 @@ returns
if overview_selected.group_by && !overview_selected.group_by.empty? if overview_selected.group_by && !overview_selected.group_by.empty?
order_by = overview_selected.group_by + '_id, ' + order_by order_by = overview_selected.group_by + '_id, ' + order_by
end end
tickets = Ticket.select( 'id' )
.where( group_id: group_ids ) query_condition, bind_condition = Ticket._selectors(overview_selected.condition)
.where( _condition( overview_selected.condition ) )
tickets = Ticket.select('id')
.where( access_condition )
.where( query_condition, *bind_condition )
.order( order_by ) .order( order_by )
.limit( 500 ) .limit( 500 )
@ -162,9 +149,7 @@ returns
ticket_ids.push ticket.id ticket_ids.push ticket.id
} }
tickets_count = Ticket.where( group_id: group_ids ) tickets_count = Ticket.where( access_condition ).where( query_condition, *bind_condition ).count()
.where( _condition( overview_selected.condition ) )
.count()
return { return {
ticket_ids: ticket_ids, ticket_ids: ticket_ids,
@ -175,15 +160,12 @@ returns
# get tickets for overview # get tickets for overview
data[:start_page] ||= 1 data[:start_page] ||= 1
tickets = Ticket.where( group_id: group_ids ) query_condition, bind_condition = Ticket._selectors(overview_selected.condition)
.where( _condition( overview_selected.condition ) ) tickets = Ticket.where( access_condition )
.where( query_condition, *bind_condition )
.order( overview_selected[:order][:by].to_s + ' ' + overview_selected[:order][:direction].to_s ) .order( overview_selected[:order][:by].to_s + ' ' + overview_selected[:order][:direction].to_s )
# .limit( overview_selected.view[ data[:view_mode].to_sym ][:per_page] )
# .offset( overview_selected.view[ data[:view_mode].to_sym ][:per_page].to_i * ( data[:start_page].to_i - 1 ) )
tickets_count = Ticket.where( group_id: group_ids ) tickets_count = Ticket.where( access_condition ).where( query_condition, *bind_condition ).count()
.where( _condition( overview_selected.condition ) )
.count()
{ {
tickets: tickets, tickets: tickets,
@ -192,64 +174,4 @@ returns
} }
end end
private
def self._condition(condition)
sql = ''
bind = [nil]
condition.each {|key, value|
if sql != ''
sql += ' AND '
end
if value.class == Array
sql += " #{key} IN (?)"
bind.push value
elsif value.class == Hash || value.class == ActiveSupport::HashWithIndifferentAccess
time = Time.zone.now
if value['area'] == 'minute'
if value['direction'] == 'last'
time -= value['count'].to_i * 60
else
time += value['count'].to_i * 60
end
elsif value['area'] == 'hour'
if value['direction'] == 'last'
time -= value['count'].to_i * 60 * 60
else
time += value['count'].to_i * 60 * 60
end
elsif value['area'] == 'day'
if value['direction'] == 'last'
time -= value['count'].to_i * 60 * 60 * 24
else
time += value['count'].to_i * 60 * 60 * 24
end
elsif value['area'] == 'month'
if value['direction'] == 'last'
time -= value['count'].to_i * 60 * 60 * 24 * 31
else
time += value['count'].to_i * 60 * 60 * 24 * 31
end
elsif value['area'] == 'year'
if value['direction'] == 'last'
time -= value['count'].to_i * 60 * 60 * 24 * 365
else
time += value['count'].to_i * 60 * 60 * 24 * 365
end
end
if value['direction'] == 'last'
sql += " #{key} > ?"
bind.push time
else
sql += " #{key} < ?"
bind.push time
end
else
sql += " #{key} = ?"
bind.push value
end
}
bind[0] = sql
bind
end
end end

View file

@ -59,14 +59,20 @@ search tickets via database
result = Ticket.search( result = Ticket.search(
current_user: User.find(123), current_user: User.find(123),
condition: { condition: {
'tickets.owner_id' => user.id, 'tickets.owner_id' => {
'tickets.state_id' => Ticket::State.where( operator: 'is',
value: user.id,
},
'tickets.state_id' => {
operator: 'is',
value: Ticket::State.where(
state_type_id: Ticket::StateType.where( state_type_id: Ticket::StateType.where(
name: [ name: [
'pending reminder', 'pending reminder',
'pending action', 'pending action',
], ],
), ).map(&:id),
},
), ),
}, },
limit: 15, limit: 15,
@ -154,9 +160,10 @@ returns
.order('`tickets`.`created_at` DESC') .order('`tickets`.`created_at` DESC')
.limit(limit) .limit(limit)
else else
query_condition, bind_condition = _selectors(params[:condition])
tickets_all = Ticket.select('DISTINCT(tickets.id)') tickets_all = Ticket.select('DISTINCT(tickets.id)')
.where(access_condition) .where(access_condition)
.where(params[:condition]) .where(query_condition, *bind_condition)
.order('`tickets`.`created_at` DESC') .order('`tickets`.`created_at` DESC')
.limit(limit) .limit(limit)
end end

View file

@ -0,0 +1,218 @@
class UpdateOverview3 < ActiveRecord::Migration
def up
UserInfo.current_user_id = 1
overview_role = Role.where( name: 'Agent' ).first
Overview.create_or_update(
name: 'My assigned Tickets',
link: 'my_assigned',
prio: 1000,
role_id: overview_role.id,
condition: {
'ticket.state_id' => {
operator: 'is',
value: [ 1, 2, 3, 7 ],
},
'ticket.owner_id' => {
operator: 'is',
value: 'current_user.id',
},
},
order: {
by: 'created_at',
direction: 'ASC',
},
view: {
d: %w(title customer group created_at),
s: %w(title customer group created_at),
m: %w(number title customer group created_at),
view_mode_default: 's',
},
)
Overview.create_or_update(
name: 'My pending reached Tickets',
link: 'my_pending_reached',
prio: 1010,
role_id: overview_role.id,
condition: {
'ticket.state_id' => {
operator: 'is',
value: 3,
},
'ticket.owner_id' => {
operator: 'is',
value: 'current_user.id',
},
'ticket.pending_time' => {
operator: 'after (relative)',
value: '1',
},
},
order: {
by: 'created_at',
direction: 'ASC',
},
view: {
d: %w(title customer group created_at),
s: %w(title customer group created_at),
m: %w(number title customer group created_at),
view_mode_default: 's',
},
)
Overview.create_or_update(
name: 'Unassigned & Open Tickets',
link: 'all_unassigned',
prio: 1020,
role_id: overview_role.id,
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
'ticket.owner_id' => {
operator: 'is',
value: 1,
},
},
order: {
by: 'created_at',
direction: 'ASC',
},
view: {
d: %w(title customer group created_at),
s: %w(title customer group created_at),
m: %w(number title customer group created_at),
view_mode_default: 's',
},
)
Overview.create_or_update(
name: 'All Open Tickets',
link: 'all_open',
prio: 1030,
role_id: overview_role.id,
condition: {
'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
},
order: {
by: 'created_at',
direction: 'ASC',
},
view: {
d: %w(title customer group state owner created_at),
s: %w(title customer group state owner created_at),
m: %w(number title customer group state owner created_at),
view_mode_default: 's',
},
)
Overview.create_or_update(
name: 'All pending reached Tickets',
link: 'all_pending_reached',
prio: 1035,
role_id: overview_role.id,
condition: {
'ticket.state_id' => {
operator: 'is',
value: [3],
},
'ticket.pending_time' => {
operator: 'after (relative)',
value: 1,
},
},
order: {
by: 'created_at',
direction: 'ASC',
},
view: {
d: %w(title customer group owner created_at),
s: %w(title customer group owner created_at),
m: %w(number title customer group owner created_at),
view_mode_default: 's',
},
)
Overview.create_or_update(
name: 'Escalated Tickets',
link: 'all_escalated',
prio: 1040,
role_id: overview_role.id,
condition: {
'ticket.escalation_time' => {
operator: 'before (relative)',
value: 5,
},
},
order: {
by: 'escalation_time',
direction: 'ASC',
},
view: {
d: %w(title customer group owner escalation_time),
s: %w(title customer group owner escalation_time),
m: %w(number title customer group owner escalation_time),
view_mode_default: 's',
},
)
overview_role = Role.where( name: 'Customer' ).first
Overview.create_or_update(
name: 'My Tickets',
link: 'my_tickets',
prio: 1000,
role_id: overview_role.id,
condition: {
'ticket.state_id' => {
operator: 'is',
value: [ 1, 2, 3, 4, 6 ],
},
'ticket.customer_id' => {
operator: 'is',
value: 'current_user.id',
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title state created_at),
m: %w(number title state created_at),
view_mode_default: 's',
},
)
Overview.create_or_update(
name: 'My Organization Tickets',
link: 'my_organization_tickets',
prio: 1100,
role_id: overview_role.id,
organization_shared: true,
condition: {
'ticket.state_id' => {
operator: 'is',
value: [ 1, 2, 3, 4, 6 ],
},
'ticket.organization_id' => {
operator: 'is',
value: 'current_user.organization_id',
},
},
order: {
by: 'created_at',
direction: 'DESC',
},
view: {
d: %w(title customer state created_at),
s: %w(number title customer state created_at),
m: %w(number title customer state created_at),
view_mode_default: 's',
},
)
end
end

View file

@ -1575,8 +1575,14 @@ Overview.create_if_not_exists(
prio: 1000, prio: 1000,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.state_id' => [ 1, 2, 3, 7 ], 'ticket.state_id' => {
'tickets.owner_id' => 'current_user.id', operator: 'is',
value: [ 1, 2, 3, 7 ],
},
'ticket.owner_id' => {
operator: 'is',
value: current_user.id,
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',
@ -1596,9 +1602,18 @@ Overview.create_if_not_exists(
prio: 1010, prio: 1010,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.state_id' => [3], 'ticket.state_id' => {
'tickets.owner_id' => 'current_user.id', operator: 'is',
'tickets.pending_time' => { 'direction' => 'before', 'count' => 1, 'area' => 'minute' }, value: 3,
},
'ticket.owner_id' => {
operator: 'is',
value: 'current_user.id',
},
'ticket.pending_time' => {
operator: 'after (relative)',
value: '1',
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',
@ -1618,8 +1633,14 @@ Overview.create_if_not_exists(
prio: 1020, prio: 1020,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.state_id' => [1, 2, 3], 'ticket.state_id' => {
'tickets.owner_id' => 1, operator: 'is',
value: [1, 2, 3],
},
'ticket.owner_id' => {
operator: 'is',
value: 1,
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',
@ -1639,7 +1660,10 @@ Overview.create_if_not_exists(
prio: 1030, prio: 1030,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.state_id' => [1, 2, 3], 'ticket.state_id' => {
operator: 'is',
value: [1, 2, 3],
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',
@ -1659,8 +1683,14 @@ Overview.create_if_not_exists(
prio: 1035, prio: 1035,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.state_id' => [3], 'ticket.state_id' => {
'tickets.pending_time' => { 'direction' => 'before', 'count' => 1, 'area' => 'minute' }, operator: 'is',
value: [3],
},
'ticket.pending_time' => {
operator: 'after (relative)',
value: 1,
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',
@ -1680,7 +1710,10 @@ Overview.create_if_not_exists(
prio: 1040, prio: 1040,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.escalation_time' => { 'direction' => 'before', 'count' => 5, 'area' => 'minute' }, 'ticket.escalation_time' => {
operator: 'before (relative)',
value: 5,
},
}, },
order: { order: {
by: 'escalation_time', by: 'escalation_time',
@ -1701,8 +1734,14 @@ Overview.create_if_not_exists(
prio: 1000, prio: 1000,
role_id: overview_role.id, role_id: overview_role.id,
condition: { condition: {
'tickets.state_id' => [ 1, 2, 3, 4, 6 ], 'ticket.state_id' => {
'tickets.customer_id' => 'current_user.id', operator: 'is',
value: [ 1, 2, 3, 4, 6 ],
},
'ticket.customer_id' => {
operator: 'is',
value: 'current_user.id',
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',
@ -1722,8 +1761,14 @@ Overview.create_if_not_exists(
role_id: overview_role.id, role_id: overview_role.id,
organization_shared: true, organization_shared: true,
condition: { condition: {
'tickets.state_id' => [ 1, 2, 3, 4, 6 ], 'ticket.state_id' => {
'tickets.organization_id' => 'current_user.organization_id', operator: 'is',
value: [ 1, 2, 3, 4, 6 ],
},
'ticket.organization_id' => {
operator: 'is',
value: 'current_user.organization_id',
},
}, },
order: { order: {
by: 'created_at', by: 'created_at',

View file

@ -46,12 +46,18 @@ class CalendarSubscriptions::Tickets
return events_data if owner_ids.empty? return events_data if owner_ids.empty?
condition = { condition = {
'tickets.owner_id' => owner_ids, 'tickets.owner_id' => {
'tickets.state_id' => Ticket::State.where( operator: 'is',
value: owner_ids,
},
'tickets.state_id' => {
operator: 'is',
value: Ticket::State.where(
state_type_id: Ticket::StateType.where( state_type_id: Ticket::StateType.where(
name: %w(new open), name: %w(new open),
), ),
), ).map(&:id),
},
} }
tickets = Ticket.search( tickets = Ticket.search(
@ -87,15 +93,21 @@ class CalendarSubscriptions::Tickets
return events_data if owner_ids.empty? return events_data if owner_ids.empty?
condition = { condition = {
'tickets.owner_id' => owner_ids, 'tickets.owner_id' => {
'tickets.state_id' => Ticket::State.where( operator: 'is',
value: owner_ids,
},
'tickets.state_id' => {
operator: 'is',
value: Ticket::State.where(
state_type_id: Ticket::StateType.where( state_type_id: Ticket::StateType.where(
name: [ name: [
'pending reminder', 'pending reminder',
'pending action', 'pending action',
], ],
), ),
), ).map(&:id),
},
} }
tickets = Ticket.search( tickets = Ticket.search(
@ -137,9 +149,16 @@ class CalendarSubscriptions::Tickets
owner_ids = owner_ids(:escalation) owner_ids = owner_ids(:escalation)
return events_data if owner_ids.empty? return events_data if owner_ids.empty?
condition = [ condition = {
'tickets.owner_id IN (?) AND tickets.escalation_time IS NOT NULL', owner_ids 'tickets.owner_id' => {
] operator: 'is',
value: owner_ids,
},
'tickets.escalation_time' => {
operator: 'is not',
value: nil,
}
}
tickets = Ticket.search( tickets = Ticket.search(
current_user: @user, current_user: @user,