diff --git a/.circleci/config.yml b/.circleci/config.yml index daf1e81ed..bb3fc634b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,7 +29,7 @@ workflows: only: - develop - automated_tests - - release-1.6 + - /release-.*/ version: 2 jobs: diff --git a/compose/local/tests/Dockerfile b/compose/local/tests/Dockerfile index b1d2518d0..1cac611a9 100644 --- a/compose/local/tests/Dockerfile +++ b/compose/local/tests/Dockerfile @@ -17,11 +17,13 @@ RUN apt-get install -y nodejs RUN npm install jsdom RUN npm install jquery RUN npm install jquery-csv +RUN npm install unzipper +RUN npm install node-fetch RUN npm install selenium-webdriver RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && apt install -y ./google-chrome-stable_current_amd64.deb -RUN wget https://chromedriver.storage.googleapis.com/85.0.4183.83/chromedriver_linux64.zip && unzip -o chromedriver_linux64.zip -d/usr/local/bin +RUN wget https://chromedriver.storage.googleapis.com/86.0.4240.22/chromedriver_linux64.zip && unzip -o chromedriver_linux64.zip -d/usr/local/bin #RUN apt install -y firefox && apt install -y firefox-geckodriver diff --git a/config/settings/base.py b/config/settings/base.py index be155c176..97c2b99d0 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -394,3 +394,6 @@ # https://docs.celeryproject.org/en/latest/userguide/configuration.html#std-setting-task_track_started # Report task state updates CELERY_TASK_TRACK_STARTED = True +# https://docs.djangoproject.com/en/3.1/ref/settings/#file-upload-max-memory-size +# 100mb (in bytes) +FILE_UPLOAD_MAX_MEMORY_SIZE = 100000000 diff --git a/rebuild_django_and_deploy.sh b/rebuild_django_and_deploy.sh index 14f318e77..f523218f8 100644 --- a/rebuild_django_and_deploy.sh +++ b/rebuild_django_and_deploy.sh @@ -2,7 +2,7 @@ # Copyright (C) 2019-2020 Woods Hole Oceanographic Institution # -# This file is part of the Roundabout Database project ("RDB" or +# This file is part of the Roundabout Database project ("RDB" or # "ooicgsn-roundabout"). # # ooicgsn-roundabout is free software: you can redistribute it and/or modify @@ -28,6 +28,9 @@ then docker-compose -f production.yml up -d --no-deps --build django docker-compose -f production.yml run --rm django python manage.py migrate docker-compose -f production.yml run --rm django python manage.py collectstatic --noinput + docker-compose -f production.yml up -d --no-deps --build celeryworker + docker-compose -f production.yml up -d --no-deps --build celerybeat + docker-compose -f production.yml up -d --no-deps --build flower fi if [ -e production-demo-site.yml ] diff --git a/rebuild_testing_and_deploy.sh b/rebuild_testing_and_deploy.sh index 0b7c21d42..f007cfba0 100644 --- a/rebuild_testing_and_deploy.sh +++ b/rebuild_testing_and_deploy.sh @@ -28,4 +28,7 @@ then docker-compose -f production-testing-site.yml up -d --no-deps --build django_testing docker-compose -f production-testing-site.yml run --rm django_testing python manage.py migrate docker-compose -f production-testing-site.yml run --rm django_testing python manage.py collectstatic --noinput + docker-compose -f production-testing-site.yml up -d --no-deps --build celeryworker + docker-compose -f production-testing-site.yml up -d --no-deps --build celerybeat + docker-compose -f production-testing-site.yml up -d --no-deps --build flower fi diff --git a/requirements/base.txt b/requirements/base.txt index 3d89124ae..381761230 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,7 +11,7 @@ flower==0.9.5 # https://github.com/mher/flower # Django # ------------------------------------------------------------------------------ -django==3.0.10 # pyup: < 3.1 # https://www.djangoproject.com/ +django==3.1.3 # # https://www.djangoproject.com/ django-environ==0.4.5 # https://github.com/joke2k/django-environ django-model-utils==4.0.0 # https://github.com/jazzband/django-model-utils django-allauth==0.42.0 # https://github.com/pennersr/django-allauth @@ -22,16 +22,16 @@ django-redis==4.12.1 # https://github.com/niwinz/django-redis djangorestframework==3.12.1 # https://github.com/encode/django-rest-framework django-cors-headers==3.5.0 coreapi==2.3.3 # https://github.com/core-api/python-client -django-filter==2.2.0 +django-filter==2.4.0 djangorestframework-link-header-pagination==0.1.1 -drf-flex-fields==0.8.6 +drf-flex-fields==0.8.8 # Your custom requirements go here django-mptt==0.11.0 django-summernote==0.8.11.6 django-bootstrap-datepicker-plus==3.0.5 sigfig==1.1.8 #https://pypi.org/project/sigfig/ -django-tables2==2.2.1 # https://github.com/jieter/django-tables2 +django-tables2==2.3.3 # https://github.com/jieter/django-tables2 tablib==1.1 # https://github.com/jazzband/tablib for exporting/downloading tables2/tabular data django-tables2-column-shifter==0.5.2 # for showing/hiding columns for tables2 django-import-export==2.0.2 diff --git a/roundabout/admintools/views.py b/roundabout/admintools/views.py index 83c488471..c83a70fee 100644 --- a/roundabout/admintools/views.py +++ b/roundabout/admintools/views.py @@ -329,7 +329,7 @@ def get_redirect_url(self, *args, **kwargs): tempimport_obj = None if tempimport_obj: - # get all the Inventory items to upload from the Temp tables + # get all the Inventory items to upload from the Temp tables2 for item_obj in tempimport_obj.tempimportitems.all(): inventory_obj = Inventory() diff --git a/roundabout/assemblies/forms.py b/roundabout/assemblies/forms.py index 44eccb373..71978a749 100644 --- a/roundabout/assemblies/forms.py +++ b/roundabout/assemblies/forms.py @@ -1,7 +1,7 @@ """ # Copyright (C) 2019-2020 Woods Hole Oceanographic Institution # -# This file is part of the Roundabout Database project ("RDB" or +# This file is part of the Roundabout Database project ("RDB" or # "ooicgsn-roundabout"). # # ooicgsn-roundabout is free software: you can redistribute it and/or modify @@ -21,6 +21,7 @@ from pprint import pprint from django import forms +from django.shortcuts import get_object_or_404 from django.forms.models import inlineformset_factory from django.core.exceptions import ValidationError @@ -164,3 +165,25 @@ class Meta: labels = { 'name': '%s Type Name' % (labels['label_assemblies_app_singular']), } + + +""" +Custom Deletion form for AssemblyType +User needs to be able to choose a new AssemblyType for existing Assemblies or they +disappear from tree nav +""" +class AssemblyTypeDeleteForm(forms.Form): + new_assembly_type = forms.ModelChoiceField(label='Select new Assembly Type', queryset=AssemblyType.objects.all()) + + def __init__(self, *args, **kwargs): + assembly_type_pk= kwargs.pop('pk') + assembly_type_to_delete = get_object_or_404(AssemblyType, id=assembly_type_pk) + + super(AssemblyTypeDeleteForm, self).__init__(*args, **kwargs) + # Check if this PartType has IPart Templates, remove new field if false + if not assembly_type_to_delete.assemblies.exists(): + self.fields['new_assembly_type'].required = False + self.fields['new_assembly_type'].widget = forms.HiddenInput() + + # remove this object from the possible replacements + self.fields['new_assembly_type'].queryset = AssemblyType.objects.exclude(id=assembly_type_to_delete.id) diff --git a/roundabout/assemblies/migrations/0011_auto_20201117_2030.py b/roundabout/assemblies/migrations/0011_auto_20201117_2030.py new file mode 100644 index 000000000..a3c3e8500 --- /dev/null +++ b/roundabout/assemblies/migrations/0011_auto_20201117_2030.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2020-11-17 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assemblies', '0010_auto_20201014_1253'), + ] + + operations = [ + migrations.AlterField( + model_name='assembly', + name='name', + field=models.CharField(db_index=True, max_length=255, unique=True), + ), + ] diff --git a/roundabout/assemblies/models.py b/roundabout/assemblies/models.py index 60b154b3d..d681b0549 100644 --- a/roundabout/assemblies/models.py +++ b/roundabout/assemblies/models.py @@ -40,7 +40,7 @@ def __str__(self): # Assembly base model class Assembly(models.Model): - name = models.CharField(max_length=255, unique=False, db_index=True) + name = models.CharField(max_length=255, unique=True, db_index=True) assembly_type = models.ForeignKey(AssemblyType, related_name='assemblies', on_delete=models.SET_NULL, null=True, blank=True) assembly_number = models.CharField(max_length=100, unique=False, db_index=True, null=False, blank=True) diff --git a/roundabout/assemblies/views.py b/roundabout/assemblies/views.py index bd4b9fb44..a35fcb3a8 100644 --- a/roundabout/assemblies/views.py +++ b/roundabout/assemblies/views.py @@ -1,7 +1,7 @@ """ # Copyright (C) 2019-2020 Woods Hole Oceanographic Institution # -# This file is part of the Roundabout Database project ("RDB" or +# This file is part of the Roundabout Database project ("RDB" or # "ooicgsn-roundabout"). # # ooicgsn-roundabout is free software: you can redistribute it and/or modify @@ -27,7 +27,7 @@ from django.db import transaction from .models import Assembly, AssemblyPart, AssemblyType, AssemblyDocument, AssemblyRevision -from .forms import AssemblyForm, AssemblyPartForm, AssemblyTypeForm, AssemblyRevisionForm, AssemblyRevisionFormset, AssemblyDocumentationFormset +from .forms import AssemblyForm, AssemblyPartForm, AssemblyTypeForm, AssemblyRevisionForm, AssemblyRevisionFormset, AssemblyDocumentationFormset, AssemblyTypeDeleteForm from roundabout.parts.models import PartType, Part from roundabout.inventory.models import Action from common.util.mixins import AjaxFormMixin @@ -746,13 +746,44 @@ def get_success_url(self): return reverse('assemblies:assembly_type_home', ) -class AssemblyTypeDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView): - model = AssemblyType +class AssemblyTypeDeleteView(LoginRequiredMixin, PermissionRequiredMixin, FormView): + form_class = AssemblyTypeDeleteForm template_name = 'assemblies/assembly_type_confirm_delete.html' - success_url = reverse_lazy('assemblies:assembly_type_home') permission_required = 'assemblies.delete_assembly' redirect_field_name = 'home' + def get_context_data(self, **kwargs): + context = super(AssemblyTypeDeleteView, self).get_context_data(**kwargs) + assembly_type = AssemblyType.objects.get(id=self.kwargs['pk']) + + context.update({ + 'assembly_type': assembly_type + }) + return context + + def get_form_kwargs(self): + kwargs = super(AssemblyTypeDeleteView, self).get_form_kwargs() + if 'pk' in self.kwargs: + kwargs['pk'] = self.kwargs['pk'] + return kwargs + + def form_valid(self, form): + new_assembly_type = form.cleaned_data['new_assembly_type'] + assembly_type_to_delete = AssemblyType.objects.get(id=self.kwargs['pk']) + + # Need to check if there's Part Templates. If so, need move them to new Part Type. + if assembly_type_to_delete.assemblies.exists(): + for assembly in assembly_type_to_delete.assemblies.all(): + assembly.assembly_type = new_assembly_type + assembly.save() + + # Delete the Assembly object + assembly_type_to_delete.delete() + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + return reverse('assemblies:assembly_type_home') + # Direct Detail view for Assembly Types class AssemblyTypeDetailView(LoginRequiredMixin, DetailView): diff --git a/roundabout/builds/api/filters.py b/roundabout/builds/api/filters.py index e492c9e5b..fb3f02789 100644 --- a/roundabout/builds/api/filters.py +++ b/roundabout/builds/api/filters.py @@ -96,3 +96,15 @@ def filter_time_in_field(self, queryset, name, value): # alternatively, you could opt to hardcode the lookup. e.g., # return queryset.filter(published_on__isnull=False) + + +class DeploymentOmsCustomFilter(filters.FilterSet): + deployment_number = filters.CharFilter(lookup_expr='icontains') + build_number = filters.CharFilter(field_name='build__build_number', lookup_expr='icontains') + class Meta: + model = Deployment + fields = [ + 'deployment_number', + 'build_number', + 'build', + ] diff --git a/roundabout/builds/api/serializers.py b/roundabout/builds/api/serializers.py index 97f44c4b0..d70e3e14c 100644 --- a/roundabout/builds/api/serializers.py +++ b/roundabout/builds/api/serializers.py @@ -28,8 +28,14 @@ from roundabout.cruises.models import Cruise from roundabout.inventory.models import Deployment from roundabout.locations.models import Location +from roundabout.configs_constants.models import ConfigEvent +from roundabout.calibrations.models import CalibrationEvent from ..models import Build +# Import environment variables from .env files +import environ +env = environ.Env() +base_url = env('RDB_SITE_URL') API_VERSION = 'api_v1' class BuildSerializer(serializers.HyperlinkedModelSerializer, FlexFieldsModelSerializer): @@ -179,3 +185,147 @@ class Meta: def get_time_in_field(self, obj): return time_at_sea_display(obj.deployment_time_in_field) + + +class DeploymentOmsCustomSerializer(FlexFieldsModelSerializer): + deployment_url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':deployments-detail', + lookup_field='pk', + ) + build_number = serializers.SerializerMethodField('get_build_number') + build_url = serializers.HyperlinkedRelatedField( + source='build', + view_name = API_VERSION + ':builds-detail', + lookup_field = 'pk', + queryset = Build.objects + ) + location_name = serializers.SerializerMethodField('get_location_name') + location_url = serializers.HyperlinkedRelatedField( + source='deployed_location', + view_name = API_VERSION + ':locations-detail', + lookup_field = 'pk', + queryset = Location.objects + ) + assembly_parts = serializers.SerializerMethodField('get_assembly_parts') + + class Meta: + model = Deployment + fields = [ + 'build_url', + 'build_number', + 'deployment_url', + 'deployment_number', + 'location_name', + 'location_url', + 'current_status', + 'latitude', + 'longitude', + 'assembly_parts', + ] + + def get_build_id(self, obj): + if obj.build: + return obj.build.id + return None + + def get_build_number(self, obj): + if obj.build: + return obj.build.build_number + return None + + def get_location_id(self, obj): + if obj.deployed_location: + return obj.deployed_location.id + return None + + def get_location_name(self, obj): + if obj.deployed_location: + return obj.deployed_location.name + return None + + def get_assembly_parts(self, obj): + # Use the InventoryDeployment related model to get historical list of Inventory items + # on each Deployment + inventory_dep_qs = obj.inventory_deployments.exclude(current_status=Deployment.DEPLOYMENTRETIRE).select_related('inventory') + assembly_parts = [] + + for inv in inventory_dep_qs: + # get all config_events for this Inventory/Deployment + configuration_values = [] + config_events = inv.inventory.config_events.filter(deployment=inv.deployment).prefetch_related('config_values') + if config_events: + for event in config_events: + for value in event.config_values.all(): + configuration_values.append({ + 'name': value.config_name.name, + 'value': value.config_value, + }) + + # get all calibration_events for this Inventory/Deployment + # need to get the CalibrationEvent that matches the Deployment date + # calibration_date field sets the range for valid Calibration Events + calibration_values = [] + if inv.inventory.calibration_events.exists(): + for event in inv.inventory.calibration_events.all(): + # find the CalibrationEvent valid date range that matches Deployment date + first_date, last_date = event.get_valid_calibration_range() + if inv.deployment_to_field_date and first_date < inv.deployment_to_field_date < last_date: + for value in event.coefficient_value_sets.all(): + calibration_values.append({ + 'name': value.coefficient_name.calibration_name, + 'value': value.value_set, + }) + break + + # get all constant_default_events for this Inventory/Deployment + constant_default_values = [] + if inv.inventory.constant_default_events.exists(): + for event in inv.inventory.constant_default_events.all(): + for value in event.constant_defaults.all(): + constant_default_values.append({ + 'name': value.config_name.name, + 'value': value.default_value, + }) + + # get all custom_fields for this Inventory/Deployment + custom_fields = [] + if inv.inventory.fieldvalues.exists(): + inv_custom_fields = inv.inventory.fieldvalues.filter(is_current=True).select_related('field') + # create initial empty dict + for field in inv_custom_fields: + custom_fields.append({ + 'name': field.field.field_name, + 'value': field.field_value, + }) + + # set up URL link fields + request = self.context.get("request") + inventory_url = reverse('api_v1:inventory-detail', kwargs={'pk': inv.inventory_id}, request=request) + assembly_part_url = reverse('api_v1:assembly-templates/assembly-parts-detail', kwargs={'pk': inv.assembly_part_id}, request=request) + + if inv.assembly_part and inv.assembly_part.parent: + parent_assembly_part_url = reverse('api_v1:assembly-templates/assembly-parts-detail', kwargs={'pk': inv.assembly_part.parent_id}, request=request) + parent_assembly_part_id = inv.assembly_part.parent.id + else: + parent_assembly_part_url = None + parent_assembly_part_id = None + # create object to populate the "assembly_part" list + item_obj = { + 'assembly_part_url': assembly_part_url, + 'assembly_part_id': inv.assembly_part.id, + 'part_name': inv.inventory.part.name, + 'part_type': inv.inventory.part.part_type.name if inv.inventory.part.part_type else None, + 'parent_assembly_part_url': parent_assembly_part_url, + 'parent_assembly_part_id': parent_assembly_part_id, + 'inventory_url': inventory_url, + 'inventory_serial_number': inv.inventory.serial_number, + 'deployment_to_field_date': inv.deployment_to_field_date, + 'deployment_recovery_date': inv.deployment_recovery_date, + 'configuration_values': configuration_values, + 'calibration_values': calibration_values, + 'constant_default_values': constant_default_values, + 'custom_fields': custom_fields, + } + assembly_parts.append(item_obj) + + return assembly_parts diff --git a/roundabout/builds/api/views.py b/roundabout/builds/api/views.py index 5b0aa2df8..34785cc8d 100644 --- a/roundabout/builds/api/views.py +++ b/roundabout/builds/api/views.py @@ -23,7 +23,7 @@ from roundabout.core.api.views import FlexModelViewSet from .filters import * -from .serializers import BuildSerializer, DeploymentSerializer +from .serializers import BuildSerializer, DeploymentSerializer, DeploymentOmsCustomSerializer class BuildViewSet(FlexModelViewSet): @@ -38,3 +38,14 @@ class DeploymentViewSet(FlexModelViewSet): serializer_class = DeploymentSerializer permission_classes = (IsAuthenticated,) filterset_class = DeploymentFilter + + +class DeploymentOmsCustomViewSet(FlexModelViewSet): + serializer_class = DeploymentOmsCustomSerializer + permission_classes = (IsAuthenticated,) + filterset_class = DeploymentOmsCustomFilter + + def get_queryset(self): + queryset = Deployment.objects.all().prefetch_related('inventory_deployments') + queryset = queryset.prefetch_related('inventory_deployments').select_related('build') + return queryset diff --git a/roundabout/builds/forms.py b/roundabout/builds/forms.py index 4a64b5924..8a7b80273 100644 --- a/roundabout/builds/forms.py +++ b/roundabout/builds/forms.py @@ -238,6 +238,8 @@ class Meta: 'deployment_number': '%s Number' % (labels['label_deployments_app_singular']), 'deployed_location': 'Final %s Location' % (labels['label_deployments_app_singular']), 'cruise_deployed': 'Cruise Deployed On', + 'latitude': 'Latitude (+/- degrees N)', + 'longitude': 'Longitude (+/- degrees E)', } widgets = { @@ -376,6 +378,8 @@ class Meta: labels = { 'location': '%s Location' % (labels['label_deployments_app_singular']), 'cruise_deployed': 'Cruise Deployed On', + 'latitude': 'Latitude (+/- degrees N)', + 'longitude': 'Longitude (+/- degrees E)', } # Add custom date field to allow user to pick date for the Action record diff --git a/roundabout/builds/models.py b/roundabout/builds/models.py index 5e9e3483d..379fd3d7c 100644 --- a/roundabout/builds/models.py +++ b/roundabout/builds/models.py @@ -68,7 +68,7 @@ class Meta: ordering = ['assembly_revision', 'build_number'] def __str__(self): - return '%s - %s' % (self.build_number, self.assembly_revision.assembly.name) + return '%s - %s' % (self.assembly_revision.assembly.name, self.build_number) @property def name(self): diff --git a/roundabout/calibrations/models.py b/roundabout/calibrations/models.py index dff82357c..4df758d82 100644 --- a/roundabout/calibrations/models.py +++ b/roundabout/calibrations/models.py @@ -27,7 +27,6 @@ from roundabout.parts.models import Part from roundabout.users.models import User - # Tracks Calibration Coefficient event history across Inventory Parts class CalibrationEvent(models.Model): class Meta: @@ -60,6 +59,23 @@ def get_sorted_reviewers(self): def get_sorted_approvers(self): return self.user_approver.all().order_by('username') + # method to return a date range that corresponds to the period of time when this CalibrationEvent is valid + # this range corresponds to this calibration_date -> next latest calibration_date. + # calibration_date is floor of range. + # Returns a list of datetimes + def get_valid_calibration_range(self): + next_event = CalibrationEvent.objects.filter(inventory=self.inventory).filter(calibration_date__gt=self.calibration_date).last() + + if next_event: + last_date = next_event.calibration_date + else: + last_date = timezone.now() + + calibration_range = [self.calibration_date, last_date] + print(calibration_range) + print(self) + return calibration_range + # Tracks Coefficient Name Event history across Parts class CoefficientNameEvent(models.Model): diff --git a/roundabout/calibrations/tasks.py b/roundabout/calibrations/tasks.py new file mode 100644 index 000000000..af98ad16c --- /dev/null +++ b/roundabout/calibrations/tasks.py @@ -0,0 +1,41 @@ +""" +# Copyright (C) 2019-2020 Woods Hole Oceanographic Institution +# +# This file is part of the Roundabout Database project ("RDB" or +# "ooicgsn-roundabout"). +# +# ooicgsn-roundabout is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# ooicgsn-roundabout is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ooicgsn-roundabout in the COPYING.md file at the project root. +# If not, see . +""" + +from celery import shared_task + +from roundabout.calibrations.models import CalibrationEvent +from roundabout.configs_constants.models import ConfigEvent, ConfigDefaultEvent, ConstDefaultEvent + + +@shared_task(bind = True) +def check_events(self): + for event in CalibrationEvent.objects.all(): + if not event.coefficient_value_sets.exists(): + event.delete() + for event in ConfigEvent.objects.all(): + if not event.config_values.exists(): + event.delete() + for event in ConfigDefaultEvent.objects.all(): + if not event.config_defaults.exists(): + event.delete() + for event in ConstDefaultEvent.objects.all(): + if not event.constant_defaults.exists(): + event.delete() \ No newline at end of file diff --git a/roundabout/calibrations/views.py b/roundabout/calibrations/views.py index f7910a9c2..1a023359b 100644 --- a/roundabout/calibrations/views.py +++ b/roundabout/calibrations/views.py @@ -19,7 +19,7 @@ # If not, see . """ -from django.shortcuts import render +from django.shortcuts import render, redirect from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.views.generic import CreateView, UpdateView, DeleteView, DetailView @@ -37,7 +37,8 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from django.forms.models import inlineformset_factory, BaseInlineFormSet -from .utils import handle_reviewers, check_events +from .utils import handle_reviewers +from .tasks import check_events # Handles creation of Calibration Events, Names,and Coefficients class EventValueSetAdd(LoginRequiredMixin, AjaxFormMixin, CreateView): @@ -231,19 +232,32 @@ class EventValueSetDelete(LoginRequiredMixin, PermissionRequiredMixin, DeleteVie permission_required = 'calibrations.add_calibrationevent' redirect_field_name = 'home' - def delete(self, request, *args, **kwargs): - self.object = self.get_object() - data = { - 'message': "Successfully submitted form data.", - 'parent_id': self.object.inventory.id, - 'parent_type': 'part_type', - 'object_type': self.object.get_object_type(), - } - self.object.delete() + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + data = { + 'message': "Successfully submitted form data.", + 'parent_id': self.object.inventory.id, + 'parent_type': 'part_type', + 'object_type': self.object.get_object_type(), + } + self.object.delete() return JsonResponse(data) def get_success_url(self): - return reverse_lazy('inventory:ajax_inventory_detail', args=(self.object.inventory.id, )) + return reverse('inventory:ajax_inventory_detail', args=(self.object.inventory.id,)) + + +def event_delete_view(request, pk): + evt = CalibrationEvent.objects.get(id=pk) + inv_id = evt.inventory.id + if request.method == "POST": + evt.delete() + return HttpResponseRedirect(reverse('inventory:ajax_inventory_detail', args=(inv_id,))) + + return render(request, 'calibrations/event_delete.html', { + "event_template": evt, + 'request': request + }) @@ -484,7 +498,7 @@ def form_valid(self, form, part_calname_form, part_cal_copy_form): part_calname_form.save() part_cal_copy_form.save() _create_action_history(self.object, Action.UPDATE, self.request.user) - check_events() + job = check_events.delay() response = HttpResponseRedirect(self.get_success_url()) if self.request.is_ajax(): data = { @@ -551,7 +565,7 @@ def delete(self, request, *args, **kwargs): 'object_type': self.object.get_object_type(), } self.object.delete() - check_events() + job = check_events.delay() return JsonResponse(data) def get_success_url(self): diff --git a/roundabout/configs_constants/api/serializers.py b/roundabout/configs_constants/api/serializers.py index 78c3746bb..bcc03f951 100644 --- a/roundabout/configs_constants/api/serializers.py +++ b/roundabout/configs_constants/api/serializers.py @@ -253,10 +253,10 @@ class ConstDefaultSerializer(FlexFieldsModelSerializer): lookup_field = 'pk', queryset = ConfigName.objects ) - config_event = serializers.HyperlinkedRelatedField( - view_name = API_VERSION + ':configs-constants/config-events-detail', + const_event = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':configs-constants/const-default-events-detail', lookup_field = 'pk', - queryset = ConfigEvent.objects + queryset = ConstDefaultEvent.objects ) class Meta: @@ -266,13 +266,13 @@ class Meta: 'url', 'default_value', 'created_at', - 'config_event', + 'const_event', 'config_name', ] expandable_fields = { 'config_name': 'roundabout.configs_constants.api.serializers.ConfigNameSerializer', - 'config_event': 'roundabout.configs_constants.api.serializers.ConfigEventSerializer', + 'const_event': 'roundabout.configs_constants.api.serializers.ConstDefaultEventSerializer', } diff --git a/roundabout/configs_constants/migrations/0019_auto_20201217_1543.py b/roundabout/configs_constants/migrations/0019_auto_20201217_1543.py new file mode 100644 index 000000000..711268d13 --- /dev/null +++ b/roundabout/configs_constants/migrations/0019_auto_20201217_1543.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.3 on 2020-12-17 15:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('configs_constants', '0018_configname_deprecated'), + ] + + operations = [ + migrations.AlterModelOptions( + name='configname', + options={'ordering': ['created_at']}, + ), + ] diff --git a/roundabout/configs_constants/models.py b/roundabout/configs_constants/models.py index 41b685952..d89af757d 100644 --- a/roundabout/configs_constants/models.py +++ b/roundabout/configs_constants/models.py @@ -103,7 +103,7 @@ def get_sorted_approvers(self): # Tracks Configurations across Parts class ConfigName(models.Model): class Meta: - ordering = ['name'] + ordering = ['created_at'] unique_together = ['part','config_type','name'] def __str__(self): return self.name diff --git a/roundabout/configs_constants/views.py b/roundabout/configs_constants/views.py index d0bf71114..ae1095188 100644 --- a/roundabout/configs_constants/views.py +++ b/roundabout/configs_constants/views.py @@ -38,7 +38,8 @@ from django.utils.translation import gettext_lazy as _ from django.forms.models import inlineformset_factory, BaseInlineFormSet from roundabout.inventory.utils import _create_action_history -from roundabout.calibrations.utils import handle_reviewers, check_events +from roundabout.calibrations.utils import handle_reviewers +from roundabout.calibrations.tasks import check_events # Handles creation of Configuration / Constant Events, along with Name/Value formsets class ConfigEventValueAdd(LoginRequiredMixin, AjaxFormMixin, CreateView): @@ -463,7 +464,7 @@ def form_valid(self, form, part_confname_form, part_conf_copy_form): part_confname_form.save() part_conf_copy_form.save() _create_action_history(self.object, Action.UPDATE, self.request.user) - check_events() + job = check_events.delay() response = HttpResponseRedirect(self.get_success_url()) if self.request.is_ajax(): data = { @@ -529,7 +530,7 @@ def delete(self, request, *args, **kwargs): 'object_type': self.object.get_object_type(), } self.object.delete() - check_events() + job = check_events.delay() return JsonResponse(data) def get_success_url(self): diff --git a/roundabout/contrib/sites/migrations/0004_auto_20201117_1702.py b/roundabout/contrib/sites/migrations/0004_auto_20201117_1702.py new file mode 100644 index 000000000..0d2c3f467 --- /dev/null +++ b/roundabout/contrib/sites/migrations/0004_auto_20201117_1702.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.3 on 2020-11-17 17:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0003_set_site_domain_and_name'), + ] + + operations = [ + migrations.AlterModelOptions( + name='site', + options={'ordering': ['domain'], 'verbose_name': 'site', 'verbose_name_plural': 'sites'}, + ), + ] diff --git a/roundabout/core/api/renderers.py b/roundabout/core/api/renderers.py index d633c4534..b56131630 100644 --- a/roundabout/core/api/renderers.py +++ b/roundabout/core/api/renderers.py @@ -5,5 +5,10 @@ class CustomBrowsableAPIRenderer(BrowsableAPIRenderer): def get_rendered_html_form(self, data, view, method, request): return None + def get_name(self, view): + if 'Deployment Oms Custom' in view.get_view_name(): + return 'OMS++ Build/Deployment Custom Endpoint' + return view.get_view_name() + def get_description(self, view, status_code): return '' diff --git a/roundabout/core/api/urls.py b/roundabout/core/api/urls.py index e8b052806..4847ce3c8 100644 --- a/roundabout/core/api/urls.py +++ b/roundabout/core/api/urls.py @@ -25,7 +25,7 @@ from roundabout.assemblies.api.views import AssemblyViewSet, AssemblyRevisionViewSet, AssemblyPartViewSet, \ AssemblyTypeViewSet -from roundabout.builds.api.views import BuildViewSet, DeploymentViewSet +from roundabout.builds.api.views import BuildViewSet, DeploymentViewSet, DeploymentOmsCustomViewSet from roundabout.calibrations.api.views import * from roundabout.configs_constants.api.views import * from roundabout.cruises.api.views import CruiseViewSet, VesselViewSet @@ -42,6 +42,7 @@ router.register(r'builds', BuildViewSet, 'builds' ) router.register(r'deployments', DeploymentViewSet, 'deployments' ) +router.register(r'oms-builds', DeploymentOmsCustomViewSet, 'oms-builds' ) router.register(r'cruises', CruiseViewSet, 'cruises' ) router.register(r'vessels', VesselViewSet, 'vessels' ) diff --git a/roundabout/core/updaters.py b/roundabout/core/updaters.py index 6921fe050..c0905f537 100644 --- a/roundabout/core/updaters.py +++ b/roundabout/core/updaters.py @@ -26,6 +26,19 @@ # Functions to update legacy content to match new model updates #------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ +# v.1.6.1 upgrades +# v1.5 upgrades +def run_v1_6_1_content_updates(): + _update_inventory_deployments() + +def _update_inventory_deployments(): + inventory_deployments = InventoryDeployment.objects.all() + + for inv in inventory_deployments: + print(inv.inventory.assembly_part) + inv.assembly_part = inv.inventory.assembly_part + inv.save() + # v1.5 upgrades def run_v1_5_content_updates(): _update_deployment_actions() diff --git a/roundabout/cruises/forms.py b/roundabout/cruises/forms.py index f56d7d748..54fb3758c 100644 --- a/roundabout/cruises/forms.py +++ b/roundabout/cruises/forms.py @@ -27,7 +27,6 @@ class VesselForm(forms.ModelForm): - class Meta: model = Vessel fields = '__all__' @@ -36,6 +35,24 @@ class Meta: 'max_speed': 'Max speed (m/s)', 'max_draft': 'Max draft (m)', } + number_err_message = 'Enter a value 0.1 - 999.9 in the format NNN.N, with or without leading zeros or a decimal place.' + error_messages = { + 'length': { + 'max_digits': number_err_message, + 'min_value': number_err_message, + 'decimal_places': number_err_message, + }, + 'max_speed': { + 'max_digits': number_err_message, + 'min_value': number_err_message, + 'decimal_places': number_err_message, + }, + 'max_draft': { + 'max_digits': number_err_message, + 'min_value': number_err_message, + 'decimal_places': number_err_message, + }, + } class CruiseForm(forms.ModelForm): diff --git a/roundabout/cruises/migrations/0015_auto_20201117_1702.py b/roundabout/cruises/migrations/0015_auto_20201117_1702.py new file mode 100644 index 000000000..1403d6be4 --- /dev/null +++ b/roundabout/cruises/migrations/0015_auto_20201117_1702.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.3 on 2020-11-17 17:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cruises', '0014_auto_20201002_1827'), + ] + + operations = [ + migrations.AlterField( + model_name='vessel', + name='R2R', + field=models.BooleanField(blank=True, choices=[(True, 'Yes'), (False, 'No')], null=True), + ), + migrations.AlterField( + model_name='vessel', + name='active', + field=models.BooleanField(blank=True, choices=[(True, 'Yes'), (False, 'No')], null=True), + ), + ] diff --git a/roundabout/cruises/models.py b/roundabout/cruises/models.py index fcfd8c2eb..6e060dc6a 100644 --- a/roundabout/cruises/models.py +++ b/roundabout/cruises/models.py @@ -36,8 +36,8 @@ class Vessel(models.Model): validators=[MinValueValidator(Decimal('0.01'))], ) designation = models.CharField(max_length=10, null=False, blank=True) - active = models.BooleanField(choices=BOOLEAN_CHOICES, null=True, default=True) - R2R = models.BooleanField(choices=BOOLEAN_CHOICES, null=True, default=True, blank=True) + active = models.BooleanField(choices=BOOLEAN_CHOICES, null=True, blank=True) + R2R = models.BooleanField(choices=BOOLEAN_CHOICES, null=True, blank=True) notes = models.TextField(null=False, blank=True) class Meta: diff --git a/roundabout/exports/views.py b/roundabout/exports/views.py index 66eef876a..ca5f9a82b 100644 --- a/roundabout/exports/views.py +++ b/roundabout/exports/views.py @@ -172,7 +172,9 @@ class ExportCalibrationEvents(ZipExport): @classmethod def build_zip(cls, zf, objs, subdir=None): + objs = objs.select_related('inventory__part__part_type').exclude(inventory__part__part_type__ccc_toggle=False) objs = objs.prefetch_related('inventory', 'inventory__fieldvalues', 'inventory__fieldvalues__field') + for cal in objs: csv_fname = '{}__{}.csv'.format(cal.inventory.serial_number, cal.calibration_date.strftime('%Y%m%d')) if subdir: csv_fname = join(subdir, csv_fname) @@ -190,8 +192,7 @@ def build_zip(cls, zf, objs, subdir=None): @staticmethod def get_csvrows_aux(cal): - serial_label_qs = cal.inventory.fieldvalues.filter(field__field_name__iexact='Manufacturer Serial Number', - is_current=True) + serial_label_qs = cal.inventory.fieldvalues.filter(field__field_name__iexact='Manufacturer Serial Number', is_current=True) if serial_label_qs.exists(): serial_label = serial_label_qs[0].field_value else: @@ -226,7 +227,9 @@ class ExportConfigEvents(ZipExport): @classmethod def build_zip(cls, zf, objs, subdir=None): + objs = objs.select_related('inventory__part__part_type').exclude(inventory__part__part_type__ccc_toggle=False) objs = objs.prefetch_related('inventory', 'inventory__fieldvalues', 'inventory__fieldvalues__field') + for confconst in objs: csv_fname = '{}__{}.csv'.format(confconst.inventory.serial_number, confconst.configuration_date.strftime('%Y%m%d')) @@ -278,10 +281,9 @@ def get_queryset(cls): calib_qs = CalibrationEvent.objects.all().annotate(date = F('calibration_date')).order_by('-date') config_qs = ConfigEvent.objects.all().annotate(date = F('configuration_date')).order_by('-date') - # keep only configs that are associated with a deployment and approved - # -- commented out for verbose output in build_zip() -- - #config_qs = config_qs.filter(approved=True) - #config_qs = config_qs.exclude(deployment__isnull=True) + # keep only CCCs that are active in the part-template + config_qs = config_qs.select_related('inventory__part__part_type').exclude(inventory__part__part_type__ccc_toggle=False) + calib_qs = calib_qs.select_related('inventory__part__part_type').exclude(inventory__part__part_type__ccc_toggle=False) # keep only configs where at least one ConfigValue has include_with_calibration=True include_with_calibs = ConfigValue.objects.filter(config_event=OuterRef('pk'), config_name__include_with_calibrations=True) @@ -303,7 +305,7 @@ def get_queryset(cls): return qs @classmethod - def build_zip(cls, zf, objs, subdir=None, verbose='CalibrationsWithConfigs_exportlog.txt'): # TODO~ PRODUCTION: change VERBOSE to None + def build_zip(cls, zf, objs, subdir=None, verbose=None): # objs here is a dict-of-dicts, not a queryset. # Each top-level key is an inst_id. # Per inst_id there is (a) a "calibs" key containing a CalibrationEvent Queryset @@ -313,7 +315,7 @@ def build_zip(cls, zf, objs, subdir=None, verbose='CalibrationsWithConfigs_expor # To be valid, the bundled inventory CCC fields must (1) include all the part's inventory CCC fields # (2) be approved==True - # setting up printing to file option. + # if verbose is a string, a log file with that string name is included in the export if isinstance(verbose,str): if subdir: verbose = join(subdir,verbose) out = io.StringIO() @@ -563,7 +565,7 @@ def depl_row(depl_obj, attribs): objs = objs.prefetch_related('build__assembly_revision__assembly') assy_names = objs.values_list('build__assembly_revision__assembly__name',flat=True) assy_names = set(assy_names) - #assy_names.discard(None) # removes None if any + assy_names.discard(None) # removes None if any for assy_name in assy_names: csv_fname = '{}_Deploy.csv'.format(str(assy_name).replace(' ','_')) if subdir: csv_fname = join(subdir,csv_fname) diff --git a/roundabout/inventory/api/serializers.py b/roundabout/inventory/api/serializers.py index 99dbbd67b..69296b32f 100644 --- a/roundabout/inventory/api/serializers.py +++ b/roundabout/inventory/api/serializers.py @@ -256,15 +256,22 @@ class InventorySerializer(FlexFieldsModelSerializer): read_only = True, lookup_field = 'pk', ) + constant_default_events = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':configs-constants/const-default-events-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) time_in_field = serializers.SerializerMethodField('get_time_in_field') class Meta: model = Inventory fields = [ - 'id', 'url', 'serial_number', 'old_serial_number', 'part', 'location', 'revision', \ - 'parent', 'children', 'build', 'assembly_part', 'assigned_destination_root', 'created_at', \ + 'id', 'url', 'serial_number', 'old_serial_number', 'part', 'location', 'revision', + 'parent', 'children', 'build', 'assembly_part', 'assigned_destination_root', 'created_at', 'updated_at', 'test_result', 'test_type', 'flag', 'time_in_field', - 'calibration_events', 'config_events', 'actions', 'fieldvalues', 'inventory_deployments', + 'calibration_events', 'config_events', 'constant_default_events', + 'actions', 'fieldvalues', 'inventory_deployments', ] expandable_fields = { @@ -278,6 +285,7 @@ class Meta: 'children': ('roundabout.inventory.api.serializers.InventorySerializer', {'many': True}), 'calibration_events': ('roundabout.calibrations.api.serializers.CalibrationEventSerializer', {'many': True}), 'config_events': ('roundabout.configs_constants.api.serializers.ConfigEventSerializer', {'many': True}), + 'constant_default_events': ('roundabout.configs_constants.api.serializers.ConstDefaultEventSerializer', {'many': True}), 'actions': ('roundabout.inventory.api.serializers.ActionSerializer', {'many': True}), 'fieldvalues': ('roundabout.userdefinedfields.api.serializers.FieldValueSerializer', {'many': True, "omit": ["field.fieldvalues"]}), 'inventory_deployments': ('roundabout.inventory.api.serializers.InventoryDeploymentSerializer', {'many': True}), diff --git a/roundabout/inventory/migrations/0056_inventorydeployment_assembly_part.py b/roundabout/inventory/migrations/0056_inventorydeployment_assembly_part.py new file mode 100644 index 000000000..1dde7bd66 --- /dev/null +++ b/roundabout/inventory/migrations/0056_inventorydeployment_assembly_part.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.3 on 2020-11-23 17:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('assemblies', '0011_auto_20201117_2030'), + ('inventory', '0055_auto_20201014_1253'), + ] + + operations = [ + migrations.AddField( + model_name='inventorydeployment', + name='assembly_part', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_deployments', to='assemblies.assemblypart'), + ), + ] diff --git a/roundabout/inventory/models.py b/roundabout/inventory/models.py index f291eb011..452217d29 100644 --- a/roundabout/inventory/models.py +++ b/roundabout/inventory/models.py @@ -302,6 +302,8 @@ class InventoryDeployment(DeploymentBase): on_delete=models.CASCADE, null=False) inventory = models.ForeignKey(Inventory, related_name='inventory_deployments', on_delete=models.CASCADE, null=False) + assembly_part = models.ForeignKey('assemblies.AssemblyPart', related_name='inventory_deployments', + on_delete=models.SET_NULL, null=True) objects = InventoryDeploymentQuerySet.as_manager() @@ -316,7 +318,12 @@ def deployment_percentage_vs_build(self): deployment_percentage = 0 if self.deployment_to_field_date: # calculate percentage of total build deployment item was deployed - deployment_percentage = int(self.deployment_time_in_field / self.deployment.deployment_time_in_field * 100) + if not self.deployment.deployment_time_in_field: + deployment_percentage = 0 + elif not self.deployment_time_in_field: + deployment_percentage = 0 + else: + deployment_percentage = int(self.deployment_time_in_field / self.deployment.deployment_time_in_field * 100) if deployment_percentage >= 99: deployment_percentage = 100 return deployment_percentage diff --git a/roundabout/inventory/urls.py b/roundabout/inventory/urls.py index edd41eba0..40329626b 100644 --- a/roundabout/inventory/urls.py +++ b/roundabout/inventory/urls.py @@ -19,7 +19,6 @@ # If not, see . """ -from django.urls import path from django.urls import path from . import views diff --git a/roundabout/inventory/utils.py b/roundabout/inventory/utils.py index f228fa102..2de20a8a1 100644 --- a/roundabout/inventory/utils.py +++ b/roundabout/inventory/utils.py @@ -214,6 +214,7 @@ def _create_action_history(obj, action_type, user, referring_obj=None, referring inventory_deployment = InventoryDeployment.objects.create( deployment=deployment, inventory=obj, + assembly_part=obj.assembly_part, deployment_start_date = action_date ) action_record.inventory_deployment = inventory_deployment diff --git a/roundabout/ooi_ci_tools/forms.py b/roundabout/ooi_ci_tools/forms.py index dfb26a762..6761e43c3 100644 --- a/roundabout/ooi_ci_tools/forms.py +++ b/roundabout/ooi_ci_tools/forms.py @@ -20,20 +20,31 @@ """ import csv +import io +import json +import re +import requests +from dateutil import parser import datetime import io from types import SimpleNamespace +from decimal import Decimal + from django import forms +from django.db import transaction from django.core.cache import cache from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -from roundabout.calibrations.forms import validate_coeff_vals -from roundabout.calibrations.models import CoefficientName -from roundabout.inventory.models import Inventory +from roundabout.inventory.models import Inventory, Action, Deployment +from roundabout.cruises.models import Cruise, Vessel +from roundabout.inventory.utils import _create_action_history +from roundabout.calibrations.models import CoefficientName, CoefficientValueSet, CalibrationEvent, CoefficientNameEvent +from roundabout.calibrations.forms import validate_coeff_vals, parse_valid_coeff_vals +from roundabout.configs_constants.models import ConfigName from roundabout.users.models import User - +from roundabout.userdefinedfields.models import Field, FieldValue class ImportDeploymentsForm(forms.Form): deployments_csv = forms.FileField( @@ -41,16 +52,223 @@ class ImportDeploymentsForm(forms.Form): attrs={ 'multiple': True } - ) + ), + required=False ) + def clean_deployments_csv(self): + deployments_csv = self.files.getlist('deployments_csv') + counter = 0 + for csv_file in deployments_csv: + counter += 1 + filename = csv_file.name[:-4] + cache.set('validation_progress',{ + 'progress': counter, + 'total': len(deployments_csv), + 'file': filename + }) + try: + csv_file.seek(0) + reader = csv.DictReader(io.StringIO(csv_file.read().decode('utf-8'))) + headers = reader.fieldnames + except: + raise ValidationError( + _('File: %(filename)s: Unable to decode file headers'), + params={'filename': filename}, + ) + deployments = [] + for row in reader: + try: + mooring_id = row['mooring.uid'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Mooring UID'), + params={'filename': filename}, + ) + if mooring_id not in deployments: + # get Assembly number from RefDes as that seems to be most consistent across CSVs + try: + ref_des = row['Reference Designator'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Reference Designator'), + params={'filename': filename}, + ) + try: + assembly = ref_des.split('-')[0] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Assembly from Reference Designator'), + params={'filename': filename}, + ) + # build data dict + mooring_uid_dict = {'mooring.uid': row['mooring.uid'], 'assembly': assembly, 'rows': []} + deployments.append(mooring_uid_dict) + deployment = next((deployment for deployment in deployments if deployment['mooring.uid']== row['mooring.uid']), False) + for key, value in row.items(): + deployment['rows'].append({key: value}) + return deployments_csv + class ImportVesselsForm(forms.Form): - vessels_csv = forms.FileField() + vessels_csv = forms.FileField( + widget=forms.ClearableFileInput( + attrs={ + 'multiple': True + } + ), + required=False + ) + + def clean_vessels_csv(self): + vessels_csv = self.files.getlist('vessels_csv') + counter = 0 + for csv_file in vessels_csv: + counter += 1 + filename = csv_file.name[:-4] + cache.set('validation_progress',{ + 'progress': counter, + 'total': len(vessels_csv), + 'file': filename + }) + try: + csv_file.seek(0) + reader = csv.DictReader(io.StringIO(csv_file.read().decode('utf-8'))) + headers = reader.fieldnames + except: + raise ValidationError( + _('File: %(filename)s: Unable to decode file headers'), + params={'filename': filename}, + ) + for row in reader: + try: + vessel_name = row['Vessel Name'] + vessel_obj = Vessel.objects.get( + vessel_name = vessel_name, + ) + except Vessel.DoesNotExist: + vessel_obj = '' + except Vessel.MultipleObjectsReturned: + raise ValidationError( + _('File: %(filename)s, %(v_name)s: More than one Vessel associated with CSV Vessel Name'), + params={'filename': filename, 'v_name': vessel_name}, + ) + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Vessel Name'), + params={'filename': filename}, + ) + MMSI_number = None + IMO_number = None + length = None + max_speed = None + max_draft = None + try: + active = row['Active'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Active'), + params={'filename': filename}, + ) + try: + R2R = row['R2R'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse R2R'), + params={'filename': filename}, + ) + try: + MMSI_number = row['MMSI#'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse MMSI'), + params={'filename': filename}, + ) + try: + IMO_number = row['IMO#'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse IMO'), + params={'filename': filename}, + ) + try: + length = row['Length (m)'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Lenth (m)'), + params={'filename': filename}, + ) + try: + max_speed = row['Max Draft (m)'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Max Speed (m/s)'), + params={'filename': filename}, + ) + try: + max_draft = row['Max Draft (m)'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Max Draft (m)'), + params={'filename': filename}, + ) + return vessels_csv class ImportCruisesForm(forms.Form): - cruises_csv = forms.FileField() + cruises_csv = forms.FileField( + widget=forms.ClearableFileInput( + attrs={ + 'multiple': True + } + ), + required=False + ) + + def clean_cruises_csv(self): + cruises_csv = self.files.getlist('cruises_csv') + counter = 0 + for csv_file in cruises_csv: + filename = csv_file.name[:-4] + counter += 1 + cache.set('validation_progress',{ + 'progress': counter, + 'total': len(cruises_csv), + 'file': filename + }) + try: + csv_file.seek(0) + reader = csv.DictReader(io.StringIO(csv_file.read().decode('utf-8'))) + headers = reader.fieldnames + except: + raise ValidationError( + _('File: %(filename)s: Unable to decode file headers'), + params={'filename': filename}, + ) + for row in reader: + try: + cuid = row['CUID'] + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse CUID'), + params={'filename': filename}, + ) + try: + cruise_start_date = parser.parse(row['cruiseStartDateTime']).date() + cruise_stop_date = parser.parse(row['cruiseStopDateTime']).date() + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Cruise Start/Stop Dates'), + params={'filename': filename}, + ) + try: + vessel_name_csv = row['ShipName'].strip() + except: + raise ValidationError( + _('File: %(filename)s: Unable to parse Vessel Name'), + params={'filename': filename}, + ) + return cruises_csv @@ -83,21 +301,65 @@ def validate_cal_files(csv_files,ext_files): _('File: %(filename)s, %(value)s: Unable to parse Calibration Date from Filename'), params={'value': cal_date_string, 'filename': cal_csv.name}, ) + try: + deployment = Deployment.objects.filter( + deployment_to_field_date__year=cal_date_date.year, + deployment_to_field_date__month=cal_date_date.month, + deployment_to_field_date__day=cal_date_date.day, + ) + assert len(deployment) < 2 + except: + raise ValidationError( + _('File: %(filename)s, %(value)s: More than one existing Deployment associated with File Deployment Date'), + params={'value': cal_date_string, 'filename': cal_csv.name}, + ) + try: + custom_field = Field.objects.get(field_name='Manufacturer Serial Number') + except: + raise ValidationError( + _('Global Custom Field "Manufacturer Serial Number" must be created prior to import'), + ) + try: + inv_manufacturer_serial = FieldValue.objects.get(inventory=inventory_item,field=custom_field,is_current=True) + except FieldValue.DoesNotExist: + inv_keys = {'field_value': ''} + inv_manufacturer_serial = SimpleNamespace(**inv_keys) for idx, row in enumerate(reader): row_data = row.items() for key, value in row_data: + if key == 'serial': + try: + csv_manufacturer_serial = value.strip() + except: + raise ValidationError( + _('File: %(filename)s, Row %(row)s: Cannot parse Manufacturer Serial Number'), + params={'row': idx, 'filename': cal_csv.name}, + ) + if len(inv_manufacturer_serial.field_value) > 0 and len(csv_manufacturer_serial) > 0: + try: + assert csv_manufacturer_serial == inv_manufacturer_serial.field_value + except: + raise ValidationError( + _('File: %(filename)s, Row %(row)s: Manufacturer Serial Number differs between Inventory Item (%(inv_msn)s) and file (%(csv_msn)s)'), + params={'row': idx, 'filename': cal_csv.name, 'inv_msn': inv_manufacturer_serial.field_value, 'csv_msn':csv_manufacturer_serial}, + ) if key == 'name': calibration_name = value.strip() try: - cal_name_item = CoefficientName.objects.get( - calibration_name = calibration_name, - coeff_name_event = inventory_item.part.coefficient_name_events.first() - ) + assert len(calibration_name) > 0 except: raise ValidationError( - _('File: %(filename)s, Calibration Name: %(value)s, Row %(row)s: Unable to find Calibration item with this Name'), + _('File: %(filename)s, Calibration Name: %(value)s, Row %(row)s: Calibration Name is blank'), params={'value': calibration_name, 'row': idx, 'filename': cal_csv.name}, ) + try: + cal_name_item = CoefficientName.objects.get( + calibration_name = calibration_name, + coeff_name_event = inventory_item.part.coefficient_name_events.first() + ) + except CoefficientName.DoesNotExist: + calname_keys = {'value_set_type': 'sl'} + cal_name_item = SimpleNamespace(**calname_keys) elif key == 'value': valset_keys = {'cal_dec_places': inventory_item.part.cal_dec_places} mock_valset_instance = SimpleNamespace(**valset_keys) @@ -109,8 +371,10 @@ def validate_cal_files(csv_files,ext_files): params={'value': calibration_name,'row': idx, 'filename': cal_csv.name}, ) if '[' in raw_valset: + cal_name_item.value_set_type = '1d' raw_valset = raw_valset[1:-1] if 'SheetRef' in raw_valset: + cal_name_item.value_set_type = '2d' ext_finder_filename = "__".join((cal_csv_filename,calibration_name)) try: ref_file = [file for file in ext_files if ext_finder_filename in file.name][0] @@ -136,14 +400,15 @@ def validate_cal_files(csv_files,ext_files): params={'value': calibration_name, 'row': idx, 'filename': cal_csv.name}, ) - +# class ImportCalibrationForm(forms.Form): - cal_csv = forms.FileField( + calibration_csv = forms.FileField( widget=forms.ClearableFileInput( attrs={ 'multiple': True } - ) + ), + required = False ) user_draft = forms.ModelMultipleChoiceField( queryset = User.objects.all().exclude(groups__name__in=['inventory only']).order_by('username'), @@ -151,8 +416,8 @@ class ImportCalibrationForm(forms.Form): label = 'Select Reviewers' ) - def clean_cal_csv(self): - cal_files = self.files.getlist('cal_csv') + def clean_calibration_csv(self): + cal_files = self.files.getlist('calibration_csv') csv_files = [] ext_files = [] for file in cal_files: diff --git a/roundabout/ooi_ci_tools/tasks.py b/roundabout/ooi_ci_tools/tasks.py index ef01bce6e..bcbfacfcf 100644 --- a/roundabout/ooi_ci_tools/tasks.py +++ b/roundabout/ooi_ci_tools/tasks.py @@ -19,18 +19,25 @@ # If not, see . """ + import csv import datetime import io +import re +from decimal import Decimal from types import SimpleNamespace from celery import shared_task +from dateutil import parser from django.core.cache import cache from roundabout.calibrations.forms import parse_valid_coeff_vals from roundabout.calibrations.models import CoefficientName, CoefficientValueSet, CalibrationEvent -from roundabout.inventory.models import Inventory, Action +from roundabout.configs_constants.models import ConfigName, ConfigValue, ConfigEvent +from roundabout.cruises.models import Cruise, Vessel +from roundabout.inventory.models import Inventory, Action, Deployment from roundabout.inventory.utils import _create_action_history +from roundabout.userdefinedfields.models import Field, FieldValue @shared_task(bind = True) @@ -43,29 +50,71 @@ def parse_cal_files(self): counter = 0 for cal_csv in csv_files: counter+=1 - self.update_state(state='PROGRESS', meta = {'progress': counter, 'total': len(cal_csv)}) + self.update_state(state='PROGRESS', meta = {'progress': counter, 'total': len(csv_files)}) cal_csv_filename = cal_csv.name[:-4] cal_csv.seek(0) reader = csv.DictReader(io.StringIO(cal_csv.read().decode('utf-8'))) headers = reader.fieldnames coeff_val_sets = [] + config_val_sets = [] + const_val_sets = [] inv_serial = cal_csv.name.split('__')[0] cal_date_string = cal_csv.name.split('__')[1][:8] inventory_item = Inventory.objects.get(serial_number=inv_serial) cal_date_date = datetime.datetime.strptime(cal_date_string, "%Y%m%d").date() - csv_event = CalibrationEvent.objects.create( + custom_field = Field.objects.get(field_name='Manufacturer Serial Number') + try: + inv_manufacturer_serial = FieldValue.objects.get(inventory=inventory_item,field=custom_field,is_current=True) + except FieldValue.DoesNotExist: + inv_manufacturer_serial = FieldValue.objects.create(inventory=inventory_item,field=custom_field,is_current=True) + try: + deployment = Deployment.objects.get( + deployment_to_field_date__year=cal_date_date.year, + deployment_to_field_date__month=cal_date_date.month, + deployment_to_field_date__day=cal_date_date.day, + ) + except Deployment.DoesNotExist: + deployment = None + conf_event, created = ConfigEvent.objects.get_or_create( + configuration_date = cal_date_date, + inventory = inventory_item, + config_type = 'conf', + deployment = deployment + ) + cnst_event, created = ConfigEvent.objects.get_or_create( + configuration_date = cal_date_date, + inventory = inventory_item, + config_type = 'cnst', + deployment = deployment + ) + csv_event, created = CalibrationEvent.objects.get_or_create( calibration_date = cal_date_date, inventory = inventory_item ) for idx, row in enumerate(reader): row_data = row.items() for key, value in row_data: + if key == 'serial': + csv_manufacturer_serial = value.strip() + if len(inv_manufacturer_serial.field_value) == 0 and len(csv_manufacturer_serial) > 0: + inv_manufacturer_serial.field_value = csv_manufacturer_serial + inv_manufacturer_serial.save() if key == 'name': calibration_name = value.strip() - cal_name_item = CoefficientName.objects.get( - calibration_name = calibration_name, - coeff_name_event = inventory_item.part.coefficient_name_events.first() - ) + try: + cal_name_item = CoefficientName.objects.get( + calibration_name = calibration_name, + coeff_name_event = inventory_item.part.coefficient_name_events.first() + ) + except CoefficientName.DoesNotExist: + cal_name_item = None + try: + config_name_item = ConfigName.objects.get( + name = calibration_name, + config_name_event = inventory_item.part.config_name_events.first() + ) + except ConfigName.DoesNotExist: + config_name_item = None elif key == 'value': valset_keys = {'cal_dec_places': inventory_item.part.cal_dec_places} mock_valset_instance = SimpleNamespace(**valset_keys) @@ -81,18 +130,226 @@ def parse_cal_files(self): raw_valset = contents elif key == 'notes': notes = value.strip() - coeff_val_set = CoefficientValueSet( - coefficient_name = cal_name_item, - value_set = raw_valset, - notes = notes - ) - coeff_val_sets.append(coeff_val_set) + if cal_name_item: + coeff_val_set = { + 'coefficient_name': cal_name_item, + 'value_set': raw_valset, + 'notes': notes + } + coeff_val_sets.append(coeff_val_set) + if config_name_item: + if config_name_item.config_type == 'conf': + config_val_set = { + 'config_name': config_name_item, + 'config_value': raw_valset, + 'notes': notes, + 'config_event': conf_event + } + config_val_sets.append(config_val_set) + if config_name_item.config_type == 'cnst': + const_val_set = { + 'config_name': config_name_item, + 'config_value': raw_valset, + 'notes': notes, + 'config_event': cnst_event + } + const_val_sets.append(const_val_set) if user_draft.exists(): - draft_users = user_draft - for user in draft_users: - csv_event.user_draft.add(user) - for valset in coeff_val_sets: - valset.calibration_event = csv_event - valset.save() - parse_valid_coeff_vals(valset) - _create_action_history(csv_event, Action.CALCSVIMPORT, user) + for draft_user in user_draft: + csv_event.user_draft.add(draft_user) + if len(coeff_val_sets) >= 1: + for valset in coeff_val_sets: + valset['calibration_event'] = csv_event + coeff_val_set, created = CoefficientValueSet.objects.update_or_create( + coefficient_name = valset['coefficient_name'], + calibration_event = valset['calibration_event'], + defaults = { + 'value_set': valset['value_set'], + 'notes': valset['notes'], + } + ) + parse_valid_coeff_vals(coeff_val_set) + _create_action_history(csv_event, Action.CALCSVIMPORT, user) + else: + csv_event.delete() + if len(config_val_sets) >= 1: + for valset in config_val_sets: + valset['config_event'] = conf_event + coeff_val_set, created = ConfigValue.objects.update_or_create( + config_name = valset['config_name'], + config_event = valset['config_event'], + defaults = { + 'config_value': valset['config_value'], + 'notes': valset['notes'], + } + ) + _create_action_history(conf_event, Action.CALCSVIMPORT, user) + else: + conf_event.delete() + if len(const_val_sets) >= 1: + for valset in const_val_sets: + valset['config_event'] = cnst_event + const_val_set, created = ConfigValue.objects.update_or_create( + config_name = valset['config_name'], + config_event = valset['config_event'], + defaults = { + 'config_value': valset['config_value'], + 'notes': valset['notes'], + } + ) + _create_action_history(cnst_event, Action.CALCSVIMPORT, user) + else: + cnst_event.delete() + cache.delete('user') + cache.delete('user_draft') + cache.delete('ext_files') + cache.delete('csv_files') + + + +@shared_task(bind=True) +def parse_cruise_files(self): + cruises_files = cache.get('cruises_files') + for csv_file in cruises_files: + # Set up the Django file object for CSV DictReader + csv_file.seek(0) + reader = csv.DictReader(io.StringIO(csv_file.read().decode('utf-8'))) + # Get the column headers to save with parent TempImport object + headers = reader.fieldnames + # Set up data lists for returning results + cruises_created = [] + cruises_updated = [] + + for row in reader: + cuid = row['CUID'] + cruise_start_date = parser.parse(row['cruiseStartDateTime']) + cruise_stop_date = parser.parse(row['cruiseStopDateTime']) + vessel_obj = None + # parse out the vessel name to match its formatting from Vessel CSV + vessel_name_csv = row['ShipName'].strip() + if vessel_name_csv == 'N/A': + vessel_name_csv = None + + if vessel_name_csv: + # update or create Cruise object based on CUID field + vessel_obj, vessel_created = Vessel.objects.get_or_create( + vessel_name = vessel_name_csv, + ) + + # update or create Cruise object based on CUID field + cruise_obj, created = Cruise.objects.update_or_create( + CUID = cuid, + defaults = { + 'notes': row['notes'], + 'cruise_start_date': cruise_start_date, + 'cruise_stop_date': cruise_stop_date, + 'vessel': vessel_obj, + }, + ) + + if created: + cruises_created.append(cruise_obj) + else: + cruises_updated.append(cruise_obj) + cache.delete('cruises_files') + + +@shared_task(bind=True) +def parse_vessel_files(self): + vessels_files = cache.get('vessels_files') + for csv_file in vessels_files: + # Set up the Django file object for CSV DictReader + csv_file.seek(0) + reader = csv.DictReader(io.StringIO(csv_file.read().decode('utf-8'))) + # Get the column headers to save with parent TempImport object + headers = reader.fieldnames + # Set up data lists for returning results + vessels_created = [] + vessels_updated = [] + for row in reader: + vessel_name = row['Vessel Name'].strip() + MMSI_number = None + IMO_number = None + length = None + max_speed = None + max_draft = None + active = re.sub(r'[()]', '', row['Active']) + R2R = row['R2R'] + + if row['MMSI#']: + MMSI_number = int(re.sub('[^0-9]','', row['MMSI#'])) + + if row['IMO#']: + IMO_number = int(re.sub('[^0-9]','', row['IMO#'])) + + if row['Length (m)']: + length = Decimal(row['Length (m)']) + + if row['Max Speed (m/s)']: + max_speed = Decimal(row['Max Speed (m/s)']) + + if row['Max Draft (m)']: + max_draft = Decimal(row['Max Draft (m)']) + + if active: + if active == 'Y': + active = True + else: + active = False + if R2R: + if R2R == 'Y': + R2R = True + else: + R2R = False + + # update or create Vessel object based on vessel_name field + vessel_obj, created = Vessel.objects.update_or_create( + vessel_name = vessel_name, + defaults = { + 'prefix': row['Prefix'], + 'vessel_designation': row['Vessel Designation'], + 'ICES_code': row['ICES Code'], + 'operator': row['Operator'], + 'call_sign': row['Call Sign'], + 'MMSI_number': MMSI_number, + 'IMO_number': IMO_number, + 'length': length, + 'max_speed': max_speed, + 'max_draft': max_draft, + 'designation': row['Designation'], + 'active': active, + 'R2R': R2R, + }, + ) + + if created: + vessels_created.append(vessel_obj) + else: + vessels_updated.append(vessel_obj) + cache.delete('vessels_files') + +@shared_task(bind=True) +def parse_deployment_files(self): + csv_files = cache.get('dep_files') + for csv_file in csv_files: + csv_file.seek(0) + reader = csv.DictReader(io.StringIO(csv_file.read().decode('utf-8'))) + headers = reader.fieldnames + deployments = [] + for row in reader: + if row['mooring.uid'] not in deployments: + # get Assembly number from RefDes as that seems to be most consistent across CSVs + ref_des = row['Reference Designator'] + assembly = ref_des.split('-')[0] + # build data dict + mooring_uid_dict = {'mooring.uid': row['mooring.uid'], 'assembly': assembly, 'rows': []} + deployments.append(mooring_uid_dict) + + deployment = next((deployment for deployment in deployments if deployment['mooring.uid']== row['mooring.uid']), False) + for key, value in row.items(): + deployment['rows'].append({key: value}) + + print(deployments[0]) + for row in deployments[0]['rows']: + print(row) + cache.delete('dep_files') diff --git a/roundabout/ooi_ci_tools/urls.py b/roundabout/ooi_ci_tools/urls.py index 6438f3aef..e98be5ce5 100644 --- a/roundabout/ooi_ci_tools/urls.py +++ b/roundabout/ooi_ci_tools/urls.py @@ -35,6 +35,6 @@ #path('import/calibrations/upload/', view=views.ImportCalibrationsUploadView.as_view(), name='import_calibrations_upload'), path('import/upload/success/', view=views.ImportUploadSuccessView.as_view(), name='import_upload_success'), #Import Calibrations - path('import/calibrations/upload/', view=views.import_calibrations, name='import_calibrations_upload'), + path('import/csv/upload/', view=views.import_csv, name='import_csv'), path('import/calibrations/status/', view=views.upload_status, name='upload_status'), ] diff --git a/roundabout/ooi_ci_tools/views.py b/roundabout/ooi_ci_tools/views.py index 00f904a5e..1a428d6d0 100644 --- a/roundabout/ooi_ci_tools/views.py +++ b/roundabout/ooi_ci_tools/views.py @@ -33,8 +33,10 @@ from django.views.generic import TemplateView, FormView from roundabout.cruises.models import Cruise, Vessel +from roundabout.assemblies.models import Assembly from .forms import ImportDeploymentsForm, ImportVesselsForm, ImportCruisesForm, ImportCalibrationForm -from .tasks import parse_cal_files +from .models import * +from .tasks import parse_cal_files, parse_cruise_files, parse_vessel_files, parse_deployment_files # Github CSV file importer for Vessels @@ -73,7 +75,7 @@ def form_valid(self, form): length = Decimal(row['Length (m)']) if row['Max Speed (m/s)']: - max_speed = Decimal(row['Max Draft (m)']) + max_speed = Decimal(row['Max Speed (m/s)']) if row['Max Draft (m)']: max_draft = Decimal(row['Max Draft (m)']) @@ -139,8 +141,8 @@ def form_valid(self, form): for row in reader: cuid = row['CUID'] - cruise_start_date = parser.parse(row['cruiseStartDateTime']).date() - cruise_stop_date = parser.parse(row['cruiseStopDateTime']).date() + cruise_start_date = parser.parse(row['cruiseStartDateTime']) + cruise_stop_date = parser.parse(row['cruiseStopDateTime']) vessel_obj = None # parse out the vessel name to match its formatting from Vessel CSV vessel_name_csv = row['ShipName'].strip() @@ -195,7 +197,7 @@ def form_valid(self, form): headers = reader.fieldnames deployments = [] for row in reader: - if row['mooring.uid'] not in deployments: + if not any(dict['mooring.uid'] == row['mooring.uid'] for dict in deployments): # get Assembly number from RefDes as that seems to be most consistent across CSVs ref_des = row['Reference Designator'] assembly = ref_des.split('-')[0] @@ -203,13 +205,26 @@ def form_valid(self, form): mooring_uid_dict = {'mooring.uid': row['mooring.uid'], 'assembly': assembly, 'rows': []} deployments.append(mooring_uid_dict) - deployment = next((deployment for deployment in deployments if deployment['mooring.uid']== row['mooring.uid']), False) - for key, value in row.items(): - deployment['rows'].append({key: value}) + deployment = next((deployment for deployment in deployments if deployment['mooring.uid'] == row['mooring.uid']), False) + deployment['rows'].append(row) + + print(deployments) + for deployment in deployments: + # get the Assembly template for this Build + assembly_qs = Assembly.objects.filter(assembly_number=deployment['assembly']) + if assembly_qs: + if assembly_qs.count() == 1: + assembly = assembly_qs[0] + print(assembly) + print(deployment['mooring.uid']) + else: + raise ValueError("Too many results") + else: + raise ValueError("No results") - print(deployments[0]) - for row in deployments[0]['rows']: - print(row) + for row in deployments[0]['rows']: + pass + #print(row['sensor.uid']) return super(ImportDeploymentsUploadView, self).form_valid(form) @@ -221,33 +236,6 @@ class ImportUploadSuccessView(TemplateView): template_name = "ooi_ci_tools/import_upload_success.html" -# CSV File Uploader for GitHub Calibration Coefficients -class ImportCalibrationsUploadView(LoginRequiredMixin, FormView): - form_class = ImportCalibrationForm - template_name = 'ooi_ci_tools/import_calibrations_upload_form.html' - - - def form_valid(self, form): - cal_files = self.request.FILES.getlist('cal_csv') - csv_files = [] - ext_files = [] - for file in cal_files: - ext = file.name[-3:] - if ext == 'ext': - ext_files.append(file) - if ext == 'csv': - csv_files.append(file) - cache.set('user', self.request.user, timeout=None) - cache.set('user_draft', form.cleaned_data['user_draft'], timeout=None) - cache.set('ext_files', ext_files, timeout=None) - cache.set('csv_files', csv_files, timeout=None) - job = parse_cal_files.delay() - cache.set('import_task', job.task_id, timeout=None) - return super(ImportCalibrationsUploadView, self).form_valid(form) - - def get_success_url(self): - return reverse('ooi_ci_tools:import_upload_success', ) - def upload_status(request): # import_task = cache.get('import_task') @@ -261,31 +249,73 @@ def upload_status(request): 'progress': result, }) -def import_calibrations(request): +# Deployment CSV Importer +def import_deployments(csv_files): + cache.set('dep_files',csv_files, timeout=None) + job = parse_deployment_files.delay() + + +# Cruise CSV Importer +def import_cruises(cruises_files): + cache.set('cruises_files', cruises_files, timeout=None) + job = parse_cruise_files.delay() + +# Vessel CSV Importer +def import_vessels(vessels_files): + cache.set('vessels_files', vessels_files, timeout=None) + job = parse_vessel_files.delay() + +# Calibration CSV Importer +def import_calibrations(cal_files, user, user_draft): + csv_files = [] + ext_files = [] + for file in cal_files: + ext = file.name[-3:] + if ext == 'ext': + ext_files.append(file) + if ext == 'csv': + csv_files.append(file) + cache.set('user', user, timeout=None) + cache.set('user_draft', user_draft, timeout=None) + cache.set('ext_files', ext_files, timeout=None) + cache.set('csv_files', csv_files, timeout=None) + job = parse_cal_files.delay() + cache.set('import_task', job.task_id, timeout=None) + +# CSV Importer View +# Activates parsing tasks based on selected files +def import_csv(request): confirm = "" if request.method == "POST": - form = ImportCalibrationForm(request.POST, request.FILES) - if form.is_valid(): - cal_files = request.FILES.getlist('cal_csv') - csv_files = [] - ext_files = [] - for file in cal_files: - ext = file.name[-3:] - if ext == 'ext': - ext_files.append(file) - if ext == 'csv': - csv_files.append(file) - cache.set('user', request.user, timeout=None) - cache.set('user_draft', form.cleaned_data['user_draft'], timeout=None) - cache.set('ext_files', ext_files, timeout=None) - cache.set('csv_files', csv_files, timeout=None) - job = parse_cal_files.delay() - cache.set('import_task', job.task_id, timeout=None) - return redirect(reverse("ooi_ci_tools:import_calibrations_upload") + "?confirm=True") + cal_form = ImportCalibrationForm(request.POST, request.FILES) + dep_form = ImportDeploymentsForm(request.POST, request.FILES) + cruises_form = ImportCruisesForm(request.POST, request.FILES) + vessels_form = ImportVesselsForm(request.POST, request.FILES) + cal_files = request.FILES.getlist('calibration_csv') + dep_files = request.FILES.getlist('deployments_csv') + cruises_file = request.FILES.getlist('cruises_csv') + vessels_file = request.FILES.getlist('vessels_csv') + if cal_form.is_valid() and len(cal_files) >= 1: + import_calibrations(cal_files, request.user, cal_form.cleaned_data['user_draft']) + confirm = "True" + if dep_form.is_valid() and len(dep_files) >= 1: + import_deployments(dep_files) + confirm = "True" + if cruises_form.is_valid() and len(cruises_file) >= 1: + import_cruises(cruises_file) + confirm = "True" + if vessels_form.is_valid() and len(vessels_file) >= 1: + import_vessels(vessels_file) + confirm = "True" else: - form = ImportCalibrationForm() - confirm = request.GET.get("confirm") - return render(request, 'ooi_ci_tools/import_calibrations_upload_form.html', { - "form": form, + cal_form = ImportCalibrationForm() + dep_form = ImportDeploymentsForm() + cruises_form = ImportCruisesForm() + vessels_form = ImportVesselsForm() + return render(request, 'ooi_ci_tools/import_tool.html', { + "form": cal_form, + 'dep_form': dep_form, + 'cruises_form': cruises_form, + 'vessels_form': vessels_form, 'confirm': confirm }) diff --git a/roundabout/parts/forms.py b/roundabout/parts/forms.py index b278757df..2081e57bc 100644 --- a/roundabout/parts/forms.py +++ b/roundabout/parts/forms.py @@ -1,7 +1,7 @@ """ # Copyright (C) 2019-2020 Woods Hole Oceanographic Institution # -# This file is part of the Roundabout Database project ("RDB" or +# This file is part of the Roundabout Database project ("RDB" or # "ooicgsn-roundabout"). # # ooicgsn-roundabout is free software: you can redistribute it and/or modify @@ -23,11 +23,13 @@ import re from django import forms +from django.shortcuts import get_object_or_404 from django.core.exceptions import ValidationError from django.forms.models import inlineformset_factory from django.template.defaultfilters import slugify from django_summernote.widgets import SummernoteWidget from bootstrap_datepicker_plus import DatePickerInput, DateTimePickerInput +from mptt.forms import TreeNodeChoiceField from .models import Part, PartType, Documentation, Revision from roundabout.locations.models import Location @@ -183,3 +185,25 @@ class Meta: 'name': 'Part Type Name', 'ccc_toggle': 'Enable Configs, Constants, and Calibration Coefficients' } + + +""" +Custom Deletion form for PartType +User needs to be able to choose a new PartType for existing Part Templates or they +disappear from tree nav +""" +class PartTypeDeleteForm(forms.Form): + new_part_type = TreeNodeChoiceField(label='Select new Part Type', queryset=PartType.objects.all()) + + def __init__(self, *args, **kwargs): + part_type_pk= kwargs.pop('pk') + part_type_to_delete = get_object_or_404(PartType, id=part_type_pk) + + super(PartTypeDeleteForm, self).__init__(*args, **kwargs) + # Check if this PartType has IPart Templates, remove new field if false + if not part_type_to_delete.parts.exists(): + self.fields['new_part_type'].required = False + self.fields['new_part_type'].widget = forms.HiddenInput() + + # remove this object from the possible replacements + self.fields['new_part_type'].queryset = PartType.objects.exclude(id=part_type_to_delete.id) diff --git a/roundabout/parts/migrations/0011_merge_20201015_1401.py b/roundabout/parts/migrations/0011_merge_20201015_1401.py new file mode 100644 index 000000000..2e56e7e3b --- /dev/null +++ b/roundabout/parts/migrations/0011_merge_20201015_1401.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.10 on 2020-10-15 14:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0010_merge_20201015_1353'), + ('parts', '0010_auto_20201014_1253'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0012_merge_20201016_1512.py b/roundabout/parts/migrations/0012_merge_20201016_1512.py new file mode 100644 index 000000000..41b8496eb --- /dev/null +++ b/roundabout/parts/migrations/0012_merge_20201016_1512.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.10 on 2020-10-16 15:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0011_merge_20201016_1334'), + ('parts', '0011_merge_20201015_1401'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0013_merge_20201106_1900.py b/roundabout/parts/migrations/0013_merge_20201106_1900.py new file mode 100644 index 000000000..2ab155f55 --- /dev/null +++ b/roundabout/parts/migrations/0013_merge_20201106_1900.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.10 on 2020-11-06 19:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0012_merge_20201016_1512'), + ('parts', '0012_merge_20201106_1538'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0013_merge_20201110_1515.py b/roundabout/parts/migrations/0013_merge_20201110_1515.py new file mode 100644 index 000000000..505b2ab7b --- /dev/null +++ b/roundabout/parts/migrations/0013_merge_20201110_1515.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.10 on 2020-11-10 15:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0012_merge_20201106_1538'), + ('parts', '0012_merge_20201016_1512'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0014_merge_20201116_1621.py b/roundabout/parts/migrations/0014_merge_20201116_1621.py new file mode 100644 index 000000000..cf10b1b29 --- /dev/null +++ b/roundabout/parts/migrations/0014_merge_20201116_1621.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.10 on 2020-11-16 16:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0013_merge_20201110_1515'), + ('parts', '0013_merge_20201106_1900'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0015_auto_20201116_1949.py b/roundabout/parts/migrations/0015_auto_20201116_1949.py new file mode 100644 index 000000000..c5f6c5cfd --- /dev/null +++ b/roundabout/parts/migrations/0015_auto_20201116_1949.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.10 on 2020-11-16 19:49 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0014_merge_20201116_1621'), + ] + + operations = [ + migrations.AlterField( + model_name='part', + name='cal_dec_places', + field=models.IntegerField(blank=True, default=15, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(32)]), + ), + ] diff --git a/roundabout/parts/models.py b/roundabout/parts/models.py index d6996cb8b..4bb55f6e5 100644 --- a/roundabout/parts/models.py +++ b/roundabout/parts/models.py @@ -57,7 +57,7 @@ class Part(models.Model): note = models.TextField(blank=True) custom_fields = JSONField(blank=True, null=True) user_defined_fields = models.ManyToManyField(Field, blank=True, related_name='parts') - cal_dec_places = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(32)], null=False, blank=True, default=8) + cal_dec_places = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(32)], null=False, blank=True, default=15) class Meta: ordering = ['name'] diff --git a/roundabout/parts/views.py b/roundabout/parts/views.py index 4a08ef0cd..922b1a2f1 100644 --- a/roundabout/parts/views.py +++ b/roundabout/parts/views.py @@ -31,7 +31,7 @@ from django.template.defaultfilters import slugify from .models import Part, PartType, Revision, Documentation -from .forms import PartForm, PartTypeForm, RevisionForm, DocumentationFormset, RevisionFormset, PartUdfAddFieldForm, PartUdfFieldSetValueForm +from .forms import PartForm, PartTypeForm, RevisionForm, DocumentationFormset, RevisionFormset, PartUdfAddFieldForm, PartUdfFieldSetValueForm, PartTypeDeleteForm from roundabout.calibrations.forms import PartCalNameFormset from roundabout.locations.models import Location from roundabout.inventory.models import Inventory @@ -827,13 +827,45 @@ def get_success_url(self): return reverse('parts:parts_type_home', ) -class PartsTypeDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView): - model = PartType +class PartsTypeDeleteView(LoginRequiredMixin, PermissionRequiredMixin, FormView): + form_class = PartTypeDeleteForm template_name = 'parts/part_type_confirm_delete.html' - success_url = reverse_lazy('parts:parts_type_home') permission_required = 'parts.delete_part' redirect_field_name = 'home' + def get_context_data(self, **kwargs): + context = super(PartsTypeDeleteView, self).get_context_data(**kwargs) + part_type = PartType.objects.get(id=self.kwargs['pk']) + + context.update({ + 'part_type': part_type + }) + return context + + def get_form_kwargs(self): + kwargs = super(PartsTypeDeleteView, self).get_form_kwargs() + if 'pk' in self.kwargs: + kwargs['pk'] = self.kwargs['pk'] + return kwargs + + def form_valid(self, form): + new_part_type = form.cleaned_data['new_part_type'] + part_type_to_delete = PartType.objects.get(id=self.kwargs['pk']) + + # Need to check if there's Part Templates. If so, need move them to new Part Type. + if part_type_to_delete.parts.exists(): + for part in part_type_to_delete.parts.all(): + part.part_type = new_part_type + part.save() + + # Delete the Part Type object + part_type_to_delete.delete() + return HttpResponseRedirect(self.get_success_url()) + + def get_success_url(self): + return reverse('parts:parts_type_home') + + # Direct detail view class PartsTypeDetailView(LoginRequiredMixin, DetailView): model = PartType @@ -852,6 +884,7 @@ def post(self, request, *args, **kwargs): context = self.get_context_data(object=self.object) return self.render_to_response(context) + # AJAX Views class PartsTypeAjaxDetailView(LoginRequiredMixin , DetailView): diff --git a/roundabout/search/mixins.py b/roundabout/search/mixins.py index f5b115f81..dcb746d21 100644 --- a/roundabout/search/mixins.py +++ b/roundabout/search/mixins.py @@ -26,7 +26,7 @@ class TableExportStream(TableExport): - def table_to_dataset(self, table, exclude_columns): + def table_to_dataset(self, table, exclude_columns, dataset_kwargs=None): """A generator that returns a tablib dataset for each row of the table.""" table_rows = table.as_values(exclude_columns=exclude_columns) headers = next(table_rows) diff --git a/roundabout/search/urls.py b/roundabout/search/urls.py index 5051d54e7..9f0498cbe 100644 --- a/roundabout/search/urls.py +++ b/roundabout/search/urls.py @@ -21,6 +21,7 @@ from django.urls import path +from . import user_search from . import views app_name = 'search' @@ -33,4 +34,5 @@ path('parts',view=views.PartTableView.as_view(),name='part'), path('assembly', view=views.AssemblyTableView.as_view(), name='assembly'), path('actions', view=views.ActionTableView.as_view(), name='action'), + path('user', view=user_search.UserSearchView.as_view(), name='user'), ] diff --git a/roundabout/search/user_search.py b/roundabout/search/user_search.py new file mode 100644 index 000000000..35d60a31f --- /dev/null +++ b/roundabout/search/user_search.py @@ -0,0 +1,262 @@ +""" +# Copyright (C) 2019-2020 Woods Hole Oceanographic Institution +# +# This file is part of the Roundabout Database project ("RDB" or +# "ooicgsn-roundabout"). +# +# ooicgsn-roundabout is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# ooicgsn-roundabout is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ooicgsn-roundabout in the COPYING.md file at the project root. +# If not, see . +""" + +import django_tables2 as tables2 +from django import forms +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q +from django.utils.html import format_html +from django.views.generic import TemplateView +from django_tables2.columns import Column, DateTimeColumn, ManyToManyColumn, BooleanColumn +from django_tables2_column_shifter.tables import ColumnShiftTable + +from roundabout.builds.models import BuildAction +from roundabout.calibrations.models import CalibrationEvent, CoefficientNameEvent +from roundabout.configs_constants.models import ConfigEvent, ConfigNameEvent, ConstDefaultEvent, ConfigDefaultEvent +from roundabout.inventory.models import Action, DeploymentAction +from roundabout.users.models import User + + +# ========= TABLE BASES ========== # + +class UserTableBase(tables2.Table): + class Meta: + template_name = "django_tables2/bootstrap4.html" + attrs = {'style': 'display: block; overflow-x: auto;'} + model = None + title = None + +class CCCUserTableBase(UserTableBase): + class Meta(UserTableBase.Meta): + fields = ['approved', 'user_draft', 'user_approver', 'created_at', 'detail'] + approved = BooleanColumn() + user_approver = ManyToManyColumn(verbose_name='Approvers', accessor='user_approver', transform=lambda x: x.name, default='') + user_draft = ManyToManyColumn(verbose_name='Reviewers', accessor='user_draft', transform=lambda x: x.name, default='') + created_at = DateTimeColumn(verbose_name='Date Entered', accessor='created_at', format='Y-m-d H:i') + detail = Column(verbose_name='Note', accessor='detail') + +class ActionUserTableBase(UserTableBase): + class Meta(UserTableBase.Meta): + fields = ['action_type', 'user__name', 'created_at', 'detail'] + user__name = Column(verbose_name='User') + +# ========= TABLES ========== # + +## CCC Events ## + +class CalibrationTable(CCCUserTableBase): + class Meta(CCCUserTableBase.Meta): + model = CalibrationEvent + title = 'Calibration Events' + fields = ['inventory__serial_number'] + CCCUserTableBase.Meta.fields + inventory__serial_number = Column(verbose_name='Inventory', linkify=dict(viewname="inventory:inventory_detail", args=[tables2.A('inventory__pk')])) + value_names = ManyToManyColumn(verbose_name='Coefficient Names', accessor='coefficient_value_sets', transform=lambda x: x.coefficient_name) + #value_notes = ManyToManyColumn(verbose_name='Coefficient Notes', accessor='coefficient_value_sets', + # transform=lambda x: format_html('{}: [{}]
'.format(x.coefficient_name,x.notes)) if x.notes else '', separator='\n') + +class ConfigConstTable(CCCUserTableBase): + class Meta(CCCUserTableBase.Meta): + model = ConfigEvent + title = 'Configuration/Constant Events' + fields = ['inventory__serial_number'] + CCCUserTableBase.Meta.fields + inventory__serial_number = Column(verbose_name='Inventory', linkify=dict(viewname="inventory:inventory_detail", args=[tables2.A('inventory__pk')])) + value_names = ManyToManyColumn(verbose_name='Config/Constant Names', accessor='config_values', transform=lambda x: x.config_name) + #value_notes = ManyToManyColumn(verbose_name='Config/Constant Notes', accessor='config_values', + # transform=lambda x: format_html('{}: [{}]
'.format(x.config_name,x.notes)) if x.notes else '', separator='\n') + + +## CCC Name Events ## + +class CoefficientNameEventTable(CCCUserTableBase): + class Meta(CCCUserTableBase.Meta): + model = CoefficientNameEvent + title = 'Calibration Name-Events' + fields = ['part__name'] + CCCUserTableBase.Meta.fields + part__name = Column(verbose_name='Part', linkify=dict(viewname="parts:parts_detail", args=[tables2.A('part__pk')])) + value_names = ManyToManyColumn(verbose_name='Coefficient Names', accessor='coefficient_names', transform=lambda x: x.calibration_name) + +class ConfigNameEventTable(CCCUserTableBase): + class Meta(CCCUserTableBase.Meta): + model = ConfigNameEvent + title = 'Configuration/Constant Name-Events' + fields = ['part__name'] + CCCUserTableBase.Meta.fields + part__name = Column(verbose_name='Part', linkify=dict(viewname="parts:parts_detail", args=[tables2.A('part__pk')])) + value_names = ManyToManyColumn(verbose_name='Configuration Names', accessor='config_names', transform=lambda x: x.name) + + +## CCC Default Events ## + +class ConfigDefaultEventTable(CCCUserTableBase): + class Meta(CCCUserTableBase.Meta): + model = ConfigDefaultEvent + title = 'Configuration Default-Events' + fields = ['assembly_part__part__name'] + CCCUserTableBase.Meta.fields + assembly_part__part__name = Column(verbose_name='Inventory', linkify=dict(viewname="assemblies:assemblypart_detail", args=[tables2.A('assembly_part__pk')])) + value_names = ManyToManyColumn(verbose_name='Configurations', accessor='config_defaults', + transform=lambda x: format_html('{}: {}
'.format(x.config_name,x.default_value)), separator='\n') + +class ConstDefaultEventTable(CCCUserTableBase): + class Meta(CCCUserTableBase.Meta): + model = ConstDefaultEvent + title = 'Constant Default-Events' + fields = ['inventory__serial_number'] + CCCUserTableBase.Meta.fields + inventory__serial_number = Column(verbose_name='Inventory', linkify=dict(viewname="inventory:inventory_detail", args=[tables2.A('inventory__pk')])) + value_names = ManyToManyColumn(verbose_name='Constants', accessor='constant_defaults', + transform=lambda x: format_html('{}: {}
'.format(x.config_name,x.default_value)), separator='\n') + +## Actions ## + +class ActionTable(ActionUserTableBase): + class Meta(ActionUserTableBase.Meta): + model = Action + title = 'Misc. Actions' + +class BuildActionTable(ActionUserTableBase): + class Meta(ActionUserTableBase.Meta): + model = BuildAction + title = 'Build Actions' + fields = ['build'] + ActionUserTableBase.Meta.fields + build = Column(linkify=dict(viewname="builds:builds_detail", args=[tables2.A('build__pk')])) + +class DeploymentActionTable(ActionUserTableBase): + class Meta(ActionUserTableBase.Meta): + model = DeploymentAction + title = 'Deployment Actions' + fields = ['deployment'] + ActionUserTableBase.Meta.fields + + # workaround linking to deployment. + def render_deployment(self, record): + from django.urls import reverse + build_url = reverse("builds:builds_detail", args=[record.deployment.build.pk]) + deployment_anchor = '#deployment-{}-'.format(record.deployment.pk) # doesn't work, anchor doesn't exist + deployment_anchor = '#deployments' # next best anchor that does work + html_string = '{}'.format(build_url+deployment_anchor, record.deployment) + return format_html(html_string) + + +# ========= FORM STUFF ========= # + +class ListTextWidget(forms.TextInput): + def __init__(self, data_list, name, *args, **kwargs): + super(ListTextWidget, self).__init__(*args, **kwargs) + self._name = name + self._list = data_list + self.attrs.update({'list':'list__{}'.format(self._name)}) + + def render(self, name, value, attrs=None, renderer=None): + text_html = super(ListTextWidget, self).render(name, value, attrs=attrs) + data_list = ''.format(self._name) + for item in self._list: + data_list += '' + return (text_html + data_list) + + +class UserSearchForm(forms.Form): + q = forms.CharField(required=True, label='Name') + ccc_role = forms.ChoiceField(label='CCC Role',choices=[('both','Both'),('app','Approver'),('rev','Reviewer')]) + ccc_status = forms.ChoiceField(label='CCC Status',choices=[('all','Show All'),('app','Show Approved'),('uapp','Show UnApproved')]) + + def __init__(self, *args, **kwargs): + default_userlist = User.objects.all().values_list('username',flat=True) + userlist = kwargs.pop('data_list', default_userlist) + super(UserSearchForm, self).__init__(*args, **kwargs) + self.fields['q'].widget=ListTextWidget(userlist, name='userlist') + + +# ========= VIEWS ========= # + +class UserSearchView(LoginRequiredMixin, tables2.MultiTableMixin, TemplateView): + template_name = 'search/ReviewerApproverSearch.html' + form_class = UserSearchForm + table_pagination = {"per_page": 10} + + tables = [CalibrationTable, + ConfigConstTable, + CoefficientNameEventTable, + ConfigNameEventTable, + ConfigDefaultEventTable, + ConstDefaultEventTable, + DeploymentActionTable, + BuildActionTable, + ActionTable] + + def __init__(self,*args,**kwargs): + super().__init__(*args,**kwargs) + + # disable ColumnShiftTable behavior + for table in self.tables: + if issubclass(table,ColumnShiftTable): + table.shift_table_column = False + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + for table in context['tables']: + table.attrs['title'] = table.Meta.title if hasattr(table.Meta,'title') else table.__name__.replace('Table','s') + return context + + def get_tables_data(self): + if 'q' in self.request.GET: + user_query = self.request.GET.get('q') + ccc_role = self.request.GET.get('ccc_role',None) + ccc_status = self.request.GET.get('ccc_status',None) + else: # defaults + user_query = self.request.user.username + ccc_role = 'both' + ccc_status = 'all' + + ccc_Q_approver = Q(user_approver__username__icontains=user_query) | Q(user_approver__name__icontains=user_query) + ccc_Q_draft = Q(user_draft__username__icontains=user_query) | Q(user_draft__name__icontains=user_query) + action_Q = Q(user__username__icontains=user_query) | Q(user__name__icontains=user_query) + + if ccc_role == 'both': + ccc_Q = ccc_Q_approver | ccc_Q_draft + elif ccc_role == 'app': ccc_Q = ccc_Q_approver + elif ccc_role == 'rev': ccc_Q = ccc_Q_draft + else: ccc_Q = Q(pk__in=[]) # else select none + + if ccc_status=='uapp': + ccc_Q = ccc_Q & Q(approved=False) + elif ccc_status=='app': + ccc_Q = ccc_Q & Q(approved=True) + # else show all CCCs regardless of approval status + + qs_list = [] + for table in self.tables: + if table.Meta.model in [Action, BuildAction, DeploymentAction]: + action_qs = table.Meta.model.objects.select_related('user').filter(action_Q) + qs_list.append(action_qs) + else: # it's a CCC_qs + ccc_model = table.Meta.model + ccc_qs = ccc_model.objects.prefetch_related('user_approver','user_draft').filter(ccc_Q) + qs_list.append(ccc_qs) + + return qs_list + + def get(self, request, *args, **kwargs): + initial = dict(q=request.user.username, ccc_role='both', ccc_status='all') + if 'q' in request.GET: + form = self.form_class(request.GET) + else: + form = self.form_class(initial) + context = self.get_context_data() + context['form'] = form + return self.render_to_response(context) diff --git a/roundabout/search/views.py b/roundabout/search/views.py index bcaf4f132..8ea08f7d3 100644 --- a/roundabout/search/views.py +++ b/roundabout/search/views.py @@ -27,10 +27,7 @@ import django_tables2 as tables from django.contrib.auth.mixins import LoginRequiredMixin -#from django.template.defaultfilters import register -#from django.shortcuts import render, get_object_or_404 #from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector -#from django.http import HttpResponseRedirect, HttpResponse, JsonResponse, QueryDict from django.db.models import Q, Count, OuterRef, Subquery from django.shortcuts import redirect from django.urls import reverse @@ -67,18 +64,19 @@ def searchbar_redirect(request): elif model=='calibrations': if fnmatch(query.strip(),'????-??-??'): query = query.strip() - getstr = '?f=.0.calibration_event__calibration_date&l=.0.exact&q=.0.{query}' + getstr = '?f=.0.calibration_event__calibration_date&l=.0.date&q=.0.{query}' else: getstr = '?f=.0.calibration_event__inventory__serial_number&f=.0.calibration_event__inventory__part__name&f=.0.coefficient_name__calibration_name&f=.0.calibration_event__user_approver__any__name&f=.0.calibration_event__user_draft__any__name&f=.0.notes&l=.0.icontains&q=.0.{query}' elif model == 'configconsts': if fnmatch(query.strip(), '????-??-??'): query = query.strip() - getstr = '?f=.0.config_event__configuration_date&l=.0.exact&q=.0.{query}' + getstr = '?f=.0.config_event__configuration_date&l=.0.date&q=.0.{query}' else: getstr = '?f=.0.config_event__inventory__serial_number&f=.0.config_event__inventory__part__name&f=.0.config_name__name&f=.0.config_event__user_approver__any__name&f=.0.config_event__user_draft__any__name&f=.0.notes&l=.0.icontains&q=.0.{query}' elif model=='part': getstr = '?f=.0.part_number&f=.0.name&f=.0.friendly_name&l=.0.icontains&q=.0.{query}' elif model == 'build': getstr = '?f=.0.build_number&f=.0.assembly__name&f=.0.assembly__assembly_type__name&f=.0.assembly__description&f=.0.build_notes&f=.0.location__name&l=.0.icontains&q=.0.{query}' elif model == 'assembly': getstr = '?f=.0.assembly_number&f=.0.name&f=.0.assembly_type__name&f=.0.description&l=.0.icontains&q=.0.{query}' elif model == 'action': getstr = '?f=.0.action_type&f=.0.user__name&f=.0.detail&f=.0.location__name&f=.0.inventory__serial_number&f=.0.inventory__part__name&l=.0.icontains'+'&q=.0.{query}' + elif model == 'user': getstr = '?ccc_role=both&ccc_status=all'+'&q={query}' getstr = getstr.format(query=query) resp['Location'] += getstr return resp @@ -99,6 +97,7 @@ def get_search_cards(self): queries = self.request.GET.getlist('q') negas = self.request.GET.getlist('n') + # the +['t'] corresponds to the t of "... for c,r,v,t in ..." below fields = [unquote(f).split('.',2)+['f'] for f in fields] lookups = [unquote(l).split('.',2)+['l'] for l in lookups] queries = [unquote(q).split('.',2)+['q'] for q in queries] @@ -121,19 +120,35 @@ def get_search_cards(self): query = [v for v,t in row_items if t=='q'] nega = [v for v,t in row_items if t=='n'] try: - assert len(fields) >=1 - assert len(lookup)==1 - assert len(query)==1 and query[0] + assert len(fields) >= 1 + assert len(lookup) == 1 + assert len(query) == 1 assert len(nega) <= 1 + lookup,query = lookup[0],query[0] + multi_bool = len(fields) > 1 except AssertionError: continue #skip + # Searching for Null/None field values + if lookup == 'isnull': + query = True if query == 'True' else False + + # querying for empty strings + if query == '' and lookup not in ['exact','iexact']: + continue #skip empty + + # hack: implicitly search for usernames in addition to a user's name + for f in fields[:]: + if 'user__name' in f: + extra_f = f.replace('user__name','user__username') + fields.append(extra_f) + row = dict( #row_id=row_id, fields=fields, - lookup=lookup[0], - query=query[0], + lookup=lookup, + query=query, nega=bool(nega), - multi=len(fields) > 1) + multi=multi_bool) rows.append(row) # choice field hack @@ -221,6 +236,7 @@ def make_Qkwarg(field,row): Q_kwarg = {'id__in': matched_model_IDs} return Q_kwarg + cards = self.get_search_cards() final_Qs = [] @@ -286,8 +302,10 @@ def get_context_data(self, **kwargs): avail_lookups = [dict(value='icontains',text='Contains'), dict(value='exact', text='Exact'), + dict(value='date', text='Date'), dict(value='gte', text='>='), - dict(value='lte', text='<='),] + dict(value='lte', text='<='), + dict(value='isnull', text='Is-Null')] context['avail_lookups'] = json.dumps(avail_lookups) lcats = dict( @@ -300,6 +318,7 @@ def get_context_data(self, **kwargs): ITER_LOOKUP=['in']+['icontains', 'exact'], EXACT_LOOKUP = ['exact'], BOOL_LOOKUP = ['exact','iexact'], ) + lcats = {k:v+['isnull'] for k,v in lcats.items()} context['lookup_categories'] = json.dumps(lcats) avail_fields_sans_col_args = self.get_avail_fields() @@ -625,7 +644,7 @@ class ActionTableView(GenericSearchTableView): def get_avail_fields(): avail_fields = [dict(value="action_type", text="Action Type", legal_lookup='STR_LOOKUP'), dict(value="user__name", text="User", legal_lookup='STR_LOOKUP'), - dict(value="created_at", text="Timestamp", legal_lookup='DATETIME_LOOKUP'), + dict(value="created_at", text="Timestamp", legal_lookup='DATE_LOOKUP'), dict(value="detail", text="Detail", legal_lookup='STR_LOOKUP'), dict(value="location__name",text="Location",legal_lookup='STR_LOOKUP'), dict(value="inventory__serial_number", text="Inventory: Serial Number", legal_lookup='STR_LOOKUP'), @@ -672,6 +691,8 @@ def get_queryset(self): # final query results must be CalibrationEvents. calibration_event_ids = qs.values_list('calibration_event__id', flat=True) qs = CalibrationEvent.objects.filter(id__in=calibration_event_ids) + qs = qs.prefetch_related(*[pf.replace('calibration_event__','') for pf in self.query_prefetch if pf.startswith('calibration_event__')]) + qs = qs.select_related('inventory__part__part_type').exclude(inventory__part__part_type__ccc_toggle=False) return qs def get_table_kwargs(self): # since search model is CoefficientValueSet and results are CalibrationEvents, @@ -686,7 +707,7 @@ def get_table_kwargs(self): class ConfigConstTableView(GenericSearchTableView): model = ConfigValue table_class = ConfigConstTable - query_prefetch = ['config_name','config_event','config_event__inventory','config_event__inventory__part','config_event__user_approver','config_event__draft_approver'] + query_prefetch = ['config_name','config_event','config_event__inventory','config_event__inventory__part','config_event__user_approver','config_event__user_draft'] @staticmethod def get_avail_fields(): @@ -712,6 +733,8 @@ def get_queryset(self): # final query results must be CalibrationEvents. config_event_ids = qs.values_list('config_event__id', flat=True) qs = ConfigEvent.objects.filter(id__in=config_event_ids) + qs = qs.prefetch_related(*[pf.replace('config_event__','') for pf in self.query_prefetch if pf.startswith('config_event__')]) + qs = qs.select_related('inventory__part__part_type').exclude(inventory__part__part_type__ccc_toggle=False) return qs def get_table_kwargs(self): # since search model is ConfigValue and results are ConfigEvents, @@ -723,6 +746,3 @@ def get_table_kwargs(self): return {'extra_columns':[]} -# TODO see sn CGINS-DOSTAD-00134 for approver/reviewer search functionality -# http://0.0.0.0:8000/search/calibrations?f=.0.calibration_event__inventory__serial_number&l=.0.icontains&q=.0.CGINS-DOSTAD-00134 -# http://0.0.0.0:8000/search/inventory?f=.0.serial_number&l=.0.icontains&q=.0.CGINS-DOSTAD-00134 diff --git a/roundabout/static/css/project.css b/roundabout/static/css/project.css index ccb181284..fb05f528c 100644 --- a/roundabout/static/css/project.css +++ b/roundabout/static/css/project.css @@ -193,10 +193,6 @@ a.wh { color: #eee !important; } -#inventory-tabs table th { - border-top: none; -} - #inventory-tabs .list-group-item { padding-right: 0; } @@ -215,6 +211,10 @@ a.wh { font-size: 12px; } +.action-table th { + border-top: none; +} + #history { max-height:500px; overflow-y: scroll; @@ -269,6 +269,11 @@ a.wh { white-space: nowrap; } +.item-data-table th { + width: 28%; + color: #666; +} + /* Template Mode styling for apps that are considered Templates */ .template #detail-view { border-color: #28a745; @@ -293,7 +298,6 @@ a.wh { footer { background-color: white; - color: white; } /* Table Header Ordering */ diff --git a/roundabout/static/js/form-search.js b/roundabout/static/js/form-search.js index 30870da1b..0df248345 100644 --- a/roundabout/static/js/form-search.js +++ b/roundabout/static/js/form-search.js @@ -124,12 +124,6 @@ function create_row(card_idx, model, row_index,row_data=null,field_options=null) init_nega = row_data['nega'] init_multi = row_data['multi'] } - else{ - if (model === 'Inventory'){ init_fields = [] } - else if (model === 'Part'){ init_fields = [] } - else if (model === 'Build'){ init_fields = [] } - else if (model === 'Assembly'){ init_fields = [] } - } const row_id = `qfield-row_c${card_idx}_r${row_index}` let row = `
@@ -269,21 +263,27 @@ function DoSubmit(e){ const query_value = query_input.val() // alert if query text box empty - if (!query_input.val()){ - validation_alerts.push('Query textboxes cannot be left empty.') + if (!query_input.val() && lookup_value !== 'exact'){ + validation_alerts.push('Query textboxes may only be left blank if "Exact" is selected (to query a field for "empty" string entries') } field_texts.forEach(function(field_text){ const idx = avail_fields.findIndex(f => f.text === field_text) + + //Assert that field input matches field type if ( ! lookup_categories[avail_fields[idx].legal_lookup].includes(lookup_value) ){ - validation_alerts.push(`Field "${field_text}" cannot be used with "${lookup_text}".`) + validation_alerts.push(`Field "${field_text}" cannot be used with Lookup "${lookup_text}".`) } //Assert that date input is valid - if (avail_fields[idx].legal_lookup === 'DATE_LOOKUP') { + if ( lookup_value === 'date' && !query_value.match(/^\d{4}-\d{2}-\d{2}$/)) { + validation_alerts.push(`"Date" lookup query "${query_value}" is invalid. Must use "YYYY-MM-DD" format`) + } + else if (avail_fields[idx].legal_lookup === 'DATE_LOOKUP' && lookup_value !== 'isnull') { if ( !( query_value.match(/^\d{4}-\d{2}-\d{2}$/) || - query_value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)) ){ - validation_alerts.push(`Date query "${query_value}" is invalid. Must use "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" format`) + query_value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/) || + query_value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) )){ + validation_alerts.push(`Date query "${query_value}" is invalid. Must use "YYYY-MM-DD [HH:MM[:SS]]" format`) } else if (! Date.parse(query_value)){ validation_alerts.push(`Date query "${query_value}" is invalid`) @@ -296,7 +296,7 @@ function DoSubmit(e){ } //Assert that boolean field recieves only legal boolean input/query - if (avail_fields[idx].legal_lookup === 'BOOL_LOOKUP'){ + if (avail_fields[idx].legal_lookup === 'BOOL_LOOKUP' || lookup_value === 'isnull'){ if (['True','False'].includes(query_value)) { } else if(['TRUE','true','T','t','1','yes','Yes','YES','y','Y'].includes(query_value)) { query_input.val('True') } diff --git a/roundabout/static/js/project.js b/roundabout/static/js/project.js index 82dc6e551..01c46a08d 100644 --- a/roundabout/static/js/project.js +++ b/roundabout/static/js/project.js @@ -85,8 +85,13 @@ $(document).ready(function() { }); } else if (typeof assemblyID !== 'undefined') { console.log(assemblyID); - data.instance._open_to(assemblyID); - data.instance.open_node(assemblyID); + if (typeof assemblyRevisionID !== 'undefined') { + data.instance._open_to(assemblyRevisionID); + data.instance.open_node(assemblyRevisionID); + } else{ + data.instance._open_to(assemblyID); + data.instance.open_node(assemblyID); + } $(navTree).on("open_node.jstree", function (event, data) { data.instance._open_to(nodeID); data.instance.select_node(nodeID); @@ -326,7 +331,6 @@ function getCookie(name) { } return cookieValue; } - var csrftoken = getCookie('csrftoken'); function csrfSafeMethod(method) { diff --git a/roundabout/templates/assemblies/ajax_assemblypart_detail.html b/roundabout/templates/assemblies/ajax_assemblypart_detail.html index c22f8d2bb..e9d248c83 100644 --- a/roundabout/templates/assemblies/ajax_assemblypart_detail.html +++ b/roundabout/templates/assemblies/ajax_assemblypart_detail.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} -{% block title %}{{object_name}} Confirm delete?{% endblock %} + +{% load crispy_forms_tags %} + +{% block title %}{{assembly_type}} Confirm delete?{% endblock %} {% block content %}

Are you sure?

-

You're about to delete {{ object }}. Please confirm.

-
+ + {% if assembly_type.assemblies.exists %} + + {% endif %} + + + {% csrf_token %} - + + {{ form|crispy }} + +

You're about to delete {{ assembly_type }}. Please confirm.

+ Cancel
diff --git a/roundabout/templates/base.html b/roundabout/templates/base.html index 4dd83c4a5..48a0f49df 100644 --- a/roundabout/templates/base.html +++ b/roundabout/templates/base.html @@ -144,7 +144,7 @@ Bulk Upload Tool Bulk Download Tool Printers - Upload GitHub Calibration CSV + Upload GitHub CSVs
{% endif %} @@ -184,7 +184,7 @@
Editing Templates
diff --git a/roundabout/templates/builds/ajax_build_detail.html b/roundabout/templates/builds/ajax_build_detail.html index d339fe4c5..3e99cc67c 100644 --- a/roundabout/templates/builds/ajax_build_detail.html +++ b/roundabout/templates/builds/ajax_build_detail.html @@ -24,6 +24,9 @@ {% load mptt_tags %}
+ {% if build.flag %} + + {% endif %}

{{ build }}

@@ -123,7 +126,7 @@

{{ build }}

-

{{ label_builds_app_singular }} Number: {{ build.build_number }}
+

@@ -187,58 +190,65 @@

{{ build }}

-

{{ label_assemblies_app_singular }} Template: {{ build.assembly }}

- -

{{ label_assemblies_app_singular }} Revision: {{ build.assembly_revision.revision_code }}

- -

{{ label_assemblies_app_singular }} Type: {{ build.assembly.assembly_type }}

+ + + + + + + + + + + + + + + {% if build.build_notes %} + + + + + {% endif %} + + + + + + + + + +
{{ label_builds_app_singular }} Number{{ build.build_number }}
{{ label_assemblies_app_singular }} Template + + {{ build.assembly }} | Revision: {{ build.assembly_revision.revision_code }} + + + + +
{{ label_assemblies_app_singular }} Type{{ build.assembly.assembly_type }}
{{ label_builds_app_singular }} Notes{{ build.build_notes }}
Current Location + + {{ build.location }} + + + + +
Total Time in Field{{ build.time_at_sea|time_at_sea_display }}
-

{{ label_builds_app_singular }} Notes: {{ build.build_notes }}

+ {% if percent_complete %} +
Percent Built
-
-
- -
{{ label_builds_app_singular }} Status
- - {% if build.flag %} - - {% endif %} - -

Current Location: {{ build.location }}

- -

Total Time in Field: {{ build.time_at_sea|time_at_sea_display }}

- - {% if percent_complete %} -
Percent Built
- -
- {% if percent_complete == 0 %} {{ percent_complete }}% {% endif %} -
- {{ percent_complete }}% -
-
- {% endif %} - - - - -
- - - + {% endif %} {% if build.is_deployed %} -
+
Current {{ label_deployments_app_singular }} Data
-

{{ label_deployments_app_singular }} Number: {{ current_deployment.deployment_number }}

-

Final {{ label_deployments_app_singular }} Location: {{ current_deployment.deployed_location }}

- {% if current_deployment.cruise_deployed %} -

Cruise Deployed On: - {{ current_deployment.cruise_deployed }} -

- {% endif %} - {% if current_deployment.cruise_recovered %} -

Cruise Recovered On: - {{ current_deployment.cruise_recovered }} -

- {% endif %} - -
{{ label_deployments_app_singular }} Status
-
{{ current_deployment.deployment_progress_bar|get_item:'status_label' }}
-
- {% if current_deployment.current_status != 'startdeployment' and current_deployment.current_status != 'deploymentburnin' %} -

{{ label_deployments_app_singular }} to Field Date: {{ current_deployment.deployment_to_field_date|date:"n/j/y H:i" }}

-

Latitude: {{ current_deployment.latitude}}

-

Longitude: {{ current_deployment.longitude }}

-

Depth: {{ current_deployment.depth }} m

- {% endif %} + + + + + + + {% if current_deployment.deployed_location %} + + + + + {% endif %} + {% if current_deployment.cruise_deployed %} + + + + + {% endif %} + {% if current_deployment.cruise_recovered %} + + + + + {% endif %} + + + + + {% if current_deployment.current_status != 'startdeployment' and current_deployment.current_status != 'deploymentburnin' %} + + + + + {% if current_deployment.latitude %} + + + + + {% endif %} + {% if current_deployment.longitude %} + + + + + {% endif %} + {% if current_deployment.depth %} + + + + + {% endif %} + {% endif %} + +
{{ label_deployments_app_singular }} Number{{ current_deployment.deployment_number }}
Final {{ label_deployments_app_singular }} Location + + {{ current_deployment.deployed_location }} + + + + +
Cruise Deployed On + + {{ current_deployment.cruise_deployed }} + + + + +
Cruise Recovered On + + {{ current_deployment.cruise_recovered }} + + + + +
Current {{ label_deployments_app_singular }} Time in Field{{ current_deployment.deployment_time_in_field|time_at_sea_display }}
{{ label_deployments_app_singular }} to Field Date{{ current_deployment.deployment_to_field_date|date:"n/j/Y H:i" }}
Latitude{{ current_deployment.latitude }} ° N
Longitude{{ current_deployment.longitude }} ° E
Depth{{ current_deployment.depth }} m
+ {% endif %}
diff --git a/roundabout/templates/builds/deployment_detail.html b/roundabout/templates/builds/deployment_detail.html index 21afc8cca..37ccda1f1 100644 --- a/roundabout/templates/builds/deployment_detail.html +++ b/roundabout/templates/builds/deployment_detail.html @@ -21,7 +21,7 @@ {% load common_tags %}
-

Build: - {{ deployment.build }} -

- -

{{ label_deployments_app_singular }} Location: {{ deployment.deployed_location }}

- - {% if deployment.deployment_to_field_date %} -

{{ label_deployments_app_singular }} To Field Date: {{ deployment.deployment_to_field_date }}

- {% endif %} - - {% if deployment.deployment_recovery_date %} -

{{ label_deployments_app_singular }} Recovery Date: {{ deployment.deployment_recovery_date }}

- {% endif %} - {% if deployment.cruise_deployed %} -

Cruise Deployed On: - {{ deployment.cruise_deployed }} -

- {% endif %} - - {% if deployment.cruise_recovered %} -

Cruise Recovered On: - {{ deployment.cruise_recovered }} -

- {% endif %} - - {% if deployment.deployment_time_in_field %} -

Deployment Time in Field: {{ deployment.deployment_time_in_field|time_at_sea_display }} - {% endif %} - - {% if deployment.current_status != 'startdeployment' and deployment.current_status != 'deploymentburnin' %} -

Latitude: {{ deployment.latitude}}

-

Longitude: {{ deployment.longitude }}

-

Depth: {{ deployment.depth }} m

- {% endif %} + + + + + + + {% if deployment.deployed_location %} + + + + + {% endif %} + {% if deployment.deployment_to_field_date %} + + + + + {% endif %} + {% if deployment.deployment_recovery_date %} + + + + + {% endif %} + {% if deployment.cruise_deployed %} + + + + + {% endif %} + {% if deployment.cruise_recovered %} + + + + + {% endif %} + {% if deployment.deployment_time_in_field %} + + + + + {% endif %} + {% if deployment.current_status != 'startdeployment' and deployment.current_status != 'deploymentburnin' %} + {% if deployment.latitude %} + + + + + {% endif %} + {% if deployment.longitude %} + + + + + {% endif %} + {% if deployment.depth %} + + + + + {% endif %} + {% endif %} + +
{{ label_builds_app_singular }} + {{ deployment.build }} + + + + +
{{ label_deployments_app_singular }} Location + {{ deployment.deployed_location }} + + + +
{{ label_deployments_app_singular }} to Field Date{{ deployment.deployment_to_field_date|date:"n/j/Y H:i" }}
{{ label_deployments_app_singular }} Recovery Date{{ deployment.deployment_recovery_date|date:"n/j/Y H:i" }}
Cruise Deployed On + {{ deployment.cruise_deployed }} + + + +
Cruise Recovered On + {{ deployment.cruise_recovered }} + + + +
{{ label_deployments_app_singular }} Time in Field{{ deployment.deployment_time_in_field|time_at_sea_display }}
Latitude{{ deployment.latitude }} ° N
Longitude{{ deployment.longitude }} ° E
Depth{{ deployment.depth }} m
diff --git a/roundabout/templates/calibrations/cal_name_detail.html b/roundabout/templates/calibrations/cal_name_detail.html index b2236430e..380285286 100644 --- a/roundabout/templates/calibrations/cal_name_detail.html +++ b/roundabout/templates/calibrations/cal_name_detail.html @@ -89,6 +89,7 @@

Calibrations

- - - - - - - +
NameTypeSignificant FiguresDeprecated
+ + + + + + + {{ part_calname_form.management_form }} {% for cal in part_calname_form %} - - - - - - + + + + + + {% endfor %} - +
NameTypeSignificant FiguresDeprecated
{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.calibration_name }}{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.value_set_type }}{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.sigfig_override }} {{cal.sigfig_override.help_text}}{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.deprecated }}
{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.calibration_name }}{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.value_set_type }}{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.sigfig_override }} {{cal.sigfig_override.help_text}}{% if cal.id %}{{ cal.DELETE }}{% endif %} {{ cal.id }} {{ cal.deprecated }}
-
+
@@ -123,6 +97,55 @@
Calibration(s)
+ {% block javascript %} + +{% endblock javascript %} \ No newline at end of file diff --git a/roundabout/templates/configs_constants/config_default_detail.html b/roundabout/templates/configs_constants/config_default_detail.html index 3ace49706..b2d479d26 100644 --- a/roundabout/templates/configs_constants/config_default_detail.html +++ b/roundabout/templates/configs_constants/config_default_detail.html @@ -19,187 +19,187 @@ # If not, see . --> {% load common_tags %} - -
-
-
    - {% for event in assembly_part.config_default_events.all %} -
  • - - - {% if user in event.user_draft.all %} - Review Requested - {% endif %} - {% if event.approved %} - Approved - {% else %} - In Progress - {% endif %} - {% if not user|has_group:"inventory only" %} - - {% endif %} -
    -
    +{% if assembly_part.config_default_events.exists %} + +
    +
      + {% for event in assembly_part.config_default_events.all %} +
    • + + {% if user in event.user_draft.all %} - + Review Requested + {% endif %} + {% if event.approved %} + Approved + {% else %} + In Progress {% endif %} {% if not user|has_group:"inventory only" %} -
    • - {% endfor %} -
    -
    - +
  • + {% endfor %} +
+
+{% endif %} {% block javascript %} {% endblock javascript %} diff --git a/roundabout/templates/configs_constants/config_name_detail.html b/roundabout/templates/configs_constants/config_name_detail.html index 1feb66b21..4c44d4dc4 100644 --- a/roundabout/templates/configs_constants/config_name_detail.html +++ b/roundabout/templates/configs_constants/config_name_detail.html @@ -89,6 +89,7 @@

Configurations / Constants