localcosmos-app-kit 0.9.17__py3-none-any.whl → 0.10.0__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.
Files changed (27) hide show
  1. app_kit/appbuilder/AppReleaseBuilder.py +111 -53
  2. app_kit/appbuilder/JSONBuilders/TaxonProfilesJSONBuilder.py +38 -15
  3. app_kit/appbuilder/JSONBuilders/__pycache__/TaxonProfilesJSONBuilder.cpython-313.pyc +0 -0
  4. app_kit/appbuilder/TaxonBuilder.py +35 -28
  5. app_kit/appbuilder/__pycache__/AppReleaseBuilder.cpython-313.pyc +0 -0
  6. app_kit/appbuilder/__pycache__/TaxonBuilder.cpython-313.pyc +0 -0
  7. app_kit/features/backbonetaxonomy/templates/backbonetaxonomy/manage_taxon.html +1 -1
  8. app_kit/features/taxon_profiles/forms.py +12 -37
  9. app_kit/features/taxon_profiles/models.py +29 -36
  10. app_kit/features/taxon_profiles/templates/taxon_profiles/ajax/create_taxon_profile.html +1 -1
  11. app_kit/features/taxon_profiles/templates/taxon_profiles/ajax/manage_taxon_profile_form.html +35 -54
  12. app_kit/features/taxon_profiles/templates/taxon_profiles/ajax/manage_taxon_profile_morphotype.html +2 -1
  13. app_kit/features/taxon_profiles/templates/taxon_profiles/ajax/nature_guide_taxonlist.html +2 -2
  14. app_kit/features/taxon_profiles/templates/taxon_profiles/ajax/non_nature_guide_taxonlist.html +1 -1
  15. app_kit/features/taxon_profiles/templates/taxon_profiles/manage_taxon_profile.html +4 -4
  16. app_kit/features/taxon_profiles/urls.py +3 -0
  17. app_kit/features/taxon_profiles/views.py +44 -32
  18. app_kit/taxonomy/sources/custom/management/commands/__pycache__/import_custom_species_csv.cpython-313.pyc +0 -0
  19. app_kit/taxonomy/sources/custom/management/commands/__pycache__/import_custom_taxonomy_csv.cpython-313.pyc +0 -0
  20. app_kit/taxonomy/sources/custom/management/commands/import_custom_species_csv.py +177 -0
  21. app_kit/taxonomy/sources/custom/management/commands/import_custom_taxonomy_csv.py +177 -0
  22. {localcosmos_app_kit-0.9.17.dist-info → localcosmos_app_kit-0.10.0.dist-info}/METADATA +1 -1
  23. {localcosmos_app_kit-0.9.17.dist-info → localcosmos_app_kit-0.10.0.dist-info}/RECORD +26 -23
  24. app_kit/tests/TESTS_ROOT/media_for_tests/test/imagestore/31/a6a11b61d65ee19c4c22caa0682288ff.jpg +0 -0
  25. {localcosmos_app_kit-0.9.17.dist-info → localcosmos_app_kit-0.10.0.dist-info}/WHEEL +0 -0
  26. {localcosmos_app_kit-0.9.17.dist-info → localcosmos_app_kit-0.10.0.dist-info}/licenses/LICENCE +0 -0
  27. {localcosmos_app_kit-0.9.17.dist-info → localcosmos_app_kit-0.10.0.dist-info}/top_level.txt +0 -0
@@ -87,51 +87,44 @@ class TaxonProfiles(GenericContent):
87
87
  def get_primary_localization(self, meta_app=None):
88
88
  locale = super().get_primary_localization(meta_app)
89
89
 
