mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-10 14:07:43 +00:00
Create a better CLI for IFCCOBie, and don't create CSVs when you just need a XLSX
This commit is contained in:
+114
-73
@@ -1,30 +1,10 @@
|
|||||||
# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico bimtester.py`
|
# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico bimtester.py`
|
||||||
|
|
||||||
print('# IFC COBie')
|
|
||||||
print('''
|
|
||||||
IFC COBie is free software developed under the IfcOpenShell and BlenderBIM
|
|
||||||
projects. IFC COBie will convert IFC files, ideally produced with the COBie MVD
|
|
||||||
in mind, into a spreadsheet format for inspection. To learn more, visit:
|
|
||||||
|
|
||||||
- http://ifcopenshell.org
|
|
||||||
- https://blenderbim.org
|
|
||||||
|
|
||||||
To run, a `input.ifc` file is required in the current folder.
|
|
||||||
''')
|
|
||||||
|
|
||||||
value = input('Would you like to run IFC COBie? [Y/n] ')
|
|
||||||
if value == 'n':
|
|
||||||
quit()
|
|
||||||
|
|
||||||
print('Loading libraries ...')
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import argparse
|
||||||
import datetime
|
import datetime
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import logging
|
import logging
|
||||||
import csv
|
|
||||||
from xlsxwriter import Workbook
|
|
||||||
|
|
||||||
|
|
||||||
class IfcCobieParser():
|
class IfcCobieParser():
|
||||||
def __init__(self, logger):
|
def __init__(self, logger):
|
||||||
@@ -119,8 +99,8 @@ class IfcCobieParser():
|
|||||||
}
|
}
|
||||||
self.default_date = (datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds = -2177452801)).isoformat()
|
self.default_date = (datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds = -2177452801)).isoformat()
|
||||||
|
|
||||||
def parse(self):
|
def parse(self, filename):
|
||||||
self.file = ifcopenshell.open('input.ifc')
|
self.file = ifcopenshell.open(filename)
|
||||||
self.get_contacts()
|
self.get_contacts()
|
||||||
self.get_facilities()
|
self.get_facilities()
|
||||||
self.get_floors()
|
self.get_floors()
|
||||||
@@ -987,18 +967,25 @@ class IfcCobieParser():
|
|||||||
return address.ElectronicMailAddresses[0]
|
return address.ElectronicMailAddresses[0]
|
||||||
self.logger.warning('An email address was not found for {}'.format(person_or_org))
|
self.logger.warning('An email address was not found for {}'.format(person_or_org))
|
||||||
|
|
||||||
class CobieCsvWriter():
|
class CobieWriter():
|
||||||
def __init__(self, parser):
|
def __init__(self, parser, filename=None):
|
||||||
|
self.filename = filename
|
||||||
self.parser = parser
|
self.parser = parser
|
||||||
|
self.sheets = []
|
||||||
|
self.sheet_data = {}
|
||||||
|
|
||||||
def write(self):
|
def write(self):
|
||||||
self.write_file('Contact', self.parser.contacts, 'Email',
|
self.sheets = ['Contact', 'Facility', 'Floor', 'Space', 'Zone', 'Type',
|
||||||
|
'Component', 'System', 'Assembly', 'Connection', 'Spare',
|
||||||
|
'Resource', 'Job', 'Impact', 'Document', 'Attribute', 'Coordinate',
|
||||||
|
'Issue', 'Component', 'System', '']
|
||||||
|
self.write_data('Contact', self.parser.contacts, 'Email',
|
||||||
['Email', 'CreatedBy', 'CreatedOn', 'Category', 'Company',
|
['Email', 'CreatedBy', 'CreatedOn', 'Category', 'Company',
|
||||||
'Phone', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
'Phone', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
||||||
'Department', 'OrganizationCode', 'GivenName', 'FamilyName',
|
'Department', 'OrganizationCode', 'GivenName', 'FamilyName',
|
||||||
'Street', 'PostalBox', 'Town', 'StateRegion', 'PostalCode',
|
'Street', 'PostalBox', 'Town', 'StateRegion', 'PostalCode',
|
||||||
'Country'])
|
'Country'])
|
||||||
self.write_file('Facility', self.parser.facilities, 'Name',
|
self.write_data('Facility', self.parser.facilities, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ProjectName',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ProjectName',
|
||||||
'SiteName', 'LinearUnits', 'AreaUnits', 'VolumeUnits', 'CostUnit',
|
'SiteName', 'LinearUnits', 'AreaUnits', 'VolumeUnits', 'CostUnit',
|
||||||
'AreaMeasurement', 'ExternalSystem', 'ExternalProjectObject',
|
'AreaMeasurement', 'ExternalSystem', 'ExternalProjectObject',
|
||||||
@@ -1006,17 +993,17 @@ class CobieCsvWriter():
|
|||||||
'ExternalSiteIdentifier', 'ExternalFacilityObject',
|
'ExternalSiteIdentifier', 'ExternalFacilityObject',
|
||||||
'ExternalFacilityIdentifier', 'Description', 'ProjectDescription',
|
'ExternalFacilityIdentifier', 'Description', 'ProjectDescription',
|
||||||
'SiteDescription', 'Phase'])
|
'SiteDescription', 'Phase'])
|
||||||
self.write_file('Floor', self.parser.floors, 'Name',
|
self.write_data('Floor', self.parser.floors, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ExtSystem',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ExtSystem',
|
||||||
'ExtObject', 'ExtIdentifier', 'Description', 'Elevation', 'Height'])
|
'ExtObject', 'ExtIdentifier', 'Description', 'Elevation', 'Height'])
|
||||||
self.write_file('Space', self.parser.spaces, 'Name',
|
self.write_data('Space', self.parser.spaces, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'FloorName',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'FloorName',
|
||||||
'Description', 'ExtSystem', 'ExtObject', 'ExtIdentifier', 'RoomTag',
|
'Description', 'ExtSystem', 'ExtObject', 'ExtIdentifier', 'RoomTag',
|
||||||
'UsableHeight', 'GrossArea', 'NetArea'])
|
'UsableHeight', 'GrossArea', 'NetArea'])
|
||||||
self.write_file('Zone', self.parser.zones, 'Name',
|
self.write_data('Zone', self.parser.zones, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'SpaceNames',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'SpaceNames',
|
||||||
'ExtSystem', 'ExtObject', 'ExtIdentifier', 'Description',])
|
'ExtSystem', 'ExtObject', 'ExtIdentifier', 'Description',])
|
||||||
self.write_file('Type', self.parser.types, 'Name',
|
self.write_data('Type', self.parser.types, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'Description',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'Description',
|
||||||
'AssetType', 'Manufacturer', 'ModelNumber',
|
'AssetType', 'Manufacturer', 'ModelNumber',
|
||||||
'WarrantyGuarantorParts', 'WarrantyDurationParts',
|
'WarrantyGuarantorParts', 'WarrantyDurationParts',
|
||||||
@@ -1027,60 +1014,84 @@ class CobieCsvWriter():
|
|||||||
'Color', 'Finish', 'Grade', 'Material', 'Constituents', 'Features',
|
'Color', 'Finish', 'Grade', 'Material', 'Constituents', 'Features',
|
||||||
'AccessibilityPerformance', 'CodePerformance',
|
'AccessibilityPerformance', 'CodePerformance',
|
||||||
'SustainabilityPerformance',])
|
'SustainabilityPerformance',])
|
||||||
self.write_file('Component', self.parser.components, 'Name',
|
self.write_data('Component', self.parser.components, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'TypeName', 'Space',
|
['Name', 'CreatedBy', 'CreatedOn', 'TypeName', 'Space',
|
||||||
'Description', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
'Description', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
||||||
'SerialNumber', 'InstallationDate', 'WarrantyStartDate',
|
'SerialNumber', 'InstallationDate', 'WarrantyStartDate',
|
||||||
'TagNumber', 'BarCode', 'AssetIdentifier',])
|
'TagNumber', 'BarCode', 'AssetIdentifier',])
|
||||||
self.write_file('System', self.parser.systems, 'Name',
|
self.write_data('System', self.parser.systems, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ComponentNames',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ComponentNames',
|
||||||
'ExtSystem', 'ExtObject', 'ExtIdentifier', 'Description',])
|
'ExtSystem', 'ExtObject', 'ExtIdentifier', 'Description',])
|
||||||
self.write_file('Assembly', self.parser.assemblies, 'Name',
|
self.write_data('Assembly', self.parser.assemblies, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'SheetName', 'ParentName',
|
['Name', 'CreatedBy', 'CreatedOn', 'SheetName', 'ParentName',
|
||||||
'ChildNames', 'AssemblyType', 'ExtSystem', 'ExtObject',
|
'ChildNames', 'AssemblyType', 'ExtSystem', 'ExtObject',
|
||||||
'ExtIdentifier', 'Description',])
|
'ExtIdentifier', 'Description',])
|
||||||
self.write_file('Connection', self.parser.connections, 'Name',
|
self.write_data('Connection', self.parser.connections, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'ConnectionType', 'SheetName',
|
['Name', 'CreatedBy', 'CreatedOn', 'ConnectionType', 'SheetName',
|
||||||
'RowName1', 'RowName2', 'RealizingElement', 'PortName1',
|
'RowName1', 'RowName2', 'RealizingElement', 'PortName1',
|
||||||
'PortName2', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
'PortName2', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
||||||
'Description',])
|
'Description',])
|
||||||
self.write_file('Spare', self.parser.spares, 'Name',
|
self.write_data('Spare', self.parser.spares, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'TypeName',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'TypeName',
|
||||||
'Suppliers', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
'Suppliers', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
||||||
'Description', 'SetNumber', 'PartNumber',])
|
'Description', 'SetNumber', 'PartNumber',])
|
||||||
self.write_file('Resource', self.parser.resources, 'Name',
|
self.write_data('Resource', self.parser.resources, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ExtSystem',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ExtSystem',
|
||||||
'ExtObject', 'ExtIdentifier', 'Description',])
|
'ExtObject', 'ExtIdentifier', 'Description',])
|
||||||
self.write_file('Job', self.parser.jobs, 'Name',
|
self.write_data('Job', self.parser.jobs, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'Status', 'TypeName',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'Status', 'TypeName',
|
||||||
'Description', 'Duration', 'DurationUnit', 'Start', 'TaskStartUnit',
|
'Description', 'Duration', 'DurationUnit', 'Start', 'TaskStartUnit',
|
||||||
'Frequency', 'FrequencyUnit', 'ExtSystem', 'ExtObject',
|
'Frequency', 'FrequencyUnit', 'ExtSystem', 'ExtObject',
|
||||||
'ExtIdentifier', 'TaskNumber', 'Priors', 'ResourceNames',])
|
'ExtIdentifier', 'TaskNumber', 'Priors', 'ResourceNames',])
|
||||||
self.write_file('Impact', self.parser.impacts, 'Name',
|
self.write_data('Impact', self.parser.impacts, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'ImpactType', 'ImpactStage',
|
['Name', 'CreatedBy', 'CreatedOn', 'ImpactType', 'ImpactStage',
|
||||||
'SheetName', 'RowName', 'Value', 'Unit', 'LeadInTime', 'Duration',
|
'SheetName', 'RowName', 'Value', 'Unit', 'LeadInTime', 'Duration',
|
||||||
'LeadOutTime', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
'LeadOutTime', 'ExtSystem', 'ExtObject', 'ExtIdentifier',
|
||||||
'Description',])
|
'Description',])
|
||||||
self.write_file('Document', self.parser.documents, 'Name',
|
self.write_data('Document', self.parser.documents, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ApprovalBy',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'ApprovalBy',
|
||||||
'Stage', 'SheetName', 'RowName', 'Directory', 'File', 'ExtSystem',
|
'Stage', 'SheetName', 'RowName', 'Directory', 'File', 'ExtSystem',
|
||||||
'ExtObject', 'ExtIdentifier', 'Description', 'Reference',])
|
'ExtObject', 'ExtIdentifier', 'Description', 'Reference',])
|
||||||
self.write_file('Attribute', self.parser.attributes, 'Name',
|
self.write_data('Attribute', self.parser.attributes, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'SheetName',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'SheetName',
|
||||||
'RowName', 'Value', 'Unit', 'ExtSystem', 'ExtObject',
|
'RowName', 'Value', 'Unit', 'ExtSystem', 'ExtObject',
|
||||||
'ExtIdentifier', 'Description', 'AllowedValues',])
|
'ExtIdentifier', 'Description', 'AllowedValues',])
|
||||||
self.write_file('Coordinate', self.parser.coordinates, 'Name',
|
self.write_data('Coordinate', self.parser.coordinates, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'SheetName',
|
['Name', 'CreatedBy', 'CreatedOn', 'Category', 'SheetName',
|
||||||
'RowName', 'CoordinateXAxis', 'CoordinateYAxis', 'CoordinateZAxis',
|
'RowName', 'CoordinateXAxis', 'CoordinateYAxis', 'CoordinateZAxis',
|
||||||
'ExtSystem', 'ExtObject', 'ExtIdentifier', 'ClockwiseRotation',
|
'ExtSystem', 'ExtObject', 'ExtIdentifier', 'ClockwiseRotation',
|
||||||
'ElevationalRotation', 'YawRotation',])
|
'ElevationalRotation', 'YawRotation',])
|
||||||
self.write_file('Issue', self.parser.issues, 'Name',
|
self.write_data('Issue', self.parser.issues, 'Name',
|
||||||
['Name', 'CreatedBy', 'CreatedOn', 'Type', 'Risk', 'Chance',
|
['Name', 'CreatedBy', 'CreatedOn', 'Type', 'Risk', 'Chance',
|
||||||
'Impact', 'SheetName1', 'RowName1', 'SheetName2', 'RowName2',
|
'Impact', 'SheetName1', 'RowName1', 'SheetName2', 'RowName2',
|
||||||
'Description', 'Owner', 'Mitigation', 'ExtSystem', 'ExtObject',
|
'Description', 'Owner', 'Mitigation', 'ExtSystem', 'ExtObject',
|
||||||
'ExtIdentifier',])
|
'ExtIdentifier',])
|
||||||
|
|
||||||
def write_file(self, csv_name, data, primary_key, fieldnames):
|
def write_data(self, sheet, data, primary_key, fieldnames):
|
||||||
|
self.sheet_data[sheet] = {
|
||||||
|
'headers': fieldnames,
|
||||||
|
'rows': []
|
||||||
|
}
|
||||||
|
for name, row in data.items():
|
||||||
|
row[primary_key] = name
|
||||||
|
values = []
|
||||||
|
for fieldname in fieldnames:
|
||||||
|
values.append(row[fieldname])
|
||||||
|
self.sheet_data[sheet]['rows'].append(values)
|
||||||
|
|
||||||
|
|
||||||
|
class CobieCsvWriter(CobieWriter):
|
||||||
|
def write(self):
|
||||||
|
import csv
|
||||||
|
super().write()
|
||||||
|
for sheet, data in self.sheet_data.items():
|
||||||
|
with open('{}.csv'.format(sheet), 'w', newline='', encoding='utf-8') as file:
|
||||||
|
writer = csv.writer(file)
|
||||||
|
writer.writerow(data['headers'])
|
||||||
|
for row in data['rows']:
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
def write_file_old(self, csv_name, data, primary_key, fieldnames):
|
||||||
with open('{}.csv'.format(csv_name), 'w', newline='', encoding='utf-8') as file:
|
with open('{}.csv'.format(csv_name), 'w', newline='', encoding='utf-8') as file:
|
||||||
writer = csv.DictWriter(file, fieldnames=fieldnames)
|
writer = csv.DictWriter(file, fieldnames=fieldnames)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
@@ -1088,8 +1099,11 @@ class CobieCsvWriter():
|
|||||||
row[primary_key] = name
|
row[primary_key] = name
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
class CobieXlsWriter(CobieCsvWriter):
|
|
||||||
|
class CobieXlsWriter(CobieWriter):
|
||||||
def write(self):
|
def write(self):
|
||||||
|
from xlsxwriter import Workbook
|
||||||
|
super().write()
|
||||||
self.colours = {
|
self.colours = {
|
||||||
'r': 'fdff8e', # Required
|
'r': 'fdff8e', # Required
|
||||||
'i': 'fdcd94', # Internal reference
|
'i': 'fdcd94', # Internal reference
|
||||||
@@ -1099,8 +1113,7 @@ class CobieXlsWriter(CobieCsvWriter):
|
|||||||
'p': '9ccaff', # Project specific
|
'p': '9ccaff', # Project specific
|
||||||
'n': '000000' # Not used
|
'n': '000000' # Not used
|
||||||
}
|
}
|
||||||
super().write()
|
self.workbook = Workbook(self.filename + '.xlsx')
|
||||||
self.workbook = Workbook('output.xlsx')
|
|
||||||
|
|
||||||
self.cell_formats = {}
|
self.cell_formats = {}
|
||||||
for key, value in self.colours.items():
|
for key, value in self.colours.items():
|
||||||
@@ -1129,35 +1142,63 @@ class CobieXlsWriter(CobieCsvWriter):
|
|||||||
|
|
||||||
def write_worksheet(self, name, tab_colour, fill_map):
|
def write_worksheet(self, name, tab_colour, fill_map):
|
||||||
worksheet = self.workbook.add_worksheet(name)
|
worksheet = self.workbook.add_worksheet(name)
|
||||||
|
r = 0
|
||||||
|
c = 0
|
||||||
|
for header in self.sheet_data[name]['headers']:
|
||||||
|
cell = worksheet.write(r, c, header, self.cell_formats['s'])
|
||||||
|
c += 1
|
||||||
|
c = 0
|
||||||
|
r += 1
|
||||||
|
for row in self.sheet_data[name]['rows']:
|
||||||
|
c = 0
|
||||||
|
for col in row:
|
||||||
|
cell = worksheet.write(r, c, col, self.cell_formats[fill_map[c]])
|
||||||
|
c += 1
|
||||||
|
r += 1
|
||||||
|
|
||||||
with open('{}.csv'.format(name), 'r') as f:
|
|
||||||
reader = csv.reader(f)
|
|
||||||
for r, row in enumerate(reader):
|
|
||||||
for c, col in enumerate(row):
|
|
||||||
if r == 0:
|
|
||||||
cell = worksheet.write(r, c, col, self.cell_formats['s'])
|
|
||||||
else:
|
|
||||||
cell = worksheet.write(r, c, col, self.cell_formats[fill_map[c]])
|
|
||||||
|
|
||||||
print('Processing IFC file ...')
|
if __name__ == '__main__':
|
||||||
print('Note: view issues with your file in `process.log` ...')
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Converts COBie IFC MVD into its spreadsheet equivalent')
|
||||||
|
parser.add_argument('input', type=str, help='Specify an IFC file to process')
|
||||||
|
parser.add_argument(
|
||||||
|
'-l',
|
||||||
|
'--log',
|
||||||
|
type=str,
|
||||||
|
help='Specify where errors should be logged',
|
||||||
|
default='process.log')
|
||||||
|
parser.add_argument(
|
||||||
|
'-f',
|
||||||
|
'--format',
|
||||||
|
type=str,
|
||||||
|
help='Choose which format to export in (csv/xlsx)',
|
||||||
|
default='csv')
|
||||||
|
parser.add_argument(
|
||||||
|
'-o',
|
||||||
|
'--output',
|
||||||
|
type=str,
|
||||||
|
help='The output filename excluding the extension. Not required for CSV.',
|
||||||
|
default='output')
|
||||||
|
args = vars(parser.parse_args())
|
||||||
|
|
||||||
start = time.time()
|
print('Processing IFC file ...')
|
||||||
logging.basicConfig(
|
|
||||||
filename='process.log',
|
|
||||||
filemode='a', level=logging.DEBUG)
|
|
||||||
logger = logging.getLogger('IFCtoCOBie')
|
|
||||||
logger.info('Starting conversion')
|
|
||||||
parser = IfcCobieParser(logger)
|
|
||||||
parser.parse()
|
|
||||||
|
|
||||||
print('Generating reports ...')
|
start = time.time()
|
||||||
|
logging.basicConfig(
|
||||||
|
filename=args['log'],
|
||||||
|
filemode='a', level=logging.DEBUG)
|
||||||
|
logger = logging.getLogger('IFCtoCOBie')
|
||||||
|
logger.info('Starting conversion')
|
||||||
|
parser = IfcCobieParser(logger)
|
||||||
|
parser.parse(args['input'])
|
||||||
|
|
||||||
#writer = CobieCsvWriter(parser)
|
print('Generating reports ...')
|
||||||
#writer.write()
|
|
||||||
writer = CobieXlsWriter(parser)
|
|
||||||
writer.write()
|
|
||||||
logger.info('Finished conversion in {}s'.format(time.time() - start))
|
|
||||||
|
|
||||||
print('# All reports are complete :-)')
|
if args['format'] == 'xlsx':
|
||||||
input('Press <enter> to quit.')
|
writer = CobieXlsWriter(parser, args['output'])
|
||||||
|
else:
|
||||||
|
writer = CobieCsvWriter(parser)
|
||||||
|
writer.write()
|
||||||
|
|
||||||
|
logger.info('Finished conversion in {}s'.format(time.time() - start))
|
||||||
|
print('# All reports are complete :-)')
|
||||||
|
|||||||
Reference in New Issue
Block a user