Implement catering order flow
This commit is contained in:
+1
-1
@@ -48,7 +48,7 @@ qrcode==8.2
|
|||||||
RapidFuzz==3.14.5
|
RapidFuzz==3.14.5
|
||||||
readchar==4.2.2
|
readchar==4.2.2
|
||||||
revel==0.9.2.post1
|
revel==0.9.2.post1
|
||||||
rio-ui==0.12
|
rio-ui==0.12.1
|
||||||
sentinel==1.0.0
|
sentinel==1.0.0
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
starlette==0.52.1
|
starlette==0.52.1
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from rio import Component, Rectangle, Column, Text, Spacer, List, Row, IconButton
|
||||||
|
|
||||||
|
from elm.components import ElmButton
|
||||||
|
from elm.services import AccountingService
|
||||||
|
from elm.types import User, UserSession
|
||||||
|
from elm.types.CateringTypes import CateringOrderedItem, CateringOrder
|
||||||
|
|
||||||
|
|
||||||
|
class CateringCart(Component):
|
||||||
|
cart: List[CateringOrderedItem]
|
||||||
|
order_button_loading: bool = False
|
||||||
|
status_text: str = " "
|
||||||
|
status_is_error: bool = False
|
||||||
|
|
||||||
|
def remove_item(self, item: CateringOrderedItem) -> None:
|
||||||
|
self.cart.remove(item)
|
||||||
|
|
||||||
|
def get_cart_price(self) -> str:
|
||||||
|
price = Decimal(0)
|
||||||
|
for item in self.cart:
|
||||||
|
price += item.final_unit_price
|
||||||
|
return self.session[AccountingService].make_euro_string_from_decimal(price)
|
||||||
|
|
||||||
|
async def order_pressed(self) -> None:
|
||||||
|
self.order_button_loading = True
|
||||||
|
|
||||||
|
if len(self.cart) == 0:
|
||||||
|
self.status_text = "Warenkorb leer!"
|
||||||
|
self.status_is_error = True
|
||||||
|
self.order_button_loading = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = await User.find_one(User.user_name == self.session[UserSession].user_name)
|
||||||
|
except KeyError:
|
||||||
|
self.status_text = "Nicht eingeloggt!"
|
||||||
|
self.status_is_error = True
|
||||||
|
self.order_button_loading = False
|
||||||
|
return None
|
||||||
|
if not user:
|
||||||
|
self.status_text = "Nicht eingeloggt!"
|
||||||
|
self.status_is_error = True
|
||||||
|
self.order_button_loading = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_price = Decimal(0)
|
||||||
|
for item in self.cart:
|
||||||
|
total_price += item.final_unit_price
|
||||||
|
|
||||||
|
balance = await self.session[AccountingService].get_balance(user.user_name)
|
||||||
|
|
||||||
|
if total_price > balance:
|
||||||
|
self.status_text = "Guthaben nicht ausreichend!"
|
||||||
|
self.status_is_error = True
|
||||||
|
self.order_button_loading = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_order = await CateringOrder(
|
||||||
|
customer_id=user.id,
|
||||||
|
items=list(self.cart)
|
||||||
|
).save()
|
||||||
|
except Exception as e:
|
||||||
|
self.status_text = f"Fehler: {e}"
|
||||||
|
self.status_is_error = True
|
||||||
|
self.order_button_loading = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self.session[AccountingService].remove_balance(
|
||||||
|
user.user_name,
|
||||||
|
total_price,
|
||||||
|
f"Catering: {new_order.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.cart.clear()
|
||||||
|
self.order_button_loading = False
|
||||||
|
self.status_text = "Bestellt!"
|
||||||
|
self.status_is_error = False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build(self) -> Component:
|
||||||
|
return Rectangle(
|
||||||
|
content=Column(
|
||||||
|
*[Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Row(
|
||||||
|
Text(text=item.name, overflow="ellipsize", grow_x=True),
|
||||||
|
IconButton(icon="material/cancel", style="plain-text", min_size=2, on_press=partial(self.remove_item, item)),
|
||||||
|
margin=0.5
|
||||||
|
),
|
||||||
|
*[
|
||||||
|
Row(Text(f"{'MIT' if modifier.selected else 'OHNE'} {modifier.label}", overflow="ellipsize", grow_x=True, font_size=0.8, margin_left=1), margin=0.5)
|
||||||
|
for modifier in item.selected_modifiers
|
||||||
|
],
|
||||||
|
Row(Text(self.session[AccountingService].make_euro_string_from_decimal(item.final_unit_price), overflow="ellipsize", grow_x=True, margin_left=1, justify="center"), margin=0.5),
|
||||||
|
),
|
||||||
|
margin=0.5,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.primary_color
|
||||||
|
) for item in self.cart],
|
||||||
|
Spacer(),
|
||||||
|
Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Row(
|
||||||
|
Text(text="Preis:", overflow="nowrap"),
|
||||||
|
Text(text=self.get_cart_price(), overflow="ellipsize", grow_x=True, justify="right")
|
||||||
|
),
|
||||||
|
ElmButton(text="Bestellen", is_loading=self.order_button_loading, on_press=self.order_pressed),
|
||||||
|
Text(text=self.status_text, fill=self.session.theme.danger_color if self.status_is_error else self.session.theme.success_color, overflow="wrap", justify="center"),
|
||||||
|
margin=0.5,
|
||||||
|
spacing=1
|
||||||
|
),
|
||||||
|
margin=0.5,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.primary_color
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fill=self.session.theme.box_color,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color
|
||||||
|
)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from typing import Literal
|
from typing import Literal, Callable
|
||||||
|
|
||||||
from rio import Component, Rectangle, Column, Spacer, Text, Row, TextInput, FlowContainer
|
from rio import Component, Rectangle, Column, Spacer, Text
|
||||||
from rio.event import on_populate
|
from rio.event import on_populate
|
||||||
|
|
||||||
from elm.components import ElmButton, CateringItemBox
|
from elm.components import CateringItemBox
|
||||||
from elm.types.CateringTypes import CateringMenuItem, CateringMenuItemCategory
|
from elm.types.CateringTypes import CateringMenuItem, CateringMenuItemCategory
|
||||||
|
|
||||||
ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"], list[CateringMenuItemCategory]] = {
|
ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"], list[CateringMenuItemCategory]] = {
|
||||||
@@ -17,6 +17,7 @@ ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", "
|
|||||||
|
|
||||||
class CateringCategoryDisplay(Component):
|
class CateringCategoryDisplay(Component):
|
||||||
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"]
|
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"]
|
||||||
|
add_to_cart_pressed_callback: Callable
|
||||||
catering_menu_items: list[CateringMenuItem] = []
|
catering_menu_items: list[CateringMenuItem] = []
|
||||||
|
|
||||||
@on_populate
|
@on_populate
|
||||||
@@ -28,6 +29,10 @@ class CateringCategoryDisplay(Component):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
).to_list()
|
).to_list()
|
||||||
|
|
||||||
|
async def add_to_cart_pressed(self, item: CateringMenuItem, changed_options: dict[str, bool]) -> None:
|
||||||
|
await self.add_to_cart_pressed_callback(item, changed_options)
|
||||||
|
|
||||||
def build(self) -> Component:
|
def build(self) -> Component:
|
||||||
if len(self.catering_menu_items) <= 0:
|
if len(self.catering_menu_items) <= 0:
|
||||||
return Spacer()
|
return Spacer()
|
||||||
@@ -44,7 +49,7 @@ class CateringCategoryDisplay(Component):
|
|||||||
stroke_color=self.session.theme.box_border_color,
|
stroke_color=self.session.theme.box_border_color,
|
||||||
),
|
),
|
||||||
# Items here
|
# Items here
|
||||||
Column(*[CateringItemBox(i, margin=0.5, grow_y=True) for i in self.catering_menu_items]),
|
Column(*[CateringItemBox(item=i, add_to_cart_pressed_callback=self.add_to_cart_pressed, margin=0.5, grow_y=True) for i in self.catering_menu_items]),
|
||||||
Spacer()
|
Spacer()
|
||||||
),
|
),
|
||||||
fill=self.session.theme.box_color,
|
fill=self.session.theme.box_color,
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from rio import Component, Rectangle, Column, Text, Row, Separator, Color, Checkbox, FlowContainer, IconButton, Icon, Spacer
|
from rio import Component, Rectangle, Column, Text, Row, Separator, Color, Checkbox, FlowContainer, Icon, Spacer, CheckboxChangeEvent, PointerEventListener, PointerEvent, Dict, TextStyle
|
||||||
|
|
||||||
from elm.services import AccountingService
|
from elm.services import AccountingService
|
||||||
from elm.components import ElmButton
|
from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey, CateringModifierOption
|
||||||
from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey
|
|
||||||
|
|
||||||
|
|
||||||
class CateringItemBox(Component):
|
class CateringItemBox(Component):
|
||||||
item: CateringMenuItem
|
item: CateringMenuItem
|
||||||
|
add_to_cart_pressed_callback: Callable
|
||||||
|
changed_options: Dict[str, bool] = Dict()
|
||||||
|
|
||||||
def make_money_string(self, money: Decimal) -> str:
|
def make_money_string(self, money: Decimal) -> str:
|
||||||
return self.session[AccountingService].make_euro_string_from_decimal(money)
|
return self.session[AccountingService].make_euro_string_from_decimal(money)
|
||||||
|
|
||||||
|
def on_option_change(self, e: CheckboxChangeEvent, option: CateringModifierOption) -> None:
|
||||||
|
self.changed_options[option.key] = e.is_on
|
||||||
|
|
||||||
|
async def add_to_cart_pressed(self, _: PointerEvent) -> None:
|
||||||
|
if self.item.active:
|
||||||
|
await self.add_to_cart_pressed_callback(self.item, self.changed_options)
|
||||||
|
|
||||||
def build(self) -> Component:
|
def build(self) -> Component:
|
||||||
base_mods = []
|
base_mods = []
|
||||||
extra_mods = []
|
extra_mods = []
|
||||||
@@ -22,7 +31,7 @@ class CateringItemBox(Component):
|
|||||||
base_mods.append(Text("Basis:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.5))
|
base_mods.append(Text("Basis:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.5))
|
||||||
container = FlowContainer(spacing=2.5)
|
container = FlowContainer(spacing=2.5)
|
||||||
for option in modifier_group.options:
|
for option in modifier_group.options:
|
||||||
container.children.append(Row(Checkbox(is_on=option.default_selected), Text(option.label), spacing=0.6))
|
container.children.append(Row(Checkbox(is_on=self.changed_options.get(option.key, option.default_selected), on_change=lambda event, option=option: self.on_option_change(event, option)), Text(option.label), spacing=0.6))
|
||||||
base_mods.append(container)
|
base_mods.append(container)
|
||||||
if modifier_group.key == CateringModificationKey.EXTRA:
|
if modifier_group.key == CateringModificationKey.EXTRA:
|
||||||
extra_mods.append(Text("Extras:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.8))
|
extra_mods.append(Text("Extras:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.8))
|
||||||
@@ -31,7 +40,7 @@ class CateringItemBox(Component):
|
|||||||
text = f"{option.label}"
|
text = f"{option.label}"
|
||||||
if option.price_delta > Decimal("0"):
|
if option.price_delta > Decimal("0"):
|
||||||
text += f" (+ {self.make_money_string(option.price_delta)})"
|
text += f" (+ {self.make_money_string(option.price_delta)})"
|
||||||
container.children.append(Row(Checkbox(is_on=option.default_selected), Text(text), spacing=0.6))
|
container.children.append(Row(Checkbox(is_on=self.changed_options.get(option.key, option.default_selected), on_change=lambda event, option=option: self.on_option_change(event, option)), Text(text), spacing=0.6))
|
||||||
extra_mods.append(container)
|
extra_mods.append(container)
|
||||||
|
|
||||||
return Rectangle(
|
return Rectangle(
|
||||||
@@ -39,7 +48,7 @@ class CateringItemBox(Component):
|
|||||||
Row(
|
Row(
|
||||||
Column(
|
Column(
|
||||||
Row(
|
Row(
|
||||||
Text(text=self.item.name, overflow="nowrap", justify="left", font_size=1.1, margin_right=0.8, font_weight="bold", strikethrough=not self.item.active),
|
Text(text=self.item.name, overflow="nowrap", justify="left", font_size=1.1, margin_right=0.8, font_weight="bold", style=TextStyle(strikethrough=not self.item.active)),
|
||||||
Text(text=self.make_money_string(self.item.base_price), overflow="ellipsize", justify="left", font_size=0.8, grow_x=True, fill=self.session.theme.primary_color, align_y=1.2)
|
Text(text=self.make_money_string(self.item.base_price), overflow="ellipsize", justify="left", font_size=0.8, grow_x=True, fill=self.session.theme.primary_color, align_y=1.2)
|
||||||
),
|
),
|
||||||
Text(self.item.description, font_size=0.7, margin_left=2),
|
Text(self.item.description, font_size=0.7, margin_left=2),
|
||||||
@@ -51,11 +60,14 @@ class CateringItemBox(Component):
|
|||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
Spacer(),
|
Spacer(),
|
||||||
Rectangle(
|
PointerEventListener(
|
||||||
content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1),
|
content=Rectangle(
|
||||||
hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color,
|
content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1),
|
||||||
cursor="not-allowed" if not self.item.active else "pointer",
|
hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color,
|
||||||
transition_time=0.2
|
cursor="not-allowed" if not self.item.active else "pointer",
|
||||||
|
transition_time=0.2
|
||||||
|
),
|
||||||
|
on_press=self.add_to_cart_pressed
|
||||||
),
|
),
|
||||||
Spacer()
|
Spacer()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class UserNavigation(Component):
|
|||||||
return Rectangle(
|
return Rectangle(
|
||||||
content=Column(
|
content=Column(
|
||||||
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation),
|
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation),
|
||||||
|
UserNavigationButton("Meine Bestellungen", "/my-orders", self.close_navigation),
|
||||||
UserNavigationButton("Mein Profil", "/my-profile", self.close_navigation),
|
UserNavigationButton("Mein Profil", "/my-profile", self.close_navigation),
|
||||||
UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation),
|
UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation),
|
||||||
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
|
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ from .SeatingPlanPixels import *
|
|||||||
from .SeatingPlan import *
|
from .SeatingPlan import *
|
||||||
from .CateringItemBox import CateringItemBox
|
from .CateringItemBox import CateringItemBox
|
||||||
from .CateringCategoryDisplay import CateringCategoryDisplay
|
from .CateringCategoryDisplay import CateringCategoryDisplay
|
||||||
|
from .CateringCart import CateringCart
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
from typing import Literal
|
||||||
import io
|
|
||||||
from copy import copy
|
|
||||||
from typing import Any, Optional, Literal
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from rio import Component, Column, Row, Text, Spacer, page, Color, Rectangle, TextInput, GuardEvent, SwitcherBar, SwitcherBarChangeEvent
|
from rio import Component, Column, Row, Text, page, Rectangle, SwitcherBar, SwitcherBarChangeEvent, List
|
||||||
|
|
||||||
from elm.types import UserSession, User
|
from elm.components import CateringCategoryDisplay, CateringCart
|
||||||
from elm.types.CateringTypes import *
|
from elm.types.CateringTypes import CateringMenuItem, CateringOrderedItem
|
||||||
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
|
|
||||||
from elm.components import ElmButton, CateringCategoryDisplay
|
|
||||||
|
|
||||||
|
|
||||||
@page(name="Catering", url_segment="catering")
|
@page(name="Catering", url_segment="catering")
|
||||||
class CateringPage(Component):
|
class CateringPage(Component):
|
||||||
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] = "Hauptspeisen"
|
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] = "Hauptspeisen"
|
||||||
|
cart: List[CateringOrderedItem] = List()
|
||||||
|
|
||||||
async def on_switcher_bar_change(self, event: SwitcherBarChangeEvent) -> None:
|
async def on_switcher_bar_change(self, event: SwitcherBarChangeEvent) -> None:
|
||||||
self.active_category = event.value
|
self.active_category = event.value
|
||||||
print(event)
|
|
||||||
|
async def add_to_cart_pressed(self, item: CateringMenuItem, changed_options: dict[str, bool]) -> None:
|
||||||
|
order_item = CateringOrderedItem.from_menu_item(item, changed_options)
|
||||||
|
self.cart.append(order_item)
|
||||||
|
|
||||||
def build(self) -> Component:
|
def build(self) -> Component:
|
||||||
return Row(
|
return Row(
|
||||||
@@ -37,7 +35,7 @@ class CateringPage(Component):
|
|||||||
stroke_width=0.1,
|
stroke_width=0.1,
|
||||||
stroke_color=self.session.theme.box_border_color
|
stroke_color=self.session.theme.box_border_color
|
||||||
),
|
),
|
||||||
CateringCategoryDisplay(active_category=self.active_category, grow_y=True),
|
CateringCategoryDisplay(active_category=self.active_category, add_to_cart_pressed_callback=self.add_to_cart_pressed, grow_y=True),
|
||||||
grow_x=True,
|
grow_x=True,
|
||||||
spacing=1
|
spacing=1
|
||||||
),
|
),
|
||||||
@@ -58,18 +56,9 @@ class CateringPage(Component):
|
|||||||
stroke_width=0.1,
|
stroke_width=0.1,
|
||||||
stroke_color=self.session.theme.box_border_color
|
stroke_color=self.session.theme.box_border_color
|
||||||
),
|
),
|
||||||
Rectangle(
|
CateringCart(cart=self.cart, grow_y=True),
|
||||||
content=Column(
|
|
||||||
Text("ToDo", margin=1),
|
|
||||||
Spacer()
|
|
||||||
),
|
|
||||||
fill=self.session.theme.box_color,
|
|
||||||
stroke_width=0.1,
|
|
||||||
stroke_color=self.session.theme.box_border_color,
|
|
||||||
grow_y=True
|
|
||||||
),
|
|
||||||
spacing=1,
|
spacing=1,
|
||||||
min_width=18
|
min_width=20
|
||||||
),
|
),
|
||||||
spacing=1,
|
spacing=1,
|
||||||
margin=1
|
margin=1
|
||||||
|
|||||||
@@ -124,6 +124,52 @@ class CateringOrderedItem(MongoDecimalModel):
|
|||||||
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_menu_item(cls, menu_item: CateringMenuItem, changed_modifiers: dict[str, bool], quantity: int = 1) -> "CateringOrderedItem":
|
||||||
|
selected_modifiers: list[CateringSelectedModifier] = []
|
||||||
|
|
||||||
|
for modifier_group in menu_item.modifier_groups:
|
||||||
|
for option in modifier_group.options:
|
||||||
|
selected = changed_modifiers.get(option.key)
|
||||||
|
|
||||||
|
if selected is not None:
|
||||||
|
|
||||||
|
selected_modifiers.append(
|
||||||
|
CateringSelectedModifier(
|
||||||
|
group_key=modifier_group.key,
|
||||||
|
option_key=option.key,
|
||||||
|
label=option.label,
|
||||||
|
selected=selected,
|
||||||
|
price_delta=option.price_delta
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
final_unit_price = cls._calculate_final_unit_price(
|
||||||
|
menu_item.base_price,
|
||||||
|
selected_modifiers
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
menu_item_id=menu_item.id,
|
||||||
|
name=menu_item.name,
|
||||||
|
quantity=quantity,
|
||||||
|
base_price=menu_item.base_price,
|
||||||
|
selected_modifiers=selected_modifiers,
|
||||||
|
final_unit_price=final_unit_price
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_final_unit_price(base_price: Decimal, modifiers: list[CateringSelectedModifier]) -> Decimal:
|
||||||
|
|
||||||
|
return (
|
||||||
|
base_price +
|
||||||
|
sum(
|
||||||
|
modifier.price_delta
|
||||||
|
for modifier in modifiers
|
||||||
|
if modifier.selected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
class CateringOrder(Document):
|
class CateringOrder(Document):
|
||||||
customer_id: PydanticObjectId
|
customer_id: PydanticObjectId
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user