实现AI生成测试项接口
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -3,12 +3,12 @@ from pathlib import Path
|
||||
from ninja_extra import api_controller, ControllerBase, route
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models import Q
|
||||
from docxtpl import DocxTemplate
|
||||
from typing import Optional
|
||||
from docx import Document
|
||||
from ninja_extra.permissions import IsAuthenticated
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja_extra.permissions import IsAuthenticated # type:ignore
|
||||
from ninja_jwt.authentication import JWTAuth # type:ignore
|
||||
# 导入模型
|
||||
from apps.project.models import Project, Dut, TestDemand, Problem
|
||||
# 工具类函数
|
||||
@@ -143,18 +143,18 @@ class GenerateControllerBG(ControllerBase):
|
||||
# 找到第一轮轮次对象、第二轮轮次对象
|
||||
round1 = project_obj.pField.filter(key='0').first()
|
||||
# 第一轮测试项个数
|
||||
round1_demand_qs = round1.rtField.all()
|
||||
round1_demand_qs = round1 and round1.rtField.all()
|
||||
# 第一轮用例个数
|
||||
round1_case_qs = round1.rcField.all()
|
||||
round1_case_qs = round1.rcField.all() # type:ignore
|
||||
# 这部分找出第一轮的所有测试类型,输出字符串,并排序
|
||||
test_type_set: set = set()
|
||||
for case in round1_case_qs:
|
||||
for case in round1_case_qs: # type:ignore
|
||||
demand: TestDemand = case.test
|
||||
test_type_set.add(demand.testType)
|
||||
round1_testType_list = list(
|
||||
map(lambda x: x['ident_version'], get_list_dict('testType', list(test_type_set))))
|
||||
# 这里找出第一轮,源代码被测件,并获取版本
|
||||
so_dut = round1.rdField.filter(type='SO').first()
|
||||
so_dut = round1.rdField.filter(type='SO').first() # type:ignore
|
||||
so_dut_verson = "$请添加第一轮的源代码信息$"
|
||||
if so_dut:
|
||||
so_dut_verson = so_dut.version
|
||||
@@ -193,7 +193,7 @@ class GenerateControllerBG(ControllerBase):
|
||||
'start_time_year': project_obj.beginTime.year,
|
||||
'start_time_month': project_obj.beginTime.month,
|
||||
'round1_case_count': round1_case_qs.count(),
|
||||
'round1_demand_count': round1_demand_qs.count(),
|
||||
'round1_demand_count': round1_demand_qs.count(), # type:ignore
|
||||
'round1_testType_str': '、'.join(round1_testType_list),
|
||||
'testType_count': len(round1_testType_list),
|
||||
'round1_version': so_dut_verson,
|
||||
@@ -290,26 +290,26 @@ class GenerateControllerBG(ControllerBase):
|
||||
problems_doc_r1 = problems_r1.filter(case__test__testType='8') # 第一轮所有文档问题
|
||||
|
||||
# 3.第一轮代码审查问题统计/版本
|
||||
source_r1_dut = round1.rdField.filter(type='SO').first() # !warning:小变量-第一轮源代码对象
|
||||
source_r1_dut = round1.rdField.filter(type='SO').first() # type:ignore
|
||||
program_r1_problems = problems_r1.filter(case__test__testType='2')
|
||||
|
||||
# 4.第一轮代码走查问题统计/版本
|
||||
zou_r1_problems = problems_r1.filter(case__test__testType='3')
|
||||
# 找下是否存在代码走查测试项
|
||||
r1_demand_qs = round1.rtField.filter(testType='3')
|
||||
r1_demand_qs = round1.rtField.filter(testType='3') # type:ignore
|
||||
has_zou = True if r1_demand_qs.count() > 0 else False
|
||||
|
||||
# 5.第一轮静态分析问题统计
|
||||
static_problems = problems_r1.filter(case__test__testType='15')
|
||||
|
||||
# 6.第一轮动态测试用例个数(动态测试-非静态分析、文档审查、代码审查、代码走查4个)
|
||||
case_r1_qs = round1.rcField.filter(~Q(test__testType='2'), ~Q(test__testType='3'),
|
||||
case_r1_qs = round1.rcField.filter(~Q(test__testType='2'), ~Q(test__testType='3'), # type:ignore
|
||||
~Q(test__testType='8'),
|
||||
~Q(test__testType='15'),
|
||||
round__key='0') # !warning:中变量-第一轮动态测试用例qs
|
||||
testType_list, testType_count = create_str_testType_list(case_r1_qs)
|
||||
## 动态测试(第一轮)各个类型测试用例执行表/各个测试需求表
|
||||
demand_r1_dynamic_qs = round1.rtField.filter(~Q(testType='2'), ~Q(testType='3'), ~Q(testType='8'),
|
||||
demand_r1_dynamic_qs = round1.rtField.filter(~Q(testType='2'), ~Q(testType='3'), ~Q(testType='8'), # type:ignore
|
||||
~Q(testType='15')) # !warning:中变量:第一轮动态测试的测试项
|
||||
summary_r1_demand_info, summry_r1_demandType_info = create_demand_summary(demand_r1_dynamic_qs,
|
||||
project_ident)
|
||||
@@ -678,14 +678,14 @@ class GenerateControllerBG(ControllerBase):
|
||||
case__test__testType__in=['2', '3', '8', '15']).distinct()
|
||||
for problem in r1_static_problems:
|
||||
problem_dict = create_one_problem_dit(problem, problem_prefix, doc)
|
||||
round_dict['static'].append(problem_dict)
|
||||
round_dict['static'].append(problem_dict) # type:ignore
|
||||
|
||||
# 找出轮次中动态问题
|
||||
r1_dynamic_problems = problems.filter(case__round__key=round_str).exclude(
|
||||
case__test__testType__in=['2', '3', '8', '15']).distinct()
|
||||
for problem in r1_dynamic_problems:
|
||||
problem_dict = create_one_problem_dit(problem, problem_prefix, doc)
|
||||
round_dict['dynamic'].append(problem_dict)
|
||||
round_dict['dynamic'].append(problem_dict) # type:ignore
|
||||
data_list.append(round_dict)
|
||||
|
||||
context = {
|
||||
|
||||
@@ -55,7 +55,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
|
||||
# 查出第一轮所有testdemand
|
||||
project_round_one = project_qs.pField.filter(key=0).first()
|
||||
testDemand_qs = project_round_one.rtField.all().select_related('design')
|
||||
testDemand_qs = project_round_one.rtField.all().select_related('design') # type:ignore
|
||||
# 按照自己key排序,这样可以按照design的key排序
|
||||
sorted_demand_qs = sorted(testDemand_qs, key=demand_sort_by_designKey)
|
||||
|
||||
@@ -228,9 +228,9 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
# 2025/12/11:将20250417格式改为2025年04月17日 - 封装函数,传入字典和键值,修改对应键值信息
|
||||
def change_time_to_another(self, context: dict, key_list: list[str]):
|
||||
for key in key_list:
|
||||
time_val = context.get(key, None)
|
||||
time_val = context.get(key)
|
||||
if time_val:
|
||||
context[key] = datetime.strptime(time_val, "%Y%m%d").strftime("%Y年%m月%d日")
|
||||
context[key] = datetime.strptime(time_val, "%Y%m%d").strftime("%Y年%m月%d日") # type:ignore
|
||||
return context
|
||||
|
||||
# 生成【主要功能和性能指标】文档片段
|
||||
@@ -361,7 +361,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
if qs.exists():
|
||||
data_qs = qs.first().data_schemas
|
||||
context = cls.create_data_schema_list_context(data_qs, doc)
|
||||
doc.render(context)
|
||||
doc.render(context or {})
|
||||
try:
|
||||
doc.save(Path.cwd() / "media" / project_path(id) / "output_dir" / r_filename)
|
||||
return ChenResponse(status=200, code=200, message="文档生成成功!")
|
||||
@@ -430,9 +430,9 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
image_render = None
|
||||
fontnote = None
|
||||
if image_obj.exists():
|
||||
base64_bytes = base64.b64decode(image_obj.first().content.replace("data:image/png;base64,", ""))
|
||||
base64_bytes = base64.b64decode(image_obj.first().content.replace("data:image/png;base64,", "")) # type:ignore
|
||||
image_render = InlineImage(doc, io.BytesIO(base64_bytes), width=Mm(120))
|
||||
fontnote = image_obj.first().fontnote
|
||||
fontnote = image_obj.first().fontnote # type:ignore
|
||||
context = {
|
||||
'project_name': project_name,
|
||||
'iters': interfaceNameList,
|
||||
@@ -604,12 +604,12 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
qs = EnvAnalysis.objects.filter(project=project_obj)
|
||||
if qs.exists():
|
||||
obj = qs.first()
|
||||
table_data = obj.table
|
||||
table_data = obj.table # type:ignore
|
||||
subdoc = create_table_context(table_data, doc)
|
||||
context = {
|
||||
"description": obj.description,
|
||||
"description": obj.description, # type:ignore
|
||||
"table": subdoc,
|
||||
"fontnote": obj.fontnote,
|
||||
"fontnote": obj.fontnote, # type:ignore
|
||||
}
|
||||
doc.render(context, autoescape=True)
|
||||
try:
|
||||
@@ -643,7 +643,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
devplant_list = [item['ident_version'] for item in devplants]
|
||||
# 版本先找第一轮
|
||||
project_round = project_qs.pField.filter(key=0).first()
|
||||
first_round_SO = project_round.rdField.filter(type='SO').first()
|
||||
first_round_SO = project_round.rdField.filter(type='SO').first() # type:ignore
|
||||
if not first_round_SO:
|
||||
return ChenResponse(code=400, status=400, message='您还未创建轮次,请进入工作区创建')
|
||||
version = first_round_SO.version
|
||||
@@ -721,7 +721,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
isDmsc = True if int(security) <= 2 else False
|
||||
# 获取第一轮所有测试项QuerySet
|
||||
project_round_one = project_qs.pField.filter(key=0).first()
|
||||
testDemand_qs = project_round_one.rtField.all()
|
||||
testDemand_qs = project_round_one.rtField.all() # type:ignore
|
||||
# grouped_data的键是测试类型名称,值为测试项名称数组
|
||||
grouped_data = {}
|
||||
for item in testDemand_qs:
|
||||
@@ -819,9 +819,9 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
design_list = [] # 先按照design的思路进行追踪
|
||||
# 查询第一轮次
|
||||
project_round_one = project_qs.pField.filter(key=0).first()
|
||||
testType_list, last_chapter_items = create_csx_chapter_dict(project_round_one)
|
||||
testType_list, last_chapter_items = create_csx_chapter_dict(project_round_one) # type:ignore
|
||||
# 找出第一轮的研总
|
||||
yz_dut = project_round_one.rdField.filter(type='YZ').first()
|
||||
yz_dut = project_round_one.rdField.filter(type='YZ').first() # type:ignore
|
||||
if yz_dut:
|
||||
# 查询出验证所有design
|
||||
yz_designs = yz_dut.rsField.all()
|
||||
@@ -843,7 +843,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
str(test_item_last_chapter)])
|
||||
test_item_dict = {'name': test_item.name, 'chapter': test_chapter,
|
||||
'ident': reveal_ident}
|
||||
design_dict['test_demand'].append(test_item_dict)
|
||||
design_dict['test_demand'].append(test_item_dict) # type:ignore
|
||||
design_list.append(design_dict)
|
||||
try:
|
||||
design_list = sorted(design_list, key=chapter_key)
|
||||
@@ -889,7 +889,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
str(test_item_last_chapter)])
|
||||
test_item_dict = {'name': test_item.name, 'chapter': test_chapter,
|
||||
'ident': reveal_ident}
|
||||
design_dict['test_demand'].append(test_item_dict)
|
||||
design_dict['test_demand'].append(test_item_dict) # type:ignore
|
||||
design_list.append(design_dict)
|
||||
|
||||
if xq_dut:
|
||||
@@ -912,7 +912,7 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
str(test_item_last_chapter)])
|
||||
test_item_dict = {'name': test_item.name, 'chapter': test_chapter,
|
||||
'ident': reveal_ident}
|
||||
design_dict['test_demand'].append(test_item_dict)
|
||||
design_dict['test_demand'].append(test_item_dict) # type:ignore
|
||||
|
||||
design_list.append(design_dict)
|
||||
# 根据design的chapter排序-为防止报错崩溃使用try-但难排查
|
||||
@@ -933,10 +933,10 @@ class GenerateControllerDG(ControllerBase, FragementToolsMixin):
|
||||
test_item_prefix = '6.2'
|
||||
# 取出第一轮所有测试项的章节处理列表和字典
|
||||
project_round_one = project_qs.pField.filter(key=0).first()
|
||||
testType_list, last_chapter_items = create_csx_chapter_dict(project_round_one)
|
||||
testType_list, last_chapter_items = create_csx_chapter_dict(project_round_one) # type:ignore
|
||||
# 查询第一轮所有测试项
|
||||
test_items = []
|
||||
test_items.extend(project_round_one.rtField.all())
|
||||
test_items.extend(project_round_one.rtField.all()) # type:ignore
|
||||
# 最后渲染列表
|
||||
items_list = []
|
||||
for test_item in test_items:
|
||||
|
||||
Binary file not shown.
@@ -1,128 +1,85 @@
|
||||
from datetime import date
|
||||
from ninja_extra import api_controller, ControllerBase, route
|
||||
import json
|
||||
|
||||
from apps.project.models import Project
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from utils.chen_response import ChenResponse
|
||||
from django.db.models import Q
|
||||
from ninja import Schema
|
||||
|
||||
Users = get_user_model()
|
||||
|
||||
class AIPostSchema(Schema):
|
||||
question: str
|
||||
stream: bool
|
||||
|
||||
# AI测试接口
|
||||
@api_controller("/local_doc_qa", tags=['AI测试接口'])
|
||||
class AITestController(ControllerBase):
|
||||
"""AI测试接口:自定义延迟"""
|
||||
|
||||
@route.post("/testing_item")
|
||||
def ai_return(self, item: AIPostSchema):
|
||||
import time
|
||||
time.sleep(2)
|
||||
res = [
|
||||
{
|
||||
"demandDescription": "验证外部32MHz品振时钟和内部10KHZ时钟能否正确布线至FPGA内部相应的全局时钟网络,并通过指定缓冲器降低延迟。",
|
||||
"title": "时钟布线与缓冲功能测试",
|
||||
"children": [
|
||||
{
|
||||
"name": "外部32MHz时钟布线到HCLKBUF级冲测试",
|
||||
"subDescription": "验证外部32MH布线的测试子项描述",
|
||||
"subStep": [
|
||||
{
|
||||
"operation": "配置FPGA逻辑,将外部32MHz晶振输入连接到HCLKBUF缓冲器。",
|
||||
"expect": "时钟信号成功接入HCLKBUF缓冲器,无错误提示。"
|
||||
}, {
|
||||
"operation": "使用示波器或时序分析工具检测HCLKBUF输出端的时钟波形。",
|
||||
"expect": "输出端应稳定输出32MHz时钟信号,频率准确目波形无明显失真。"
|
||||
}, {
|
||||
"operation": "监测从HCLKBUF到各寄存器的时钟路径延迟。",
|
||||
"expect": "各路径延迟保持一致目为最小值,满足分布式延迟最低的变求。"
|
||||
}
|
||||
]
|
||||
}, {
|
||||
"name": "内部10KHz时钟布线到CLKINT缓冲测试",
|
||||
"subDescription": "验证内部10KHz时钟布线到CLKINT缓冲的测试子项描述",
|
||||
"subStep": [
|
||||
{
|
||||
"operation": "在FPGA中启用内部10KHz时钟源并将其连接至CLKINT缓冲器。",
|
||||
"expect": "内部时钟信号成功接入CLKINT缓冲器,系统无报错。"
|
||||
}, {
|
||||
"operation": "测量CLKINT输出端的时钟频率。",
|
||||
"expect": "输出端应稳定输出10KHz时钟信号,频率精度符合设计要求。"
|
||||
}, {
|
||||
"operation": "检查CLKINT是否将时钟广播到全局时钟网器",
|
||||
"expect": "时钟能被正常分发至内部各个需要该时钟的模块。"
|
||||
}
|
||||
]
|
||||
}, {
|
||||
"name": "异常情况下的时钟处理测试",
|
||||
"subDescription": "验证异常情况下的时钟处理测试的测试子项描述",
|
||||
"subStep": [
|
||||
{
|
||||
"operation": "断开外部32MHz晶振输入后尝试进行HCLKBUF配置。",
|
||||
"expect": "系统应报告时钟缺失错误,无法完成正常的时钟分配。"
|
||||
}, {
|
||||
"operation": "人为制造内部10KHz时钟不稳定(如干扰)后再送入CLKINT。",
|
||||
"expect": "CLKINT应拒绝不稳定的时钟或将错误上报给监控机制。"
|
||||
}, {
|
||||
"operation": "同时配置两个时钟但未正确绑定各自缓冲器。",
|
||||
"expect": "系统应阻止非法配置操作,确保每个时钟进入正确的缓冲通道。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
return {
|
||||
"history": [["我是没有用的", json.dumps(res)]]
|
||||
}
|
||||
|
||||
# 这是其他common内容接口
|
||||
@api_controller("/system", tags=['通用接口'])
|
||||
class CommonController(ControllerBase):
|
||||
"""通用接口类:工作台内的信息"""
|
||||
|
||||
@route.get("/getNoticeList")
|
||||
def get_notice(self, pageSize, orderBy, orderType):
|
||||
item_list = []
|
||||
item1 = {"title": "测试管理平台V0.0.2测试发布", "created_at": "2023-09-23",
|
||||
"content": "测试管理平台V0.0.2发布,正在进行内部测试.."}
|
||||
item_list.append(item1)
|
||||
item2 = {"title": "测试管理平台更新公共", "created_at": "2024-06-17",
|
||||
"content": "<p>1.修改大纲和报告模版<p><p>2.修复多个bug<p>"}
|
||||
item_list.append(item2)
|
||||
return item_list
|
||||
|
||||
@route.get('/workplace/statistics')
|
||||
@transaction.atomic
|
||||
def get_statistics(self):
|
||||
# 查询用户数量,进行的项目,项目总数,已完成项目数
|
||||
user_count = Users.objects.count()
|
||||
project_qs = Project.objects.all()
|
||||
project_count = project_qs.count()
|
||||
project_done_count = project_qs.filter(step='3').count()
|
||||
project_processing_count = project_qs.filter(Q(step='1') | Q(step='2')).count()
|
||||
return ChenResponse(data={'pcount': project_count, 'ucount': user_count,
|
||||
'pdcount': project_done_count, 'ppcount': project_processing_count})
|
||||
|
||||
@route.get('/statistics/chart')
|
||||
@transaction.atomic
|
||||
def get_chart(self):
|
||||
"""该接口返回当前年份下,每月的项目统计,返回横坐标12个月的字符串以及12个月数据"""
|
||||
current_year = date.today().year
|
||||
month_list = []
|
||||
# 构造数组,里面是字典
|
||||
for i in range(12):
|
||||
month_dict = {'month': i + 1, 'count': 0}
|
||||
month_list.append(month_dict)
|
||||
project_qs = Project.objects.all()
|
||||
for project in project_qs:
|
||||
for m in month_list:
|
||||
if m['month'] == project.beginTime.month and project.beginTime.year == current_year:
|
||||
m['count'] += 1
|
||||
return ChenResponse(status=200, code=200, data=month_list)
|
||||
from datetime import date
|
||||
from ninja_extra import api_controller, ControllerBase, route
|
||||
import json
|
||||
from typing import Literal
|
||||
from apps.project.models import Project
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
import requests
|
||||
from utils.chen_response import ChenResponse
|
||||
from django.db.models import Q
|
||||
from ninja import Schema
|
||||
|
||||
Users = get_user_model()
|
||||
|
||||
class AIPostSchema(Schema):
|
||||
question: str
|
||||
project_type: Literal["cpu", "fpga"]
|
||||
|
||||
# AI测试接口
|
||||
@api_controller("/local_doc_qa", tags=['AI测试接口'])
|
||||
class AITestController(ControllerBase):
|
||||
"""AI测试接口:自定义延迟"""
|
||||
|
||||
@route.post("/testing_item")
|
||||
def ai_return(self, item: AIPostSchema):
|
||||
target_url = "http://192.168.0.63:8777/api/local_doc_qa/testing_item"
|
||||
payload = {
|
||||
"question": item.question,
|
||||
"model_name": "qwen3.5", # 可能会变
|
||||
"project_type": item.project_type,
|
||||
"streaming": False,
|
||||
"user_focus_points": "",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(target_url, json=payload, timeout=120, headers={"Content-Type": "application/json"})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException:
|
||||
# 调用失败,返回 502
|
||||
return ChenResponse(data={}, message="调用大模型接口失败,请联系管理员", code=502, status=502)
|
||||
|
||||
# 这是其他common内容接口
|
||||
@api_controller("/system", tags=['通用接口'])
|
||||
class CommonController(ControllerBase):
|
||||
"""通用接口类:工作台内的信息"""
|
||||
|
||||
@route.get("/getNoticeList")
|
||||
def get_notice(self, pageSize, orderBy, orderType):
|
||||
item_list = []
|
||||
item1 = {"title": "测试管理平台V0.0.2测试发布", "created_at": "2023-09-23",
|
||||
"content": "测试管理平台V0.0.2发布,正在进行内部测试.."}
|
||||
item_list.append(item1)
|
||||
item2 = {"title": "测试管理平台更新公共", "created_at": "2024-06-17",
|
||||
"content": "<p>1.修改大纲和报告模版<p><p>2.修复多个bug<p>"}
|
||||
item_list.append(item2)
|
||||
return item_list
|
||||
|
||||
@route.get('/workplace/statistics')
|
||||
@transaction.atomic
|
||||
def get_statistics(self):
|
||||
# 查询用户数量,进行的项目,项目总数,已完成项目数
|
||||
user_count = Users.objects.count()
|
||||
project_qs = Project.objects.all()
|
||||
project_count = project_qs.count()
|
||||
project_done_count = project_qs.filter(step='3').count()
|
||||
project_processing_count = project_qs.filter(Q(step='1') | Q(step='2')).count()
|
||||
return ChenResponse(data={'pcount': project_count, 'ucount': user_count,
|
||||
'pdcount': project_done_count, 'ppcount': project_processing_count})
|
||||
|
||||
@route.get('/statistics/chart')
|
||||
@transaction.atomic
|
||||
def get_chart(self):
|
||||
"""该接口返回当前年份下,每月的项目统计,返回横坐标12个月的字符串以及12个月数据"""
|
||||
current_year = date.today().year
|
||||
month_list = []
|
||||
# 构造数组,里面是字典
|
||||
for i in range(12):
|
||||
month_dict = {'month': i + 1, 'count': 0}
|
||||
month_list.append(month_dict)
|
||||
project_qs = Project.objects.all()
|
||||
for project in project_qs:
|
||||
for m in month_list:
|
||||
if m['month'] == project.beginTime.month and project.beginTime.year == current_year:
|
||||
m['count'] += 1
|
||||
return ChenResponse(status=200, code=200, data=month_list)
|
||||
|
||||
Reference in New Issue
Block a user