90
- taxon_query = TaxonProfile.objects.filter(taxon_profiles=self)
91
- taxa = LazyTaxonList(queryset=taxon_query)
92
- for lazy_taxon in taxa:
93
-
94
- taxon_query = {
95
- 'taxon_source' : lazy_taxon.taxon_source,
96
- 'taxon_latname' : lazy_taxon.taxon_latname,
97
- 'taxon_author' : lazy_taxon.taxon_author,
98
- }
99
-
100
- taxon_profile = TaxonProfile.objects.filter(taxon_profiles=self, **taxon_query).first()
101
-
102
- if taxon_profile:
90
+ all_taxon_profiles = TaxonProfile.objects.filter(taxon_profiles=self)
91
+ for taxon_profile in all_taxon_profiles:
92
+
93
+ if taxon_profile.publication_status == 'draft':
94
+ continue
103
95
 
104
- if taxon_profile.morphotype:
105
- locale[taxon_profile.morphotype] = taxon_profile.morphotype
96
+ if taxon_profile.morphotype:
97
+ locale[taxon_profile.morphotype] = taxon_profile.morphotype
106
98
 
107
- for text in taxon_profile.texts():
99
+ for text in taxon_profile.texts():
108
100
 
109
- # text_type_key = 'taxon_text_{0}'.format(text.taxon_text_type.id)
110
- # short: use name as key (-> no duplicates in translation matrix)
111
- text_type_key = text.taxon_text_type.text_type
112
- locale[text_type_key] = text.taxon_text_type.text_type
113
-
114
- # text.text is a bad key, because if text.text changes, the translation is gone
115
- # text.text are long texts, so use a different key which survives text changes
116
- # locale[text.text] = text.text
101
+ # text_type_key = 'taxon_text_{0}'.format(text.taxon_text_type.id)
102
+ # short: use name as key (-> no duplicates in translation matrix)
103
+ text_type_key = text.taxon_text_type.text_type
104
+ locale[text_type_key] = text.taxon_text_type.text_type
105
+
106
+ # text.text is a bad key, because if text.text changes, the translation is gone
107
+ # text.text are long texts, so use a different key which survives text changes
108
+ # locale[text.text] = text.text
117
109
 
118
- short_text_key = self.get_short_text_key(text)
110
+ short_text_key = self.get_short_text_key(text)
119
111
 
120
- if text.text:
121
- locale[short_text_key] = text.text
112
+ if text.text:
113
+ locale[short_text_key] = text.text
122
114
 
123
- long_text_key = self.get_long_text_key(text)
115
+ long_text_key = self.get_long_text_key(text)
124
116
 
125
- if text.long_text:
126
- locale[long_text_key] = text.long_text
117
+ if text.long_text:
118
+ locale[long_text_key] = text.long_text
127
119
 
128
- content_images_primary_localization = taxon_profile.get_content_images_primary_localization()
129
- locale.update(content_images_primary_localization)
120
+ content_images_primary_localization = taxon_profile.get_content_images_primary_localization()
121
+ locale.update(content_images_primary_localization)
122
+
123
+ short_profile = taxon_profile.short_profile
124
+ if short_profile:
125
+ locale[short_profile] = short_profile
130
126
 
131
- short_profile = taxon_profile.short_profile
132
- if short_profile:
133
- locale[short_profile] = short_profile
134
-
127
+
135
128
  navigation = TaxonProfilesNavigation.objects.filter(taxon_profiles=self).first()
136
129
 
137
130
  if navigation:
@@ -32,7 +32,7 @@
32
32
  <script>
33
33
  var modal = $("#Modal");
34
34
  modal.modal("hide");
35
- window.location = "{% url 'manage_taxon_profile' meta_app.id taxon_profiles.id taxon.taxon_source taxon.name_uuid %}";
35
+ window.location = "{% url 'manage_taxon_profile' meta_app.id taxon_profile.id %}";
36
36
  </script>
37
37
  {% endif %}
38
38
  {% endblock %}
@@ -1,70 +1,51 @@
1
1
  {% load i18n static localcosmos_tags %}
2
2
  {% if text_types %}
3
- <form id="taxon-text-types-form" method="POST" action="{% url 'manage_taxon_profile' meta_app.id taxon_profiles.id taxon.taxon_source taxon.name_uuid %}">{% csrf_token %}
3
+ <form id="taxon-text-types-form" method="POST" action="{% url 'manage_taxon_profile' meta_app.id taxon_profile.id %}"
4
+ >{% csrf_token %}
4
5
  <div id="text-types-form-fields">
