mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Convert category parameter forms to use the API (#3130)
* Moving PartCategoryParameterTemplate model to the API - Add detail API endpoint - Add 'create' action to LIST endpoint * Update settings page to use the new API forms * Remove old views / forms * Update API version * Fix table buttons * Add title to deletion form * Add unit tests for new API views
This commit is contained in:
parent
80b10c62f3
commit
bbbfd003e0
@ -2,11 +2,14 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 56
|
||||
INVENTREE_API_VERSION = 57
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v57 -> 2022-06-05 : https://github.com/inventree/InvenTree/pull/3130
|
||||
- Transfer PartCategoryTemplateParameter actions to the API
|
||||
|
||||
v56 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3123
|
||||
- Expose the PartParameterTemplate model to use the API
|
||||
|
||||
|
@ -194,7 +194,7 @@ class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
|
||||
class CategoryParameterList(generics.ListAPIView):
|
||||
class CategoryParameterList(generics.ListCreateAPIView):
|
||||
"""API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||
|
||||
- GET: Return a list of PartCategoryParameterTemplate objects
|
||||
@ -235,6 +235,13 @@ class CategoryParameterList(generics.ListAPIView):
|
||||
return queryset
|
||||
|
||||
|
||||
class CategoryParameterDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""Detail endpoint fro the PartCategoryParameterTemplate model"""
|
||||
|
||||
queryset = PartCategoryParameterTemplate.objects.all()
|
||||
serializer_class = part_serializers.CategoryParameterTemplateSerializer
|
||||
|
||||
|
||||
class CategoryTree(generics.ListAPIView):
|
||||
"""API endpoint for accessing a list of PartCategory objects ready for rendering a tree."""
|
||||
|
||||
@ -1855,7 +1862,11 @@ part_api_urls = [
|
||||
# Base URL for PartCategory API endpoints
|
||||
re_path(r'^category/', include([
|
||||
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
||||
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||
|
||||
re_path(r'^parameters/', include([
|
||||
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
|
||||
re_path('^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||
])),
|
||||
|
||||
# Category detail endpoints
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
|
@ -10,8 +10,8 @@ from InvenTree.fields import RoundingDecimalFormField
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.helpers import clean_decimal
|
||||
|
||||
from .models import (Part, PartCategory, PartCategoryParameterTemplate,
|
||||
PartInternalPriceBreak, PartSellPriceBreak)
|
||||
from .models import (Part, PartCategory, PartInternalPriceBreak,
|
||||
PartSellPriceBreak)
|
||||
|
||||
|
||||
class PartImageDownloadForm(HelperForm):
|
||||
@ -59,29 +59,6 @@ class SetPartCategoryForm(forms.Form):
|
||||
part_category = TreeNodeChoiceField(queryset=PartCategory.objects.all(), required=True, help_text=_('Select part category'))
|
||||
|
||||
|
||||
class EditCategoryParameterTemplateForm(HelperForm):
|
||||
"""Form for editing a PartCategoryParameterTemplate object."""
|
||||
|
||||
add_to_same_level_categories = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
help_text=_('Add parameter template to same level categories'))
|
||||
|
||||
add_to_all_categories = forms.BooleanField(required=False,
|
||||
initial=False,
|
||||
help_text=_('Add parameter template to all categories'))
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines fields for this form"""
|
||||
model = PartCategoryParameterTemplate
|
||||
fields = [
|
||||
'category',
|
||||
'parameter_template',
|
||||
'default_value',
|
||||
'add_to_same_level_categories',
|
||||
'add_to_all_categories',
|
||||
]
|
||||
|
||||
|
||||
class PartPriceForm(forms.Form):
|
||||
"""Simple form for viewing part pricing information."""
|
||||
|
||||
|
@ -2383,7 +2383,9 @@ class PartParameter(models.Model):
|
||||
|
||||
|
||||
class PartCategoryParameterTemplate(models.Model):
|
||||
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate. Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
|
||||
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate.
|
||||
|
||||
Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation.
|
||||
|
||||
Attributes:
|
||||
category: Reference to a single PartCategory object
|
||||
|
@ -753,10 +753,9 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
|
||||
class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for PartCategoryParameterTemplate."""
|
||||
"""Serializer for the PartCategoryParameterTemplate model."""
|
||||
|
||||
parameter_template = PartParameterTemplateSerializer(many=False,
|
||||
read_only=True)
|
||||
parameter_template_detail = PartParameterTemplateSerializer(source='parameter_template', many=False, read_only=True)
|
||||
|
||||
category_detail = CategorySerializer(source='category', many=False, read_only=True)
|
||||
|
||||
@ -768,6 +767,7 @@ class CategoryParameterTemplateSerializer(InvenTreeModelSerializer):
|
||||
'category',
|
||||
'category_detail',
|
||||
'parameter_template',
|
||||
'parameter_template_detail',
|
||||
'default_value',
|
||||
]
|
||||
|
||||
|
@ -14,6 +14,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
StockStatus)
|
||||
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
|
||||
PartCategoryParameterTemplate, PartParameterTemplate,
|
||||
PartRelated)
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
@ -24,6 +25,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'params',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
@ -40,6 +42,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
'part.delete',
|
||||
'part_category.change',
|
||||
'part_category.add',
|
||||
'part_category.delete',
|
||||
]
|
||||
|
||||
def test_category_list(self):
|
||||
@ -94,6 +97,57 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(metadata['water'], 'melon')
|
||||
self.assertEqual(metadata['abc'], 'ABC')
|
||||
|
||||
def test_category_parameters(self):
|
||||
"""Test that the PartCategoryParameterTemplate API function work"""
|
||||
|
||||
url = reverse('api-part-category-parameter-list')
|
||||
|
||||
response = self.get(url, {}, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
# Add some more category templates via the API
|
||||
n = PartParameterTemplate.objects.count()
|
||||
|
||||
for template in PartParameterTemplate.objects.all():
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'category': 2,
|
||||
'parameter_template': template.pk,
|
||||
'default_value': 'xyz',
|
||||
}
|
||||
)
|
||||
|
||||
# Total number of category templates should have increased
|
||||
response = self.get(url, {}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 2 + n)
|
||||
|
||||
# Filter by category
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'category': 2,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), n)
|
||||
|
||||
# Test that we can retrieve individual templates via the API
|
||||
for template in PartCategoryParameterTemplate.objects.all():
|
||||
url = reverse('api-part-category-parameter-detail', kwargs={'pk': template.pk})
|
||||
|
||||
data = self.get(url, {}, expected_code=200).data
|
||||
|
||||
for key in ['pk', 'category', 'category_detail', 'parameter_template', 'parameter_template_detail', 'default_value']:
|
||||
self.assertIn(key, data.keys())
|
||||
|
||||
# Test that we can delete via the API also
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# There should not be any templates left at this point
|
||||
self.assertEqual(PartCategoryParameterTemplate.objects.count(), 0)
|
||||
|
||||
|
||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
"""Tests for the various OPTIONS endpoints in the /part/ API.
|
||||
|
@ -28,12 +28,6 @@ part_detail_urls = [
|
||||
re_path(r'^.*$', views.PartDetail.as_view(), name='part-detail'),
|
||||
]
|
||||
|
||||
category_parameter_urls = [
|
||||
re_path(r'^new/', views.CategoryParameterTemplateCreate.as_view(), name='category-param-template-create'),
|
||||
re_path(r'^(?P<pid>\d+)/edit/', views.CategoryParameterTemplateEdit.as_view(), name='category-param-template-edit'),
|
||||
re_path(r'^(?P<pid>\d+)/delete/', views.CategoryParameterTemplateDelete.as_view(), name='category-param-template-delete'),
|
||||
]
|
||||
|
||||
category_urls = [
|
||||
|
||||
# Top level subcategory display
|
||||
@ -42,8 +36,6 @@ category_urls = [
|
||||
# Category detail views
|
||||
re_path(r'(?P<pk>\d+)/', include([
|
||||
re_path(r'^delete/', views.CategoryDelete.as_view(), name='category-delete'),
|
||||
re_path(r'^parameters/', include(category_parameter_urls)),
|
||||
|
||||
# Anything else
|
||||
re_path(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
|
||||
]))
|
||||
|
@ -9,8 +9,6 @@ from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.forms import HiddenInput
|
||||
from django.shortcuts import HttpResponseRedirect, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -27,8 +25,8 @@ from common.models import InvenTreeSetting
|
||||
from common.views import FileManagementAjaxView, FileManagementFormView
|
||||
from company.models import SupplierPart
|
||||
from InvenTree.helpers import str2bool
|
||||
from InvenTree.views import (AjaxCreateView, AjaxDeleteView, AjaxUpdateView,
|
||||
AjaxView, InvenTreeRoleMixin, QRCodeView)
|
||||
from InvenTree.views import (AjaxDeleteView, AjaxUpdateView, AjaxView,
|
||||
InvenTreeRoleMixin, QRCodeView)
|
||||
from order.models import PurchaseOrderLineItem
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
from stock.models import StockItem, StockLocation
|
||||
@ -36,7 +34,7 @@ from stock.models import StockItem, StockLocation
|
||||
from . import forms as part_forms
|
||||
from . import settings as part_settings
|
||||
from .bom import ExportBom, IsValidBOMFormat, MakeBomTemplate
|
||||
from .models import Part, PartCategory, PartCategoryParameterTemplate
|
||||
from .models import Part, PartCategory
|
||||
|
||||
|
||||
class PartIndex(InvenTreeRoleMixin, ListView):
|
||||
@ -984,185 +982,3 @@ class CategoryDelete(AjaxDeleteView):
|
||||
return {
|
||||
'danger': _('Part category was deleted'),
|
||||
}
|
||||
|
||||
|
||||
class CategoryParameterTemplateCreate(AjaxCreateView):
|
||||
"""View for creating a new PartCategoryParameterTemplate."""
|
||||
|
||||
model = PartCategoryParameterTemplate
|
||||
form_class = part_forms.EditCategoryParameterTemplateForm
|
||||
ajax_form_title = _('Create Category Parameter Template')
|
||||
|
||||
def get_initial(self):
|
||||
"""Get initial data for Category."""
|
||||
initials = super().get_initial()
|
||||
|
||||
category_id = self.kwargs.get('pk', None)
|
||||
|
||||
if category_id:
|
||||
try:
|
||||
initials['category'] = PartCategory.objects.get(pk=category_id)
|
||||
except (PartCategory.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
return initials
|
||||
|
||||
def get_form(self):
|
||||
"""Create a form to upload a new CategoryParameterTemplate.
|
||||
|
||||
- Hide the 'category' field (parent part)
|
||||
- Display parameter templates which are not yet related
|
||||
"""
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['category'].widget = HiddenInput()
|
||||
|
||||
if form.is_valid():
|
||||
form.cleaned_data['category'] = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
# Get selected category
|
||||
category = self.get_initial()['category']
|
||||
|
||||
# Get existing parameter templates
|
||||
parameters = [template.parameter_template.pk
|
||||
for template in category.get_parameter_templates()]
|
||||
|
||||
# Exclude templates already linked to category
|
||||
updated_choices = []
|
||||
for choice in form.fields["parameter_template"].choices:
|
||||
if (choice[0] not in parameters):
|
||||
updated_choices.append(choice)
|
||||
|
||||
# Update choices for parameter templates
|
||||
form.fields['parameter_template'].choices = updated_choices
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Capture the POST request.
|
||||
|
||||
- If the add_to_all_categories object is set, link parameter template to
|
||||
all categories
|
||||
- If the add_to_same_level_categories object is set, link parameter template to
|
||||
same level categories
|
||||
"""
|
||||
form = self.get_form()
|
||||
|
||||
valid = form.is_valid()
|
||||
|
||||
if valid:
|
||||
add_to_same_level_categories = form.cleaned_data['add_to_same_level_categories']
|
||||
add_to_all_categories = form.cleaned_data['add_to_all_categories']
|
||||
|
||||
selected_category = PartCategory.objects.get(pk=int(self.kwargs['pk']))
|
||||
parameter_template = form.cleaned_data['parameter_template']
|
||||
default_value = form.cleaned_data['default_value']
|
||||
|
||||
categories = PartCategory.objects.all()
|
||||
|
||||
if add_to_same_level_categories and not add_to_all_categories:
|
||||
# Get level
|
||||
level = selected_category.level
|
||||
# Filter same level categories
|
||||
categories = categories.filter(level=level)
|
||||
|
||||
if add_to_same_level_categories or add_to_all_categories:
|
||||
# Add parameter template and default value to categories
|
||||
for category in categories:
|
||||
# Skip selected category (will be processed in the post call)
|
||||
if category.pk != selected_category.pk:
|
||||
try:
|
||||
cat_template = PartCategoryParameterTemplate.objects.create(category=category,
|
||||
parameter_template=parameter_template,
|
||||
default_value=default_value)
|
||||
cat_template.save()
|
||||
except IntegrityError:
|
||||
# Parameter template is already linked to category
|
||||
pass
|
||||
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
class CategoryParameterTemplateEdit(AjaxUpdateView):
|
||||
"""View for editing a PartCategoryParameterTemplate."""
|
||||
|
||||
model = PartCategoryParameterTemplate
|
||||
form_class = part_forms.EditCategoryParameterTemplateForm
|
||||
ajax_form_title = _('Edit Category Parameter Template')
|
||||
|
||||
def get_object(self):
|
||||
"""Returns the PartCategoryParameterTemplate associated with this view
|
||||
|
||||
- First, attempt lookup based on supplied 'pid' kwarg
|
||||
- Else, attempt lookup based on supplied 'pk' kwarg
|
||||
"""
|
||||
try:
|
||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
||||
except:
|
||||
return None
|
||||
|
||||
return self.object
|
||||
|
||||
def get_form(self):
|
||||
"""Create a form to upload a new CategoryParameterTemplate.
|
||||
|
||||
- Hide the 'category' field (parent part)
|
||||
- Display parameter templates which are not yet related
|
||||
"""
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['category'].widget = HiddenInput()
|
||||
form.fields['add_to_all_categories'].widget = HiddenInput()
|
||||
form.fields['add_to_same_level_categories'].widget = HiddenInput()
|
||||
|
||||
if form.is_valid():
|
||||
form.cleaned_data['category'] = self.kwargs.get('pk', None)
|
||||
|
||||
try:
|
||||
# Get selected category
|
||||
category = PartCategory.objects.get(pk=self.kwargs.get('pk', None))
|
||||
# Get selected template
|
||||
selected_template = self.get_object().parameter_template
|
||||
|
||||
# Get existing parameter templates
|
||||
parameters = [template.parameter_template.pk
|
||||
for template in category.get_parameter_templates()
|
||||
if template.parameter_template.pk != selected_template.pk]
|
||||
|
||||
# Exclude templates already linked to category
|
||||
updated_choices = []
|
||||
for choice in form.fields["parameter_template"].choices:
|
||||
if (choice[0] not in parameters):
|
||||
updated_choices.append(choice)
|
||||
|
||||
# Update choices for parameter templates
|
||||
form.fields['parameter_template'].choices = updated_choices
|
||||
# Set initial choice to current template
|
||||
form.fields['parameter_template'].initial = selected_template
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class CategoryParameterTemplateDelete(AjaxDeleteView):
|
||||
"""View for deleting an existing PartCategoryParameterTemplate."""
|
||||
|
||||
model = PartCategoryParameterTemplate
|
||||
ajax_form_title = _("Delete Category Parameter Template")
|
||||
|
||||
def get_object(self):
|
||||
"""Returns the PartCategoryParameterTemplate associated with this view
|
||||
|
||||
- First, attempt lookup based on supplied 'pid' kwarg
|
||||
- Else, attempt lookup based on supplied 'pk' kwarg
|
||||
"""
|
||||
try:
|
||||
self.object = self.model.objects.get(pk=self.kwargs['pid'])
|
||||
except:
|
||||
return None
|
||||
|
||||
return self.object
|
||||
|
@ -8,14 +8,14 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
<button class='btn btn-success' id='new-cat-param' disabled=''>
|
||||
<button class='btn btn-success' id='new-cat-param'>
|
||||
<div class='fas fa-plus-circle'></div> {% trans "New Parameter" %}
|
||||
</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class='row'>
|
||||
<div class='row' id='cat-param-buttons'>
|
||||
<form action=''>
|
||||
<div class='col-sm-6' style='width: 250px'>
|
||||
<div class='form-group'><div class='controls'>
|
||||
|
@ -222,7 +222,7 @@ $('#cat-param-table').inventreeTable({
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'parameter_template.name',
|
||||
field: 'parameter_template_detail.name',
|
||||
title: '{% trans "Parameter Template" %}',
|
||||
sortable: 'true',
|
||||
},
|
||||
@ -249,18 +249,23 @@ $('#cat-param-table').inventreeTable({
|
||||
|
||||
function loadTemplateTable(pk) {
|
||||
|
||||
// Enable the buttons
|
||||
$('#new-cat-param').removeAttr('disabled');
|
||||
var query = {};
|
||||
|
||||
if (pk) {
|
||||
query['category'] = pk;
|
||||
}
|
||||
|
||||
// Load the parameter table
|
||||
$("#cat-param-table").bootstrapTable('refresh', {
|
||||
query: {
|
||||
category: pk,
|
||||
},
|
||||
query: query,
|
||||
url: '{% url "api-part-category-parameter-list" %}',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Initially load table with *all* categories
|
||||
loadTemplateTable();
|
||||
|
||||
$('body').on('change', '#category-select', function() {
|
||||
var pk = $(this).val();
|
||||
loadTemplateTable(pk);
|
||||
@ -270,14 +275,20 @@ $("#new-cat-param").click(function() {
|
||||
|
||||
var pk = $('#category-select').val();
|
||||
|
||||
launchModalForm(`/part/category/${pk}/parameters/new/`, {
|
||||
success: function() {
|
||||
$("#cat-param-table").bootstrapTable('refresh', {
|
||||
query: {
|
||||
category: pk,
|
||||
}
|
||||
});
|
||||
constructForm('{% url "api-part-category-parameter-list" %}', {
|
||||
title: '{% trans "Create Category Parameter Template" %}',
|
||||
method: 'POST',
|
||||
fields: {
|
||||
parameter_template: {},
|
||||
category: {
|
||||
icon: 'fa-sitemap',
|
||||
value: pk,
|
||||
},
|
||||
default_value: {},
|
||||
},
|
||||
onSuccess: function() {
|
||||
loadTemplateTable(pk);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -286,15 +297,21 @@ $("#cat-param-table").on('click', '.template-edit', function() {
|
||||
var category = $('#category-select').val();
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
var url = `/part/category/${category}/parameters/${pk}/edit/`;
|
||||
|
||||
launchModalForm(url, {
|
||||
success: function() {
|
||||
$("#cat-param-table").bootstrapTable('refresh');
|
||||
constructForm(`/api/part/category/parameters/${pk}/`, {
|
||||
fields: {
|
||||
parameter_template: {},
|
||||
category: {
|
||||
icon: 'fa-sitemap',
|
||||
},
|
||||
default_value: {},
|
||||
},
|
||||
onSuccess: function() {
|
||||
loadTemplateTable(pk);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
$("#cat-param-table").on('click', '.template-delete', function() {
|
||||
|
||||
var category = $('#category-select').val();
|
||||
@ -302,9 +319,11 @@ $("#cat-param-table").on('click', '.template-delete', function() {
|
||||
|
||||
var url = `/part/category/${category}/parameters/${pk}/delete/`;
|
||||
|
||||
launchModalForm(url, {
|
||||
success: function() {
|
||||
$("#cat-param-table").bootstrapTable('refresh');
|
||||
constructForm(`/api/part/category/parameters/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Category Parameter Template" %}',
|
||||
onSuccess: function() {
|
||||
loadTemplateTable(pk);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user