feat(fifa17): project owned kits with active home/away designation
Closes the server side of the FUT kit selector. Ownership stays generic in
Core (submodule bump: club_kit_assignments + GET/PUT /club/kits); this
commit adds the FIFA17 representation, the host projection and importer
support.
adapter:
* ContentKind::Kit ("kit") so kits are classified alongside player/staff/
consumable instead of being mistaken for 0-rated players.
* Fifa17CardIdentity carries card_asset_id and team_id; RawCard keeps both
optional because the emitted catalog writes null for non-kit definitions.
* shape_kit_item emits only the fields the client's kit path reads
(id/resourceId/assetId/cardassetid/cardsubtypeid/itemState/owners/
untradeable/teamid) — no attributeList, no itemType.
* itemState on the wire is the STRING token activeHomeKit/activeAwayKit;
the 101/102 integers are the client's post-deserialisation runtime enum
(item+0x5c) and are never emitted.
* club_stats S_KITS (0x28) now counts owned kits instead of a hard zero.
host:
* CoreKitAssignments + CoreAccess::get_active_kits (GET /club/kits),
defaulting to no active kits so a Core without the endpoint degrades
instead of fabricating a designation.
* handle_club classifies type=player|kit, rejects any other type with an
empty page and outcome=unsupported_type, and now always fetches
unpaginated from Core: kind and transfer-pile membership are host-side
concepts Core cannot express, so filtering and pagination must both
happen after shaping or pages come back short.
importer:
* ItemClass::Kit (cardsubtypeid == 9 and resourceId in 6_300_000..=6_400_654),
kit counts/balances, and card_asset_id/team_id carried into the emitted
catalog and manifest.
* a kit group missing cardassetid == 35 or teamid is DEFERRED
(missing_kit_render_metadata) rather than defaulted; conflicting render
metadata across instances defers as render_metadata_conflict.
staging: sold-staging-up.py seeds two owned kits (6300006 home / 6400003
away, team 21) plus both active designations so the projection can be
verified over HTTP before involving the client.
This commit is contained in:
+149
-106
@@ -51,7 +51,8 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use openfut_adapter_fifa17::fut::catalog::{Fifa17CardCatalog, Fifa17WireItemIdPolicy};
|
||||
use openfut_adapter_fifa17::fut::club_response::{
|
||||
shape_club_response, CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats,
|
||||
shape_club_response_with_kits, ActiveKitAssignments, CoreOwnedItem, Fifa17Identity,
|
||||
Fifa17KitIdentity, ItemIdentityResolver,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::club_stats::{club_stats_body, ClubStatInput, ContextField};
|
||||
use openfut_adapter_fifa17::fut::content_taxonomy::ContentKind;
|
||||
@@ -636,6 +637,13 @@ pub struct CoreReplaceResult {
|
||||
pub slots_written: usize,
|
||||
}
|
||||
|
||||
/// Core owned-instance ids assigned to the club's two active kit roles.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CoreKitAssignments {
|
||||
pub home_owned_card_id: Option<String>,
|
||||
pub away_owned_card_id: Option<String>,
|
||||
}
|
||||
|
||||
/// How the host reaches Core. The adapter never sees this — the host owns the
|
||||
/// transport, mirroring the architecture rule. Tests inject a fake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -698,6 +706,12 @@ pub trait CoreAccess: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// Ownership-backed active home/away kit ids (`GET /club/kits`). A Core
|
||||
/// without the endpoint projects no active kits rather than fabricating one.
|
||||
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
|
||||
Ok(CoreKitAssignments::default())
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
Err(CoreError::Parse(
|
||||
"Core SBC access is not implemented".into(),
|
||||
@@ -872,6 +886,32 @@ impl CoreAccess for HttpCoreClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_active_kits(&self) -> Result<CoreKitAssignments, CoreError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{}/club/kits", self.base_url))
|
||||
.header("X-OpenFUT-Game", &self.game)
|
||||
.send()
|
||||
.map_err(|error| CoreError::Http(error.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(CoreError::Status(status));
|
||||
}
|
||||
let body: Value = response
|
||||
.json()
|
||||
.map_err(|error| CoreError::Parse(error.to_string()))?;
|
||||
let owned_id = |slot: &str| {
|
||||
body.get(slot)
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
Ok(CoreKitAssignments {
|
||||
home_owned_card_id: owned_id("home"),
|
||||
away_owned_card_id: owned_id("away"),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_sbcs(&self) -> Result<Vec<CoreSbcDefinition>, CoreError> {
|
||||
let response = self
|
||||
.client
|
||||
@@ -1608,6 +1648,24 @@ impl Fifa17IdentityResolver {
|
||||
pub fn definition_identity(&self, card_id: &str) -> Option<(i64, ContentKind)> {
|
||||
self.catalog.lookup(card_id).map(|c| (c.rareflag, c.kind))
|
||||
}
|
||||
|
||||
fn wire_for(&self, item: &CoreOwnedItem) -> Option<u32> {
|
||||
match self.store.resolve_or_allocate(
|
||||
Fifa17WireItemIdPolicy::GAME,
|
||||
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||
&item.owned_card_id,
|
||||
Fifa17WireItemIdPolicy::owned_item_base_floor(),
|
||||
) {
|
||||
Ok(wire) => Some(wire as u32),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"utas-host ERROR identity store alloc failed for {}: {error}",
|
||||
item.owned_card_id
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
@@ -1615,36 +1673,32 @@ impl ItemIdentityResolver for Fifa17IdentityResolver {
|
||||
// Definition identity first: an unmapped card is dropped (never faked).
|
||||
let ident = self.catalog.lookup(&item.card_id)?;
|
||||
// Instance identity: stable, persistent, reversible wire id.
|
||||
let wire = match self.store.resolve_or_allocate(
|
||||
Fifa17WireItemIdPolicy::GAME,
|
||||
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||
&item.owned_card_id,
|
||||
Fifa17WireItemIdPolicy::owned_item_base_floor(),
|
||||
) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
// Infrastructure failure allocating a wire id: drop this item
|
||||
// (freeze-safe) and log — never emit an unstable/fake id.
|
||||
eprintln!(
|
||||
"utas-host ERROR identity store alloc failed for {}: {e}",
|
||||
item.owned_card_id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let wire = self.wire_for(item)?;
|
||||
Some(Fifa17Identity {
|
||||
// Wire ids live in 1e8..9e8 (policy) — well within u32.
|
||||
item_id: wire as u32,
|
||||
item_id: wire,
|
||||
asset_id: ident.asset_id,
|
||||
resource_id: ident.resource_id,
|
||||
rareflag: ident.rareflag,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delegate content classification to the catalog so `/club` excludes
|
||||
/// consumable/staff cards (they must never render as 0-rated players). An
|
||||
/// unmapped card_id resolves to `Player` (the catalog default) but is already
|
||||
/// dropped by `resolve` returning `None`, so it is never emitted anyway.
|
||||
fn resolve_kit(&self, item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
let ident = self.catalog.lookup(&item.card_id)?;
|
||||
if ident.kind != ContentKind::Kit {
|
||||
return None;
|
||||
}
|
||||
Some(Fifa17KitIdentity {
|
||||
item_id: self.wire_for(item)?,
|
||||
asset_id: ident.asset_id,
|
||||
resource_id: ident.resource_id,
|
||||
card_asset_id: ident.card_asset_id,
|
||||
subtype: ident.subtype,
|
||||
team_id: ident.team_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delegate content classification to the catalog. Unknown definitions
|
||||
/// retain the backward-compatible Player default but fail identity resolution.
|
||||
fn kind_of(&self, item: &CoreOwnedItem) -> ContentKind {
|
||||
self.catalog.kind_of(&item.card_id)
|
||||
}
|
||||
@@ -1684,6 +1738,7 @@ pub struct ClubDeps<'a> {
|
||||
/// ACTIVE transfer-market listing. In FIFA a listed card has LEFT the club, so
|
||||
/// it must not also appear here. Empty = show everything Core owns.
|
||||
pub hidden: &'a std::collections::HashSet<String>,
|
||||
pub active_kits: &'a CoreKitAssignments,
|
||||
}
|
||||
|
||||
/// Filter already-shaped `/club` items to SPECIALS (`rareflag > 1`) and paginate
|
||||
@@ -1742,10 +1797,27 @@ fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (
|
||||
/// JSON body (UTAS must never 401/403; an empty result is the safe degrade).
|
||||
pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog) {
|
||||
let raw = parse_club_query(query);
|
||||
let requested_kind = match raw.item_type.as_deref() {
|
||||
None | Some("player") => ContentKind::Player,
|
||||
Some("kit") => ContentKind::Kit,
|
||||
Some(other) => {
|
||||
return (
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
outcome: "unsupported_type",
|
||||
filter: format!("type={other}"),
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
offset: raw.start.map(|value| value as i64),
|
||||
limit: raw.count.map(|value| value as i64),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
let core_q = match map_to_core(&raw, deps.entities) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
// Unknown FIFA id — never a raw-id passthrough, never a guess.
|
||||
return (
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
@@ -1754,104 +1826,70 @@ pub fn handle_club(query: &str, deps: &ClubDeps<'_>) -> (WireResponse, ClubLog)
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
offset: raw.start.map(|s| s as i64),
|
||||
limit: raw.count.map(|c| c as i64),
|
||||
offset: raw.start.map(|value| value as i64),
|
||||
limit: raw.count.map(|value| value as i64),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
let pairs = core_q.to_query_pairs();
|
||||
let filter = summarize(&pairs);
|
||||
let (offset, limit) = (core_q.offset, core_q.limit);
|
||||
|
||||
// Host-side filters Core cannot express:
|
||||
// * "Special" (rare=SP): rareflag lives in the FIFA catalog, not Core.
|
||||
// * listed-card exclusion: transfer-market listings are host-owned state.
|
||||
// Either way Core must NOT paginate — it would paginate the unfiltered set
|
||||
// and return short pages. So fetch everything matching the OTHER filters,
|
||||
// exclude/shape locally, then paginate the filtered set here. When neither
|
||||
// applies (the common case) the fast Core-paginated path below is unchanged.
|
||||
if core_q.special || !deps.hidden.is_empty() {
|
||||
// Log which host-side filter forced local pagination.
|
||||
let local_desc = match (core_q.special, deps.hidden.len()) {
|
||||
(true, 0) => format!("{filter},rare=SP"),
|
||||
(true, n) => format!("{filter},rare=SP,hidden={n}"),
|
||||
(false, n) => format!("{filter},hidden={n}"),
|
||||
};
|
||||
let mut base = core_q.clone();
|
||||
base.offset = None;
|
||||
base.limit = None;
|
||||
return match deps.core.query_owned(&base.to_query_pairs()) {
|
||||
Ok(page) => {
|
||||
// Exclude hidden instances BEFORE shaping: a card on the transfer
|
||||
// list is not in the club, so it must not consume a page slot.
|
||||
let visible: Vec<CoreOwnedItem> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|it| !deps.hidden.contains(&it.owned_card_id))
|
||||
.collect();
|
||||
let (body, stats) = shape_club_response(&visible, deps.entities, deps.assets);
|
||||
let all = body
|
||||
.get("itemData")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (paged, total) = if core_q.special {
|
||||
special_filter_page(&all, offset, limit)
|
||||
} else {
|
||||
paginate_items(&all, offset, limit)
|
||||
};
|
||||
let emitted = paged.len();
|
||||
(
|
||||
json_response(&json!({ "itemData": paged })),
|
||||
ClubLog {
|
||||
outcome: "ok",
|
||||
filter: local_desc,
|
||||
total,
|
||||
emitted,
|
||||
dropped_no_asset: stats.dropped_no_asset,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("utas-host ERROR /club (local-filter) core query failed: {e}");
|
||||
(
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
outcome: "core_error",
|
||||
filter: local_desc,
|
||||
total: 0,
|
||||
emitted: 0,
|
||||
dropped_no_asset: 0,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut base = core_q.clone();
|
||||
base.offset = None;
|
||||
base.limit = None;
|
||||
let mut filter = summarize(&base.to_query_pairs());
|
||||
if !filter.is_empty() {
|
||||
filter.push(',');
|
||||
}
|
||||
match deps.core.query_owned(&pairs) {
|
||||
filter.push_str(&format!("type={}", requested_kind.as_str()));
|
||||
if core_q.special {
|
||||
filter.push_str(",rare=SP");
|
||||
}
|
||||
if !deps.hidden.is_empty() {
|
||||
filter.push_str(&format!(",hidden={}", deps.hidden.len()));
|
||||
}
|
||||
|
||||
match deps.core.query_owned(&base.to_query_pairs()) {
|
||||
Ok(page) => {
|
||||
let (body, stats): (Value, ShapeStats) =
|
||||
shape_club_response(&page.items, deps.entities, deps.assets);
|
||||
// Kind and transfer-pile membership live outside generic Core, so
|
||||
// filtering and pagination must happen here over the final set.
|
||||
let visible: Vec<CoreOwnedItem> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|item| !deps.hidden.contains(&item.owned_card_id))
|
||||
.filter(|item| deps.assets.kind_of(item) == requested_kind)
|
||||
.collect();
|
||||
let active = ActiveKitAssignments {
|
||||
home: deps.active_kits.home_owned_card_id.as_deref(),
|
||||
away: deps.active_kits.away_owned_card_id.as_deref(),
|
||||
};
|
||||
let (body, stats) =
|
||||
shape_club_response_with_kits(&visible, deps.entities, deps.assets, active);
|
||||
let all = body
|
||||
.get("itemData")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let (paged, total) = if core_q.special {
|
||||
special_filter_page(&all, offset, limit)
|
||||
} else {
|
||||
paginate_items(&all, offset, limit)
|
||||
};
|
||||
let emitted = paged.len();
|
||||
(
|
||||
json_response(&body),
|
||||
json_response(&json!({ "itemData": paged })),
|
||||
ClubLog {
|
||||
outcome: "ok",
|
||||
filter,
|
||||
total: page.total,
|
||||
emitted: stats.emitted,
|
||||
total,
|
||||
emitted,
|
||||
dropped_no_asset: stats.dropped_no_asset,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
// Degrade to a valid empty page; DO NOT fall back to Python.
|
||||
eprintln!("utas-host ERROR /club core query failed: {e}");
|
||||
Err(error) => {
|
||||
eprintln!("utas-host ERROR /club core query failed: {error}");
|
||||
(
|
||||
json_response(&json!({ "itemData": [] })),
|
||||
ClubLog {
|
||||
@@ -3382,11 +3420,16 @@ impl Server {
|
||||
Route::Club => {
|
||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||
let hidden = self.club_hidden_ids();
|
||||
let active_kits = self.core.get_active_kits().unwrap_or_else(|error| {
|
||||
eprintln!("utas-host WARN active kit read unavailable: {error}");
|
||||
CoreKitAssignments::default()
|
||||
});
|
||||
let deps = ClubDeps {
|
||||
core: self.core.as_ref(),
|
||||
entities: self.entities.as_ref(),
|
||||
assets: self.resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp, log) = handle_club(query, &deps);
|
||||
eprintln!(
|
||||
|
||||
@@ -15,9 +15,9 @@ use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::account_store::AccountStore;
|
||||
use openfut_utas_host::{
|
||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||
CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState,
|
||||
CoreKitAssignments, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead,
|
||||
CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -338,11 +338,13 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
|
||||
// oc2 has an active transfer-market listing.
|
||||
let hidden: std::collections::HashSet<String> = ["oc2".to_string()].into_iter().collect();
|
||||
let active_kits = CoreKitAssignments::default();
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp, log) = handle_club("", &deps);
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
@@ -370,13 +372,14 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
);
|
||||
assert_eq!(log2.total, 2);
|
||||
|
||||
// Nothing hidden → the fast Core-paginated path, all three visible.
|
||||
// Nothing hidden → all three player items remain visible.
|
||||
let none: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let deps_all = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &none,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
let (resp3, log3) = handle_club("", &deps_all);
|
||||
let v3: Value = serde_json::from_slice(&resp3.body).unwrap();
|
||||
@@ -384,6 +387,67 @@ fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||
assert_eq!(log3.total, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_projects_only_owned_kits_with_active_designations() {
|
||||
let core = Arc::new(FakeCore::new(
|
||||
vec![
|
||||
item(
|
||||
"player",
|
||||
"card_player",
|
||||
90,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("home", "kit_home", 0, "", "", "", ""),
|
||||
item("away", "kit_away", 0, "", "", "", ""),
|
||||
],
|
||||
3,
|
||||
));
|
||||
let catalog = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_player":{"asset_id":20801},
|
||||
"kit_home":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0},
|
||||
"kit_away":{"asset_id":6400003,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let resolver = Arc::new(Fifa17IdentityResolver::new(
|
||||
catalog,
|
||||
Arc::new(JsonIdentityStore::open(unique_store_path()).unwrap()),
|
||||
));
|
||||
let ents = entities();
|
||||
let hidden = std::collections::HashSet::new();
|
||||
let active_kits = CoreKitAssignments {
|
||||
home_owned_card_id: Some("home".into()),
|
||||
away_owned_card_id: Some("away".into()),
|
||||
};
|
||||
let deps = ClubDeps {
|
||||
core: core.as_ref(),
|
||||
entities: &ents,
|
||||
assets: resolver.as_ref(),
|
||||
hidden: &hidden,
|
||||
active_kits: &active_kits,
|
||||
};
|
||||
|
||||
let (kit_response, kit_log) = handle_club("type=kit", &deps);
|
||||
let kits: Value = serde_json::from_slice(&kit_response.body).unwrap();
|
||||
assert_eq!(kit_log.total, 2);
|
||||
assert_eq!(kits["itemData"][0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(kits["itemData"][1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(kits["itemData"][0]["cardassetid"], 35);
|
||||
assert_eq!(kits["itemData"][0]["teamid"], 21);
|
||||
|
||||
let (player_response, player_log) = handle_club("type=player", &deps);
|
||||
let players: Value = serde_json::from_slice(&player_response.body).unwrap();
|
||||
assert_eq!(player_log.total, 1);
|
||||
assert_eq!(players["itemData"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(players["itemData"][0]["itemType"], "player");
|
||||
}
|
||||
|
||||
// ── /club served from Core ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user