5
6
  {% for field in form %}
6
- {% if field.field.is_category_field %}
7
- {% if field.field.is_first_category %}
8
- <div id="order-ctype-{{ field.field.category|ctype_id }}-container" class="object-order-flex mb-3">
9
- {% endif %}
10
- <div id="order-ctype-{{ text_type_content_type.id }}{% if field.field.category %}-{{ field.field.category.id }}{% endif %}-container" {% if field.field.category %}data-object-id="{{ field.field.category.id }}"{% endif %} class="text-type-category-container">
7
+ {% if field.field.category %}
8
+ <h2>
9
+ {{ field.field.category }}
10
+ </h2>
11
11
  {% endif %}
12
- <div {% if field.name in form.text_type_map %}id="ctype-{{ field.field.taxon_text_type|ctype_id }}-{{ field.field.taxon_text_type.id }}" data-object-id="{{ field.field.taxon_text_type.id }}" {% endif %} class="row">
13
- <div class="col-12">
14
- {% if field.is_hidden %}
15
- {% if field.field.category %}
16
- <h2>
17
- {{ field.field.category }}
18
- </h2>
12
+ <div class="row">
13
+ <div class="col-12">
19
14
 
20
- {% if not field.field.text_type_count %}
21
- {% if taxon_profile.taxon_text_set %}
22
- <div class="alert alert-info">{% trans 'The selected Text Set does not include any text types for this category.' %}</div>
23
- {% else %}
24
- <div class="alert alert-info">{% trans 'No text types for this this category yet.' %}</div>
25
- {% endif %}
26
- {% endif %}
27
- {% else %}
15
+ {% if field.is_hidden %}
16
+ {{ field }}
17
+ {% else %}
18
+ <div class="form-group {% if field.errors %}has-error{% endif %}">
19
+
20
+ <h4>
21
+ {{ field.label }}
22
+ {% if field.field.taxon_text_type %}
23
+ {% if show_text_length_badges %}{% if field.field.is_short_version %}<span class="badge badge-info">{% trans 'short version' %}</span>{% else %}<span class="badge badge-primary">{% trans 'long version' %}</span>{% endif %} {% endif %}
28
24
  {% endif %}
29
- {{ field }}
30
- {% else %}
31
- <div class="form-group {% if field.errors %}has-error{% endif %}">
25
+ </h4>
32
26
 
33
- <h4>
34
- {{ field.label }}
35
- {% if field.field.taxon_text_type %}
36
- {% if show_text_length_badges %}{% if field.field.is_short_version %}<span class="badge badge-info">{% trans 'short version' %}</span>{% else %}<span class="badge badge-primary">{% trans 'long version' %}</span>{% endif %} {% endif %}
37
- {% endif %}
38
- </h4>
27
+ {% if field.field.language %}
28
+ <img src="{% static 'localcosmos_server/images/countries/' %}{{ field.field.language }}.gif" /> {{ field.field.language }}
29
+ {% endif %}
39
30
 
40
- {% if field.field.language %}
41
- <img src="{% static 'localcosmos_server/images/countries/' %}{{ field.field.language }}.gif" /> {{ field.field.language }}
42
- {% endif %}
31
+ {{ field }}
32
+ {% if field.help_text %}
33
+ <small class="form-text text-muted">{{ field.help_text }}</small>
34
+ {% endif %}
35
+ </div>
43
36
 
44
- {{ field }}
45
- {% if field.help_text %}
46
- <small class="form-text text-muted">{{ field.help_text }}</small>
47
- {% endif %}
37
+ {% if field.field.taxon_text and field.field.is_short_version %}
38
+ {% with content_instance=field.field.taxon_text %}
39
+ <div id="content-images-list-{{ content_instance|ctype_id }}-{{ content_instance.id }}" class="taxon-text-images-container">
40
+ {% include 'app_kit/ajax/content_images_list.html' %}
48
41
  </div>
