feat(pre-flight): канон-справочник + golden-нормализация, дизайн Nomad, питч
- contracts/service_canon.json — 15 канонов медуслуг RU/KZ/EN + синонимы - fixtures/normalized/golden.json — 52 эталонные строки (оракул TDD) - design/ — tailwind-токены + shadcn-тема Nomad - pitch/ — структура слайдов + демо-сценарий Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+137
-122
@@ -1,149 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Генератор синтетических прайсов клиник (RU/KZ) в разных форматах для отладки pipeline."""
|
||||
import csv, os, random, subprocess, sys
|
||||
"""Генератор синтетических прайсов клиник (RU/KZ/EN) + эталонная нормализация + справочник канонов.
|
||||
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else "fixtures/raw"
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
random.seed(42) # детерминированно
|
||||
Выдаёт:
|
||||
fixtures/raw/* — 6 форматов «сырых» прайсов (Excel/CSV/txt/PNG/PDF)
|
||||
fixtures/normalized/golden.json — эталон: каждая строка → канон (оракул для TDD ETL)
|
||||
contracts/service_canon.json — справочник канонических услуг (seed для нормализации)
|
||||
Детерминированно (seed=42).
|
||||
"""
|
||||
import csv, json, os, random, subprocess, sys
|
||||
|
||||
ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
RAW = os.path.join(ROOT, "fixtures/raw")
|
||||
NORM = os.path.join(ROOT, "fixtures/normalized")
|
||||
CONTRACTS = os.path.join(ROOT, "contracts")
|
||||
for d in (RAW, NORM, CONTRACTS):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
random.seed(42)
|
||||
|
||||
def ensure(pkg, mod=None):
|
||||
try:
|
||||
__import__(mod or pkg)
|
||||
except ImportError:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
|
||||
try: __import__(mod or pkg)
|
||||
except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
|
||||
|
||||
# Базовый каталог: (канон RU, канон KZ, [варианты названий], мин, макс, единица)
|
||||
CATALOG = [
|
||||
("Общий анализ крови", "Қанның жалпы анализі",
|
||||
["Общий анализ крови", "ОАК", "Общий анализ крови (развернутый)", "Клинический анализ крови", "Қанның жалпы анализі"],
|
||||
1200, 4500, "1 иссл."),
|
||||
("УЗИ органов брюшной полости", "Құрсақ қуысы ағзаларының УДЗ",
|
||||
["УЗИ органов брюшной полости", "УЗИ ОБП", "УЗИ брюшной полости", "Құрсақ қуысы УДЗ"],
|
||||
6000, 13000, "1 иссл."),
|
||||
("Приём врача-терапевта", "Терапевт дәрігердің қабылдауы",
|
||||
["Прием терапевта", "Консультация терапевта первичная", "Приём врача-терапевта", "Терапевт қабылдауы"],
|
||||
4000, 9000, "1 приём"),
|
||||
("Электрокардиография (ЭКГ)", "Электрокардиография (ЭКГ)",
|
||||
["ЭКГ", "Электрокардиография", "ЭКГ с расшифровкой", "ЭКГ + описание"],
|
||||
2000, 5000, "1 иссл."),
|
||||
("Глюкоза крови", "Қандағы глюкоза",
|
||||
["Глюкоза", "Глюкоза крови", "Сахар крови", "Қандағы глюкоза"],
|
||||
800, 2200, "1 иссл."),
|
||||
("Консультация кардиолога", "Кардиолог кеңесі",
|
||||
["Консультация кардиолога", "Прием кардиолога", "Кардиолог консультация первичная"],
|
||||
5000, 12000, "1 приём"),
|
||||
("МРТ головного мозга", "Бас миының МРТ",
|
||||
["МРТ головного мозга", "Магнитно-резонансная томография головного мозга", "МРТ г/м", "Бас миы МРТ"],
|
||||
18000, 35000, "1 иссл."),
|
||||
("Биохимический анализ крови", "Қанның биохимиялық анализі",
|
||||
["Биохимия крови", "Биохимический анализ крови (базовый)", "Б/х крови", "Қан биохимиясы"],
|
||||
3500, 9000, "1 иссл."),
|
||||
("Удаление зуба", "Тісті жұлу",
|
||||
["Удаление зуба", "Удаление зуба простое", "Экстракция зуба", "Тіс жұлу"],
|
||||
6000, 15000, "1 зуб"),
|
||||
("Рентген грудной клетки", "Кеуде клеткасының рентгені",
|
||||
["Рентген грудной клетки", "Рентгенография ОГК", "Флюорография", "Кеуде рентгені"],
|
||||
2500, 6000, "1 иссл."),
|
||||
# Справочник канонических медуслуг. lang-тегированные синонимы — материал для нормализации.
|
||||
CANONS = [
|
||||
{"id":"cbc","ru":"Общий анализ крови","kz":"Қанның жалпы анализі","en":"Complete blood count (CBC)",
|
||||
"category":"Лаборатория","code":"B03.016.003","unit":"1 иссл.","price":(1200,4500),
|
||||
"syn":[("ОАК","ru"),("Общий анализ крови (развернутый)","ru"),("Клинический анализ крови","ru"),("КАК","ru"),("CBC","en"),("Қанның жалпы талдауы","kz")]},
|
||||
{"id":"uzi_abd","ru":"УЗИ органов брюшной полости","kz":"Құрсақ қуысы ағзаларының УДЗ","en":"Abdominal ultrasound",
|
||||
"category":"Диагностика/УЗИ","code":"A04.16.001","unit":"1 иссл.","price":(6000,13000),
|
||||
"syn":[("УЗИ ОБП","ru"),("УЗИ брюшной полости","ru"),("Ультразвуковое исследование брюшной полости","ru"),("Құрсақ қуысы УДЗ","kz"),("Abdominal US","en")]},
|
||||
{"id":"therapist","ru":"Приём врача-терапевта","kz":"Терапевт дәрігердің қабылдауы","en":"General practitioner consultation",
|
||||
"category":"Приём специалистов","code":"B01.047.001","unit":"1 приём","price":(4000,9000),
|
||||
"syn":[("Прием терапевта","ru"),("Консультация терапевта первичная","ru"),("Терапевт қабылдауы","kz"),("GP visit","en")]},
|
||||
{"id":"ecg","ru":"Электрокардиография (ЭКГ)","kz":"Электрокардиография (ЭКГ)","en":"Electrocardiography (ECG)",
|
||||
"category":"Диагностика","code":"A05.10.006","unit":"1 иссл.","price":(2000,5000),
|
||||
"syn":[("ЭКГ","ru"),("ЭКГ с расшифровкой","ru"),("Электрокардиография","ru"),("ECG","en"),("ЭКГ + описание","ru")]},
|
||||
{"id":"glucose","ru":"Глюкоза крови","kz":"Қандағы глюкоза","en":"Blood glucose",
|
||||
"category":"Лаборатория","code":"B03.016.006","unit":"1 иссл.","price":(800,2200),
|
||||
"syn":[("Глюкоза","ru"),("Сахар крови","ru"),("Қандағы глюкоза","kz"),("Glucose","en")]},
|
||||
{"id":"cardiologist","ru":"Консультация кардиолога","kz":"Кардиолог кеңесі","en":"Cardiologist consultation",
|
||||
"category":"Приём специалистов","code":"B01.015.001","unit":"1 приём","price":(5000,12000),
|
||||
"syn":[("Прием кардиолога","ru"),("Кардиолог консультация первичная","ru"),("Кардиолог қабылдауы","kz")]},
|
||||
{"id":"mri_brain","ru":"МРТ головного мозга","kz":"Бас миының МРТ","en":"Brain MRI",
|
||||
"category":"Диагностика/МРТ","code":"A05.23.009","unit":"1 иссл.","price":(18000,35000),
|
||||
"syn":[("МРТ г/м","ru"),("Магнитно-резонансная томография головного мозга","ru"),("Бас миы МРТ","kz"),("Brain MRI","en")]},
|
||||
{"id":"biochem","ru":"Биохимический анализ крови","kz":"Қанның биохимиялық анализі","en":"Blood chemistry panel",
|
||||
"category":"Лаборатория","code":"B03.016.004","unit":"1 иссл.","price":(3500,9000),
|
||||
"syn":[("Биохимия крови","ru"),("Б/х крови","ru"),("Биохимический анализ крови (базовый)","ru"),("Қан биохимиясы","kz")]},
|
||||
{"id":"tooth_ext","ru":"Удаление зуба","kz":"Тісті жұлу","en":"Tooth extraction",
|
||||
"category":"Стоматология","code":"A16.07.001","unit":"1 зуб","price":(6000,15000),
|
||||
"syn":[("Удаление зуба простое","ru"),("Экстракция зуба","ru"),("Тіс жұлу","kz"),("Tooth removal","en")]},
|
||||
{"id":"xray_chest","ru":"Рентген грудной клетки","kz":"Кеуде клеткасының рентгені","en":"Chest X-ray",
|
||||
"category":"Диагностика","code":"A06.09.007","unit":"1 иссл.","price":(2500,6000),
|
||||
"syn":[("Рентгенография ОГК","ru"),("Флюорография","ru"),("Кеуде рентгені","kz"),("Chest X-ray","en")]},
|
||||
{"id":"tsh","ru":"Тиреотропный гормон (ТТГ)","kz":"Тиреотропты гормон (ТТГ)","en":"Thyroid-stimulating hormone (TSH)",
|
||||
"category":"Лаборатория","code":"B03.016.021","unit":"1 иссл.","price":(1500,4000),
|
||||
"syn":[("ТТГ","ru"),("Тиреотропный гормон","ru"),("TSH","en"),("ТТГ гормоны","kz")]},
|
||||
{"id":"urinalysis","ru":"Общий анализ мочи","kz":"Зәрдің жалпы анализі","en":"Urinalysis",
|
||||
"category":"Лаборатория","code":"B03.016.010","unit":"1 иссл.","price":(900,2500),
|
||||
"syn":[("ОАМ","ru"),("Общий анализ мочи","ru"),("Зәр анализі","kz"),("Urine test","en")]},
|
||||
{"id":"gyn","ru":"Приём гинеколога","kz":"Гинеколог қабылдауы","en":"Gynecologist consultation",
|
||||
"category":"Приём специалистов","code":"B01.001.001","unit":"1 приём","price":(5000,11000),
|
||||
"syn":[("Прием гинеколога","ru"),("Консультация гинеколога первичная","ru"),("Гинеколог кеңесі","kz")]},
|
||||
{"id":"covid_pcr","ru":"ПЦР на COVID-19","kz":"COVID-19 ПТР","en":"COVID-19 PCR test",
|
||||
"category":"Лаборатория","code":"A26.08.027","unit":"1 иссл.","price":(7000,16000),
|
||||
"syn":[("ПЦР COVID-19","ru"),("ПЦР-тест на коронавирус","ru"),("COVID PCR","en"),("COVID ПТР","kz")]},
|
||||
{"id":"vit_d","ru":"Витамин D (25-OH)","kz":"D дәрумені (25-OH)","en":"Vitamin D (25-OH)",
|
||||
"category":"Лаборатория","code":"B03.016.115","unit":"1 иссл.","price":(6000,12000),
|
||||
"syn":[("25-OH витамин D","ru"),("Витамин Д","ru"),("Vitamin D","en"),("D витамині","kz")]},
|
||||
]
|
||||
CLINICS = ["Клиника «Аль-Шифа»","Медцентр «Сункар»","Diagnostic Lab Astana","Клиника «Береке»","Медцентр «Тенгри»"]
|
||||
|
||||
CLINICS = ["Клиника «Аль-Шифа»", "Медцентр «Сункар»", "Diagnostic Lab Astana", "Клиника «Береке»", "Медцентр «Тенгри»"]
|
||||
def price(c): return random.randint(c["price"][0]//100, c["price"][1]//100) * 100
|
||||
def variant(c): return random.choice([(c["ru"],"ru")] + c["syn"])
|
||||
|
||||
def price(lo, hi):
|
||||
return round(random.randint(lo, hi), -2) # круглые сотни
|
||||
|
||||
def variants_for(clinic_idx, n):
|
||||
"""Выбрать n услуг с разными вариантами написания и ценами."""
|
||||
GOLDEN = []
|
||||
def build(file_name, clinic, n):
|
||||
rows = []
|
||||
chosen = random.sample(CATALOG, k=min(n, len(CATALOG)))
|
||||
for ru, kz, names, lo, hi, unit in chosen:
|
||||
name = random.choice(names)
|
||||
rows.append((name, price(lo, hi), unit))
|
||||
for c in random.sample(CANONS, k=min(n, len(CANONS))):
|
||||
raw_name, lang = variant(c); p = price(c)
|
||||
rows.append({"raw_name":raw_name,"price":p,"unit":c["unit"]})
|
||||
GOLDEN.append({"source_file":file_name,"clinic":clinic,"raw_name":raw_name,"lang":lang,
|
||||
"canon_id":c["id"],"canonical_ru":c["ru"],"canonical_kz":c["kz"],"canonical_en":c["en"],
|
||||
"category":c["category"],"code":c["code"],"price":p,"currency":"KZT","unit":c["unit"]})
|
||||
return rows
|
||||
|
||||
ensure("openpyxl")
|
||||
from openpyxl import Workbook
|
||||
ensure("openpyxl"); from openpyxl import Workbook
|
||||
|
||||
# 1) Чистый Excel
|
||||
wb = Workbook(); ws = wb.active; ws.title = "Прайс"
|
||||
ws.append(["Наименование услуги", "Цена, тг"])
|
||||
for name, p, _ in variants_for(0, 9):
|
||||
ws.append([name, p])
|
||||
wb.save(f"{OUT}/clinic_alpha_clean.xlsx")
|
||||
# 1) чистый Excel
|
||||
rows = build("clinic_alpha_clean.xlsx", CLINICS[0], 9)
|
||||
wb=Workbook(); ws=wb.active; ws.title="Прайс"; ws.append(["Наименование услуги","Цена, тг"])
|
||||
for r in rows: ws.append([r["raw_name"], r["price"]])
|
||||
wb.save(f"{RAW}/clinic_alpha_clean.xlsx")
|
||||
|
||||
# 2) Грязный Excel: секции-заголовки, "от", разные колонки, KZ
|
||||
wb = Workbook(); ws = wb.active; ws.title = "PRICE 2026"
|
||||
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"])
|
||||
ws.append([])
|
||||
ws.append(["№", "Услуга / Қызмет", "Стоимость (тенге)", "Ед."])
|
||||
section = ["— Лаборатория / Зертхана —", "— Диагностика —", "— Приём специалистов —"]
|
||||
i = 1
|
||||
for s in section:
|
||||
ws.append([s, None, None, None])
|
||||
for name, p, unit in variants_for(1, 3):
|
||||
val = f"от {p}" if random.random() < 0.3 else p
|
||||
ws.append([i, name, val, unit]); i += 1
|
||||
wb.save(f"{OUT}/clinic_beta_dirty.xlsx")
|
||||
# 2) грязный Excel
|
||||
rows = build("clinic_beta_dirty.xlsx", CLINICS[1], 9)
|
||||
wb=Workbook(); ws=wb.active; ws.title="PRICE 2026"
|
||||
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"]); ws.append([])
|
||||
ws.append(["№","Услуга / Қызмет","Стоимость (тенге)","Ед."])
|
||||
secs=["— Лаборатория / Зертхана —","— Диагностика —","— Приём специалистов —"]; i=1
|
||||
for k,r in enumerate(rows):
|
||||
if k%3==0 and secs: ws.append([secs.pop(0),None,None,None])
|
||||
val=f"от {r['price']}" if random.random()<0.3 else r["price"]
|
||||
ws.append([i, r["raw_name"], val, r["unit"]]); i+=1
|
||||
wb.save(f"{RAW}/clinic_beta_dirty.xlsx")
|
||||
|
||||
# 3) CSV с разделителем ; и смешанными языками
|
||||
with open(f"{OUT}/clinic_gamma.csv", "w", encoding="utf-8", newline="") as f:
|
||||
w = csv.writer(f, delimiter=";")
|
||||
w.writerow(["service", "price_kzt", "unit"])
|
||||
for name, p, unit in variants_for(2, 8):
|
||||
w.writerow([name, p, unit])
|
||||
# 3) CSV ;
|
||||
rows = build("clinic_gamma.csv", CLINICS[2], 8)
|
||||
with open(f"{RAW}/clinic_gamma.csv","w",encoding="utf-8",newline="") as f:
|
||||
w=csv.writer(f,delimiter=";"); w.writerow(["service","price_kzt","unit"])
|
||||
for r in rows: w.writerow([r["raw_name"], r["price"], r["unit"]])
|
||||
|
||||
# 4) Грязный текст (прайс абзацем)
|
||||
with open(f"{OUT}/clinic_delta_messy.txt", "w", encoding="utf-8") as f:
|
||||
# 4) грязный текст
|
||||
rows = build("clinic_delta_messy.txt", CLINICS[4], 7)
|
||||
with open(f"{RAW}/clinic_delta_messy.txt","w",encoding="utf-8") as f:
|
||||
f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n")
|
||||
for name, p, unit in variants_for(3, 7):
|
||||
sep = random.choice([" — ", " ... ", ": ", " "])
|
||||
cur = random.choice(["тг", "тенге", "₸", "KZT"])
|
||||
f.write(f"{name}{sep}{p} {cur} ({unit})\n")
|
||||
for r in rows:
|
||||
sep=random.choice([" — "," ... ",": "," "]); cur=random.choice(["тг","тенге","₸","KZT"])
|
||||
f.write(f"{r['raw_name']}{sep}{r['price']} {cur} ({r['unit']})\n")
|
||||
f.write("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n")
|
||||
|
||||
# 5) Картинка-«скан» прайса (имитация фото)
|
||||
ensure("Pillow", "PIL")
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
img = Image.new("RGB", (900, 700), (250, 248, 244))
|
||||
d = ImageDraw.Draw(img)
|
||||
# 5) PNG-скан
|
||||
rows = build("clinic_epsilon_scan.png", CLINICS[3], 9)
|
||||
ensure("Pillow","PIL"); from PIL import Image, ImageDraw, ImageFont
|
||||
def font(sz):
|
||||
for p in ["/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
|
||||
if os.path.exists(p):
|
||||
return ImageFont.truetype(p, sz)
|
||||
for p in ["/System/Library/Fonts/Supplemental/Arial.ttf","/Library/Fonts/Arial.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
|
||||
if os.path.exists(p): return ImageFont.truetype(p,sz)
|
||||
return ImageFont.load_default()
|
||||
d.text((40, 30), "Клиника «Береке» — прайс-лист", fill=(20, 20, 20), font=font(34))
|
||||
d.line((40, 85, 860, 85), fill=(180, 180, 180), width=2)
|
||||
y = 110
|
||||
for name, p, unit in variants_for(4, 9):
|
||||
d.text((50, y), name, fill=(30, 30, 30), font=font(24))
|
||||
d.text((720, y), f"{p} тг", fill=(30, 30, 30), font=font(24))
|
||||
y += 58
|
||||
img = img.rotate(-1.4, expand=False, fillcolor=(250, 248, 244)) # лёгкий наклон как у фото
|
||||
img.save(f"{OUT}/clinic_epsilon_scan.png")
|
||||
img=Image.new("RGB",(900,720),(250,248,244)); d=ImageDraw.Draw(img)
|
||||
d.text((40,30),"Клиника «Береке» — прайс-лист",fill=(20,20,20),font=font(34)); d.line((40,85,860,85),fill=(180,180,180),width=2)
|
||||
y=110
|
||||
for r in rows:
|
||||
d.text((50,y),r["raw_name"][:42],fill=(30,30,30),font=font(23)); d.text((720,y),f"{r['price']} тг",fill=(30,30,30),font=font(23)); y+=62
|
||||
img=img.rotate(-1.4,expand=False,fillcolor=(250,248,244)); img.save(f"{RAW}/clinic_epsilon_scan.png")
|
||||
|
||||
# 6) PDF (если есть reportlab)
|
||||
# 6) PDF
|
||||
rows = build("clinic_zeta_prices.pdf", CLINICS[2], 10)
|
||||
pdf="skip"
|
||||
try:
|
||||
ensure("reportlab")
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
fp = next((p for p in ["/Library/Fonts/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] if os.path.exists(p)), None)
|
||||
from reportlab.lib.pagesizes import A4; from reportlab.pdfgen import canvas
|
||||
from reportlab.pdfbase import pdfmetrics; from reportlab.pdfbase.ttfonts import TTFont
|
||||
fp=next((p for p in ["/Library/Fonts/Arial.ttf","/System/Library/Fonts/Supplemental/Arial.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] if os.path.exists(p)),None)
|
||||
if fp:
|
||||
pdfmetrics.registerFont(TTFont("U", fp))
|
||||
c = canvas.Canvas(f"{OUT}/clinic_zeta_prices.pdf", pagesize=A4)
|
||||
c.setFont("U", 18); c.drawString(60, 790, "Diagnostic Lab Astana — Price list / Прайс")
|
||||
c.setFont("U", 12); y = 750
|
||||
for name, p, unit in variants_for(2, 10):
|
||||
c.drawString(60, y, name[:55]); c.drawRightString(540, y, f"{p} KZT"); y -= 28
|
||||
c.save()
|
||||
pdf = "ok"
|
||||
else:
|
||||
pdf = "no font"
|
||||
except Exception as e:
|
||||
pdf = f"skip ({e})"
|
||||
pdfmetrics.registerFont(TTFont("U",fp)); c=canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf",pagesize=A4)
|
||||
c.setFont("U",18); c.drawString(60,790,"Diagnostic Lab Astana — Price list / Прайс"); c.setFont("U",12); y=750
|
||||
for r in rows: c.drawString(60,y,r["raw_name"][:55]); c.drawRightString(540,y,f"{r['price']} KZT"); y-=28
|
||||
c.save(); pdf="ok"
|
||||
except Exception as e: pdf=f"skip ({e})"
|
||||
|
||||
# эталон нормализации + справочник
|
||||
with open(f"{NORM}/golden.json","w",encoding="utf-8") as f:
|
||||
json.dump(GOLDEN, f, ensure_ascii=False, indent=2)
|
||||
canon_out=[{"canon_id":c["id"],"canonical_ru":c["ru"],"canonical_kz":c["kz"],"canonical_en":c["en"],
|
||||
"category":c["category"],"code":c["code"],"unit":c["unit"],
|
||||
"synonyms":[{"text":t,"lang":l} for t,l in c["syn"]]} for c in CANONS]
|
||||
with open(f"{CONTRACTS}/service_canon.json","w",encoding="utf-8") as f:
|
||||
json.dump(canon_out, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print("PDF:", pdf)
|
||||
print("Files:", sorted(os.listdir(OUT)))
|
||||
print("raw:", sorted(os.listdir(RAW)))
|
||||
print("golden rows:", len(GOLDEN), "| canons:", len(CANONS))
|
||||
|
||||
Reference in New Issue
Block a user