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.
@@ -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):
@@ -13,6 +13,7 @@ TAXON_RANK_CHOICES = (
13
13
  ('', '-----'),
14
14
  ('kingdom', _('Kingdom')),
15
15
  ('phylum', _('Phylum')),
16
+ ('subphylum', _('Subphylum')),
16
17
  ('class', _('Class')),
17
18
  ('order', _('Order')),
18
19
  ('family', _('Family')),
@@ -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()
@@ -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, 'Art Vandeley')
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.16
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.10
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=LOkLgSNHEZDJHqa2P2ngRVinFefPGQFWKyEZyf4A6uQ,38495
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=IkW_Of1Dgb3j0aIKoWsUMQajPAX2aQeR31vCUn7N3d4,27185
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=nfMAbWoVuc43-Gn2dHkmGbvu1J0mppy_9X82vXDUsGw,35276
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=QQtfVkTgskISOh-nad7irG6dP47jG79AufPF_bkepms,6518
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=aPpEIytg4xuIMo0RDUlkCeH2cLE3cayETrmAvB_JW_A,2576
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=d309mIKMB1hFtwdPCUWyS6f03PFMY0R78cr_7Z1mJ9w,24313
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=qqzff_gvP29eYod6WNQLyy0g9XjP0Xjo0T61Zn-AV2E,29539
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.16.dist-info/licenses/LICENCE,sha256=VnxALPSxXoU59rlNeRdJtwS_nU79IFpVWsZZCQUM4Mw,1086
1938
- localcosmos_app_kit-0.9.16.dist-info/METADATA,sha256=dFda2jrSES7m8CBqR8DaaQnUQIfY-n_-Jseazqzr--Y,1388
1939
- localcosmos_app_kit-0.9.16.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
1940
- localcosmos_app_kit-0.9.16.dist-info/top_level.txt,sha256=F6H4pEBkCvUR_iwQHIy4K1iby-jzfWg3CTym5XJKeys,8
1941
- localcosmos_app_kit-0.9.16.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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5