mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
More serial number validation and unit testing
-
This commit is contained in:
parent
2d6c531fda
commit
ea88a03b5a
@ -266,6 +266,48 @@ class Part(MPTTModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{n} - {d}".format(n=self.full_name, d=self.description)
|
return "{n} - {d}".format(n=self.full_name, d=self.description)
|
||||||
|
|
||||||
|
def check_if_serial_number_exists(self, sn):
|
||||||
|
"""
|
||||||
|
Check if a serial number exists for this Part.
|
||||||
|
|
||||||
|
Note: Serial numbers must be unique across an entire Part "tree",
|
||||||
|
so here we filter by the entire tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||||
|
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
|
||||||
|
|
||||||
|
return stock.exists()
|
||||||
|
|
||||||
|
def get_highest_serial_number(self):
|
||||||
|
"""
|
||||||
|
Return the highest serial number for this Part.
|
||||||
|
|
||||||
|
Note: Serial numbers must be unique across an entire Part "tree",
|
||||||
|
so we filter by the entire tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||||
|
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None).order_by('-serial')
|
||||||
|
|
||||||
|
if stock.count() > 0:
|
||||||
|
return stock.first().serial
|
||||||
|
|
||||||
|
# No serial numbers found
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_next_serial_number(self):
|
||||||
|
"""
|
||||||
|
Return the next-available serial number for this Part.
|
||||||
|
"""
|
||||||
|
|
||||||
|
n = self.get_highest_serial_number()
|
||||||
|
|
||||||
|
if n is None:
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
return n + 1
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self):
|
||||||
""" Format a 'full name' for this Part.
|
""" Format a 'full name' for this Part.
|
||||||
@ -642,32 +684,40 @@ class Part(MPTTModel):
|
|||||||
self.sales_order_allocation_count(),
|
self.sales_order_allocation_count(),
|
||||||
])
|
])
|
||||||
|
|
||||||
@property
|
def stock_entries(self, include_variants=True, in_stock=None):
|
||||||
def stock_entries(self):
|
""" Return all stock entries for this Part.
|
||||||
""" Return all 'in stock' items. To be in stock:
|
|
||||||
|
|
||||||
- build_order is None
|
- If this is a template part, include variants underneath this.
|
||||||
- sales_order is None
|
|
||||||
- belongs_to is None
|
Note: To return all stock-entries for all part variants under this one,
|
||||||
|
we need to be creative with the filtering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
if include_variants:
|
||||||
|
query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True))
|
||||||
|
else:
|
||||||
|
query = self.stock_items
|
||||||
|
|
||||||
|
if in_stock is True:
|
||||||
|
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
elif in_stock is False:
|
||||||
|
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_stock(self):
|
def total_stock(self):
|
||||||
""" Return the total stock quantity for this part.
|
""" Return the total stock quantity for this part.
|
||||||
Part may be stored in multiple locations
|
|
||||||
|
- Part may be stored in multiple locations
|
||||||
|
- If this part is a "template" (variants exist) then these are counted too
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.is_template:
|
entries = self.stock_entries(in_stock=True)
|
||||||
total = sum([variant.total_stock for variant in self.variants.all()])
|
|
||||||
else:
|
|
||||||
total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
|
|
||||||
|
|
||||||
if total:
|
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
|
||||||
return total
|
|
||||||
else:
|
return query['t']
|
||||||
return Decimal(0)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
|
@ -85,7 +85,7 @@ class PartAPITest(APITestCase):
|
|||||||
data = {'cascade': True}
|
data = {'cascade': True}
|
||||||
response = self.client.get(url, data, format='json')
|
response = self.client.get(url, data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(len(response.data), 8)
|
self.assertEqual(len(response.data), 13)
|
||||||
|
|
||||||
def test_get_parts_by_cat(self):
|
def test_get_parts_by_cat(self):
|
||||||
url = reverse('api-part-list')
|
url = reverse('api-part-list')
|
||||||
|
@ -88,9 +88,9 @@ class CategoryTest(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(self.electronics.partcount(), 3)
|
self.assertEqual(self.electronics.partcount(), 3)
|
||||||
|
|
||||||
self.assertEqual(self.mechanical.partcount(), 4)
|
self.assertEqual(self.mechanical.partcount(), 8)
|
||||||
self.assertEqual(self.mechanical.partcount(active=True), 3)
|
self.assertEqual(self.mechanical.partcount(active=True), 7)
|
||||||
self.assertEqual(self.mechanical.partcount(False), 2)
|
self.assertEqual(self.mechanical.partcount(False), 6)
|
||||||
|
|
||||||
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
||||||
|
|
||||||
|
@ -63,6 +63,8 @@ class PartTest(TestCase):
|
|||||||
green = Part.objects.get(pk=10004)
|
green = Part.objects.get(pk=10004)
|
||||||
self.assertEqual(green.get_ancestors().count(), 2)
|
self.assertEqual(green.get_ancestors().count(), 2)
|
||||||
self.assertEqual(green.get_root(), chair)
|
self.assertEqual(green.get_root(), chair)
|
||||||
|
self.assertEqual(green.get_family().count(), 3)
|
||||||
|
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
p = Part.objects.get(pk=100)
|
p = Part.objects.get(pk=100)
|
||||||
|
@ -69,3 +69,159 @@
|
|||||||
tree_id: 0
|
tree_id: 0
|
||||||
lft: 0
|
lft: 0
|
||||||
rght: 0
|
rght: 0
|
||||||
|
|
||||||
|
# Stock items for template / variant parts
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 500
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 5
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 501
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 1
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 501
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 1
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 502
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 2
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 503
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 3
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 504
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 4
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 505
|
||||||
|
fields:
|
||||||
|
part: 10001
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 5
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 510
|
||||||
|
fields:
|
||||||
|
part: 10002
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 10
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 511
|
||||||
|
fields:
|
||||||
|
part: 10002
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 11
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 512
|
||||||
|
fields:
|
||||||
|
part: 10002
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 12
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 520
|
||||||
|
fields:
|
||||||
|
part: 10004
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 20
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 521
|
||||||
|
fields:
|
||||||
|
part: 10004
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 21
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
||||||
|
|
||||||
|
- model: stock.stockitem
|
||||||
|
pk: 522
|
||||||
|
fields:
|
||||||
|
part: 10004
|
||||||
|
location: 7
|
||||||
|
quantity: 1
|
||||||
|
serial: 22
|
||||||
|
level: 0
|
||||||
|
tree_id: 0
|
||||||
|
lft: 0
|
||||||
|
rght: 0
|
@ -142,11 +142,31 @@ class StockItem(MPTTModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Save this StockItem to the database. Performs a number of checks:
|
||||||
|
|
||||||
|
- Unique serial number requirement
|
||||||
|
- Adds a transaction note when the item is first created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Query to look for duplicate serial numbers
|
||||||
|
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
||||||
|
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
|
||||||
|
|
||||||
if not self.pk:
|
if not self.pk:
|
||||||
|
# StockItem has not yet been saved
|
||||||
add_note = True
|
add_note = True
|
||||||
else:
|
else:
|
||||||
|
# StockItem has already been saved
|
||||||
add_note = False
|
add_note = False
|
||||||
|
|
||||||
|
stock = stock.exclude(pk=self.pk)
|
||||||
|
|
||||||
|
if self.serial is not None:
|
||||||
|
# Check for presence of stock with same serial number
|
||||||
|
if stock.exists():
|
||||||
|
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
|
||||||
|
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop('user', None)
|
||||||
|
|
||||||
add_note = add_note and kwargs.pop('note', True)
|
add_note = add_note and kwargs.pop('note', True)
|
||||||
@ -172,37 +192,6 @@ class StockItem(MPTTModel):
|
|||||||
""" Return True if this StockItem is serialized """
|
""" Return True if this StockItem is serialized """
|
||||||
return self.serial is not None and self.quantity == 1
|
return self.serial is not None and self.quantity == 1
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def check_serial_number(cls, part, serial_number):
|
|
||||||
""" Check if a new stock item can be created with the provided part_id
|
|
||||||
|
|
||||||
Args:
|
|
||||||
part: The part to be checked
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not part.trackable:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Return False if an invalid serial number is supplied
|
|
||||||
try:
|
|
||||||
serial_number = int(serial_number)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
items = StockItem.objects.filter(serial=serial_number)
|
|
||||||
|
|
||||||
# Is this part a variant? If so, check S/N across all sibling variants
|
|
||||||
if part.variant_of is not None:
|
|
||||||
items = items.filter(part__variant_of=part.variant_of)
|
|
||||||
else:
|
|
||||||
items = items.filter(part=part)
|
|
||||||
|
|
||||||
# An existing serial number exists
|
|
||||||
if items.exists():
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
def validate_unique(self, exclude=None):
|
||||||
super(StockItem, self).validate_unique(exclude)
|
super(StockItem, self).validate_unique(exclude)
|
||||||
|
|
||||||
@ -210,18 +199,21 @@ class StockItem(MPTTModel):
|
|||||||
# ensure that the serial number is unique
|
# ensure that the serial number is unique
|
||||||
# across all variants of the same template part
|
# across all variants of the same template part
|
||||||
|
|
||||||
|
print("validating...")
|
||||||
|
print(self.pk, self.serial)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.serial is not None:
|
if self.serial is not None:
|
||||||
# This is a variant part (check S/N across all sibling variants)
|
|
||||||
if self.part.variant_of is not None:
|
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
||||||
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists():
|
stock = StockItem.objects.filter(
|
||||||
raise ValidationError({
|
part__in=parts,
|
||||||
'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
|
serial=self.serial,
|
||||||
})
|
).exclude(pk=self.pk)
|
||||||
else:
|
|
||||||
if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists():
|
if stock.exists():
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'serial': _('A stock item with this serial number already exists')
|
'serial': _('A stock item with this serial number already exists for this part'),
|
||||||
})
|
})
|
||||||
except PartModels.Part.DoesNotExist:
|
except PartModels.Part.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
@ -599,6 +591,9 @@ class StockItem(MPTTModel):
|
|||||||
if self.serialized:
|
if self.serialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.part.trackable:
|
||||||
|
raise ValidationError({"part": _("Part is not set as trackable")})
|
||||||
|
|
||||||
# Quantity must be a valid integer value
|
# Quantity must be a valid integer value
|
||||||
try:
|
try:
|
||||||
quantity = int(quantity)
|
quantity = int(quantity)
|
||||||
@ -624,7 +619,7 @@ class StockItem(MPTTModel):
|
|||||||
existing = []
|
existing = []
|
||||||
|
|
||||||
for serial in serials:
|
for serial in serials:
|
||||||
if not StockItem.check_serial_number(self.part, serial):
|
if self.part.check_if_serial_number_exists(serial):
|
||||||
existing.append(serial)
|
existing.append(serial)
|
||||||
|
|
||||||
if len(existing) > 0:
|
if len(existing) > 0:
|
||||||
|
@ -38,6 +38,10 @@ class StockTest(TestCase):
|
|||||||
|
|
||||||
self.user = User.objects.get(username='username')
|
self.user = User.objects.get(username='username')
|
||||||
|
|
||||||
|
# Ensure the MPTT objects are correctly rebuild
|
||||||
|
Part.objects.rebuild()
|
||||||
|
StockItem.objects.rebuild()
|
||||||
|
|
||||||
def test_loc_count(self):
|
def test_loc_count(self):
|
||||||
self.assertEqual(StockLocation.objects.count(), 7)
|
self.assertEqual(StockLocation.objects.count(), 7)
|
||||||
|
|
||||||
@ -91,13 +95,16 @@ class StockTest(TestCase):
|
|||||||
self.assertFalse(self.drawer2.has_items())
|
self.assertFalse(self.drawer2.has_items())
|
||||||
|
|
||||||
# Drawer 3 should have three stock items
|
# Drawer 3 should have three stock items
|
||||||
self.assertEqual(self.drawer3.stock_items.count(), 3)
|
self.assertEqual(self.drawer3.stock_items.count(), 15)
|
||||||
self.assertEqual(self.drawer3.item_count, 3)
|
self.assertEqual(self.drawer3.item_count, 15)
|
||||||
|
|
||||||
def test_stock_count(self):
|
def test_stock_count(self):
|
||||||
part = Part.objects.get(pk=1)
|
part = Part.objects.get(pk=1)
|
||||||
|
entries = part.stock_entries()
|
||||||
|
|
||||||
# There should be 5000 screws in stock
|
self.assertEqual(entries.count(), 2)
|
||||||
|
|
||||||
|
# There should be 9000 screws in stock
|
||||||
self.assertEqual(part.total_stock, 9000)
|
self.assertEqual(part.total_stock, 9000)
|
||||||
|
|
||||||
# There should be 18 widgets in stock
|
# There should be 18 widgets in stock
|
||||||
@ -301,6 +308,7 @@ class StockTest(TestCase):
|
|||||||
item.delete_on_deplete = True
|
item.delete_on_deplete = True
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
n = StockItem.objects.filter(part=25).count()
|
n = StockItem.objects.filter(part=25).count()
|
||||||
|
|
||||||
self.assertEqual(item.quantity, 10)
|
self.assertEqual(item.quantity, 10)
|
||||||
@ -327,3 +335,73 @@ class StockTest(TestCase):
|
|||||||
|
|
||||||
# Serialize the remainder of the stock
|
# Serialize the remainder of the stock
|
||||||
item.serializeStock(2, [99, 100], self.user)
|
item.serializeStock(2, [99, 100], self.user)
|
||||||
|
|
||||||
|
|
||||||
|
class VariantTest(StockTest):
|
||||||
|
"""
|
||||||
|
Tests for calculation stock counts against templates / variants
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_variant_stock(self):
|
||||||
|
# Check the 'Chair' variant
|
||||||
|
chair = Part.objects.get(pk=10000)
|
||||||
|
|
||||||
|
# No stock items for the variant part itself
|
||||||
|
self.assertEqual(chair.stock_entries(include_variants=False).count(), 0)
|
||||||
|
|
||||||
|
self.assertEqual(chair.stock_entries().count(), 12)
|
||||||
|
|
||||||
|
green = Part.objects.get(pk=10003)
|
||||||
|
self.assertEqual(green.stock_entries(include_variants=False).count(), 0)
|
||||||
|
self.assertEqual(green.stock_entries().count(), 3)
|
||||||
|
|
||||||
|
def test_serial_numbers(self):
|
||||||
|
# Test serial number functionality for variant / template parts
|
||||||
|
|
||||||
|
chair = Part.objects.get(pk=10000)
|
||||||
|
|
||||||
|
# Operations on the top-level object
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(1))
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(2))
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(3))
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(4))
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(5))
|
||||||
|
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(20))
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(21))
|
||||||
|
self.assertTrue(chair.check_if_serial_number_exists(22))
|
||||||
|
|
||||||
|
self.assertFalse(chair.check_if_serial_number_exists(30))
|
||||||
|
|
||||||
|
self.assertEqual(chair.get_next_serial_number(), 23)
|
||||||
|
|
||||||
|
# Same operations on a sub-item
|
||||||
|
variant = Part.objects.get(pk=10003)
|
||||||
|
self.assertEqual(variant.get_next_serial_number(), 23)
|
||||||
|
|
||||||
|
# Create a new serial number
|
||||||
|
n = variant.get_highest_serial_number()
|
||||||
|
|
||||||
|
item = StockItem(
|
||||||
|
part=variant,
|
||||||
|
quantity=1,
|
||||||
|
serial=n
|
||||||
|
)
|
||||||
|
|
||||||
|
# This should fail
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
# This should pass
|
||||||
|
item.serial = n + 1
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
# Attempt to create the same serial number but for a variant (should fail!)
|
||||||
|
item.pk = None
|
||||||
|
item.part = Part.objects.get(pk=10004)
|
||||||
|
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
item.serial += 1
|
||||||
|
item.save()
|
||||||
|
Loading…
Reference in New Issue
Block a user