igs-slm 0.1.4b0__py3-none-any.whl → 0.1.5b1__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 (151) hide show
  1. igs_slm-0.1.5b1.dist-info/METADATA +115 -0
  2. {igs_slm-0.1.4b0.dist-info → igs_slm-0.1.5b1.dist-info}/RECORD +193 -173
  3. {igs_slm-0.1.4b0.dist-info → igs_slm-0.1.5b1.dist-info}/WHEEL +1 -1
  4. igs_slm-0.1.5b1.dist-info/entry_points.txt +3 -0
  5. {igs_slm-0.1.4b0.dist-info → igs_slm-0.1.5b1.dist-info/licenses}/LICENSE +1 -1
  6. slm/__init__.py +17 -14
  7. slm/admin.py +32 -5
  8. slm/api/edit/views.py +22 -9
  9. slm/api/public/views.py +9 -7
  10. slm/api/views.py +45 -6
  11. slm/apps.py +28 -6
  12. slm/authentication.py +3 -2
  13. slm/bin/startproject.py +102 -31
  14. slm/bin/templates/{{ project_dir }}/pyproject.toml +30 -21
  15. slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/base.py +12 -1
  16. slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/develop/__init__.py +5 -27
  17. slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/production/__init__.py +6 -27
  18. slm/bin/templates/{{ project_dir }}/src/sites/{{ site }}/urls.py +15 -0
  19. slm/bin/templates/{{ project_dir }}/src/sites/{{ site }}/validation.py +29 -0
  20. slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/urls.py +1 -0
  21. slm/context.py +5 -0
  22. slm/defines/AlertLevel.py +10 -2
  23. slm/defines/AntennaCalibration.py +6 -4
  24. slm/defines/AntennaFeatures.py +12 -9
  25. slm/defines/AntennaReferencePoint.py +12 -10
  26. slm/defines/Aspiration.py +4 -2
  27. slm/defines/CardinalDirection.py +6 -4
  28. slm/defines/CollocationStatus.py +3 -1
  29. slm/defines/EquipmentState.py +6 -12
  30. slm/defines/FlagSeverity.py +4 -2
  31. slm/defines/FractureSpacing.py +7 -5
  32. slm/defines/FrequencyStandardType.py +6 -4
  33. slm/defines/GeodesyMLVersion.py +2 -0
  34. slm/defines/Instrumentation.py +9 -7
  35. slm/defines/LogEntryType.py +17 -15
  36. slm/defines/SLMFileType.py +4 -2
  37. slm/defines/SiteFileUploadStatus.py +7 -24
  38. slm/defines/SiteLogFormat.py +8 -32
  39. slm/defines/SiteLogStatus.py +9 -28
  40. slm/defines/TectonicPlates.py +18 -16
  41. slm/manage.py +24 -0
  42. slm/management/commands/check_upgrade.py +142 -0
  43. slm/management/commands/generate_sinex.py +110 -92
  44. slm/management/commands/head_from_index.py +11 -8
  45. slm/management/commands/import_archive.py +27 -18
  46. slm/management/commands/import_equipment.py +1 -1
  47. slm/management/commands/sitelog.py +1 -3
  48. slm/management/commands/synchronize.py +1 -1
  49. slm/management/commands/validate_db.py +6 -4
  50. slm/map/defines.py +18 -14
  51. slm/map/templates/slm/map.html +5 -7
  52. slm/migrations/0001_remove_archiveindex_no_overlapping_ranges_per_site_and_more.py +26 -0
  53. slm/migrations/0002_alter_archivedsitelog_file_and_more.py +44 -0
  54. slm/migrations/0003_alter_archivedsitelog_name_and_more.py +35 -0
  55. slm/migrations/0004_alter_site_name.py +24 -0
  56. slm/migrations/0005_slmversion.py +30 -0
  57. slm/migrations/0017_alter_logentry_unique_together_and_more.py +3 -1
  58. slm/migrations/0018_afix_deleted.py +3 -1
  59. slm/migrations/0018_alter_siteantenna_options_and_more.py +87 -56
  60. slm/migrations/0019_remove_siteantenna_marker_enu_siteantenna_marker_une_and_more.py +1 -1
  61. slm/migrations/0023_archivedsitelog_gml_version_and_more.py +1 -1
  62. slm/migrations/0031_alter_antenna_features.py +44 -0
  63. slm/migrations/0032_archiveindex_valid_range_and_more.py +84 -0
  64. slm/migrations/add_index_order_index.py +54 -0
  65. slm/migrations/normalize_index.py +147 -0
  66. slm/migrations/simplify_index.py +48 -0
  67. slm/migrations/verify_index.py +67 -0
  68. slm/models/__init__.py +2 -0
  69. slm/models/alerts.py +7 -10
  70. slm/models/data.py +1 -2
  71. slm/models/equipment.py +1 -1
  72. slm/models/fields.py +41 -0
  73. slm/models/index.py +183 -53
  74. slm/models/sitelog.py +35 -38
  75. slm/models/system.py +72 -31
  76. slm/models/user.py +1 -1
  77. slm/parsing/__init__.py +33 -16
  78. slm/parsing/legacy/binding.py +65 -34
  79. slm/parsing/legacy/parser.py +2 -2
  80. slm/parsing/xsd/binding.py +1 -1
  81. slm/parsing/xsd/parser.py +1 -2
  82. slm/receivers/__init__.py +2 -2
  83. slm/receivers/index.py +2 -1
  84. slm/receivers/migration.py +21 -0
  85. slm/settings/__init__.py +192 -4
  86. slm/settings/assets.py +26 -0
  87. slm/settings/auth.py +18 -14
  88. slm/settings/ckeditor.py +12 -6
  89. slm/settings/debug.py +2 -2
  90. slm/settings/emails.py +50 -0
  91. slm/settings/internationalization.py +8 -6
  92. slm/settings/logging.py +100 -88
  93. slm/settings/platform/darwin.py +16 -6
  94. slm/settings/rest.py +20 -15
  95. slm/settings/root.py +192 -98
  96. slm/settings/routines.py +5 -1
  97. slm/settings/secrets.py +20 -31
  98. slm/settings/security.py +7 -5
  99. slm/settings/slm.py +35 -23
  100. slm/settings/static_templates.py +12 -9
  101. slm/settings/templates.py +31 -25
  102. slm/settings/uploads.py +33 -5
  103. slm/settings/urls.py +7 -12
  104. slm/settings/validation.py +165 -165
  105. slm/signals.py +3 -2
  106. slm/static/slm/css/style.css +37 -36
  107. slm/static/slm/js/autocomplete.js +6 -4
  108. slm/static/slm/js/enums.js +6 -5
  109. slm/static/slm/js/file_modal.js +62 -0
  110. slm/static/slm/js/form.js +3 -3
  111. slm/static/slm/js/formWidget.js +3 -3
  112. slm/static/slm/js/persistable.js +5 -1
  113. slm/templates/admin/base.html +1 -0
  114. slm/templates/rest_framework/base.html +23 -11
  115. slm/templates/slm/base.html +27 -22
  116. slm/templates/slm/forms/widgets/auto_complete.html +12 -11
  117. slm/templates/slm/forms/widgets/auto_complete_multiple.html +8 -7
  118. slm/templates/slm/station/download.html +6 -6
  119. slm/templates/slm/station/edit.html +9 -17
  120. slm/templates/slm/station/review.html +5 -3
  121. slm/templates/slm/station/upload.html +4 -1
  122. slm/templates/slm/station/uploads/legacy.html +1 -1
  123. slm/templates/slm/widgets/alert_scroll.html +4 -8
  124. slm/templates/slm/widgets/filelist.html +0 -5
  125. slm/templates/slm/widgets/log_scroll.html +2 -13
  126. slm/templates/slm/widgets/stationlist.html +2 -8
  127. slm/templatetags/slm.py +70 -9
  128. slm/utils.py +13 -4
  129. slm/validators.py +14 -14
  130. slm/views.py +6 -6
  131. slm/wsgi.py +16 -0
  132. igs_slm-0.1.4b0.dist-info/METADATA +0 -154
  133. igs_slm-0.1.4b0.dist-info/entry_points.txt +0 -3
  134. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/urls.py +0 -7
  135. slm/bin/templates/{{ project_dir }}/sites/{{ site }}/validation.py +0 -11
  136. /slm/bin/templates/{{ project_dir }}/{sites → src/sites}/__init__.py +0 -0
  137. /slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/__init__.py +0 -0
  138. /slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/develop/local.py +0 -0
  139. /slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/develop/wsgi.py +0 -0
  140. /slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/manage.py +0 -0
  141. /slm/bin/templates/{{ project_dir }}/{sites → src/sites}/{{ site }}/production/wsgi.py +0 -0
  142. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/__init__.py +0 -0
  143. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/admin.py +0 -0
  144. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/apps.py +0 -0
  145. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/management/__init__.py +0 -0
  146. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/management/commands/__init__.py +0 -0
  147. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/management/commands/import_archive.py +0 -0
  148. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/migrations/__init__.py +0 -0
  149. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/models.py +0 -0
  150. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/templates/slm/base.html +0 -0
  151. /slm/bin/templates/{{ project_dir }}/{{{ extension_app }} → src/{{ extension_app }}}/views.py +0 -0
