mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-02 03:14:56 +00:00
Stock Tracking - Add Old Status to Deltas (#11179)
* match custom status tracking entry in edit * add old_status to stockitemtracking * test old_status tracking * use vars for readability * split custom status test * move custom status from fixture to setup * add old status to tracking table * fallback to logical status if custom removed * avoid shared deltas reference in loop * track old status in stock add/remove/count/transfer --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@@ -795,12 +795,28 @@ class StockItem(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
old = StockItem.objects.get(pk=self.pk)
|
old = StockItem.objects.get(pk=self.pk)
|
||||||
|
old_custom_status = old.get_custom_status()
|
||||||
|
custom_status = self.get_custom_status()
|
||||||
|
|
||||||
deltas = {}
|
deltas = {}
|
||||||
|
|
||||||
# Status changed?
|
# Status changed?
|
||||||
if old.status != self.status:
|
if old.status != self.status:
|
||||||
deltas['status'] = self.status
|
# Custom status changed?
|
||||||
|
# Matches custom status tracking behavior of StockChangeStatusSerializer
|
||||||
|
if old_custom_status != custom_status:
|
||||||
|
deltas['status'] = custom_status
|
||||||
|
deltas['status_logical'] = self.status
|
||||||
|
else:
|
||||||
|
deltas['status'] = self.status
|
||||||
|
deltas['status_logical'] = self.status
|
||||||
|
|
||||||
|
if old_custom_status:
|
||||||
|
deltas['old_status'] = old_custom_status
|
||||||
|
deltas['old_status_logical'] = old.status
|
||||||
|
else:
|
||||||
|
deltas['old_status'] = old.status
|
||||||
|
deltas['old_status_logical'] = old.status
|
||||||
|
|
||||||
if add_note and len(deltas) > 0:
|
if add_note and len(deltas) > 0:
|
||||||
self.add_tracking_entry(
|
self.add_tracking_entry(
|
||||||
@@ -1446,8 +1462,17 @@ class StockItem(
|
|||||||
|
|
||||||
if status := kwargs.pop('status', None):
|
if status := kwargs.pop('status', None):
|
||||||
if not item.compare_status(status):
|
if not item.compare_status(status):
|
||||||
|
old_custom_status = item.get_custom_status()
|
||||||
|
old_status_logical = item.status
|
||||||
item.set_status(status)
|
item.set_status(status)
|
||||||
tracking_info['status'] = status
|
tracking_info['status'] = status # may be a custom value
|
||||||
|
tracking_info['status_logicial'] = (
|
||||||
|
item.status
|
||||||
|
) # always the logical value
|
||||||
|
tracking_info['old_status'] = (
|
||||||
|
old_custom_status if old_custom_status else old_status_logical
|
||||||
|
)
|
||||||
|
tracking_info['old_status_logical'] = old_status_logical
|
||||||
|
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
@@ -2268,8 +2293,26 @@ class StockItem(
|
|||||||
# Optional fields which can be supplied in a 'move' call
|
# Optional fields which can be supplied in a 'move' call
|
||||||
for field in StockItem.optional_transfer_fields():
|
for field in StockItem.optional_transfer_fields():
|
||||||
if field in kwargs:
|
if field in kwargs:
|
||||||
setattr(new_stock, field, kwargs[field])
|
# handle specific case for status deltas
|
||||||
deltas[field] = kwargs[field]
|
if field == 'status':
|
||||||
|
status = kwargs[field]
|
||||||
|
if not new_stock.compare_status(status):
|
||||||
|
old_custom_status = new_stock.get_custom_status()
|
||||||
|
old_status_logical = new_stock.status
|
||||||
|
new_stock.set_status(status)
|
||||||
|
deltas['status'] = status # may be a custom value
|
||||||
|
deltas['status_logicial'] = (
|
||||||
|
new_stock.status
|
||||||
|
) # always the logical value
|
||||||
|
deltas['old_status'] = (
|
||||||
|
old_custom_status
|
||||||
|
if old_custom_status
|
||||||
|
else old_status_logical
|
||||||
|
)
|
||||||
|
deltas['old_status_logical'] = old_status_logical
|
||||||
|
else:
|
||||||
|
setattr(new_stock, field, kwargs[field])
|
||||||
|
deltas[field] = kwargs[field]
|
||||||
|
|
||||||
new_stock.save(add_note=False)
|
new_stock.save(add_note=False)
|
||||||
|
|
||||||
@@ -2385,8 +2428,15 @@ class StockItem(
|
|||||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
if status and not self.compare_status(status):
|
if status and not self.compare_status(status):
|
||||||
|
old_custom_status = self.get_custom_status()
|
||||||
|
old_status_logical = self.status
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
tracking_info['status'] = status
|
tracking_info['status'] = status # may be a custom value
|
||||||
|
tracking_info['status_logicial'] = self.status # always the logical value
|
||||||
|
tracking_info['old_status'] = (
|
||||||
|
old_custom_status if old_custom_status else old_status_logical
|
||||||
|
)
|
||||||
|
tracking_info['old_status_logical'] = old_status_logical
|
||||||
|
|
||||||
# Optional fields which can be supplied in a 'move' call
|
# Optional fields which can be supplied in a 'move' call
|
||||||
for field in StockItem.optional_transfer_fields():
|
for field in StockItem.optional_transfer_fields():
|
||||||
@@ -2437,7 +2487,7 @@ class StockItem(
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.save()
|
self.save(add_note=False)
|
||||||
|
|
||||||
trigger_event(
|
trigger_event(
|
||||||
StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity)
|
StockEvents.ITEM_QUANTITY_UPDATED, id=self.id, quantity=float(self.quantity)
|
||||||
@@ -2470,8 +2520,15 @@ class StockItem(
|
|||||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
if status and not self.compare_status(status):
|
if status and not self.compare_status(status):
|
||||||
|
old_custom_status = self.get_custom_status()
|
||||||
|
old_status_logical = self.status
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
tracking_info['status'] = status
|
tracking_info['status'] = status # may be a custom value
|
||||||
|
tracking_info['status_logicial'] = self.status # always the logical value
|
||||||
|
tracking_info['old_status'] = (
|
||||||
|
old_custom_status if old_custom_status else old_status_logical
|
||||||
|
)
|
||||||
|
tracking_info['old_status_logical'] = old_status_logical
|
||||||
|
|
||||||
if self.updateQuantity(count):
|
if self.updateQuantity(count):
|
||||||
tracking_info['quantity'] = float(count)
|
tracking_info['quantity'] = float(count)
|
||||||
@@ -2533,8 +2590,15 @@ class StockItem(
|
|||||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
if status and not self.compare_status(status):
|
if status and not self.compare_status(status):
|
||||||
|
old_custom_status = self.get_custom_status()
|
||||||
|
old_status_logical = self.status
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
tracking_info['status'] = status
|
tracking_info['status'] = status # may be a custom value
|
||||||
|
tracking_info['status_logicial'] = self.status # always the logical value
|
||||||
|
tracking_info['old_status'] = (
|
||||||
|
old_custom_status if old_custom_status else old_status_logical
|
||||||
|
)
|
||||||
|
tracking_info['old_status_logical'] = old_status_logical
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity + quantity):
|
if self.updateQuantity(self.quantity + quantity):
|
||||||
tracking_info['added'] = float(quantity)
|
tracking_info['added'] = float(quantity)
|
||||||
@@ -2587,8 +2651,15 @@ class StockItem(
|
|||||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
if status and not self.compare_status(status):
|
if status and not self.compare_status(status):
|
||||||
|
old_custom_status = self.get_custom_status()
|
||||||
|
old_status_logical = self.status
|
||||||
self.set_status(status)
|
self.set_status(status)
|
||||||
deltas['status'] = status
|
deltas['status'] = status # may be a custom value
|
||||||
|
deltas['status_logicial'] = self.status # always the logical value
|
||||||
|
deltas['old_status'] = (
|
||||||
|
old_custom_status if old_custom_status else old_status_logical
|
||||||
|
)
|
||||||
|
deltas['old_status_logical'] = old_status_logical
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity - quantity):
|
if self.updateQuantity(self.quantity - quantity):
|
||||||
deltas['removed'] = float(quantity)
|
deltas['removed'] = float(quantity)
|
||||||
|
|||||||
@@ -463,12 +463,16 @@ class StockItemSerializer(
|
|||||||
status_custom_key = validated_data.pop('status_custom_key', None)
|
status_custom_key = validated_data.pop('status_custom_key', None)
|
||||||
status = validated_data.pop('status', None)
|
status = validated_data.pop('status', None)
|
||||||
|
|
||||||
instance = super().update(instance, validated_data=validated_data)
|
|
||||||
|
|
||||||
if status_code := status_custom_key or status:
|
if status_code := status_custom_key or status:
|
||||||
if not instance.compare_status(status_code):
|
# avoid a second .save() call and perform both status updates at once (to support `old_status` in tracking event)
|
||||||
instance.set_status(status_code)
|
# by setting the values in validated_data as computed by set_status()
|
||||||
instance.save()
|
instance.set_status(status_code)
|
||||||
|
validated_data['status'] = instance.status
|
||||||
|
validated_data['status_custom_key'] = (
|
||||||
|
status_code # for compatibility with custom "leader/follower" concept in super().update()
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = super().update(instance, validated_data=validated_data)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@@ -1053,8 +1057,6 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
transaction_notes = []
|
transaction_notes = []
|
||||||
|
|
||||||
deltas = {'status': status}
|
|
||||||
|
|
||||||
now = InvenTree.helpers.current_time()
|
now = InvenTree.helpers.current_time()
|
||||||
|
|
||||||
# Instead of performing database updates for each item,
|
# Instead of performing database updates for each item,
|
||||||
@@ -1072,9 +1074,22 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
|||||||
if status == custom_status or custom_status is None:
|
if status == custom_status or custom_status is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
deltas = {'status': status}
|
||||||
|
|
||||||
|
# before save, track old status logical
|
||||||
|
deltas['old_status_logical'] = item.status
|
||||||
|
|
||||||
|
if item.get_custom_status():
|
||||||
|
deltas['old_status'] = item.get_custom_status()
|
||||||
|
else:
|
||||||
|
deltas['old_status'] = item.status
|
||||||
|
|
||||||
item.set_status(status, custom_values=custom_status_codes)
|
item.set_status(status, custom_values=custom_status_codes)
|
||||||
item.save(add_note=False)
|
item.save(add_note=False)
|
||||||
|
|
||||||
|
# after save, can track new status_logical
|
||||||
|
deltas['status_logical'] = item.status
|
||||||
|
|
||||||
# Create a new transaction note for each item
|
# Create a new transaction note for each item
|
||||||
transaction_notes.append(
|
transaction_notes.append(
|
||||||
StockItemTracking(
|
StockItemTracking(
|
||||||
|
|||||||
@@ -1315,6 +1315,17 @@ class StockItemTest(StockAPITestCase):
|
|||||||
StockLocation.objects.create(name='B', description='location b', parent=top)
|
StockLocation.objects.create(name='B', description='location b', parent=top)
|
||||||
StockLocation.objects.create(name='C', description='location c', parent=top)
|
StockLocation.objects.create(name='C', description='location c', parent=top)
|
||||||
|
|
||||||
|
# Create a custom status
|
||||||
|
self.inspect_custom_status = InvenTreeCustomUserStateModel.objects.create(
|
||||||
|
key=150,
|
||||||
|
name='INSPECT',
|
||||||
|
label='Incoming goods inspection',
|
||||||
|
color='warning',
|
||||||
|
logical_key=50,
|
||||||
|
model=ContentType.objects.get(model='stockitem'),
|
||||||
|
reference_status='StockStatus',
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_default_location(self):
|
def test_create_default_location(self):
|
||||||
"""Test the default location functionality, if a 'location' is not specified in the creation request."""
|
"""Test the default location functionality, if a 'location' is not specified in the creation request."""
|
||||||
# The part 'R_4K7_0603' (pk=4) has a default location specified
|
# The part 'R_4K7_0603' (pk=4) has a default location specified
|
||||||
@@ -1820,6 +1831,15 @@ class StockItemTest(StockAPITestCase):
|
|||||||
item.refresh_from_db()
|
item.refresh_from_db()
|
||||||
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
self.assertEqual(item.status, StockStatus.DAMAGED.value)
|
||||||
self.assertEqual(item.tracking_info.count(), 2)
|
self.assertEqual(item.tracking_info.count(), 2)
|
||||||
|
tracking = item.tracking_info.last()
|
||||||
|
self.assertEqual(tracking.deltas['old_status'], StockStatus.OK.value)
|
||||||
|
self.assertEqual(
|
||||||
|
tracking.deltas['old_status_logical'], StockStatus.OK.value
|
||||||
|
)
|
||||||
|
self.assertEqual(tracking.deltas['status'], StockStatus.DAMAGED.value)
|
||||||
|
self.assertEqual(
|
||||||
|
tracking.deltas['status_logical'], StockStatus.DAMAGED.value
|
||||||
|
)
|
||||||
|
|
||||||
# Same test, but with one item unchanged
|
# Same test, but with one item unchanged
|
||||||
items[0].set_status(StockStatus.ATTENTION.value)
|
items[0].set_status(StockStatus.ATTENTION.value)
|
||||||
@@ -1837,6 +1857,68 @@ class StockItemTest(StockAPITestCase):
|
|||||||
tracking = item.tracking_info.last()
|
tracking = item.tracking_info.last()
|
||||||
self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value)
|
self.assertEqual(tracking.tracking_type, StockHistoryCode.EDITED.value)
|
||||||
|
|
||||||
|
def test_set_custom_status(self):
|
||||||
|
"""Test API endpoint for setting StockItem custom status."""
|
||||||
|
url = reverse('api-stock-change-status')
|
||||||
|
|
||||||
|
prt = Part.objects.first()
|
||||||
|
|
||||||
|
# Number of items to create
|
||||||
|
N_ITEMS = 10
|
||||||
|
|
||||||
|
# Create a bunch of items
|
||||||
|
items = [
|
||||||
|
StockItem.objects.create(part=prt, quantity=10) for _ in range(N_ITEMS)
|
||||||
|
]
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.status, StockStatus.OK.value)
|
||||||
|
self.assertEqual(item.tracking_info.count(), 1)
|
||||||
|
|
||||||
|
# Test tracking with custom status
|
||||||
|
# *from* standard *to* custom
|
||||||
|
data = {
|
||||||
|
'items': [item.pk for item in items],
|
||||||
|
'status': self.inspect_custom_status.key,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.post(url, data, expected_code=201)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.status, self.inspect_custom_status.logical_key)
|
||||||
|
self.assertEqual(item.get_custom_status(), self.inspect_custom_status.key)
|
||||||
|
tracking = item.tracking_info.last()
|
||||||
|
self.assertEqual(tracking.deltas['old_status'], StockStatus.OK.value)
|
||||||
|
self.assertEqual(
|
||||||
|
tracking.deltas['old_status_logical'], StockStatus.OK.value
|
||||||
|
)
|
||||||
|
self.assertEqual(tracking.deltas['status'], self.inspect_custom_status.key)
|
||||||
|
self.assertEqual(
|
||||||
|
tracking.deltas['status_logical'],
|
||||||
|
self.inspect_custom_status.logical_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# reverse case
|
||||||
|
# *from* custom *to* standard
|
||||||
|
data['status'] = StockStatus.OK.value
|
||||||
|
self.post(url, data, expected_code=201)
|
||||||
|
for item in items:
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertEqual(item.status, StockStatus.OK.value)
|
||||||
|
self.assertIsNone(item.get_custom_status())
|
||||||
|
tracking = item.tracking_info.last()
|
||||||
|
self.assertEqual(
|
||||||
|
tracking.deltas['old_status'], self.inspect_custom_status.key
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
tracking.deltas['old_status_logical'],
|
||||||
|
self.inspect_custom_status.logical_key,
|
||||||
|
)
|
||||||
|
self.assertEqual(tracking.deltas['status'], StockStatus.OK.value)
|
||||||
|
self.assertEqual(tracking.deltas['status_logical'], StockStatus.OK.value)
|
||||||
|
|
||||||
|
|
||||||
class StocktakeTest(StockAPITestCase):
|
class StocktakeTest(StockAPITestCase):
|
||||||
"""Series of tests for the Stocktake API."""
|
"""Series of tests for the Stocktake API."""
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ interface RenderStatusLabelOptionsInterface {
|
|||||||
function renderStatusLabel(
|
function renderStatusLabel(
|
||||||
key: string | number,
|
key: string | number,
|
||||||
codes: StatusCodeListInterface,
|
codes: StatusCodeListInterface,
|
||||||
options: RenderStatusLabelOptionsInterface = {}
|
options: RenderStatusLabelOptionsInterface = {},
|
||||||
|
fallback_key: string | number | null = null
|
||||||
) {
|
) {
|
||||||
let text = null;
|
let text = null;
|
||||||
let color = null;
|
let color = null;
|
||||||
@@ -46,6 +47,19 @@ function renderStatusLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!text && fallback_key !== null) {
|
||||||
|
// Handle fallback key (if provided)
|
||||||
|
for (const name in codes.values) {
|
||||||
|
const entry: StatusCodeInterface = codes.values[name];
|
||||||
|
|
||||||
|
if (entry?.key == fallback_key) {
|
||||||
|
text = entry.label;
|
||||||
|
color = entry.color;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
console.error(
|
console.error(
|
||||||
`ERR: renderStatusLabel could not find match for code ${key}`
|
`ERR: renderStatusLabel could not find match for code ${key}`
|
||||||
@@ -164,11 +178,13 @@ export function getStatusCodeLabel(
|
|||||||
export const StatusRenderer = ({
|
export const StatusRenderer = ({
|
||||||
status,
|
status,
|
||||||
type,
|
type,
|
||||||
options
|
options,
|
||||||
|
fallbackStatus
|
||||||
}: {
|
}: {
|
||||||
status: string | number;
|
status: string | number;
|
||||||
type: ModelType | string;
|
type: ModelType | string;
|
||||||
options?: RenderStatusLabelOptionsInterface;
|
options?: RenderStatusLabelOptionsInterface;
|
||||||
|
fallbackStatus?: string | number | null;
|
||||||
}) => {
|
}) => {
|
||||||
const statusCodes = getStatusCodes(type);
|
const statusCodes = getStatusCodes(type);
|
||||||
|
|
||||||
@@ -183,7 +199,7 @@ export const StatusRenderer = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderStatusLabel(status, statusCodes, options);
|
return renderStatusLabel(status, statusCodes, options, fallbackStatus);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ function BuildOutputFormRow({
|
|||||||
<Table.Td>{record.batch}</Table.Td>
|
<Table.Td>{record.batch}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<StatusRenderer
|
<StatusRenderer
|
||||||
status={record.status}
|
status={record.custom_status_key || record.status}
|
||||||
|
fallbackStatus={record.status}
|
||||||
type={ModelType.stockitem}
|
type={ModelType.stockitem}
|
||||||
/>{' '}
|
/>{' '}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -646,7 +646,8 @@ function StockOperationsRow({
|
|||||||
<Group grow justify='space-between' wrap='nowrap'>
|
<Group grow justify='space-between' wrap='nowrap'>
|
||||||
<Text>{stockString}</Text>
|
<Text>{stockString}</Text>
|
||||||
<StatusRenderer
|
<StatusRenderer
|
||||||
status={record.status_custom_key}
|
status={record.status_custom_key || record.status}
|
||||||
|
fallbackStatus={record.status}
|
||||||
type={ModelType.stockitem}
|
type={ModelType.stockitem}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -956,6 +956,7 @@ export default function StockDetail() {
|
|||||||
/>,
|
/>,
|
||||||
<StatusRenderer
|
<StatusRenderer
|
||||||
status={stockitem.status_custom_key || stockitem.status}
|
status={stockitem.status_custom_key || stockitem.status}
|
||||||
|
fallbackStatus={stockitem.status}
|
||||||
type={ModelType.stockitem}
|
type={ModelType.stockitem}
|
||||||
options={{
|
options={{
|
||||||
size: 'lg'
|
size: 'lg'
|
||||||
|
|||||||
@@ -66,7 +66,22 @@ export function StockTrackingTable({ itemId }: Readonly<{ itemId: number }>) {
|
|||||||
key: 'status',
|
key: 'status',
|
||||||
details:
|
details:
|
||||||
deltas.status &&
|
deltas.status &&
|
||||||
StatusRenderer({ status: deltas.status, type: ModelType.stockitem })
|
StatusRenderer({
|
||||||
|
status: deltas.status,
|
||||||
|
type: ModelType.stockitem,
|
||||||
|
fallbackStatus: deltas.status_logical
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Old Status`,
|
||||||
|
key: 'old_status',
|
||||||
|
details:
|
||||||
|
deltas.old_status &&
|
||||||
|
StatusRenderer({
|
||||||
|
status: deltas.old_status,
|
||||||
|
type: ModelType.stockitem,
|
||||||
|
fallbackStatus: deltas.old_status_logical
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Quantity`,
|
label: t`Quantity`,
|
||||||
|
|||||||
Reference in New Issue
Block a user