localcosmos-app-kit 0.9.17__py3-none-any.whl → 0.9.18__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import csv
2
+ import os
3
+ from typing import Optional, List
4
+
5
+ from django.core.management.base import BaseCommand, CommandError
6
+ from django.db import transaction
7
+
8
+ from taxonomy.models import TaxonomyModelRouter
9
+
10
+
11
+ class Command(BaseCommand):
12
+ help = (
13
+ 'Importiert die Arten-/Taxa-CSV (|-getrennt). Erkennt Headerzeile automatisch und überspringt sie.\n'
14
+ 'Unterstütztes Layout (aktuell):\n'
15
+ ' 0: scientific_name | 1: rank | 2: parent_lat | 3: parent_code | 4: de | 5: en | 6: nl | 7: da'
16
+ )
17
+
18
+ def add_arguments(self, parser):
19
+ parser.add_argument('csv_path', type=str, help='Pfad zur Arten-CSV (|-getrennt).')
20
+ parser.add_argument('--delimiter', type=str, default='|', help='Trennzeichen (Standard: |).')
21
+ parser.add_argument('--encoding', type=str, default='utf-8', help='Datei-Kodierung (Standard: utf-8).')
22
+ parser.add_argument('--dry-run', action='store_true', help='Nur prüfen, am Ende zurückrollen.')
23
+
24
+ def handle(self, *args, **options):
25
+ csv_path = options['csv_path']
26
+ delimiter = options['delimiter'] or '|'
27
+ encoding = options['encoding']
28
+ dry_run = options['dry_run']
29
+
30
+ if not os.path.exists(csv_path):
31
+ raise CommandError(f'Datei nicht gefunden: {csv_path}')
32
+
33
+ models = TaxonomyModelRouter('taxonomy.sources.custom')
34
+ self.stdout.write(self.style.NOTICE('Quelle: taxonomy.sources.custom'))
35
+ self.stdout.write(self.style.NOTICE(f'Trennzeichen: {repr(delimiter)}'))
36
+
37
+ created = 0
38
+ skipped = 0
39
+ existing = 0
40
+ rows = 0
41
+ self._missing_parents = {}
42
+ self._header_skipped = False
43
+ self._existing_names = []
44
+
45
+ with open(csv_path, 'r', encoding=encoding, newline='') as f:
46
+ reader = csv.reader(f, delimiter=delimiter)
47
+
48
+ with transaction.atomic():
49
+ for parts in reader:
50
+ rows += 1
51
+ if not parts:
52
+ continue
53
+ # Skip header line if present
54
+ if not self._header_skipped and self._looks_like_header(parts):
55
+ self._header_skipped = True
56
+ continue
57
+ c, e, s, existing_name = self._import_species_row(models, parts)
58
+ created += c
59
+ existing += e
60
+ skipped += s
61
+ if e and existing_name:
62
+ # preserve insertion order, avoid duplicates
63
+ if existing_name not in self._existing_names:
64
+ self._existing_names.append(existing_name)
65
+
66
+ if dry_run:
67
+ # Rollback alle DB-Änderungen, aber dennoch Auswertung anzeigen
68
+ transaction.set_rollback(True)
69
+
70
+ self.stdout.write(self.style.SUCCESS(
71
+ f'Fertig. Erstellt: {created}, vorhanden: {existing}, übersprungen: {skipped}, Zeilen: {rows}'))
72
+ if dry_run:
73
+ self.stdout.write(self.style.WARNING('Dry-Run: Alle Änderungen wurden zurückgerollt.'))
74
+
75
+ if existing and self._existing_names:
76
+ self.stdout.write(self.style.NOTICE('Bereits vorhandene Taxa (exakt):'))
77
+ for nm in self._existing_names:
78
+ self.stdout.write(f'- {nm}')
79
+
80
+ if skipped and self._missing_parents:
81
+ self.stdout.write(self.style.WARNING('Übersprungene Zeilen wegen fehlendem Eltern-Taxon (Auszug):'))
82
+ # Zeige bis zu 25 fehlende Eltern mit Häufigkeit
83
+ shown = 0
84
+ for parent_name, count in sorted(self._missing_parents.items(), key=lambda x: -x[1]):
85
+ self.stdout.write(f'- {parent_name or "<leer>"}: {count}')
86
+ shown += 1
87
+ if shown >= 25:
88
+ break
89
+
90
+ def _looks_like_header(self, parts: List[str]) -> bool:
91
+ if not parts:
92
+ return False
93
+ p0 = (parts[0] or '').strip().lower()
94
+ p1 = (parts[1] or '').strip().lower() if len(parts) > 1 else ''
95
+ # Typical header tokens
96
+ header_tokens = {'scientific name', 'name', 'latname', 'parent taxon', 'rank', 'de', 'en', 'nl', 'dk'}
97
+ return (p0 in header_tokens) or (p1 in header_tokens)
98
+
99
+ def _get_parent(self, models, parent_latname: str):
100
+ if not parent_latname:
101
+ return None
102
+ # exakter Treffer zuerst
103
+ parent = models.TaxonTreeModel.objects.filter(taxon_latname=parent_latname).first()
104
+ if parent:
105
+ return parent
106
+ # Fallback: case-insensitive Vergleich
107
+ return models.TaxonTreeModel.objects.filter(taxon_latname__iexact=parent_latname).first()
108
+
109
+ def _get_or_create_taxon(self, models, parent, latname: str, rank: str):
110
+ if not latname:
111
+ return None, False
112
+
113
+ r = (rank or 'species').strip().lower()
114
+ qs = models.TaxonTreeModel.objects.filter(taxon_latname=latname, rank=r)
115
+ if parent is None:
116
+ qs = qs.filter(is_root_taxon=True)
117
+ else:
118
+ qs = qs.filter(parent=parent)
119
+
120
+ instance = qs.first()
121
+ created = False
122
+ if not instance:
123
+ extra = {'rank': r}
124
+ if parent is None:
125
+ extra['is_root_taxon'] = True
126
+ else:
127
+ extra['parent'] = parent
128
+ instance = models.TaxonTreeModel.objects.create(latname, None, **extra)
129
+ created = True
130
+ return instance, created
131
+
132
+ def _add_locale_if_present(self, models, taxon, name: Optional[str], language: str):
133
+ if not taxon or not name:
134
+ return False
135
+ exists = models.TaxonLocaleModel.objects.filter(taxon=taxon, name=name, language=language).exists()
136
+ if not exists:
137
+ models.TaxonLocaleModel.objects.create(taxon, name, language, preferred=True)
138
+ return True
139
+ return False
140
+
141
+ def _import_species_row(self, models, parts: List[str]):
142
+ # Unterstütztes Layout (aktuell):
143
+ # 0: scientific_name | 1: rank | 2: parent_lat | 3: parent_code | 4: de | 5: en | 6: nl | 7: da
144
+
145
+ # Normalize parts
146
+ parts = [(p or '').strip() for p in parts]
147
+
148
+ # Minimalprüfung: mindestens 4 Spalten (bis parent_code) erwartet
149
+ if len(parts) < 4:
150
+ return 0, 0, 1, None
151
+
152
+ name = parts[0]
153
+ rank = (parts[1] or 'species').strip().lower()
154
+ parent_latname = parts[2]
155
+ # parent_code aktuell informativ, optional verwendbar
156
+ # parent_code = parts[3]
157
+ locale_start_idx = 4
158
+
159
+ parent = self._get_parent(models, parent_latname) if parent_latname else None
160
+ # If no parent and this should be a root-level taxon, allow creation with no parent
161
+ if not parent and parent_latname:
162
+ key = parent_latname
163
+ self._missing_parents[key] = self._missing_parents.get(key, 0) + 1
164
+ return 0, 0, 1, None
165
+
166
+ taxon, created = self._get_or_create_taxon(models, parent, name, rank)
167
+ if not taxon:
168
+ return 0, 0, 1, None
169
+
170
+ # Attach vernacular locales if present (de, en, nl, da)
171
+ locale_langs = ['de', 'en', 'nl', 'da']
172
+ for offset, lang in enumerate(locale_langs):
173
+ idx = locale_start_idx + offset
174
+ val = parts[idx] if idx < len(parts) else ''
175
+ self._add_locale_if_present(models, taxon, val, lang)
176
+
177
+ return (1, 0, 0, None) if created else (0, 1, 0, name)
@@ -0,0 +1,177 @@
1
+ # temporary script for a one-time import of BeachExplorer's higher taxonom
2
+ import csv
3
+ import os
4
+ import re
5
+ from typing import List, Optional
6
+
7
+ from django.core.management.base import BaseCommand, CommandError
8
+ from django.db import transaction
9
+
10
+ from taxonomy.models import TaxonomyModelRouter
11
+
12
+ RANK_SET = set([
13
+ 'domain','kingdom','phylum','division','class','order','family','genus','species',
14
+ 'subkingdom','infrakingdom','superphylum','subphylum','infraphylum',
15
+ 'superclass','subclass','infraclass','superorder','suborder',
16
+ 'infraorder','parvorder','superfamily','subfamily','tribe','subtribe',
17
+ 'clade'
18
+ ])
19
+
20
+
21
+ def is_code_like(val: str) -> bool:
22
+ return bool(re.fullmatch(r'[a-z]{2,6}', val))
23
+
24
+ def _non_empty_indices(parts: List[str]) -> List[int]:
25
+ return [i for i, p in enumerate(parts) if p]
26
+
27
+
28
+ class Command(BaseCommand):
29
+ help = 'Import a custom taxonomy tree from a GROK-Rang CSV (headerlos, |-getrennt).'
30
+
31
+ def add_arguments(self, parser):
32
+ parser.add_argument('csv_path', type=str, help='Pfad zur GROK-Rang CSV-Datei (ohne Header).')
33
+ parser.add_argument('--language', type=str, default=None,
34
+ help='Sprachcode für Bezeichnungen aus dem Pfad (z. B. de, en).')
35
+ parser.add_argument('--delimiter', type=str, default='|',
36
+ help='Trennzeichen (Standard: |).')
37
+ parser.add_argument('--encoding', type=str, default='utf-8',
38
+ help='Datei-Kodierung (Standard: utf-8).')
39
+ parser.add_argument('--dry-run', action='store_true', help='Nur prüfen, am Ende zurückrollen.')
40
+ parser.add_argument('--report-limit', type=int, default=10,
41
+ help='Maximale Anzahl Beispiel-Einträge in der Dry-Run-Übersicht (Standard: 10).')
42
+
43
+ def handle(self, *args, **options):
44
+ csv_path = options['csv_path']
45
+ language = options['language']
46
+ delimiter = options['delimiter'] or '|'
47
+ encoding = options['encoding']
48
+ dry_run = options['dry_run']
49
+
50
+ if not os.path.exists(csv_path):
51
+ raise CommandError(f'Datei nicht gefunden: {csv_path}')
52
+
53
+ models = TaxonomyModelRouter('taxonomy.sources.custom')
54
+ self.stdout.write(self.style.NOTICE('Quelle: taxonomy.sources.custom'))
55
+ self.stdout.write(self.style.NOTICE(f'Trennzeichen: {repr(delimiter)}'))
56
+
57
+ # track last Latin parent per depth to build a Latin-only hierarchy
58
+ self.depth_taxon = {}
59
+
60
+ with open(csv_path, 'r', encoding=encoding, newline='') as f:
61
+ reader = csv.reader(f, delimiter=delimiter)
62
+ created_count = 0
63
+ existing_count = 0
64
+ skipped_count = 0
65
+ skip_reasons = {}
66
+ skipped_examples = []
67
+ row_index = 0
68
+
69
+ try:
70
+ with transaction.atomic():
71
+ for parts in reader:
72
+ row_index += 1
73
+ if not parts:
74
+ # Treat truly empty lines as skipped for reporting
75
+ skipped_count += 1
76
+ skip_reasons['empty line'] = skip_reasons.get('empty line', 0) + 1
77
+ if len(skipped_examples) < (options.get('report_limit') or 10):
78
+ skipped_examples.append({'row': row_index, 'reason': 'empty line', 'raw': []})
79
+ continue
80
+ c, e, s, reason = self._import_grok_row(models, parts, language)
81
+ created_count += c
82
+ existing_count += e
83
+ if s:
84
+ skipped_count += s
85
+ if reason:
86
+ skip_reasons[reason] = skip_reasons.get(reason, 0) + 1
87
+ if len(skipped_examples) < (options.get('report_limit') or 10):
88
+ skipped_examples.append({'row': row_index, 'reason': reason, 'raw': parts})
89
+
90
+ if dry_run:
91
+ # Print a detailed dry-run report before rolling back
92
+ self.stdout.write(self.style.WARNING('Dry-Run Bericht:'))
93
+ self.stdout.write(f' Zeilen gelesen: {row_index}')
94
+ self.stdout.write(f' Würde erstellen: {created_count}')
95
+ self.stdout.write(f' Bereits vorhanden: {existing_count}')
96
+ self.stdout.write(f' Übersprungen: {skipped_count}')
97
+ if skip_reasons:
98
+ self.stdout.write(' Gründe für Überspringen:')
99
+ for r, cnt in sorted(skip_reasons.items(), key=lambda x: (-x[1], x[0])):
100
+ self.stdout.write(f' - {r}: {cnt}')
101
+ if skipped_examples:
102
+ self.stdout.write(' Beispiele übersprungener Einträge:')
103
+ for ex in skipped_examples:
104
+ preview = '|'.join([str(p) for p in ex.get('raw', [])])
105
+ self.stdout.write(f" - Zeile {ex['row']}: {ex['reason']} -> {preview}")
106
+ # Trigger rollback
107
+ raise CommandError('Dry-Run abgeschlossen: Änderungen werden zurückgerollt.')
108
+
109
+ except CommandError as e:
110
+ if 'Dry-Run abgeschlossen' in str(e):
111
+ self.stdout.write(self.style.WARNING(str(e)))
112
+ return
113
+ raise
114
+
115
+ self.stdout.write(self.style.SUCCESS(
116
+ f'Fertig. Erstellt: {created_count}, vorhanden: {existing_count}, Zeilen: {row_index}'))
117
+
118
+ def _get_or_create(self, models, parent, latname, author, rank):
119
+ existing_qs = models.TaxonTreeModel.objects.filter(taxon_latname=latname, rank=rank)
120
+ if parent is None:
121
+ existing_qs = existing_qs.filter(is_root_taxon=True)
122
+ else:
123
+ existing_qs = existing_qs.filter(parent=parent)
124
+
125
+ instance = existing_qs.first()
126
+ created = False
127
+ if not instance:
128
+ extra = {'rank': rank}
129
+ if parent is None:
130
+ extra['is_root_taxon'] = True
131
+ else:
132
+ extra['parent'] = parent
133
+ instance = models.TaxonTreeModel.objects.create(latname, None, **extra)
134
+ created = True
135
+ return instance, created
136
+
137
+ def _clear_deeper_depth(self, depth: int):
138
+ # drop any cached parents deeper than current depth
139
+ for d in list(self.depth_taxon.keys()):
140
+ if d > depth:
141
+ del self.depth_taxon[d]
142
+
143
+ def _import_grok_row(self, models, parts: List[str], language: Optional[str]):
144
+ # Normalize tokens but keep positional layout
145
+ parts = [(p or '').strip() for p in parts]
146
+ if not parts or len(parts) < 3:
147
+ return 0, 0, 1, 'empty or too few columns'
148
+
149
+ # Fixed GROK layout: [...path..., latin, code, rank]
150
+ rank = parts[-1]
151
+ if not rank:
152
+ return 0, 0, 1, 'missing rank'
153
+ latin = parts[-3] if len(parts) >= 3 else ''
154
+ if not latin:
155
+ return 0, 0, 1, 'missing latin name'
156
+
157
+ # Path segments are all tokens before latin
158
+ path_segments = [p for p in parts[:-3] if p]
159
+ depth = len(path_segments)
160
+ parent = self.depth_taxon.get(depth - 1) if depth > 0 else None
161
+
162
+ taxon, created = self._get_or_create(models, parent, latin, author=None, rank=rank)
163
+
164
+ # Store this taxon as the current parent at its depth and clear deeper cache
165
+ self.depth_taxon[depth] = taxon
166
+ self._clear_deeper_depth(depth)
167
+
168
+ # Locale: letzter Pfadteil als Bezeichnung (attach vernacular to Latin taxon)
169
+ if path_segments and language:
170
+ vernacular = path_segments[-1]
171
+ exists = models.TaxonLocaleModel.objects.filter(
172
+ taxon=taxon, name=vernacular, language=language
173
+ ).exists()
174
+ if not exists:
175
+ models.TaxonLocaleModel.objects.create(taxon, vernacular, language, preferred=True)
176
+
177
+ return (1, 0, 0, None) if created else (0, 1, 0, None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localcosmos_app_kit
3
- Version: 0.9.17
3
+ Version: 0.9.18
4
4
  Summary: LocalCosmos App Kit. Web Portal to build Android and iOS apps
5
5
  Home-page: https://github.com/localcosmos/app-kit
6
6
  Author: Thomas Uher
@@ -1797,6 +1797,10 @@ app_kit/taxonomy/sources/custom/models.py,sha256=4ExGyy2l_mYnGlsBhLndS7lQL5QSFGb
1797
1797
  app_kit/taxonomy/sources/custom/tests.py,sha256=mrbGGRNg5jwbTJtWWa7zSKdDyeB4vmgZCRc2nk6VY-g,60
1798
1798
  app_kit/taxonomy/sources/custom/urls.py,sha256=0P71XKmzS7mLeYIMcn0EE6Vi0pRV1fu-l_d317Kpnug,1032
1799
1799
  app_kit/taxonomy/sources/custom/views.py,sha256=ZFkqba9mGgKdbaZx4GNGI0hKoviyaEOnjf4CGvTTr8E,9780
1800
+ app_kit/taxonomy/sources/custom/management/commands/import_custom_species_csv.py,sha256=Ng3-FKfJpxXijxb8EzTEKXgjM8ivJiM92FAIG4ngrug,7434
1801
+ app_kit/taxonomy/sources/custom/management/commands/import_custom_taxonomy_csv.py,sha256=Y3QHClrZdvz5Fz-UgyTYhxtcCc6UxBa4BXpyL47PN54,8202
1802
+ app_kit/taxonomy/sources/custom/management/commands/__pycache__/import_custom_species_csv.cpython-313.pyc,sha256=AxUh7xot7wo__NBggEI9PEqH9lDX0GHBQ9ZkmTwLjNs,9725
1803
+ app_kit/taxonomy/sources/custom/management/commands/__pycache__/import_custom_taxonomy_csv.cpython-313.pyc,sha256=nU0sag8bvHUMSb6KBbs7EF8sZbdp5zwDdSiERW_PAQg,10567
1800
1804
  app_kit/taxonomy/sources/custom/migrations/0001_initial.py,sha256=FLCqXqGQcG0-cMeyxgSt-BuE-5uNq7HfCvYXBDni4wc,4246
1801
1805
  app_kit/taxonomy/sources/custom/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
1802
1806
  app_kit/taxonomy/sources/custom/templates/custom_taxonomy/manage_custom_taxon.html,sha256=SSXwvvZflxkKAK1GrkZM15ZD6JUfjEWDmiI78TGhmKg,1348
@@ -1935,8 +1939,8 @@ app_kit/tests/__pycache__/test_models.cpython-313.pyc,sha256=Lmv3BfjLs5Fg-olJeMl
1935
1939
  app_kit/tests/__pycache__/test_utils.cpython-313.pyc,sha256=GX3REqZygi2eO_A2F2_KtYi7hg54X5QPtCTCGWuxGpM,14054
1936
1940
  app_kit/tests/__pycache__/test_views.cpython-311.pyc,sha256=NDJR40TcMm-bXXC-wV7OgH1sGR3N7psSWYiUirkkrjU,133242
1937
1941
  app_kit/tests/__pycache__/test_views.cpython-313.pyc,sha256=q851UqIZFCCTfQb1lF4SVxV1j_Vu1hJdOlpckmrGX28,125363
1938
- localcosmos_app_kit-0.9.17.dist-info/licenses/LICENCE,sha256=VnxALPSxXoU59rlNeRdJtwS_nU79IFpVWsZZCQUM4Mw,1086
1939
- localcosmos_app_kit-0.9.17.dist-info/METADATA,sha256=e8ksAKCmQ153iA3vmv2B_1ILF-cHvCiN4JbKU19a1XA,1388
1940
- localcosmos_app_kit-0.9.17.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
1941
- localcosmos_app_kit-0.9.17.dist-info/top_level.txt,sha256=F6H4pEBkCvUR_iwQHIy4K1iby-jzfWg3CTym5XJKeys,8
1942
- localcosmos_app_kit-0.9.17.dist-info/RECORD,,
1942
+ localcosmos_app_kit-0.9.18.dist-info/licenses/LICENCE,sha256=VnxALPSxXoU59rlNeRdJtwS_nU79IFpVWsZZCQUM4Mw,1086
1943
+ localcosmos_app_kit-0.9.18.dist-info/METADATA,sha256=wC25Ke7ekb_Rf0l-tcIN2Ih3__GSe64Z0SNNQwGQVSg,1388
1944
+ localcosmos_app_kit-0.9.18.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
1945
+ localcosmos_app_kit-0.9.18.dist-info/top_level.txt,sha256=F6H4pEBkCvUR_iwQHIy4K1iby-jzfWg3CTym5XJKeys,8
1946
+ localcosmos_app_kit-0.9.18.dist-info/RECORD,,