slm/__init__.py CHANGED
@@ -1,21 +1,24 @@
1
1
  r"""
2
- .----------------. .----------------. .----------------.
3
- | .--------------. || .--------------. || .--------------. |
4
- | | _______ | || | _____ | || | ____ ____ | |
5
- | | / ___ | | || | |_ _| | || ||_ \ / _|| |
6
- | | | (__ \_| | || | | | | || | | \/ | | |
7
- | | '.___`-. | || | | | _ | || | | |\ /| | | |
8
- | | |`\____) | | || | _| |__/ | | || | _| |_\/_| |_ | |
9
- | | |_______.' | || | |________| | || ||_____||_____|| |
10
- | | | || | | || | | |
11
- | '--------------' || '--------------' || '--------------' |
12
- '----------------' '----------------' '----------------'
2
+ ::
3
+
4
+ .----------------. .----------------. .----------------.
5
+ | .--------------. || .--------------. || .--------------. |
6
+ | | _______ | || | _____ | || | ____ ____ | |
7
+ | | / ___ | | || | |_ _| | || ||_ \ / _|| |
8
+ | | | (__ \_| | || | | | | || | | \/ | | |
9
+ | | '.___`-. | || | | | _ | || | | |\ /| | | |
10
+ | | |`\____) | | || | _| |__/ | | || | _| |_\/_| |_ | |
11
+ | | |_______.' | || | |________| | || ||_____||_____|| |
12
+ | | | || | | || | | |
13
+ | '--------------' || '--------------' || '--------------' |
14
+ '----------------' '----------------' '----------------'
15
+
13
16
  """
