Initial commit – AufmaßCreater v2.35
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Project: AufmaßWeb (AufmassCreater v2.35)
|
||||
|
||||
## Obsidian Status-Speicherung
|
||||
**WICHTIG:** Am Ende jeder Session (oder wenn der Nutzer darum bittet), speichere den aktuellen Projektstatus in Obsidian:
|
||||
|
||||
### Vault-Pfad
|
||||
```
|
||||
C:\NC_KPT\Documents\_SecondBrain\DD_KPT\Agentic OS\
|
||||
```
|
||||
|
||||
### Was speichern
|
||||
1. **Journal/<yyyy-MM-dd>.md** – Heutige Session
|
||||
2. **Goals/Current.md** – Gesamtstatus (`Stand:`+ Abgeschlossen + Nächste Schritte)
|
||||
3. **Chats/<yyyy-MM-dd>.md** (optional) – Detaillierte Zusammenfassung
|
||||
|
||||
### Manuelles Skript
|
||||
Im Projekt-Root: `.\save-status.ps1`
|
||||
|
||||
### Wichtige Pfade
|
||||
- Projekt-Root: `C:\NC_KPT\Documents\_Zwischenablage\_Coding\AufmassCreater v2.35 (20260309)`
|
||||
- Web-App: `_aufmass_web\` (vollständig: `C:\NC_KPT\Documents\_Zwischenablage\_Coding\AufmassCreater v2.35 (20260309)\_aufmass_web`)
|
||||
- Python: `C:\Users\FK\AppData\Local\Programs\Python\Python39\python.exe`
|
||||
- Server: `http://localhost:5000`
|
||||
- DB: `_aufmass_web\data\aufmass.db`
|
||||
|
||||
## Modul-Status
|
||||
| Legacy | Größe | Python-Modul | Status |
|
||||
|--------|-------|-------------|--------|
|
||||
| `sasmecka` | 681 Z. | `sas_mecka.py` | ✅ Fertig |
|
||||
| `neffachberg` | 1319 Z. | `neff_achberg.py` | ✅ Fertig (298 Z.) |
|
||||
| `graben` | 1400+ Z. | `graben.py` | ⚠️ Stark vereinfacht |
|
||||
| `ftth` | 1336 Z. | `ftth.py` | ⚠️ Stark vereinfacht |
|
||||
| `gf` | 1638+ Z. | `gf_montage.py` | ⚠️ Stark vereinfacht |
|
||||
| `gruben` | 1225 Z. | `gruben.py` | ⚠️ Stark vereinfacht |
|
||||
| `kabelzug` | 262 Z. | `kabelzug.py` | ⚠️ Vereinfacht |
|
||||
| `absperrung` | 92 Z. | `absperrung.py` | ⚠️ Leicht vereinfacht |
|
||||
|
||||
## Custom Module Builder (Phasen 1-5) ✅
|
||||
- **Phase 1** – CustomModule + CustomModuleAssignment DB-Modelle, CRUD-Routen, Template-Import, Permission-System
|
||||
- **Phase 2** – Drag & Drop Formular-Builder mit 9 Feldtypen, Conditional-Show, Live-Vorschau, Properties-Panel
|
||||
- **Phase 3** – Regel-Builder mit Tabsystem, Bedingungs-Editor (8 Operatoren), Aktions-Editor (LV-Lookup, Spalten-Overrides, Formel-Parser)
|
||||
- **Phase 4** – Execution Engine: Form-Renderer + Rule-Executor, HTMX-Routen (`/formular`, `/berechnen`), Integration in `bearbeiten.html`
|
||||
- **Phase 5** – Drag & Drop Sortierung per Firma mit SortableJS und Batch-Sort-API
|
||||
- **Seed**: "Kabelgraben Standard" mit 9 Feldern + 4 Regeln
|
||||
- **15 Routen**: Index, Neu, Bearbeiten, Builder, Löschen, Importieren, Als-Vorlage, User-Toggle, Form-JSON, Rules-JSON, Available, Formular, Berechnen, Sort-Batch, Sort
|
||||
|
||||
## Wichtige Code-Konventionen
|
||||
- **Zahlen**: `type="text" inputmode="decimal"`, `sanitizeNum()`/`parseNum()`/`germanNum()`/`validateNumField()`
|
||||
- **ST/LE/STD/h/Psch**: Faktor=1,0, Menge leer+amber Highlight
|
||||
- **Einfügeposition**: `rightLastClickedIdx` (nicht `:last-child`)
|
||||
- **Spalten-Filter**: Reine clientseitige Ansichtsfilter, keine Server-Anfrage
|
||||
- **Spalten-Resize**: `pointer-events:none` + 10px Hit-Areal
|
||||
- **Export "Aktuelle Ansicht"**: `?visible_ids=1,2,3`
|
||||
- **PDF-Engine**: fpdf2 mit DejaVuSans (nicht xhtml2pdf)
|
||||
- **TXT-Format**: 13 Felder, `|`-separiert, Abschnitt als 1. Spalte
|
||||
- **Modul-Architektur**: `_lookup_pos()` + `_make_pos()` + `berechne()`
|
||||
|
||||
## Letzte Änderungen (2026-06-09)
|
||||
- Pfade korrigiert: `C:` statt `E:`
|
||||
- NetBird VPS Clean-Rebuild + Dashboard Setup
|
||||
- AGENTS.md mit korrektem Obsidian-Vault-Pfad
|
||||
@@ -0,0 +1,148 @@
|
||||
# NetBird Admin – Erste Schritte
|
||||
|
||||
Dashboard: https://netbird.kpt-lab.de
|
||||
Login: fk@kpt-consulting.de / E7U6*P8hM5Nz
|
||||
|
||||
---
|
||||
|
||||
## 1. Setup Key erstellen
|
||||
|
||||
Setup Keys erlauben Clients (PCs/Laptops), dem Netzwerk beizutreten.
|
||||
|
||||
- Gehe zu **Setup Keys** (Seitenmenü)
|
||||
- Klicke **"Add Setup Key"**
|
||||
- Name: z.B. `DD-Kabelbau`
|
||||
- Key Type: **One-off** (einmalig, gilt für 1 Gerät) **oder Reusable (empfohlen)**
|
||||
- Expires: `30d` oder leer (kein Ablauf)
|
||||
- **Auto-assign Groups**: später
|
||||
- **Speichern** → den Key kopieren & auf den Client-Maschinen verwenden
|
||||
|
||||
## 2. NetBird Client auf Clients installieren
|
||||
|
||||
### Windows
|
||||
```
|
||||
netbird.io → Download → netbird-x.x.x-windows-amd64.msi
|
||||
netbird install
|
||||
netbird up --setup-key NB_XXXX
|
||||
```
|
||||
|
||||
### Linux
|
||||
```bash
|
||||
curl -fsSL https://pkgs.netbird.io/install.sh | sh
|
||||
netbird up --setup-key NB_XXXX
|
||||
```
|
||||
|
||||
### Prüfen
|
||||
```bash
|
||||
netbird status
|
||||
# → Management: Connected
|
||||
# → IP: 100.xxx.xxx.xxx
|
||||
```
|
||||
|
||||
## 3. Access Policies erstellen
|
||||
|
||||
Policies steuern, wer mit wem kommunizieren darf.
|
||||
|
||||
- Gehe zu **Access Control** → **Policies**
|
||||
- **"Add Policy"**
|
||||
- Name: `Alle zu Allen`
|
||||
- Rule: **Traffic**
|
||||
- Source: `Alle` (oder `All Users`, `All Groups`)
|
||||
- Destination: `Alle` (oder `All Users`, `All Groups`)
|
||||
- Ports: `*`
|
||||
- Action: **Allow**
|
||||
- Speichern
|
||||
|
||||
> Für erste Tests reicht eine offene Policy. Später feinere Regeln (z.B. nur bestimmte Peers).
|
||||
|
||||
## 4. Exit Node (VPS als Gateway)
|
||||
|
||||
Damit der VPS den gesamten Traffic der Clients routen kann.
|
||||
|
||||
- **Auf dem VPS-Host (Proxmox – NICHT im CT 111) installieren:**
|
||||
```bash
|
||||
curl -fsSL https://pkgs.netbird.io/install.sh | sh
|
||||
netbird up --setup-key NB_XXXX
|
||||
```
|
||||
- **Im Dashboard:** Gehe zu **Peers** → den VPS-Peer anklicken
|
||||
- **"Enable as exit node"** aktivieren
|
||||
- **Auf dem Client:** `netbird up --exit-node <PEER_ID>` oder im Dashboard per Policy
|
||||
|
||||
> Der VPS-Host ist `159.195.68.61` (Proxmox), **nicht** CT 111.
|
||||
|
||||
## 5. Routes (dauerhafte Netzwerkrouten)
|
||||
|
||||
Wenn Clients bestimmte private Subnetze (z.B. `192.168.222.0/24`) über den NetBird erreichen sollen.
|
||||
|
||||
- Gehe zu **Routes** → **"Add Route"**
|
||||
- Network: `192.168.222.0/24` (euer internes Netz)
|
||||
- Peer: Den VPS-Exit-Node-Peer auswählen
|
||||
- Metric: `1`
|
||||
- Masquerade: `ON` (damit Antworten zurückgeroutet werden)
|
||||
- Enabled: ✔
|
||||
- Speichern
|
||||
|
||||
> **Alternative:** Wenn nur einzelne Dienste exponiert werden sollen, reichen Services (siehe Punkt 7).
|
||||
|
||||
## 6. DNS konfigurieren
|
||||
|
||||
Eigene Domains für NetBird-Endpoints.
|
||||
|
||||
- Gehe zu **DNS** → **"Add Nameserver Group"**
|
||||
- Name: `Intern`
|
||||
- Domains: `intern.kpt-lab.de`
|
||||
- Nameserver: `1.1.1.1` (oder eigener DNS)
|
||||
- **Enabled**: ✔
|
||||
- Speichern
|
||||
|
||||
> Damit können Clients `dienst.intern.kpt-lab.de` auflösen, wenn der DNS-Eintrag im Dashboard gesetzt wird.
|
||||
|
||||
## 7. Reverse Proxy / Services einrichten
|
||||
|
||||
Der NetBird Proxy (läuft auf CT 111) kann interne Dienste per Domain ins Internet exposen.
|
||||
|
||||
> **Voraussetzung:** Der Proxy-Container läuft (Status: Up). Ist erledigt.
|
||||
|
||||
### Service anlegen
|
||||
|
||||
- Gehe zu **Services** → **"Add Service"**
|
||||
- **Domain:** `z.b.gitea.pr.kpt-lab.de`
|
||||
- Die Domain muss via DNS auf `159.195.68.61` zeigen (bereits erledigt: `*.pr.kpt-lab.de`)
|
||||
- **Backend:** Den internen NetBird-Peer auswählen (IP:Port des Dienstes)
|
||||
- z.B. `192.168.222.40:3000` für Gitea
|
||||
- **TCP Ports:** leer lassen (Default)
|
||||
- **HTTP:** ON (für http->https redirect)
|
||||
- Speichern
|
||||
|
||||
> Der Proxy holt automatisch ein Let's Encrypt Zertifikat.
|
||||
|
||||
### Wichtige Domains
|
||||
| Domäne | Ziel |
|
||||
|--------|------|
|
||||
| `*.pr.kpt-lab.de` | → öffentlich über Traefik (CT 111) |
|
||||
| DNS im Dashboard setzen | → NetBird-interne Auflösung |
|
||||
|
||||
### Nächste Dienste die du anlegen könntest
|
||||
| Service | Interne Addr (NetBird-IP) | Domain |
|
||||
|---------|--------------------------|--------|
|
||||
| Gitea | `100.x.x.x:3000` | `gitea.pr.kpt-lab.de` |
|
||||
| AufmaßWeb (lokal) | `100.x.x.x:5000` | `aufmass.pr.kpt-lab.de` |
|
||||
|
||||
## 8. Nächste Schritte
|
||||
|
||||
1. **Exit Node einrichten** → NetBird Client auf dem Proxmox-Host installieren
|
||||
2. **Gitea neu aufsetzen** (alter CT 404 ist gelöscht)
|
||||
3. **AufmaßWeb lokal starten** → Service im Dashboard anlegen
|
||||
4. **Access Policies verfeinern** (nicht alle zu allen)
|
||||
|
||||
---
|
||||
|
||||
## Kurzreferenz NetBird CLI (Client)
|
||||
|
||||
```bash
|
||||
netbird up --setup-key NB_KEY # Verbinden
|
||||
netbird up --exit-node <PEER_ID> # Exit-Node nutzen
|
||||
netbird status # Status anzeigen
|
||||
netbird down # Trennen
|
||||
netbird service install # Autostart (Windows/Linux)
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
.env.local
|
||||
*.db
|
||||
*.xlsx
|
||||
*.pdf
|
||||
*.zip
|
||||
*.x31
|
||||
*.x31ca
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
data/exports
|
||||
data/uploads
|
||||
@@ -0,0 +1,11 @@
|
||||
# AufmaßWeb – Konfiguration
|
||||
# Kopiere diese Datei nach .env und passe die Werte an
|
||||
|
||||
SECRET_KEY=ersetzen-mit-sicherem-schluessel-mindestens-32-zeichen
|
||||
|
||||
# SQLite (Entwicklung lokal):
|
||||
# DATABASE_URL=sqlite:///data/aufmass.db
|
||||
|
||||
# PostgreSQL (Produktion / Docker):
|
||||
DATABASE_URL=postgresql://user:password@host:5432/aufmassweb
|
||||
FLASK_ENV=production
|
||||
@@ -0,0 +1,16 @@
|
||||
# OpenCode Agents-Regeln für AufmaßWeb
|
||||
|
||||
## Session-Persistenz in Obsidian
|
||||
Nach jeder Sitzung (wenn der Nutzer darum bittet oder bei signifikanten Änderungen):
|
||||
1. Aktualisiere `C:\NC_KPT\Documents\_SecondBrain\DD_KPT\Agentic OS\Chats\AufmassWeb – Vollständiger Chat-Kontext.md`
|
||||
2. Füge neue "Done"-Einträge unter dem aktuellen Datum hinzu
|
||||
3. Aktualisiere "In Progress", "Next Steps", "Next Up" und "File-Referenzen"
|
||||
4. Korrigiere ggf. den Projekt-Pfad (aktuell: `C:\NC_KPT\Documents\_Zwischenablage\_Coding\AufmassCreater v2.35 (20260309)\_aufmass_web`)
|
||||
|
||||
## Wichtige Code-Konventionen
|
||||
- **Zahlen**: `type="text" inputmode="decimal"`, `sanitizeNum()`/`parseNum()`/`germanNum()`/`validateNumField()`
|
||||
- **ST/LE/STD/h/Psch**: Faktor=1,0, Menge leer+amber Highlight
|
||||
- **Einfügeposition**: `rightLastClickedIdx` (nicht `:last-child`)
|
||||
- **Spalten-Filter**: Reine clientseitige Ansichtsfilter, keine Server-Anfrage
|
||||
- **Spalten-Resize**: `pointer-events:none` + 10px Hit-Areal
|
||||
- **Export "Aktuelle Ansicht"**: `?visible_ids=1,2,3`
|
||||
@@ -0,0 +1,388 @@
|
||||
# Anforderungen – AufmaßWeb v2.35
|
||||
|
||||
## Projektübersicht
|
||||
Flask-basierte Webanwendung zur Verwaltung von Aufmaßen, Projekten und Positionen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Projektübersicht (`/projekt/`)
|
||||
|
||||
### 1.1 Sortierung
|
||||
- Projekte werden nach **Bezeichnung A-Z** aufsteigend sortiert angezeigt
|
||||
- NULL-Werte werden als leerer String behandelt (sortieren ans Ende)
|
||||
|
||||
### 1.2 Projektsuche
|
||||
- Suchleiste oben auf der Seite
|
||||
- Clientseitige Filterung nach Projektname (Bezeichnung / SM-Nr)
|
||||
- Echtzeit-Filterung bei Eingabe, Clear-Button vorhanden
|
||||
|
||||
### 1.3 Baumansicht
|
||||
- Jedes Projekt als aufklappbarer `<details>`-Block
|
||||
- Zeigt: Bezeichnung, Summe (€), Anzahl Aufmaße, Anzahl Positionen, Status
|
||||
- Doppelklick auf Projekt-Header navigiert zur Listenansicht (`/projekt/<id>`)
|
||||
|
||||
### 1.4 Aufmaß-Suche
|
||||
- Suchleiste filtert Aufmaß-Namen innerhalb der Baumansicht
|
||||
|
||||
### 1.5 Inline-Bearbeitung Aufmaß-Name
|
||||
- ✏️-Button pro Aufmaß → Inline-Input für Name
|
||||
- Speichern: Blur oder Enter
|
||||
- Abbrechen: Escape
|
||||
- Kein OK/Abbrechen-Button, kein setTimeout
|
||||
|
||||
### 1.6 Inline-Bearbeitung Projekt-Name
|
||||
- ✏️-Button im Projekt-Header → Inline-Input für Name
|
||||
- Speichern: Blur oder Enter
|
||||
- Abbrechen: Escape
|
||||
- POST an `/projekt/<id>/update-name`
|
||||
|
||||
### 1.7 Aufmaß löschen
|
||||
- ✕-Button pro Aufmaß, keine Bestätigung (ohne `confirm()`)
|
||||
- Redirect per `request.referrer`: in Baumansicht → bleibt in `index`, in Detailansicht → bleibt in `list`
|
||||
|
||||
### 1.8 Aufmaß duplizieren
|
||||
- Duplizieren-Button (📋) pro Aufmaß, keine Bestätigung
|
||||
- Erstellt Kopie mit allen Positionen, hängt ` (Kopie)` an den Namen
|
||||
- Bei Namenskonflikt: automatisch ` (1)`, ` (2)` etc.
|
||||
- Redirect per `request.referrer` (wie Löschen)
|
||||
|
||||
### 1.9 Baum-Status persistent
|
||||
- `<details>` open/close-Zustand in `localStorage`
|
||||
- Schlüssel: `tree_open_{projectId}`
|
||||
- Überlebt Seitenneuladungen nach Aktionen
|
||||
|
||||
---
|
||||
|
||||
## 2. Neues Aufmaß erstellen
|
||||
|
||||
### 2.1 Button
|
||||
- **"+ Neues Aufmaß"**-Button unter jedem Projekt in der Baumansicht
|
||||
- Klick klappt ein vollständiges Formular auf (slide-down)
|
||||
|
||||
### 2.2 Formular-Felder (Reihenfolge)
|
||||
1. **Vertrag** (Dropdown, optional)
|
||||
2. **LV-Name** (Text, optional)
|
||||
3. **Typ** (Dropdown, optional)
|
||||
4. **Bezeichnung (Baustelle)** (Text, leer)
|
||||
5. **Bauabschnitt** (Text, pre-filled aus Projekt)
|
||||
6. **SM-Nr** (Text, pre-filled aus Projekt)
|
||||
7. **Abruf-Nr** (Text, pre-filled aus Projekt)
|
||||
8. **Kopfdaten EV holen** (Button, bedingt sichtbar – siehe Abschnitt 11)
|
||||
9. **Ansprechpartner** (Trennlinie)
|
||||
- Vorname
|
||||
- Name
|
||||
- Tel
|
||||
- Email
|
||||
10. **Startdatum** (Date)
|
||||
11. **Endedatum** (Date)
|
||||
12. **Aufmaß-Name** (readonly, automatisch generiert)
|
||||
|
||||
### 2.3 Automatische Aufmaß-Namensgenerierung
|
||||
- Zusammensetzung: `Bezeichnung + " - " + Bauabschnitt + " - " + SM-Nr + " - " + Abruf-Nr`
|
||||
- Nur nicht-leere Felder werden berücksichtigt
|
||||
- Wird live bei Eingabe aktualisiert (readonly-Feld)
|
||||
- Nur OS/Nextcloud-sichere Zeichen: `< > : " / \ | ? * & # % { } ~ [ ]` werden entfernt, Mehrfachleerzeichen → eines
|
||||
|
||||
### 2.4 Speichern
|
||||
- Aktualisiert Projekt-Metadaten (SM-Nr, Vertrag, LV-Name, Abruf-Nr, Daten, Ansprechpartner)
|
||||
- `project.bezeichnung` wird NICHT überschrieben
|
||||
- Erstellt neues Aufmaß mit generiertem Namen und gewähltem Typ
|
||||
- Redirect zur Projektübersicht
|
||||
|
||||
---
|
||||
|
||||
## 3. Neues Projekt erstellen (`/projekt/neu`)
|
||||
|
||||
### 3.1 Formular (vereinfacht)
|
||||
- **Projektname / Bezeichnung** (Pflichtfeld)
|
||||
- **Vertrag** (Dropdown, optional)
|
||||
- **LV-Name** (Dropdown, optional, abhängig von Vertrag)
|
||||
- Kein Standard-Aufmaß wird automatisch erstellt
|
||||
|
||||
---
|
||||
|
||||
## 4. Projekt-Listenansicht (`/projekt/<id>`)
|
||||
|
||||
### 4.1 Aufmaß-Liste
|
||||
- Alle Aufmaße eines Projekts als Tabelle
|
||||
- Inline-Editing für Name, Typ, Status
|
||||
- Sofortige visuelle Aktualisierung vor async-fetch
|
||||
- Kein `resp.status`-Check (Server-Response enthält Aufmaß-Status, nicht 'ok')
|
||||
|
||||
### 4.2 Projekt-Name inline bearbeiten
|
||||
- ✏️-Button in der Projektkopfzeile
|
||||
- Gleiche Funktionalität wie in Baumansicht (POST `/projekt/<id>/update-name`)
|
||||
|
||||
### 4.3 Aufmaß erstellen (in Listenansicht)
|
||||
- Formular mit Name + Typ + Sortierung
|
||||
|
||||
### 4.4 TXT-Import
|
||||
- 📥 Import-Button → Lädt TXT-Datei
|
||||
- Parst `[Kopfdaten]` + `[Aufmaßdaten]`
|
||||
- Erstellt Aufmaß mit Positionen
|
||||
- Bei doppeltem Namen: automatisch ` (1)`, ` (2)` etc. anfügen
|
||||
|
||||
---
|
||||
|
||||
## 5. Aufmaß-Editor (`/projekt/<id>/<aufmass_id>`)
|
||||
|
||||
### 5.1 Positionstabelle
|
||||
- Inline-Editing für alle Positionsfelder
|
||||
- Speichern: Blur oder Enter
|
||||
- Sofortige visuelle Aktualisierung vor async-fetch
|
||||
- Kein `resp.status`-Check (Server-Response enthält Aufmaß-Status, nicht 'ok')
|
||||
|
||||
### 5.2 Zahlenformat
|
||||
- Alle Zahleneingaben: `type="text" inputmode="decimal"`
|
||||
- **Deutsches Format**: Komma = Dezimaltrenner, Punkt = Tausendertrenner
|
||||
- `germanNum()`: Zahl → deutsche Anzeige (z.B. `1.234,56`)
|
||||
- `parseNum()`: Deutsche Eingabe → Zahl (z.B. `1.234,56` → `1234.56`)
|
||||
- `sanitizeNum()`: Entfernt ungültige Zeichen live bei Eingabe
|
||||
- `validateNumField()`: Rote Border bei ungültigem Format
|
||||
- **Validierung**: Erlaubt sind `123`, `1.234`, `1,5`, `1.234,56`, `-1,5`
|
||||
- Ungültig: Buchstaben, `1,2,3`, `1.2.3`, etc.
|
||||
|
||||
### 5.3 ST/LE/STD/h/Psch – Menge-Verhalten
|
||||
- Faktor-Feld ist immer `1,0` (kein Highlight)
|
||||
- Bei ST/LE/STD/h/Psch wird **Menge** hervorgehoben (amber `#fff3cd`, fett) und ist **leer** – die Menge ist hier das relevante Feld, muss aber vom Benutzer über die Berechnung (Faktor-Eingabe) bestimmt werden
|
||||
- Bei M/M2/M3 werden die Dimensionsfelder (Länge/Breite/Tiefe) hervorgehoben
|
||||
- Menge ist readonly und wird automatisch berechnet: ST/LE/STD/h/Psch → `Faktor × 1`, M → Länge, M2 → L×B, M3 → L×B×T
|
||||
|
||||
### 5.4 Formel-Auswahl
|
||||
- **Standard**: Länge × Breite × Tiefe (abhängig von Einheit)
|
||||
- **Frei**: Eigenes Formelfeld
|
||||
- Typabhängige Markierung der Pflichtfelder (Länge, Breite, Tiefe) mit amber
|
||||
|
||||
### 5.5 Einfüge-Position ("Am Ende anfügen")
|
||||
- Checkbox "Am Ende anfügen" (default: checked)
|
||||
- **checked**: Neue Positionen werden am Ende der Tabelle angefügt
|
||||
- **unchecked**: Neue Positionen werden unterhalb der zuletzt markierten Position eingefügt
|
||||
- Klick auf eine Position in der rechten Tabelle setzt die Einfügemarke, ohne die LV-Auswahl zu beeinflussen
|
||||
|
||||
### 5.6 LV-Tabelle (linke Seite)
|
||||
- Spalten: Pos-Nr, Kurztext, EP (deutsch formatiert via `|german_number`), Einheit
|
||||
- Einfachklick: LV-Zeile markieren, Formular füllen
|
||||
- **Multi-Select**: Strg+Klick markiert mehrere Zeilen
|
||||
- **Drag & Drop**: Ziehen einer LV-Zeile überträgt alle markierten IDs (nicht nur die gezogene)
|
||||
- **Standard-Menge bei Drag**: Nach LV-Drag bleibt menge=0 (keine automatische `berechne_menge()`)
|
||||
- `fillForm()` setzt `currentLVRow` / `currentLVId` pro Zeile
|
||||
- Klick auf Positionstabelle löscht NICHT `currentLVRow` oder die LV-Auswahl
|
||||
|
||||
### 5.7 Hinzufügen-Button
|
||||
- **"Hinzufügen"** und **"Auswahl hinzufügen"**: Beide verwenden `selectedSet` (alle markierten LV-Zeilen) + Formularwerte (Overrides)
|
||||
- Bei Mehrfachauswahl (>1 LV-Zeile) werden Text-Overrides (`pos_nr`, `kurztext`, `abschnitt`, `bemerkung`) **nicht** gesendet – jede Position behält ihre LV-eigenen Textwerte
|
||||
- Dimensions-Overrides (Faktor, Länge, Breite, Tiefe, Formel) werden immer für alle markierten Positionen verwendet
|
||||
- Beide nutzen `getInsertIdxFromCheckbox()` für die Einfügeposition
|
||||
|
||||
### 5.8 Positionen importieren (TXT)
|
||||
- 📥 Button "Import TXT" im Tabellen-Footer
|
||||
- Lädt nur `[Aufmaßdaten]` aus einer TXT-Datei
|
||||
- Fügt Positionen in das aktuelle Aufmaß ein
|
||||
- Bestehende Kopfdaten bleiben unverändert
|
||||
|
||||
### 5.9 Kontext-Menü (Rechtsklick)
|
||||
- Rechtsklick auf eine Position in der Tabelle öffnet ein Kontext-Menü
|
||||
- **Markierte löschen** (✕) – löscht alle markierten Positionen (wie Button unten)
|
||||
- **Markierte kopieren** (📋) – kopiert alle markierten Positionen (wie Button unten)
|
||||
- **Leere Zeile einfügen** (📄) – fügt eine leere Zeile unterhalb der angeklickten Position ein
|
||||
- Beim Rechtsklick ohne Ctrl/Shift wird die angeklickte Zeile zuerst selektiert (Single-Select)
|
||||
- Rechtsklick auf Trenner/Leerzeilen ist ebenfalls möglich (setzt Einfügepunkt)
|
||||
- Menü schließt bei Klick außerhalb oder Escape
|
||||
|
||||
### 5.10 Spalten-Filter (Excel-ähnlich)
|
||||
- Jede Spaltenüberschrift (außer Z-Art und ✕) hat einen ▾-Filterbutton
|
||||
- Klick öffnet ein Dropdown-Menü mit:
|
||||
- **↑ Aufsteigend / ↓ Absteigend sortieren** (Sortierung aufheben mit ✕)
|
||||
- **Suchen…** (Livesuche filtert Werte-Liste + Tabelle)
|
||||
- **Checkbox-Liste** aller vorkommenden Werte mit **Alles**/**Nichts**
|
||||
- Mehrere Spalten können gleichzeitig gefiltert werden (Und-Verknüpfung)
|
||||
- Aktive Filter werden durch blaue ▾-Buttons angezeigt
|
||||
- **↺ Filter zurücksetzen**-Button lädt die Tabelle neu und entfernt alle Filter
|
||||
- Nur Datenzeilen (mit `data-id`) werden gefiltert – Trenner bleiben sichtbar
|
||||
- Sortieren + Filtern sind kombinierbar und wirken nur clientseitig (Ansichtsfilter)
|
||||
- Einfügen, Löschen und Bearbeiten sind auch im gefilterten Zustand möglich
|
||||
|
||||
### 5.11 Export (Excel, PDF, TXT) mit Ansichtsfilter
|
||||
- Buttons: **📊 Excel**, **📄 PDF**, **📄 TXT** in der Kopfzeile
|
||||
- Checkbox **"Aktuelle Ansicht"** – wenn aktiv, werden nur sichtbare (gefilterte) Positionen exportiert
|
||||
- TXT-Export erzeugt UTF-8-Datei im Format `[Kopfdaten]` + `[Aufmaßdaten]` (pipe-separiert)
|
||||
- Export-Route akzeptiert `?visible_ids=1,2,3` zum Filtern
|
||||
|
||||
### 5.12 LV-Tabelle Filter
|
||||
- Gleiches Filter-UI wie Positionstabelle (▾-Buttons in Spaltenüberschriften)
|
||||
- Spalten: Pos-Nr, Kurztext, EH, EP
|
||||
- **Sortieren** (↑/↓) und **Suchen** (Text-Livesuche)
|
||||
- Keine Checkbox-Liste (LV hat zu viele unique values)
|
||||
- Aktive Filter werden durch blaue ▾-Buttons angezeigt
|
||||
|
||||
### 5.13 Spaltenbreite anpassen (ziehen)
|
||||
- Jede Spaltenüberschrift hat rechts einen unsichtbaren Resize-Handle (5px breit)
|
||||
- **Ziehen**: Breite ändert sich live, bei Loslassen in `localStorage` gespeichert
|
||||
- **Doppelklick** auf Handle: automatische optimale Breite (maximale Inhaltsbreite)
|
||||
- **Rechtsklick** auf Spaltenkopf → Kontext-Menü mit **"💾 Ansicht speichern"**
|
||||
- Fixiert die aktuellen Spaltenbreiten als `saved_lv_col_widths` / `saved_pos_col_widths`
|
||||
- Nach Seitenneuladung werden gespeicherte Breiten wiederhergestellt
|
||||
- Funktion `saveColumnWidths()` speichert aktuelle Breiten separat als "gespeicherte Ansicht"
|
||||
- Gespeicherte Breiten überleben Seitenneuladungen (werden sofort beim Laden angewandt)
|
||||
|
||||
### 5.14 Positionstabelle Farben
|
||||
- `has-background-warning-light` (gelb) nur wenn Wert=0 UND passende Einheit (z.B. Menge=0 bei ST)
|
||||
|
||||
### 5.10 LV-Ergebnis EP
|
||||
- EP-Werte in der LV-Tabelle werden mit deutschem Zahlenformat angezeigt (`|german_number`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Datenbank
|
||||
|
||||
### 6.1 Projekt-Modell (`projekte`)
|
||||
- `id`, `company_id`, `contract_id`
|
||||
- `sm_nr`, `bezeichnung`, `vertrag`, `abruf_nr`, `lv_name`
|
||||
- `datum_start`, `datum_ende`
|
||||
- `ansprechpartner_vorname`, `ansprechpartner_nachname`, `ansprechpartner_tel`, `ansprechpartner_email`
|
||||
- `bauabschnitt`, `status`
|
||||
- `erstellt_von`, `erstellt_am`, `geaendert_am`
|
||||
|
||||
### 6.2 Lizenz-Modell (`lizenzen`)
|
||||
- `uid` (auto-generiert SHA256)
|
||||
- `max_mitarbeiter`, `max_module_slots`
|
||||
- `unlimited_users`, `unlimited_modules`
|
||||
- Properties: `used_users`, `used_module_slots`, `user_slots_display()`, `module_slots_display()`
|
||||
|
||||
### 6.3 Datenbank-Engine
|
||||
- Lokal: SQLite (`data/aufmass.db`)
|
||||
- Produktion: PostgreSQL via `DATABASE_URL` Umgebungsvariable
|
||||
- Docker-Setup mit PostgreSQL-Service vorhanden
|
||||
|
||||
---
|
||||
|
||||
## 7. Docker
|
||||
|
||||
### 7.1 Services
|
||||
- `db`: PostgreSQL 16-alpine mit Healthcheck
|
||||
- `web`: Flask-App mit Gunicorn (4 Worker)
|
||||
|
||||
### 7.2 Konfiguration
|
||||
- `DATABASE_URL` wird via Environment Variable gesetzt
|
||||
- `SECRET_KEY` muss vor Produktion geändert werden
|
||||
- Volume `pgdata` für persistente Daten
|
||||
|
||||
---
|
||||
|
||||
## 8. Rollen & Berechtigungen
|
||||
|
||||
### 8.1 Superadmin
|
||||
- Zugriff auf alle Projekte, Firmen, Lizenzen
|
||||
- Firmenverwaltung & Lizenzverwaltung im Menü
|
||||
- Dashboard: Lizenz-Statistiken, Firmen-Übersicht
|
||||
|
||||
### 8.2 Firmadmin
|
||||
- Zugriff auf Projekte der eigenen Firma
|
||||
- Mitarbeiterverwaltung, Rechteverwaltung
|
||||
- Lizenz-Übersicht
|
||||
|
||||
### 8.3 Mitarbeiter
|
||||
- Zugriff nur auf zugewiesene Projekte (ProjectAccess)
|
||||
- Berechtigungen: lesen, schreiben, aufmass_verwalten, projekte_anlegen, darf_preise_sehen, darf_lv_verwalten
|
||||
|
||||
---
|
||||
|
||||
## 9. Login-Daten
|
||||
|
||||
| Rolle | Email | Passwort |
|
||||
|---|---|---|
|
||||
| Superadmin | super@admin.de | admin123 |
|
||||
| Firmadmin | firmadmin@kpt.de | firma123 |
|
||||
|
||||
---
|
||||
|
||||
## 10. Wichtige technische Regeln
|
||||
|
||||
### 10.1 Inline-Edit
|
||||
- Blur/Enter speichert, Escape bricht ab
|
||||
- Keine OK/X-Buttons, kein setTimeout
|
||||
|
||||
### 10.2 Visuelle Aktualisierung
|
||||
- SOFORT vor async-fetch, nicht im Callback
|
||||
|
||||
### 10.3 Server-Response (resp.status)
|
||||
- **`resp.status`** ist das Aufmaß-Statusfeld ('aktiv'), NICHT ein Erfolgsindikator
|
||||
- **Kein `resp.status !== 'ok'`-Check**
|
||||
|
||||
### 10.4 Projekt-Name schützen
|
||||
- `project.bezeichnung` wird in `aufmass_neu_voll` und `aufmass_import` NICHT überschrieben
|
||||
- Nur explizit via `POST /projekt/<id>/update-name` änderbar
|
||||
|
||||
### 10.5 Redirect-Verhalten
|
||||
- Löschen, Duplizieren, Import: `request.referrer` prüfen
|
||||
- Baumansicht (`/projekt/`) → Redirect zu `aufmass.index`
|
||||
- Detailansicht (`/projekt/<id>`) → Redirect zu `aufmass.aufmass_list`
|
||||
- Erkennung: `referrer.rstrip('/').endswith('/projekt')`
|
||||
|
||||
### 10.6 Namensvalidierung
|
||||
- Aufmaß-Name: nur OS/Nextcloud-sichere Zeichen
|
||||
- Entfernt: `< > : " / \ | ? * & # % { } ~ [ ]`
|
||||
- Mehrfachleerzeichen → eines
|
||||
|
||||
---
|
||||
|
||||
## 11. E-Vergabe Addon
|
||||
|
||||
### 11.1 Firmeneinstellungen (Firmadmin)
|
||||
- Neuer Bereich **"E-Vergabe"** in den Firmeneinstellungen (`/admin/firma`)
|
||||
- Sichtbar nur wenn E-Vergabe-Addon für die Firma freigeschaltet ist (durch Superadmin)
|
||||
- Felder:
|
||||
- **Benutzer** (aus `_legacy/daten/conf.ini` → `[EVergabe] Benutzer`)
|
||||
- **Passwort** (aus `_legacy/daten/conf.ini` → `[EVergabe] Passwort`)
|
||||
- **Name** (aus `_legacy/daten/conf.ini` → `[EVergabe] Name`)
|
||||
|
||||
### 11.2 Superadmin – E-Vergabe Addon freigeben
|
||||
- Superadmin kann E-Vergabe-Addon pro Firma freischalten/deaktivieren
|
||||
- Dashboard: Spalte "E-Vergabe" mit Status und Toggle-Button (EV +/EV ✕)
|
||||
- Firma-Detail: E-Vergabe-Bereich mit Status und Freischalten/Deaktivieren-Button
|
||||
|
||||
### 11.3 Berechtigungen (User-Ebene)
|
||||
Drei neue Berechtigungen für Mitarbeiter:
|
||||
- **E-Vergabe Addon nutzen** (`darf_evergabe_nutzen`) – Grundvoraussetzung für alle E-Vergabe-Funktionen
|
||||
- **Kopfdaten holen erlauben** (`darf_kopfdaten_holen`) – Erlaubt das Abrufen von Kopfdaten aus der E-Vergabe
|
||||
- **Aufmaße in E-Vergabe übertragen** (`darf_aufmass_uebertragen`) – Erlaubt das Übertragen von Aufmaßen
|
||||
|
||||
### 11.4 "Kopfdaten EV holen" Button
|
||||
- Sichtbar nur wenn:
|
||||
- User hat `darf_evergabe_nutzen` UND `darf_kopfdaten_holen`
|
||||
- Firma hat `evergabe_aktiviert = True`
|
||||
- Anklickbar nur wenn:
|
||||
- Firma hat `evergabe_benutzer` UND `evergabe_passwort` hinterlegt
|
||||
- Mouseover-Hinweis wenn nicht anklickbar: *"Es sind keine E-Vergabe Logindaten hinterlegt."*
|
||||
- Funktion: Ruft Kopfdaten von der E-Vergabe-API ab und füllt die Formularfelder (Bezeichnung, Bauabschnitt, SM-Nr, Abruf-Nr, Daten, Ansprechpartner)
|
||||
|
||||
### 11.5 Company-Modell (neue Felder)
|
||||
- `evergabe_aktiviert` (Boolean, default=False) –Addon freigeschaltet
|
||||
- `evergabe_benutzer` (String) – Login-Benutzername
|
||||
- `evergabe_passwort` (String) – Login-Passwort
|
||||
- `evergabe_name` (String) – Name/Kennung
|
||||
|
||||
### 11.6 E-Vergabe Service (`evergabe_service.py`)
|
||||
Python-Port der AutoIt `EVvergabeWebobj.au3` Funktionen:
|
||||
- **`EVergabeClient`** Klasse mit `requests.Session()` für Cookie-Handling
|
||||
- **`login()`** – Login via POST `/public/login` mit CSRF-Token-Extraktion
|
||||
- **`search_sm(sm_nr)`** – SM-Nr-Suche via GET `/framework-agreement-call`, extrahiert Title, Bedarfsnr, Belegnr, DetailsID, RV, Daten, Status
|
||||
- **`get_aspa(details_id)`** – Ansprechpartner-Daten (Name, Email, Tel) aus Details-Seite
|
||||
- **`hole_kopfdaten(sm_nr)`** – Kombiniert Login + Suche + ASPA → Rückgabe aller Kopfdaten
|
||||
- **`aufmass_uebertragen(sm_nr, positionen, ...)`** – Überträgt Positionen in E-Vergabe:
|
||||
- Erstellt Sheet via `/sheet/create-sheet`
|
||||
- Für jede Position: Erstellt Position, dann INSERT je nach Einheit (ST, M, M2, M3, STD, LE)
|
||||
- Richtige Headers: User-Agent, Referer, Content-Type, CSRF-Token
|
||||
- URL-Encoding aller Parameter
|
||||
|
||||
### 11.7 Aufmaß in E-Vergabe übertragen
|
||||
- Route: `POST /projekt/<id>/aufmass/<aufmass_id>/evergabe-uebertragen`
|
||||
- Berechtigung: `darf_evergabe_nutzen` + `darf_aufmass_uebertragen`
|
||||
- Überträgt alle Positionen des Aufmaßes in die E-Vergabe-Plattform
|
||||
- Rückgabe: JSON mit Erfolg/Fehler pro Position
|
||||
- `evergabe_sub` (String) – Sub-Kennung
|
||||
|
||||
### 11.8 User-Modell (neue Felder)
|
||||
- `darf_evergabe_nutzen` (Boolean, default=False)
|
||||
- `darf_kopfdaten_holen` (Boolean, default=False)
|
||||
- `darf_aufmass_uebertragen` (Boolean, default=False)
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libpq-dev fonts-dejavu-core && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--preload", "run:app"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
[Kopfdaten]
|
||||
Teilaufma=
|
||||
Schlussaufma=X
|
||||
Datum=20.05.2026
|
||||
Baustelle=Doku
|
||||
AbrufNr=4213906453
|
||||
SMNr=211826415
|
||||
Vertrag=LV-KPT-Proj+Doku-2024ff
|
||||
StartZ=15.01.2026
|
||||
EndZ=01.07.2026
|
||||
AspaN=Metta Enrico
|
||||
AspaTel=+49 621 294
|
||||
Bauabschnitt=NVT 56V1102 FED
|
||||
Kolone=
|
||||
[Aufmaßdaten]
|
||||
|10086332|1,0|95|||95|M|Dokumentation von Cu/Gf VzK-Trassen||95|1,25|118,75
|
||||
|10086667|1,0||||4|ST|Dokumentation vorversorgtes Grundstück||4|22,47|89,88
|
||||
@@ -0,0 +1,165 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for
|
||||
from config import Config
|
||||
from app.extensions import db, login_manager, migrate
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Bitte melden Sie sich an.'
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.company import Company
|
||||
from app.models.license import License
|
||||
from app.models.module import Module
|
||||
from app.models.lv import LVPosition
|
||||
from app.models.project import Project
|
||||
from app.models.aufmass import Aufmass
|
||||
from app.models.project_access import ProjectAccess
|
||||
from app.models.position import Position
|
||||
from app.models.contract import Contract
|
||||
from app.models.view_profile import ViewProfile
|
||||
from app.models.company_module import CompanyModule
|
||||
from app.models.user_module import UserModulePermission
|
||||
from app.models.aufmass_typ import AufmassTyp
|
||||
from app.models.aufmass_history import AufmassHistory
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
user = User.query.get(int(user_id))
|
||||
if user:
|
||||
from flask import session
|
||||
session.setdefault('font_size', user.font_size or '1')
|
||||
return user
|
||||
|
||||
from app.routes.auth import auth_bp
|
||||
from app.routes.admin import admin_bp
|
||||
from app.routes.lv import lv_bp
|
||||
from app.routes.aufmass import aufmass_bp
|
||||
from app.routes.export import export_bp
|
||||
from app.routes.modules import modules_bp
|
||||
from app.routes.contracts import contracts_bp
|
||||
from app.routes.views import views_bp
|
||||
from app.routes.superadmin import superadmin_bp
|
||||
from app.routes.custom_modules import custom_modules_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||
app.register_blueprint(lv_bp, url_prefix='/lv')
|
||||
app.register_blueprint(aufmass_bp, url_prefix='/projekt')
|
||||
app.register_blueprint(export_bp, url_prefix='/export')
|
||||
app.register_blueprint(modules_bp, url_prefix='/modules')
|
||||
app.register_blueprint(contracts_bp, url_prefix='/contracts')
|
||||
app.register_blueprint(views_bp)
|
||||
app.register_blueprint(superadmin_bp, url_prefix='/superadmin')
|
||||
app.register_blueprint(custom_modules_bp, url_prefix='/custom-modules')
|
||||
|
||||
@app.route('/aufmass/')
|
||||
@app.route('/aufmass/<path:subpath>')
|
||||
def _aufmass_redirect(subpath=''):
|
||||
return redirect('/projekt/' + subpath), 301
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
if current_user.is_superadmin():
|
||||
return redirect(url_for('superadmin.dashboard'))
|
||||
return redirect(url_for('admin.dashboard'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@app.route('/font-size/<size>')
|
||||
def set_font_size(size):
|
||||
from flask import session
|
||||
from flask_login import current_user
|
||||
if size in ('0.8', '0.9', '1', '1.1', '1.25', '1.5'):
|
||||
session['font_size'] = size
|
||||
if current_user.is_authenticated:
|
||||
current_user.font_size = size
|
||||
try:
|
||||
db.session.commit()
|
||||
except:
|
||||
db.session.rollback()
|
||||
return redirect(request.referrer or url_for('admin.dashboard'))
|
||||
|
||||
@app.template_filter('german_number')
|
||||
def german_number_filter(value, precision=2, zero_dash=False):
|
||||
if value is None or (zero_dash and value == 0):
|
||||
return '\u2013'
|
||||
try:
|
||||
s = f'{float(value):.{precision}f}'
|
||||
parts = s.split('.')
|
||||
int_part = parts[0]
|
||||
dec_part = parts[1] if len(parts) > 1 else '0'*precision
|
||||
int_part = '{:,}'.format(int(int_part)).replace(',', '.')
|
||||
return f'{int_part},{dec_part}'
|
||||
except (ValueError, TypeError):
|
||||
return '\u2013' if zero_dash else '0,00'
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(e):
|
||||
return render_template('errors/500.html'), 500
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
_seed_defaults()
|
||||
|
||||
return app
|
||||
|
||||
def _seed_defaults():
|
||||
from app.extensions import db
|
||||
from app.models.module import Module
|
||||
|
||||
def upsert(name, titel, kategorie, icon, standard, sortierung=0):
|
||||
m = Module.query.filter_by(name=name).first()
|
||||
if m:
|
||||
m.titel = titel
|
||||
m.kategorie = kategorie
|
||||
m.icon = icon
|
||||
m.standard = standard
|
||||
m.sortierung = sortierung
|
||||
else:
|
||||
db.session.add(Module(name=name, titel=titel, kategorie=kategorie, icon=icon, standard=standard, sortierung=sortierung))
|
||||
|
||||
# 1. Tote Module entfernen (inkl. FK-Referenzen)
|
||||
for dead_name in ('mfg', 'stoersammler'):
|
||||
old = Module.query.filter_by(name=dead_name).first()
|
||||
if old:
|
||||
from app.models.license import LicenseModule
|
||||
from app.models.company_module import CompanyModule
|
||||
LicenseModule.query.filter_by(module_id=old.id).delete()
|
||||
CompanyModule.query.filter_by(module_id=old.id).delete()
|
||||
db.session.delete(old)
|
||||
db.session.commit()
|
||||
|
||||
# 2. Alle Module upserten
|
||||
upsert('graben', 'Graben', 'Tiefbau', '🔲', standard=True)
|
||||
upsert('gruben', 'Gruben', 'Tiefbau', '🕳️', standard=True)
|
||||
upsert('gf_montage', 'GF-Montage', 'Glasfaser', '🔗', standard=True)
|
||||
upsert('ftth', 'FTTH', 'Glasfaser', '🏠', standard=True)
|
||||
upsert('kabelzug', 'Kabelzug', 'Tiefbau', '🔌', standard=True)
|
||||
upsert('absperrung', 'Absperrung', 'Tiefbau', '🚧', standard=True)
|
||||
upsert('sas_mecka', 'SAS Meckenbeuren', 'Spezial', '📍', standard=False)
|
||||
upsert('neff_achberg', 'Neff-Achberg', 'Spezial', '🏗️', standard=False)
|
||||
upsert('cu', 'CU', 'Kupfer', '📞', standard=True)
|
||||
upsert('stoerung', 'Störung', 'Service', '🔧', standard=True)
|
||||
upsert('tvum', 'TV/UM', 'Glasfaser', '📺', standard=True)
|
||||
upsert('planung', 'Planung', 'Planung', '📐', standard=True)
|
||||
upsert('zw_rv', 'ZW/RV', 'Tiefbau', '🔩', standard=True)
|
||||
upsert('doku', 'Dokumentation', 'Planung', '📄', standard=True)
|
||||
upsert('sto_sammler', 'Störungs-Sammler', 'Service', '📋', standard=True)
|
||||
db.session.commit()
|
||||
from app.models.aufmass_typ import AufmassTyp
|
||||
if AufmassTyp.query.count() == 0:
|
||||
db.session.add(AufmassTyp(name='Teilaufmaß/AZ', sortierung=1))
|
||||
db.session.add(AufmassTyp(name='Schlussaufmaß', sortierung=2))
|
||||
db.session.commit()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
migrate = Migrate()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
LOCK_TIMEOUT = timedelta(minutes=2)
|
||||
|
||||
class Aufmass(db.Model):
|
||||
__tablename__ = 'aufmass'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projekte.id'), nullable=False)
|
||||
name = db.Column(db.String(200), nullable=False, default='Standard')
|
||||
typ = db.Column(db.String(50), default='')
|
||||
status = db.Column(db.String(20), default='aktiv')
|
||||
sortierung = db.Column(db.Integer, default=0)
|
||||
bemerkung = db.Column(db.Text)
|
||||
erstellt_von = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
geaendert_am = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
locked_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
locked_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
positionen = db.relationship('Position', backref='aufmass_ref', lazy='dynamic',
|
||||
cascade='all, delete-orphan', order_by='Position.sortierung')
|
||||
|
||||
def is_locked(self):
|
||||
if not self.locked_by or not self.locked_at:
|
||||
return False, None
|
||||
if datetime.utcnow() - self.locked_at > LOCK_TIMEOUT:
|
||||
return False, None
|
||||
return True, self.locked_by
|
||||
|
||||
def try_lock(self, user_id):
|
||||
locked, holder = self.is_locked()
|
||||
if locked and holder != user_id:
|
||||
return False
|
||||
self.locked_by = user_id
|
||||
self.locked_at = datetime.utcnow()
|
||||
return True
|
||||
|
||||
def unlock(self):
|
||||
self.locked_by = None
|
||||
self.locked_at = None
|
||||
|
||||
def refresh_lock(self, user_id):
|
||||
if self.locked_by == user_id:
|
||||
self.locked_at = datetime.utcnow()
|
||||
return True
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Aufmass {self.name} @ {self.project_id}>'
|
||||
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
class AufmassHistory(db.Model):
|
||||
__tablename__ = 'aufmass_history'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
aufmass_id = db.Column(db.Integer, db.ForeignKey('aufmass.id'), nullable=False, index=True)
|
||||
position_id = db.Column(db.Integer, db.ForeignKey('positionen.id', ondelete='SET NULL'), nullable=True)
|
||||
changed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
changed_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
|
||||
action = db.Column(db.String(10), nullable=False)
|
||||
description = db.Column(db.String(500), nullable=True)
|
||||
diff = db.Column(db.Text, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AufmassHistory {self.id} {self.action} @ {self.changed_at}>'
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.extensions import db
|
||||
|
||||
class AufmassTyp(db.Model):
|
||||
__tablename__ = 'aufmass_typen'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=True)
|
||||
sortierung = db.Column(db.Integer, default=0)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AufmassTyp {self.name}>'
|
||||
@@ -0,0 +1,29 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class Company(db.Model):
|
||||
__tablename__ = 'companies'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
slug = db.Column(db.String(100), unique=True, nullable=False)
|
||||
strasse = db.Column(db.String(200))
|
||||
house_number = db.Column(db.String(20))
|
||||
plz = db.Column(db.String(10))
|
||||
ort = db.Column(db.String(100))
|
||||
telefon = db.Column(db.String(50))
|
||||
email = db.Column(db.String(200))
|
||||
logo = db.Column(db.String(500))
|
||||
aktiv = db.Column(db.Boolean, default=True)
|
||||
evergabe_aktiviert = db.Column(db.Boolean, default=False)
|
||||
evergabe_benutzer = db.Column(db.String(200))
|
||||
evergabe_passwort = db.Column(db.String(300))
|
||||
evergabe_name = db.Column(db.String(200))
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
users = db.relationship('User', backref='company', lazy='dynamic')
|
||||
licenses = db.relationship('License', backref='company', lazy='dynamic')
|
||||
projekte = db.relationship('Project', backref='company', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Company {self.name}>'
|
||||
@@ -0,0 +1,14 @@
|
||||
from app.extensions import db
|
||||
|
||||
class CompanyModule(db.Model):
|
||||
__tablename__ = 'company_modules'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=False)
|
||||
module_id = db.Column(db.Integer, db.ForeignKey('modules.id'), nullable=False)
|
||||
aktiv = db.Column(db.Boolean, default=True)
|
||||
|
||||
company = db.relationship('Company', backref='company_module_list')
|
||||
module = db.relationship('Module', backref='company_assignments')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'module_id'),)
|
||||
@@ -0,0 +1,20 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class Contract(db.Model):
|
||||
__tablename__ = 'contracts'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=False)
|
||||
name = db.Column(db.String(300), nullable=False)
|
||||
belegnummer = db.Column(db.String(100))
|
||||
beleg_datum = db.Column(db.Date)
|
||||
laufzeit_start = db.Column(db.Date)
|
||||
laufzeit_ende = db.Column(db.Date)
|
||||
status = db.Column(db.String(50), default='NEU') # NEU, Zur Prüfung, Angenommen
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
projekte = db.relationship('Project', backref='contract', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Contract {self.name}>'
|
||||
@@ -0,0 +1,62 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class CustomModule(db.Model):
|
||||
__tablename__ = 'custom_modules'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=True)
|
||||
original_template_id = db.Column(db.Integer, db.ForeignKey('custom_modules.id'), nullable=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, default='')
|
||||
kategorie = db.Column(db.String(50), default='allgemein')
|
||||
icon = db.Column(db.String(50), default='🔧')
|
||||
form_json = db.Column(db.Text, default='[]')
|
||||
rules_json = db.Column(db.Text, default='[]')
|
||||
is_template = db.Column(db.Boolean, default=False)
|
||||
sort_index = db.Column(db.Integer, default=0)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
company = db.relationship('Company', backref='custom_modules', foreign_keys=[company_id])
|
||||
template = db.relationship('CustomModule', backref='copies', remote_side=[id])
|
||||
creator = db.relationship('User', backref='created_custom_modules', foreign_keys=[created_by])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'company_id': self.company_id,
|
||||
'original_template_id': self.original_template_id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'kategorie': self.kategorie,
|
||||
'icon': self.icon,
|
||||
'is_template': self.is_template,
|
||||
'sort_index': self.sort_index,
|
||||
'is_active': self.is_active,
|
||||
'created_by': self.created_by,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CustomModule {self.name} ({"template" if self.is_template else "company"})>'
|
||||
|
||||
|
||||
class CustomModuleAssignment(db.Model):
|
||||
__tablename__ = 'custom_module_assignments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
module_id = db.Column(db.Integer, db.ForeignKey('custom_modules.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
can_edit = db.Column(db.Boolean, default=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
module = db.relationship('CustomModule', backref='assignments')
|
||||
user = db.relationship('User', backref='custom_module_assignments')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('module_id', 'user_id', name='uq_module_user'),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
import hashlib, secrets
|
||||
|
||||
def _generate_uid(company_name):
|
||||
raw = f"{company_name}-{secrets.token_hex(6)}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:12]
|
||||
|
||||
class License(db.Model):
|
||||
__tablename__ = 'licenses'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=False)
|
||||
uid = db.Column(db.String(64), unique=True, nullable=False)
|
||||
max_mitarbeiter = db.Column(db.Integer, default=5)
|
||||
max_module_slots = db.Column(db.Integer, default=5)
|
||||
unlimited_users = db.Column(db.Boolean, default=False)
|
||||
unlimited_modules = db.Column(db.Boolean, default=False)
|
||||
aktiv = db.Column(db.Boolean, default=True)
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
modules = db.relationship('LicenseModule', backref='license', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
@property
|
||||
def used_users(self):
|
||||
from app.models.user import User
|
||||
return User.query.filter_by(company_id=self.company_id).count()
|
||||
|
||||
@property
|
||||
def used_module_slots(self):
|
||||
from app.models.user_module import UserModulePermission
|
||||
from app.models.user import User
|
||||
return db.session.query(UserModulePermission.id).join(User, UserModulePermission.user_id==User.id)\
|
||||
.filter(User.company_id==self.company_id, UserModulePermission.aktiv==True).count()
|
||||
|
||||
def user_slots_display(self):
|
||||
if self.unlimited_users: return '\u221e'
|
||||
return f'{self.used_users} / {self.max_mitarbeiter}'
|
||||
|
||||
def module_slots_display(self):
|
||||
if self.unlimited_modules: return '\u221e'
|
||||
return f'{self.used_module_slots} / {self.max_module_slots}'
|
||||
|
||||
@staticmethod
|
||||
def generate_uid(company_name):
|
||||
return _generate_uid(company_name)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<License {self.uid}>'
|
||||
|
||||
class LicenseModule(db.Model):
|
||||
__tablename__ = 'license_modules'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
license_id = db.Column(db.Integer, db.ForeignKey('licenses.id'), nullable=False)
|
||||
module_id = db.Column(db.Integer, db.ForeignKey('modules.id'), nullable=False)
|
||||
aktiv = db.Column(db.Boolean, default=True)
|
||||
|
||||
module = db.relationship('Module', backref='license_assignments')
|
||||
@@ -0,0 +1,44 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class LVPosition(db.Model):
|
||||
__tablename__ = 'lv_positionen'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=False)
|
||||
contract_id = db.Column(db.Integer, db.ForeignKey('contracts.id'), nullable=True)
|
||||
lv_name = db.Column(db.String(200), nullable=False)
|
||||
pos_nr = db.Column(db.String(50), nullable=False)
|
||||
order_index = db.Column(db.Integer, default=0)
|
||||
kurztext = db.Column(db.String(300))
|
||||
langtext = db.Column(db.Text)
|
||||
einheit = db.Column(db.String(10), default='ST')
|
||||
einzelpreis = db.Column(db.Float, default=0.0)
|
||||
gruppe = db.Column(db.String(100))
|
||||
rsa = db.Column(db.String(20))
|
||||
abschnitt = db.Column(db.String(100))
|
||||
notiz = db.Column(db.Text)
|
||||
favorite = db.Column(db.Boolean, default=False)
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('company_id', 'lv_name', 'pos_nr', name='uq_lv_position'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'lv_name': self.lv_name,
|
||||
'pos_nr': self.pos_nr,
|
||||
'kurztext': self.kurztext,
|
||||
'langtext': self.langtext,
|
||||
'einheit': self.einheit,
|
||||
'einzelpreis': self.einzelpreis,
|
||||
'gruppe': self.gruppe,
|
||||
'rsa': self.rsa,
|
||||
'abschnitt': self.abschnitt,
|
||||
'favorite': self.favorite,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<LV {self.lv_name} | {self.pos_nr}>'
|
||||
@@ -0,0 +1,16 @@
|
||||
from app.extensions import db
|
||||
|
||||
class Module(db.Model):
|
||||
__tablename__ = 'modules'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), unique=True, nullable=False)
|
||||
titel = db.Column(db.String(200), nullable=False)
|
||||
kategorie = db.Column(db.String(100))
|
||||
icon = db.Column(db.String(20), default='📦')
|
||||
beschreibung = db.Column(db.Text)
|
||||
standard = db.Column(db.Boolean, default=False) # immer verfügbar
|
||||
sortierung = db.Column(db.Integer, default=0)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Module {self.name}>'
|
||||
@@ -0,0 +1,78 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class Position(db.Model):
|
||||
__tablename__ = 'positionen'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projekte.id'), nullable=False)
|
||||
aufmass_id = db.Column(db.Integer, db.ForeignKey('aufmass.id'), nullable=True)
|
||||
lv_position_id = db.Column(db.Integer, db.ForeignKey('lv_positionen.id'), nullable=True)
|
||||
pos_nr = db.Column(db.String(50), nullable=False)
|
||||
sortierung = db.Column(db.Integer, default=0)
|
||||
rsa = db.Column(db.String(20))
|
||||
abschnitt = db.Column(db.String(100))
|
||||
kurztext = db.Column(db.String(300))
|
||||
langtext = db.Column(db.Text)
|
||||
einheit = db.Column(db.String(10), default='ST')
|
||||
einzelpreis = db.Column(db.Float, default=0.0)
|
||||
menge = db.Column(db.Float, default=0.0)
|
||||
gesamtpreis = db.Column(db.Float, default=0.0)
|
||||
faktor = db.Column(db.Float, default=1.0)
|
||||
laenge = db.Column(db.Float, default=0.0)
|
||||
breite = db.Column(db.Float, default=0.0)
|
||||
tiefe = db.Column(db.Float, default=0.0)
|
||||
formel_typ = db.Column(db.String(10), default='standard')
|
||||
formel = db.Column(db.String(300))
|
||||
bemerkung = db.Column(db.Text)
|
||||
menge_hinten = db.Column(db.Float, default=0.0)
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def berechne_menge(self, recalc_hinten=True, skip_menge_recalc=False):
|
||||
if not skip_menge_recalc:
|
||||
if self.formel_typ == 'frei':
|
||||
if self.formel:
|
||||
from app.services.formel_rechner import berechne_formel
|
||||
try:
|
||||
self.menge = berechne_formel(self.formel)
|
||||
except Exception:
|
||||
self.menge = 0
|
||||
else:
|
||||
self.menge = 0
|
||||
elif self.einheit == 'ST':
|
||||
self.menge = self.faktor * 1
|
||||
elif self.einheit == 'M':
|
||||
self.menge = self.laenge
|
||||
elif self.einheit == 'M2':
|
||||
self.menge = self.laenge * self.breite
|
||||
elif self.einheit == 'M3':
|
||||
self.menge = self.laenge * self.breite * self.tiefe
|
||||
else:
|
||||
self.menge = self.laenge
|
||||
if recalc_hinten:
|
||||
self.menge_hinten = self.faktor * self.menge
|
||||
self.gesamtpreis = self.menge_hinten * self.einzelpreis
|
||||
return self.menge
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'pos_nr': self.pos_nr,
|
||||
'sortierung': self.sortierung,
|
||||
'rsa': self.rsa,
|
||||
'kurztext': self.kurztext,
|
||||
'langtext': self.langtext,
|
||||
'einheit': self.einheit,
|
||||
'einzelpreis': self.einzelpreis,
|
||||
'menge': self.menge,
|
||||
'gesamtpreis': self.gesamtpreis,
|
||||
'faktor': self.faktor,
|
||||
'laenge': self.laenge,
|
||||
'breite': self.breite,
|
||||
'tiefe': self.tiefe,
|
||||
'bemerkung': self.bemerkung,
|
||||
'menge_hinten': self.menge_hinten,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Position {self.pos_nr} @ {self.project_id}>'
|
||||
@@ -0,0 +1,37 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class Project(db.Model):
|
||||
__tablename__ = 'projekte'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=False)
|
||||
contract_id = db.Column(db.Integer, db.ForeignKey('contracts.id'), nullable=True)
|
||||
sm_nr = db.Column(db.String(100), nullable=False, default='')
|
||||
bezeichnung = db.Column(db.String(300))
|
||||
baustelle = db.Column(db.String(300))
|
||||
vertrag = db.Column(db.String(200))
|
||||
abruf_nr = db.Column(db.String(100))
|
||||
lv_name = db.Column(db.String(200))
|
||||
datum_start = db.Column(db.Date)
|
||||
datum_ende = db.Column(db.Date)
|
||||
ansprechpartner_vorname = db.Column(db.String(100))
|
||||
ansprechpartner_nachname = db.Column(db.String(100))
|
||||
ansprechpartner_tel = db.Column(db.String(50))
|
||||
ansprechpartner_email = db.Column(db.String(200))
|
||||
bauabschnitt = db.Column(db.String(200))
|
||||
datum = db.Column(db.Date)
|
||||
ev_details_id = db.Column(db.String(50))
|
||||
status = db.Column(db.String(20), default='aktiv')
|
||||
erstellt_von = db.Column(db.Integer, db.ForeignKey('users.id'))
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
geaendert_am = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
positionen = db.relationship('Position', backref='project', lazy='dynamic',
|
||||
cascade='all, delete-orphan', order_by='Position.sortierung')
|
||||
aufmass_liste = db.relationship('Aufmass', backref='aufmass_project',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='Aufmass.sortierung')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Project {self.sm_nr}>'
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.extensions import db
|
||||
|
||||
class ProjectAccess(db.Model):
|
||||
__tablename__ = 'project_access'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projekte.id'), nullable=False)
|
||||
zugriff = db.Column(db.String(20), default='lesen')
|
||||
|
||||
user = db.relationship('User', backref='project_access_list')
|
||||
project = db.relationship('Project', backref='user_access_list')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'project_id'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ProjectAccess u={self.user_id} p={self.project_id} {self.zugriff}>'
|
||||
@@ -0,0 +1,24 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
class Settings(db.Model):
|
||||
__tablename__ = 'settings'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(100), unique=True, nullable=False)
|
||||
value = db.Column(db.Text)
|
||||
|
||||
@classmethod
|
||||
def get(cls, key, default=None):
|
||||
s = cls.query.filter_by(key=key).first()
|
||||
return s.value if s else default
|
||||
|
||||
@classmethod
|
||||
def set(cls, key, value):
|
||||
s = cls.query.filter_by(key=key).first()
|
||||
if s:
|
||||
s.value = value
|
||||
else:
|
||||
s = cls(key=key, value=value)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
@@ -0,0 +1,80 @@
|
||||
from app.extensions import db, login_manager
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('companies.id'), nullable=True)
|
||||
email = db.Column(db.String(200), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(300), nullable=False)
|
||||
vorname = db.Column(db.String(100))
|
||||
nachname = db.Column(db.String(100))
|
||||
rolle = db.Column(db.String(20), default='mitarbeiter')
|
||||
aktiv = db.Column(db.Boolean, default=True)
|
||||
font_size = db.Column(db.String(10), default='1')
|
||||
profile_image = db.Column(db.String(255), nullable=True)
|
||||
letzter_login = db.Column(db.DateTime)
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
darf_projekte_anlegen = db.Column(db.Boolean, default=False)
|
||||
darf_lv_verwalten = db.Column(db.Boolean, default=False)
|
||||
darf_preise_sehen = db.Column(db.Boolean, default=False)
|
||||
darf_aufmass_verwalten = db.Column(db.Boolean, default=False)
|
||||
darf_evergabe_nutzen = db.Column(db.Boolean, default=False)
|
||||
darf_kopfdaten_holen = db.Column(db.Boolean, default=False)
|
||||
darf_aufmass_uebertragen = db.Column(db.Boolean, default=False)
|
||||
hidden_modules = db.Column(db.Text, default='[]')
|
||||
|
||||
def get_hidden_modules(self):
|
||||
import json
|
||||
try:
|
||||
return json.loads(self.hidden_modules or '[]')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def set_hidden_modules(self, val):
|
||||
import json
|
||||
self.hidden_modules = json.dumps(val, ensure_ascii=False)
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return f"{self.vorname or ''} {self.nachname or ''}".strip() or self.email
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def is_superadmin(self):
|
||||
return self.rolle == 'superadmin'
|
||||
|
||||
def is_firmadmin(self):
|
||||
return self.rolle == 'firmadmin'
|
||||
|
||||
def is_admin(self):
|
||||
return self.rolle in ('firmadmin', 'superadmin')
|
||||
|
||||
def hat_zugriff(self, project, required='lesen'):
|
||||
if self.is_superadmin():
|
||||
return True
|
||||
if self.is_firmadmin():
|
||||
from app.models.project import Project
|
||||
return Project.query.get(project.id).company_id == self.company_id
|
||||
from app.models.project_access import ProjectAccess
|
||||
access = ProjectAccess.query.filter_by(
|
||||
user_id=self.id, project_id=project.id
|
||||
).first()
|
||||
if not access:
|
||||
return False
|
||||
if required == 'lesen':
|
||||
return True
|
||||
if required == 'schreiben':
|
||||
return access.zugriff in ('lesen', 'schreiben')
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.email} ({self.rolle})>'
|
||||
@@ -0,0 +1,14 @@
|
||||
from app.extensions import db
|
||||
|
||||
class UserModulePermission(db.Model):
|
||||
__tablename__ = 'user_module_permissions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
module_id = db.Column(db.Integer, db.ForeignKey('modules.id'), nullable=False)
|
||||
aktiv = db.Column(db.Boolean, default=True)
|
||||
|
||||
user = db.relationship('User', backref='user_module_list')
|
||||
module = db.relationship('Module', backref='user_assignments')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'module_id'),)
|
||||
@@ -0,0 +1,34 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
class ViewProfile(db.Model):
|
||||
__tablename__ = 'view_profiles'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
view_type = db.Column(db.String(50), default='lv') # lv, aufmass, ...
|
||||
config_json = db.Column(db.Text, default='{}')
|
||||
is_default = db.Column(db.Boolean, default=False)
|
||||
erstellt_am = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
def get_config(self):
|
||||
try:
|
||||
return json.loads(self.config_json or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
def set_config(self, config):
|
||||
self.config_json = json.dumps(config)
|
||||
|
||||
@staticmethod
|
||||
def get_default_config():
|
||||
return {
|
||||
'column_order': ['fav', 'drag', 'pos_nr', 'text', 'einheit', 'ep', 'aktion'],
|
||||
'column_widths': {'fav': 32, 'drag': 28, 'pos_nr': 90, 'text': 400, 'einheit': 60, 'ep': 80, 'aktion': 70},
|
||||
'column_visible': {'fav': True, 'drag': True, 'pos_nr': True, 'text': True, 'einheit': True, 'ep': True, 'aktion': True},
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ViewProfile {self.name} ({self.view_type})>'
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_absperrung.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
positionen = []
|
||||
tage = _int(form_data.get('tage', 1))
|
||||
typ = form_data.get('absperr_typ', 'voll')
|
||||
|
||||
if typ == 'voll':
|
||||
positionen.append({
|
||||
'pos_nr': '10041001',
|
||||
'kurztext': 'Vollsperrung einrichten',
|
||||
'menge': tage,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
elif typ == 'teil':
|
||||
positionen.append({
|
||||
'pos_nr': '10041002',
|
||||
'kurztext': 'Teilsperrung einrichten',
|
||||
'menge': tage,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
|
||||
if form_data.get('ampel') == 'an':
|
||||
positionen.append({
|
||||
'pos_nr': '10041003',
|
||||
'kurztext': 'Baustellenampel',
|
||||
'menge': tage,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
|
||||
return positionen
|
||||
|
||||
def _int(val, default=0):
|
||||
try:
|
||||
return int(float(str(val).replace(',', '.')))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import render_template
|
||||
|
||||
class AufmassModul:
|
||||
name = ''
|
||||
titel = ''
|
||||
template = ''
|
||||
|
||||
@classmethod
|
||||
def get_formular_html(cls):
|
||||
return render_template(cls.template)
|
||||
|
||||
@classmethod
|
||||
def berechne(cls, form_data):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,50 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_cu.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
pos = []
|
||||
abschnitt = form_data.get('abschnitt', '')
|
||||
anz_verb = _int(form_data.get('anz_cu_verb', 0))
|
||||
anz_stk = _int(form_data.get('anz_stk', 0))
|
||||
|
||||
if form_data.get('muffe_bis10') == 'an':
|
||||
pos.append(dict(pos_nr='10037500', kurztext='Muffe bis 10 DA montieren', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('apl_bis10') == 'an':
|
||||
pos.append(dict(pos_nr='10037501', kurztext='APL bis 10 DA montieren', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('anschl_trenn') == 'an':
|
||||
pos.append(dict(pos_nr='10037502', kurztext='Anschluss-/Trennleisten einbauen', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('muffe_schrumpf') == 'an':
|
||||
pos.append(dict(pos_nr='10037503', kurztext='Schrumpfmuffe herstellen', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('muffe_klemm') == 'an':
|
||||
pos.append(dict(pos_nr='10037504', kurztext='Klemmmuffe herstellen', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('muffe_gel') == 'an':
|
||||
pos.append(dict(pos_nr='10037505', kurztext='Gel-Muffen herstellen', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('muffe_klemm_dlr') == 'an':
|
||||
pos.append(dict(pos_nr='10037506', kurztext='Klemmmuffen für DLR', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if anz_verb > 0:
|
||||
pos.append(dict(pos_nr='10037507', kurztext=f'CU verbinden ({anz_verb} Stk)', menge=anz_verb, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('cu_da_gr') == 'an':
|
||||
pos.append(dict(pos_nr='10037508', kurztext='CU-DA > 0,8 mm verbinden', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('cu_da_kl') == 'an':
|
||||
pos.append(dict(pos_nr='10037509', kurztext='CU-DA ≤ 0,8 mm verbinden', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('stopfstelle') == 'an':
|
||||
pos.append(dict(pos_nr='10037510', kurztext='Druckluftstutzen Stopfstelle einbauen', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('zulage_stopf') == 'an':
|
||||
pos.append(dict(pos_nr='10037511', kurztext='Zulage Stopfstelle DLR', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('evs_einbauen') == 'an' and anz_stk > 0:
|
||||
pos.append(dict(pos_nr='10037512', kurztext=f'EVs einbauen ({anz_stk} Stk)', menge=anz_stk, einheit='ST', abschnitt=abschnitt))
|
||||
if form_data.get('kabel_anlegen_ev') == 'an':
|
||||
pos.append(dict(pos_nr='10037513', kurztext='Kabel anlegen EVs/TrLe', menge=1, einheit='ST', abschnitt=abschnitt))
|
||||
return pos
|
||||
|
||||
def _float(val, default=0):
|
||||
try: return float(str(val).replace(',', '.'))
|
||||
except: return default
|
||||
|
||||
def _int(val, default=0):
|
||||
try: return int(float(str(val).replace(',', '.')))
|
||||
except: return default
|
||||
@@ -0,0 +1,36 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_doku.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
pos = []
|
||||
laenge = _float(form_data.get('doku_laenge', 0))
|
||||
breite = _float(form_data.get('doku_breite', 0))
|
||||
hktr = _float(form_data.get('doku_hktr_m', 0))
|
||||
vzktr = _float(form_data.get('doku_vzktr_m', 0))
|
||||
gf_haus = _int(form_data.get('doku_gf_haus_anz', 0))
|
||||
|
||||
if laenge > 0 and breite > 0:
|
||||
pos.append(dict(pos_nr='10038000', kurztext=f'MP-Einarbeitung Gelände/Gebäude ({laenge}×{breite}m)', menge=1, einheit='ST'))
|
||||
elif laenge > 0:
|
||||
pos.append(dict(pos_nr='10038000', kurztext='MP-Einarbeitung Gelände/Gebäude', menge=laenge, einheit='M'))
|
||||
if hktr > 0:
|
||||
pos.append(dict(pos_nr='10038001', kurztext=f'Dokumentation HK-Trasse ({hktr}m)', menge=hktr, einheit='M'))
|
||||
if vzktr > 0:
|
||||
pos.append(dict(pos_nr='10038002', kurztext=f'Dokumentation VzK-Trasse ({vzktr}m)', menge=vzktr, einheit='M'))
|
||||
if gf_haus > 0:
|
||||
pos.append(dict(pos_nr='10038003', kurztext=f'Dokumentation GF-Hausanschluss ({gf_haus} Stk)', menge=gf_haus, einheit='ST'))
|
||||
if form_data.get('doku_geh') == 'an':
|
||||
pos.append(dict(pos_nr='10038004', kurztext='Dokumentation von Gehäusen', menge=1, einheit='ST'))
|
||||
return pos
|
||||
|
||||
def _float(val, default=0):
|
||||
try: return float(str(val).replace(',', '.'))
|
||||
except: return default
|
||||
|
||||
def _int(val, default=0):
|
||||
try: return int(float(str(val).replace(',', '.')))
|
||||
except: return default
|
||||
@@ -0,0 +1,53 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_ftth.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
positionen = []
|
||||
anzahl = _int(form_data.get('anzahl_ha', 0))
|
||||
|
||||
if anzahl <= 0:
|
||||
return positionen
|
||||
|
||||
# Hausanschluss GF-Montage
|
||||
positionen.append({
|
||||
'pos_nr': '10039001',
|
||||
'kurztext': 'GF-Hausanschluss herstellen',
|
||||
'menge': anzahl,
|
||||
'einheit': 'ST',
|
||||
'bemerkung': form_data.get('bemerkung', ''),
|
||||
})
|
||||
|
||||
if form_data.get('tiefbau') == 'an':
|
||||
positionen.append({
|
||||
'pos_nr': '10039002',
|
||||
'kurztext': 'Tiefbau für Hausanschluss',
|
||||
'menge': anzahl * _float(form_data.get('trassenlaenge', 5)),
|
||||
'einheit': 'M',
|
||||
'bemerkung': 'Trassenlänge pro HA',
|
||||
})
|
||||
|
||||
if form_data.get('muffe') == 'an':
|
||||
positionen.append({
|
||||
'pos_nr': '10038002',
|
||||
'kurztext': 'Muffe bauen (FTTH)',
|
||||
'menge': anzahl,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
|
||||
return positionen
|
||||
|
||||
def _int(val, default=0):
|
||||
try:
|
||||
return int(float(str(val).replace(',', '.')))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def _float(val, default=0):
|
||||
try:
|
||||
return float(str(val).replace(',', '.'))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
@@ -0,0 +1,50 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_gf.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
positionen = []
|
||||
typ = form_data.get('typ', 'nvt')
|
||||
anzahl = _int(form_data.get('anzahl', 1))
|
||||
|
||||
if typ == 'nvt':
|
||||
positionen.append({
|
||||
'pos_nr': '10038001',
|
||||
'kurztext': 'NVT Verbinden',
|
||||
'menge': anzahl,
|
||||
'einheit': 'ST',
|
||||
'bemerkung': form_data.get('bemerkung', ''),
|
||||
})
|
||||
elif typ == 'muffe':
|
||||
positionen.append({
|
||||
'pos_nr': '10038002',
|
||||
'kurztext': 'Muffe bauen',
|
||||
'menge': anzahl,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
elif typ == 'pegel':
|
||||
positionen.append({
|
||||
'pos_nr': '10038003',
|
||||
'kurztext': 'Pegelmessung durchführen',
|
||||
'menge': anzahl,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
|
||||
if form_data.get('fasern_verbinden') == 'an':
|
||||
positionen.append({
|
||||
'pos_nr': '10038004',
|
||||
'kurztext': 'Fasern verbinden',
|
||||
'menge': anzahl,
|
||||
'einheit': 'ST',
|
||||
})
|
||||
|
||||
return positionen
|
||||
|
||||
def _int(val, default=0):
|
||||
try:
|
||||
return int(float(str(val).replace(',', '.')))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
@@ -0,0 +1,113 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_graben.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
positionen = []
|
||||
l = _float(form_data.get('laenge', 0))
|
||||
b = _float(form_data.get('breite', 0))
|
||||
t = _float(form_data.get('tiefe', 0))
|
||||
asphalt = _float(form_data.get('asphaltstaerke', 0))
|
||||
rest_l = _float(form_data.get('rest_laenge', 0))
|
||||
rest_b = _float(form_data.get('rest_breite', 0))
|
||||
anz_einz = _int(form_data.get('anz_einzeiler', 0))
|
||||
lm_bre = _float(form_data.get('lm_bre', 0))
|
||||
abschnitt = form_data.get('abschnitt', '')
|
||||
bemerkung = form_data.get('bemerkung', '')
|
||||
|
||||
if l <= 0 or b <= 0 or t <= 0:
|
||||
return positionen
|
||||
|
||||
vol = l * b * t / 100
|
||||
|
||||
# Mindertiefe
|
||||
mind_lt = form_data.get('mind_langstrasse') == 'an'
|
||||
mind_ftth = form_data.get('mind_ftth_ha') == 'an'
|
||||
if mind_lt:
|
||||
positionen.append({'pos_nr': '10037491', 'kurztext': 'Mindertiefe Längstrasse', 'menge': vol, 'einheit': 'M3', 'laenge': l, 'breite': b, 'tiefe': t, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
if mind_ftth:
|
||||
positionen.append({'pos_nr': '10037492', 'kurztext': 'Mindertiefe FTTH Hausanschluss', 'menge': vol, 'einheit': 'M3', 'laenge': l, 'breite': b, 'tiefe': t, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Oberfläche - mehrere Checkboxen möglich
|
||||
oberfl_checks = [
|
||||
('ob_wiese', '10037463', 'Graben in Wiese herstellen'),
|
||||
('ob_kies', '10037464', 'Graben in Kies herstellen'),
|
||||
('ob_asphalt', '10037465', 'Graben in Asphalt herstellen'),
|
||||
('ob_pflaster', '10037466', 'Graben in Pflaster herstellen'),
|
||||
('ob_mosaik', '10037467', 'Graben in Mosaik herstellen'),
|
||||
('ob_bodentausch', '10037468', 'Graben mit Bodentausch herstellen'),
|
||||
('ob_fels', '10037469', 'Graben in Fels herstellen'),
|
||||
('ob_winterbau', '10037470', 'Graben im Winterbau herstellen'),
|
||||
('ob_gr_natur_pfl', '10037471', 'Graben in Groß/Natursteinpflaster herstellen'),
|
||||
('ob_in_beton', '10037472', 'Graben in Beton Pflaster/Mosaik herstellen'),
|
||||
]
|
||||
for key, pnr, txt in oberfl_checks:
|
||||
if form_data.get(key) == 'an':
|
||||
positionen.append({'pos_nr': pnr, 'kurztext': txt, 'menge': vol, 'einheit': 'M3', 'laenge': l, 'breite': b, 'tiefe': t, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Asphalt durchtrennen
|
||||
if asphalt > 0:
|
||||
positionen.append({'pos_nr': '10037473', 'kurztext': 'Asphaltdecke durchtrennen', 'menge': l, 'einheit': 'M', 'laenge': l, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Reststreifen
|
||||
if rest_l > 0 and rest_b > 0:
|
||||
positionen.append({'pos_nr': '10037480', 'kurztext': 'Reststreifen herstellen', 'menge': rest_l * rest_b / 100, 'einheit': 'M3', 'laenge': rest_l, 'breite': rest_b, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Bord/Rinne/Einzeiler
|
||||
bord = form_data.get('bord') == 'an'
|
||||
kante = form_data.get('kante') == 'an'
|
||||
rinne = form_data.get('rinne') == 'an'
|
||||
liefern = form_data.get('liefern') == 'an'
|
||||
if bord:
|
||||
positionen.append({'pos_nr': '10037481', 'kurztext': 'Bordstein setzen', 'menge': l, 'einheit': 'M', 'laenge': l, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
if kante:
|
||||
positionen.append({'pos_nr': '10037482', 'kurztext': 'Kantenstein setzen', 'menge': l, 'einheit': 'M', 'laenge': l, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
if rinne:
|
||||
positionen.append({'pos_nr': '10037483', 'kurztext': 'Einzeiler/Rinne setzen', 'menge': l, 'einheit': 'M', 'laenge': l, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
if liefern and (bord or kante or rinne):
|
||||
positionen.append({'pos_nr': '10037484', 'kurztext': 'Bord/Rinne/Einzeiler liefern', 'menge': anz_einz if anz_einz > 0 else 1, 'einheit': 'ST', 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
if anz_einz > 0:
|
||||
positionen.append({'pos_nr': '10037485', 'kurztext': f'Einzeiler ({anz_einz} Stk)', 'menge': anz_einz, 'einheit': 'ST', 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Medien
|
||||
medien_map = [
|
||||
('cu_kabel', '10037450', f'6-100 DA CU-Kabel ({_int(form_data.get("anz_cu",0))} DA)', _int(form_data.get('anz_cu',0)), 'DA'),
|
||||
('cu_kabel_gr', '10037451', f'> 100 DA CU-Kabel ({_int(form_data.get("anz_cu",0))} DA)', _int(form_data.get('anz_cu',0)), 'DA'),
|
||||
('dn110', '10037452', f'DN110 ({_int(form_data.get("anz_dn110",0))} Stk)', _int(form_data.get('anz_dn110',0)), 'ST'),
|
||||
('dn50_1', '10037453', f'1xDN50 ({_int(form_data.get("anz_dn50_1",0))} Stk)', _int(form_data.get('anz_dn50_1',0)), 'ST'),
|
||||
('dn50_2', '10037454', f'2xDN50 ({_int(form_data.get("anz_dn50_2",0))} Stk)', _int(form_data.get('anz_dn50_2',0)), 'ST'),
|
||||
('dn50_3', '10037455', f'3xDN50 ({_int(form_data.get("anz_dn50_3",0))} Stk)', _int(form_data.get('anz_dn50_3',0)), 'ST'),
|
||||
('snrve_7x12', '10037456', f'SNRVe 7x12 ({_int(form_data.get("anz_snrve_7x12",0))} Stk)', _int(form_data.get('anz_snrve_7x12',0)), 'ST'),
|
||||
('snrve_22x7', '10037457', f'SNRVe 22x7 ({_int(form_data.get("anz_snrve_22x7",0))} Stk)', _int(form_data.get('anz_snrve_22x7',0)), 'ST'),
|
||||
('snrve_8x7', '10037458', f'SNRVe 8x7 ({_int(form_data.get("anz_snrve_8x7",0))} Stk)', _int(form_data.get('anz_snrve_8x7',0)), 'ST'),
|
||||
('snrve_1x7', '10037459', f'SNRVe 1x7 ({_int(form_data.get("anz_snrve_1x7",0))} Stk)', _int(form_data.get('anz_snrve_1x7',0)), 'ST'),
|
||||
]
|
||||
for key, pnr, txt, menge, eh in medien_map:
|
||||
if form_data.get(key) == 'an' and menge > 0:
|
||||
positionen.append({'pos_nr': pnr, 'kurztext': txt, 'menge': menge, 'einheit': eh, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Trasse einmessen
|
||||
if form_data.get('trasseeinmessen') == 'an':
|
||||
positionen.append({'pos_nr': '10037486', 'kurztext': 'Trasse einmessen', 'menge': l, 'einheit': 'M', 'laenge': l, 'bemerkung': bemerkung, 'abschnitt': abschnitt})
|
||||
|
||||
# Stahlplatte
|
||||
if form_data.get('stahlplatte') == 'an':
|
||||
stahl_bem = form_data.get('stahlplatte_bemerk', '')
|
||||
positionen.append({'pos_nr': '10037487', 'kurztext': 'Stahlplatte', 'menge': 1, 'einheit': 'ST', 'bemerkung': stahl_bem, 'abschnitt': abschnitt})
|
||||
|
||||
return positionen
|
||||
|
||||
def _float(val, default=0):
|
||||
try:
|
||||
return float(str(val).replace(',', '.'))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def _int(val, default=0):
|
||||
try:
|
||||
return int(float(str(val).replace(',', '.')))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
@@ -0,0 +1,39 @@
|
||||
from flask import render_template
|
||||
|
||||
TEMPLATE = 'components/modul_gruben.html'
|
||||
|
||||
def get_formular_html():
|
||||
return render_template(TEMPLATE)
|
||||
|
||||
def berechne(form_data):
|
||||
positionen = []
|
||||
l = _float(form_data.get('laenge', 0))
|
||||
b = _float(form_data.get('breite', 0))
|
||||
t = _float(form_data.get('tiefe', 0))
|
||||
|
||||
if l <= 0 or b <= 0 or t <= 0:
|
||||
return positionen
|
||||
|
||||
oberflaeche = form_data.get('oberflaeche', 'wiese')
|
||||
volumen = l * b * t / 100
|
||||
|
||||
oberfl_map = {
|
||||
'wiese': ('10037463', 'Grube in Wiese herstellen'),
|
||||
'kies': ('10037464', 'Grube in Kies herstellen'),
|
||||
'asphalt': ('10037465', 'Grube in Asphalt herstellen'),
|
||||
'pflaster': ('10037466', 'Grube in Pflaster herstellen'),
|
||||
}
|
||||
if oberflaeche in oberfl_map:
|
||||
pnr, txt = oberfl_map[oberflaeche]
|
||||
positionen.append({
|
||||
'pos_nr': pnr, 'kurztext': txt, 'menge': volumen,
|
||||
'einheit': 'M3', 'laenge': l, 'breite': b, 'tiefe': t,
|
||||
})
|
||||
|
||||
return positionen
|
||||
|
||||
def _float(val, default=0):
|
||||
try:
|
||||
return float(str(val).replace(',', '.'))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user