Hilfskripte zum Extrakt aus dem Produktprogramm erstellt. Kernkonzepte in README.md und doc
This commit is contained in:
@@ -12,8 +12,17 @@ Das Zielsystem besteht aus zwei Repos:
|
|||||||
- **projekt-kunde-xyz** (pro Anlage): `anlage.json` (ERP-Export), projektspezifische Bilder, Overrides, `conf.py`, `build.py`. Bindet das Base-Repo per Symlink/Mount ein.
|
- **projekt-kunde-xyz** (pro Anlage): `anlage.json` (ERP-Export), projektspezifische Bilder, Overrides, `conf.py`, `build.py`. Bindet das Base-Repo per Symlink/Mount ein.
|
||||||
|
|
||||||
### Technologie-Stack
|
### Technologie-Stack
|
||||||
|
| Komponente | Technologie |
|
||||||
|
|------------------|--------------------------------------------------|
|
||||||
|
| Build-System | Sphinx (Python) |
|
||||||
|
| Textformat | reStructuredText (rST) |
|
||||||
|
| Templating | Jinja2 (Rahmenkapitel, Titelseite) |
|
||||||
|
| PDF-Erzeugung | LaTeX (pdflatex/lualatex) |
|
||||||
|
| Versionierung | Git (Gitea auf Hetzner) |
|
||||||
|
| Konfiguration | JSON (ERP-Export) + YAML (Modul-Metadaten) |
|
||||||
|
| CI/Automatisierung | Gitea Actions oder n8n |
|
||||||
|
| Bildkonvertierung | ezdxf → SVG, Pillow (Optimierung) |
|
||||||
|
|
||||||
Sphinx + reStructuredText + Jinja2-Templating → LaTeX → PDF. Konfiguration via JSON (ERP-Export) + YAML (Modul-Metadaten). Bildkonvertierung: ezdxf → SVG, Pillow.
|
|
||||||
|
|
||||||
### Build-Pipeline (zu implementieren)
|
### Build-Pipeline (zu implementieren)
|
||||||
|
|
||||||
@@ -23,6 +32,7 @@ anlage.json → build.py → Versionscheck → Abhängigkeiten auflösen (meta.y
|
|||||||
→ pdflatex → PDF pro Sprache
|
→ pdflatex → PDF pro Sprache
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -64,6 +74,34 @@ docu_build/
|
|||||||
└── results/ # Build-Ausgaben (nicht im Git)
|
└── results/ # Build-Ausgaben (nicht im Git)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein Kundenprojekt enthält nur noch die Verweise auf die verbauten Teile, overrides und additionals, also das Überschreiben von vorhandenen Texten oder Ergänzungen
|
||||||
|
|
||||||
|
```
|
||||||
|
projekt-kunde-xyz-2026/
|
||||||
|
├── conf.py # Importiert conf_base.py vom Base-Repo
|
||||||
|
├── anlage.json # ERP-Export: Maschinenauswahl + Metadaten
|
||||||
|
├── build.py # Hauptbuild-Skript
|
||||||
|
├── Makefile
|
||||||
|
├── README.md
|
||||||
|
├── images/ # Projektspezifische Bilder
|
||||||
|
│ ├── aufstellplan_halle3.pdf
|
||||||
|
│ ├── foto_abnahme_01.jpg
|
||||||
|
│ └── kundenlogo.pdf
|
||||||
|
├── overrides/ # Projektspezifische Überschreibungen
|
||||||
|
│ ├── modules/
|
||||||
|
│ │ └── weiche_typ_a/
|
||||||
|
│ │ └── beschreibung.de.rst # Überschreibt Basis wenn vorhanden
|
||||||
|
│ └── chapters/
|
||||||
|
│ └── 01_einleitung/
|
||||||
|
│ └── template.de.rst # Kundenspezifische Einleitung
|
||||||
|
├── additional/ # Kapitel die NUR dieses Projekt hat
|
||||||
|
│ ├── sonderausfuehrung_xyz.de.rst
|
||||||
|
│ └── sonderausfuehrung_xyz.en.rst
|
||||||
|
└── transport-docs-base/ ──────────── # Symlink/Mount zum Base-Repo (Share)
|
||||||
|
```
|
||||||
|
|
||||||
|
Details zur Konfiguration und Aufbau einer Doku siehe `doc/Kernkonzepte.md`.
|
||||||
|
|
||||||
### Kapitelstruktur
|
### Kapitelstruktur
|
||||||
|
|
||||||
| Kapitel | Inhalt |
|
| Kapitel | Inhalt |
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Kernkonzepte
|
||||||
|
|
||||||
|
### anlage.json (ERP-Export)
|
||||||
|
|
||||||
|
Steuert die Zusammenstellung. Enthält Projektmetadaten, Zielsprachen, Normenregion, Liste verbauter Maschinen und Referenzen auf Zusatzkapitel.
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projekt": {
|
||||||
|
"nummer": "P-2026-0042",
|
||||||
|
"kunde": "Müller Automotive GmbH",
|
||||||
|
"kunde_logo": "images/kundenlogo.pdf",
|
||||||
|
"standort": "Werk Ingolstadt, Halle 3",
|
||||||
|
"sprachen": ["de", "en"],
|
||||||
|
"normen_region": "EU",
|
||||||
|
"base_version": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"maschinen": [
|
||||||
|
{
|
||||||
|
"modul": "weiche_typ_a",
|
||||||
|
"anzahl": 4,
|
||||||
|
"positionen": ["W01", "W02", "W03", "W04"],
|
||||||
|
"variante": "standard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modul": "hubstation",
|
||||||
|
"anzahl": 2,
|
||||||
|
"positionen": ["H01", "H02"],
|
||||||
|
"variante": "schwerlast",
|
||||||
|
"optionen": ["hydraulisch"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modul": "antrieb_sew",
|
||||||
|
"anzahl": 12,
|
||||||
|
"positionen": ["A01-A12"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modul": "steuerung_siemens_s7",
|
||||||
|
"anzahl": 1,
|
||||||
|
"positionen": ["SPS01"],
|
||||||
|
"optionen": ["profinet", "hmi_tp700"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"zusatz_kapitel": [
|
||||||
|
"additional/sonderausfuehrung_xyz"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### meta.yaml (Modul-Metadaten)
|
||||||
|
|
||||||
|
Pro Maschinentyp-Modul. Definiert Bezeichner, mehrsprachigen Namen, Kapitelzuordnung, Bildreferenzen und Abhängigkeiten.
|
||||||
|
|
||||||
|
**Beispiel** (`modules/weiche_typ_a/meta.yaml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
module: weiche_typ_a
|
||||||
|
display_name:
|
||||||
|
de: "Weiche Typ A"
|
||||||
|
en: "Switch Type A"
|
||||||
|
|
||||||
|
chapters:
|
||||||
|
- 02_sicherheit:
|
||||||
|
sections: [risikomatrix]
|
||||||
|
- 03_anlagenbeschreibung:
|
||||||
|
sections: [beschreibung, technische_daten]
|
||||||
|
- 05_wartung:
|
||||||
|
sections: [wartung, schmierplan]
|
||||||
|
|
||||||
|
images:
|
||||||
|
shared:
|
||||||
|
- photos/weiche_typ_a_foto_01.jpg
|
||||||
|
- drawings/weiche_typ_a_layout.svg
|
||||||
|
- diagrams/stromlaufplan_weiche_a.pdf
|
||||||
|
localized:
|
||||||
|
- diagramme/risikograph_weiche_a.svg
|
||||||
|
- screenshots/hmi_hauptmenu.png
|
||||||
|
module_local:
|
||||||
|
- montage_detail_01.jpg
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- antrieb_sew
|
||||||
|
```
|
||||||
|
|
||||||
|
### conf.py (Projekt-Konfiguration)
|
||||||
|
|
||||||
|
Importiert die Basiskonfiguration vom gemounteten Base-Repo und setzt projektspezifische Overrides.
|
||||||
|
|
||||||
|
**Beispiel** (`projekt-kunde-xyz-2026/conf.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ══ Pfad zum Base-Repo (Share/Mount) ══
|
||||||
|
BASE_DOCS = Path(os.environ.get(
|
||||||
|
"DOCS_BASE_PATH",
|
||||||
|
Path(__file__).parent / "transport-docs-base"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Extensions und Basiskonfiguration importieren
|
||||||
|
sys.path.insert(0, str(BASE_DOCS / "_ext"))
|
||||||
|
sys.path.insert(0, str(BASE_DOCS))
|
||||||
|
from conf_base import * # noqa: F401, F403
|
||||||
|
|
||||||
|
# ══ Projektspezifische Overrides ══
|
||||||
|
project = "Anlagendokumentation P-2026-0042"
|
||||||
|
release = "1.0"
|
||||||
|
author = "Mista GmbH"
|
||||||
|
copyright = "2026, Mista GmbH"
|
||||||
|
|
||||||
|
# Bildpfade: Projekt → Overrides → Base (Reihenfolge = Priorität)
|
||||||
|
image_search_path = [
|
||||||
|
"images", # Projektbilder
|
||||||
|
"overrides", # Überschreibungen
|
||||||
|
str(BASE_DOCS / "assets" / "shared"), # Sprachneutrale Basis
|
||||||
|
str(BASE_DOCS / "assets" / "localized"), # Sprachspezifische Basis
|
||||||
|
str(BASE_DOCS / "modules"), # Modulbilder
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sphinx-Extensions aus Base-Repo laden
|
||||||
|
extensions += [
|
||||||
|
"anlagen_builder",
|
||||||
|
"localized_figure",
|
||||||
|
"glossary_loader",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Glossar-Pfad
|
||||||
|
glossary_csv_path = str(BASE_DOCS / "glossary")
|
||||||
|
|
||||||
|
# LaTeX-Templates aus Base-Repo
|
||||||
|
templates_path.insert(0, str(BASE_DOCS / "_templates"))
|
||||||
|
|
||||||
|
# LaTeX-spezifisch
|
||||||
|
latex_elements = {
|
||||||
|
"preamble": open(BASE_DOCS / "_templates" / "latex" / "preamble.tex").read(),
|
||||||
|
"maketitle": open(BASE_DOCS / "_templates" / "latex" / "maketitle.tex").read(),
|
||||||
|
"papersize": "a4paper",
|
||||||
|
"pointsize": "11pt",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Override-Mechanismus
|
||||||
|
|
||||||
|
Suchreihenfolge beim Build:
|
||||||
|
1. `projekt/overrides/` (projektspezifisch)
|
||||||
|
2. `transport-docs-base/modules/` bzw. `chapters/` (allgemein)
|
||||||
|
|
||||||
|
Ermöglicht kundenspezifische Anpassungen ohne Base-Repo-Änderung.
|
||||||
|
|
||||||
|
### Versionskontrolle
|
||||||
|
|
||||||
|
Base-Repo hat Semver in `VERSION`. Projekt-Repo kann `"base_version": ">=2.1.0"` in anlage.json setzen. build.py prüft Kompatibilität vor dem Build.
|
||||||
|
|
||||||
|
## Mehrsprachigkeit
|
||||||
|
|
||||||
|
### Drei Ebenen
|
||||||
|
|
||||||
|
1. **Textbausteine**: Dateinamenskonvention `{name}.{lang}.rst`. Fallback auf `de` bei fehlender Übersetzung.
|
||||||
|
2. **Bilder**: `localized-figure`-Direktive löst `assets/localized/{lang}/pfad/bild.png` auf. Fallback auf `de`. Optional: Jinja2-SVG-Templates für Diagramme mit Text.
|
||||||
|
3. **Glossar**: CSV pro Sprache (`terms.{lang}.csv`), `glossary_loader.py` erzeugt Sphinx-Glossardirektiven. Im Text via `:term:`Fachbegriff`` verlinkt.
|
||||||
|
|
||||||
|
### Sprachspezifische Normen
|
||||||
|
|
||||||
|
Gesteuert über `normen_region` in anlage.json. Kapitel 02_sicherheit enthält z.B. `normen_ce.de.rst` (EU) und `normen_ukca.en.rst` (UK), Build wählt nach Region.
|
||||||
|
|
||||||
|
## Bildverwaltung
|
||||||
|
|
||||||
|
| Kategorie | Pfad | Beispiele |
|
||||||
|
|------------------|-----------------------------------|----------------------------------------------|
|
||||||
|
| Sprachneutral | `assets/shared/{typ}/` | Fotos, DXF/SVG-Zeichnungen, ISO-Symbole |
|
||||||
|
| Sprachspezifisch | `assets/localized/{lang}/{typ}/` | HMI-Screenshots, Risikographen, Typenschilder|
|
||||||
|
| Modulspezifisch | `modules/{modul}/images/` | Montagedetails |
|
||||||
|
| Projektspezifisch| `projekt/images/` | Aufstellpläne, Abnahmefotos, Kundenlogo |
|
||||||
|
|
||||||
|
Formate: SVG/PDF für Zeichnungen (vektoriell), JPG für Fotos (max 2000px, 85% Qualität).
|
||||||
|
|
||||||
|
## Build-Prozess
|
||||||
|
|
||||||
|
```
|
||||||
|
anlage.json ──→ build.py
|
||||||
|
│
|
||||||
|
├─ Phase 1: Konfiguration einlesen, Versionscheck
|
||||||
|
├─ Phase 2: Abhängigkeiten auflösen (meta.yaml, transitiv)
|
||||||
|
│ Overrides anwenden (Projekt > Base)
|
||||||
|
│ Fehlende Module/Bilder/Übersetzungen melden
|
||||||
|
├─ Phase 3: Sphinx-Quellen generieren (index.rst, toctrees)
|
||||||
|
│ Glossar aus CSV erzeugen
|
||||||
|
│ Jinja2-Templates mit Projektdaten füllen
|
||||||
|
└─ Phase 4: sphinx-build -b latex → pdflatex → PDF (pro Sprache)
|
||||||
|
|
||||||
|
Ausgabe:
|
||||||
|
_output/P-2026-0042_Dokumentation_DE.pdf
|
||||||
|
_output/P-2026-0042_Documentation_EN.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sphinx-Extensions (zu implementieren)
|
||||||
|
|
||||||
|
### anlagen_builder.py
|
||||||
|
- Liest anlage.json + meta.yaml aller referenzierten Module
|
||||||
|
- Löst Abhängigkeiten auf (topologisch, transitiv)
|
||||||
|
- Generiert toctree-Einträge pro Kapitel
|
||||||
|
- Wendet Override-Logik an
|
||||||
|
|
||||||
|
### localized_figure.py
|
||||||
|
- Neue Direktive `.. localized-figure::`
|
||||||
|
- Löst Bildpfad nach Build-Sprache auf
|
||||||
|
- Fallback: `de` wenn Sprachversion fehlt
|
||||||
|
|
||||||
|
### glossary_loader.py
|
||||||
|
- Liest `terms.{lang}.csv` (Semikolon-getrennt)
|
||||||
|
- Erzeugt `.. glossary::` Blöcke
|
||||||
|
- Ermöglicht `:term:` Verlinkung im gesamten Dokument
|
||||||
|
|
||||||
|
## Hilfsskripte
|
||||||
|
|
||||||
|
- `scripts/dxf2svg.py` – DXF → SVG Konvertierung (ezdxf)
|
||||||
|
- `scripts/optimize_images.py` – Bildoptimierung vor Commit (Pillow, max 2000px)
|
||||||
|
- `scripts/validate_modules.py` – Prüft meta.yaml-Konsistenz, fehlende Dateien/Übersetzungen
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
collect_partnumbers.py - Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs.
|
||||||
|
|
||||||
|
Erkennt auch Bereiche wie 834372007-011 und expandiert diese zu
|
||||||
|
834372007, 834372008, 834372009, 834372010, 834372011.
|
||||||
|
|
||||||
|
Gleicht die Nummern gegen eine Excel-Stueckliste ab und schreibt
|
||||||
|
eine CSV mit Teilenummer, Quelldatei und Bezeichnung.
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
python collect_partnumbers.py [--data-dir <pfad>]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_ranges(text: str) -> list[str]:
|
||||||
|
"""Findet alle 9-stelligen Nummern und Bereiche in einem Text.
|
||||||
|
|
||||||
|
Unterstuetzte Formate:
|
||||||
|
- Einzeln: 834372007
|
||||||
|
- Bereich: 834372007-011 -> 834372007..834372011
|
||||||
|
- OCR-verklebt: "834372001-005834372 007-011" bedeutet eigentlich
|
||||||
|
"834372001-005 834372007-011" -> zwei Bereiche mit gleicher Stammnummer
|
||||||
|
- Kurzbereich mit Stammnummer: "834372 007-011" -> 834372007..834372011
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste der gefundenen/expandierten 9-stelligen Nummern.
|
||||||
|
"""
|
||||||
|
results: list[str] = []
|
||||||
|
|
||||||
|
# Phase 1: OCR-verklebte Nummern trennen.
|
||||||
|
# "005834372" -> "005 834372" (3 Suffix-Ziffern gefolgt von 6-stelliger Stammnr)
|
||||||
|
# Wiederhole bis stabil.
|
||||||
|
cleaned = text
|
||||||
|
for _ in range(10):
|
||||||
|
prev = cleaned
|
||||||
|
# Nach einem Bereichs-Ende (-NNN) klebt eine Stammnummer (6 Ziffern) dran
|
||||||
|
cleaned = re.sub(r'-(\d{3})(\d{6})(?=\s|\d{3}-|\d{3}\s|$)', r'-\1 \2', cleaned)
|
||||||
|
if cleaned == prev:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Phase 2: Tokenisieren und mit Kontext parsen.
|
||||||
|
# Tokens: 9-stellige Nummern, 6-stellige Stammnummern, NNN-NNN Bereiche,
|
||||||
|
# einzelne NNN Suffixe.
|
||||||
|
# Die Stammnummer wird aus dem letzten vollstaendigen 9-Steller oder
|
||||||
|
# expliziten 6-Steller uebernommen.
|
||||||
|
|
||||||
|
current_prefix = "" # 6-stellige Stammnummer
|
||||||
|
|
||||||
|
# Tokenize: Zifferngruppen und Bindestriche extrahieren
|
||||||
|
# Wir verarbeiten die Zeile sequentiell
|
||||||
|
token_pat = re.compile(r'\d+')
|
||||||
|
dash_pat = re.compile(r'(\d+)-(\d+)')
|
||||||
|
|
||||||
|
# Strategie: alle Zifferngruppen und ihre Kontexte finden
|
||||||
|
# Zuerst alle "Nummer-Nummer" Paare und einzelne Nummern identifizieren
|
||||||
|
segments: list[str] = []
|
||||||
|
# Aufteilen an Nicht-Ziffern-nicht-Bindestrich Grenzen
|
||||||
|
# aber Bindestrich zwischen Ziffern beibehalten
|
||||||
|
seg_pat = re.compile(r'[\d]+(?:-[\d]+)*')
|
||||||
|
segments = seg_pat.findall(cleaned)
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
# Fall 1: "834372007-011" (9 Stellen - 3 Stellen)
|
||||||
|
m = re.match(r'^(\d{9})-(\d{3})$', seg)
|
||||||
|
if m:
|
||||||
|
base = m.group(1)
|
||||||
|
current_prefix = base[:6]
|
||||||
|
start = int(base[6:])
|
||||||
|
end = int(m.group(2))
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
else:
|
||||||
|
results.append(base)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 2: "834372007" (einzelne 9-stellige Nummer)
|
||||||
|
m = re.match(r'^(\d{9})$', seg)
|
||||||
|
if m:
|
||||||
|
num = m.group(1)
|
||||||
|
current_prefix = num[:6]
|
||||||
|
if num not in results:
|
||||||
|
results.append(num)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 3: "834372" (6-stellige Stammnummer, setzt Prefix)
|
||||||
|
m = re.match(r'^(\d{6})$', seg)
|
||||||
|
if m:
|
||||||
|
current_prefix = m.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 4: "007-011" (3-3 Bereich mit bekanntem Prefix)
|
||||||
|
m = re.match(r'^(\d{3})-(\d{3})$', seg)
|
||||||
|
if m and current_prefix:
|
||||||
|
start = int(m.group(1))
|
||||||
|
end = int(m.group(2))
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 5: "420" (einzelnes 3-stelliges Suffix mit bekanntem Prefix)
|
||||||
|
m = re.match(r'^(\d{3})$', seg)
|
||||||
|
if m and current_prefix:
|
||||||
|
num = f"{current_prefix}{m.group(1)}"
|
||||||
|
if num not in results:
|
||||||
|
results.append(num)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 6: "200-202834372" -> nach Phase 1 Trennung sollte das
|
||||||
|
# nicht mehr vorkommen, aber als Fallback:
|
||||||
|
# Mehrere Bereiche hintereinander
|
||||||
|
sub_segs = re.findall(r'(\d{9})-(\d{3})|(\d{9})|(\d{3})-(\d{3})', seg)
|
||||||
|
for ss in sub_segs:
|
||||||
|
if ss[0] and ss[1]: # 9-3 Bereich
|
||||||
|
base = ss[0]
|
||||||
|
current_prefix = base[:6]
|
||||||
|
start = int(base[6:])
|
||||||
|
end = int(ss[1])
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
elif ss[2]: # Einzelne 9-stellige
|
||||||
|
current_prefix = ss[2][:6]
|
||||||
|
if ss[2] not in results:
|
||||||
|
results.append(ss[2])
|
||||||
|
elif ss[3] and ss[4] and current_prefix: # 3-3 Bereich
|
||||||
|
start = int(ss[3])
|
||||||
|
end = int(ss[4])
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
RE_UNDERLINE = re.compile(r"^([=\-~\^])\1{2,}$")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_from_rst(rst_path: Path) -> dict[str, str]:
|
||||||
|
"""Sammelt alle 9-stelligen Teilenummern aus einer RST-Datei.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict nummer -> kapitelueberschrift in der die Nummer gefunden wurde.
|
||||||
|
"""
|
||||||
|
lines = rst_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
num_to_chapter: dict[str, str] = {}
|
||||||
|
current_heading = ""
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i].rstrip()
|
||||||
|
|
||||||
|
# RST-Ueberschriften erkennen:
|
||||||
|
# Variante 1: overline + title + underline (Level 1)
|
||||||
|
# Variante 2: title + underline (Level 2+)
|
||||||
|
if RE_UNDERLINE.match(line):
|
||||||
|
# Pruefen ob dies eine Underline nach einem Titel ist
|
||||||
|
if i > 0 and lines[i - 1].strip():
|
||||||
|
title = lines[i - 1].strip()
|
||||||
|
# Nur nummerierte Ueberschriften als Kapitel verwenden
|
||||||
|
if re.match(r"^\d+(\.\d+)*\s+", title):
|
||||||
|
current_heading = title
|
||||||
|
# Oder eine Overline vor einem Titel
|
||||||
|
elif i + 1 < len(lines) and lines[i + 1].strip():
|
||||||
|
title = lines[i + 1].strip()
|
||||||
|
if re.match(r"^\d+(\.\d+)*\s+", title):
|
||||||
|
current_heading = title
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Teilenummern aus der Zeile extrahieren
|
||||||
|
nums = expand_ranges(line)
|
||||||
|
for n in nums:
|
||||||
|
if n[0] in ("8", "9") and n not in num_to_chapter:
|
||||||
|
num_to_chapter[n] = current_heading
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return num_to_chapter
|
||||||
|
|
||||||
|
|
||||||
|
def load_excel_descriptions(xlsx_path: Path) -> dict[str, str]:
|
||||||
|
"""Laedt Teilenummer -> Bezeichnung aus Excel-Stueckliste."""
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(str(xlsx_path), read_only=True)
|
||||||
|
ws = wb["Stk.-Liste SOLL"]
|
||||||
|
desc_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=5, values_only=True):
|
||||||
|
tn = str(row[1]).strip() if row[1] else ""
|
||||||
|
if re.match(r"^\d{9}$", tn) and tn not in desc_map:
|
||||||
|
bez = str(row[2]).strip() if row[2] else ""
|
||||||
|
if bez:
|
||||||
|
desc_map[tn] = bez
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return desc_map
|
||||||
|
|
||||||
|
|
||||||
|
def process(data_dir: Path) -> None:
|
||||||
|
pp_dir = data_dir / "Produktprogramm"
|
||||||
|
rst_files = sorted(pp_dir.glob("*.de.rst"))
|
||||||
|
|
||||||
|
if not rst_files:
|
||||||
|
log.error("Keine RST-Dateien in %s gefunden", pp_dir)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Teilenummern sammeln: nummer -> list[(quelldatei, kapitel)]
|
||||||
|
numbers: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
for rst_path in rst_files:
|
||||||
|
basename = rst_path.name
|
||||||
|
num_chapters = collect_from_rst(rst_path)
|
||||||
|
log.info("%s: %d Teilenummern gefunden", basename, len(num_chapters))
|
||||||
|
for num, chapter in num_chapters.items():
|
||||||
|
numbers.setdefault(num, []).append((basename, chapter))
|
||||||
|
|
||||||
|
log.info("Gesamt: %d eindeutige Teilenummern", len(numbers))
|
||||||
|
|
||||||
|
# Excel-Bezeichnungen laden
|
||||||
|
xlsx_files = list((data_dir).glob("KA*.xlsx"))
|
||||||
|
desc_map: dict[str, str] = {}
|
||||||
|
if xlsx_files:
|
||||||
|
xlsx_path = xlsx_files[0]
|
||||||
|
log.info("Lade Excel: %s", xlsx_path.name)
|
||||||
|
desc_map = load_excel_descriptions(xlsx_path)
|
||||||
|
log.info(" %d Bezeichnungen geladen", len(desc_map))
|
||||||
|
else:
|
||||||
|
log.warning("Keine KA*.xlsx Datei gefunden in %s", data_dir)
|
||||||
|
|
||||||
|
# CSV schreiben
|
||||||
|
outfile = pp_dir / "teilenummern.csv"
|
||||||
|
matched = 0
|
||||||
|
with open(outfile, "w", encoding="utf-8-sig", newline="") as fh:
|
||||||
|
writer = csv.writer(fh, delimiter=";")
|
||||||
|
writer.writerow(["Teilenummer", "Quelldatei", "Kapitel", "Bezeichnung"])
|
||||||
|
for num in sorted(numbers.keys()):
|
||||||
|
entries = sorted(numbers[num], key=lambda e: e[0])
|
||||||
|
sources = ", ".join(e[0] for e in entries)
|
||||||
|
chapters = ", ".join(e[1] for e in entries if e[1])
|
||||||
|
bez = desc_map.get(num, "")
|
||||||
|
if bez:
|
||||||
|
matched += 1
|
||||||
|
writer.writerow([num, sources, chapters, bez])
|
||||||
|
|
||||||
|
log.info("CSV geschrieben: %s", outfile)
|
||||||
|
log.info(" %d Nummern, %d mit Bezeichnung, %d ohne",
|
||||||
|
len(numbers), matched, len(numbers) - matched)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs"
|
||||||
|
)
|
||||||
|
parser.add_argument("--data-dir", type=Path, default=None,
|
||||||
|
help="data/ Verzeichnis (default: aus PV_DATA oder ./data)")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||||
|
format="%(levelname)-7s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.data_dir:
|
||||||
|
data_dir = args.data_dir.resolve()
|
||||||
|
elif os.environ.get("PV_DATA"):
|
||||||
|
data_dir = Path(os.environ["PV_DATA"]).resolve()
|
||||||
|
else:
|
||||||
|
data_dir = Path("data").resolve()
|
||||||
|
|
||||||
|
log.info("Data-Verzeichnis: %s", data_dir)
|
||||||
|
process(data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
create_examples.py - Liest Stuecklisten-Daten aus Excel-Dateien im Examples-Ordner.
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
python create_examples.py [--example-dir <pfad>]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StkItem:
|
||||||
|
position: str
|
||||||
|
teilenummer: str
|
||||||
|
menge: Any
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_str(value: Any) -> str:
|
||||||
|
return str(value).strip() if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _find_col_index(header_row: tuple[Any, ...], candidates: list[str]) -> int | None:
|
||||||
|
normalized = [_cell_str(v).lower() for v in header_row]
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate_norm = candidate.lower()
|
||||||
|
if candidate_norm in normalized:
|
||||||
|
return normalized.index(candidate_norm)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_stk_liste_ist(xlsx_path: Path) -> list[StkItem]:
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(str(xlsx_path), read_only=True, data_only=True)
|
||||||
|
if "Stk.-Liste IST" not in wb.sheetnames:
|
||||||
|
wb.close()
|
||||||
|
raise ValueError("Reiter 'Stk.-Liste IST' nicht gefunden")
|
||||||
|
|
||||||
|
ws = wb["Stk.-Liste IST"]
|
||||||
|
|
||||||
|
header_idx: tuple[int | None, int | None, int | None] | None = None
|
||||||
|
items: list[StkItem] = []
|
||||||
|
|
||||||
|
for row in ws.iter_rows(values_only=True):
|
||||||
|
if header_idx is None:
|
||||||
|
pos_idx = _find_col_index(list(row), ["position", "pos", "pos."])
|
||||||
|
part_idx = _find_col_index(
|
||||||
|
list(row),
|
||||||
|
["teilenummer", "teilnummer", "article", "artikelnummer"],
|
||||||
|
)
|
||||||
|
qty_idx = _find_col_index(list(row), ["menge", "anzahl", "qty", "quantity"])
|
||||||
|
if pos_idx is not None and part_idx is not None and qty_idx is not None:
|
||||||
|
header_idx = (pos_idx, part_idx, qty_idx)
|
||||||
|
continue
|
||||||
|
|
||||||
|
pos_idx, part_idx, qty_idx = header_idx
|
||||||
|
assert pos_idx is not None and part_idx is not None and qty_idx is not None
|
||||||
|
|
||||||
|
position = _cell_str(row[pos_idx]) if pos_idx < len(row) else ""
|
||||||
|
teilenummer = _cell_str(row[part_idx]) if part_idx < len(row) else ""
|
||||||
|
menge = row[qty_idx] if qty_idx < len(row) else None
|
||||||
|
|
||||||
|
# Leere Zeilen ueberspringen
|
||||||
|
if not position and not teilenummer and menge in ("", None):
|
||||||
|
continue
|
||||||
|
if not position or not teilenummer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append(StkItem(position=position, teilenummer=teilenummer, menge=menge))
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def process_all(example_dir: Path) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
Durchsucht rekursiv alle Unterverzeichnisse in example_dir.
|
||||||
|
|
||||||
|
Pro Unterverzeichnis wird die erste gefundene .xlsx-Datei gelesen und aus
|
||||||
|
dem Reiter "Stk.-Liste IST" werden Position, Teilenummer, Menge extrahiert.
|
||||||
|
"""
|
||||||
|
if not example_dir.exists():
|
||||||
|
log.error("Beispiel-Verzeichnis existiert nicht: %s", example_dir)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
subdirs = sorted([p for p in example_dir.rglob("*") if p.is_dir()])
|
||||||
|
if not subdirs:
|
||||||
|
log.error("Keine Unterverzeichnisse in %s gefunden", example_dir)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info("Gefunden: %d Unterverzeichnisse in %s", len(subdirs), example_dir)
|
||||||
|
|
||||||
|
result: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for folder in subdirs:
|
||||||
|
xlsx_files = sorted(folder.glob("*.xlsx"))
|
||||||
|
if not xlsx_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
xlsx_path = xlsx_files[0]
|
||||||
|
rel_folder = str(folder.relative_to(example_dir))
|
||||||
|
log.info("Verarbeite %s: %s", rel_folder, xlsx_path.name)
|
||||||
|
try:
|
||||||
|
items = _extract_stk_liste_ist(xlsx_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
log.warning("%s/%s uebersprungen: %s", rel_folder, xlsx_path.name, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result[rel_folder] = [
|
||||||
|
{
|
||||||
|
"position": item.position,
|
||||||
|
"teilenummer": item.teilenummer,
|
||||||
|
"menge": item.menge,
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|
||||||
|
log.info("Fertig. Gelesene Verzeichnisse mit Daten: %d", len(result))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Liest Position/Teilenummer/Menge aus Example-Exceldateien"
|
||||||
|
)
|
||||||
|
parser.add_argument("--example-dir", type=Path, default=None,
|
||||||
|
help="example/ Verzeichnis (default: aus PV_EXAMPLES oder ./examples)")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
|
help="Ausfuehrliche Ausgabe")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||||
|
format="%(levelname)-7s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
import os
|
||||||
|
if args.example_dir:
|
||||||
|
example_dir = args.example_dir.resolve()
|
||||||
|
elif os.environ.get("PV_EXAMPLES"):
|
||||||
|
example_dir = Path(os.environ["PV_EXAMPLES"]).resolve()
|
||||||
|
else:
|
||||||
|
example_dir = Path("example").resolve()
|
||||||
|
|
||||||
|
log.info("Beispiel-Verzeichnis: %s", example_dir)
|
||||||
|
data = process_all(example_dir)
|
||||||
|
log.info("Objekt erzeugt mit %d Schluesseln", len(data))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+91
-18
@@ -77,6 +77,70 @@ def extract_images(pdf_path: Path, output_dir: Path) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_toc_numbers(doc) -> dict[str, str]:
|
||||||
|
"""Extrahiert Kapitelnummern aus dem Inhaltsverzeichnis des PDFs.
|
||||||
|
|
||||||
|
Sucht TOC-Seiten (Seiten mit '....' Punkt-Fuellern) und ordnet
|
||||||
|
Ueberschriftentexte ihren Kapitelnummern zu.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict title_text -> kapitelnummer, z.B. {"Typen von Weichen mit Bögen": "2.3.1"}
|
||||||
|
"""
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
toc_entries: dict[str, str] = {}
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
# Nur TOC-Seiten verarbeiten (enthalten "....")
|
||||||
|
if "...." not in text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
current_number = ""
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i].strip()
|
||||||
|
i += 1
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# TOC-Zeile: "Text ........... Seitennr"
|
||||||
|
# Kapitelnummer steht oft auf separater Zeile davor
|
||||||
|
num_match = re.match(r"^(\d+(?:\.\d+)*)\s*$", line)
|
||||||
|
if num_match:
|
||||||
|
current_number = num_match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Zeile mit Punkt-Fuellern = TOC-Eintrag
|
||||||
|
dot_match = re.search(r"\.{4,}", line)
|
||||||
|
if dot_match:
|
||||||
|
# Text vor den Punkten extrahieren
|
||||||
|
title = line[:dot_match.start()].strip()
|
||||||
|
# Seitennummer nach den Punkten
|
||||||
|
if title and current_number:
|
||||||
|
toc_entries[title] = current_number
|
||||||
|
current_number = ""
|
||||||
|
elif title:
|
||||||
|
# Nummer koennte am Anfang des Titels stehen
|
||||||
|
nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title)
|
||||||
|
if nm:
|
||||||
|
toc_entries[nm.group(2).strip()] = nm.group(1)
|
||||||
|
|
||||||
|
# Auch inline Nummern: "1.4 Abkürzungen ..... 8"
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
dot_match = re.search(r"\.{4,}", line)
|
||||||
|
if dot_match:
|
||||||
|
title_part = line[:dot_match.start()].strip()
|
||||||
|
nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title_part)
|
||||||
|
if nm:
|
||||||
|
toc_entries[nm.group(2).strip()] = nm.group(1)
|
||||||
|
|
||||||
|
return toc_entries
|
||||||
|
|
||||||
|
|
||||||
def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
||||||
"""Extrahiert Text aus PDF und schreibt ihn als .rst.
|
"""Extrahiert Text aus PDF und schreibt ihn als .rst.
|
||||||
|
|
||||||
@@ -86,16 +150,31 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
import fitz
|
import fitz
|
||||||
|
|
||||||
doc = fitz.open(str(pdf_path))
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
# TOC-Nummern vorab extrahieren
|
||||||
|
toc_numbers = _extract_toc_numbers(doc)
|
||||||
|
|
||||||
raw_lines: list[dict] = []
|
raw_lines: list[dict] = []
|
||||||
|
|
||||||
for page_num in range(len(doc)):
|
for page_num in range(len(doc)):
|
||||||
page = doc[page_num]
|
page = doc[page_num]
|
||||||
|
page_height = page.rect.height
|
||||||
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
||||||
|
|
||||||
for block in page_dict.get("blocks", []):
|
for block in page_dict.get("blocks", []):
|
||||||
if block.get("type") != 0:
|
if block.get("type") != 0:
|
||||||
continue
|
continue
|
||||||
for line in block.get("lines", []):
|
for line in block.get("lines", []):
|
||||||
|
bbox = line.get("bbox", [0, 0, 0, 0])
|
||||||
|
|
||||||
|
# Positionsbasierte Kopf-/Fusszeilen-Erkennung
|
||||||
|
# Kopfzeile: Y < 100pt (Logos, Kapitelname, OMNIFLO etc.)
|
||||||
|
if bbox[1] < 100:
|
||||||
|
continue
|
||||||
|
# Fusszeile: Y > page_height - 50pt
|
||||||
|
if bbox[3] > page_height - 50:
|
||||||
|
continue
|
||||||
|
|
||||||
text = ""
|
text = ""
|
||||||
max_size = 0.0
|
max_size = 0.0
|
||||||
for span in line.get("spans", []):
|
for span in line.get("spans", []):
|
||||||
@@ -109,8 +188,6 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
if _is_header_footer(text, page_num + 1):
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_lines.append({
|
raw_lines.append({
|
||||||
"text": text,
|
"text": text,
|
||||||
@@ -200,6 +277,18 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
has_dots = "." in num
|
has_dots = "." in num
|
||||||
is_heading = has_dots or entry["size"] >= 12.0
|
is_heading = has_dots or entry["size"] >= 12.0
|
||||||
|
|
||||||
|
# Phase 3b: Nicht-nummerierte Texte mit grosser Schrift pruefen,
|
||||||
|
# ob sie im TOC eine Kapitelnummer haben (fehlende PDF-Nummerierung)
|
||||||
|
if not is_heading and entry["size"] >= 12.0:
|
||||||
|
# Text gegen TOC abgleichen
|
||||||
|
toc_num = toc_numbers.get(text)
|
||||||
|
if toc_num:
|
||||||
|
# Nummer aus TOC voranstellen
|
||||||
|
text = f"{toc_num} {text}"
|
||||||
|
hm = RE_HEADING.match(text)
|
||||||
|
is_heading = True
|
||||||
|
log.debug(" TOC-Nummer ergaenzt: %s", text)
|
||||||
|
|
||||||
if is_heading:
|
if is_heading:
|
||||||
num = hm.group(1)
|
num = hm.group(1)
|
||||||
heading_text = f"{num} {hm.group(2)}"
|
heading_text = f"{num} {hm.group(2)}"
|
||||||
@@ -229,22 +318,6 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
return block_count
|
return block_count
|
||||||
|
|
||||||
|
|
||||||
def _is_header_footer(text: str, page_num: int) -> bool:
|
|
||||||
"""Erkennt typische Kopf-/Fusszeilen."""
|
|
||||||
lower = text.lower().strip()
|
|
||||||
# Copyright-Zeilen
|
|
||||||
if "copyright" in lower and "schönenberger" in lower:
|
|
||||||
return True
|
|
||||||
# Seitenzahlen
|
|
||||||
if re.match(r"^seite\s+\d+$", lower):
|
|
||||||
return True
|
|
||||||
# Version/Datum Fusszeilen
|
|
||||||
if re.match(r"^version\s+\d+", lower):
|
|
||||||
return True
|
|
||||||
# "Erstellt:" / "Freigabe:" Fusszeilen
|
|
||||||
if lower.startswith("erstellt:") or lower.startswith("freigabe:"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user