14
17
 
15
- VERSION = (0, 1, "4b")
18
+ VERSION = (0, 1, "5b1")
16
19
 
17
20
  __title__ = "IGS/Site Log Manager"
18
21
  __version__ = ".".join(str(i) for i in VERSION)
19
- __author__ = ["Ashley Santiago", "Brian Kohan", "Rachel Pham"]
22
+ __author__ = "Ashley Santiago, Brian Kohan, Rachel Pham, Robert Khachikyan"
20
23
  __license__ = "MIT"
21
- __copyright__ = "Copyright 2022-2024 International GNSS Service"
24
+ __copyright__ = "Copyright 2022-2025 International GNSS Service"
slm/admin.py CHANGED
@@ -27,6 +27,7 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
27
27
  from django.contrib.auth.models import Group
28
28
  from django.db.models import BooleanField, Count, Q
29
29
  from django.db.models.expressions import ExpressionWrapper
30
+ from django.utils.html import format_html
30
31
  from django.utils.safestring import mark_safe
31
32
  from django.utils.translation import gettext_lazy as _
32
33
  from polymorphic.admin import (
@@ -281,6 +282,11 @@ class NetworkAdmin(admin.ModelAdmin):
281
282
  ordering = ("name",)
282
283
  list_filter = ("public",)
283
284
 
285
+ def formfield_for_manytomany(self, db_field, request, **kwargs):
286
+ if db_field.name == "sites":
287
+ kwargs["queryset"] = Site.objects.order_by("name")
288
+ return super().formfield_for_manytomany(db_field, request, **kwargs)
289
+
284
290
 
285
291
  class AgencyUserInline(admin.TabularInline):
286
292
  model = Agency.users.through
@@ -459,29 +465,50 @@ class ArchiveFileInline(admin.TabularInline):
459
465
  field.name if field.name != "file" else "path"
460
466
  for field in ArchivedSiteLog._meta.get_fields()
461
467
  if field.name not in ["id", "site", "thumbnail", "name", "timestamp"]
462
- ]
468
+ ] + ["view_file"]
463
469
  can_delete = False
464
470
  exclude = ["site", "file", "thumbnail", "name", "timestamp"]
465
471
 
466
472
  def path(self, obj):
467
- return obj.file.path
473
+ return format_html('<a href="{}" download>{}</a>', obj.link, obj.file.name)
468
474
 
469
475
  def has_add_permission(self, request, obj):
470
476
  return False
471
477
 
