My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + _select_my_sea_token / debit_my_sea_token helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD
First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1178,3 +1178,380 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
# ── Sprint 6 — my-sea gatekeeper (token-deposit-to-redraw within 24h) ─────────
|
||||
# FT skeleton written TDD-first per user spec 2026-05-20. See
|
||||
# [[sprint-my-sea-iter-6-plan]] for the full spec; iters 6a/6b/6c break
|
||||
# the work into three commits but the FTs describe the user-facing
|
||||
# behavior end-to-end so the impl can converge against them.
|
||||
|
||||
|
||||
class MySeaGatekeeperPageTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6a — `/gameboard/my-sea/gate/` renders the solo
|
||||
gatekeeper UI. Coin-slot rails (INSERT TOKEN TO PLAY) + 6-chair hex
|
||||
(seat 1 always reserved for owner, others banned). One-token-per-
|
||||
draw, refundable until PAID DRAW commits."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "gate@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
# Seed a FREE token so the gatekeeper has something to deposit.
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone as dj_tz
|
||||
from apps.lyric.models import Token
|
||||
Token.objects.create(
|
||||
user=self.gamer, token_type=Token.FREE,
|
||||
expires_at=dj_tz.now() + timedelta(days=30),
|
||||
)
|
||||
|
||||
def _save_empty_hand_draw(self):
|
||||
"""Quota-spent state: an active MySeaDraw row w. empty hand
|
||||
(post-DEL or post-completion-DEL). This is the canonical state
|
||||
where the gatekeeper is meaningful."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
return MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
)
|
||||
|
||||
def test_gatekeeper_page_renders_token_rails_in_empty_state(self):
|
||||
"""No deposit yet → coin-slot shows INSERT TOKEN TO PLAY + the
|
||||
rails are active (click-target). No refund btn yet."""
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
rails_form = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "form[action$='/my-sea/insert']"
|
||||
)
|
||||
)
|
||||
rails_form.find_element(By.CSS_SELECTOR, "button.token-rails")
|
||||
# No refund btn or PAID DRAW yet.
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_gatekeeper_renders_six_chair_seats_with_seat1_seated(self):
|
||||
"""Hex w. 6 chair seats; seat 1 is the owner's (always `.seated`
|
||||
when quota is committed); seats 2-6 carry `.fa-ban` (placeholders
|
||||
for the future friend-invite feature)."""
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
self.wait_for(
|
||||
lambda: self._assert_seats(6)
|
||||
)
|
||||
seat1 = self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
)
|
||||
self.assertIn("seated", seat1.get_attribute("class"))
|
||||
seat1.find_element(By.CSS_SELECTOR, ".fa-circle-check")
|
||||
|
||||
def _assert_seats(self, count):
|
||||
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||||
if len(seats) != count:
|
||||
raise AssertionError(f"expected {count} seats, got {len(seats)}")
|
||||
return seats
|
||||
|
||||
def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self):
|
||||
"""Click INSERT TOKEN → server reserves the user's next-priority
|
||||
token on the MySeaDraw row; gatekeeper re-renders w. refund btn
|
||||
+ `#id_my_sea_paid_draw_btn` visible."""
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
rails = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/insert'] button.token-rails",
|
||||
)
|
||||
)
|
||||
rails.click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)
|
||||
)
|
||||
# Refund affordance is now present.
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
|
||||
)
|
||||
# Server-side: MySeaDraw row has deposit_token_id set.
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||||
self.assertIsNotNone(draw.deposit_token_id)
|
||||
|
||||
def test_refund_clears_deposit_and_returns_to_empty_state(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self._save_empty_hand_draw()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/insert'] button.token-rails",
|
||||
)
|
||||
).click()
|
||||
refund = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/refund'] button",
|
||||
)
|
||||
)
|
||||
refund.click()
|
||||
# After refund, INSERT TOKEN form is back; PAID DRAW gone.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/insert']",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||||
self.assertIsNone(draw.deposit_token_id)
|
||||
|
||||
def test_paid_draw_commits_token_and_redirects_to_picker(self):
|
||||
"""PAID DRAW commits the deposited token (FREE token gets
|
||||
consumed → user's token count drops by 1); server resets the
|
||||
MySeaDraw row (hand=[], created_at=now, deposit cleared); user
|
||||
lands back on /gameboard/my-sea/ ready to draw a fresh hand."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
self._save_empty_hand_draw()
|
||||
free_count_before = Token.objects.filter(
|
||||
user=self.gamer, token_type=Token.FREE,
|
||||
).count()
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
"form[action$='/my-sea/insert'] button.token-rails",
|
||||
)
|
||||
).click()
|
||||
paid_draw = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)
|
||||
)
|
||||
paid_draw.click()
|
||||
# Redirect lands on /gameboard/my-sea/ (landing or picker).
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("/gameboard/my-sea/", self.browser.current_url)
|
||||
)
|
||||
# FREE token consumed.
|
||||
self.assertEqual(
|
||||
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(),
|
||||
free_count_before - 1,
|
||||
)
|
||||
# MySeaDraw row: hand reset to empty, deposit cleared, fresh quota.
|
||||
draw = MySeaDraw.objects.get(user=self.gamer)
|
||||
self.assertEqual(draw.hand, [])
|
||||
self.assertIsNone(draw.deposit_token_id)
|
||||
|
||||
|
||||
class MySeaLandingPaidDrawTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6b — landing center-btn state machine extended w.
|
||||
PAID DRAW. After depositing in the gatekeeper, refresh / navigate
|
||||
back to /gameboard/my-sea/ → landing renders PAID DRAW (not GATE
|
||||
VIEW) at the hex center."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "landpaid@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def test_landing_shows_paid_draw_btn_when_deposit_reserved(self):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone as dj_tz
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.lyric.models import Token
|
||||
free_tok = Token.objects.create(
|
||||
user=self.gamer, token_type=Token.FREE,
|
||||
expires_at=dj_tz.now() + timedelta(days=30),
|
||||
)
|
||||
MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
deposit_token_id=free_tok.pk,
|
||||
deposit_reserved_at=dj_tz.now(),
|
||||
)
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
|
||||
)
|
||||
)
|
||||
# FREE DRAW + GATE VIEW are NOT shown when a deposit is reserved.
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class MySeaNavbarGateViewTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6b — navbar CONT GAME swaps to GATE VIEW whenever
|
||||
the user is on `body.page-my-sea`. Always reachable, regardless of
|
||||
quota state — clicking takes the user to /gameboard/my-sea/gate/."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "navgate@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def test_navbar_renders_gate_view_btn_on_my_sea_page(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
nav = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||
)
|
||||
# GATE VIEW btn present, CONT GAME btn not.
|
||||
nav.find_element(By.CSS_SELECTOR, "#id_navbar_gate_view_btn")
|
||||
self.assertEqual(
|
||||
len(nav.find_elements(By.CSS_SELECTOR, "#id_navbar_cont_game_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class MySeaSeatOnePersistenceTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6b — seat 1 (the owner's reserved chair) renders
|
||||
`.seated` whenever the user's hand is non-empty (mid-draw or
|
||||
complete). DEL empties the hand → seat 1 reverts to `.fa-ban`."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "seat1@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
|
||||
def test_seat_1_banned_for_fresh_user_no_quota(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
seat1 = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
)
|
||||
)
|
||||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||||
seat1.find_element(By.CSS_SELECTOR, ".fa-ban")
|
||||
|
||||
def test_seat_1_banned_when_active_draw_has_empty_hand(self):
|
||||
"""DEL leaves the row but wipes the hand; seat 1 reverts to
|
||||
banned (per user spec 2026-05-20: seat 1 tied to hand non-empty,
|
||||
NOT to active_draw existence)."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
)
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
|
||||
seat1 = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat[data-slot='1']"
|
||||
)
|
||||
)
|
||||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||||
|
||||
|
||||
class MySeaBudBtnStubTest(FunctionalTest):
|
||||
"""Sprint 6 iter 6c — bud-btn invite panel rendered on the
|
||||
gatekeeper. Panel opens + autocomplete works (reuses billboard:
|
||||
search_buds), but the OK btn is a no-op stub — POSTs return a
|
||||
'Multiplayer my-sea coming soon' Brief banner. Async invite is
|
||||
deferred to a future sprint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_seed_earthman_sig_pile()
|
||||
_seed_gameboard_applets()
|
||||
self.email = "bud@test.io"
|
||||
self.gamer = User.objects.create(email=self.email)
|
||||
_assign_sig(self.gamer)
|
||||
# Seed a quota row so the gatekeeper has context.
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
MySeaDraw.objects.create(
|
||||
user=self.gamer, spread="situation-action-outcome",
|
||||
significator_id=self.gamer.significator_id, hand=[],
|
||||
)
|
||||
|
||||
def test_bud_btn_panel_opens_on_gatekeeper(self):
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
bud_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||||
)
|
||||
bud_btn.click()
|
||||
# Panel opens — html.bud-open class is added.
|
||||
self.wait_for(
|
||||
lambda: self.assertIn(
|
||||
"bud-open",
|
||||
self.browser.find_element(By.TAG_NAME, "html").get_attribute("class"),
|
||||
)
|
||||
)
|
||||
self.browser.find_element(By.ID, "id_recipient")
|
||||
|
||||
def test_bud_btn_ok_renders_coming_soon_brief(self):
|
||||
from apps.lyric.models import User as _U
|
||||
# Seed a friend so the OK click has a recipient to "invite".
|
||||
_U.objects.create(email="friend@test.io")
|
||||
self.create_pre_authenticated_session(self.email)
|
||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||
bud_btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_bud_btn")
|
||||
)
|
||||
bud_btn.click()
|
||||
recipient = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_recipient")
|
||||
)
|
||||
# Wait for slide-out animation to settle (per
|
||||
# [[feedback-css-transition-selenium-click-race]]).
|
||||
self.wait_for(
|
||||
lambda: self.assertGreater(
|
||||
self.browser.execute_script(
|
||||
"return document.getElementById('id_bud_panel').getBoundingClientRect().width;"
|
||||
),
|
||||
100,
|
||||
)
|
||||
)
|
||||
recipient.send_keys("friend@test.io")
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||
).click()
|
||||
# Brief banner appears w. coming-soon copy.
|
||||
brief = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
|
||||
)
|
||||
self.assertIn("coming soon", brief.text.lower())
|
||||
|
||||
Reference in New Issue
Block a user