Upgrades to cost calculation methods
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
|
## 202305.01
|
||||||
|
|
||||||
|
- Improved kit cost calculation.
|
||||||
|
|
||||||
## 202304.04
|
## 202304.04
|
||||||
|
|
||||||
|
- Added in discounts for kits based on kit used and submitting client.
|
||||||
- Kraken controls graph now only pulls top 20 results to prevent crashing.
|
- Kraken controls graph now only pulls top 20 results to prevent crashing.
|
||||||
- Improved cost calculations per column in a 96 well plate.
|
- Improved cost calculations per column in a 96 well plate.
|
||||||
|
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -1 +1 @@
|
|||||||
- [ ] Move bulk of functions from frontend.__init__ to frontend.functions as __init__ is getting bloated.
|
- [x] Move bulk of functions from frontend.__init__ to frontend.functions as __init__ is getting bloated.
|
||||||
@@ -56,7 +56,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
|
|||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
; sqlalchemy.url = sqlite:///L:\Robotics Laboratory Support\Submissions\submissions.db
|
; sqlalchemy.url = sqlite:///L:\Robotics Laboratory Support\Submissions\submissions.db
|
||||||
sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\Archives\DB_backups\submissions-20230302.db
|
sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\Archives\DB_backups\submissions-20230427.db
|
||||||
; sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\python\submissions\tests\test_assets\submissions_test.db
|
; sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\python\submissions\tests\test_assets\submissions_test.db
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
30
alembic/versions/83b06f3f4869_updated_discount_table.py
Normal file
30
alembic/versions/83b06f3f4869_updated_discount_table.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""updated discount table
|
||||||
|
|
||||||
|
Revision ID: 83b06f3f4869
|
||||||
|
Revises: cc9672a505f5
|
||||||
|
Create Date: 2023-04-27 13:04:35.886294
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import sqlite
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '83b06f3f4869'
|
||||||
|
down_revision = 'cc9672a505f5'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('_discounts', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('name', sa.String(length=128), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('_discounts', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('name')
|
||||||
|
# ### end Alembic commands ###
|
||||||
37
alembic/versions/cc9672a505f5_added_discount_table.py
Normal file
37
alembic/versions/cc9672a505f5_added_discount_table.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""added discount table
|
||||||
|
|
||||||
|
Revision ID: cc9672a505f5
|
||||||
|
Revises: 00de69ad6eab
|
||||||
|
Create Date: 2023-04-27 12:58:41.331563
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'cc9672a505f5'
|
||||||
|
down_revision = '00de69ad6eab'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('_discounts',
|
||||||
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('kit_id', sa.INTEGER(), nullable=True),
|
||||||
|
sa.Column('client_id', sa.INTEGER(), nullable=True),
|
||||||
|
sa.Column('amount', sa.FLOAT(precision=2), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['client_id'], ['_organizations.id'], name='fk_org_id', ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['kit_id'], ['_kits.id'], name='fk_kit_type_id', ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('_discounts')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""split mutable costs into 96 and 24
|
||||||
|
|
||||||
|
Revision ID: dc780c868efd
|
||||||
|
Revises: 83b06f3f4869
|
||||||
|
Create Date: 2023-05-01 14:05:47.762441
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import sqlite
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'dc780c868efd'
|
||||||
|
down_revision = '83b06f3f4869'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('_alembic_tmp__kits')
|
||||||
|
with op.batch_alter_table('_kits', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('mutable_cost_96', sa.FLOAT(precision=2), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('mutable_cost_24', sa.FLOAT(precision=2), nullable=True))
|
||||||
|
batch_op.drop_column('mutable_cost')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
|
||||||
|
with op.batch_alter_table('_kits', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('mutable_cost', sa.FLOAT(), nullable=True))
|
||||||
|
batch_op.drop_column('mutable_cost_24')
|
||||||
|
batch_op.drop_column('mutable_cost_96')
|
||||||
|
|
||||||
|
op.create_table('_alembic_tmp__kits',
|
||||||
|
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=64), nullable=True),
|
||||||
|
sa.Column('used_for', sqlite.JSON(), nullable=True),
|
||||||
|
sa.Column('cost_per_run', sa.FLOAT(), nullable=True),
|
||||||
|
sa.Column('reagent_types_id', sa.INTEGER(), nullable=True),
|
||||||
|
sa.Column('constant_cost', sa.FLOAT(), nullable=True),
|
||||||
|
sa.Column('mutable_cost_96', sa.FLOAT(), nullable=True),
|
||||||
|
sa.Column('mutable_cost_24', sa.FLOAT(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['reagent_types_id'], ['_reagent_types.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
# Version of the realpython-reader package
|
# Version of the realpython-reader package
|
||||||
__project__ = "submissions"
|
__project__ = "submissions"
|
||||||
__version__ = "202304.4b"
|
__version__ = "202305.1b"
|
||||||
__author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"}
|
__author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"}
|
||||||
__copyright__ = "2022-2023, Government of Canada"
|
__copyright__ = "2022-2023, Government of Canada"
|
||||||
|
|
||||||
|
|||||||
@@ -164,13 +164,24 @@ def construct_submission_info(ctx:dict, info_dict:dict) -> models.BasicSubmissio
|
|||||||
try:
|
try:
|
||||||
# ceil(instance.sample_count / 8) will get number of columns
|
# ceil(instance.sample_count / 8) will get number of columns
|
||||||
# the cost of a full run multiplied by (that number / 12) is x twelfths the cost of a full run
|
# the cost of a full run multiplied by (that number / 12) is x twelfths the cost of a full run
|
||||||
logger.debug(f"Instance extraction kit details: {instance.extraction_kit.__dict__}")
|
logger.debug(f"Calculating costs for procedure...")
|
||||||
cols_count = ceil(int(instance.sample_count) / 8)
|
# cols_count = ceil(int(instance.sample_count) / 8)
|
||||||
instance.run_cost = instance.extraction_kit.constant_cost + (instance.extraction_kit.mutable_cost * (cols_count / 12))
|
# instance.run_cost = instance.extraction_kit.constant_cost + (instance.extraction_kit.mutable_cost * (cols_count / 12))
|
||||||
|
instance.calculate_base_cost()
|
||||||
except (TypeError, AttributeError) as e:
|
except (TypeError, AttributeError) as e:
|
||||||
logger.debug(f"Looks like that kit doesn't have cost breakdown yet due to: {e}, using full plate cost.")
|
logger.debug(f"Looks like that kit doesn't have cost breakdown yet due to: {e}, using full plate cost.")
|
||||||
instance.run_cost = instance.extraction_kit.cost_per_run
|
instance.run_cost = instance.extraction_kit.cost_per_run
|
||||||
|
logger.debug(f"Calculated base run cost of: {instance.run_cost}")
|
||||||
|
try:
|
||||||
|
logger.debug("Checking and applying discounts...")
|
||||||
|
discounts = [item.amount for item in lookup_discounts_by_org_and_kit(ctx=ctx, kit_id=instance.extraction_kit.id, lab_id=instance.submitting_lab.id)]
|
||||||
|
logger.debug(f"We got discounts: {discounts}")
|
||||||
|
discounts = sum(discounts)
|
||||||
|
instance.run_cost = instance.run_cost - discounts
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"An unknown exception occurred: {e}")
|
||||||
# We need to make sure there's a proper rsl plate number
|
# We need to make sure there's a proper rsl plate number
|
||||||
|
logger.debug(f"We've got a total cost of {instance.run_cost}")
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Constructed instance: {instance.to_string()}")
|
logger.debug(f"Constructed instance: {instance.to_string()}")
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
@@ -466,7 +477,7 @@ def create_kit_from_yaml(ctx:dict, exp:dict) -> dict:
|
|||||||
continue
|
continue
|
||||||
# A submission type may use multiple kits.
|
# A submission type may use multiple kits.
|
||||||
for kt in exp[type]['kits']:
|
for kt in exp[type]['kits']:
|
||||||
kit = models.KitType(name=kt, used_for=[type.replace("_", " ").title()], constant_cost=exp[type]["kits"][kt]["constant_cost"], mutable_cost=exp[type]["kits"][kt]["mutable_cost"])
|
kit = models.KitType(name=kt, used_for=[type.replace("_", " ").title()], constant_cost=exp[type]["kits"][kt]["constant_cost"], mutable_cost_column=exp[type]["kits"][kt]["mutable_cost_column"])
|
||||||
# A kit contains multiple reagent types.
|
# A kit contains multiple reagent types.
|
||||||
for r in exp[type]['kits'][kt]['reagenttypes']:
|
for r in exp[type]['kits'][kt]['reagenttypes']:
|
||||||
# check if reagent type already exists.
|
# check if reagent type already exists.
|
||||||
@@ -737,3 +748,9 @@ def update_ww_sample(ctx:dict, sample_obj:dict):
|
|||||||
return
|
return
|
||||||
ctx['database_session'].add(ww_samp)
|
ctx['database_session'].add(ww_samp)
|
||||||
ctx["database_session"].commit()
|
ctx["database_session"].commit()
|
||||||
|
|
||||||
|
def lookup_discounts_by_org_and_kit(ctx:dict, kit_id:int, lab_id:int):
|
||||||
|
return ctx['database_session'].query(models.Discount).join(models.KitType).join(models.Organization).filter(and_(
|
||||||
|
models.KitType.id==kit_id,
|
||||||
|
models.Organization.id==lab_id
|
||||||
|
)).all()
|
||||||
@@ -7,7 +7,7 @@ Base = declarative_base()
|
|||||||
metadata = Base.metadata
|
metadata = Base.metadata
|
||||||
|
|
||||||
from .controls import Control, ControlType
|
from .controls import Control, ControlType
|
||||||
from .kits import KitType, ReagentType, Reagent
|
from .kits import KitType, ReagentType, Reagent, Discount
|
||||||
from .organizations import Organization, Contact
|
from .organizations import Organization, Contact
|
||||||
from .samples import WWSample, BCSample
|
from .samples import WWSample, BCSample
|
||||||
from .submissions import BasicSubmission, BacterialCulture, Wastewater
|
from .submissions import BasicSubmission, BacterialCulture, Wastewater
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ class KitType(Base):
|
|||||||
submissions = relationship("BasicSubmission", back_populates="extraction_kit") #: submissions this kit was used for
|
submissions = relationship("BasicSubmission", back_populates="extraction_kit") #: submissions this kit was used for
|
||||||
used_for = Column(JSON) #: list of names of sample types this kit can process
|
used_for = Column(JSON) #: list of names of sample types this kit can process
|
||||||
cost_per_run = Column(FLOAT(2)) #: dollar amount for each full run of this kit NOTE: depreciated, use the constant and mutable costs instead
|
cost_per_run = Column(FLOAT(2)) #: dollar amount for each full run of this kit NOTE: depreciated, use the constant and mutable costs instead
|
||||||
mutable_cost = Column(FLOAT(2)) #: dollar amount per plate that can change with number of columns (reagents, tips, etc)
|
# TODO: Change below to 'mutable_cost_column' and 'mutable_cost_sample' before moving to production.
|
||||||
|
mutable_cost_column = Column(FLOAT(2)) #: dollar amount per 96 well plate that can change with number of columns (reagents, tips, etc)
|
||||||
|
mutable_cost_sample = Column(FLOAT(2)) #: dollar amount that can change with number of samples (reagents, tips, etc)
|
||||||
constant_cost = Column(FLOAT(2)) #: dollar amount per plate that will remain constant (plates, man hours, etc)
|
constant_cost = Column(FLOAT(2)) #: dollar amount per plate that will remain constant (plates, man hours, etc)
|
||||||
reagent_types = relationship("ReagentType", back_populates="kits", uselist=True, secondary=reagenttypes_kittypes) #: reagent types this kit contains
|
reagent_types = relationship("ReagentType", back_populates="kits", uselist=True, secondary=reagenttypes_kittypes) #: reagent types this kit contains
|
||||||
reagent_types_id = Column(INTEGER, ForeignKey("_reagent_types.id", ondelete='SET NULL', use_alter=True, name="fk_KT_reagentstype_id")) #: joined reagent type id
|
reagent_types_id = Column(INTEGER, ForeignKey("_reagent_types.id", ondelete='SET NULL', use_alter=True, name="fk_KT_reagentstype_id")) #: joined reagent type id
|
||||||
@@ -113,13 +115,16 @@ class Reagent(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# class Discounts(Base):
|
class Discount(Base):
|
||||||
# """
|
"""
|
||||||
# Relationship table for client labs for certain kits.
|
Relationship table for client labs for certain kits.
|
||||||
# """
|
"""
|
||||||
# __tablename__ = "_discounts"
|
__tablename__ = "_discounts"
|
||||||
|
|
||||||
# id = Column(INTEGER, primary_key=True) #: primary key
|
id = Column(INTEGER, primary_key=True) #: primary key
|
||||||
# kit = relationship("KitType") #: joined parent reagent type
|
kit = relationship("KitType") #: joined parent reagent type
|
||||||
# kit_id = Column(INTEGER, ForeignKey("_kits.id", ondelete='SET NULL', name="fk_kit_type_id"))
|
kit_id = Column(INTEGER, ForeignKey("_kits.id", ondelete='SET NULL', name="fk_kit_type_id"))
|
||||||
# client = relationship("Organization")
|
client = relationship("Organization") #: joined client lab
|
||||||
|
client_id = Column(INTEGER, ForeignKey("_organizations.id", ondelete='SET NULL', name="fk_org_id"))
|
||||||
|
name = Column(String(128))
|
||||||
|
amount = Column(FLOAT(2))
|
||||||
@@ -8,6 +8,7 @@ from datetime import datetime as dt
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
logger = logging.getLogger(f"submissions.{__name__}")
|
logger = logging.getLogger(f"submissions.{__name__}")
|
||||||
|
|
||||||
@@ -152,6 +153,8 @@ class BasicSubmission(Base):
|
|||||||
}
|
}
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Below are the custom submission types
|
# Below are the custom submission types
|
||||||
|
|
||||||
class BacterialCulture(BasicSubmission):
|
class BacterialCulture(BasicSubmission):
|
||||||
@@ -174,6 +177,18 @@ class BacterialCulture(BasicSubmission):
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_base_cost(self):
|
||||||
|
try:
|
||||||
|
cols_count_96 = ceil(int(self.sample_count) / 8)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Column count error: {e}")
|
||||||
|
# cols_count_24 = ceil(int(self.sample_count) / 3)
|
||||||
|
try:
|
||||||
|
self.run_cost = self.extraction_kit.constant_cost + (self.extraction_kit.mutable_cost_column * cols_count_96) + (self.extraction_kit.mutable_cost_sample * int(self.sample_count))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Calculation error: {e}")
|
||||||
|
|
||||||
|
|
||||||
class Wastewater(BasicSubmission):
|
class Wastewater(BasicSubmission):
|
||||||
"""
|
"""
|
||||||
derivative submission type from BasicSubmission
|
derivative submission type from BasicSubmission
|
||||||
@@ -196,3 +211,15 @@ class Wastewater(BasicSubmission):
|
|||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
pass
|
pass
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def calculate_base_cost(self):
|
||||||
|
try:
|
||||||
|
cols_count_96 = ceil(int(self.sample_count) / 8) + 1 #: Adding in one column to account for 24 samples + ext negatives
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Column count error: {e}")
|
||||||
|
# cols_count_24 = ceil(int(self.sample_count) / 3)
|
||||||
|
try:
|
||||||
|
self.run_cost = self.extraction_kit.constant_cost + (self.extraction_kit.mutable_cost_column * cols_count_96) + (self.extraction_kit.mutable_cost_sample * int(self.sample_count))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Calculation error: {e}")
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import logging
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import re
|
import re
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
import uuid
|
import uuid
|
||||||
from tools import check_not_nan, RSLNamer
|
from tools import check_not_nan, RSLNamer
|
||||||
|
|
||||||
@@ -129,8 +129,12 @@ class SheetParser(object):
|
|||||||
logger.debug(f"Output variable is {output_var}")
|
logger.debug(f"Output variable is {output_var}")
|
||||||
logger.debug(f"Expiry date for imported reagent: {row[3]}")
|
logger.debug(f"Expiry date for imported reagent: {row[3]}")
|
||||||
if check_not_nan(row[3]):
|
if check_not_nan(row[3]):
|
||||||
|
try:
|
||||||
expiry = row[3].date()
|
expiry = row[3].date()
|
||||||
|
except AttributeError as e:
|
||||||
|
expiry = datetime.strptime(row[3], "%Y-%m-%d")
|
||||||
else:
|
else:
|
||||||
|
logger.debug(f"Date: {row[3]}")
|
||||||
expiry = date.today()
|
expiry = date.today()
|
||||||
self.sub[f"lot_{reagent_type}"] = {'lot':output_var, 'exp':expiry}
|
self.sub[f"lot_{reagent_type}"] = {'lot':output_var, 'exp':expiry}
|
||||||
submission_info = self.parse_generic("Sample List")
|
submission_info = self.parse_generic("Sample List")
|
||||||
@@ -265,8 +269,8 @@ class SampleParser(object):
|
|||||||
new_list = []
|
new_list = []
|
||||||
for sample in self.samples:
|
for sample in self.samples:
|
||||||
new = WWSample()
|
new = WWSample()
|
||||||
if check_not_nan(sample["Unnamed: 9"]):
|
if check_not_nan(sample["Unnamed: 7"]):
|
||||||
new.rsl_number = sample['Unnamed: 9']
|
new.rsl_number = sample['Unnamed: 7'] # previously Unnamed: 9
|
||||||
else:
|
else:
|
||||||
logger.error(f"No RSL sample number found for this sample.")
|
logger.error(f"No RSL sample number found for this sample.")
|
||||||
continue
|
continue
|
||||||
@@ -282,9 +286,9 @@ class SampleParser(object):
|
|||||||
new.collection_date = sample['Unnamed: 5']
|
new.collection_date = sample['Unnamed: 5']
|
||||||
else:
|
else:
|
||||||
new.collection_date = date.today()
|
new.collection_date = date.today()
|
||||||
new.testing_type = sample['Unnamed: 6']
|
# new.testing_type = sample['Unnamed: 6']
|
||||||
new.site_status = sample['Unnamed: 7']
|
# new.site_status = sample['Unnamed: 7']
|
||||||
new.notes = str(sample['Unnamed: 8'])
|
new.notes = str(sample['Unnamed: 6']) # previously Unnamed: 8
|
||||||
new.well_number = sample['Unnamed: 1']
|
new.well_number = sample['Unnamed: 1']
|
||||||
new_list.append(new)
|
new_list.append(new)
|
||||||
return new_list
|
return new_list
|
||||||
|
|||||||
Reference in New Issue
Block a user