478
+ def view_file(self, obj):
479
+ if obj.pk:
480
+ return format_html(
481
+ '<button type="button" class="button view-file" data-url="{}">{}</button>',
482
+ obj.link,
483
+ _("View"),
484
+ )
485
+ return ""
486
+
487
+ view_file.short_description = _("View")
488
+
472
489
 
473
490
  class ArchiveIndexAdmin(admin.ModelAdmin):
474
491
  search_fields = ["site__name"]
475
492
  list_display = ("site", "begin", "end")
476
493
 
477
- ordering = ("-begin",)
478
-
479
494
  inlines = [ArchiveFileInline]
480
- readonly_fields = ["site", "begin", "end"]
495
+ readonly_fields = ["site"]
496
+
497
+ def begin(self, obj):
498
+ return obj.begin
499
+
500
+ def end(self, obj):
501
+ return obj.end
481
502
 
482
503
  def get_queryset(self, request):
483
504
  return self.model.objects.select_related("site").prefetch_related("files")
484
505
 
506
+ class Media:
507
+ js = (
508
+ "admin/js/jquery.init.js",
509
+ "slm/js/file_modal.js",
510
+ )
511
+
485
512
 
486
513
  """
487
514
  class SLMAdminSite(admin.AdminSite):
slm/api/edit/views.py CHANGED
@@ -726,11 +726,12 @@ class SectionViewSet(type):
726
726
  # also needs to be DRYed w/ published_diff function
727
727
  for field in ModelClass.site_log_fields():
728
728
  if field in validated_data:
729
- is_many = isinstance(
730
- instance._meta.get_field(field),
731
- models.ManyToManyField,
732
- )
729
+ mdl_field = instance._meta.get_field(field)
730
+ is_many = isinstance(mdl_field, models.ManyToManyField)
733
731
  new_value = validated_data.get(field)
732
+ if new_value is None and not mdl_field.null:
733
+ # convert Nones to empty strings if warranted
734
+ new_value = mdl_field.default
734
735
  old_value = getattr(instance, field)
735
736
  if (
736
737
  not is_many
@@ -1327,7 +1328,7 @@ class SiteFileUploadViewSet(
1327
1328
  {
1328
1329
  "file": upload.id,
1329
1330
  "error": _(
1330
- "There were errors parsing the site " "log."
1331
+ "There were errors parsing the site log."
1331
1332
  ),
1332
1333
  },
1333
1334
  status=400,
@@ -1372,7 +1373,7 @@ class SiteFileUploadViewSet(
1372
1373
  {
1373
1374
  "file": upload.id,
1374
1375
  "error": _(
1375
- "There were errors parsing the site " "log."
1376
+ "There were errors parsing the site log."
1376
1377
  ),
1377
1378
  },
1378
1379
  status=400,
@@ -1483,12 +1484,24 @@ class SiteFileUploadViewSet(
1483
1484
  # ensure that the prepared by field will be set to what was given
1484
1485
  # in the upload log
1485
1486
  for index, section in reversed(parsed.sections.items()):
1486
- if section.example or not section.contains_values:
1487
+ if section.example:
1487
1488
  continue
1488
-
1489
1489
  section_view = self.SECTION_VIEWS.get(section.heading_index, None)
1490
+ # this is a complicated conditional to determine if the section is a subsection
1491
+ # header with no bindable values - think about encapsulating this logic onto
1492
+ # the binder
1493
+ if (
1494
+ section_view
1495
+ and issubclass(
1496
+ section_view.serializer_class.Meta.model, SiteSubSection
1497
+ )
1498
+ and not isinstance(section.order, int)
1499
+ and not section.contains_values
1500
+ ):
1501
+ # skip subsection headers
1502
+ continue
1490
1503
  if section_view:
1491
- data = {**section.binding, "site": self.site.id}
1504
+ data = {**(section.binding or {}), "site": self.site.id}
1492
1505
  subsection_number = self.get_subsection_id(section)
1493
1506
  if subsection_number is not None:
1494
1507
  # we have to find the right subsection identifiers
slm/api/public/views.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import json
2
- from datetime import datetime
2
+ from datetime import datetime, timezone
3
3
 
4
4
  import django_filters
5
5
  from crispy_forms.helper import FormHelper
@@ -565,6 +565,8 @@ class ArchiveViewSet(
565
565
  permission_classes = []
566
566
 
567
567
  class ArchiveFilter(CrispyFormCompat, FilterSet):
568
+ NULL_EPOCH = datetime.max.replace(tzinfo=timezone.utc)
569
+
568
570
  site = django_filters.CharFilter(
569
571
  field_name="site__name",
570
572
  help_text=_("The site of the archived log to download."),
@@ -577,17 +579,17 @@ class ArchiveViewSet(
577
579
 
578
580
  epoch = SLMDateTimeFilter(
579
581
  method="at_epoch",
580
- initial=lambda: datetime.now(),
582
+ initial=NULL_EPOCH,
581
583
  help_text=_(
582
- "Get the archive that was active at this given date or " "datetime."
584
+ "Get the archive that was active at this given date or datetime."
583
585
  ),
584
586
  )
585
587
 
586
588
  def at_epoch(self, queryset, name, value):
587
- return queryset.filter(
588
- Q(index__begin__lte=value)
589
- & (Q(index__end__isnull=True) | Q(index__end__gt=value))
590
- )
589
+ if value == self.NULL_EPOCH:
590
+ return queryset.order_by("-valid_range")[:1]
591
+ else:
592
+ return queryset.filter(valid_range__contains=value)
591
593
 
592
594
  class Meta:
593
595
  model = ArchivedSiteLog
slm/api/views.py CHANGED
@@ -1,11 +1,11 @@
1
- from datetime import datetime
1
+ from datetime import datetime, timezone
2
2
 
3
- from django.db.models import Q
4
3
  from django.http import FileResponse
5
4
  from django.utils.translation import gettext as _
6
5
  from django_filters import filters
7
6
  from django_filters.rest_framework import DjangoFilterBackend
8
7
  from rest_framework import mixins, renderers, viewsets
8
+ from rest_framework.generics import get_object_or_404
9
9
 
10
10
  from slm.api.filter import InitialValueFilterSet, SLMDateTimeFilter
11
11
  from slm.defines import SiteLogFormat
@@ -71,9 +71,11 @@ class BaseSiteLogDownloadViewSet(mixins.RetrieveModelMixin, viewsets.GenericView
71
71
  queryset = ArchiveIndex.objects.all()
72
72
 
73
73
  class ArchiveIndexFilter(InitialValueFilterSet):
74
+ NULL_EPOCH = datetime.max.replace(tzinfo=timezone.utc)
75
+
74
76
  epoch = SLMDateTimeFilter(
75
77
  method="at_epoch",
76
- initial=lambda: datetime.now(),
78
+ initial=NULL_EPOCH,
77
79
  help_text=_("Get the log that was active at this given date or datetime."),
78
80
  )
79
81
 
@@ -90,9 +92,17 @@ class BaseSiteLogDownloadViewSet(mixins.RetrieveModelMixin, viewsets.GenericView
90
92
  )
91
93
 
92
94
  def at_epoch(self, queryset, name, value):
93
- return queryset.filter(
94
- Q(begin__lte=value) & (Q(end__isnull=True) | Q(end__gt=value))
95
- )
95
+ if value == self.NULL_EPOCH:
96
+ # # this does not work because it pulls the most recent site log *first* - would need to
97
+ # # do this after the site log filter!
98
+ # last_index = Subquery(
99
+ # queryset.order_by(Func('valid_range', function='lower').desc())
100
+ # .values('valid_range')[:1]
101
+ # )
102
+ # return queryset.filter(valid_range=last_index)
103
+ return queryset.order_by("-valid_range")[:1]
104
+ else:
105
+ return queryset.filter(valid_range__contains=value)
96
106
 
97
107
  def noop(self, queryset, _1, _2):
98
108
  return queryset
@@ -104,6 +114,35 @@ class BaseSiteLogDownloadViewSet(mixins.RetrieveModelMixin, viewsets.GenericView
104
114
  filter_backends = (DjangoFilterBackend,)
105
115
  filterset_class = ArchiveIndexFilter
106
116
 
117
+ def get_object(self):
118
+ """
119
+ We override get_object so we can apply the station lookup first to the queryset.
120
+
121
+ This is necessary because the at_epoch query uses a subquery.
122
+ """
123
+ # Perform the lookup filtering.
124
+ lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
125
+
126
+ assert lookup_url_kwarg in self.kwargs, (
127
+ "Expected view %s to be called with a URL keyword argument "
128
+ 'named "%s". Fix your URL conf, or set the `.lookup_field` '
129
+ "attribute on the view correctly."
130
+ % (self.__class__.__name__, lookup_url_kwarg)
131
+ )
132
+
133
+ obj = get_object_or_404(
134
+ self.filter_queryset(
135
+ self.get_queryset().filter(
136
+ **{self.lookup_field: self.kwargs[lookup_url_kwarg]}
137
+ )
138
+ )
139
+ )
140
+
141
+ # May raise a permission denied
142
+ self.check_object_permissions(self.request, obj)
143
+
144
+ return obj
145
+
107
146
  def get_format_suffix(self, **kwargs):
108
147
  requested_format = super().get_format_suffix(**kwargs)
109
148
 
slm/apps.py CHANGED
@@ -1,3 +1,4 @@
1
+ import shutil
1
2
  import sys
2
3
  from pprint import pformat
3
4
 
@@ -20,6 +21,30 @@ from slm.signals import signal_name
20
21
  from slm.utils import clear_caches
21
22
 
22
23
 
24
+ @register("slm", Tags.staticfiles)
25
+ def check_node_available(**kwargs):
26
+ if (
27
+ any(
28
+ (
29
+ cmd.startswith("npx")
30
+ for _, cmd in getattr(settings, "COMPRESS_PRECOMPILERS", [])
31
+ )
32
+ )
33
+ and shutil.which("npx") is None
34
+ ):
35
+ return [
36
+ Warning(
37
+ "nodejs is not available.",
38
+ hint=(
39
+ "Make sure nodejs is installed so esbuild can be used to bundle "
40
+ "static assets. (https://nodejs.org/en/download)"
41
+ ),
42
+ id="slm.W002",
43
+ )
44
+ ]
45
+ return []
46
+
47
+
23
48
  @register("slm", Tags.security)
24
49
  def check_permission_groups_setting(**kwargs):
25
50
  error_id = "slm.E002"
@@ -81,10 +106,7 @@ def check_permissions_setting(**kwargs):
81
106
  except ImportError:
82
107
  return [
83
108
  Warning(
84
- _(
85
- f"Was unable to load SLM_PERMISSIONS callable: "
86
- f"{permissions}"
87
- ),
109
+ _(f"Was unable to load SLM_PERMISSIONS callable: {permissions}"),
88
110
  hint=_(
89
111
  "Set SLM_PERMISSIONS to the import string for a "
90
112
  "callable that returns a queryset of valid system"
@@ -158,8 +180,7 @@ def check_automated_alerts(**kwargs):
158
180
  errors.append(
159
181
  alert_conf_error(
160
182
  _(
161
- "SLM_AUTOMATED_ALERTS[{}][{}][{}] is not a "
162
- "Signal"
183
+ "SLM_AUTOMATED_ALERTS[{}][{}][{}] is not a Signal"
163
184
  ).format(alert, signal_type, signal_name(signal)),
164
185
  )
165
186
  )
@@ -262,6 +283,7 @@ def site_save(sender, instance, created, raw, using, update_fields, **kwargs):
262
283
  site=instance,
263
284
  previous_status=instance._slm_pre_status,
264
285
  new_status=instance.status,
286
+ reverted=getattr(instance, "_reverted", False),
265
287
  )
266
288
 
267
289
 
slm/authentication.py CHANGED
@@ -15,7 +15,9 @@ from allauth.account.utils import user_pk_to_url_str, user_username
15
15
  from allauth.utils import build_absolute_uri
16
16
  from django.conf import settings
17
17
  from django.contrib.auth import get_user_model
18
+ from django.contrib.auth.models import Permission
18
19
  from django.contrib.sites.shortcuts import get_current_site
20
+ from django.db.models import QuerySet
19
21
  from django.urls import reverse
20
22
  from django.utils.module_loading import import_string
21
23
  from rest_framework import authentication, exceptions
@@ -187,9 +189,8 @@ def permissions():
187
189
  return Permission.objects.all()
188
190
 
189
191
 
190
- def default_permissions():
192
+ def default_permissions() -> QuerySet[Permission]:
191
193
  from django.contrib.auth import get_user_model
192
- from django.contrib.auth.models import Permission
193
194
  from django.contrib.contenttypes.models import ContentType
194
195
 
195
196
  return Permission.objects.filter(
slm/bin/startproject.py CHANGED
@@ -1,12 +1,15 @@
1
1
  import os
2
+ import re
2
3
  import shutil
3
4
  import sys
4
5
  from pathlib import Path
5
6
  from urllib.parse import urlparse
6
7
 
7
8
  import django
9
+ import environ
8
10
  from django.conf import settings
9
11
  from django_typer.completers import complete_path
12
+ from packaging.version import Version
10
13
  from render_static.engine import StaticTemplateEngine
11
14
  from rich.console import Console
12
15
  from rich.markdown import Markdown
@@ -36,36 +39,59 @@ REPORT_MARKDOWN = """
36
39
 