49
-
50
- {% if field.field.taxon_text and field.field.is_short_version %}
51
- {% with content_instance=field.field.taxon_text %}
52
- <div id="content-images-list-{{ content_instance|ctype_id }}-{{ content_instance.id }}" class="taxon-text-images-container">
53
- {% include 'app_kit/ajax/content_images_list.html' %}
54
- </div>
55
- {% endwith %}
56
- {% endif %}
57
- <br><br>
58
- {% endif %}
59
- </div>
60
- </div>
61
- {% if field.field.is_last %}
42
+ {% endwith %}
43
+ {% endif %}
44
+ <br><br>
45
+ {% endif %}
62
46
  </div>
63
- {% endif %}
47
+ </div>
64
48
  {% endfor %}
65
- {% if form.has_categories %}
66
- <!--</div>-->
67
- {% endif %}
68
49
  </div>
69
50
 
70
51
  <div class="row">
@@ -23,7 +23,8 @@
23
23
  {% if success %}
24
24
  <script>
25
25
  {% if created %}
26
- window.location.href = "{% url 'manage_taxon_profile' meta_app.id taxon_profiles.id taxon_profile.taxon.taxon_source taxon_profile.taxon.name_uuid taxon_profile.morphotype %}";
26
+ // taxon_profile is the created morphotype profile
27
+ window.location.href = "{% url 'manage_taxon_profile' meta_app.id taxon_profile.id %}";
27
28
  {% else %}
28
29
  // Reload the page to show the updated morphotype
29
30
  $('#Modal').modal('hide');
@@ -7,7 +7,7 @@
7
7
  {% if meta_node.taxon %}
8
8
  {% get_taxon_profile meta_app meta_node.taxon as profile %}
9
9
  {% if profile %}
10
- <a href="{% url 'manage_taxon_profile' meta_app.id generic_content.id profile.taxon_source profile.name_uuid %}">
10
+ <a href="{% url 'manage_taxon_profile' meta_app.id profile.id %}">
11
11
  {{ meta_node.name }} <i>{{ meta_node.taxon_latname }} {{ meta_node.taxon_author }}</i>
12
12
  </a>
13
13
  {% if profile.publication_status == 'draft' %}
@@ -23,7 +23,7 @@
23
23
  {% get_nature_guide_taxon meta_node nature_guide as taxon %}
24
24
  {% get_taxon_profile meta_app taxon as non_taxon_profile %}
25
25
  {% if non_taxon_profile %}
26
- <a href="{% url 'manage_taxon_profile' meta_app.id generic_content.id non_taxon_profile.taxon_source non_taxon_profile.name_uuid %}">
26
+ <a href="{% url 'manage_taxon_profile' meta_app.id non_taxon_profile.id %}">
27
27
  {{ meta_node.name }}
28
28
  </a>
29
29
  {% if non_taxon_profile.publication_status == 'draft' %}
@@ -3,7 +3,7 @@
3
3
  <ul>
4
4
  {% for profile in non_nature_guide_taxon_profiles %}
5
5
  <li>
6
- <a href="{% url 'manage_taxon_profile' meta_app.id generic_content.id profile.taxon_source profile.name_uuid %}">
6
+ <a href="{% url 'manage_taxon_profile' meta_app.id profile.id %}">
7
7
  <i>{{ profile.taxon_latname }} {{ profile.taxon_author }}</i>
8
8
  </a>
9
9
  {% if profile.publication_status == 'draft' %}
@@ -92,7 +92,7 @@
92
92
  </div>
93
93
  {% for duplicate in possible_duplicates %}
94
94
  <i>
95
- <a href="{% url 'manage_taxon_profile' meta_app.id taxon_profiles.id duplicate.taxon_source duplicate.name_uuid %}">{{ duplicate.taxon_latname }} {{ duplicate.taxon_author }}</a>
95
+ <a href="{% url 'manage_taxon_profile' meta_app.id duplicate.id %}">{{ duplicate.taxon_latname }} {{ duplicate.taxon_author }}</a>
96
96
  </i>
97
97
  {% endfor %}
98
98
  </div>
