feat: sla based on customer/group/territory
This commit is contained in:
parent
db1d1197ea
commit
0674d16fee
@ -264,7 +264,6 @@ scheduler_events = {
|
||||
"erpnext.projects.doctype.project.project.send_project_status_email_to_users",
|
||||
"erpnext.quality_management.doctype.quality_review.quality_review.review",
|
||||
"erpnext.support.doctype.service_level_agreement.service_level_agreement.check_agreement_status",
|
||||
"erpnext.support.doctype.issue.issue.set_service_level_agreement_status"
|
||||
],
|
||||
"daily_long": [
|
||||
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.update_latest_price_in_all_boms"
|
||||
|
@ -6,10 +6,11 @@ def execute():
|
||||
priorities = frappe.get_meta("Issue").get_field("priority").options.split("\n")
|
||||
|
||||
for priority in priorities:
|
||||
frappe.get_doc({
|
||||
"doctype": "Issue Priority",
|
||||
"name": priority
|
||||
}).insert(ignore_permissions=True)
|
||||
if not frappe.db.exists("Issue Priority", priority):
|
||||
frappe.get_doc({
|
||||
"doctype": "Issue Priority",
|
||||
"name": priority
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
frappe.reload_doc("support", "doctype", "issue")
|
||||
frappe.reload_doc("support", "doctype", "service_level")
|
||||
|
@ -8,7 +8,7 @@ from frappe import _
|
||||
|
||||
from frappe.utils.nestedset import NestedSet
|
||||
class CustomerGroup(NestedSet):
|
||||
nsm_parent_field = 'parent_customer_group';
|
||||
nsm_parent_field = 'parent_customer_group'
|
||||
|
||||
def on_update(self):
|
||||
self.validate_name_with_customer()
|
||||
|
@ -1,6 +1,25 @@
|
||||
frappe.ui.form.on("Issue", {
|
||||
onload: function(frm) {
|
||||
frm.email_field = "raised_by";
|
||||
if (frm.doc.service_level_agreement) {
|
||||
frappe.call({
|
||||
method: "erpnext.support.doctype.service_level_agreement.service_level_agreement.get_service_level_agreement_priorities",
|
||||
args: {
|
||||
name: frm.doc.service_level_agreement,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
frm.set_query('priority', function() {
|
||||
return {
|
||||
filters: {
|
||||
"name": ["in", r.message],
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
@ -43,24 +62,6 @@ frappe.ui.form.on("Issue", {
|
||||
frm.save();
|
||||
});
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.support.doctype.service_level_agreement.service_level_agreement.get_service_level_agreement_priorities",
|
||||
args: {
|
||||
name: frm.doc.service_level_agreement,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
frm.set_query('priority', function() {
|
||||
return {
|
||||
filters: {
|
||||
"name": ["in", r.message],
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
priority: function(frm) {
|
||||
|
@ -77,11 +77,9 @@ class Issue(Document):
|
||||
|
||||
def update_agreement_status(self):
|
||||
current_time = frappe.flags.current_time or now_datetime()
|
||||
if self.service_level_agreement and self.agreement_fulfilled == "Ongoing":
|
||||
response_time_diff = round(time_diff_in_hours(self.response_by, current_time), 2)
|
||||
resolution_time_diff = round(time_diff_in_hours(self.resolution_by, current_time), 2)
|
||||
|
||||
if response_time_diff < 0 or resolution_time_diff < 0:
|
||||
if self.service_level_agreement and self.agreement_fulfilled == "Ongoing":
|
||||
if self.response_by_variance < 0 or self.resolution_by_variance < 0:
|
||||
self.agreement_fulfilled = "Failed"
|
||||
else:
|
||||
self.agreement_fulfilled = "Fulfilled"
|
||||
@ -232,33 +230,27 @@ def get_expected_time_for(parameter, service_level, start_date_time):
|
||||
|
||||
return current_date_time
|
||||
|
||||
def set_service_level_agreement_status():
|
||||
issues = frappe.get_list("Issue", filters={"status": "Open", "agreement_fulfilled": "Ongoing"})
|
||||
for issue in issues:
|
||||
doc = frappe.get_doc("Issue", issue.name)
|
||||
if doc.service_level_agreement and doc.agreement_fulfilled == "Ongoing":
|
||||
response_time_diff = round(time_diff_in_hours(doc.response_by, now_datetime()), 2)
|
||||
resolution_time_diff = round(time_diff_in_hours(doc.resolution_by, now_datetime()), 2)
|
||||
if response_time_diff < 0 or resolution_time_diff < 0:
|
||||
frappe.db.set_value("Issue", doc.name, "agreement_fulfilled", "Failed")
|
||||
else:
|
||||
frappe.db.set_value("Issue", doc.name, "agreement_fulfilled", "Fulfilled")
|
||||
|
||||
def set_service_level_agreement_variance(issue=None):
|
||||
filters = {"status": "Open", "agreement_fulfilled": "Ongoing"}
|
||||
current_time = frappe.flags.current_time or now_datetime()
|
||||
|
||||
filters = {"status": "Open", "agreement_fulfilled": "Ongoing"}
|
||||
if issue:
|
||||
filters = {"name": issue}
|
||||
|
||||
issues = frappe.get_list("Issue", filters=filters)
|
||||
for issue in issues:
|
||||
for issue in frappe.get_list("Issue", filters=filters):
|
||||
doc = frappe.get_doc("Issue", issue.name)
|
||||
if not doc.first_responded_on:
|
||||
variance = round(time_diff_in_hours(doc.response_by, now_datetime()), 2)
|
||||
|
||||
if not doc.first_responded_on: # first_responded_on set when first reply is sent to customer
|
||||
variance = round(time_diff_in_hours(doc.response_by, current_time), 2)
|
||||
frappe.db.set_value("Issue", doc.name, "response_by_variance", variance)
|
||||
if not doc.resolution_date:
|
||||
variance = round(time_diff_in_hours(doc.resolution_by, now_datetime()), 2)
|
||||
if variance < 0:
|
||||
frappe.db.set_value("Issue", doc.name, "agreement_fulfilled", "Failed")
|
||||
|
||||
if not doc.resolution_date: # resolution_date set when issue has been closed
|
||||
variance = round(time_diff_in_hours(doc.resolution_by, current_time), 2)
|
||||
frappe.db.set_value("Issue", doc.name, "resolution_by_variance", variance)
|
||||
if variance < 0:
|
||||
frappe.db.set_value("Issue", doc.name, "agreement_fulfilled", "Failed")
|
||||
|
||||
def get_list_context(context=None):
|
||||
return {
|
||||
|
@ -6,12 +6,15 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"service_level",
|
||||
"customer",
|
||||
"default_service_level_agreement",
|
||||
"holiday_list",
|
||||
"column_break_2",
|
||||
"employee_group",
|
||||
"default_priority",
|
||||
"apply_to_section",
|
||||
"apply_to",
|
||||
"column_break_10",
|
||||
"entity",
|
||||
"agreement_details_section",
|
||||
"start_date",
|
||||
"active",
|
||||
@ -23,16 +26,6 @@
|
||||
"support_and_resolution"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"depends_on": "eval: !doc.default_service_level_agreement;",
|
||||
"fieldname": "customer",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Customer",
|
||||
"options": "Customer",
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: !doc.customer;",
|
||||
@ -133,9 +126,34 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Active",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible_depends_on": "eval: !doc.default_service_level_agreement;",
|
||||
"fieldname": "apply_to_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Apply To"
|
||||
},
|
||||
{
|
||||
"fieldname": "apply_to",
|
||||
"fieldtype": "Select",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Apply To",
|
||||
"options": "\nCustomer\nCustomer Group\nTerritory"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_10",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "entity",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Entity",
|
||||
"options": "apply_to"
|
||||
}
|
||||
],
|
||||
"modified": "2019-06-06 12:56:24.545060",
|
||||
"modified": "2019-06-07 00:30:34.755416",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Support",
|
||||
"name": "Service Level Agreement",
|
||||
|
@ -45,14 +45,14 @@ def check_agreement_status():
|
||||
|
||||
def get_active_service_level_agreement_for(priority, customer=None, service_level_agreement=None):
|
||||
filters = [
|
||||
["Service Level Agreement", "active", "=", 1]
|
||||
["Service Level Agreement", "active", "=", 1],
|
||||
]
|
||||
|
||||
if priority:
|
||||
filters.append(["Service Level Priority", "priority", "=", priority])
|
||||
|
||||
or_filters = [
|
||||
["Service Level Agreement", "customer", "=", customer]
|
||||
["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer)]]
|
||||
]
|
||||
if service_level_agreement:
|
||||
or_filters = [
|
||||
@ -62,10 +62,18 @@ def get_active_service_level_agreement_for(priority, customer=None, service_leve
|
||||
or_filters.append(["Service Level Agreement", "default_service_level_agreement", "=", 1])
|
||||
|
||||
agreement = frappe.get_list("Service Level Agreement", filters=filters, or_filters=or_filters,
|
||||
fields=["name", "default_priority", "customer"])
|
||||
fields=["name", "default_priority"], debug=True)
|
||||
|
||||
return agreement[0] if agreement else None
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_service_level_agreement_priorities(name):
|
||||
return [priority.priority for priority in frappe.get_list("Service Level Priority", filters={"parent": name}, fields=["priority"])]
|
||||
return [priority.priority for priority in frappe.get_list("Service Level Priority", filters={"parent": name}, fields=["priority"])]
|
||||
|
||||
def get_customer_group(customer):
|
||||
if customer:
|
||||
return frappe.db.get_value("Customer", customer, "customer_group")
|
||||
|
||||
def get_customer_territory(customer):
|
||||
if customer:
|
||||
return frappe.db.get_value("Customer", customer, "territory")
|
@ -10,136 +10,116 @@ from erpnext.support.doctype.service_level.test_service_level import make_servic
|
||||
class TestServiceLevelAgreement(unittest.TestCase):
|
||||
|
||||
def test_service_level_agreement(self):
|
||||
test_make_service_level_agreement = make_service_level_agreement()
|
||||
test_get_service_level_agreement = get_service_level_agreement()
|
||||
make_service_level()
|
||||
|
||||
self.assertEqual(test_make_service_level_agreement.name, test_get_service_level_agreement.name)
|
||||
self.assertEqual(test_make_service_level_agreement.customer, test_get_service_level_agreement.customer)
|
||||
self.assertEqual(test_make_service_level_agreement.default_service_level_agreement, test_get_service_level_agreement.default_service_level_agreement)
|
||||
|
||||
def make_service_level_agreement():
|
||||
make_service_level()
|
||||
# Default Service Level Agreement
|
||||
create_default_service_level_agreement = create_service_level_agreement(default_service_level_agreement=1,
|
||||
service_level="__Test Service Level", holiday_list="__Test Holiday List", employee_group="_Test Employee Group",
|
||||
apply_to=None, entity=None, response_time=4, resolution_time=6)
|
||||
get_default_service_level_agreement = get_service_level_agreement(default_service_level_agreement=1)
|
||||
|
||||
# Default Service Level Agreement
|
||||
default_service_level_agreement = frappe.get_doc({
|
||||
"doctype": "Service Level Agreement",
|
||||
"service_level_agreement_name": "Default Service Level Agreement",
|
||||
"default_service_level_agreement": 1,
|
||||
"service_level": "__Test Service Level",
|
||||
"holiday_list": "__Test Holiday List",
|
||||
"employee_group": "_Test Employee Group",
|
||||
"start_date": frappe.utils.getdate(),
|
||||
"end_date": frappe.utils.add_to_date(frappe.utils.getdate(), days=100),
|
||||
"priorities": [
|
||||
{
|
||||
"priority": "Low",
|
||||
"response_time": 4,
|
||||
"response_time_period": "Hour",
|
||||
"resolution_time": 6,
|
||||
"resolution_time_period": "Hour",
|
||||
},
|
||||
{
|
||||
"priority": "Medium",
|
||||
"response_time": 4,
|
||||
"default_priority": 1,
|
||||
"response_time_period": "Hour",
|
||||
"resolution_time": 6,
|
||||
"resolution_time_period": "Hour",
|
||||
},
|
||||
{
|
||||
"priority": "High",
|
||||
"response_time": 4,
|
||||
"response_time_period": "Hour",
|
||||
"resolution_time": 6,
|
||||
"resolution_time_period": "Hour",
|
||||
}
|
||||
],
|
||||
"support_and_resolution": [
|
||||
{
|
||||
"workday": "Monday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
},
|
||||
{
|
||||
"workday": "Tuesday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
},
|
||||
{
|
||||
"workday": "Wednesday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
},
|
||||
{
|
||||
"workday": "Thursday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
},
|
||||
{
|
||||
"workday": "Friday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
},
|
||||
{
|
||||
"workday": "Saturday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
},
|
||||
{
|
||||
"workday": "Sunday",
|
||||
"start_time": "10:00:00",
|
||||
"end_time": "18:00:00",
|
||||
}
|
||||
]
|
||||
})
|
||||
self.assertEqual(create_default_service_level_agreement.name, get_default_service_level_agreement.name)
|
||||
self.assertEqual(create_default_service_level_agreement.apply_to, get_default_service_level_agreement.apply_to)
|
||||
self.assertEqual(create_default_service_level_agreement.entity, get_default_service_level_agreement.entity)
|
||||
self.assertEqual(create_default_service_level_agreement.default_service_level_agreement, get_default_service_level_agreement.default_service_level_agreement)
|
||||
|
||||
default_service_level_agreement_exists = frappe.db.exists("Service Level Agreement", "SLA-Default Service Level Agreement")
|
||||
|
||||
if not default_service_level_agreement_exists:
|
||||
default_service_level_agreement.insert(ignore_permissions=True)
|
||||
# Service Level Agreement for Customer
|
||||
customer = frappe.get_doc({
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test Customer",
|
||||
"customer_group": "Commercial",
|
||||
"customer_type": "Individual",
|
||||
"territory": "Rest Of The World"
|
||||
})
|
||||
if not frappe.db.exists("Customer", "_Test Customer"):
|
||||
customer.insert(ignore_permissions=True)
|
||||
else:
|
||||
customer = frappe.get_doc("Customer", "_Test Customer")
|
||||
|
||||
customer = frappe.get_doc({
|
||||
"doctype": "Customer",
|
||||
"customer_name": "_Test Customer",
|
||||
"customer_group": "Commercial",
|
||||
"customer_type": "Individual",
|
||||
"territory": "Rest Of The World"
|
||||
})
|
||||
if not frappe.db.exists("Customer", "_Test Customer"):
|
||||
customer.insert(ignore_permissions=True)
|
||||
create_customer_service_level_agreement = create_service_level_agreement(default_service_level_agreement=0,
|
||||
service_level="__Test Service Level", holiday_list="__Test Holiday List", employee_group="_Test Employee Group",
|
||||
apply_to="Customer", entity=customer.name, response_time=2, resolution_time=3)
|
||||
get_customer_service_level_agreement = get_service_level_agreement(apply_to="Customer", entity=customer.name)
|
||||
|
||||
self.assertEqual(create_customer_service_level_agreement.name, get_customer_service_level_agreement.name)
|
||||
self.assertEqual(create_customer_service_level_agreement.apply_to, get_customer_service_level_agreement.apply_to)
|
||||
self.assertEqual(create_customer_service_level_agreement.entity, get_customer_service_level_agreement.entity)
|
||||
self.assertEqual(create_customer_service_level_agreement.default_service_level_agreement, get_customer_service_level_agreement.default_service_level_agreement)
|
||||
|
||||
|
||||
# Service Level Agreement for Customer Group
|
||||
customer_group = create_customer_group()
|
||||
create_customer_group_service_level_agreement = create_service_level_agreement(default_service_level_agreement=0,
|
||||
service_level="__Test Service Level", holiday_list="__Test Holiday List", employee_group="_Test Employee Group",
|
||||
apply_to="Customer Group", entity=customer_group.name, response_time=4, resolution_time=6)
|
||||
get_customer_group_service_level_agreement = get_service_level_agreement(apply_to="Customer Group", entity=customer_group.name)
|
||||
|
||||
self.assertEqual(create_customer_group_service_level_agreement.name, get_customer_group_service_level_agreement.name)
|
||||
self.assertEqual(create_customer_group_service_level_agreement.apply_to, get_customer_group_service_level_agreement.apply_to)
|
||||
self.assertEqual(create_customer_group_service_level_agreement.entity, get_customer_group_service_level_agreement.entity)
|
||||
self.assertEqual(create_customer_group_service_level_agreement.default_service_level_agreement, get_customer_group_service_level_agreement.default_service_level_agreement)
|
||||
|
||||
|
||||
# Service Level Agreement for Territory
|
||||
territory = create_territory()
|
||||
create_territory_service_level_agreement = create_service_level_agreement(default_service_level_agreement=0,
|
||||
service_level="__Test Service Level", holiday_list="__Test Holiday List", employee_group="_Test Employee Group",
|
||||
apply_to="Territory", entity=territory.name, response_time=2, resolution_time=3)
|
||||
get_territory_service_level_agreement = get_service_level_agreement(apply_to="Territory", entity=territory.name)
|
||||
|
||||
self.assertEqual(create_territory_service_level_agreement.name, get_territory_service_level_agreement.name)
|
||||
self.assertEqual(create_territory_service_level_agreement.apply_to, get_territory_service_level_agreement.apply_to)
|
||||
self.assertEqual(create_territory_service_level_agreement.entity, get_territory_service_level_agreement.entity)
|
||||
self.assertEqual(create_territory_service_level_agreement.default_service_level_agreement, get_territory_service_level_agreement.default_service_level_agreement)
|
||||
|
||||
|
||||
def get_service_level_agreement(default_service_level_agreement=None, apply_to=None, entity=None):
|
||||
if default_service_level_agreement:
|
||||
filters = {"default_service_level_agreement": default_service_level_agreement}
|
||||
else:
|
||||
customer = frappe.get_doc("Customer", "_Test Customer")
|
||||
filters = {"apply_to": apply_to, "entity": entity}
|
||||
|
||||
service_level_agreement = frappe.get_doc("Service Level Agreement", filters)
|
||||
print(service_level_agreement)
|
||||
return service_level_agreement
|
||||
|
||||
def create_service_level_agreement(default_service_level_agreement, service_level, holiday_list, employee_group,
|
||||
response_time, apply_to, entity, resolution_time):
|
||||
|
||||
service_level_agreement = frappe.get_doc({
|
||||
"doctype": "Service Level Agreement",
|
||||
"service_level_agreement_name": "_Test Service Level Agreement",
|
||||
"customer": customer.customer_name,
|
||||
"service_level": "_Test Service Level",
|
||||
"holiday_list": "__Test Holiday List",
|
||||
"employee_group": "_Test Employee Group",
|
||||
"default_service_level_agreement": default_service_level_agreement,
|
||||
"service_level": service_level,
|
||||
"holiday_list": holiday_list,
|
||||
"employee_group": employee_group,
|
||||
"apply_to": apply_to,
|
||||
"entity": entity,
|
||||
"start_date": frappe.utils.getdate(),
|
||||
"end_date": frappe.utils.add_to_date(frappe.utils.getdate(), days=100),
|
||||
"priorities": [
|
||||
{
|
||||
"priority": "Low",
|
||||
"response_time": 2,
|
||||
"response_time_period": "Day",
|
||||
"resolution_time": 3,
|
||||
"resolution_time_period": "Day",
|
||||
"response_time": response_time,
|
||||
"response_time_period": "Hour",
|
||||
"resolution_time": resolution_time,
|
||||
"resolution_time_period": "Hour",
|
||||
},
|
||||
{
|
||||
"priority": "Medium",
|
||||
"response_time": 2,
|
||||
"response_time_period": "Day",
|
||||
"resolution_time": 3,
|
||||
"resolution_time_period": "Day",
|
||||
"response_time": response_time,
|
||||
"default_priority": 1,
|
||||
"response_time_period": "Hour",
|
||||
"resolution_time": resolution_time,
|
||||
"resolution_time_period": "Hour",
|
||||
},
|
||||
{
|
||||
"priority": "High",
|
||||
"response_time": 2,
|
||||
"response_time_period": "Day",
|
||||
"resolution_time": 3,
|
||||
"resolution_time_period": "Day",
|
||||
"response_time": response_time,
|
||||
"response_time_period": "Hour",
|
||||
"resolution_time": resolution_time,
|
||||
"resolution_time_period": "Hour",
|
||||
}
|
||||
],
|
||||
"support_and_resolution": [
|
||||
@ -181,14 +161,32 @@ def make_service_level_agreement():
|
||||
]
|
||||
})
|
||||
|
||||
service_level_agreement_exists = frappe.db.exists("Service Level Agreement", {"service_level_agreement_name": "_Test Service Level Agreement"})
|
||||
service_level_agreement_exists = frappe.db.exists("Service Level Agreement", service_level_agreement.name)
|
||||
|
||||
if not service_level_agreement_exists:
|
||||
service_level_agreement.insert(ignore_permissions=True)
|
||||
return service_level_agreement
|
||||
else:
|
||||
return frappe.get_doc("Service Level Agreement", "SLA-_Test Service Level Agreement")
|
||||
return frappe.get_doc("Service Level Agreement", service_level_agreement.name)
|
||||
|
||||
def get_service_level_agreement():
|
||||
service_level_agreement = frappe.get_doc("Service Level Agreement", "SLA-_Test Service Level Agreement")
|
||||
return service_level_agreement
|
||||
def create_customer_group():
|
||||
customer_group = frappe.get_doc({
|
||||
"doctype": "Customer Group",
|
||||
"customer_group_name": "_Test SLA Customer Group"
|
||||
})
|
||||
|
||||
if not frappe.db.exists("Customer Group", {"customer_group_name": "_Test SLA Customer Group"}):
|
||||
customer_group.insert()
|
||||
|
||||
return customer_group.name
|
||||
|
||||
def create_territory():
|
||||
territory = frappe.get_doc({
|
||||
"doctype": "Territory",
|
||||
"territory_name": "_Test SLA Territory",
|
||||
})
|
||||
|
||||
if not frappe.db.exists("Territory", {"territory_name": "_Test SLA Territory"}):
|
||||
territory.insert()
|
||||
|
||||
return territory.name
|
Loading…
x
Reference in New Issue
Block a user