37
40
  ### Installation Instructions
38
41
 
39
- 1. Create a database called `{site}`.
42
+ 1. Create a database called `{database_name}`.
40
43
 
41
- 2. Use `poetry` to install your project's virtual environment:
44
+ 2. Use `uv` to install your project's virtual environment:
42
45
 
43
- {install_poetry}
46
+ {install_uv}
44
47
 
45
48
  Install dependencies:
46
49
 
47
50
  ```bash
48
- poetry install
51
+ uv sync --all-extras
49
52
  ```
50
53
 
51
54
  3. To install your database and import existing sitelogs, run:
52
55
 
53
56
  ```bash
54
- {site} routine install
57
+ uv run {site} routine install
55
58
  ```
56
59
 
57
60
  4. To run the development server, run:
58
61
 
59
62
  ```bash
60
- network runserver
63
+ uv run {site} runserver
61
64
  ```
62
65
  """
63
66
 
64
- INSTALL_POETRY = """
65
- **You do not have `poetry` installed, see**: https://python-poetry.org/docs/#installing-with-the-official-installer
67
+ INSTALL_UV = """
68
+ **You do not have `uv` installed, see**: https://docs.astral.sh/uv/getting-started/installation/
66
69
  """
67
70
 
68
71
 
72
+ def sanitize_package_dir(name: str) -> str:
73
+ """
74
+ Convert an arbitrary string to a valid Python importable name.
75
+
76
+ Rules:
77
+ - Replace any non-alphanumeric character with underscore.
78
+ - Collapse multiple underscores.
79
+ - Remove leading/trailing underscores.
80
+ - Lowercase the result.
81
+ """
82
+ return re.sub(r"_+", "_", re.sub(r"[^A-Za-z0-9]+", "_", name)).strip("_").lower()
83
+
84
+
85
+ def sanitize_package_name(name: str) -> str:
86
+ """
87
+ Replace illegal characters in a package name with underscores
88
+ according to PyPI naming rules (PEP 508).
89
+ """
90
+ return re.sub(
91
+ r"_+", "_", re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip(".-")
92
+ ).lower()
93
+
94
+
69
95
  @app.command(
70
96
  help="Create a directory structure for an slm deployment. This includes settings files and an extension app. You can change any names or settings at a later time!"
71
97
  )
