Compare commits
32 Commits
ac1467b49b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b5e9c17eb | |||
| 392ff3100f | |||
| 22df5b3a57 | |||
| 52afd88712 | |||
| 1e08327c1f | |||
| f4a021b9c9 | |||
| f5636a4cb9 | |||
| f8ef3e6d77 | |||
| 21897499a7 | |||
| 915822f670 | |||
| 86c0b0a848 | |||
| 0dd4b0c5cc | |||
| 4206b24063 | |||
| fe7ffcf963 | |||
| d6e00f287a | |||
| 51d1e8d214 | |||
| 4d9bb2d3dc | |||
| 92a1e9a9cf | |||
| 2311b894a0 | |||
| dacbaa0119 | |||
| 56e85f1968 | |||
| 194462642a | |||
| c32d566b4d | |||
| 3ddcab4840 | |||
| 2267b45519 | |||
| a29a556e1f | |||
| 34c64d560f | |||
| c1cf53fad1 | |||
| 3b17b2755d | |||
| eb65419412 | |||
| 4a5d1a13c8 | |||
| 1a39410881 |
@@ -2,3 +2,4 @@ __pycache__
|
||||
.env
|
||||
logos
|
||||
old
|
||||
*.png
|
||||
+11981
File diff suppressed because it is too large
Load Diff
@@ -2,19 +2,29 @@ from scoreboard import ScoreboardMatrix
|
||||
from time import sleep
|
||||
|
||||
# modes
|
||||
from modes import score
|
||||
from modes import score, fantasy
|
||||
|
||||
scoreboard_matrix = ScoreboardMatrix()
|
||||
|
||||
def main():
|
||||
while True:
|
||||
print(scoreboard_matrix.current_slide)
|
||||
if scoreboard_matrix.current_slide == "score":
|
||||
canvas = score.draw_frame(scoreboard_matrix)
|
||||
print(canvas)
|
||||
if not canvas:
|
||||
scoreboard_matrix.canvas.Clear()
|
||||
scoreboard_matrix.canvas = scoreboard_matrix.matrix.SwapOnVSync(scoreboard_matrix.canvas)
|
||||
scoreboard_matrix.current_slide = "fantasy"
|
||||
if canvas:
|
||||
else:
|
||||
scoreboard_matrix.canvas = scoreboard_matrix.matrix.SwapOnVSync(canvas)
|
||||
elif scoreboard_matrix.current_slide == "fantasy":
|
||||
canvas = fantasy.draw_frame(scoreboard_matrix)
|
||||
if not canvas:
|
||||
scoreboard_matrix.canvas.Clear()
|
||||
scoreboard_matrix.canvas = scoreboard_matrix.matrix.SwapOnVSync(scoreboard_matrix.canvas)
|
||||
scoreboard_matrix.current_slide = "score"
|
||||
else:
|
||||
scoreboard_matrix.canvas = scoreboard_matrix.matrix.SwapOnVSync(canvas)
|
||||
|
||||
sleep(0.1)
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
import os
|
||||
import tempfile
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont, BdfFontFile
|
||||
from time import time
|
||||
from rgbmatrix import graphics
|
||||
from vars import PANEL_HEIGHT, PANEL_WIDTH, GAME_WIDTH, DIVIDER_COLOR, Colors, font, font_small, font_super_small, ASSET_DIR
|
||||
|
||||
players = {
|
||||
"team1": [],
|
||||
"team2": []
|
||||
}
|
||||
team_info = {
|
||||
"team1": {},
|
||||
"team2": {}
|
||||
}
|
||||
last_fetch = 0
|
||||
|
||||
STATIC_CARD_WIDTH = 96
|
||||
SCROLL_REGION_X = STATIC_CARD_WIDTH
|
||||
SCROLL_REGION_WIDTH = PANEL_WIDTH - STATIC_CARD_WIDTH
|
||||
|
||||
# BDF fonts here are fixed-width bitmap fonts named "<char_width>x<char_height>.bdf"
|
||||
SMALL_CHAR_WIDTH = 5 # 5x7.bdf
|
||||
SUPER_SMALL_CHAR_WIDTH = 4 # 4x6.bdf
|
||||
STATIC_CARD_MARGIN = 4
|
||||
|
||||
_pil_font_cache = {}
|
||||
|
||||
def _pil_font(bdf_filename):
|
||||
"""Load a .bdf bitmap font as a PIL ImageFont, so player text can be
|
||||
drawn directly into the same virtual PIL canvas used for logos. That way
|
||||
it goes through the exact same blit_slice() pixel copy/clip as the logos,
|
||||
instead of being drawn separately onto the live matrix canvas."""
|
||||
if bdf_filename not in _pil_font_cache:
|
||||
bdf_path = os.path.join(ASSET_DIR, "fonts", bdf_filename)
|
||||
name = os.path.splitext(bdf_filename)[0]
|
||||
cache_base = os.path.join(tempfile.gettempdir(), f"scoreboard_pilfont_{name}")
|
||||
if not os.path.exists(cache_base + ".pil"):
|
||||
with open(bdf_path, "rb") as f:
|
||||
BdfFontFile.BdfFontFile(f).save(cache_base)
|
||||
_pil_font_cache[bdf_filename] = ImageFont.load(cache_base + ".pil")
|
||||
return _pil_font_cache[bdf_filename]
|
||||
|
||||
NAME_PIL_FONT = _pil_font("7x13.bdf")
|
||||
SMALL_PIL_FONT = _pil_font("5x7.bdf")
|
||||
SUPER_SMALL_PIL_FONT = _pil_font("4x6.bdf")
|
||||
|
||||
scroll_x = 0
|
||||
scroll_speed = 1
|
||||
frames_per_tick = 1
|
||||
tick = 0
|
||||
times_scrolled = 0
|
||||
virtual_canvas = None
|
||||
virtual_dirty = True
|
||||
current_team = "team1"
|
||||
|
||||
def rbg(color_tuple):
|
||||
r, g, b = color_tuple
|
||||
return graphics.Color(r, b, g)
|
||||
|
||||
def fetch_data():
|
||||
try:
|
||||
resp = requests.get("https://api.alexav.gg/v4/sports/fantasy", timeout=5)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
new_players = {"team1": [], "team2": []}
|
||||
new_team_info = {"team1": {}, "team2": {}}
|
||||
for key in ("team1", "team2"):
|
||||
for player in data[key]["players"]:
|
||||
new_players[key].append({
|
||||
"first_name": player['first_name'],
|
||||
"last_name": player['last_name'],
|
||||
"abbr_name": f"{player['first_name'][0]}. {player['last_name']}",
|
||||
"position": player['position'],
|
||||
"team": player['team'],
|
||||
"injury_status": "Q" if player.get('injury_status') == "Questionable" else player.get('injury_status'),
|
||||
"injury_body_part": player.get('injury_body_part')
|
||||
})
|
||||
|
||||
owner = data[key].get("owner") or {}
|
||||
metadata = owner.get("metadata") or {}
|
||||
new_team_info[key] = {
|
||||
"name": metadata.get("team_name") or owner.get("display_name") or key.upper(),
|
||||
"points": data[key].get("points"),
|
||||
}
|
||||
|
||||
return new_players, new_team_info
|
||||
except Exception as e:
|
||||
print(f"[fantasy] fetch error: {e}")
|
||||
return None, None
|
||||
|
||||
def _wrap_to_width(text, char_width, max_width, max_lines):
|
||||
"""Word-wrap text into at most max_lines lines that each fit within
|
||||
max_width pixels, assuming a fixed-width font. Overlong single words are
|
||||
hard-truncated so nothing ever overflows the given width."""
|
||||
max_chars = max(1, max_width // char_width)
|
||||
|
||||
lines = []
|
||||
current = ""
|
||||
for word in text.split(" "):
|
||||
if len(word) > max_chars:
|
||||
word = word[:max_chars]
|
||||
trial = f"{current} {word}".strip()
|
||||
if len(trial) > max_chars and current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
else:
|
||||
current = trial
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
if current and len(lines) < max_lines:
|
||||
lines.append(current)
|
||||
|
||||
return [line[:max_chars] for line in lines[:max_lines]]
|
||||
|
||||
def draw_static_card(canvas, team):
|
||||
"""Draw a fixed, non-scrolling card for the given team into the leftmost
|
||||
STATIC_CARD_WIDTH pixels of the canvas."""
|
||||
info = team_info.get(team, {})
|
||||
name = info.get("name") or team.upper()
|
||||
points = info.get("points")
|
||||
|
||||
text_width = STATIC_CARD_WIDTH - STATIC_CARD_MARGIN - 3 # stay clear of the divider
|
||||
|
||||
label = "TEAM 1" if team == "team1" else "TEAM 2"
|
||||
graphics.DrawText(canvas, font_small, STATIC_CARD_MARGIN, 8, rbg(Colors.YELLOW.value), label)
|
||||
|
||||
name_lines = _wrap_to_width(name, SUPER_SMALL_CHAR_WIDTH, text_width, max_lines=2)
|
||||
|
||||
y = 17
|
||||
for line in name_lines:
|
||||
graphics.DrawText(canvas, font_super_small, STATIC_CARD_MARGIN, y, rbg(Colors.WHITE.value), line)
|
||||
y += 7
|
||||
|
||||
if points is not None:
|
||||
graphics.DrawText(canvas, font_small, STATIC_CARD_MARGIN, 30, rbg(Colors.RED.value), f"{points:.1f}")
|
||||
|
||||
# divider on right edge of the static card
|
||||
r, g, b = DIVIDER_COLOR
|
||||
for row in range(PANEL_HEIGHT):
|
||||
canvas.SetPixel(STATIC_CARD_WIDTH - 1, row, r, b, g)
|
||||
|
||||
def render_player(matrix, img, player, x_offset):
|
||||
league = "nfl"
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
team_logo = matrix.load_logo(league, player['team'])
|
||||
if team_logo:
|
||||
img.paste(team_logo.resize((28, 28)), (x_offset, 0))
|
||||
|
||||
# player text is baked directly into the virtual canvas (same as the logo
|
||||
# above) so it gets clipped identically by blit_slice() when scrolled
|
||||
draw.text((x_offset + 35, 3), player['abbr_name'], font=NAME_PIL_FONT, fill=Colors.WHITE.value)
|
||||
draw.text((x_offset + 35, 18), player['position'], font=SMALL_PIL_FONT, fill=Colors.WHITE.value)
|
||||
|
||||
if player['injury_status'] and player['injury_body_part']:
|
||||
draw.text((x_offset + 50, 18), f"{player['injury_status']} - ", font=SMALL_PIL_FONT, fill=Colors.RED.value)
|
||||
draw.text((x_offset + 70, 19), str(player['injury_body_part']), font=SUPER_SMALL_PIL_FONT, fill=Colors.RED.value)
|
||||
|
||||
# divider on right edge (except last slot handled by wrapping)
|
||||
for row in range(PANEL_HEIGHT):
|
||||
img.putpixel((x_offset + GAME_WIDTH - 1, row), DIVIDER_COLOR)
|
||||
|
||||
def build_canvas(matrix, team):
|
||||
print(players)
|
||||
total_players = len(players[team])
|
||||
total_width = GAME_WIDTH * (total_players + 4)
|
||||
img = Image.new("RGB", (total_width, PANEL_HEIGHT), (0,0,0))
|
||||
|
||||
for i, player in enumerate(players[team] * 2):
|
||||
if i >= total_players + 4:
|
||||
break
|
||||
render_player(matrix, img, player, i * GAME_WIDTH)
|
||||
|
||||
return img, total_players
|
||||
|
||||
def blit_slice(canvas, pil_img, x_offset, dest_x=0, width=PANEL_WIDTH):
|
||||
total_width = pil_img.width
|
||||
for x in range(width):
|
||||
src_x = (x_offset + x) % total_width
|
||||
for y in range(PANEL_HEIGHT):
|
||||
r, g, b = pil_img.getpixel((src_x, y))
|
||||
canvas.SetPixel(dest_x + x, y, r, b, g)
|
||||
|
||||
def draw_frame(matrix):
|
||||
global players, team_info, last_fetch, virtual_canvas, virtual_dirty, scroll_x, tick
|
||||
global total_players, current_team
|
||||
now = time()
|
||||
|
||||
if now - last_fetch > 30 or not players['team1'] or not players['team2']:
|
||||
fetched_players, fetched_team_info = fetch_data()
|
||||
if fetched_players is not None:
|
||||
players = fetched_players
|
||||
team_info = fetched_team_info
|
||||
last_fetch = now
|
||||
virtual_dirty = True
|
||||
|
||||
if virtual_dirty or virtual_canvas is None:
|
||||
result = build_canvas(matrix, current_team)
|
||||
if result:
|
||||
virtual_canvas, total_players = result
|
||||
virtual_dirty = False
|
||||
|
||||
matrix.canvas.Clear()
|
||||
draw_static_card(matrix.canvas, current_team)
|
||||
|
||||
if not len(players[current_team]) > 0:
|
||||
graphics.DrawText(matrix.canvas, font, GAME_WIDTH + 10, 22,
|
||||
rbg(Colors.RED.value),
|
||||
"No players")
|
||||
scroll_x = tick = 0
|
||||
return matrix.canvas
|
||||
|
||||
total_scroll_width = GAME_WIDTH * len(players[current_team])
|
||||
if virtual_canvas:
|
||||
blit_slice(matrix.canvas, virtual_canvas, scroll_x, dest_x=SCROLL_REGION_X, width=SCROLL_REGION_WIDTH)
|
||||
|
||||
tick += 1
|
||||
if tick >= frames_per_tick:
|
||||
tick = 0
|
||||
next_x = scroll_x + scroll_speed
|
||||
if next_x >= total_scroll_width:
|
||||
scroll_x = 0
|
||||
tick = 0
|
||||
|
||||
if current_team == "team1":
|
||||
current_team = "team2"
|
||||
virtual_dirty = True
|
||||
return matrix.canvas
|
||||
else:
|
||||
current_team = "team1"
|
||||
virtual_dirty = True
|
||||
return None
|
||||
scroll_x = next_x
|
||||
|
||||
return matrix.canvas
|
||||
+12
-9
@@ -16,12 +16,13 @@ preferred_teams = [
|
||||
]
|
||||
|
||||
scroll_x = 0
|
||||
scroll_speed = 10
|
||||
frames_per_tick = 2
|
||||
scroll_speed = 1
|
||||
frames_per_tick = 1
|
||||
tick = 0
|
||||
times_scrolled = 0
|
||||
virtual_canvas = None
|
||||
virtual_dirty = True
|
||||
last_ordered_ids = None
|
||||
|
||||
def rbg(color_tuple):
|
||||
r, g, b = color_tuple
|
||||
@@ -76,8 +77,9 @@ def update_preferred():
|
||||
|
||||
# add new matching games
|
||||
for game in games:
|
||||
if (game["away"], game["league"]) in preferred_teams or \
|
||||
(game["home"], game["league"]) in preferred_teams:
|
||||
if game["id"] not in preferred_games and \
|
||||
((game["away"], game["league"]) in preferred_teams or
|
||||
(game["home"], game["league"]) in preferred_teams):
|
||||
preferred_games.append(game["id"])
|
||||
|
||||
def draw_text_overlay(canvas, ordered, scroll_x):
|
||||
@@ -166,14 +168,18 @@ def blit_slice(canvas, pil_img, x_offset):
|
||||
|
||||
def draw_frame(matrix):
|
||||
global games, last_fetch, virtual_canvas, virtual_dirty, scroll_x, tick
|
||||
global total_games
|
||||
global total_games, last_ordered_ids
|
||||
now = time()
|
||||
|
||||
if now - last_fetch > 30 or not games:
|
||||
games = get_all_scores()
|
||||
last_fetch = now
|
||||
update_preferred()
|
||||
virtual_dirty = True
|
||||
|
||||
ordered_ids = [g["id"] for g in ordered_games()]
|
||||
if ordered_ids != last_ordered_ids:
|
||||
last_ordered_ids = ordered_ids
|
||||
virtual_dirty = True
|
||||
|
||||
# rebuild virtual canvas if data changed
|
||||
if virtual_dirty or virtual_canvas is None:
|
||||
@@ -198,13 +204,10 @@ def draw_frame(matrix):
|
||||
blit_slice(matrix.canvas, virtual_canvas, scroll_x)
|
||||
draw_text_overlay(matrix.canvas, ordered, scroll_x)
|
||||
|
||||
print(tick)
|
||||
|
||||
tick += 1
|
||||
if tick >= frames_per_tick:
|
||||
tick = 0
|
||||
next_x = scroll_x + scroll_speed
|
||||
print(scroll_x, scroll_speed, next_x, total_scroll_width)
|
||||
if next_x >= total_scroll_width:
|
||||
scroll_x = 0
|
||||
tick = 0
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
# --- stub out rgbmatrix so vars.py / modes/fantasy.py import cleanly on a dev machine ---
|
||||
from PIL import ImageFont
|
||||
|
||||
_FONT_PATH_SIZES = {"4x6.bdf": 6, "5x7.bdf": 7, "7x13.bdf": 12, "9x18.bdf": 16}
|
||||
|
||||
|
||||
def _load_pil_font(px_size):
|
||||
for candidate in (r"C:\Windows\Fonts\consola.ttf", r"C:\Windows\Fonts\cour.ttf"):
|
||||
if os.path.exists(candidate):
|
||||
try:
|
||||
return ImageFont.truetype(candidate, px_size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
class _FakeFont:
|
||||
def __init__(self):
|
||||
self.size = 10
|
||||
self.pil_font = _load_pil_font(self.size)
|
||||
|
||||
def LoadFont(self, path):
|
||||
# real BDF files are named e.g. "5x7.bdf" / "7x13.bdf" / "9x18.bdf" -
|
||||
# use that to pick a comparably-sized stand-in TTF for the preview
|
||||
self.size = _FONT_PATH_SIZES.get(os.path.basename(path), 10)
|
||||
self.pil_font = _load_pil_font(self.size)
|
||||
|
||||
|
||||
def _fake_draw_text(canvas, font, x, y, color, text):
|
||||
r, g, b = color
|
||||
offset = getattr(font, "size", 10)
|
||||
canvas.draw.text((x, y - offset), text, fill=(r, g, b), font=getattr(font, "pil_font", None))
|
||||
return len(text) * (offset // 2 + 1)
|
||||
|
||||
def _fake_color(r, g, b):
|
||||
return (r, g, b)
|
||||
|
||||
_graphics_mod = types.ModuleType("rgbmatrix.graphics")
|
||||
_graphics_mod.Font = _FakeFont
|
||||
_graphics_mod.DrawText = _fake_draw_text
|
||||
_graphics_mod.Color = _fake_color
|
||||
|
||||
_rgbmatrix_mod = types.ModuleType("rgbmatrix")
|
||||
_rgbmatrix_mod.graphics = _graphics_mod
|
||||
|
||||
sys.modules["rgbmatrix"] = _rgbmatrix_mod
|
||||
sys.modules["rgbmatrix.graphics"] = _graphics_mod
|
||||
|
||||
# --- safe to import project code now ---
|
||||
from PIL import Image, ImageDraw
|
||||
import vars
|
||||
from modes import fantasy
|
||||
|
||||
class FakeCanvas:
|
||||
def __init__(self, width, height):
|
||||
self.img = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.img)
|
||||
|
||||
def Clear(self):
|
||||
self.draw.rectangle([0, 0, self.img.width, self.img.height], fill=(0, 0, 0))
|
||||
|
||||
def SetPixel(self, x, y, r, g, b):
|
||||
if 0 <= x < self.img.width and 0 <= y < self.img.height:
|
||||
self.img.putpixel((x, y), (r, g, b))
|
||||
|
||||
class FakeMatrix:
|
||||
def __init__(self):
|
||||
self.canvas = FakeCanvas(vars.PANEL_WIDTH, vars.PANEL_HEIGHT)
|
||||
self._logo_cache = {}
|
||||
|
||||
def load_logo(self, league, abbr):
|
||||
key = f"{league}_{abbr}"
|
||||
if key in self._logo_cache:
|
||||
return self._logo_cache[key]
|
||||
path = os.path.join(vars.LOGO_DIR, f"{key}.png")
|
||||
if not os.path.exists(path):
|
||||
self._logo_cache[key] = None
|
||||
return None
|
||||
img = Image.open(path).convert("RGB")
|
||||
self._logo_cache[key] = img
|
||||
return img
|
||||
|
||||
|
||||
def render_single_card(player, out_path, scale=6):
|
||||
matrix = FakeMatrix()
|
||||
fantasy.players["team1"] = [player]
|
||||
|
||||
img, _ = fantasy.build_canvas(matrix, "team1")
|
||||
canvas = matrix.canvas
|
||||
fantasy.blit_slice(canvas, img, 0)
|
||||
|
||||
card = canvas.img.crop((0, 0, vars.GAME_WIDTH, vars.PANEL_HEIGHT))
|
||||
card = card.resize((card.width * scale, card.height * scale), Image.NEAREST)
|
||||
card.save(out_path)
|
||||
return out_path
|
||||
|
||||
|
||||
healthy_player = {
|
||||
"first_name": "Josh",
|
||||
"last_name": "Allen",
|
||||
"abbr_name": "J. Allen",
|
||||
"position": "QB",
|
||||
"team": "BUF",
|
||||
"injury_status": None,
|
||||
"injury_body_part": None,
|
||||
}
|
||||
|
||||
injured_player = {
|
||||
"first_name": "Stefon",
|
||||
"last_name": "Diggs",
|
||||
"abbr_name": "S. Diggs",
|
||||
"position": "WR",
|
||||
"team": "BUF",
|
||||
"injury_status": "Questionable",
|
||||
"injury_body_part": "Hamstring",
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
healthy_path = REPO_ROOT / "preview_healthy.png"
|
||||
injured_path = REPO_ROOT / "preview_injured.png"
|
||||
|
||||
render_single_card(healthy_player, healthy_path)
|
||||
render_single_card(injured_player, injured_path)
|
||||
print(f"Saved: {healthy_path}")
|
||||
print(f"Saved: {injured_path}")
|
||||
|
||||
img1 = Image.open(healthy_path)
|
||||
img2 = Image.open(injured_path)
|
||||
gap = 20
|
||||
combined = Image.new(
|
||||
"RGB", (img1.width + img2.width + gap, max(img1.height, img2.height)), (30, 30, 30)
|
||||
)
|
||||
combined.paste(img1, (0, 0))
|
||||
combined.paste(img2, (img1.width + gap, 0))
|
||||
combined_path = REPO_ROOT / "preview_comparison.png"
|
||||
combined.save(combined_path)
|
||||
print(f"Saved: {combined_path}")
|
||||
# combined.show()
|
||||
+1
-1
@@ -5,7 +5,7 @@ import vars
|
||||
|
||||
class ScoreboardMatrix():
|
||||
logo_cache = {}
|
||||
current_slide = "score"
|
||||
current_slide = "fantasy"
|
||||
options = RGBMatrixOptions()
|
||||
options.rows = 32
|
||||
options.cols = 64
|
||||
|
||||
@@ -23,7 +23,9 @@ DIVIDER_COLOR = (40, 40, 40)
|
||||
|
||||
font = graphics.Font()
|
||||
font_small = graphics.Font()
|
||||
font_super_small = graphics.Font()
|
||||
font_big = graphics.Font()
|
||||
font.LoadFont(os.path.join(ASSET_DIR, "fonts/7x13.bdf"))
|
||||
font_small.LoadFont(os.path.join(ASSET_DIR, "fonts/5x7.bdf"))
|
||||
font_super_small.LoadFont(os.path.join(ASSET_DIR, "fonts/4x6.bdf"))
|
||||
font_big.LoadFont(os.path.join(ASSET_DIR, "fonts/9x18.bdf"))
|
||||
Reference in New Issue
Block a user