feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Frontend - i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css. - Language toggle: localize aria-label / menu label, persist choice, update document dir synchronously. - Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass side = i18n.dir() === 'rtl' ? 'right' : 'left'. - AdminLmsLayout: convert every nav item from hard-coded title to titleKey, translate group labels (incl. the collapsible Training), breadcrumbs, user menu (Profile / Settings / Logout), help button and toggle aria labels; replace physical mr-/right- utilities with logical me-/end-. - AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar, AiAssistantDrawer): apply dir="auto" at the container level, localize titles, loading / error / empty states. - Dashboards (admin / student / teacher): wrap numeric values in <bdi>, localize dates via ar-EG, fix flex direction for KPI and assignment cards. - UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar, context-menu, pagination, sidebar): flip chevrons in RTL via a scoped CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-. - Add logical-direction helpers and bidirectional isolation classes. Locales - Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`, `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity. API client - `api-client.ts` reads the active language from localStorage/i18n and sends `Accept-Language` on every request so the backend can localize AI output. Backend (encoach_ai) - openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware system prompt injection for every OpenAI call. - coach_service + controllers (coach_controller, ai_controller): thread the requested language from headers / user locale down to OpenAIService. - ai_feedback: fix latent registry error by pointing course_id at op.course instead of the non-existent encoach.course. Other - .gitignore: ignore runtime odoo logs and local caches. Made-with: Cursor
This commit is contained in:
@@ -24,6 +24,25 @@ def _get_json():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_language():
|
||||||
|
"""Return the caller's UI language from ``Accept-Language``.
|
||||||
|
|
||||||
|
The frontend ``api-client`` forwards the active i18n language (e.g. ``ar``
|
||||||
|
or ``en``) via this header so AI-generated natural-language strings can
|
||||||
|
be returned in the same language as the UI chrome.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return request.httprequest.headers.get("Accept-Language", "") or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_for_request():
|
||||||
|
"""Construct an OpenAIService bound to the caller's UI language."""
|
||||||
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
|
return OpenAIService(request.env, language=_request_language())
|
||||||
|
|
||||||
|
|
||||||
class AIController(http.Controller):
|
class AIController(http.Controller):
|
||||||
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
||||||
|
|
||||||
@@ -37,7 +56,7 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"answer": "", "suggestions": []})
|
return _json_response({"answer": "", "suggestions": []})
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
result = ai.search_with_rag(query, context=body.get("context", ""))
|
result = ai.search_with_rag(query, context=body.get("context", ""))
|
||||||
return _json_response(result)
|
return _json_response(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -69,7 +88,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
result = ai.generate_insights(
|
result = ai.generate_insights(
|
||||||
body.get("data", {}),
|
body.get("data", {}),
|
||||||
insight_type=body.get("type", "general"),
|
insight_type=body.get("type", "general"),
|
||||||
@@ -85,7 +104,7 @@ class AIController(http.Controller):
|
|||||||
def ai_alerts(self, **kw):
|
def ai_alerts(self, **kw):
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
context = request.params.get("context", "dashboard")
|
context = request.params.get("context", "dashboard")
|
||||||
result = ai.generate_insights(
|
result = ai.generate_insights(
|
||||||
{"context": context, "request": "alerts"},
|
{"context": context, "request": "alerts"},
|
||||||
@@ -103,7 +122,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
narrative = ai.generate_report_narrative(
|
narrative = ai.generate_report_narrative(
|
||||||
body.get("report_type", "performance"),
|
body.get("report_type", "performance"),
|
||||||
body.get("data", {}),
|
body.get("data", {}),
|
||||||
@@ -119,7 +138,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
result = ai.batch_optimize(
|
result = ai.batch_optimize(
|
||||||
body.get("items", []),
|
body.get("items", []),
|
||||||
optimization_type=body.get("type", "schedule"),
|
optimization_type=body.get("type", "schedule"),
|
||||||
@@ -135,7 +154,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
skill = body.get("skill", "writing")
|
skill = body.get("skill", "writing")
|
||||||
if skill == "speaking":
|
if skill == "speaking":
|
||||||
result = ai.grade_speaking(
|
result = ai.grade_speaking(
|
||||||
@@ -160,7 +179,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
result = ai.generate_content_dedup(
|
result = ai.generate_content_dedup(
|
||||||
body.get("content_type", "reading_passage"),
|
body.get("content_type", "reading_passage"),
|
||||||
body.get("brief", {}),
|
body.get("brief", {}),
|
||||||
@@ -204,7 +223,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
||||||
@@ -224,7 +243,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Create a personalized learning plan. Return JSON: "
|
"Create a personalized learning plan. Return JSON: "
|
||||||
@@ -246,7 +265,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Generate a course outline. Return JSON: {\"chapters\": "
|
"Generate a course outline. Return JSON: {\"chapters\": "
|
||||||
@@ -264,7 +283,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Generate detailed chapter content for a course. Return JSON: "
|
"Generate detailed chapter content for a course. Return JSON: "
|
||||||
@@ -283,7 +302,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
||||||
@@ -350,7 +369,7 @@ class AIController(http.Controller):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
if not ai.client:
|
if not ai.client:
|
||||||
raise RuntimeError("OpenAI not configured")
|
raise RuntimeError("OpenAI not configured")
|
||||||
|
|
||||||
@@ -480,7 +499,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
has_ai = bool(ai.client)
|
has_ai = bool(ai.client)
|
||||||
except Exception:
|
except Exception:
|
||||||
ai, has_ai = None, False
|
ai, has_ai = None, False
|
||||||
@@ -1618,7 +1637,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"You are an educational materials expert. Suggest learning materials "
|
"You are an educational materials expert. Suggest learning materials "
|
||||||
@@ -1639,7 +1658,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
result = ai.generate_content(
|
result = ai.generate_content(
|
||||||
body.get("content_type", "explanation"),
|
body.get("content_type", "explanation"),
|
||||||
{"topic_id": topic_id, **body},
|
{"topic_id": topic_id, **body},
|
||||||
|
|||||||
@@ -23,12 +23,25 @@ def _get_json():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_language():
|
||||||
|
"""Read the caller's UI language from the ``Accept-Language`` header.
|
||||||
|
|
||||||
|
The frontend ``api-client`` automatically attaches this header from the
|
||||||
|
active i18n language so AI-generated text can be localized. Falls back
|
||||||
|
to English if the header is missing or malformed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return request.httprequest.headers.get("Accept-Language", "") or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class CoachController(http.Controller):
|
class CoachController(http.Controller):
|
||||||
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
|
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
|
||||||
|
|
||||||
def _get_coach(self):
|
def _get_coach(self):
|
||||||
from odoo.addons.encoach_ai.services.coach_service import CoachService
|
from odoo.addons.encoach_ai.services.coach_service import CoachService
|
||||||
return CoachService(request.env)
|
return CoachService(request.env, language=_request_language())
|
||||||
|
|
||||||
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
|
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
|
||||||
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
|
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class EncoachAIFeedback(models.Model):
|
|||||||
"encoach.entity", ondelete="set null", index=True,
|
"encoach.entity", ondelete="set null", index=True,
|
||||||
)
|
)
|
||||||
course_id = fields.Many2one(
|
course_id = fields.Many2one(
|
||||||
"encoach.course", ondelete="set null", index=True,
|
"op.course", ondelete="set null", index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__)
|
|||||||
class CoachService:
|
class CoachService:
|
||||||
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
|
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
|
||||||
|
|
||||||
def __init__(self, env):
|
def __init__(self, env, *, language=None):
|
||||||
from .openai_service import OpenAIService
|
from .openai_service import OpenAIService
|
||||||
self.env = env
|
self.env = env
|
||||||
self.ai = OpenAIService(env)
|
self.ai = OpenAIService(env, language=language)
|
||||||
|
|
||||||
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
|
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,10 +12,45 @@ except ImportError:
|
|||||||
_openai_mod = None
|
_openai_mod = None
|
||||||
|
|
||||||
|
|
||||||
|
# Human-readable names for the UI languages we support. Kept in sync with the
|
||||||
|
# frontend i18n language set. When a user has the UI in Arabic (`ar`), we want
|
||||||
|
# the LLM to reply in Arabic too — otherwise the user sees Arabic chrome with
|
||||||
|
# English AI content, which is what they reported as "not translated correct".
|
||||||
|
_LANGUAGE_NAMES = {
|
||||||
|
"en": "English",
|
||||||
|
"ar": "Arabic",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish",
|
||||||
|
"de": "German",
|
||||||
|
"ru": "Russian",
|
||||||
|
"tr": "Turkish",
|
||||||
|
"fa": "Persian",
|
||||||
|
"ur": "Urdu",
|
||||||
|
"hi": "Hindi",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"ja": "Japanese",
|
||||||
|
"ko": "Korean",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_language(code):
|
||||||
|
"""Pull a short ISO-639-1 code out of a raw Accept-Language-style string.
|
||||||
|
|
||||||
|
Handles ``ar``, ``ar-EG``, ``ar-EG,en;q=0.9`` and friends. Falls back to
|
||||||
|
``en`` for anything we don't recognise so the AI always has a concrete
|
||||||
|
target language and never reverts to an empty prompt.
|
||||||
|
"""
|
||||||
|
if not code:
|
||||||
|
return "en"
|
||||||
|
token = str(code).strip().split(",")[0].split(";")[0].strip().lower()
|
||||||
|
short = token.split("-")[0]
|
||||||
|
return short if short in _LANGUAGE_NAMES else "en"
|
||||||
|
|
||||||
|
|
||||||
class OpenAIService:
|
class OpenAIService:
|
||||||
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
|
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
|
||||||
|
|
||||||
def __init__(self, env):
|
def __init__(self, env, *, language=None):
|
||||||
self.env = env
|
self.env = env
|
||||||
self._get_param = env["ir.config_parameter"].sudo().get_param
|
self._get_param = env["ir.config_parameter"].sudo().get_param
|
||||||
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
|
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
|
||||||
@@ -34,6 +69,49 @@ class OpenAIService:
|
|||||||
self.client = None
|
self.client = None
|
||||||
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
||||||
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini")
|
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini")
|
||||||
|
self.language = _normalize_language(language)
|
||||||
|
|
||||||
|
def _language_system_message(self):
|
||||||
|
"""Return a system message that forces the LLM to answer in the user's
|
||||||
|
UI language, or ``None`` for English (the model's default).
|
||||||
|
|
||||||
|
We keep the original English prompts (which are tuned for JSON
|
||||||
|
structure) and simply tack a language instruction on the end. This
|
||||||
|
preserves behaviour for ``en`` users while giving Arabic users Arabic
|
||||||
|
output without having to translate every prompt in the codebase.
|
||||||
|
"""
|
||||||
|
if not self.language or self.language == "en":
|
||||||
|
return None
|
||||||
|
lang_name = _LANGUAGE_NAMES.get(self.language, "English")
|
||||||
|
return {
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
f"LOCALIZATION: Write every user-facing natural-language string "
|
||||||
|
f"(titles, descriptions, explanations, feedback, recommendations, "
|
||||||
|
f"suggestions, motivation, narrative) in {lang_name}. "
|
||||||
|
"Keep JSON keys, enum values (e.g. 'info', 'warning', 'critical', "
|
||||||
|
"'TRUE', 'FALSE', 'NOT GIVEN'), CEFR band codes (A1-C2), band numbers, "
|
||||||
|
"and identifiers in their original form. Do not translate rubric "
|
||||||
|
"category names that appear as JSON keys."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _inject_language(self, messages):
|
||||||
|
"""Prepend the localization instruction after the existing system
|
||||||
|
prompt(s) so it doesn't displace the structural prompt but is still
|
||||||
|
heeded by the model."""
|
||||||
|
lang_msg = self._language_system_message()
|
||||||
|
if not lang_msg:
|
||||||
|
return messages
|
||||||
|
messages = list(messages)
|
||||||
|
# Find the index of the last system message so we append after it.
|
||||||
|
last_system = -1
|
||||||
|
for i, m in enumerate(messages):
|
||||||
|
if isinstance(m, dict) and m.get("role") == "system":
|
||||||
|
last_system = i
|
||||||
|
insert_at = last_system + 1 if last_system >= 0 else 0
|
||||||
|
messages.insert(insert_at, lang_msg)
|
||||||
|
return messages
|
||||||
|
|
||||||
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
||||||
try:
|
try:
|
||||||
@@ -82,6 +160,7 @@ class OpenAIService:
|
|||||||
if not self.client:
|
if not self.client:
|
||||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||||
model = model or self.model
|
model = model or self.model
|
||||||
|
messages = self._inject_language(messages)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
@@ -108,6 +187,7 @@ class OpenAIService:
|
|||||||
if not self.client:
|
if not self.client:
|
||||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||||
model = model or self.model
|
model = model or self.model
|
||||||
|
messages = self._inject_language(messages)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
|
|||||||
Reference in New Issue
Block a user