@@ -84,7 +110,7 @@ def main(
84
110
  prompt="What is the name of your organization?",
85
111
  help="What is the name of your organization?",
86
112
  ),
87
- ] = "",
113
+ ] = "{domain}",
88
114
  project_dir: Annotated[
89
115
  str,
90
116
  Option(
@@ -120,22 +146,29 @@ def main(
120
146
  help="What should we call your custom Django app?",
121
147
  ),
122
148
  ] = "{org}_extensions",
149
+ database: Annotated[
150
+ str,
151
+ Option(
152
+ prompt="How should we connect to the database (db url)?",
153
+ help="How should we connect to the database (db url)?",
154
+ ),
155
+ ] = "postgis:///{site}",
123
156
  include_map: Annotated[
124
157
  bool,
125
158
  Option(
126
- "--include-map",
159
+ "--no-include-map",
127
160
  prompt="Install the mapbox map app?",
128
161
  help="Install the mapbox map app?",
129
162
  ),
130
- ] = False,
163
+ ] = True,
131
164
  use_igs_validation: Annotated[
132
165
  bool,
133
166
  Option(
134
- "--use-igs-validation",
167
+ "--no-use-igs-validation",
135
168
  prompt="Use IGS sitelog validation defaults?",
136
169
  help="Use IGS sitelog validation defaults?",
137
170
  ),
138
- ] = False,
171
+ ] = True,
139
172
  ):