@@ -111,10 +111,10 @@
111
111
  </div>
112
112
  </div>
113
113
  {% endif %}
114
- {% if taxon_profile.morphotype %}
114
+ {% if taxon_profile.morphotype and taxon_profile.parent_profile %}
115
115
  <div class="row">
116
116
  <div class="col-12">
117
- {% trans 'This is a morphotype of:' %} <a href="{% url 'manage_taxon_profile' meta_app.id taxon_profiles.id taxon.taxon_source taxon.name_uuid %}">
117
+ {% trans 'This is a morphotype of:' %} <a href="{% url 'manage_taxon_profile' meta_app.id taxon_profile.parent_profile.id %}">
118
118
  {{ taxon }}
119
119
  </a>
120
120
  </div>
@@ -131,7 +131,7 @@
131
131
  {% if taxon_profile.morphotype_profiles %}
132
132
  {% for morphotype in taxon_profile.morphotype_profiles %}
133
133
  <div>
134
- <a href="{% url 'manage_taxon_profile' meta_app.id taxon_profiles.id taxon_profile.taxon.taxon_source taxon_profile.taxon.name_uuid morphotype.morphotype %}">
134
+ <a href="{% url 'manage_taxon_profile' meta_app.id morphotype.id %}">
135
135
  {{ morphotype.morphotype }}
136
136
  </a>
137
137
  </div>
@@ -10,6 +10,9 @@ urlpatterns = [
10
10
  views.CreateTaxonProfile.as_view(), name='create_taxon_profile'),
11
11
  path('manage-taxon-profile/<int:meta_app_id>/<int:taxon_profiles_id>/<str:taxon_source>/<uuid:name_uuid>/',
12
12
  views.ManageTaxonProfile.as_view(), name='manage_taxon_profile'),
13
+ # this should be the future wherever possible, as it is more robust (does not rely on taxon_source and name_uuid to be unchanged)
14
+ path('manage-taxon-profile/<int:meta_app_id>/<int:taxon_profile_id>/',
15
+ views.ManageTaxonProfile.as_view(), name='manage_taxon_profile'),
13
16
  path('delete-taxon-profile/<int:meta_app_id>/<int:pk>/',
14
17
  views.DeleteTaxonProfile.as_view(), name='delete_taxon_profile'),
15
18
  # morphotypes
