Draw the TRO flow to scale with neato via --use-cords
Adds a --use-cords switch to tro_flow.py (--use-coords accepted too). With it, every TRO node carries its plant coordinate as a pos attribute and the SVG is produced by "neato -n" instead of dot, so the diagram looks like the plant layout rather than a computed left-to-right flow. Scale is 0.25 pt/mm (COORD_SCALE), which turns the ~11 x 20 m plant into roughly 2800 x 5000 pt - large enough that the TRO boxes do not overlap. Without the switch the pos attributes stay in the DOT as information only, at 1:100, and dot ignores them; the default layout is unchanged. Two details neato -n needs: the legend gets a position of its own, placed below left of the plant bounding box, because with -n a node without pos would land at 0,0 in the middle of the drawing. And the switch is refused with a clear message if any TRO has no coordinate, since -n cannot lay out a graph with missing positions. Verified that neato -n reproduces the given coordinates: the positions it reports back differ from the input by a constant offset only (spread 0.08 pt, from Graphviz moving the drawing into the positive quadrant), and no node distance changes by more than 0.08 pt over spans of up to 3600 pt. graph_to_svg() in material_flow.py replaces dot_to_svg() to take an engine and the no-op flag; dot_to_svg() stays as a thin wrapper. It now also treats output on stderr as an error, so neato's "node has no position" warnings do not pass silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+39
-12
@@ -692,26 +692,53 @@ def find_dot() -> str | None:
|
|||||||
return shutil.which("dot")
|
return shutil.which("dot")
|
||||||
|
|
||||||
|
|
||||||
def dot_to_svg(dot_file: Path, svg_file: Path) -> None:
|
def find_engine(engine: str) -> str | None:
|
||||||
"""SVG aus der .dot-Datei erzeugen (Graphviz)."""
|
"""Layout-Programm von Graphviz suchen ('dot', 'neato', ...)."""
|
||||||
executable = find_dot()
|
if engine == "dot":
|
||||||
|
return find_dot()
|
||||||
|
return shutil.which(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def graph_to_svg(
|
||||||
|
dot_file: Path, svg_file: Path, engine: str = "dot", no_op: int = 0
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
SVG aus der .dot-Datei erzeugen.
|
||||||
|
|
||||||
|
engine Layout-Programm ('dot' fuer die berechnete Anordnung, 'neato' fuer
|
||||||
|
vorgegebene Koordinaten)
|
||||||
|
no_op nur fuer neato: 1 oder 2 setzt den No-op-Schalter (-n / -n2), dann
|
||||||
|
werden die pos-Attribute der Knoten als Position uebernommen und
|
||||||
|
nur die Kanten berechnet. Alle Knoten brauchen dann ein pos.
|
||||||
|
"""
|
||||||
|
executable = find_engine(engine)
|
||||||
if not executable:
|
if not executable:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Graphviz 'dot' nicht gefunden. Bitte Graphviz installieren "
|
f"Graphviz '{engine}' nicht gefunden. Bitte Graphviz installieren "
|
||||||
"(https://graphviz.org/download/) oder GRAPHVIZ_DOT auf die "
|
f"(https://graphviz.org/download/)"
|
||||||
"dot-Programmdatei setzen."
|
+ (" oder GRAPHVIZ_DOT auf die dot-Programmdatei setzen."
|
||||||
|
if engine == "dot" else ".")
|
||||||
)
|
)
|
||||||
|
|
||||||
result = subprocess.run(
|
command = [executable]
|
||||||
[executable, "-Tsvg", str(dot_file), "-o", str(svg_file)],
|
if no_op:
|
||||||
capture_output=True,
|
command.append(f"-n{no_op}")
|
||||||
text=True,
|
command += ["-Tsvg", str(dot_file), "-o", str(svg_file)]
|
||||||
)
|
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"'dot' ist mit Code {result.returncode} fehlgeschlagen:\n"
|
f"'{engine}' ist mit Code {result.returncode} fehlgeschlagen:\n"
|
||||||
f"{(result.stderr or result.stdout).strip()}"
|
f"{(result.stderr or result.stdout).strip()}"
|
||||||
)
|
)
|
||||||
|
# neato meldet fehlende Positionen nur als Warnung auf stderr
|
||||||
|
if result.stderr.strip():
|
||||||
|
raise RuntimeError(f"'{engine}' meldet: {result.stderr.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def dot_to_svg(dot_file: Path, svg_file: Path) -> None:
|
||||||
|
"""SVG mit der berechneten Anordnung von 'dot' erzeugen."""
|
||||||
|
graph_to_svg(dot_file, svg_file, engine="dot")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+110
-25
@@ -13,6 +13,10 @@ Schwesterprogramm zu material_flow.py:
|
|||||||
Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das
|
Gleiche Schalter (--file, --tosvg, --doc); --tosvg erzeugt hier das
|
||||||
Flussdiagramm der TROs, nicht das der Einzelobjekte.
|
Flussdiagramm der TROs, nicht das der Einzelobjekte.
|
||||||
|
|
||||||
|
Zusaetzlich --use-cords: jeder TRO traegt die Koordinate seiner Bauteile, und
|
||||||
|
das SVG wird mit "neato -n" lagerichtig gezeichnet statt von "dot" angeordnet.
|
||||||
|
Der Graph sieht dann aus wie das Anlagenlayout.
|
||||||
|
|
||||||
Was ein TRO ist
|
Was ein TRO ist
|
||||||
---------------
|
---------------
|
||||||
Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer
|
Ein TRO ("Transfer Route Object") ist die Steuerungslogik **einer
|
||||||
@@ -42,8 +46,8 @@ lib/tro_catalog.py.
|
|||||||
|
|
||||||
Ausgabe (alles in SKEL_RESULTS)
|
Ausgabe (alles in SKEL_RESULTS)
|
||||||
-------------------------------
|
-------------------------------
|
||||||
<name>_tro_flow.dot immer
|
<name>_tro_flow.dot immer (mit --use-cords lagerichtige pos-Attribute)
|
||||||
<name>_tro_flow.svg mit --tosvg (Graphviz)
|
<name>_tro_flow.svg mit --tosvg (dot, mit --use-cords neato -n)
|
||||||
<name>_tro_doc.md mit --doc
|
<name>_tro_doc.md mit --doc
|
||||||
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
|
<name>_tro_errors.md nur wenn TRO-Fehler gefunden wurden
|
||||||
|
|
||||||
@@ -73,8 +77,8 @@ from material_flow import (
|
|||||||
_dot_label,
|
_dot_label,
|
||||||
_merkmal,
|
_merkmal,
|
||||||
build_graph,
|
build_graph,
|
||||||
dot_to_svg,
|
|
||||||
env_dir,
|
env_dir,
|
||||||
|
graph_to_svg,
|
||||||
read_elements,
|
read_elements,
|
||||||
resolve_input,
|
resolve_input,
|
||||||
)
|
)
|
||||||
@@ -98,6 +102,14 @@ MIN_STORE_LANES = 2
|
|||||||
|
|
||||||
GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
|
GENERATED_MARKER = "<!-- erzeugt von lib/tro_flow.py -->"
|
||||||
|
|
||||||
|
# Zeichnungsmassstab fuer --use-cords: Graphviz rechnet in Punkt, die Anlage in
|
||||||
|
# Millimeter. 0.25 pt/mm entspricht 1:4 - bei ~11 x 20 m Anlage ergibt das eine
|
||||||
|
# Zeichnung von etwa 2800 x 5000 pt, in der sich die TRO-Kaesten nicht ueberdecken.
|
||||||
|
COORD_SCALE = 0.25
|
||||||
|
|
||||||
|
# Abstand der Legende zum Anlagenrand in mm
|
||||||
|
LEGEND_MARGIN = 3000
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Datenmodell
|
# Datenmodell
|
||||||
@@ -526,17 +538,47 @@ def _tro_label(tro: Tro) -> str:
|
|||||||
return _dot_label(lines)
|
return _dot_label(lines)
|
||||||
|
|
||||||
|
|
||||||
def render_dot(analysis: Analysis, source: str) -> str:
|
def missing_positions(analysis: Analysis) -> list[str]:
|
||||||
"""Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle."""
|
"""TROs ohne Koordinate - mit ihnen ist kein lagerichtiges Layout moeglich."""
|
||||||
|
return [t.tro_id for t in analysis.tros if not t.position]
|
||||||
|
|
||||||
|
|
||||||
|
def _legend_pos(analysis: Analysis) -> tuple[float, float]:
|
||||||
|
"""Legende links unterhalb der Anlage platzieren (mm)."""
|
||||||
|
xs = [t.position[0] for t in analysis.tros if t.position]
|
||||||
|
ys = [t.position[1] for t in analysis.tros if t.position]
|
||||||
|
if not xs:
|
||||||
|
return (0.0, 0.0)
|
||||||
|
return (min(xs) - LEGEND_MARGIN, min(ys) - LEGEND_MARGIN)
|
||||||
|
|
||||||
|
|
||||||
|
def render_dot(analysis: Analysis, source: str, use_coords: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Gerichtetes Flussdiagramm der TROs als Graphviz-Quelle.
|
||||||
|
|
||||||
|
use_coords=False Anordnung berechnet 'dot' (Fluss von links nach rechts)
|
||||||
|
use_coords=True jeder Knoten wird auf seine Anlagenkoordinate gesetzt und
|
||||||
|
mit "neato -n" lagerichtig gezeichnet
|
||||||
|
"""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
add = out.append
|
add = out.append
|
||||||
|
|
||||||
add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.")
|
add("// Automatisch erzeugt von lib/tro_flow.py - nicht manuell aendern.")
|
||||||
add(f"// Quelle: {source}")
|
add(f"// Quelle: {source}")
|
||||||
|
if use_coords:
|
||||||
|
add(f"// Lagerichtig: Knoten auf Anlagenkoordinate, {COORD_SCALE} pt/mm.")
|
||||||
|
add("// Rendern mit: neato -n -Tsvg <datei>.dot -o <datei>.svg")
|
||||||
add("digraph TROFluss {")
|
add("digraph TROFluss {")
|
||||||
add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,")
|
if use_coords:
|
||||||
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
|
# Bei -n bestimmen die pos-Attribute die Lage; rankdir/ranksep entfallen.
|
||||||
add(f' label="TRO-Fluss - {_dot_escape(source)}"];')
|
add(" graph [splines=true, outputorder=edgesfirst,")
|
||||||
|
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
|
||||||
|
add(f' label="TRO-Fluss lagerichtig ({COORD_SCALE} pt/mm) - '
|
||||||
|
f'{_dot_escape(source)}"];')
|
||||||
|
else:
|
||||||
|
add(" graph [rankdir=LR, splines=spline, nodesep=0.35, ranksep=1.3,")
|
||||||
|
add(' fontname="Segoe UI", fontsize=11, labelloc="t",')
|
||||||
|
add(f' label="TRO-Fluss - {_dot_escape(source)}"];')
|
||||||
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];')
|
add(' node [shape=box, style="rounded,filled", fontname="Segoe UI", fontsize=9];')
|
||||||
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
|
add(' edge [fontname="Segoe UI", fontsize=8, color="#2f5597", penwidth=1.2,'
|
||||||
" arrowsize=0.8];")
|
" arrowsize=0.8];")
|
||||||
@@ -548,11 +590,13 @@ def render_dot(analysis: Analysis, source: str) -> str:
|
|||||||
"fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000"
|
"fillcolor": "#ffffff", "color": "#c00000", "fontcolor": "#c00000"
|
||||||
}
|
}
|
||||||
rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
|
rendered = ", ".join(f'{k}="{v}"' for k, v in attrs.items())
|
||||||
# Anlagenkoordinate mitgeben: dot ignoriert pos, "neato -n" zeichnet
|
# Anlagenkoordinate mitgeben. Ohne --use-cords ignoriert dot das pos,
|
||||||
# damit den Graphen lagerichtig (Millimeter -> Punkt, 1:100).
|
# die Angabe bleibt aber als Information im DOT stehen.
|
||||||
pos = ""
|
pos = ""
|
||||||
if tro.position:
|
if tro.position:
|
||||||
pos = f', pos="{tro.position[0] / 100:.2f},{tro.position[1] / 100:.2f}"'
|
scale = COORD_SCALE if use_coords else 0.01
|
||||||
|
pos = (f', pos="{tro.position[0] * scale:.2f},'
|
||||||
|
f'{tro.position[1] * scale:.2f}"')
|
||||||
add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];')
|
add(f' "{tro.tro_id}" [label="{_tro_label(tro)}", {rendered}{pos}];')
|
||||||
add("")
|
add("")
|
||||||
|
|
||||||
@@ -570,22 +614,33 @@ def render_dot(analysis: Analysis, source: str) -> str:
|
|||||||
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
|
f'color="#7f7f7f", constraint=false, tooltip="gleiche Bahn"];')
|
||||||
add("")
|
add("")
|
||||||
|
|
||||||
# Legende: eine Zeile je Farbgruppe, plus die Kantenarten
|
# Legende: eine Zeile je Farbgruppe, plus die Kantenarten.
|
||||||
add(" subgraph cluster_legende {")
|
# Bei -n braucht auch sie ein pos, sonst landet sie auf 0,0 mitten in der
|
||||||
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
|
# Anlage - daher unterhalb links vom Anlagenrand.
|
||||||
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
|
|
||||||
rows = "".join(
|
rows = "".join(
|
||||||
f'<tr><td bgcolor="{s.fill}" align="left"> {s.group} </td>'
|
f'<tr><td bgcolor="{s.fill}" align="left"> {s.group} </td>'
|
||||||
f'<td align="left">{s.label}</td></tr>'
|
f'<td align="left">{s.label}</td></tr>'
|
||||||
for s in STYLES
|
for s in STYLES
|
||||||
)
|
)
|
||||||
add(' legende [shape=plaintext, style="", fillcolor="none", label=<'
|
legend_label = ('<<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
|
||||||
'<table border="0" cellborder="1" cellspacing="0" cellpadding="3">'
|
+ rows
|
||||||
+ rows
|
+ '<tr><td align="left">durchgezogen</td>'
|
||||||
+ '<tr><td align="left">durchgezogen</td><td align="left">Materialfluss</td></tr>'
|
'<td align="left">Materialfluss</td></tr>'
|
||||||
'<tr><td align="left">gepunktet</td><td align="left">gleiche Bahn / Strecke</td></tr>'
|
'<tr><td align="left">gepunktet</td>'
|
||||||
"</table>>];")
|
'<td align="left">gleiche Bahn / Strecke</td></tr>'
|
||||||
add(" }")
|
"</table>>")
|
||||||
|
if use_coords:
|
||||||
|
lx, ly = _legend_pos(analysis)
|
||||||
|
add(f' legende [shape=plaintext, style="", fillcolor="none", '
|
||||||
|
f'label={legend_label}, pos="{lx * COORD_SCALE:.2f},'
|
||||||
|
f'{ly * COORD_SCALE:.2f}"];')
|
||||||
|
else:
|
||||||
|
add(" subgraph cluster_legende {")
|
||||||
|
add(' label="Legende"; style="rounded"; color="#a6a6a6";')
|
||||||
|
add(' fontname="Segoe UI"; fontsize=10; fontcolor="#404040";')
|
||||||
|
add(f' legende [shape=plaintext, style="", fillcolor="none", '
|
||||||
|
f'label={legend_label}];')
|
||||||
|
add(" }")
|
||||||
add("}")
|
add("}")
|
||||||
|
|
||||||
return "\n".join(out) + "\n"
|
return "\n".join(out) + "\n"
|
||||||
@@ -879,6 +934,7 @@ def report(
|
|||||||
error_file: Path | None,
|
error_file: Path | None,
|
||||||
doc_file: Path | None,
|
doc_file: Path | None,
|
||||||
warnings: list[str],
|
warnings: list[str],
|
||||||
|
engine: str = "dot",
|
||||||
) -> None:
|
) -> None:
|
||||||
types_used: dict[str, int] = {}
|
types_used: dict[str, int] = {}
|
||||||
for tro in analysis.tros:
|
for tro in analysis.tros:
|
||||||
@@ -896,6 +952,10 @@ def report(
|
|||||||
f"nicht verbunden: {len(analysis.isolated)}")
|
f"nicht verbunden: {len(analysis.isolated)}")
|
||||||
print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), "
|
print(f"Typdefinition = lib/tro_catalog.py (Stand {CATALOG_AS_OF}), "
|
||||||
f"{len(TRO_CATALOG)} Typen")
|
f"{len(TRO_CATALOG)} Typen")
|
||||||
|
print(f"Layout = {engine}"
|
||||||
|
+ (" (lagerichtig, neato -n auf Anlagenkoordinaten, "
|
||||||
|
f"{COORD_SCALE} pt/mm)" if engine == "neato"
|
||||||
|
else " (berechnete Anordnung; lagerichtig mit --use-cords)"))
|
||||||
print(f"DOT = {dot_file}")
|
print(f"DOT = {dot_file}")
|
||||||
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
|
print(f"SVG = {svg_file if svg_file else '- (ohne --tosvg)'}")
|
||||||
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
|
print(f"Doku = {doc_file if doc_file else '- (ohne --doc)'}")
|
||||||
@@ -943,6 +1003,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
" tro_flow.bat --file mubea.csv\n"
|
" tro_flow.bat --file mubea.csv\n"
|
||||||
" tro_flow.bat --file mubea.csv --tosvg\n"
|
" tro_flow.bat --file mubea.csv --tosvg\n"
|
||||||
" tro_flow.bat --file mubea.csv --tosvg --doc\n"
|
" tro_flow.bat --file mubea.csv --tosvg --doc\n"
|
||||||
|
" tro_flow.bat --file mubea.csv --tosvg --use-cords\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Exit-Codes:\n"
|
"Exit-Codes:\n"
|
||||||
" 0 ok\n"
|
" 0 ok\n"
|
||||||
@@ -963,6 +1024,15 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als "
|
help="Aus der erzeugten DOT-Datei zusaetzlich das TRO-Flussdiagramm als "
|
||||||
"SVG erzeugen (benoetigt Graphviz 'dot').",
|
"SVG erzeugen (benoetigt Graphviz 'dot').",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--use-cords",
|
||||||
|
"--use-coords",
|
||||||
|
dest="use_cords",
|
||||||
|
action="store_true",
|
||||||
|
help="Lagerichtig zeichnen: jeder TRO wird auf seine Anlagenkoordinate "
|
||||||
|
"gesetzt und das SVG von Graphviz 'neato -n' erzeugt statt von "
|
||||||
|
f"'dot'. Massstab {COORD_SCALE} pt/mm. Nur mit --tosvg wirksam.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--doc",
|
"--doc",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -1013,15 +1083,29 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
doc_target = results / f"{stem}_tro_doc.md"
|
doc_target = results / f"{stem}_tro_doc.md"
|
||||||
error_target = results / f"{stem}_tro_errors.md"
|
error_target = results / f"{stem}_tro_errors.md"
|
||||||
|
|
||||||
dot_file.write_text(render_dot(analysis, csv_file.name), encoding="utf-8")
|
# Ohne Koordinaten ist kein lagerichtiges Layout moeglich
|
||||||
|
without = missing_positions(analysis)
|
||||||
|
if args.use_cords and without:
|
||||||
|
print(f"FEHLER: --use-cords nicht moeglich, {len(without)} TRO(s) ohne "
|
||||||
|
f"Koordinate: {', '.join(without)}", file=sys.stderr)
|
||||||
|
print(" Ohne pos an jedem Knoten kann 'neato -n' nicht zeichnen.",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dot_file.write_text(
|
||||||
|
render_dot(analysis, csv_file.name, use_coords=args.use_cords),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
has_errors = write_error_file(analysis, csv_file, error_target)
|
has_errors = write_error_file(analysis, csv_file, error_target)
|
||||||
error_file = error_target if has_errors else None
|
error_file = error_target if has_errors else None
|
||||||
|
|
||||||
|
engine = "neato" if args.use_cords else "dot"
|
||||||
svg_error: str | None = None
|
svg_error: str | None = None
|
||||||
if args.tosvg:
|
if args.tosvg:
|
||||||
try:
|
try:
|
||||||
dot_to_svg(dot_file, svg_file)
|
graph_to_svg(dot_file, svg_file, engine=engine,
|
||||||
|
no_op=1 if args.use_cords else 0)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
svg_error = str(exc)
|
svg_error = str(exc)
|
||||||
created_svg = svg_file if args.tosvg and svg_error is None else None
|
created_svg = svg_file if args.tosvg and svg_error is None else None
|
||||||
@@ -1034,7 +1118,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
)
|
)
|
||||||
doc_file = doc_target
|
doc_file = doc_target
|
||||||
|
|
||||||
report(analysis, csv_file, dot_file, created_svg, error_file, doc_file, warnings)
|
report(analysis, csv_file, dot_file, created_svg, error_file, doc_file,
|
||||||
|
warnings, engine=engine)
|
||||||
|
|
||||||
if svg_error:
|
if svg_error:
|
||||||
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)
|
print(f"FEHLER: SVG nicht erzeugt: {svg_error}", file=sys.stderr)
|
||||||
|
|||||||
Reference in New Issue
Block a user