140
173
  netloc = (urlparse(netloc).netloc or netloc).lower()
141
174
  parts = netloc.split(".")
@@ -186,13 +219,28 @@ def main(
186
219
  ]
187
220
  }
188
221
  )
189
- if not organization:
190
- organization = domain.title()
191
- org = organization.replace(" ", "_").lower()
192
- extension_app = extension_app.format(org=(org or "slm"))
222
+ if not organization or organization == "{domain}":
223
+ parts = domain.split(".")
224
+ organization = parts[0]
225
+ if len(parts) > 1:
226
+ organization = parts[-2]
227
+ organization = organization.title()
228
+ org = organization.replace(" ", "_").replace(".", "_").lower()
229
+ extension_app = sanitize_package_dir(extension_app.format(org=(org or "slm")))
193
230
  site = site.format(subdomain=subdomain).replace(" ", "_").lower()
194
231
  project_dir = project_dir.format(subdomain=subdomain)
195
- package_name = package_name.format(project_dir=project_dir)
232
+ package_name = sanitize_package_name(package_name.format(project_dir=project_dir))
233
+ database = database.format(site=site)
234
+ if database.isalpha():
235
+ database = f"postgis:///{database}"
236
+
237
+ env = environ.Env()
238
+ database_name = env.db_url_config(
239
+ database, engine="django.contrib.gis.db.backends.postgis"
240
+ )["NAME"]
241
+
242
+ if not database_name:
243
+ raise
196
244
 