@@ -298,16 +298,22 @@ class ManageTaxonProfile(CreateTaxonProfileMixin, MetaAppFormLanguageMixin, Form
298
298
 
299
299
  def set_taxon(self, request, **kwargs):
300
300
 
301
- self.taxon_profiles = TaxonProfiles.objects.get(pk=kwargs['taxon_profiles_id'])
302
-
303
- taxon_source = kwargs['taxon_source']
304
- name_uuid = kwargs['name_uuid']
301
+ if 'taxon_profile_id' in kwargs:
302
+ self.taxon_profile = TaxonProfile.objects.get(pk=kwargs['taxon_profile_id'])
303
+ self.taxon_profiles = self.taxon_profile.taxon_profiles
304
+
305
+ else:
305
306
 
306
- morphotype = kwargs.get('morphotype', None)
307
+ self.taxon_profiles = TaxonProfiles.objects.get(pk=kwargs['taxon_profiles_id'])
307
308
 
308
- self.taxon_profile = TaxonProfile.objects.get(taxon_profiles=self.taxon_profiles,
309
- taxon_source=taxon_source, name_uuid=name_uuid,
310
- morphotype=morphotype)
309
+ taxon_source = kwargs['taxon_source']
310
+ name_uuid = kwargs['name_uuid']
311
+
312
+ morphotype = kwargs.get('morphotype', None)
313
+
314
+ self.taxon_profile = TaxonProfile.objects.get(taxon_profiles=self.taxon_profiles,
315
+ taxon_source=taxon_source, name_uuid=name_uuid,
316
+ morphotype=morphotype)
311
317
 
312
318
  self.taxon = LazyTaxon(instance=self.taxon_profile)
313
319
 
@@ -744,44 +750,50 @@ class CollectTaxonImages(MetaAppFormLanguageMixin, TemplateView):
744
750
  return images
745
751
 
746
752
  def get_taxon_images(self, exclude=[]):
747
- images = ContentImage.objects.filter(image_store__taxon_source=self.taxon.taxon_source,
748
- image_store__taxon_latname=self.taxon.taxon_latname).exclude(pk__in=exclude)
753
+
754
+ images = []
755
+
756
+ if not self.taxon_profile.morphotype:
757
+ images = ContentImage.objects.filter(image_store__taxon_source=self.taxon.taxon_source,
758
+ image_store__taxon_latname=self.taxon.taxon_latname).exclude(pk__in=exclude)
749
759
 
750
760
  return images
751
761
 
752
762
  # images can be on MetNode or NatureGuidesTaxonTree
753
763
  def get_nature_guide_images(self, exclude=[]):
754
764
 
755
- meta_nodes = MetaNode.objects.filter(taxon_source=self.taxon.taxon_source,
756
- taxon_latname=self.taxon.taxon_latname, taxon_author=self.taxon.taxon_author, morphotype=self.morphotype)
757
-
758
765
  nature_guide_images = []
766
+
767
+ if not self.taxon_profile.morphotype:
768
+
769
+ meta_nodes = MetaNode.objects.filter(taxon_source=self.taxon.taxon_source,
770
+ taxon_latname=self.taxon.taxon_latname, taxon_author=self.taxon.taxon_author, morphotype=self.morphotype)
759
771
 
760
- if meta_nodes:
772
+ if meta_nodes:
761
773
 
762
- meta_node_ids = meta_nodes.values_list('id', flat=True)
774
+ meta_node_ids = meta_nodes.values_list('id', flat=True)
763
775
 
764
- meta_node_content_type = ContentType.objects.get_for_model(MetaNode)
765
- meta_node_images = ContentImage.objects.filter(content_type=meta_node_content_type,
766
- object_id__in=meta_node_ids).exclude(pk__in=exclude)
767
-
768
- exclude += list(meta_node_images.values_list('id', flat=True))
769
- nature_guide_images += list(meta_node_images)
776
+ meta_node_content_type = ContentType.objects.get_for_model(MetaNode)
777
+ meta_node_images = ContentImage.objects.filter(content_type=meta_node_content_type,
778
+ object_id__in=meta_node_ids).exclude(pk__in=exclude)
779
+
780
+ exclude += list(meta_node_images.values_list('id', flat=True))
781
+ nature_guide_images += list(meta_node_images)
770
782
 
771
-
772
- nodes = NatureGuidesTaxonTree.objects.filter(meta_node__taxon_source=self.taxon.taxon_source,
773
- meta_node__taxon_latname=self.taxon.taxon_latname,
774
- meta_node__taxon_author=self.taxon.taxon_author)
783
+
784
+ nodes = NatureGuidesTaxonTree.objects.filter(meta_node__taxon_source=self.taxon.taxon_source,
785
+ meta_node__taxon_latname=self.taxon.taxon_latname,
786
+ meta_node__taxon_author=self.taxon.taxon_author)
775
787
 
776
- if nodes:
788
+ if nodes:
777
789
 
778
- node_ids = nodes.values_list('id', flat=True)
790
+ node_ids = nodes.values_list('id', flat=True)
779
791
 
780
- node_content_type = ContentType.objects.get_for_model(NatureGuidesTaxonTree)
781
- node_images = ContentImage.objects.filter(content_type=node_content_type,
782
- object_id__in=node_ids).exclude(pk__in=exclude)
783
-
784
- nature_guide_images += list(node_images)
792
+ node_content_type = ContentType.objects.get_for_model(NatureGuidesTaxonTree)
793
+ node_images = ContentImage.objects.filter(content_type=node_content_type,
794
+ object_id__in=node_ids).exclude(pk__in=exclude)
795
+
796
+ nature_guide_images += list(node_images)
785
797
 
786
798
 
787
799
  return nature_guide_images
@@ -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)