localcosmos-app-kit 0.9.16__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.
- app_kit/features/taxon_profiles/tests/__pycache__/test_zip_import.cpython-313.pyc +0 -0
- app_kit/features/taxon_profiles/tests/test_zip_import.py +4 -0
- app_kit/generic_content_zip_import.py +174 -0
- app_kit/taxonomy/sources/custom/forms.py +1 -0
- app_kit/taxonomy/sources/custom/management/commands/__pycache__/import_custom_species_csv.cpython-313.pyc +0 -0
- app_kit/taxonomy/sources/custom/management/commands/__pycache__/import_custom_taxonomy_csv.cpython-313.pyc +0 -0
- app_kit/taxonomy/sources/custom/management/commands/import_custom_species_csv.py +177 -0
- app_kit/taxonomy/sources/custom/management/commands/import_custom_taxonomy_csv.py +177 -0
- app_kit/taxonomy/views.py +1 -1
- app_kit/tests/TESTS_ROOT/media_for_tests/test/imagestore/31/a6a11b61d65ee19c4c22caa0682288ff.jpg +0 -0
- app_kit/tests/__pycache__/test_generic_content_zip_import.cpython-313.pyc +0 -0
- app_kit/tests/test_generic_content_zip_import.py +145 -2
- {localcosmos_app_kit-0.9.16.dist-info → localcosmos_app_kit-0.9.18.dist-info}/METADATA +2 -2
- {localcosmos_app_kit-0.9.16.dist-info → localcosmos_app_kit-0.9.18.dist-info}/RECORD +17 -12
- {localcosmos_app_kit-0.9.16.dist-info → localcosmos_app_kit-0.9.18.dist-info}/WHEEL +1 -1
- {localcosmos_app_kit-0.9.16.dist-info → localcosmos_app_kit-0.9.18.dist-info}/licenses/LICENCE +0 -0
- {localcosmos_app_kit-0.9.16.dist-info → localcosmos_app_kit-0.9.18.dist-info}/top_level.txt +0 -0
|
Binary file
|
|
@@ -352,6 +352,10 @@ class TestTaxonProfilesZipImporter(WithMedia, WithTaxonProfiles, WithUser, WithM
|
|
|
352
352
|
taxon_latname='Fraxinus excelsior', morphotype='leaf')
|
|
353
353
|
self.assertEqual(fraxinus_excielior_leaf_profile.short_profile,'Fraxinus excelsior morphotype leaf short profile')
|
|
354
354
|
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
|
|
355
359
|
@test_settings
|
|
356
360
|
def test_partial_import(self):
|
|
357
361
|
importer = self.get_zip_importer()
|
|
@@ -570,6 +570,180 @@ class GenericContentZipImporter:
|
|
|
570
570
|
'valid_media_types': ', '.join(AVAILABLE_EXTERNAL_MEDIA_TYPES),
|
|
571
571
|
}
|
|
572
572
|
self.add_cell_error(self.workbook_filename, sheet_name, 'B', row_index, message)
|
|
573
|
+
|
|
574
|
+
if external_media_data['media_type']:
|
|
575
|
+
# call specific validator method for each media type
|
|
576
|
+
validator_method_name = 'validate_external_media_type_{0}'.format(
|
|
577
|
+
external_media_data['media_type'].lower()
|
|
578
|
+
)
|
|
579
|
+
validator_method = getattr(self, validator_method_name, None)
|
|
580
|
+
if validator_method:
|
|
581
|
+
validator_method(external_media_data, sheet_name, row_index)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
'''
|
|
586
|
+
image, youTube, vimeo, mp3, wav, pdf, website, file
|
|
587
|
+
'''
|
|
588
|
+
def validate_external_media_type_image(self, external_media_data, sheet_name, row_index):
|
|
589
|
+
# check that the url ends with a valid image extension
|
|
590
|
+
image_extension = os.path.splitext(external_media_data['url'])[1].lower()
|
|
591
|
+
if image_extension not in VALID_IMAGE_FORMATS:
|
|
592
|
+
message = _('Invalid image format in URL: %(cell_value)s. Valid formats are: %(valid_formats)s') % {
|
|
593
|
+
'cell_value': image_extension,
|
|
594
|
+
'valid_formats': ', '.join(VALID_IMAGE_FORMATS),
|
|
595
|
+
}
|
|
596
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def validate_external_media_type_youtube(self, external_media_data, sheet_name, row_index):
|
|
601
|
+
url = (external_media_data.get('url') or '').strip()
|
|
602
|
+
if not url:
|
|
603
|
+
message = _('Invalid YouTube URL: empty value')
|
|
604
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
605
|
+
return
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
from urllib.parse import urlparse, parse_qs
|
|
609
|
+
parsed = urlparse(url)
|
|
610
|
+
netloc = (parsed.netloc or '').lower()
|
|
611
|
+
path = parsed.path or ''
|
|
612
|
+
query = parse_qs(parsed.query or '')
|
|
613
|
+
except Exception:
|
|
614
|
+
message = _('Invalid YouTube URL: %(cell_value)s') % {'cell_value': url}
|
|
615
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if parsed.scheme not in ('http', 'https'):
|
|
619
|
+
message = _('Invalid YouTube URL (scheme must be http/https): %(cell_value)s') % {'cell_value': url}
|
|
620
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
video_id = None
|
|
624
|
+
|
|
625
|
+
# youtu.be/<id>
|
|
626
|
+
if netloc.endswith('youtu.be'):
|
|
627
|
+
candidate = path.lstrip('/')
|
|
628
|
+
m = re.match(r'^([A-Za-z0-9_-]{11})(?:$|[/?#])', candidate)
|
|
629
|
+
if m:
|
|
630
|
+
video_id = m.group(1)
|
|
631
|
+
|
|
632
|
+
# *.youtube.com/... variants
|
|
633
|
+
if not video_id and netloc.endswith('youtube.com'):
|
|
634
|
+
# watch?v=<id>
|
|
635
|
+
vvals = query.get('v')
|
|
636
|
+
if vvals:
|
|
637
|
+
v = vvals[0]
|
|
638
|
+
if re.fullmatch(r'[A-Za-z0-9_-]{11}', v or ''):
|
|
639
|
+
video_id = v
|
|
640
|
+
# /embed/<id>, /v/<id>, /shorts/<id>
|
|
641
|
+
if not video_id:
|
|
642
|
+
m = re.search(r'/(?:embed|v|shorts)/([A-Za-z0-9_-]{11})(?:$|[/?#])', path)
|
|
643
|
+
if m:
|
|
644
|
+
video_id = m.group(1)
|
|
645
|
+
|
|
646
|
+
if not video_id:
|
|
647
|
+
message = _('Invalid YouTube URL or video id not found: %(cell_value)s') % {'cell_value': url}
|
|
648
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
# Optionally: could normalize/store the ID if needed
|
|
652
|
+
# external_media_data['video_id'] = video_id
|
|
653
|
+
|
|
654
|
+
def validate_external_media_type_vimeo(self, external_media_data, sheet_name, row_index):
|
|
655
|
+
url = (external_media_data.get('url') or '').strip()
|
|
656
|
+
if not url:
|
|
657
|
+
message = _('Invalid Vimeo URL: empty value')
|
|
658
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
from urllib.parse import urlparse
|
|
663
|
+
parsed = urlparse(url)
|
|
664
|
+
netloc = (parsed.netloc or '').lower()
|
|
665
|
+
path = parsed.path or ''
|
|
666
|
+
except Exception:
|
|
667
|
+
message = _('Invalid Vimeo URL: %(cell_value)s') % {'cell_value': url}
|
|
668
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
if parsed.scheme not in ('http', 'https'):
|
|
672
|
+
message = _('Invalid Vimeo URL (scheme must be http/https): %(cell_value)s') % {'cell_value': url}
|
|
673
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
674
|
+
return
|
|
675
|
+
|
|
676
|
+
video_id = None
|
|
677
|
+
|
|
678
|
+
if netloc.endswith('vimeo.com'):
|
|
679
|
+
# player.vimeo.com/video/<id>
|
|
680
|
+
m = re.search(r'/video/(\d+)(?:$|[/?#])', path)
|
|
681
|
+
if m:
|
|
682
|
+
video_id = m.group(1)
|
|
683
|
+
# vimeo.com/<id> or any path ending with /<digits>
|
|
684
|
+
if not video_id:
|
|
685
|
+
m = re.search(r'/(\d+)(?:$|[/?#])', path)
|
|
686
|
+
if m:
|
|
687
|
+
video_id = m.group(1)
|
|
688
|
+
|
|
689
|
+
if not video_id:
|
|
690
|
+
message = _('Invalid Vimeo URL or video id not found: %(cell_value)s') % {'cell_value': url}
|
|
691
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
# Optionally: could normalize/store the ID if needed
|
|
695
|
+
# external_media_data['video_id'] = video_id
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def validate_external_media_type_mp3(self, external_media_data, sheet_name, row_index):
|
|
699
|
+
# has to end with .mp3
|
|
700
|
+
if not external_media_data['url'].lower().endswith('.mp3'):
|
|
701
|
+
message = _('Invalid mp3 format in URL: %(cell_value)s. URL has to end with .mp3') % {
|
|
702
|
+
'cell_value': external_media_data['url'],
|
|
703
|
+
}
|
|
704
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
705
|
+
|
|
706
|
+
def validate_external_media_type_wav(self, external_media_data, sheet_name, row_index):
|
|
707
|
+
# has to end with .wav
|
|
708
|
+
if not external_media_data['url'].lower().endswith('.wav'):
|
|
709
|
+
message = _('Invalid wav format in URL: %(cell_value)s. URL has to end with .wav') % {
|
|
710
|
+
'cell_value': external_media_data['url'],
|
|
711
|
+
}
|
|
712
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
713
|
+
|
|
714
|
+
def validate_external_media_type_pdf(self, external_media_data, sheet_name, row_index):
|
|
715
|
+
# has to end with .pdf
|
|
716
|
+
if not external_media_data['url'].lower().endswith('.pdf'):
|
|
717
|
+
message = _('Invalid pdf format in URL: %(cell_value)s. URL has to end with .pdf') % {
|
|
718
|
+
'cell_value': external_media_data['url'],
|
|
719
|
+
}
|
|
720
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def validate_external_media_type_website(self, external_media_data, sheet_name, row_index):
|
|
724
|
+
# Only flag as invalid if the PATH (not the domain) ends with a file extension
|
|
725
|
+
try:
|
|
726
|
+
from urllib.parse import urlparse
|
|
727
|
+
parsed = urlparse((external_media_data.get('url') or '').strip())
|
|
728
|
+
path = parsed.path or ''
|
|
729
|
+
except Exception:
|
|
730
|
+
path = ''
|
|
731
|
+
|
|
732
|
+
# Detect file-like endings in path (e.g., /file.jpg, /index.html)
|
|
733
|
+
if re.search(r'/[^/]+\.[a-zA-Z0-9]{2,5}$', path):
|
|
734
|
+
message = _('Invalid website format in URL: %(cell_value)s. URL should not end with a file extension.') % {
|
|
735
|
+
'cell_value': external_media_data['url'],
|
|
736
|
+
}
|
|
737
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def validate_external_media_type_file(self, external_media_data, sheet_name, row_index):
|
|
741
|
+
# file endings: check if the url has a file extension
|
|
742
|
+
if not re.search(r'\.[a-zA-Z0-9]{2,5}($|\?)', external_media_data['url']):
|
|
743
|
+
message = _('Invalid file format in URL: %(cell_value)s. URL has to end with a file extension.') % {
|
|
744
|
+
'cell_value': external_media_data['url'],
|
|
745
|
+
}
|
|
746
|
+
self.add_cell_error(self.workbook_filename, sheet_name, 'A', row_index, message)
|
|
573
747
|
|
|
574
748
|
|
|
575
749
|
def add_cell_error(self, filename, sheet_name, column, row, message):
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|
app_kit/taxonomy/views.py
CHANGED
|
@@ -79,7 +79,7 @@ class TaxonTreeView(TemplateView):
|
|
|
79
79
|
if self.taxon:
|
|
80
80
|
children_nuid_length = len(self.taxon.taxon_nuid) + 3
|
|
81
81
|
taxa = self.models.TaxonTreeModel.objects.annotate(nuid_len=Length('taxon_nuid')).filter(
|
|
82
|
-
taxon_nuid__startswith=self.taxon.taxon_nuid, nuid_len=children_nuid_length)
|
|
82
|
+
taxon_nuid__startswith=self.taxon.taxon_nuid, nuid_len=children_nuid_length).order_by('taxon_latname')
|
|
83
83
|
|
|
84
84
|
else:
|
|
85
85
|
taxa = self.get_root_taxa()
|
app_kit/tests/TESTS_ROOT/media_for_tests/test/imagestore/31/a6a11b61d65ee19c4c22caa0682288ff.jpg
ADDED
|
Binary file
|
|
Binary file
|
|
@@ -320,7 +320,7 @@ class TestGenericContentZipImporter(WithUser, WithMetaApp, TenantTestCase):
|
|
|
320
320
|
self.assertEqual(licence.licence, 'CC BY-NC')
|
|
321
321
|
self.assertEqual(licence.licence_version, '4.0')
|
|
322
322
|
self.assertEqual(licence.source_link, 'https://imageworld.com/lacerta-agilis.jpg')
|
|
323
|
-
self.assertEqual(licence.creator_name, '
|
|
323
|
+
self.assertEqual(licence.creator_name, 'New Author')
|
|
324
324
|
self.assertEqual(content_image.alt_text, 'A new alt text')
|
|
325
325
|
self.assertEqual(content_image.text, 'A new caption')
|
|
326
326
|
self.assertEqual(content_image.title, 'Lizard')
|
|
@@ -421,6 +421,146 @@ class TestGenericContentZipImporter(WithUser, WithMetaApp, TenantTestCase):
|
|
|
421
421
|
# No match for wrong author
|
|
422
422
|
matches = importer.get_taxa_with_taxon_author_tolerance(taxon_source, taxon_latname, 'Smith')
|
|
423
423
|
self.assertEqual(len(matches), 0)
|
|
424
|
+
|
|
425
|
+
# External Media URL validation
|
|
426
|
+
@test_settings
|
|
427
|
+
def test_validate_external_media_type_youtube_valid_urls(self):
|
|
428
|
+
importer = self.get_zip_importer()
|
|
429
|
+
importer.load_workbook()
|
|
430
|
+
importer.errors = []
|
|
431
|
+
|
|
432
|
+
valid_urls = [
|
|
433
|
+
'https://youtu.be/dQw4w9WgXcQ',
|
|
434
|
+
'http://youtu.be/dQw4w9WgXcQ',
|
|
435
|
+
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
436
|
+
'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
|
437
|
+
'https://www.youtube.com/embed/dQw4w9WgXcQ',
|
|
438
|
+
'https://www.youtube.com/v/dQw4w9WgXcQ',
|
|
439
|
+
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
for url in valid_urls:
|
|
443
|
+
importer.validate_external_media_type_youtube({'url': url}, importer.external_media_sheet_name, 2)
|
|
444
|
+
self.assertEqual(importer.errors, [])
|
|
445
|
+
|
|
446
|
+
@test_settings
|
|
447
|
+
def test_validate_external_media_type_youtube_invalid_urls(self):
|
|
448
|
+
importer = self.get_zip_importer()
|
|
449
|
+
importer.load_workbook()
|
|
450
|
+
importer.errors = []
|
|
451
|
+
|
|
452
|
+
# Empty URL
|
|
453
|
+
importer.validate_external_media_type_youtube({'url': ''}, importer.external_media_sheet_name, 2)
|
|
454
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid YouTube URL: empty value', importer.errors)
|
|
455
|
+
|
|
456
|
+
# Invalid scheme
|
|
457
|
+
bad_url = 'ftp://youtu.be/dQw4w9WgXcQ'
|
|
458
|
+
importer.validate_external_media_type_youtube({'url': bad_url}, importer.external_media_sheet_name, 2)
|
|
459
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid YouTube URL (scheme must be http/https): ftp://youtu.be/dQw4w9WgXcQ', importer.errors)
|
|
460
|
+
|
|
461
|
+
# No video id
|
|
462
|
+
bad_url = 'https://www.youtube.com/watch?v='
|
|
463
|
+
importer.validate_external_media_type_youtube({'url': bad_url}, importer.external_media_sheet_name, 2)
|
|
464
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid YouTube URL or video id not found: https://www.youtube.com/watch?v=', importer.errors)
|
|
465
|
+
|
|
466
|
+
@test_settings
|
|
467
|
+
def test_validate_external_media_type_vimeo_valid_urls(self):
|
|
468
|
+
importer = self.get_zip_importer()
|
|
469
|
+
importer.load_workbook()
|
|
470
|
+
importer.errors = []
|
|
471
|
+
|
|
472
|
+
valid_urls = [
|
|
473
|
+
'https://vimeo.com/123456789',
|
|
474
|
+
'http://vimeo.com/123456789',
|
|
475
|
+
'https://player.vimeo.com/video/123456789',
|
|
476
|
+
'https://www.vimeo.com/123456789',
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
for url in valid_urls:
|
|
480
|
+
importer.validate_external_media_type_vimeo({'url': url}, importer.external_media_sheet_name, 2)
|
|
481
|
+
self.assertEqual(importer.errors, [])
|
|
482
|
+
|
|
483
|
+
@test_settings
|
|
484
|
+
def test_validate_external_media_type_vimeo_invalid_urls(self):
|
|
485
|
+
importer = self.get_zip_importer()
|
|
486
|
+
importer.load_workbook()
|
|
487
|
+
importer.errors = []
|
|
488
|
+
|
|
489
|
+
# Empty URL
|
|
490
|
+
importer.validate_external_media_type_vimeo({'url': ''}, importer.external_media_sheet_name, 2)
|
|
491
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid Vimeo URL: empty value', importer.errors)
|
|
492
|
+
|
|
493
|
+
# Invalid scheme
|
|
494
|
+
bad_url = 'ftp://vimeo.com/123456789'
|
|
495
|
+
importer.validate_external_media_type_vimeo({'url': bad_url}, importer.external_media_sheet_name, 2)
|
|
496
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid Vimeo URL (scheme must be http/https): ftp://vimeo.com/123456789', importer.errors)
|
|
497
|
+
|
|
498
|
+
# No video id
|
|
499
|
+
bad_url = 'https://vimeo.com/'
|
|
500
|
+
importer.validate_external_media_type_vimeo({'url': bad_url}, importer.external_media_sheet_name, 2)
|
|
501
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid Vimeo URL or video id not found: https://vimeo.com/', importer.errors)
|
|
502
|
+
|
|
503
|
+
@test_settings
|
|
504
|
+
def test_validate_external_media_type_image_url_extension(self):
|
|
505
|
+
importer = self.get_zip_importer()
|
|
506
|
+
importer.load_workbook()
|
|
507
|
+
importer.errors = []
|
|
508
|
+
|
|
509
|
+
# Valid image URL
|
|
510
|
+
importer.validate_external_media_type_image({'url': 'https://example.com/pic.jpg'}, importer.external_media_sheet_name, 2)
|
|
511
|
+
# Invalid image URL extension
|
|
512
|
+
importer.validate_external_media_type_image({'url': 'https://example.com/pic.bmp'}, importer.external_media_sheet_name, 2)
|
|
513
|
+
|
|
514
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid image format in URL: .bmp. Valid formats are: .jpg, .jpeg, .png, .webp, .gif', importer.errors)
|
|
515
|
+
|
|
516
|
+
@test_settings
|
|
517
|
+
def test_validate_external_media_type_audio_pdf(self):
|
|
518
|
+
importer = self.get_zip_importer()
|
|
519
|
+
importer.load_workbook()
|
|
520
|
+
importer.errors = []
|
|
521
|
+
|
|
522
|
+
# mp3
|
|
523
|
+
importer.validate_external_media_type_mp3({'url': 'https://example.com/audio.txt'}, importer.external_media_sheet_name, 2)
|
|
524
|
+
importer.validate_external_media_type_mp3({'url': 'https://example.com/audio.mp3'}, importer.external_media_sheet_name, 2)
|
|
525
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid mp3 format in URL: https://example.com/audio.txt. URL has to end with .mp3', importer.errors)
|
|
526
|
+
|
|
527
|
+
# wav
|
|
528
|
+
importer.validate_external_media_type_wav({'url': 'https://example.com/audio.txt'}, importer.external_media_sheet_name, 2)
|
|
529
|
+
importer.validate_external_media_type_wav({'url': 'https://example.com/audio.wav'}, importer.external_media_sheet_name, 2)
|
|
530
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid wav format in URL: https://example.com/audio.txt. URL has to end with .wav', importer.errors)
|
|
531
|
+
|
|
532
|
+
# pdf
|
|
533
|
+
importer.validate_external_media_type_pdf({'url': 'https://example.com/doc.txt'}, importer.external_media_sheet_name, 2)
|
|
534
|
+
importer.validate_external_media_type_pdf({'url': 'https://example.com/doc.pdf'}, importer.external_media_sheet_name, 2)
|
|
535
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid pdf format in URL: https://example.com/doc.txt. URL has to end with .pdf', importer.errors)
|
|
536
|
+
|
|
537
|
+
@test_settings
|
|
538
|
+
def test_validate_external_media_type_website(self):
|
|
539
|
+
importer = self.get_zip_importer()
|
|
540
|
+
importer.load_workbook()
|
|
541
|
+
importer.errors = []
|
|
542
|
+
|
|
543
|
+
# Invalid: ends with file extension
|
|
544
|
+
importer.validate_external_media_type_website({'url': 'https://example.com/image.jpg'}, importer.external_media_sheet_name, 2)
|
|
545
|
+
# Valid: no file extension
|
|
546
|
+
importer.validate_external_media_type_website({'url': 'https://example.com/some/path?query=x'}, importer.external_media_sheet_name, 2)
|
|
547
|
+
# Valid: domain-only with TLD
|
|
548
|
+
importer.validate_external_media_type_website({'url': 'https://example.com'}, importer.external_media_sheet_name, 2)
|
|
549
|
+
|
|
550
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid website format in URL: https://example.com/image.jpg. URL should not end with a file extension.', importer.errors)
|
|
551
|
+
|
|
552
|
+
@test_settings
|
|
553
|
+
def test_validate_external_media_type_file(self):
|
|
554
|
+
importer = self.get_zip_importer()
|
|
555
|
+
importer.load_workbook()
|
|
556
|
+
importer.errors = []
|
|
557
|
+
|
|
558
|
+
# Invalid: no file extension
|
|
559
|
+
importer.validate_external_media_type_file({'url': 'https://example.com/download/'}, importer.external_media_sheet_name, 2)
|
|
560
|
+
# Valid: has file extension
|
|
561
|
+
importer.validate_external_media_type_file({'url': 'https://example.com/download.zip'}, importer.external_media_sheet_name, 2)
|
|
562
|
+
|
|
563
|
+
self.assertIn('[Generic Content.xlsx][Sheet:External Media][cell:A3] Invalid file format in URL: https://example.com/download/. URL has to end with a file extension.', importer.errors)
|
|
424
564
|
|
|
425
565
|
# real world test
|
|
426
566
|
# algaebase entry is : Desmarestia viridis (O.F.Müller) J.V.Lamouroux 1813
|
|
@@ -587,4 +727,7 @@ class TestGenericContentZipImporterInvalidData(WithUser, WithMetaApp, TenantTest
|
|
|
587
727
|
ignorin_importer.load_workbook()
|
|
588
728
|
ignorin_importer.errors = []
|
|
589
729
|
ignorin_importer.validate_listing_in_images_sheet('unlisted.jpg', 'A', 2)
|
|
590
|
-
self.assertEqual(ignorin_importer.errors, [])
|
|
730
|
+
self.assertEqual(ignorin_importer.errors, [])
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localcosmos_app_kit
|
|
3
|
-
Version: 0.9.
|
|
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
|
|
@@ -14,7 +14,7 @@ Classifier: Operating System :: OS Independent
|
|
|
14
14
|
Requires-Python: >=3.8
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENCE
|
|
17
|
-
Requires-Dist: localcosmos-server==0.24.
|
|
17
|
+
Requires-Dist: localcosmos-server==0.24.11
|
|
18
18
|
Requires-Dist: localcosmos-cordova-builder==0.9.6
|
|
19
19
|
Requires-Dist: django-tenants==3.7.0
|
|
20
20
|
Requires-Dist: django-cleanup==9.0.0
|
|
@@ -7,7 +7,7 @@ app_kit/definitions.py,sha256=qPfyqMKr71qvBgOj8M7j_7ZbMlFo5sdvfQUUAL0IBBU,82
|
|
|
7
7
|
app_kit/forms.py,sha256=09xz8t7j7opzAXkBAkSjpjAUFygXHXsSjD62515gRxI,20281
|
|
8
8
|
app_kit/generic.py,sha256=NZDUpyX9Il3NN-kIXwDJGd30s_keRjt2dlAXHEuWfNE,5224
|
|
9
9
|
app_kit/generic_content_validation.py,sha256=d9zM35OaOV382GMRSnIBQXzRcqiPDIaE03OAWtoHgQ4,1146
|
|
10
|
-
app_kit/generic_content_zip_import.py,sha256=
|
|
10
|
+
app_kit/generic_content_zip_import.py,sha256=sVIo5KQeohVTyWMbEyIlr5DDhQ1oXaWiMQycMptJ9vM,46523
|
|
11
11
|
app_kit/global_urls.py,sha256=9y2vQnaD5T4lzma2S0iXWGajZdq5xzq0CsOch7u9SYc,2432
|
|
12
12
|
app_kit/middleware.py,sha256=tav9GDjZlGVsxCsIXUb6Tkibrc1nGnNIK3ScZanDBE8,1278
|
|
13
13
|
app_kit/models.py,sha256=o3JqclCHD64h7Pg3MVX45pZDLYY8EyVT8-83d5FJidE,36764
|
|
@@ -464,7 +464,7 @@ app_kit/features/taxon_profiles/tests/common.py,sha256=r27zMWP3LhUCLNv2nSnbLZSb2
|
|
|
464
464
|
app_kit/features/taxon_profiles/tests/test_forms.py,sha256=tQ4NHGxZz2dzae16tu0JXazTqEskzdv473Ni_ncUaUA,16374
|
|
465
465
|
app_kit/features/taxon_profiles/tests/test_models.py,sha256=-gj-EHBWuFG1mxoOMnwSY2lo0azevO00p0yS-txKcHI,33605
|
|
466
466
|
app_kit/features/taxon_profiles/tests/test_views.py,sha256=8jgvwNe2j79CcoR-FXBI6x9ZnzzxhZLTbX_LGZDgauQ,85993
|
|
467
|
-
app_kit/features/taxon_profiles/tests/test_zip_import.py,sha256=
|
|
467
|
+
app_kit/features/taxon_profiles/tests/test_zip_import.py,sha256=NS5yAOgpedod8qkAMA47NF-Xau_N23kEI5jw88Ee0TE,27217
|
|
468
468
|
app_kit/features/taxon_profiles/tests/__pycache__/common.cpython-311.pyc,sha256=VPg2JQrdcN4hlBj-fGZvvyBb1F79FRk9QI5Wd3HlN04,2573
|
|
469
469
|
app_kit/features/taxon_profiles/tests/__pycache__/common.cpython-313.pyc,sha256=mZa0QDG_CtG8ZTIiQMWSqeBuRzSo8kKqAumsGzZUEus,2397
|
|
470
470
|
app_kit/features/taxon_profiles/tests/__pycache__/test_forms.cpython-311.pyc,sha256=KNorIkGEM4kigyBcxo-1mNhiTVcFMOMA5khcVuTCSuE,16758
|
|
@@ -473,7 +473,7 @@ app_kit/features/taxon_profiles/tests/__pycache__/test_models.cpython-311.pyc,sh
|
|
|
473
473
|
app_kit/features/taxon_profiles/tests/__pycache__/test_models.cpython-313.pyc,sha256=Yh5pGcitUJjxGCfpgLknzn_9pBxK2iBoHXz8jXmtQyY,37911
|
|
474
474
|
app_kit/features/taxon_profiles/tests/__pycache__/test_views.cpython-311.pyc,sha256=LP0Ax_996RBcO7iKOAXLfuPYBkescBUMQage7UNx6eU,94047
|
|
475
475
|
app_kit/features/taxon_profiles/tests/__pycache__/test_views.cpython-313.pyc,sha256=v10_fLVPmVHlQusn-wEobwC5JstZLzfnevMJB7k9VkI,124656
|
|
476
|
-
app_kit/features/taxon_profiles/tests/__pycache__/test_zip_import.cpython-313.pyc,sha256=
|
|
476
|
+
app_kit/features/taxon_profiles/tests/__pycache__/test_zip_import.cpython-313.pyc,sha256=wstYyGH-rg8nev8rgokM40R3A4ZgRdxAr_FUACsYTCQ,35276
|
|
477
477
|
app_kit/locale/de/LC_MESSAGES/django.mo,sha256=tSX2dudjFlnL9Vgr8ueLZQ3AxyAzFR6vQtkSRTd3FTs,89194
|
|
478
478
|
app_kit/locale/de/LC_MESSAGES/django.po,sha256=4gLbkP8aCbPWx4stAUFce2OadMQtk_CcNnXdE8MMXnQ,179096
|
|
479
479
|
app_kit/locale/en/LC_MESSAGES/django.mo,sha256=N1pb17IfLd0ASiKO8d68-B4ygSpDkhKOCs8YTzMXQo0,380
|
|
@@ -1757,7 +1757,7 @@ app_kit/taxonomy/models.py,sha256=3d5RCnD3OdcnEkGaXhP_HvCBCEz20tq2qWbuBOyYV2M,10
|
|
|
1757
1757
|
app_kit/taxonomy/signals.py,sha256=FZHsEUkWN0h7rQN22e8I8MTeP6HfBAzChBTko2PQEHE,1193
|
|
1758
1758
|
app_kit/taxonomy/urls.py,sha256=q7kE4C7cvgnKH40cUJ1q26MeuAmS7w5fFIJfQiIAgTo,668
|
|
1759
1759
|
app_kit/taxonomy/utils.py,sha256=Yqu6vuZk3JuUWFKQo9wMS1hhzem5m43SwWKOQmzec64,3181
|
|
1760
|
-
app_kit/taxonomy/views.py,sha256=
|
|
1760
|
+
app_kit/taxonomy/views.py,sha256=B--5qstiJcUK2SzVFN6nDX87yLFMqX02God3N-W465I,6544
|
|
1761
1761
|
app_kit/taxonomy/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1762
1762
|
app_kit/taxonomy/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1763
1763
|
app_kit/taxonomy/management/commands/taxonomy_recreate_indices.py,sha256=KiK_Wh7lF01Z03jrWz692aTCfDQhEU98CoTDTg-UTjk,7625
|
|
@@ -1792,11 +1792,15 @@ app_kit/taxonomy/sources/col/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQe
|
|
|
1792
1792
|
app_kit/taxonomy/sources/custom/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1793
1793
|
app_kit/taxonomy/sources/custom/admin.py,sha256=suMo4x8I3JBxAFBVIdE-5qnqZ6JAZV0FESABHOSc-vg,63
|
|
1794
1794
|
app_kit/taxonomy/sources/custom/apps.py,sha256=0pJS9BI3enVGwVS-WVMv1LTq5KjOT6QHZ0IaJQVpPw4,104
|
|
1795
|
-
app_kit/taxonomy/sources/custom/forms.py,sha256=
|
|
1795
|
+
app_kit/taxonomy/sources/custom/forms.py,sha256=EQW33U3jJjuBN-tSob0Y3heHECPJ-T4critROShFU00,2611
|
|
1796
1796
|
app_kit/taxonomy/sources/custom/models.py,sha256=4ExGyy2l_mYnGlsBhLndS7lQL5QSFGbE2JSTqx1tPNw,2140
|
|
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
|
|
@@ -1885,7 +1889,7 @@ app_kit/tests/cases.py,sha256=OGjZnENc5aIxPB0T2LxZagCD87om0hImU-aDF5WlBU8,2693
|
|
|
1885
1889
|
app_kit/tests/common.py,sha256=KxGYN73HxNPyMrJxelXsN0NA5btQkleWoqTu87jUue8,12293
|
|
1886
1890
|
app_kit/tests/mixins.py,sha256=K0iR7WSgPOKj3nFRDrm6b61EztaRcdunLlf4pEWshVw,18210
|
|
1887
1891
|
app_kit/tests/test_forms.py,sha256=5gVJHn6FJrucfq5jitv16TNGjmpkXcAdgfjQ2gdxSok,23822
|
|
1888
|
-
app_kit/tests/test_generic_content_zip_import.py,sha256=
|
|
1892
|
+
app_kit/tests/test_generic_content_zip_import.py,sha256=nmTgtVxtS0PPQbzuWOvup2oLlL-C9DyW3PWKjdb8RfA,31910
|
|
1889
1893
|
app_kit/tests/test_models.py,sha256=gvTKCpJtNxuOyGgocMIZtLkvO2CnHXJ7jAZgKIcrNNE,48837
|
|
1890
1894
|
app_kit/tests/test_utils.py,sha256=LQejfDjJw6BRoRUrGjNz0_Ap-1NJhCM6uWyEk8XR9p8,10270
|
|
1891
1895
|
app_kit/tests/test_views.py,sha256=WKzXwBKlJTtYS2m5oo94DkOUs5cJtwWiJouEM6SkqFQ,85640
|
|
@@ -1899,6 +1903,7 @@ app_kit/tests/TESTS_ROOT/images/app-background.jpg,sha256=bmW6cudSfmcRqGSK9P2Sc5
|
|
|
1899
1903
|
app_kit/tests/TESTS_ROOT/images/localcosmos-logo.svg,sha256=4-MHj0FjED9eTgmUHnAP-DHCQRqc557HH9akHaoQxs0,409549
|
|
1900
1904
|
app_kit/tests/TESTS_ROOT/images/test-image-2560-1440.jpg,sha256=BOP-1ZlA9wq-E1sUkOpyqUowpFCdSoF965oKgajjSq0,156278
|
|
1901
1905
|
app_kit/tests/TESTS_ROOT/ipa_for_tests/TestApp.ipa,sha256=YdGWqMepeBwvpkch3DSVMlnNkf8RuGwENWuwHtoY1Do,78900
|
|
1906
|
+
app_kit/tests/TESTS_ROOT/media_for_tests/test/imagestore/31/a6a11b61d65ee19c4c22caa0682288ff.jpg,sha256=dXT6yUqaMJ_91rwQMHT22VjDGb69KRCmm4eboxjtnxM,49349
|
|
1902
1907
|
app_kit/tests/TESTS_ROOT/templates/neobiota.html,sha256=3ag2VGCuaZ-FoAu25GMUb3s4qMsY7AyhNoDf7CSZJ_w,147
|
|
1903
1908
|
app_kit/tests/TESTS_ROOT/xlsx_for_testing/BackboneTaxonomy/invalid/Backbone taxonomy.xlsx,sha256=cMkmCtGBR1wwhpDSbjrscNSvLMGnb6YbaliQqws-l7M,8121
|
|
1904
1909
|
app_kit/tests/TESTS_ROOT/xlsx_for_testing/BackboneTaxonomy/invalid_content_type/Backbone taxonomy.xlsx,sha256=aYRrPI-UABxQUZF-bWvbrD_31ZQgEijdKCzBGOXzGRk,7996
|
|
@@ -1928,14 +1933,14 @@ app_kit/tests/__pycache__/common.cpython-313.pyc,sha256=29krHmMxnGBzLReM-dBcU1cR
|
|
|
1928
1933
|
app_kit/tests/__pycache__/mixins.cpython-311.pyc,sha256=BpQ9iHqtXMRgIqYh79R-qCPvV7mCxFZD16NTHrkzBLo,30160
|
|
1929
1934
|
app_kit/tests/__pycache__/mixins.cpython-313.pyc,sha256=4vbTAvCLui4d_00inAU4A01awXhCfVjvXaC4hI6sx9Q,27548
|
|
1930
1935
|
app_kit/tests/__pycache__/test_forms.cpython-311.pyc,sha256=5EkfnDI_YzuHGlq-Bse7C7XGZi32GgfTzbMg1fY87pI,37496
|
|
1931
|
-
app_kit/tests/__pycache__/test_generic_content_zip_import.cpython-313.pyc,sha256=
|
|
1936
|
+
app_kit/tests/__pycache__/test_generic_content_zip_import.cpython-313.pyc,sha256=G-U45KVf1TwXsMIfzOBntFEJo5QZFvSxpZxaCrZJT0Q,38945
|
|
1932
1937
|
app_kit/tests/__pycache__/test_models.cpython-311.pyc,sha256=4cb_Zopv6eCuBpVnv-8mIue3uUntAiDxbJZxzFMMDCw,75511
|
|
1933
1938
|
app_kit/tests/__pycache__/test_models.cpython-313.pyc,sha256=Lmv3BfjLs5Fg-olJeMll5l3f5-hf-X-9fYPhjuXmd-4,82590
|
|
1934
1939
|
app_kit/tests/__pycache__/test_utils.cpython-313.pyc,sha256=GX3REqZygi2eO_A2F2_KtYi7hg54X5QPtCTCGWuxGpM,14054
|
|
1935
1940
|
app_kit/tests/__pycache__/test_views.cpython-311.pyc,sha256=NDJR40TcMm-bXXC-wV7OgH1sGR3N7psSWYiUirkkrjU,133242
|
|
1936
1941
|
app_kit/tests/__pycache__/test_views.cpython-313.pyc,sha256=q851UqIZFCCTfQb1lF4SVxV1j_Vu1hJdOlpckmrGX28,125363
|
|
1937
|
-
localcosmos_app_kit-0.9.
|
|
1938
|
-
localcosmos_app_kit-0.9.
|
|
1939
|
-
localcosmos_app_kit-0.9.
|
|
1940
|
-
localcosmos_app_kit-0.9.
|
|
1941
|
-
localcosmos_app_kit-0.9.
|
|
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,,
|
{localcosmos_app_kit-0.9.16.dist-info → localcosmos_app_kit-0.9.18.dist-info}/licenses/LICENCE
RENAMED
|
File without changes
|
|
File without changes
|