197
245
  # find site packages dir
198
246
  local_slm = None
@@ -200,16 +248,33 @@ def main(
200
248
  for pth in (Path(pth) for pth in sys.path):
201
249
  if pth.name == "site-packages":
202
250
  if pth not in slm_pth.parents:
203
- if yes(
204
- input(
205
- "It looks like you are using a local clone of the SLM, would "
206
- "you prefer to use this instead of a release on pypi? (Y/n): "
207
- )
208
- ):
209
- local_slm = os.path.relpath(
210
- slm_pth.parent, directory.absolute().resolve() / project_dir
211
- )
251
+ local_slm = os.path.relpath(
252
+ slm_pth.parent.parent,
253
+ directory.absolute().resolve() / project_dir,
254
+ )
212
255
  break
256
+ else:
257
+ # uvx --from path edge case
258
+ import json
259
+ from importlib.metadata import version
260
+
261
+ direct_url = (
262
+ pth / f"igs_slm-{version('igs-slm')}.dist-info" / "direct_url.json"
263
+ )
264
+ if direct_url.is_file():
265
+ installed_from = json.loads(direct_url.read_text()).get("url", None)
266
+ if installed_from.startswith("file:///"):
267
+ local_slm = str(Path(installed_from[7:]).resolve())
268
+ break
269
+
270
+ if local_slm:
271
+ if not yes(
272
+ input(
273
+ "It looks like you are using a local clone of the SLM, would "
274
+ "you prefer to use this instead of a release on pypi? (Y/n): "
275
+ )
276
+ ):
277
+ local_slm = None
213
278
 
214
279
  ctx = {
215
280
  "netloc": netloc,
@@ -218,8 +283,11 @@ def main(
218
283
  "org": org,
219
284
  "project_dir": project_dir,
220
285
  "site": site,
286
+ "database": database,
287
+ "database_name": database_name,
221
288
  "production_dir": production_dir.format(site=site),
222
289
  "slm_version": slm.__version__,
290
+ "slm_version_next_major": f"{Version(slm.__version__).major + 1}.0",
223
291
  "extension_app_class": extension_app.title().replace(" ", "").replace("_", ""),
224
292
  "extension_app": extension_app.replace(" ", "_").lower(),
225
293
  "include_map": include_map,
@@ -231,17 +299,19 @@ def main(
231
299
  "{{ project_dir }}/**",
232
300
  context=ctx,
233
301
  dest=directory,
234
- exclude=[template_dir / "{{ project_dir }}/{{ extension_app }}/templates"],
302
+ exclude=[template_dir / "{{ project_dir }}/src/{{ extension_app }}/templates"],
235
303
  )
236
304
  engine.render_to_disk(
237
- "{{ project_dir }}/{{ extension_app }}/templates/**",
305
+ "{{ project_dir }}/src/{{ extension_app }}/templates/**",
238
306
  context=ctx,
239
307
  dest=directory,
240
308
  render_contents=False,
241
309
  )
242
310
 
243
311
  output = (directory / project_dir).absolute().resolve()
244
- assert output.is_dir() and output.exists()
312
+ assert output.is_dir() and output.exists(), (
313
+ f"Failed to create project directory: {output}"
314
+ )
245
315
 
246
316
  Console().print(
247
317
  Markdown(
@@ -250,11 +320,12 @@ def main(
250
320
  develop=(output / "sites" / site / "develop/__init__.py").relative_to(
251
321
  output
252
322
  ),
323
+ database_name=database_name,
253
324
  production=(
254
325
  output / "sites" / site / "production/__init__.py"
255
326
  ).relative_to(output),
256
327
  site=site,
257
- install_poetry=INSTALL_POETRY if not shutil.which("poetry") else "",
328
+ install_uv=INSTALL_UV if not shutil.which("uv") else "",
258
329
  pyproject=(output / "pyproject.toml").relative_to(output),
259
330
  )
260
331
  )