Cleanup SourceColumn invocations
Update the code of some SourceColumn invocations to be model methods instead of lambda wapped functions. Move the translated labels to the models too. Signed-off-by: Roberto Rosario <Roberto.Rosario@mayan-edms.com>
This commit is contained in:
@@ -84,81 +84,75 @@ class DocumentIndexingApp(MayanAppConfig):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(source=Index, label=_('Label'), attribute='label')
|
SourceColumn(attribute='label', is_identifier=True, source=Index)
|
||||||
SourceColumn(source=Index, label=_('Slug'), attribute='slug')
|
SourceColumn(attribute='slug', source=Index)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Index, label=_('Enabled'),
|
|
||||||
func=lambda context: TwoStateWidget(
|
func=lambda context: TwoStateWidget(
|
||||||
state=context['object'].enabled
|
state=context['object'].enabled
|
||||||
).render()
|
).render(), label=_('Enabled'), source=Index
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexInstance, label=_('Total levels'),
|
|
||||||
func=lambda context: context[
|
func=lambda context: context[
|
||||||
'object'
|
'object'
|
||||||
].instance_root.get_descendants_count()
|
].instance_root.get_descendants_count(), label=_('Total levels'),
|
||||||
|
source=IndexInstance,
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexInstance, label=_('Total documents'),
|
|
||||||
func=lambda context: context[
|
func=lambda context: context[
|
||||||
'object'
|
'object'
|
||||||
].instance_root.get_descendants_document_count(
|
].instance_root.get_descendants_document_count(
|
||||||
user=context['request'].user
|
user=context['request'].user
|
||||||
)
|
), label=_('Total documents'), source=IndexInstance
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexTemplateNode, label=_('Level'),
|
func=lambda context: node_level(context['object']),
|
||||||
func=lambda context: node_level(context['object'])
|
label=_('Level'), source=IndexTemplateNode
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexTemplateNode, label=_('Enabled'),
|
|
||||||
func=lambda context: TwoStateWidget(
|
func=lambda context: TwoStateWidget(
|
||||||
state=context['object'].enabled
|
state=context['object'].enabled
|
||||||
).render()
|
).render(), label=_('Enabled'), source=IndexTemplateNode
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexTemplateNode, label=_('Has document links?'),
|
|
||||||
func=lambda context: TwoStateWidget(
|
func=lambda context: TwoStateWidget(
|
||||||
state=context['object'].link_documents
|
state=context['object'].link_documents
|
||||||
).render()
|
).render(), label=_('Has document links?'),
|
||||||
|
source=IndexTemplateNode,
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexInstanceNode, label=_('Level'),
|
func=lambda context: index_instance_item_link(context['object']),
|
||||||
func=lambda context: index_instance_item_link(context['object'])
|
label=_('Level'), source=IndexInstanceNode
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexInstanceNode, label=_('Levels'),
|
func=lambda context: context['object'].get_descendants_count(),
|
||||||
func=lambda context: context['object'].get_descendants_count()
|
label=_('Levels'), source=IndexInstanceNode
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=IndexInstanceNode, label=_('Documents'),
|
|
||||||
func=lambda context: context[
|
func=lambda context: context[
|
||||||
'object'
|
'object'
|
||||||
].get_descendants_document_count(
|
].get_descendants_document_count(
|
||||||
user=context['request'].user
|
user=context['request'].user
|
||||||
)
|
), label=_('Documents'), source=IndexInstanceNode
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentIndexInstanceNode, label=_('Level'),
|
|
||||||
func=lambda context: get_instance_link(
|
func=lambda context: get_instance_link(
|
||||||
index_instance_node=context['object'],
|
index_instance_node=context['object'],
|
||||||
)
|
), label=_('Level'), source=DocumentIndexInstanceNode
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentIndexInstanceNode, label=_('Levels'),
|
func=lambda context: context['object'].get_descendants_count(),
|
||||||
func=lambda context: context['object'].get_descendants_count()
|
label=_('Levels'), source=DocumentIndexInstanceNode
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentIndexInstanceNode, label=_('Documents'),
|
|
||||||
func=lambda context: context[
|
func=lambda context: context[
|
||||||
'object'
|
'object'
|
||||||
].get_descendants_document_count(
|
].get_descendants_document_count(
|
||||||
user=context['request'].user
|
user=context['request'].user
|
||||||
)
|
), label=_('Documents'), source=DocumentIndexInstanceNode
|
||||||
)
|
)
|
||||||
|
|
||||||
app.conf.task_queues.append(
|
app.conf.task_queues.append(
|
||||||
|
|||||||
@@ -133,58 +133,44 @@ class DocumentStatesApp(MayanAppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Workflow, label=_('Label'), attribute='label'
|
attribute='label', is_identifier=True, source=Workflow
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Workflow, label=_('Internal name'),
|
attribute='internal_name', source=Workflow
|
||||||
attribute='internal_name'
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Workflow, label=_('Initial state'),
|
attribute='get_initial_state', source=Workflow,
|
||||||
func=lambda context: context['object'].get_initial_state() or _('None')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstance, label=_('Current state'),
|
attribute='get_current_state', source=WorkflowInstance
|
||||||
attribute='get_current_state'
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstance, label=_('User'),
|
attribute='get_last_transition_user', source=WorkflowInstance
|
||||||
func=lambda context: getattr(
|
|
||||||
context['object'].get_last_log_entry(), 'user', _('None')
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstance, label=_('Last transition'),
|
attribute='get_last_transition', source=WorkflowInstance
|
||||||
attribute='get_last_transition'
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstance, label=_('Date and time'),
|
attribute='get_last_transition_datetime', kwargs={
|
||||||
func=lambda context: getattr(
|
'formatted': True
|
||||||
context['object'].get_last_log_entry(), 'datetime', _('None')
|
}, source=WorkflowInstance
|
||||||
)
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstance, label=_('Completion'),
|
attribute='get_current_completion', source=WorkflowInstance
|
||||||
func=lambda context: getattr(
|
|
||||||
context['object'].get_current_state(), 'completion', _('None')
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstanceLogEntry, label=_('Date and time'),
|
attribute='get_rendered_datetime', source=WorkflowInstanceLogEntry
|
||||||
attribute='datetime'
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstanceLogEntry, label=_('User'), attribute='user'
|
attribute='user', source=WorkflowInstanceLogEntry
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstanceLogEntry, label=_('Transition'),
|
attribute='transition', source=WorkflowInstanceLogEntry
|
||||||
attribute='transition'
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=WorkflowInstanceLogEntry, label=_('Comment'),
|
attribute='comment', source=WorkflowInstanceLogEntry
|
||||||
attribute='comment'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
|
|||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import IntegrityError, models
|
from django.db import IntegrityError, models
|
||||||
from django.db.models import F, Max, Q
|
from django.db.models import F, Max, Q
|
||||||
|
from django.template import Context, Template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.encoding import force_text, python_2_unicode_compatible
|
from django.utils.encoding import force_text, python_2_unicode_compatible
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
@@ -113,6 +114,8 @@ class Workflow(models.Model):
|
|||||||
except self.states.model.DoesNotExist:
|
except self.states.model.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
get_initial_state.short_description = _('Initial state')
|
||||||
|
|
||||||
def launch_for(self, document):
|
def launch_for(self, document):
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -408,6 +411,10 @@ class WorkflowInstance(models.Model):
|
|||||||
'workflow_instance': self,
|
'workflow_instance': self,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_current_completion(self):
|
||||||
|
return self.get_current_state().completion
|
||||||
|
get_current_completion.short_description = _('Completion')
|
||||||
|
|
||||||
def get_current_state(self):
|
def get_current_state(self):
|
||||||
"""
|
"""
|
||||||
Actual State - The current state of the workflow. If there are
|
Actual State - The current state of the workflow. If there are
|
||||||
@@ -420,6 +427,8 @@ class WorkflowInstance(models.Model):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
return self.workflow.get_initial_state()
|
return self.workflow.get_initial_state()
|
||||||
|
|
||||||
|
get_current_state.short_description = _('Current state')
|
||||||
|
|
||||||
def get_last_log_entry(self):
|
def get_last_log_entry(self):
|
||||||
try:
|
try:
|
||||||
return self.log_entries.order_by('datetime').last()
|
return self.log_entries.order_by('datetime').last()
|
||||||
@@ -436,6 +445,28 @@ class WorkflowInstance(models.Model):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
get_last_transition.short_description = _('Last transition')
|
||||||
|
|
||||||
|
def get_last_transition_datetime(self, formatted=False):
|
||||||
|
entry = self.get_last_log_entry()
|
||||||
|
if not entry:
|
||||||
|
return _('None')
|
||||||
|
else:
|
||||||
|
if formatted:
|
||||||
|
return entry.get_rendered_datetime()
|
||||||
|
else:
|
||||||
|
return entry.datetime
|
||||||
|
|
||||||
|
get_last_transition_datetime.short_description = _('Date and time')
|
||||||
|
|
||||||
|
def get_last_transition_user(self):
|
||||||
|
try:
|
||||||
|
return self.get_last_log_entry().user
|
||||||
|
except AttributeError:
|
||||||
|
return _('None')
|
||||||
|
|
||||||
|
get_last_transition_user.short_description = _('User')
|
||||||
|
|
||||||
def get_transition_choices(self, _user=None):
|
def get_transition_choices(self, _user=None):
|
||||||
current_state = self.get_current_state()
|
current_state = self.get_current_state()
|
||||||
|
|
||||||
@@ -509,6 +540,13 @@ class WorkflowInstanceLogEntry(models.Model):
|
|||||||
if self.transition not in self.workflow_instance.get_transition_choices(_user=self.user):
|
if self.transition not in self.workflow_instance.get_transition_choices(_user=self.user):
|
||||||
raise ValidationError(_('Not a valid transition choice.'))
|
raise ValidationError(_('Not a valid transition choice.'))
|
||||||
|
|
||||||
|
def get_rendered_datetime(self):
|
||||||
|
return Template('{{ instance.datetime }}').render(
|
||||||
|
context=Context({'instance': self})
|
||||||
|
)
|
||||||
|
|
||||||
|
get_rendered_datetime.short_description = _('Date and time')
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
result = super(WorkflowInstanceLogEntry, self).save(*args, **kwargs)
|
result = super(WorkflowInstanceLogEntry, self).save(*args, **kwargs)
|
||||||
context = self.workflow_instance.get_context()
|
context = self.workflow_instance.get_context()
|
||||||
|
|||||||
@@ -237,107 +237,94 @@ class DocumentsApp(MayanAppConfig):
|
|||||||
|
|
||||||
# Document
|
# Document
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Document, label=_('Thumbnail'),
|
|
||||||
func=lambda context: document_page_thumbnail_widget.render(
|
func=lambda context: document_page_thumbnail_widget.render(
|
||||||
instance=context['object']
|
instance=context['object']
|
||||||
)
|
), label=_('Thumbnail'), source=Document
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Document, attribute='document_type'
|
attribute='document_type', label=_('Type'), source=Document
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Document, label=_('Pages'),
|
func=widget_document_page_number, label=_('Pages'), source=Document
|
||||||
func=lambda context: widget_document_page_number(
|
|
||||||
document=context['object']
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# DocumentPage
|
# DocumentPage
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentPage, label=_('Thumbnail'),
|
|
||||||
func=lambda context: document_page_thumbnail_widget.render(
|
func=lambda context: document_page_thumbnail_widget.render(
|
||||||
instance=context['object']
|
instance=context['object']
|
||||||
)
|
), label=_('Thumbnail'), source=DocumentPage
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentPageSearchResult, label=_('Thumbnail'),
|
|
||||||
func=lambda context: document_page_thumbnail_widget.render(
|
func=lambda context: document_page_thumbnail_widget.render(
|
||||||
instance=context['object']
|
instance=context['object']
|
||||||
)
|
), label=_('Thumbnail'), source=DocumentPageSearchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentPageSearchResult, label=_('Type'),
|
attribute='document_version.document.document_type',
|
||||||
attribute='document_version.document.document_type'
|
label=_('Type'), source=DocumentPageSearchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
# DocumentType
|
# DocumentType
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentType, label=_('Documents'),
|
|
||||||
func=lambda context: context['object'].get_document_count(
|
func=lambda context: context['object'].get_document_count(
|
||||||
user=context['request'].user
|
user=context['request'].user
|
||||||
)
|
), label=_('Documents'), source=DocumentType
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentTypeFilename, label=_('Enabled'),
|
|
||||||
func=lambda context: TwoStateWidget(
|
func=lambda context: TwoStateWidget(
|
||||||
state=context['object'].enabled
|
state=context['object'].enabled
|
||||||
).render()
|
).render(), label=_('Enabled'), source=DocumentTypeFilename
|
||||||
)
|
)
|
||||||
|
|
||||||
# DeletedDocument
|
# DeletedDocument
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DeletedDocument, label=_('Thumbnail'),
|
|
||||||
func=lambda context: document_page_thumbnail_widget.render(
|
func=lambda context: document_page_thumbnail_widget.render(
|
||||||
instance=context['object']
|
instance=context['object']
|
||||||
)
|
), label=_('Thumbnail'), source=DeletedDocument
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DeletedDocument, attribute='document_type'
|
attribute='document_type', source=DeletedDocument
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DeletedDocument, attribute='deleted_date_time'
|
attribute='deleted_date_time', source=DeletedDocument
|
||||||
)
|
)
|
||||||
|
|
||||||
# DocumentVersion
|
# DocumentVersion
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentVersion, label=_('Thumbnail'),
|
|
||||||
func=lambda context: document_page_thumbnail_widget.render(
|
func=lambda context: document_page_thumbnail_widget.render(
|
||||||
instance=context['object']
|
instance=context['object']
|
||||||
)
|
), label=_('Thumbnail'), source=DocumentVersion
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentVersion, attribute='timestamp'
|
attribute='timestamp', source=DocumentVersion
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentVersion, label=_('Pages'),
|
func=widget_document_version_page_number, label=_('Pages'),
|
||||||
func=lambda context: widget_document_version_page_number(
|
source=DocumentVersion
|
||||||
document_version=context['object']
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentVersion, attribute='mimetype'
|
attribute='mimetype', source=DocumentVersion
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentVersion, attribute='encoding'
|
attribute='encoding', source=DocumentVersion
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentVersion, attribute='comment'
|
attribute='comment', source=DocumentVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
# DuplicatedDocument
|
# DuplicatedDocument
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DuplicatedDocument, label=_('Thumbnail'),
|
|
||||||
func=lambda context: document_page_thumbnail_widget.render(
|
func=lambda context: document_page_thumbnail_widget.render(
|
||||||
instance=context['object'].document
|
instance=context['object'].document
|
||||||
)
|
), label=_('Thumbnail'), source=DuplicatedDocument
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DuplicatedDocument, label=_('Duplicates'),
|
func=lambda context: context['object'].documents.count(),
|
||||||
func=lambda context: context['object'].documents.count()
|
label=_('Duplicates'), source=DuplicatedDocument
|
||||||
)
|
)
|
||||||
|
|
||||||
app.conf.beat_schedule.update(
|
app.conf.beat_schedule.update(
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ def document_link(document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def widget_document_page_number(document):
|
def widget_document_page_number(context):
|
||||||
return mark_safe(s=_('Pages: %d') % document.pages.count())
|
return context['object'].pages.count()
|
||||||
|
|
||||||
|
|
||||||
def widget_document_version_page_number(document_version):
|
def widget_document_version_page_number(context):
|
||||||
return mark_safe(s=_('Pages: %d') % document_version.pages.count())
|
return context['object'].pages.count()
|
||||||
|
|||||||
@@ -101,38 +101,35 @@ class TagsApp(MayanAppConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentTag, attribute='label'
|
attribute='label', source=DocumentTag,
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentTag, attribute='get_preview_widget'
|
attribute='get_preview_widget', source=DocumentTag
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Document, label=_('Tags'),
|
|
||||||
func=lambda context: widget_document_tags(
|
func=lambda context: widget_document_tags(
|
||||||
document=context['object'], user=context['request'].user
|
document=context['object'], user=context['request'].user
|
||||||
)
|
), label=_('Tags'), source=Document
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=DocumentPageSearchResult, label=_('Tags'),
|
|
||||||
func=lambda context: widget_document_tags(
|
func=lambda context: widget_document_tags(
|
||||||
document=context['object'].document,
|
document=context['object'].document,
|
||||||
user=context['request'].user
|
user=context['request'].user
|
||||||
)
|
), label=_('Tags'), source=DocumentPageSearchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Tag, attribute='label'
|
attribute='label', is_identifier=True, source=Tag
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Tag, attribute='get_preview_widget'
|
attribute='get_preview_widget', source=Tag
|
||||||
)
|
)
|
||||||
SourceColumn(
|
SourceColumn(
|
||||||
source=Tag, label=_('Documents'),
|
|
||||||
func=lambda context: context['object'].get_document_count(
|
func=lambda context: context['object'].get_document_count(
|
||||||
user=context['request'].user
|
user=context['request'].user
|
||||||
)
|
), label=_('Documents'), source=Tag
|
||||||
)
|
)
|
||||||
|
|
||||||
document_page_search.add_model_field(
|
document_page_search.add_model_field(
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ def widget_document_tags(document, user):
|
|||||||
|
|
||||||
result.append('</div>')
|
result.append('</div>')
|
||||||
|
|
||||||
return mark_safe(''.join(result))
|
if tags:
|
||||||
|
return mark_safe(''.join(result))
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def widget_single_tag(tag):
|
def widget_single_tag(tag):
|
||||||
|
|||||||
Reference in New Issue
Block a user