diff --git a/.circleci/config.yml b/.circleci/config.yml index 6e96cb650..1fb0611dd 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 diff --git a/.envs.example/.local/.django b/.envs.example/.local/.django index b05d29f77..a21308588 100644 --- a/.envs.example/.local/.django +++ b/.envs.example/.local/.django @@ -19,6 +19,9 @@ DJANGO_SU_NAME=admin DJANGO_SU_EMAIL=admin@example.com DJANGO_SU_PASSWORD=admin +# Site Domain +RDB_SITE_URL=https://yourdomain.com + # Settings for RDB serial number generation functions # ------------------------------------------------------------------------------ # Default pattern - "1, 2, 3, ... etc." diff --git a/.envs.example/.production/.django b/.envs.example/.production/.django index c22d1c8fb..fd2bc47af 100644 --- a/.envs.example/.production/.django +++ b/.envs.example/.production/.django @@ -44,6 +44,9 @@ DJANGO_SU_NAME=admin DJANGO_SU_EMAIL=admin@example.com DJANGO_SU_PASSWORD=admin +# Site Domain +RDB_SITE_URL=https://yourdomain.com + # Settings for RDB serial number generation functions # ------------------------------------------------------------------------------ # Default pattern - "1, 2, 3, ... etc." diff --git a/compose/local/tests/Dockerfile b/compose/local/tests/Dockerfile index 3c12a49d1..86052349a 100644 --- a/compose/local/tests/Dockerfile +++ b/compose/local/tests/Dockerfile @@ -1,5 +1,6 @@ FROM ubuntu:18.04 RUN apt-get update && apt-get install -y --no-install-recommends apt-utils +RUN apt-get update RUN apt-get install curl -y && apt-get install npm -y # Tools for Circleci debugging RUN apt-get install iputils-ping -y && apt-get install net-tools -y diff --git a/config/settings/base.py b/config/settings/base.py index 233ef1753..2d3c9eb34 100644 --- a/config/settings/base.py +++ b/config/settings/base.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 @@ -98,6 +98,10 @@ 'import_export', # simple model import/export using admin interface 'django_tables2', # interactive tables views 'django_tables2_column_shifter', # show/hide tables2 columns + 'django_filters', #filters for API searching + 'dynamic_rest', # dynamic functionality for API + 'rest_framework.authtoken', + 'rest_flex_fields', ] LOCAL_APPS = [ 'roundabout.users.apps.UsersAppConfig', @@ -114,6 +118,7 @@ 'roundabout.cruises', 'roundabout.calibrations', 'roundabout.configs_constants', + 'roundabout.field_instances', 'roundabout.search', 'roundabout.exports', ] @@ -301,12 +306,20 @@ # When you enable API versioning, the request.version attribute will contain a string # that corresponds to the version requested in the incoming client request. 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning', + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', ), - #'DEFAULT_PERMISSION_CLASSES': [ - # 'rest_framework.permissions.IsAuthenticated', - #] + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + 'roundabout.core.api.renderers.BrowsableAPIRendererWithoutForms', + ), + 'DEFAULT_PAGINATION_CLASS': 'drf_link_header_pagination.LinkHeaderPagination', + 'PAGE_SIZE': 30, + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ] } # Summernote CONFIGURATION diff --git a/config/urls.py b/config/urls.py index af85afab1..a6207c7d2 100644 --- a/config/urls.py +++ b/config/urls.py @@ -26,10 +26,6 @@ from django.views.generic import TemplateView from django.views import defaults as default_views -from rest_framework_simplejwt.views import ( - TokenObtainPairView, - TokenRefreshView, -) urlpatterns = [ path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), @@ -60,14 +56,9 @@ path('export/', include('roundabout.exports.urls', namespace='export')), path('calibrations/', include('roundabout.calibrations.urls', namespace='calibrations')), path('configs_constants/', include('roundabout.configs_constants.urls', namespace='configs_constants')), + path('field_instances/', include('roundabout.field_instances.urls', namespace='field_instances')), # API urls - path('api/v1/', include('roundabout.inventory.api.urls')), - path('api/v1/', include('roundabout.locations.api.urls')), - path('api/v1/', include('roundabout.parts.api.urls')), - path('api/v1/', include('roundabout.assemblies.api.urls')), - # API JWT token paths - path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), - path('api/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/v1/', include('roundabout.core.api.urls', namespace='api_v1')), #Summernote WYSIWYG path('summernote/', include('django_summernote.urls')), ] + static( diff --git a/requirements/base.txt b/requirements/base.txt index d143f4130..d21fdb013 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -17,6 +17,10 @@ django-redis==4.10.0 # https://github.com/niwinz/django-redis djangorestframework==3.10.3 # https://github.com/encode/django-rest-framework djangorestframework_simplejwt==4.3.0 coreapi==2.3.3 # https://github.com/core-api/python-client +django-filter==2.2.0 +dynamic-rest==1.9.6 +djangorestframework-link-header-pagination==0.1.1 +drf-flex-fields==0.8.6 # Your custom requirements go here django-mptt==0.9.1 diff --git a/roundabout/admintools/admin.py b/roundabout/admintools/admin.py index dbe41e04c..ef22e022b 100644 --- a/roundabout/admintools/admin.py +++ b/roundabout/admintools/admin.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 diff --git a/roundabout/admintools/forms.py b/roundabout/admintools/forms.py index 7d81781e4..d0dbc56e1 100644 --- a/roundabout/admintools/forms.py +++ b/roundabout/admintools/forms.py @@ -56,80 +56,88 @@ class ImportCalibrationForm(forms.Form): def clean_cal_csv(self): cal_files = self.files.getlist('cal_csv') - for cal_csv in cal_files: - cal_csv.seek(0) - ext = cal_csv.name[-3:] + csv_files = [] + ext_files = [] + for file in cal_files: + ext = file.name[-3:] if ext == 'ext': - continue + ext_files.append(file) if ext == 'csv': - reader = csv.DictReader(io.StringIO(cal_csv.read().decode('utf-8'))) - headers = reader.fieldnames - try: - inv_serial = cal_csv.name.split('__')[0] - inventory_item = Inventory.objects.get(serial_number=inv_serial) - except: - raise ValidationError( - _('%(value)s: Unable to find Inventory item with this Serial Number'), - params={'value': inv_serial}, - ) - try: - cal_date_string = cal_csv.name.split('__')[1][:8] - cal_date_date = datetime.datetime.strptime(cal_date_string, "%Y%m%d").date() - except: - raise ValidationError( - _('%(value)s: Unable to parse Calibration Date from Filename'), - params={'value': cal_date_string}, - ) - for idx, row in enumerate(reader): - row_data = row.items() - for key, value in row_data: - 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() - ) - except: - raise ValidationError( - _('Row %(row)s, %(value)s: Unable to find Calibration item with this Name'), - params={'value': calibration_name, 'row': idx}, - ) - elif key == 'value': - valset_keys = {'cal_dec_places': inventory_item.part.cal_dec_places} - mock_valset_instance = SimpleNamespace(**valset_keys) - try: - raw_valset = str(value) - if '[' in raw_valset: - raw_valset = raw_valset[1:-1] - if 'SheetRef' in raw_valset: - for file in cal_files: - file.seek(0) - file_extension = file.name[-3:] - if file_extension == 'ext': - cal_ext_split = file.name.split('__') - inv_ext_serial = cal_ext_split[0] - cal_ext_date = cal_ext_split[1] - cal_ext_name = cal_ext_split[2][:-4] - if (inv_ext_serial == inv_serial) and (cal_ext_date == cal_date_string) and (cal_ext_name == calibration_name): - reader = io.StringIO(file.read().decode('utf-8')) - contents = reader.getvalue() - raw_valset = contents - except: - raise ValidationError( - _('Row %(row)s: Unable to parse Calibration Coefficient value(s)'), - params={'row': idx}, - ) - validate_coeff_vals(mock_valset_instance, cal_name_item.value_set_type, raw_valset) - elif key == 'notes': + csv_files.append(file) + for cal_csv in 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 + try: + inv_serial = cal_csv.name.split('__')[0] + inventory_item = Inventory.objects.get(serial_number=inv_serial) + except: + raise ValidationError( + _('File: %(filename)s, %(value)s: Unable to find Inventory item with this Serial Number'), + params={'value': inv_serial, 'filename': cal_csv.name}, + ) + try: + cal_date_string = cal_csv.name.split('__')[1][:8] + cal_date_date = datetime.datetime.strptime(cal_date_string, "%Y%m%d").date() + except: + raise ValidationError( + _('File: %(filename)s, %(value)s: Unable to parse Calibration Date from Filename'), + params={'value': cal_date_string, 'filename': cal_csv.name}, + ) + for idx, row in enumerate(reader): + row_data = row.items() + for key, value in row_data: + 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() + ) + except: + raise ValidationError( + _('File: %(filename)s, Calibration Name: %(value)s, Row %(row)s: Unable to find Calibration item with this Name'), + params={'value': calibration_name, 'row': idx, 'filename': cal_csv.name}, + ) + elif key == 'value': + valset_keys = {'cal_dec_places': inventory_item.part.cal_dec_places} + mock_valset_instance = SimpleNamespace(**valset_keys) + try: + raw_valset = str(value) + except: + raise ValidationError( + _('File: %(filename)s, Calibration Name: %(value)s, Row %(row)s, %(value)s: Unable to parse Calibration Coefficient value(s)'), + params={'value': calibration_name,'row': idx, 'filename': cal_csv.name}, + ) + if '[' in raw_valset: + raw_valset = raw_valset[1:-1] + if 'SheetRef' in raw_valset: + ext_finder_filename = "__".join((cal_csv_filename,calibration_name)) try: - notes = value.strip() + ref_file = [file for file in ext_files if ext_finder_filename in file.name][0] + assert len(ref_file) > 0 except: raise ValidationError( - _('Row %(row)s: Unable to parse Calibration Coefficient note(s)'), - params={'row': idx}, + _('File: %(filename)s, Calibration Name: %(value)s, Row %(row)s: No associated .ext file selected'), + params={'value': calibration_name, 'row': idx, 'filename': cal_csv.name}, ) - return cal_csv + ref_file.seek(0) + reader = io.StringIO(ref_file.read().decode('utf-8')) + contents = reader.getvalue() + raw_valset = contents + validate_coeff_vals(mock_valset_instance, cal_name_item.value_set_type, raw_valset, filename = ref_file.name, cal_name = calibration_name) + else: + validate_coeff_vals(mock_valset_instance, cal_name_item.value_set_type, raw_valset, filename = cal_csv.name, cal_name = calibration_name) + elif key == 'notes': + try: + notes = value.strip() + except: + raise ValidationError( + _('File: %(filename)s, Calibration Name: %(value)s, Row %(row)s: Unable to parse Calibration Coefficient note(s)'), + params={'value': calibration_name, 'row': idx, 'filename': cal_csv.name}, + ) + return cal_files class PrinterForm(forms.ModelForm): diff --git a/roundabout/admintools/models.py b/roundabout/admintools/models.py index 2dfac189b..077c20df5 100644 --- a/roundabout/admintools/models.py +++ b/roundabout/admintools/models.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 @@ -20,14 +20,16 @@ """ from django.db import models +from django.utils import timezone from mptt.models import MPTTModel, TreeForeignKey from django.contrib.postgres.fields import JSONField from roundabout.assemblies.models import AssemblyType from roundabout.parts.models import Part +from roundabout.users.models import User +from roundabout.cruises.models import Cruise # AdminTool models - class Printer(models.Model): PRINTER_TYPES = ( ('Brady', 'Brady'), diff --git a/roundabout/admintools/urls.py b/roundabout/admintools/urls.py index 5ca548f47..101622e1e 100644 --- a/roundabout/admintools/urls.py +++ b/roundabout/admintools/urls.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 diff --git a/roundabout/admintools/views.py b/roundabout/admintools/views.py index f1d4aa639..c6f0e5f48 100644 --- a/roundabout/admintools/views.py +++ b/roundabout/admintools/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 @@ -30,7 +30,6 @@ from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse, reverse_lazy -from django.db import IntegrityError from django.views.generic import View, DetailView, ListView, RedirectView, UpdateView, CreateView, DeleteView, TemplateView, FormView from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.exceptions import ValidationError @@ -41,9 +40,9 @@ from .models import * from roundabout.userdefinedfields.models import FieldValue, Field from roundabout.inventory.models import Inventory, Action -from roundabout.parts.models import Part, Revision +from roundabout.parts.models import Part, Revision, Documentation, PartType from roundabout.locations.models import Location -from roundabout.assemblies.models import AssemblyType, Assembly, AssemblyPart +from roundabout.assemblies.models import AssemblyType, Assembly, AssemblyPart, AssemblyRevision from roundabout.assemblies.views import _make_tree_copy from roundabout.inventory.utils import _create_action_history from roundabout.calibrations.models import CoefficientName, CoefficientValueSet, CalibrationEvent @@ -56,76 +55,75 @@ def trigger_error(request): division_by_zero = 1 / 0 -# CSV File Uploader for GitHub Calibration Coefficients +# CSV File Uploader for GitHub Calibration Coefficients class ImportCalibrationsUploadView(LoginRequiredMixin, FormView): form_class = ImportCalibrationForm template_name = 'admintools/import_calibrations_upload_form.html' def form_valid(self, form): cal_files = self.request.FILES.getlist('cal_csv') - for cal_csv in cal_files: - ext = cal_csv.name[-3:] + csv_files = [] + ext_files = [] + for file in cal_files: + ext = file.name[-3:] if ext == 'ext': - continue + ext_files.append(file) if ext == 'csv': - cal_csv.seek(0) - reader = csv.DictReader(io.StringIO(cal_csv.read().decode('utf-8'))) - headers = reader.fieldnames - coeff_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( - 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 == '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() - ) - elif key == 'value': - valset_keys = {'cal_dec_places': inventory_item.part.cal_dec_places} - mock_valset_instance = SimpleNamespace(**valset_keys) - raw_valset = str(value) - if '[' in raw_valset: - raw_valset = raw_valset[1:-1] - if 'SheetRef' in raw_valset: - for file in cal_files: - file.seek(0) - file_extension = file.name[-3:] - if file_extension == 'ext': - cal_ext_split = file.name.split('__') - inv_ext_serial = cal_ext_split[0] - cal_ext_date = cal_ext_split[1] - cal_ext_name = cal_ext_split[2][:-4] - if (inv_ext_serial == inv_serial) and (cal_ext_date == cal_date_string) and (cal_ext_name == calibration_name): - reader = io.StringIO(file.read().decode('utf-8')) - contents = reader.getvalue() - raw_valset = contents - validate_coeff_vals(mock_valset_instance, cal_name_item.value_set_type, raw_valset) - 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 form.cleaned_data['user_draft'].exists(): - draft_users = form.cleaned_data['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, self.request.user) + csv_files.append(file) + for cal_csv in 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 = [] + 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( + 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 == '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() + ) + elif key == 'value': + valset_keys = {'cal_dec_places': inventory_item.part.cal_dec_places} + mock_valset_instance = SimpleNamespace(**valset_keys) + raw_valset = str(value) + if '[' in raw_valset: + raw_valset = raw_valset[1:-1] + if 'SheetRef' in raw_valset: + ext_finder_filename = "__".join((cal_csv_filename,calibration_name)) + ref_file = [file for file in ext_files if ext_finder_filename in file.name][0] + ref_file.seek(0) + reader = io.StringIO(ref_file.read().decode('utf-8')) + contents = reader.getvalue() + raw_valset = contents + validate_coeff_vals(mock_valset_instance, cal_name_item.value_set_type, raw_valset) + 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 form.cleaned_data['user_draft'].exists(): + draft_users = form.cleaned_data['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, self.request.user) return super(ImportCalibrationsUploadView, self).form_valid(form) def get_success_url(self): @@ -408,84 +406,121 @@ class ImportInventoryUploadSuccessView(TemplateView): # Assembly Template import tool # Import an existing Assembly template from a separate RDB instance +# Makes a copy of the Assembly Revisiontree starting at "root_part", +# move to new Revision, reparenting it to "parent" +def _api_import_assembly_parts_tree(headers, root_part_url, new_revision, parent=None): + params = {'expand': 'part'} + assembly_part_request = requests.get(root_part_url, params=params, headers=headers, verify=False) + assembly_part_data = assembly_part_request.json() + # Need to validate that the Part template exists before creating AssemblyPart + try: + part_obj = Part.objects.get(part_number=assembly_part_data['part']['part_number']) + except Part.DoesNotExist: + params = {'expand': 'part_type,revisions.documentation'} + part_request = requests.get(assembly_part_data['part']['url'], params=params, headers=headers, verify=False) + part_data = part_request.json() + print(part_data) + + try: + part_type = PartType.objects.get(name=part_data['part_type']['name']) + except PartType.DoesNotExist: + # No matching AssemblyType, add it from the API request data + part_type = PartType.objects.create(name=part_data['part_type']['name']) + + part_obj = Part.objects.create( + name = part_data['name'], + friendly_name = part_data['friendly_name'], + part_type = part_type, + part_number = part_data['part_number'], + unit_cost = part_data['unit_cost'], + refurbishment_cost = part_data['refurbishment_cost'], + note = part_data['note'], + cal_dec_places = part_data['cal_dec_places'], + ) + # Create all Revisions objects for this Part + for revision in part_data['revisions']: + revision_obj = Revision.objects.create( + revision_code = revision['revision_code'], + unit_cost = revision['unit_cost'], + refurbishment_cost = revision['refurbishment_cost'], + created_at = revision['created_at'], + part = part_obj, + ) + # Create all Documentation objects for this Revision + for doc in revision['documentation']: + doc_obj = Documentation.objects.create( + name = doc['name'], + doc_type = doc['doc_type'], + doc_link = doc['doc_link'], + revision = revision_obj, + ) + # Now create the Assembly Part + assembly_part_obj = AssemblyPart.objects.create( + assembly_revision = new_revision, + part = part_obj, + parent=parent, + note = assembly_part_data['note'], + order = assembly_part_data['order'] + ) + # Loop through the tree + for child_url in assembly_part_data['children']: + _api_import_assembly_parts_tree(headers, child_url, new_revision, assembly_part_obj) + + return True + # View to make API request to a separate RDB instance and copy an Assembly Template class ImportAssemblyAPIRequestCopyView(LoginRequiredMixin, PermissionRequiredMixin, View): permission_required = 'assemblies.add_assembly' def get(self, request, *args, **kwargs): + import_url = request.GET.get('import_url') + api_token = request.GET.get('api_token') + + if not import_url: + return HttpResponse("No import_url query paramater data") + + if not api_token: + return HttpResponse("No api_token query paramater data") + #api_token = '92e4efc1731d7ed2c31bf76c8d08ab2a34d3ce6d' + headers = { + 'Authorization': 'Token ' + api_token, + } + params = {'expand': 'assembly_type,assembly_revisions'} # Get the Assembly data from RDB API - request_url = 'https://rdb-demo.whoi.edu/api/v1/assemblies/13/' - assembly_request = requests.get(request_url, verify=False) + #import_url = 'https://rdb-demo.whoi.edu/api/v1/assembly-templates/assemblies/8/' + assembly_request = requests.get(import_url, params=params, headers=headers, verify=False) new_assembly = assembly_request.json() # Get or create new parent Temp Assembly - temp_assembly_obj, created = TempImportAssembly.objects.get_or_create(name=new_assembly['name'], - assembly_number=new_assembly['assembly_number'], - description=new_assembly['description'],) - # If already exists, reset all the related items - error_count = 0 - error_parts = [] - if not created: - temp_assembly_obj.temp_assembly_parts.all().delete() - + assembly_obj, created = Assembly.objects.get_or_create(name=new_assembly['name'], + assembly_number=new_assembly['assembly_number'], + description=new_assembly['description'],) + print(assembly_obj) try: assembly_type = AssemblyType.objects.get(name=new_assembly['assembly_type']['name']) - import_error = False except AssemblyType.DoesNotExist: - assembly_type = None - import_error = True - import_error_msg = 'Assembly Type does not exist in this RDB. Please add it, and try again.' - - if not import_error: - # add Assembly Type to the parent object - temp_assembly_obj.assembly_type = assembly_type - temp_assembly_obj.save() - - # import all Assembly Parts to temp table - for assembly_part in new_assembly['assembly_parts']: - # Need to validate that the Part template exists - try: - part = Part.objects.get(part_number=assembly_part['part']['part_number']) - except Part.DoesNotExist: - part = None - import_error = True - error_count += 1 - error_parts.append(assembly_part['part']['part_number']) - import_error_msg = 'Part Number does not exist in this RDB. Please add Part Template, and try again.' - - if not import_error: - temp_assembly_part_obj = TempImportAssemblyPart(assembly=temp_assembly_obj, - part=part, - previous_id=assembly_part['id'], - previous_parent=assembly_part['parent'], - note=assembly_part['note'], - order=assembly_part['order']) - temp_assembly_part_obj.save() - print(temp_assembly_part_obj) - - # run through the temp Assembly Parts again to set the correct Parent structure for MPTT - for temp_assembly_part in temp_assembly_obj.temp_assembly_parts.all(): - # Check if there's a previous_parent, if so we need to find the correct new object - if temp_assembly_part.previous_parent: - parent_obj = TempImportAssemblyPart.objects.get(previous_id=temp_assembly_part.previous_parent) - temp_assembly_part.parent = parent_obj - temp_assembly_part.save() - - # Rebuild the TempImportAssemblyPart MPTT tree to ensure correct structure for copying - TempImportAssemblyPart._tree_manager.rebuild() - - # copy the Temp Assembly to real destination table - assembly_obj = Assembly(name=temp_assembly_obj.name, - assembly_type=temp_assembly_obj.assembly_type, - assembly_number=temp_assembly_obj.assembly_number, - description=temp_assembly_obj.description) - assembly_obj.save() - - for ap in temp_assembly_obj.temp_assembly_parts.all(): - if ap.is_root_node(): - _make_tree_copy(ap, assembly_obj, ap.parent) - - return HttpResponse('

New Assembly Template Imported! - %s

Errors count: %s

%s

' % (import_error, error_count, error_parts)) + # No matching AssemblyType, add it from the API request data + assembly_type = AssemblyType.objects.create(name=new_assembly['assembly_type']['name']) + print(assembly_type) + assembly_obj.assembly_type = assembly_type + assembly_obj.save() + + # Create all Revisions + for rev in new_assembly['assembly_revisions']: + assembly_revision_obj = AssemblyRevision.objects.create( + revision_code = rev['revision_code'], + revision_note = rev['revision_note'], + created_at = rev['created_at'], + assembly = assembly_obj, + ) + print(assembly_revision_obj) + + for root_url in rev['assembly_parts_roots']: + tree_created = _api_import_assembly_parts_tree(headers, root_url, assembly_revision_obj) + + print(tree_created) + #AssemblyPart._tree_manager.rebuild() + return HttpResponse('

New Assembly Template Imported! - %s

' % (assembly_obj)) # Printer functionality @@ -507,6 +542,7 @@ class PrinterCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView) def get_success_url(self): return reverse('admintools:printers_home', ) + class PrinterUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): model = Printer form_class = PrinterForm diff --git a/roundabout/assemblies/api/serializers.py b/roundabout/assemblies/api/serializers.py index ab811e1ca..5b507514a 100644 --- a/roundabout/assemblies/api/serializers.py +++ b/roundabout/assemblies/api/serializers.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 @@ -20,43 +20,133 @@ """ from rest_framework import serializers +from rest_framework.reverse import reverse +from rest_flex_fields import FlexFieldsModelSerializer -from ..models import Assembly, AssemblyPart, AssemblyType +from ..models import Assembly, AssemblyPart, AssemblyType, AssemblyRevision +from roundabout.parts.models import Part from roundabout.parts.api.serializers import PartSerializer +from roundabout.core.api.serializers import RecursiveFieldSerializer +API_VERSION = 'api_v1' -class AssemblyPartSerializer(serializers.ModelSerializer): - part = PartSerializer(read_only=True) +class AssemblyTypeSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':assembly-templates/assembly-types-detail', + lookup_field = 'pk', + ) + assemblies = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assemblies-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) class Meta: - model = AssemblyPart - fields = ['id', 'part', 'parent', 'note', 'order' ] + model = AssemblyType + fields = ['id', 'url', 'name', 'assemblies'] - @staticmethod - def setup_eager_loading(queryset): - """ Perform necessary prefetching of data. """ - queryset = queryset.select_related('part') + expandable_fields = { + 'assemblies': ('roundabout.assemblies.api.serializers.AssemblySerializer', {'many': True}) + } - return queryset +class AssemblySerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':assembly-templates/assemblies-detail', + lookup_field = 'pk', + ) + assembly_revisions = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assembly-revisions-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) + assembly_type = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assembly-types-detail', + lookup_field = 'pk', + queryset = AssemblyType.objects + ) -class AssemblyTypeSerializer(serializers.ModelSerializer): class Meta: - model = AssemblyType - fields = ['name'] + model = Assembly + fields = ['id', 'url', 'name', 'assembly_number', 'description', 'assembly_type', 'assembly_revisions' ] + + expandable_fields = { + 'assembly_type': 'roundabout.assemblies.api.serializers.AssemblyTypeSerializer', + 'assembly_revisions': ('roundabout.assemblies.api.serializers.AssemblyRevisionSerializer', {'many': True}) + } -class AssemblySerializer(serializers.ModelSerializer): - assembly_parts = AssemblyPartSerializer(many=True, read_only=True) - assembly_type = AssemblyTypeSerializer(read_only=True) +class AssemblyRevisionSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':assembly-templates/assembly-revisions-detail', + lookup_field = 'pk', + ) + assembly_parts_roots = serializers.SerializerMethodField('get_assembly_parts_roots') + assembly_parts = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assembly-parts-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) + assembly = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assemblies-detail', + lookup_field = 'pk', + queryset = Assembly.objects + ) class Meta: - model = Assembly - fields = ['id', 'name', 'assembly_number', 'description', 'assembly_type', 'assembly_parts' ] + model = AssemblyRevision + fields = ['id', 'url', 'revision_code', 'revision_note', 'created_at', 'assembly', 'assembly_parts_roots', 'assembly_parts'] - @staticmethod - def setup_eager_loading(queryset): - """ Perform necessary prefetching of data. """ - queryset = queryset.prefetch_related('assembly_parts') + expandable_fields = { + 'assembly': 'roundabout.assemblies.api.serializers.AssemblySerializer', + 'assembly_parts': ('roundabout.assemblies.api.serializers.AssemblyPartSerializer', {'many': True}), + } + + def get_assembly_parts_roots(self, obj): + # Get all the Root AssemblyParts only + assembly_parts = obj.assembly_parts.filter(parent__isnull=True) + assembly_parts_list = [reverse(API_VERSION + ':assembly-templates/assembly-parts-detail', kwargs={'pk': assembly_part.id}, request=self.context['request']) for assembly_part in assembly_parts] + return assembly_parts_list + + +class AssemblyPartSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':assembly-templates/assembly-parts-detail', + lookup_field = 'pk', + ) + parent = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assembly-parts-detail', + lookup_field = 'pk', + queryset = AssemblyPart.objects, + allow_null=True, + ) + children = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assembly-parts-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) + assembly_revision = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':assembly-templates/assembly-revisions-detail', + lookup_field = 'pk', + queryset = AssemblyRevision.objects, + ) + part = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/parts-detail', + lookup_field = 'pk', + queryset = Part.objects, + ) + + class Meta: + model = AssemblyPart + fields = ['id', 'url', 'assembly_revision', 'order', 'part', 'parent', 'children', 'note', ] - return queryset + expandable_fields = { + 'part': PartSerializer, + 'assembly_revision': 'roundabout.assemblies.api.serializers.AssemblyRevisionSerializer', + 'parent': 'roundabout.assemblies.api.serializers.AssemblyPartSerializer', + 'children': ('roundabout.assemblies.api.serializers.AssemblyPartSerializer', {'many': True}) + } diff --git a/roundabout/assemblies/api/views.py b/roundabout/assemblies/api/views.py index 2fb3ba5e9..4361810df 100644 --- a/roundabout/assemblies/api/views.py +++ b/roundabout/assemblies/api/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 @@ -19,18 +19,35 @@ # If not, see . """ +from django.db.models import Prefetch from rest_framework import generics, viewsets, filters -from ..models import Assembly -from .serializers import AssemblySerializer, AssemblyPartSerializer +from rest_framework.permissions import IsAuthenticated + +from ..models import Assembly, AssemblyRevision, AssemblyPart, AssemblyType +from .serializers import AssemblySerializer, AssemblyRevisionSerializer, AssemblyPartSerializer, AssemblyTypeSerializer class AssemblyViewSet(viewsets.ModelViewSet): serializer_class = AssemblySerializer - search_fields = ['name'] - filter_backends = (filters.SearchFilter,) - - def get_queryset(self): - queryset = Assembly.objects.all() - # Set up eager loading to avoid N+1 selects - queryset = self.get_serializer_class().setup_eager_loading(queryset) - return queryset + permission_classes = (IsAuthenticated,) + queryset = Assembly.objects.all() + + +class AssemblyTypeViewSet(viewsets.ModelViewSet): + serializer_class = AssemblyTypeSerializer + permission_classes = (IsAuthenticated,) + queryset = AssemblyType.objects.all() + + +class AssemblyRevisionViewSet(viewsets.ModelViewSet): + serializer_class = AssemblyRevisionSerializer + permission_classes = (IsAuthenticated,) + queryset = AssemblyRevision.objects.all() + queryset = AssemblyRevision.objects.prefetch_related(Prefetch('assembly_parts', + queryset=AssemblyPart.objects.order_by('-parent_id'))) + + +class AssemblyPartViewSet(viewsets.ModelViewSet): + serializer_class = AssemblyPartSerializer + permission_classes = (IsAuthenticated,) + queryset = AssemblyPart.objects.all() diff --git a/roundabout/assemblies/models.py b/roundabout/assemblies/models.py index 518dca95d..60b154b3d 100644 --- a/roundabout/assemblies/models.py +++ b/roundabout/assemblies/models.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 diff --git a/roundabout/calibrations/api/serializers.py b/roundabout/calibrations/api/serializers.py new file mode 100644 index 000000000..71e173db1 --- /dev/null +++ b/roundabout/calibrations/api/serializers.py @@ -0,0 +1,117 @@ +""" +# 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 rest_framework import serializers +from rest_flex_fields import FlexFieldsModelSerializer + +from ..models import CalibrationEvent, CoefficientValueSet, CoefficientName, CoefficientNameEvent, CoefficientValue +from roundabout.parts.api.serializers import PartSerializer + +class CalibrationEventSerializer(FlexFieldsModelSerializer): + class Meta: + model = CalibrationEvent + fields = [ + 'id', + 'created_at', + 'updated_at', + 'calibration_date', + 'user_draft', + 'user_approver', + 'inventory', + 'deployment', + 'approved', + 'detail', + 'coefficient_value_sets' + ] + + expandable_fields = { + 'coefficient_value_sets': ('roundabout.calibrations.api.serializers.CoefficientValueSetSerializer', {'many': True}) + } + + +class CoefficientNameEventSerializer(FlexFieldsModelSerializer): + class Meta: + model = CoefficientNameEvent + fields = [ + 'id', + 'created_at', + 'updated_at', + 'user_draft', + 'user_approver', + 'part', + 'approved', + 'detail', + 'coefficient_names' + ] + + expandable_fields = { + 'part': PartSerializer, + 'coefficient_names': ('roundabout.calibrations.api.serializers.CoefficientNameSerializer', {'many': True}) + } + + +class CoefficientNameSerializer(FlexFieldsModelSerializer): + class Meta: + model = CoefficientName + fields = [ + 'id', + 'calibration_name', + 'value_set_type', + 'sigfig_override', + 'created_at', + 'part', + 'coeff_name_event', + ] + + +class CoefficientValueSetSerializer(FlexFieldsModelSerializer): + class Meta: + model = CoefficientValueSet + fields = [ + 'id', + 'value_set', + 'notes', + 'created_at', + 'coefficient_name', + 'calibration_event', + 'coefficient_values', + ] + + expandable_fields = { + 'calibration_event': 'roundabout.calibrations.api.serializers.CalibrationEventSerializer', + 'coefficient_name': 'roundabout.calibrations.api.serializers.CoefficientNameSerializer', + 'coefficient_values': ('roundabout.calibrations.api.serializers.CoefficientValueSerializer', {'many': True}) + } + + +class CoefficientValueSerializer(FlexFieldsModelSerializer): + class Meta: + model = CoefficientValue + fields = [ + 'id', + 'value', + 'original_value', + 'notation_format', + 'sigfig', + 'row', + 'created_at', + 'coeff_value_set', + ] diff --git a/roundabout/locations/api/urls.py b/roundabout/calibrations/api/views.py similarity index 56% rename from roundabout/locations/api/urls.py rename to roundabout/calibrations/api/views.py index 6991ac770..d8c486c62 100644 --- a/roundabout/locations/api/urls.py +++ b/roundabout/calibrations/api/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 @@ -19,14 +19,19 @@ # If not, see . """ -from django.urls import path, include -from rest_framework.routers import DefaultRouter, SimpleRouter -from .views import LocationViewSet +from rest_framework import generics, filters, viewsets +from rest_framework.permissions import IsAuthenticated +from ..models import CalibrationEvent, CoefficientNameEvent +from .serializers import CalibrationEventSerializer, CoefficientNameEventSerializer -# Create a router and register our viewsets with it. -router = SimpleRouter() -router.register(r'locations', LocationViewSet ) -urlpatterns = [ - path('', include(router.urls) ), -] +class CalibrationEventViewSet(viewsets.ModelViewSet): + serializer_class = CalibrationEventSerializer + permission_classes = (IsAuthenticated,) + queryset = CalibrationEvent.objects.all() + + +class CoefficientNameEventViewSet(viewsets.ModelViewSet): + serializer_class = CoefficientNameEventSerializer + permission_classes = (IsAuthenticated,) + queryset = CoefficientNameEvent.objects.all() diff --git a/roundabout/calibrations/forms.py b/roundabout/calibrations/forms.py index 9b3099cda..b66531103 100644 --- a/roundabout/calibrations/forms.py +++ b/roundabout/calibrations/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 @@ -31,11 +31,11 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -# Event form +# Event form # Inputs: Effective Date and Approval class CalibrationEventForm(forms.ModelForm): class Meta: - model = CalibrationEvent + model = CalibrationEvent fields = ['calibration_date','user_draft'] labels = { 'calibration_date': 'Calibration Date', @@ -44,7 +44,7 @@ class Meta: widgets = { 'calibration_date': DatePickerInput( options={ - "format": "MM/DD/YYYY", + "format": "MM/DD/YYYY", "showClose": True, "showClear": True, "showTodayButton": True, @@ -61,7 +61,7 @@ def clean_user_draft(self): user_draft = self.cleaned_data.get('user_draft') return user_draft - def save(self, commit = True): + def save(self, commit = True): event = super(CalibrationEventForm, self).save(commit = False) if commit: event.save() @@ -73,11 +73,11 @@ def save(self, commit = True): return event -# CoefficientName Event form -# Inputs: Reviewers +# CoefficientName Event form +# Inputs: Reviewers class CoefficientNameEventForm(forms.ModelForm): class Meta: - model = CoefficientNameEvent + model = CoefficientNameEvent fields = ['user_draft'] labels = { 'user_draft': 'Reviewers' @@ -94,7 +94,7 @@ def clean_user_draft(self): user_draft = self.cleaned_data.get('user_draft') return user_draft - def save(self, commit = True): + def save(self, commit = True): event = super(CoefficientNameEventForm, self).save(commit = False) if commit: event.save() @@ -104,11 +104,11 @@ def save(self, commit = True): event.user_approver.remove(user) event.save() return event - + # CoefficientValueSet form -# Inputs: Coefficient values and notes per Part Calibration +# Inputs: Coefficient values and notes per Part Calibration class CoefficientValueSetForm(forms.ModelForm): class Meta: model = CoefficientValueSet @@ -154,7 +154,7 @@ def clean_value_set(self): else: return validate_coeff_vals(self.instance, set_type, raw_set) - def save(self, commit = True): + def save(self, commit = True): value_set = super(CoefficientValueSetForm, self).save(commit = False) if commit: value_set.save() @@ -167,13 +167,27 @@ def save(self, commit = True): class CoefficientNameForm(forms.ModelForm): class Meta: model = CoefficientName - fields = ['calibration_name', 'value_set_type', 'sigfig_override'] + fields = ['calibration_name', 'value_set_type', 'sigfig_override', 'deprecated'] labels = { 'calibration_name': 'Name', 'value_set_type': 'Type', - 'sigfig_override': 'Significant Figures' + 'sigfig_override': 'Significant Figures', + 'deprecated': 'Deprecated' + } + widgets = { + 'deprecated': forms.CheckboxInput() } + def __init__(self, *args, **kwargs): + super(CoefficientNameForm, self).__init__(*args, **kwargs) + if self.instance.deprecated: + self.fields['calibration_name'].widget.attrs.update( + { + 'readonly': True, + 'style': 'cursor: not-allowed; pointer-events: none; background-color: #d5dfed;' + } + ) + def clean_sigfig_override(self): raw_sigfig = self.cleaned_data.get('sigfig_override') try: @@ -222,11 +236,11 @@ def clean_original_value(self): orig_val = self.cleaned_data.get('original_value') return orig_val - def save(self, commit = True): + def save(self, commit = True): coeff_val_inst = super(CoefficientValueForm, self).save(commit = False) coeff_val_inst.value = round( - coeff_val_inst.original_value, - sigfigs = coeff_val_inst.sigfig, + coeff_val_inst.original_value, + sigfigs = coeff_val_inst.sigfig, notation = coeff_val_inst.notation_format ) coeff_val_inst.save() @@ -234,10 +248,10 @@ def save(self, commit = True): # Calibration Copy Form -# Inputs: Part +# Inputs: Part class CalPartCopyForm(forms.Form): part_select = forms.ModelChoiceField( - queryset = Part.objects.filter(part_type__name='Instrument'), + queryset = Part.objects.filter(part_type__ccc_toggle=True), required=False, label = 'Copy Calibrations from Part' ) @@ -245,7 +259,7 @@ class CalPartCopyForm(forms.Form): def __init__(self, *args, **kwargs): self.part_id = kwargs.pop('part_id') super(CalPartCopyForm, self).__init__(*args, **kwargs) - self.fields['part_select'].queryset = Part.objects.filter(part_type__name='Instrument',coefficient_name_events__gt=0).exclude(id__in=str(self.part_id)) + self.fields['part_select'].queryset = Part.objects.filter(part_type__ccc_toggle=True,coefficient_name_events__gt=0).exclude(id__in=str(self.part_id)) def clean_part_select(self): part_select = self.cleaned_data.get('part_select') @@ -265,36 +279,36 @@ def save(self): # Coefficient ValueSet form instance generator for CalibrationEvents EventValueSetFormset = inlineformset_factory( - CalibrationEvent, - CoefficientValueSet, + CalibrationEvent, + CoefficientValueSet, form=CoefficientValueSetForm, - fields=('coefficient_name', 'value_set', 'notes'), - extra=0, + fields=('coefficient_name', 'value_set', 'notes'), + extra=0, can_delete=True ) # Coefficient Name form instance generator for Parts PartCalNameFormset = inlineformset_factory( - CoefficientNameEvent, - CoefficientName, - form=CoefficientNameForm, - fields=('calibration_name', 'value_set_type', 'sigfig_override'), - extra=1, + CoefficientNameEvent, + CoefficientName, + form=CoefficientNameForm, + fields=('calibration_name', 'value_set_type', 'sigfig_override', 'deprecated'), + extra=1, can_delete=True ) # Coefficient Value form instance generator for CoefficientValueSets ValueSetValueFormset = inlineformset_factory( - CoefficientValueSet, - CoefficientValue, + CoefficientValueSet, + CoefficientValue, form=CoefficientValueForm, - fields=('original_value', 'sigfig', 'notation_format'), - extra=0, + fields=('original_value', 'sigfig', 'notation_format'), + extra=0, can_delete=True ) # Validator for 1-D, comma-separated Coefficient value arrays -def validate_coeff_array(coeff_1d_array, valset_inst, val_set_index = 0): +def validate_coeff_array(coeff_1d_array, valset_inst, val_set_index = 0, filename = '', cal_name = ''): error_row_index = val_set_index + 1 for idx, val in enumerate(coeff_1d_array): val = val.strip() @@ -303,8 +317,8 @@ def validate_coeff_array(coeff_1d_array, valset_inst, val_set_index = 0): rounded_coeff_val = round(val) except: raise ValidationError( - _('Row: %(row)s, Column: %(column)s, %(value)s is an invalid Number. Please enter a valid Number (Digits + 1 optional decimal point).'), - params={'row': error_row_index, 'value': val, 'column': error_col_index}, + _('File: %(filename)s, Calibration Name: %(cal_name)s, Row: %(row)s, Column: %(column)s, %(value)s is an invalid Number. Please enter a valid Number (Digits + 1 optional decimal point).'), + params={'row': error_row_index, 'value': val, 'column': error_col_index, 'filename': filename, 'cal_name': cal_name}, ) else: coeff_dec_places = rounded_coeff_val[::-1].find('.') @@ -312,8 +326,8 @@ def validate_coeff_array(coeff_1d_array, valset_inst, val_set_index = 0): assert coeff_dec_places <= valset_inst.cal_dec_places except: raise ValidationError( - _('Row: %(row)s, Column: %(column)s, %(value)s Exceeded %(dec_places)s-digit decimal place maximum.'), - params={'row': error_row_index, 'dec_places': valset_inst.cal_dec_places, 'value': val, 'column': error_col_index}, + _('File: %(filename)s, Calibration Name: %(cal_name)s, Row: %(row)s, Column: %(column)s, %(value)s Exceeded Instrument %(dec_places)s-digit decimal place maximum.'), + params={'row': error_row_index, 'dec_places': valset_inst.cal_dec_places, 'value': val, 'column': error_col_index, 'filename': filename, 'cal_name': cal_name}, ) else: try: @@ -321,8 +335,8 @@ def validate_coeff_array(coeff_1d_array, valset_inst, val_set_index = 0): assert len(digits_only) <= 32 except: raise ValidationError( - _('Row: %(row)s, Column: %(column)s, %(value)s Exceeded 32-digit max length'), - params={'row': error_row_index, 'column': error_col_index, 'value': val}, + _('File: %(filename)s, Calibration Name: %(cal_name)s, Row: %(row)s, Column: %(column)s, %(value)s Exceeded 32-digit max length'), + params={'row': error_row_index, 'column': error_col_index, 'value': val, 'filename': filename, 'cal_name': cal_name}, ) else: continue @@ -331,7 +345,7 @@ def validate_coeff_array(coeff_1d_array, valset_inst, val_set_index = 0): # Validator for Coefficient values within a CoefficientValueSet # Checks for numeric-type, part-based decimal place limit, number of digits limit # Displays array index/value of invalid input -def validate_coeff_vals(valset_inst, set_type, coeff_val_set): +def validate_coeff_vals(valset_inst, set_type, coeff_val_set, filename = '', cal_name = ''): if set_type == 'sl': try: coeff_batch = coeff_val_set.split(',') @@ -341,7 +355,7 @@ def validate_coeff_vals(valset_inst, set_type, coeff_val_set): _('More than 1 value associated with Single input type') ) else: - validate_coeff_array(coeff_batch, valset_inst) + validate_coeff_array(coeff_batch, valset_inst, 0, filename, cal_name) return coeff_val_set elif set_type == '1d': @@ -352,7 +366,7 @@ def validate_coeff_vals(valset_inst, set_type, coeff_val_set): _('Unable to parse 1D array') ) else: - validate_coeff_array(coeff_batch, valset_inst) + validate_coeff_array(coeff_batch, valset_inst, 0, filename, cal_name) return coeff_val_set elif set_type == '2d': @@ -365,7 +379,7 @@ def validate_coeff_vals(valset_inst, set_type, coeff_val_set): else: for row_index, row_set in enumerate(coeff_2d_array): coeff_1d_array = row_set.split(',') - validate_coeff_array(coeff_1d_array, valset_inst, row_index) + validate_coeff_array(coeff_1d_array, valset_inst, row_index, filename, cal_name) return coeff_val_set @@ -376,7 +390,7 @@ def find_notation(val): if ( 'e' in val ): notation = 'sci' return notation - + # Parses Coefficient Value significant digits def find_sigfigs(val): @@ -395,7 +409,7 @@ def parse_coeff_1d_array(coeff_1d_array, value_set_instance, row_index = 0): notation = find_notation(val) sigfig = find_sigfigs(val) coeff_val_obj = CoefficientValue( - coeff_value_set = value_set_instance, + coeff_value_set = value_set_instance, value = val, original_value = val, notation_format = notation, @@ -461,5 +475,5 @@ def validate_part_select(to_part, from_part): raise ValidationError( _('Duplicate Calibration Names exist between Parts. Please select Part with unique Calibration Names.') ) - else: - pass \ No newline at end of file + else: + pass diff --git a/roundabout/calibrations/migrations/0021_coefficientname_deprecated.py b/roundabout/calibrations/migrations/0021_coefficientname_deprecated.py new file mode 100644 index 000000000..c863e1777 --- /dev/null +++ b/roundabout/calibrations/migrations/0021_coefficientname_deprecated.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-09-08 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calibrations', '0020_auto_20200901_1711'), + ] + + operations = [ + migrations.AddField( + model_name='coefficientname', + name='deprecated', + field=models.BooleanField(default=False), + ), + ] diff --git a/roundabout/calibrations/migrations/0022_merge_20200923_1323.py b/roundabout/calibrations/migrations/0022_merge_20200923_1323.py new file mode 100644 index 000000000..5deb8bd2a --- /dev/null +++ b/roundabout/calibrations/migrations/0022_merge_20200923_1323.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-09-23 13:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('calibrations', '0021_coefficientname_deprecated'), + ('calibrations', '0021_auto_20200916_1607'), + ] + + operations = [ + ] diff --git a/roundabout/calibrations/models.py b/roundabout/calibrations/models.py index c81bc1759..ccea40c33 100644 --- a/roundabout/calibrations/models.py +++ b/roundabout/calibrations/models.py @@ -111,6 +111,7 @@ def get_object_type(self): calibration_name = models.CharField(max_length=255, unique=False, db_index=True) value_set_type = models.CharField(max_length=3, choices=VALUE_SET_TYPE, null=False, blank=False, default="sl") sigfig_override = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(20)], null=False, blank=True, default=3, help_text='Part-based default if sigfigs cannot be captured from input') + deprecated = models.BooleanField(null=False, default=False) created_at = models.DateTimeField(default=timezone.now) part = models.ForeignKey(Part, related_name='coefficient_names', on_delete=models.CASCADE, null=True) coeff_name_event = models.ForeignKey(CoefficientNameEvent, related_name='coefficient_names', on_delete=models.CASCADE, null=True) diff --git a/roundabout/calibrations/views.py b/roundabout/calibrations/views.py index f2a2fb912..daba6ea68 100644 --- a/roundabout/calibrations/views.py +++ b/roundabout/calibrations/views.py @@ -50,7 +50,7 @@ def get(self, request, *args, **kwargs): self.object = None inv_inst = Inventory.objects.get(id=self.kwargs['pk']) coeff_event = inv_inst.part.coefficient_name_events.first() - cal_names = coeff_event.coefficient_names.all() + cal_names = coeff_event.coefficient_names.exclude(deprecated=True) form_class = self.get_form_class() form = self.get_form(form_class) form.fields['user_draft'].required = True diff --git a/roundabout/configs_constants/forms.py b/roundabout/configs_constants/forms.py index f4aedbefb..37795bc76 100644 --- a/roundabout/configs_constants/forms.py +++ b/roundabout/configs_constants/forms.py @@ -139,12 +139,14 @@ class Meta: fields = [ 'name', 'config_type', - 'include_with_calibrations' + 'include_with_calibrations', + 'deprecated' ] labels = { 'name': 'Configuration/Constant Name', 'config_type': 'Type', - 'include_with_calibrations': 'Export with Calibrations' + 'include_with_calibrations': 'Export with Calibrations', + 'deprecated': 'Deprecated' } widgets = { 'name': forms.TextInput( @@ -152,8 +154,18 @@ class Meta: 'required': False } ), - 'include_with_calibrations': forms.CheckboxInput() + 'include_with_calibrations': forms.CheckboxInput(), + 'deprecated': forms.CheckboxInput() } + def __init__(self, *args, **kwargs): + super(ConfigNameForm, self).__init__(*args, **kwargs) + if self.instance.deprecated: + self.fields['name'].widget.attrs.update( + { + 'readonly': True, + 'style': 'cursor: not-allowed; pointer-events: none; background-color: #d5dfed;' + } + ) # Constant Default form @@ -278,7 +290,7 @@ def clean_config_name(self): # Inputs: Part class ConfPartCopyForm(forms.Form): from_part = forms.ModelChoiceField( - queryset = Part.objects.filter(part_type__name='Instrument'), + queryset = Part.objects.filter(part_type__ccc_toggle=True), required=False, label = 'Copy Configurations/Constants from Part' ) @@ -310,7 +322,7 @@ def save(self): ConfigValue, form=ConfigValueForm, fields=('config_name', 'config_value', 'notes'), - extra=1, + extra=0, can_delete=True ) @@ -322,7 +334,8 @@ def save(self): fields=( 'name', 'config_type', - 'include_with_calibrations' + 'include_with_calibrations', + 'deprecated' ), extra=1, can_delete=True diff --git a/roundabout/configs_constants/migrations/0018_configname_deprecated.py b/roundabout/configs_constants/migrations/0018_configname_deprecated.py new file mode 100644 index 000000000..58bbf48a8 --- /dev/null +++ b/roundabout/configs_constants/migrations/0018_configname_deprecated.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-09-25 14:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('configs_constants', '0017_configevent_config_type'), + ] + + operations = [ + migrations.AddField( + model_name='configname', + name='deprecated', + field=models.BooleanField(default=False), + ), + ] diff --git a/roundabout/configs_constants/models.py b/roundabout/configs_constants/models.py index f4618deb5..39f3a65c6 100644 --- a/roundabout/configs_constants/models.py +++ b/roundabout/configs_constants/models.py @@ -120,6 +120,7 @@ def get_object_type(self): name = models.CharField(max_length=255, unique=False, db_index=True) config_type = models.CharField(max_length=4, choices=CONFIG_TYPE, null=False, blank=False, default="cnst") created_at = models.DateTimeField(default=timezone.now) + deprecated = models.BooleanField(null=False, default=False) part = models.ForeignKey(Part, related_name='config_names', on_delete=models.CASCADE, null=True) include_with_calibrations = models.BooleanField(null=False, default=False) config_name_event = models.ForeignKey(ConfigNameEvent, related_name='config_names', on_delete=models.CASCADE, null=True) diff --git a/roundabout/configs_constants/views.py b/roundabout/configs_constants/views.py index f712aaa18..52c06292c 100644 --- a/roundabout/configs_constants/views.py +++ b/roundabout/configs_constants/views.py @@ -55,6 +55,7 @@ def get(self, request, *args, **kwargs): names = ConfigName.objects.filter(config_name_event = inv_inst.part.config_name_events.first(), config_type = 'cnst') else: names = ConfigName.objects.filter(config_name_event = inv_inst.part.config_name_events.first(), config_type = 'conf') + names = names.exclude(deprecated = True) form_class = self.get_form_class() form = self.get_form(form_class) form.fields['user_draft'].required = True @@ -188,63 +189,12 @@ class ConfigEventValueUpdate(LoginRequiredMixin, PermissionRequiredMixin, AjaxFo def get(self, request, *args, **kwargs): self.object = self.get_object() cfg_type = self.kwargs['cfg_type'] - if cfg_type == 1: - part_config_names = ConfigName.objects.filter(config_name_event = self.object.inventory.part.config_name_events.first(), config_type = 'cnst') - else: - part_config_names = ConfigName.objects.filter(config_name_event = self.object.inventory.part.config_name_events.first(), config_type = 'conf') - event_config_names = self.object.config_values.all() - extra_rows = len(part_config_names) - len (event_config_names) form_class = self.get_form_class() form = self.get_form(form_class) form.fields['user_draft'].required = False - ConfigEventValueAddFormset = inlineformset_factory( - ConfigEvent, - ConfigValue, - form=ConfigValueForm, - fields=('config_name', 'config_value', 'notes'), - extra=extra_rows, - can_delete=True - ) - config_event_value_form = ConfigEventValueAddFormset( + config_event_value_form = ConfigEventValueFormset( instance=self.object ) - for idx,name in enumerate(part_config_names): - if cfg_type == 1: - try: - default_value = ConstDefault.objects.get(const_event = self.object.inventory.constant_default_events.first(), config_name = name).default_value - except ConstDefault.DoesNotExist: - default_value = '' - if cfg_type == 2: - try: - default_value = ConfigDefault.objects.get(conf_def_event = self.object.inventory.assembly_part.config_default_events.first(), config_name = name).default_value - except ConfigDefault.DoesNotExist: - default_value = '' - try: - config_value = ConfigValue.objects.get(config_event = self.object, config_name = name) - except ConfigValue.DoesNotExist: - config_value = '' - - if default_value != '' and config_value != '': - config_event_value_form.forms[idx].initial = { - 'config_name': name, - 'config_value': default_value, - 'notes': config_value.notes - } - if default_value != '' and config_value == '': - config_event_value_form.forms[idx].initial = { - 'config_name': name, - 'config_value': default_value - } - if default_value == '' and config_value != '': - config_event_value_form.forms[idx].initial = { - 'config_name': name, - 'config_value': config_value, - 'notes': config_value.notes - } - if default_value == '' and config_value == '': - config_event_value_form.forms[idx].initial = { - 'config_name': name - } return self.render_to_response( self.get_context_data( form=form, @@ -595,7 +545,7 @@ def get(self, request, *args, **kwargs): self.object = None inv_inst = Inventory.objects.get(id=self.kwargs['pk']) conf_name_event = inv_inst.part.config_name_events.first() - const_names = conf_name_event.config_names.filter(config_type='cnst') + const_names = conf_name_event.config_names.filter(config_type='cnst', deprecated = False) form_class = self.get_form_class() form = self.get_form(form_class) form.fields['user_draft'].required = True @@ -698,38 +648,30 @@ def get(self, request, *args, **kwargs): self.object = self.get_object() form_class = self.get_form_class() form = self.get_form(form_class) + inv_inst = Inventory.objects.get(id=self.object.inventory.id) + conf_name_event = inv_inst.part.config_name_events.first() + const_names = conf_name_event.config_names.filter(config_type='cnst', deprecated = False).order_by('created_at') form.fields['user_draft'].required = False - conf_name_event = self.object.inventory.part.config_name_events.first() - const_names = conf_name_event.config_names.filter(config_type='cnst') - event_default_names = self.object.constant_defaults.all() - extra_rows = len(const_names) - len(event_default_names) EventDefaultAddFormset = inlineformset_factory( ConstDefaultEvent, ConstDefault, form=ConstDefaultForm, fields=('config_name', 'default_value'), - extra=extra_rows, + extra=len(const_names) - len(self.object.constant_defaults.all()), can_delete=True ) event_default_form = EventDefaultAddFormset( instance=self.object ) for idx,name in enumerate(const_names): - if self.object.inventory.constant_default_events.exists(): - const_def_event = self.object.inventory.constant_default_events.first() - try: - default_value = ConstDefault.objects.get(const_event = const_def_event, config_name = name).default_value - except ConstDefault.DoesNotExist: - default_value = '' - - event_default_form.forms[idx].initial = { - 'config_name': name, - 'default_value': default_value - } - else: - event_default_form.forms[idx].initial = { - 'config_name': name - } + try: + default_value = ConstDefault.objects.get(config_name = name, const_event = self.object) + except ConstDefault.DoesNotExist: + default_value = '' + event_default_form.forms[idx].initial = { + 'config_name': name, + 'default_value': default_value + } return self.render_to_response( self.get_context_data( form=form, @@ -833,7 +775,7 @@ def get(self, request, *args, **kwargs): self.object = None assm_part_inst = AssemblyPart.objects.get(id=self.kwargs['pk']) conf_name_event = assm_part_inst.part.config_name_events.first() - conf_names = conf_name_event.config_names.filter(config_type='conf') + conf_names = conf_name_event.config_names.filter(config_type='conf', deprecated = False) form_class = self.get_form_class() form = self.get_form(form_class) form.fields['user_draft'].required = True @@ -937,36 +879,29 @@ def get(self, request, *args, **kwargs): form_class = self.get_form_class() form = self.get_form(form_class) form.fields['user_draft'].required = False - part_confname_event = self.object.assembly_part.part.config_name_events.first() - part_config_names = part_confname_event.config_names.filter(config_type='conf') - event_config_names = self.object.config_defaults.all() - extra_rows = len(part_config_names) - len(event_config_names) + assm_part_inst = AssemblyPart.objects.get(id=self.object.assembly_part.id) + conf_name_event = assm_part_inst.part.config_name_events.first() + conf_names = conf_name_event.config_names.filter(config_type='conf', deprecated = False).order_by('created_at') EventConfigDefaultAddFormset = inlineformset_factory( ConfigDefaultEvent, ConfigDefault, form=ConfigDefaultForm, fields=('config_name', 'default_value'), - extra=extra_rows, + extra=len(conf_names) - len(self.object.config_defaults.all()), can_delete=True ) event_default_form = EventConfigDefaultAddFormset( instance=self.object ) - for idx,name in enumerate(part_config_names): - if self.object.assembly_part.config_default_events.exists(): - conf_def_event = self.object.assembly_part.config_default_events.first() - try: - default_value = ConfigDefault.objects.get(conf_def_event = conf_def_event, config_name = name).default_value - except ConfigDefault.DoesNotExist: - default_value = '' - event_default_form.forms[idx].initial = { - 'config_name': name, - 'default_value': default_value - } - else: - event_default_form.forms[idx].initial = { - 'config_name': name - } + for idx,name in enumerate(conf_names): + try: + default_value = ConfigDefault.objects.get(config_name = name, conf_def_event = self.object) + except ConfigDefault.DoesNotExist: + default_value = '' + event_default_form.forms[idx].initial = { + 'config_name': name, + 'default_value': default_value + } return self.render_to_response( self.get_context_data( form=form, diff --git a/roundabout/core/api/__init__.py b/roundabout/core/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/roundabout/core/api/renderers.py b/roundabout/core/api/renderers.py new file mode 100644 index 000000000..97cc148f8 --- /dev/null +++ b/roundabout/core/api/renderers.py @@ -0,0 +1,6 @@ +from rest_framework.renderers import BrowsableAPIRenderer + +class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer): + """Renders the browsable api, but excludes the forms.""" + def get_rendered_html_form(self, data, view, method, request): + return None diff --git a/roundabout/core/api/serializers.py b/roundabout/core/api/serializers.py new file mode 100644 index 000000000..a372cad97 --- /dev/null +++ b/roundabout/core/api/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + +# Need a "sub-serializer" to handle self refernce MPTT tree structures +class RecursiveFieldSerializer(serializers.Serializer): + def to_representation(self, value): + serializer = self.parent.parent.__class__(value, context=self.context) + return serializer.data diff --git a/roundabout/core/api/urls.py b/roundabout/core/api/urls.py new file mode 100644 index 000000000..adfb6a01d --- /dev/null +++ b/roundabout/core/api/urls.py @@ -0,0 +1,58 @@ +""" +# 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 django.urls import path, include +from rest_framework.routers import DefaultRouter, SimpleRouter +from roundabout.inventory.api.views import InventoryViewSet, ActionViewSet, PhotoNoteViewSet +from roundabout.assemblies.api.views import AssemblyViewSet, AssemblyRevisionViewSet, AssemblyPartViewSet, AssemblyTypeViewSet +from roundabout.calibrations.api.views import CalibrationEventViewSet, CoefficientNameEventViewSet +from roundabout.locations.api.views import LocationViewSet +from roundabout.parts.api.views import PartViewSet, PartTypeViewSet, RevisionViewSet, DocumentationViewSet +from roundabout.userdefinedfields.api.views import FieldViewSet, FieldValueViewSet + +# Create a router and register our viewsets with it. +router = DefaultRouter() +router.register(r'inventory', InventoryViewSet, 'inventory' ) +router.register(r'actions', ActionViewSet, 'actions' ) +router.register(r'photos', PhotoNoteViewSet, 'photos' ) + +router.register(r'assembly-templates/assemblies', AssemblyViewSet, 'assembly-templates/assemblies' ) +router.register(r'assembly-templates/assembly-revisions', AssemblyRevisionViewSet, 'assembly-templates/assembly-revisions' ) +router.register(r'assembly-templates/assembly-types', AssemblyTypeViewSet, 'assembly-templates/assembly-types' ) +router.register(r'assembly-templates/assembly-parts', AssemblyPartViewSet, 'assembly-templates/assembly-parts' ) + +router.register(r'calibrations/calibration-events', CalibrationEventViewSet, 'calibrations/calibration-events' ) +router.register(r'calibrations/coefficent-name-events', CoefficientNameEventViewSet, 'calibrations/coefficent-name-events' ) + +router.register(r'locations', LocationViewSet, 'locations' ) + +router.register(r'part-templates/parts', PartViewSet, 'part-templates/parts' ) +router.register(r'part-templates/part-types', PartTypeViewSet, 'part-templates/part-types' ) +router.register(r'part-templates/revisions', RevisionViewSet, 'part-templates/revisions' ) +router.register(r'part-templates/documents', DocumentationViewSet, 'part-templates/documents' ) + +router.register(r'user-defined-fields/fields', FieldViewSet, 'user-defined-fields/fields' ) +router.register(r'user-defined-fields/field-values', FieldValueViewSet, 'user-defined-fields/field-values' ) + +app_name = 'api_v1' +urlpatterns = [ + path('', include(router.urls) ), +] diff --git a/roundabout/field_instances/__init__.py b/roundabout/field_instances/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/roundabout/field_instances/admin.py b/roundabout/field_instances/admin.py new file mode 100644 index 000000000..064b122bf --- /dev/null +++ b/roundabout/field_instances/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .models import * + +# Register your models here. +admin.site.register(FieldInstance) diff --git a/roundabout/field_instances/apps.py b/roundabout/field_instances/apps.py new file mode 100644 index 000000000..d7fffa6f1 --- /dev/null +++ b/roundabout/field_instances/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FieldInstancesConfig(AppConfig): + name = 'field_instances' diff --git a/roundabout/parts/api/urls.py b/roundabout/field_instances/forms.py similarity index 73% rename from roundabout/parts/api/urls.py rename to roundabout/field_instances/forms.py index 8fd214595..1fa3daffa 100644 --- a/roundabout/parts/api/urls.py +++ b/roundabout/field_instances/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 @@ -19,14 +19,13 @@ # If not, see . """ -from django.urls import path, include -from rest_framework.routers import DefaultRouter, SimpleRouter -from .views import PartViewSet +from django import forms -# Create a router and register our viewsets with it. -router = SimpleRouter() -router.register(r'parts', PartViewSet ) +from .models import FieldInstance -urlpatterns = [ - path('', include(router.urls) ), -] + +class FieldInstanceForm(forms.ModelForm): + + class Meta: + model = FieldInstance + fields = '__all__' diff --git a/roundabout/field_instances/migrations/0001_initial.py b/roundabout/field_instances/migrations/0001_initial.py new file mode 100644 index 000000000..fc6ffdf5c --- /dev/null +++ b/roundabout/field_instances/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.13 on 2020-08-16 18:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('cruises', '0012_auto_20200727_1746'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FieldInstance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, db_index=True, max_length=255)), + ('start_date', models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True)), + ('end_date', models.DateTimeField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('is_this_instance', models.BooleanField(default=False)), + ('cruise', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='field_instances', to='cruises.Cruise')), + ('users', models.ManyToManyField(related_name='field_instances', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-start_date'], + }, + ), + ] diff --git a/roundabout/field_instances/migrations/__init__.py b/roundabout/field_instances/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/roundabout/field_instances/models.py b/roundabout/field_instances/models.py new file mode 100644 index 000000000..bea56df94 --- /dev/null +++ b/roundabout/field_instances/models.py @@ -0,0 +1,44 @@ +from django.db import models +from django.utils import timezone + +from roundabout.users.models import * + +""" +Model to track users and registrations of "in the field" RDB instances. +These are instances that run strictly locally without internet access necessary. +""" + +class FieldInstance(models.Model): + name = models.CharField(max_length=255, null=False, blank=True, db_index=True) + start_date = models.DateTimeField(default=timezone.now, null=True, blank=True) + end_date = models.DateTimeField(null=True, blank=True) + users = models.ManyToManyField('users.User', related_name='field_instances', blank=False) + cruise = models.ForeignKey('cruises.Cruise', related_name='field_instances', + on_delete=models.SET_NULL, null=True, blank=True) + notes = models.TextField(blank=True) + # True if this RDB instance is registered as a FieldInstance + is_this_instance = models.BooleanField(default=False) + + class Meta: + ordering = ['-start_date'] + + def __str__(self): + return self.name + + """ + # Custom save method to deactive non-deployed users + def save(self, *args, **kwargs): + super(FieldInstance, self).save(*args, **kwargs) + print(self.cruise) + if self.is_this_instance: + for user in self.users.all(): + print(user) + user.is_active = True + user.save() + + users_deactivate = User.objects.exclude(id__in=self.users.all()) + for user in users_deactivate: + print(user) + user.is_active = False + user.save() + """ diff --git a/roundabout/field_instances/requests.py b/roundabout/field_instances/requests.py new file mode 100644 index 000000000..ab558454e --- /dev/null +++ b/roundabout/field_instances/requests.py @@ -0,0 +1,339 @@ +import requests +import json +import random +import os + +from django.conf import settings +from django.http import HttpResponse +from django.views.generic import View +from django.db.models import Q +from django.contrib.sites.shortcuts import get_current_site +from rest_framework.reverse import reverse, reverse_lazy + +#from .models import FieldInstance +from .models import * +from roundabout.inventory.api.serializers import InventorySerializer, ActionSerializer +from roundabout.inventory.models import Inventory, Action, PhotoNote +from roundabout.userdefinedfields.api.serializers import FieldSerializer, FieldValueSerializer +from roundabout.userdefinedfields.models import Field +from roundabout.parts.models import Part, Revision +from roundabout.locations.models import Location +from roundabout.locations.api.serializers import LocationSerializer + + +# Import environment variables from .env files +import environ +env = environ.Env() +base_url = env('RDB_SITE_URL') +api_version_url = '/api/v1' +api_token = '7de11f0cd61a6d50899192c0d02a975b2b204c16' +headers = { + 'Authorization': 'Token ' + api_token, +} +""" +Main sync function to coordinate the different models +""" +def field_instance_sync_main(request, field_instance): + status_code = 401 + pk_mappings = {} + # Sync base models needed for DB relationships + # Run Location sync + location_pk_mappings = _sync_request_locations(request, field_instance) + print(location_pk_mappings) + pk_mappings.update({'location_pk_mappings': location_pk_mappings}) + # Run Field model sync + field_pk_mappings = _sync_request_fields(request, field_instance) + pk_mappings.update({'field_pk_mappings': field_pk_mappings}) + # Sync Inventory items + inventory_respone = _sync_request_inventory(request, field_instance, pk_mappings) + status_code = inventory_respone + print('STATUS CODE: ', inventory_respone) + return status_code + + +""" +Function to sync NEW objects +""" +def _sync_new_objects(field_instance, model, api_url, serializer): + pk_mappings = [] + new_objects = model.objects.filter(created_at__gte=field_instance.start_date) + + if new_objects: + for obj in new_objects: + old_pk = obj.id + # serialize data for JSON request + serializer = serializer(obj) + data_dict = serializer.data + # Need to remap any Parent items that have new PKs + new_key = next((pk for pk in pk_mappings if pk['old_pk'] == data_dict['parent']), False) + if new_key: + print('NEW KEY: ' + new_key) + data_dict['parent'] = new_key['new_pk'] + # These will be POST as new item, so remove id + data_dict.pop('id') + response = requests.post(api_url, json=data_dict) + print(f'{model._meta.model_name} RESPONSE: {response.text}') + print(f'{model._meta.model_name} CODE: {response.status_code}') + new_obj = response.json()[model._meta.model_name] + pk_mappings.append({'old_pk': old_pk, 'new_pk': new_obj['id']}) + return pk_mappings + + +""" +Function to sync EXISTING objects +""" +def _sync_existing_objects(field_instance, model, api_url, serializer, pk_mappings): + status = None + # Get all existing fields in Home Base RDB that were updated + existing_objects = model.objects.filter( + Q(updated_at__gte=field_instance.start_date) & Q(created_at__lt=field_instance.start_date) + ) + if existing_objects: + for obj in existing_objects: + # serialize data for JSON request + serializer = serializer(obj) + data_dict = serializer.data + # Need to remap any Parent items that have new PKs + new_key = next((pk for pk in pk_mappings if pk['old_pk'] == data_dict['parent']), False) + if new_key: + print('NEW KEY: ' + new_key) + data_dict['parent'] = new_key['new_pk'] + + url = F'{api_url}{obj.id}/' + response = requests.patch(url, json=data_dict, ) + print(f'{model._meta.model_name} RESPONSE: {response.text}') + print(f'{model._meta.model_name} CODE: {response.status_code}') + status = response.status_code + return status + + +""" +Request function to sync Field Instance: Locations +Args: +request: Django request object +field_values: queryset of FieldValue objects +Returns: +location_pk_mappings: array that maps old_pk to new_pk for new objects +""" +def _sync_request_locations(request, field_instance): + model = Location + serializer = LocationSerializer + api_url = base_url + reverse('locations-list') + # Sync new objects, return the pk_mappings for new items + pk_mappings = _sync_new_objects(field_instance, model, api_url, serializer) + status = _sync_existing_objects(field_instance, model, api_url, serializer, pk_mappings) + + return pk_mappings + + +""" +Request function to sync Field Instance: Fields +Args: +request: Django request object +field_values: queryset of FieldValue objects +pk_mappings: array that maps old_pk to new_pk for new objects +""" +def _sync_request_fields(request, field_instance): + model = Field + serializer = FieldSerializer + api_url = base_url + reverse('api_v1:userdefined-fields/fields-list') + # Sync new objects, return the pk_mappings for new items + pk_mappings = _sync_new_objects(field_instance, model, api_url, serializer) + status = _sync_existing_objects(field_instance, model, api_url, serializer, pk_mappings) + + """ + field_url = base_url + reverse('userdefinedfields/fields-list') + field_pk_mappings = [] + # Get new fields that were added + new_fields = Field.objects.filter(created_at__gte=field_instance.start_date) + if new_fields: + for field in new_fields: + old_pk = field['id'] + # serialize data for JSON request + field_serializer = FieldSerializer(field) + field_dict = field_serializer.data + # These will be POST as new, so remove id + field_dict.pop('id') + response = requests.post(field_url, json=field_dict ) + print('Field RESPONSE:', response.text) + print("Field CODE: ", response.status_code) + new_obj = response.json() + field_pk_mappings.append({'old_pk': old_pk, 'new_pk': new_obj['id']}) + + # Get all existing fields in Home Base RDB that were updated + existing_fields = Field.objects.filter( + Q(updated_at__gte=field_instance.start_date) & Q(created_at__lt=field_instance.start_date) + ) + if existing_fields: + for field in existing_fields: + # serialize data for JSON request + field_serializer = FieldSerializer(field) + field_dict = field_serializer.data + url = base_url + reverse('userdefinedfields/fields-detail', kwargs={'pk': field.id},) + response = requests.patch(url, json=field_dict, ) + print('Field RESPONSE:', response.text) + print("Field CODE: ", response.status_code) + """ + return pk_mappings + + +""" +Request function to sync Field Instance: Inventory items +Args: +request: Django request object +field_instance: FieldInstance object +""" +def _sync_request_inventory(request, field_instance, pk_mappings): + inventory_pk_mappings = [] + + ##### SYNC INVENTORY ##### + #inventory_url = F"{base_url}/api/v1/inventory/" + inventory_url = base_url + reverse('api_v1:inventory-list') + print(inventory_url) + # Get new items that were added, these need special handling + new_inventory = Inventory.objects.filter(created_at__gte=field_instance.start_date).order_by('-parent') + #actions_add_qs = actions.filter(object_type=Action.INVENTORY).filter(action_type=Action.ADD).order_by('-parent') + if new_inventory: + #new_inventory = [action.inventory for action in actions_add_qs.all()] + print(new_inventory) + for item in new_inventory: + # check if serial number already exists + response = requests.get(inventory_url, params={'filter{serial_number}': item.serial_number}, headers={'Content-Type': 'application/json'}, ) + if response.json(): + # Need to change the Serial Number to avoid naming conflict + item.serial_number = item.serial_number + '-' + str(random.randint(1,1001)) + #item.save() + + inventory_serializer = InventorySerializer( + new_inventory, + many=True, + #context={'request': request, } + ) + inventory_dict = inventory_serializer.data + + for item in inventory_dict: + old_pk = item['id'] + item.pop('id') + # Need to remap any Parent items that have new PKs + new_key = next((pk for pk in inventory_pk_mappings if pk['old_pk'] == item['parent']), False) + if new_key: + print('NEW KEY: ' + new_key) + item['parent'] = new_key['new_pk'] + + print(json.dumps(item)) + """ + response = requests.post(inventory_url, data=json.dumps(item), headers={'Content-Type': 'application/json'}, ) + print('RESPONSE:', response.json()) + new_obj = response.json() + inventory_pk_mappings.append({'old_pk': old_pk, 'new_pk': new_obj['id']}) + """ + print(inventory_pk_mappings) + + # Get all existing items in Home Base RDB + existing_inventory = Inventory.objects.filter( + Q(updated_at__gte=field_instance.start_date) & Q(created_at__lt=field_instance.start_date) + ) + if existing_inventory: + print(existing_inventory) + for inv in existing_inventory: + # serialize data for JSON request + inventory_serializer = InventorySerializer(inv) + inventory_dict = inventory_serializer.data + url = base_url + reverse('api_v1:inventory-detail', kwargs={'pk': inv.id},) + #url = F"{inventory_url}{inv.id}/" + print(url) + # Need to remap any Parent items that have new PKs + new_key = next((pk for pk in inventory_pk_mappings if pk['old_pk'] == inventory_dict['parent']), False) + if new_key: + print('NEW KEY: ', new_key) + inventory_dict['parent'] = new_key['new_pk'] + + response = requests.patch(url, json=inventory_dict, ) + print('INVENTORY RESPONSE:', response.text) + print("INVENTORY CODE: ", response.status_code) + + # post all new Actions for this item + action_response = _sync_request_actions(request, field_instance, inv, inventory_pk_mappings) + # post all Field Values for this item + field_value_response = _sync_request_field_values(request, field_instance, inv, field_pk_mappings, inventory_pk_mappings) + + return response.status_code + + +""" +Request function to sync Field Instance: Actions +Args: +request: Django request object +actions: queryset of Action objects +pk_mappings: array that maps old_pk to new_pk for new objects +""" +def _sync_request_actions(request, field_instance, obj, inventory_pk_mappings=None): + action_url = base_url + reverse('api_v1:actions-list') + photo_url = base_url + reverse('api_v1:photos-list') + # Get all actions for this object + object_type = obj._meta.model_name + actions = obj.actions.filter(object_type=object_type).filter(created_at__gte=field_instance.start_date) + + for action in actions: + # serialize data for JSON request + action_serializer = ActionSerializer(action) + action_dict = action_serializer.data + # These will be POST as new, so remove id + action_dict.pop('id') + response = requests.post(action_url, json=action_dict ) + print('ACTION RESPONSE:', response.text) + print("ACTION CODE: ", response.status_code) + new_action = response.json() + # Upload any photos for new Action notes + if action.photos.exists(): + for photo in action.photos.all(): + multipart_form_data = { + 'photo': (photo.photo.name, photo.photo.file), + #'inventory': (None, photo.inventory.id), + 'action': (None, new_action['action']['id']), + 'user': (None, photo.user.id) + } + response = requests.post(photo_url, files=multipart_form_data ) + print('PHOTO RESPONSE:', response.text) + print("PHOTO CODE: ", response.status_code) + + return 'ACTIONS COMPLETE' + + +""" +Request function to sync Field Instance: FieldValues +Args: +request: Django request object +field_instance: FieldInstance object +inventory_item: Inventory object +field_pk_mappings, inventory_pk_mappings: array that maps old_pk to new_pk for new objects +""" +def _sync_request_field_values(request, field_instance, inventory_item, field_pk_mappings, inventory_pk_mappings=None): + field_value_url = base_url + reverse('api_v1:userdefined-fields/field-values-list') + # Get new field values that were added + new_field_values = inventory_item.fieldvalues.filter(created_at__gte=field_instance.start_date) + for fv in new_field_values: + # serialize data for JSON request + fv_serializer = FieldValueSerializer(fv) + fv_dict = fv_serializer.data + # These will be POST as new, so remove id + fv_dict.pop('id') + response = requests.post(field_value_url, json=fv_dict ) + print('Field Value RESPONSE:', response.text) + print("Field Value CODE: ", response.status_code) + + + # Get all existing field values in Home Base RDB that were updated + existing_field_values = inventory_item.fieldvalues.filter( + Q(updated_at__gte=field_instance.start_date) & Q(created_at__lt=field_instance.start_date) + ) + for fv in existing_field_values: + # serialize data for JSON request + fv_serializer = FieldValueSerializer(fv) + fv_dict = fv_serializer.data + url = base_url + reverse('api_v1:userdefinedfields/field-values-detail', kwargs={'pk': fv.id},) + response = requests.post(field_value_url, json=fv_dict ) + print('Field Value RESPONSE:', response.text) + print("Field Value CODE: ", response.status_code) + + return 'FIELD VALUES COMPLETE' diff --git a/roundabout/field_instances/tests.py b/roundabout/field_instances/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/roundabout/field_instances/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/roundabout/inventory/api/urls.py b/roundabout/field_instances/urls.py similarity index 59% rename from roundabout/inventory/api/urls.py rename to roundabout/field_instances/urls.py index dc1a20e84..5e731ea90 100644 --- a/roundabout/inventory/api/urls.py +++ b/roundabout/field_instances/urls.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 @@ -19,14 +19,16 @@ # If not, see . """ -from django.urls import path, include -from rest_framework.routers import DefaultRouter, SimpleRouter -from .views import InventoryViewSet +from django.urls import path -# Create a router and register our viewsets with it. -router = SimpleRouter() -router.register(r'inventory', InventoryViewSet, 'inventory' ) +from . import views +app_name = 'field_instances' urlpatterns = [ - path('', include(router.urls) ), + path('sync-to-home/', view=views.FieldInstanceSyncToHomeView.as_view(), name='field_instance_sync_to_home'), + # CRUD views + path('', view=views.FieldInstanceListView.as_view(), name='field_instances_home'), + path('add/', view=views.FieldInstanceCreateView.as_view(), name='field_instances_add'), + path('edit//', view=views.FieldInstanceUpdateView.as_view(), name='field_instances_update'), + path('delete//', view=views.FieldInstanceDeleteView.as_view(), name='field_instances_delete'), ] diff --git a/roundabout/field_instances/views.py b/roundabout/field_instances/views.py new file mode 100644 index 000000000..315c236e4 --- /dev/null +++ b/roundabout/field_instances/views.py @@ -0,0 +1,90 @@ +""" +# 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 django.shortcuts import render +from django.conf import settings +from django.urls import reverse, reverse_lazy +from django.http import HttpResponse +from django.views.generic import View, DetailView, ListView, RedirectView, UpdateView, CreateView, DeleteView, TemplateView, FormView +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin + + +from .requests import field_instance_sync_main +from .models import * +from .forms import * + +# Views to handle syncing requests +# -------------------------------- +class FieldInstanceSyncToHomeView(View): + + def get(self, request, *args, **kwargs): + # Get the FieldInstance object that is current + field_instance = FieldInstance.objects.filter(is_this_instance=True).first() + if not field_instance: + return HttpResponse('ERROR. This is not a Field Instance of RDB.') + + sync_code = field_instance_sync_main(request, field_instance) + print(sync_code) + + if sync_code == 200: + return HttpResponse("Code 200") + else: + return HttpResponse("API error") + + +# Basic CBVs to handle CRUD operations +# ----------------------------------- + +class FieldInstanceListView(LoginRequiredMixin, ListView): + model = FieldInstance + template_name = 'field_instances/field_instance_list.html' + context_object_name = 'field_instances' + + +class FieldInstanceCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView): + model = FieldInstance + form_class = FieldInstanceForm + template_name = 'field_instances/field_instance_form.html' + context_object_name = 'field_instance' + permission_required = 'field_instances.add_fieldinstance' + redirect_field_name = 'home' + + def get_success_url(self): + return reverse('field_instances:field_instances_home', ) + + +class FieldInstanceUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): + model = FieldInstance + form_class = FieldInstanceForm + template_name = 'field_instances/field_instance_form.html' + context_object_name = 'field_instance' + permission_required = 'field_instances.add_fieldinstance' + redirect_field_name = 'home' + + def get_success_url(self): + return reverse('field_instances:field_instances_home', ) + + +class FieldInstanceDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView): + model = FieldInstance + success_url = reverse_lazy('field_instances:field_instances_home') + permission_required = 'field_instances.delete_fieldinstance' + redirect_field_name = 'home' diff --git a/roundabout/inventory/api/serializers.py b/roundabout/inventory/api/serializers.py index 4a2595be4..c94b2c45b 100644 --- a/roundabout/inventory/api/serializers.py +++ b/roundabout/inventory/api/serializers.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 @@ -20,41 +20,86 @@ """ from rest_framework import serializers +from rest_flex_fields import FlexFieldsModelSerializer -from ..models import Inventory +from ..models import Inventory, Action, PhotoNote from roundabout.locations.api.serializers import LocationSerializer from roundabout.parts.api.serializers import PartSerializer +from roundabout.calibrations.api.serializers import CalibrationEventSerializer -class InventorySerializer(serializers.ModelSerializer): - location = LocationSerializer(read_only=True) - part = PartSerializer(read_only=True) +class PhotoNoteSerializer(FlexFieldsModelSerializer): + class Meta: + model = PhotoNote + fields = ['id', 'photo', 'inventory', 'action', 'user'] + + +class ActionSerializer(FlexFieldsModelSerializer): + class Meta: + model = Action + fields = [ + 'id', 'action_type', 'object_type', 'created_at', 'inventory', \ + 'location', 'deployment', 'inventory_deployment', 'deployment_type', \ + 'detail', 'user', 'build', 'parent', 'cruise', 'latitude', 'longitude', \ + 'depth', 'calibration_event', 'const_default_event', 'config_event', \ + 'config_default_event', 'photos', + ] + + expandable_fields = { + 'location': LocationSerializer, + 'photos': (PhotoNoteSerializer, {'many': True}), + } + + + +class InventorySerializer(FlexFieldsModelSerializer): custom_fields = serializers.SerializerMethodField('get_custom_fields') class Meta: model = Inventory - fields = ['id', 'serial_number', 'part', 'location', 'custom_fields' ] + fields = [ + 'id', 'serial_number', 'old_serial_number', 'part', 'location', 'revision', \ + 'parent', 'children', 'build', 'assembly_part', 'assigned_destination_root', 'created_at', \ + 'updated_at', 'detail', 'test_result', 'test_type', 'flag', 'time_at_sea', 'custom_fields', + 'calibration_events' + ] + + expandable_fields = { + 'location': LocationSerializer, + 'part': PartSerializer, + 'parent': 'roundabout.inventory.api.serializers.InventorySerializer', + 'children': ('roundabout.inventory.api.serializers.InventorySerializer', {'many': True}), + 'calibration_events': (CalibrationEventSerializer, {'many': True}), + } def get_custom_fields(self, obj): # Get this item's custom fields with most recent Values + custom_fields = None + if obj.fieldvalues.exists(): obj_custom_fields = obj.fieldvalues.filter(is_current=True).select_related('field') - else: - obj_custom_fields = None - # create initial empty dict - custom_fields = {} + # create initial empty dict + custom_fields = {} - if obj_custom_fields: for field in obj_custom_fields: custom_fields[field.field.field_name] = field.field_value + return custom_fields + else: + return custom_fields + def get_children(self, obj): + # Get this item's children + custom_fields = None + if obj.children.exists(): + custom_fields = [child.id for child in obj.children.all()] return custom_fields - @staticmethod - def setup_eager_loading(queryset): - """ Perform necessary prefetching of data. """ - queryset = queryset.select_related('location').select_related('part') - queryset = queryset.prefetch_related('fieldvalues') - return queryset +""" +# Need a "sub-serializer" to handle self refernce MPTT tree structures +class RecursiveField(serializers.Serializer): + def to_representation(self, value): + serializer = self.parent.parent.__class__(value, context=self.context) + return serializer.data +""" diff --git a/roundabout/inventory/api/views.py b/roundabout/inventory/api/views.py index 98e23ce32..d6c22db7d 100644 --- a/roundabout/inventory/api/views.py +++ b/roundabout/inventory/api/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 @@ -20,17 +20,38 @@ """ from rest_framework import generics, viewsets, filters -from ..models import Inventory -from .serializers import InventorySerializer +from rest_framework.permissions import IsAuthenticated +from ..models import Inventory, Action, PhotoNote +from .serializers import InventorySerializer, ActionSerializer, PhotoNoteSerializer + + +class ActionViewSet(viewsets.ModelViewSet): + serializer_class = ActionSerializer + permission_classes = (IsAuthenticated,) + queryset = Action.objects.all() class InventoryViewSet(viewsets.ModelViewSet): serializer_class = InventorySerializer - search_fields = ['serial_number'] - filter_backends = (filters.SearchFilter,) + permission_classes = (IsAuthenticated,) + queryset = Inventory.objects.all() + filterset_fields = ('serial_number',) + + +class PhotoNoteViewSet(viewsets.ModelViewSet): + serializer_class = PhotoNoteSerializer + permission_classes = (IsAuthenticated,) + queryset = PhotoNote.objects.all() + +""" +class InventoryFullTextViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = InventoryFullTextSerializer + #filter_backends = [DjangoFilterBackend] + filterset_fields = ['serial_number',] def get_queryset(self): queryset = Inventory.objects.all() # Set up eager loading to avoid N+1 selects queryset = self.get_serializer_class().setup_eager_loading(queryset) return queryset +""" diff --git a/roundabout/inventory/migrations/0048_auto_20200801_1854.py b/roundabout/inventory/migrations/0048_auto_20200801_1854.py new file mode 100644 index 000000000..63fc89587 --- /dev/null +++ b/roundabout/inventory/migrations/0048_auto_20200801_1854.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-08-01 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0047_auto_20200723_1620_squashed_0048_auto_20200727_1905'), + ] + + operations = [ + migrations.AlterField( + model_name='inventory', + name='test_result', + field=models.BooleanField(choices=[(None, '-'), (True, 'Pass'), (False, 'Fail')], null=True), + ), + ] diff --git a/roundabout/inventory/migrations/0050_merge_20200819_1340.py b/roundabout/inventory/migrations/0050_merge_20200819_1340.py new file mode 100644 index 000000000..893754bf9 --- /dev/null +++ b/roundabout/inventory/migrations/0050_merge_20200819_1340.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-08-19 13:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0048_auto_20200801_1854'), + ('inventory', '0049_auto_20200817_2123'), + ] + + operations = [ + ] diff --git a/roundabout/inventory/migrations/0053_merge_20200925_1748.py b/roundabout/inventory/migrations/0053_merge_20200925_1748.py new file mode 100644 index 000000000..9984ac3e6 --- /dev/null +++ b/roundabout/inventory/migrations/0053_merge_20200925_1748.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-09-25 17:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0052_auto_20200923_1919'), + ('inventory', '0050_merge_20200819_1340'), + ] + + operations = [ + ] diff --git a/roundabout/inventory/migrations/0054_merge_20200928_2051.py b/roundabout/inventory/migrations/0054_merge_20200928_2051.py new file mode 100644 index 000000000..964a7875a --- /dev/null +++ b/roundabout/inventory/migrations/0054_merge_20200928_2051.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-09-28 20:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0053_merge_20200925_1748'), + ('inventory', '0053_auto_20200928_1821'), + ] + + operations = [ + ] diff --git a/roundabout/inventory/models.py b/roundabout/inventory/models.py index 5fefabdf0..ddd88faad 100644 --- a/roundabout/inventory/models.py +++ b/roundabout/inventory/models.py @@ -84,13 +84,13 @@ class Inventory(MPTTModel): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) detail = models.TextField(blank=True) - test_result = models.NullBooleanField(blank=False, choices=TEST_RESULTS) + test_result = models.BooleanField(null=True, blank=False, choices=TEST_RESULTS) test_type = models.CharField(max_length=20, choices=TEST_TYPES, null=True, blank=True) flag = models.BooleanField(choices=FLAG_TYPES, blank=False, default=False) # Deprecated as of v1.5 _time_at_sea = models.DurationField(default=timedelta(minutes=0), null=True, blank=True) - tracker = FieldTracker(fields=['location', 'parent', 'build']) + #tracker = FieldTracker(fields=['location', 'build']) class MPTTMeta: order_insertion_by = ['serial_number'] diff --git a/roundabout/inventory/views.py b/roundabout/inventory/views.py index ffd569338..3266f0c71 100644 --- a/roundabout/inventory/views.py +++ b/roundabout/inventory/views.py @@ -525,7 +525,7 @@ def form_valid(self, form): if default_value: fieldvalue = FieldValue.objects.create(field=field, field_value=default_value.field_value, - inventory=self.object, is_current=True, user=default_value.user) + inventory=self.object, is_current=True, user=default_value.user) response = HttpResponseRedirect(self.get_success_url()) diff --git a/roundabout/locations/api/serializers.py b/roundabout/locations/api/serializers.py index 6a84564e0..cad3dc709 100644 --- a/roundabout/locations/api/serializers.py +++ b/roundabout/locations/api/serializers.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 @@ -20,10 +20,34 @@ """ from rest_framework import serializers +from rest_flex_fields import FlexFieldsModelSerializer from ..models import Location -class LocationSerializer(serializers.ModelSerializer): +class LocationSerializer(serializers.HyperlinkedModelSerializer, FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='api_v1:locations-detail', + lookup_field='pk', + ) + parent = serializers.HyperlinkedRelatedField( + view_name='api_v1:locations-detail', + lookup_field='pk', + queryset=Location.objects + ) + children = serializers.HyperlinkedRelatedField( + view_name='api_v1:locations-detail', + many=True, + read_only=True, + lookup_field='pk', + ) + class Meta: model = Location - fields = ('id', 'name', 'location_type', 'location_id', ) + fields = ['id', 'url', 'name', 'parent', 'children', 'weight', + 'location_type', 'location_id', 'root_type', 'created_at', + ] + + expandable_fields = { + 'parent': 'roundabout.locations.api.serializers.LocationSerializer', + 'children': ('roundabout.locations.api.serializers.LocationSerializer', {'many': True}) + } diff --git a/roundabout/locations/api/views.py b/roundabout/locations/api/views.py index aec9c81a4..376c1c9c8 100644 --- a/roundabout/locations/api/views.py +++ b/roundabout/locations/api/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 @@ -19,12 +19,14 @@ # If not, see . """ -from rest_framework import generics, viewsets, filters +from rest_framework import generics, filters, viewsets +from rest_framework.permissions import IsAuthenticated + from ..models import Location from .serializers import LocationSerializer class LocationViewSet(viewsets.ModelViewSet): - - queryset = Location.objects.all() serializer_class = LocationSerializer + permission_classes = (IsAuthenticated,) + queryset = Location.objects.all() diff --git a/roundabout/locations/migrations/0003_auto_20191023_1513.py b/roundabout/locations/migrations/0003_auto_20191023_1513.py index a20237ed9..3979ee166 100644 --- a/roundabout/locations/migrations/0003_auto_20191023_1513.py +++ b/roundabout/locations/migrations/0003_auto_20191023_1513.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 @@ -25,10 +25,18 @@ """ from django.db import migrations from django.apps import apps +from mptt import register, managers -Location = apps.get_model('locations', 'Location') def create_root_locations(apps, schema_editor): + Location = apps.get_model('locations', 'Location') + + manager = managers.TreeManager() + manager.model = Location + + register(Location) + manager.contribute_to_class(Location, 'objects') + land, created = Location.objects.get_or_create(name='Land') if created: land.root_type = 'Land' diff --git a/roundabout/locations/migrations/0004_auto_20200821_1809.py b/roundabout/locations/migrations/0004_auto_20200821_1809.py new file mode 100644 index 000000000..0c83f8dba --- /dev/null +++ b/roundabout/locations/migrations/0004_auto_20200821_1809.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.13 on 2020-08-21 18:09 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0003_auto_20191023_1513'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='location', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/roundabout/locations/models.py b/roundabout/locations/models.py index 30c986efb..25432e99d 100644 --- a/roundabout/locations/models.py +++ b/roundabout/locations/models.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 @@ -20,6 +20,7 @@ """ from django.db import models +from django.utils import timezone from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey @@ -47,6 +48,8 @@ class Location(MPTTModel): location_id = models.CharField(max_length=100, blank=True) weight = models.IntegerField(default=0, blank=True, null=True) root_type = models.CharField(max_length=20, choices=ROOT_TYPES, blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) class MPTTMeta: order_insertion_by = ['weight', 'name'] diff --git a/roundabout/parts/api/serializers.py b/roundabout/parts/api/serializers.py index 745cfa3e8..a1ed6c2c2 100644 --- a/roundabout/parts/api/serializers.py +++ b/roundabout/parts/api/serializers.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 @@ -20,10 +20,146 @@ """ from rest_framework import serializers -from ..models import Part, PartType +from rest_flex_fields import FlexFieldsModelSerializer +from ..models import Part, PartType, Revision, Documentation +API_VERSION = 'api_v1' + +class PartTypeSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':part-templates/part-types-detail', + lookup_field = 'pk', + ) + parent = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/part-types-detail', + lookup_field = 'pk', + queryset = PartType.objects + ) + children = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/part-types-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) + parts = serializers.HyperlinkedRelatedField( + view_name = API_VERSION +':part-templates/parts-detail', + many = True, + read_only = True, + lookup_field = 'pk', + ) + + class Meta: + model = PartType + fields = ['id', 'url', 'name', 'parent', 'children', 'parts',] + + expandable_fields = { + 'parent': 'roundabout.parts.api.serializers.PartTypeSerializer', + 'children': ('roundabout.parts.api.serializers.PartTypeSerializer', {'many': True}), + 'parts': ('roundabout.userdefinedfields.api.serializers', {'many': True}) + } + + +class PartSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':part-templates/parts-detail', + lookup_field = 'pk', + ) + part_type = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/part-types-detail', + lookup_field = 'pk', + queryset = PartType.objects + ) + revisions= serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/revisions-detail', + lookup_field = 'pk', + many = True, + read_only = True, + ) + user_defined_fields= serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':user-defined-fields/fields-detail', + lookup_field = 'pk', + many = True, + read_only = True, + ) -class PartSerializer(serializers.ModelSerializer): class Meta: model = Part - fields = ('id', 'name', 'friendly_name', 'part_number', 'unit_cost', 'refurbishment_cost' ) + fields = [ + 'id', + 'url', + 'name', + 'revisions', + 'part_type', + 'friendly_name', + 'part_number', + 'unit_cost', + 'refurbishment_cost', + 'note', + 'user_defined_fields', + 'cal_dec_places', + ] + + expandable_fields = { + 'part_type': 'roundabout.parts.api.serializers.PartTypeSerializer', + 'revisions': ('roundabout.parts.api.serializers.RevisionSerializer', {'many': True}), + 'user_defined_fields': ('roundabout.userdefinedfields.api.serializers.FieldSerializer', {'many': True}), + } + + +class RevisionSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':part-templates/revisions-detail', + lookup_field = 'pk', + ) + part = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/parts-detail', + lookup_field = 'pk', + queryset = PartType.objects + ) + documentation= serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/documents-detail', + lookup_field = 'pk', + many = True, + read_only = True, + ) + + class Meta: + model = Revision + fields = [ + 'id', + 'url', + 'revision_code', + 'part', + 'unit_cost', + 'refurbishment_cost', + 'note', + 'created_at', + 'documentation', + ] + + expandable_fields = { + 'part': 'roundabout.parts.api.serializers.PartSerializer', + 'documentation': ('roundabout.parts.api.serializers.DocumentationSerializer', {'many': True}) + } + + +class DocumentationSerializer(FlexFieldsModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name = API_VERSION + ':part-templates/documents-detail', + lookup_field = 'pk', + ) + revision = serializers.HyperlinkedRelatedField( + view_name = API_VERSION + ':part-templates/revisions-detail', + lookup_field = 'pk', + queryset = Revision.objects + ) + + class Meta: + model = Documentation + fields = [ + 'id', 'url', 'name', 'doc_type', 'doc_link', 'revision', + ] + + expandable_fields = { + 'revision': 'roundabout.parts.api.serializers.RevisionSerializer', + } diff --git a/roundabout/parts/api/views.py b/roundabout/parts/api/views.py index 11f911cea..916cd41ce 100644 --- a/roundabout/parts/api/views.py +++ b/roundabout/parts/api/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 @@ -20,13 +20,30 @@ """ from rest_framework import generics, viewsets, filters -from ..models import Part -from .serializers import PartSerializer +from rest_framework.permissions import IsAuthenticated +from ..models import Part, PartType, Revision, Documentation +from .serializers import PartSerializer, PartTypeSerializer, RevisionSerializer, DocumentationSerializer -class PartViewSet(viewsets.ModelViewSet): +class PartTypeViewSet(viewsets.ModelViewSet): + serializer_class = PartTypeSerializer + permission_classes = (IsAuthenticated,) + queryset = PartType.objects.all() - queryset = Part.objects.all() + +class PartViewSet(viewsets.ModelViewSet): serializer_class = PartSerializer - search_fields = ['part_number'] - filter_backends = (filters.SearchFilter,) + permission_classes = (IsAuthenticated,) + queryset = Part.objects.all() + + +class RevisionViewSet(viewsets.ModelViewSet): + serializer_class = RevisionSerializer + permission_classes = (IsAuthenticated,) + queryset = Revision.objects.all() + + +class DocumentationViewSet(viewsets.ModelViewSet): + serializer_class = DocumentationSerializer + permission_classes = (IsAuthenticated,) + queryset = Documentation.objects.all() diff --git a/roundabout/parts/forms.py b/roundabout/parts/forms.py index 92a4c8b20..b278757df 100644 --- a/roundabout/parts/forms.py +++ b/roundabout/parts/forms.py @@ -178,7 +178,8 @@ class PartTypeForm(forms.ModelForm): class Meta: model = PartType - fields = ['name', 'parent' ] + fields = ['name', 'parent', 'ccc_toggle' ] labels = { - 'name': 'Part Type Name' - } + 'name': 'Part Type Name', + 'ccc_toggle': 'Enable Configs, Constants, and Calibration Coefficients' + } diff --git a/roundabout/parts/migrations/0004_auto_20191023_1735.py b/roundabout/parts/migrations/0004_auto_20191023_1735.py index be301f017..0fd623500 100644 --- a/roundabout/parts/migrations/0004_auto_20191023_1735.py +++ b/roundabout/parts/migrations/0004_auto_20191023_1735.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 @@ -25,14 +25,23 @@ """ from django.db import migrations from django.apps import apps +from mptt import register, managers -PartType = apps.get_model('parts', 'PartType') def create_part_types(apps, schema_editor): + PartType = apps.get_model('parts', 'PartType') part_types = ['Cable', 'Electrical', 'Instrument', 'Mechanical'] + manager = managers.TreeManager() + manager.model = PartType + + register(PartType) + manager.contribute_to_class(PartType, 'objects') + for part_type in part_types: - obj, created = PartType.objects.get_or_create(name=part_type) + obj = PartType.objects.create( + name=part_type, + ) class Migration(migrations.Migration): diff --git a/roundabout/parts/migrations/0009_parttype_ccc_toggle.py b/roundabout/parts/migrations/0009_parttype_ccc_toggle.py new file mode 100644 index 000000000..e5b923a45 --- /dev/null +++ b/roundabout/parts/migrations/0009_parttype_ccc_toggle.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-09-30 13:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0008_merge_20200617_1450'), + ] + + operations = [ + migrations.AddField( + model_name='parttype', + name='ccc_toggle', + field=models.BooleanField(default=False), + ), + ] diff --git a/roundabout/parts/migrations/0010_merge_20201015_1353.py b/roundabout/parts/migrations/0010_merge_20201015_1353.py new file mode 100644 index 000000000..4dc8d5ce5 --- /dev/null +++ b/roundabout/parts/migrations/0010_merge_20201015_1353.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-10-15 13:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0009_auto_20201014_2007'), + ('parts', '0009_parttype_ccc_toggle'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0010_merge_20201015_2202.py b/roundabout/parts/migrations/0010_merge_20201015_2202.py new file mode 100644 index 000000000..e9a877e1f --- /dev/null +++ b/roundabout/parts/migrations/0010_merge_20201015_2202.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-10-15 22:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0009_auto_20201014_2007'), + ('parts', '0009_parttype_ccc_toggle'), + ] + + operations = [ + ] diff --git a/roundabout/parts/migrations/0011_merge_20201105_1645.py b/roundabout/parts/migrations/0011_merge_20201105_1645.py new file mode 100644 index 000000000..1402ad4a3 --- /dev/null +++ b/roundabout/parts/migrations/0011_merge_20201105_1645.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.13 on 2020-11-05 16:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('parts', '0010_merge_20201015_1353'), + ('parts', '0010_merge_20201015_2202'), + ] + + operations = [ + ] diff --git a/roundabout/parts/models.py b/roundabout/parts/models.py index fdb480264..21ae2c406 100644 --- a/roundabout/parts/models.py +++ b/roundabout/parts/models.py @@ -34,6 +34,7 @@ class PartType(MPTTModel): name = models.CharField(max_length=255, unique=False) + ccc_toggle = models.BooleanField(null=False, default=False) parent = TreeForeignKey('self', null=True, blank=True, related_name='children', on_delete=models.SET_NULL) class MPTTMeta: diff --git a/roundabout/parts/urls.py b/roundabout/parts/urls.py index 4509b52f9..49203358d 100644 --- a/roundabout/parts/urls.py +++ b/roundabout/parts/urls.py @@ -53,4 +53,5 @@ path('part_type/add/', view=views.PartsTypeCreateView.as_view(), name='parts_type_add'), path('part_type/edit//', view=views.PartsTypeUpdateView.as_view(), name='parts_type_update'), path('part_type/delete//', view=views.PartsTypeDeleteView.as_view(), name='parts_type_delete'), + path('part_type/check_ccc_enabled/', views.check_ccc_enabled, name='check_ccc_enabled'), ] diff --git a/roundabout/parts/views.py b/roundabout/parts/views.py index 6b507135c..909ae811c 100644 --- a/roundabout/parts/views.py +++ b/roundabout/parts/views.py @@ -91,6 +91,18 @@ def validate_part_number(request): return JsonResponse(data) +# Function to check if CCC Names are enabled for Part Type +def check_ccc_enabled(request): + part_type_id = request.GET.get('part_type_id') + part_type = PartType.objects.get(id=part_type_id) + + + data = { + 'ccc_toggle': part_type.ccc_toggle, + } + return JsonResponse(data) + + # Part Template Views def load_parts_navtree(request): diff --git a/roundabout/templates/calibrations/cal_name_detail.html b/roundabout/templates/calibrations/cal_name_detail.html index 35ab60f05..b2236430e 100644 --- a/roundabout/templates/calibrations/cal_name_detail.html +++ b/roundabout/templates/calibrations/cal_name_detail.html @@ -132,6 +132,9 @@

Calibrations

Expand/Collapse Default + {% if name.deprecated %} + Deprecated + {% endif %}
@@ -212,8 +215,8 @@

Calibrations

// Toggle display of Constant functionality let user_id = '{{ user.id }}' - let inv_part_type = '{{ part_template.part_type }}'; - if (inv_part_type == 'Instrument') { + let ccc_toggle = '{{ part_template.part_type.ccc_toggle }}'; + if (ccc_toggle == 'True') { $('#calibrations-section').show(); } else { $('#calibrations-section').hide(); diff --git a/roundabout/templates/calibrations/events_detail.html b/roundabout/templates/calibrations/events_detail.html index f77278f02..92c897740 100644 --- a/roundabout/templates/calibrations/events_detail.html +++ b/roundabout/templates/calibrations/events_detail.html @@ -22,7 +22,7 @@ + {% if name.deprecated %} + Deprecated + {% endif %}
Name Type Significant 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 }}
@@ -212,8 +215,8 @@

Configurations / Constants

// // Toggle display of Constant functionality let user_id = '{{ user.id }}' - let inv_part_type = '{{ part_template.part_type }}'; - if (inv_part_type == 'Instrument' || inv_part_type == 'Electrical') { + let ccc_toggle = '{{ part_template.part_type.ccc_toggle }}'; + if (ccc_toggle == 'True') { $('#configuration_constant-section').show(); } else { $('#configuration_constant-section').hide(); diff --git a/roundabout/templates/configs_constants/configs_detail.html b/roundabout/templates/configs_constants/configs_detail.html index 64cc3c1f5..9b7ec896e 100644 --- a/roundabout/templates/configs_constants/configs_detail.html +++ b/roundabout/templates/configs_constants/configs_detail.html @@ -215,16 +215,6 @@ let user_id = '{{ user.id }}'; let part_has_configs = '{{ part_has_configs }}'; - // if (inv_part_type == 'Instrument' && deployment != 'None' && deployment.length > 0 && part_has_configs == 'True') { - // $('#add_conf_action').show(); - // $('#conf-template-tab').show().css('display', ''); - // $('#conf-template').show().css('display', ''); - // } else { - // $('#add_conf_action').hide(); - // $('#conf-template-tab').hide(); - // $('#conf-template').hide(); - // } - $('#conf-template').on('click', 'button[id^=expander]', function(e) { e.preventDefault(); let url = $(this).attr('data-reviewer-url'); diff --git a/roundabout/templates/configs_constants/const_default_detail.html b/roundabout/templates/configs_constants/const_default_detail.html index f21def78a..749876d1f 100644 --- a/roundabout/templates/configs_constants/const_default_detail.html +++ b/roundabout/templates/configs_constants/const_default_detail.html @@ -208,15 +208,6 @@ let user_id = '{{ user.id }}' let inv_part_type = '{{ inventory_item.part.part_type }}'; let part_has_consts = '{{ part_has_consts }}'; - // if (inv_part_type == 'Instrument' && part_has_consts == 'True') { - // $('#add_constdefault_action').show(); - // $('#const_default-template-tab').show().css('display', ''); - // $('#const_default-template').show().css('display', ''); - // } else { - // $('#add_constdefault_action').hide(); - // $('#const_default-template-tab').hide(); - // $('#const_default-template').hide(); - // } // Swap Reviewers as Approvers $('#const_default-template').on('click', 'button[id^=expander]', function(e) { diff --git a/roundabout/templates/configs_constants/constants_detail.html b/roundabout/templates/configs_constants/constants_detail.html index 8baca6e41..2e066016c 100644 --- a/roundabout/templates/configs_constants/constants_detail.html +++ b/roundabout/templates/configs_constants/constants_detail.html @@ -215,16 +215,6 @@ let user_id = '{{ user.id }}'; let part_has_consts = '{{ part_has_consts }}'; - // if (inv_part_type == 'Instrument' && deployment != 'None' && deployment.length > 0 && part_has_consts == 'True') { - // $('#add_const_action').show(); - // $('#const-template-tab').show().css('display', ''); - // $('#const-template').show().css('display', ''); - // } else { - // $('#add_const_action').hide(); - // $('#const-template-tab').hide(); - // $('#const-template').hide(); - // } - $('#const-template').on('click', 'button[id^=expander]', function(e) { e.preventDefault(); let url = $(this).attr('data-reviewer-url'); diff --git a/roundabout/templates/configs_constants/part_confname_form.html b/roundabout/templates/configs_constants/part_confname_form.html index bd4e8750a..e2b4305f5 100644 --- a/roundabout/templates/configs_constants/part_confname_form.html +++ b/roundabout/templates/configs_constants/part_confname_form.html @@ -19,13 +19,28 @@ # If not, see . --> {% load static i18n %} @@ -69,6 +84,7 @@
Configurations/Constants
+ {{ part_confname_form.management_form }} @@ -77,7 +93,7 @@
Configurations/Constants
- + {% endfor %} diff --git a/roundabout/templates/field_instances/field_instance_form.html b/roundabout/templates/field_instances/field_instance_form.html new file mode 100644 index 000000000..840ebfd98 --- /dev/null +++ b/roundabout/templates/field_instances/field_instance_form.html @@ -0,0 +1,48 @@ + + +{% extends "base.html" %} +{% load crispy_forms_tags %} + +{% block title %}Field Instance - {{ field_instance.name }}{% endblock %} + +{% block content %} +
+

{% if field_instance.name %}Edit {{ field_instance.name }}{% else %}Add Field Instance {% endif %}

+
+ {% csrf_token %} + {{ form|crispy }} + + +
+
+ {% if field_instance %} + + {% else %} + + {% endif %} + + Cancel +
+
+ +
+{% endblock %} diff --git a/roundabout/templates/field_instances/field_instance_list.html b/roundabout/templates/field_instances/field_instance_list.html new file mode 100644 index 000000000..a8d3b3930 --- /dev/null +++ b/roundabout/templates/field_instances/field_instance_list.html @@ -0,0 +1,72 @@ + + +{% extends "base.html" %} +{% load static i18n %} +{% block title %}Field Instances{% endblock %} + +{% block content %} +
+ + +

Field Instances

+ +

All currently registered Field Instances. You need to create a Field Instance on the "Home Base" RDB site + before registering a new computer for deployment

+ +
Name Type Export with CalibrationsDeprecated
{% if conf.id %}{{ conf.DELETE }}{% endif %} {{ conf.id }} {{ conf.name }} {% if conf.id %}{{ conf.DELETE }}{% endif %} {{ conf.id }} {{ conf.config_type }} {% if conf.id %}{{ conf.DELETE }}{% endif %} {{ conf.id }} {{ conf.include_with_calibrations }}{% if conf.id %}{{ conf.DELETE }}{% endif %} {{ conf.id }} {{ conf.deprecated }}
+ + + + + + + + + + + + {% for field_instance in field_instances %} + + + + + + + + + + {% endfor %} + +
NameStart DateEnd DateUsersCruiseNotes 
{{ field_instance.name }}{{ field_instance.start_date }}{{ field_instance.end_date }} +
    + {% for user in field_instance.users.all %} +
  • {{ user.name }}
  • + {% endfor %} +
+
{{ field_instance.cruise }}{{ field_instance.notes }} + Edit + Delete +
+
+{% endblock content %} diff --git a/roundabout/templates/inventory/ajax_inventory_detail.html b/roundabout/templates/inventory/ajax_inventory_detail.html index afbe21e6a..3dfa27863 100644 --- a/roundabout/templates/inventory/ajax_inventory_detail.html +++ b/roundabout/templates/inventory/ajax_inventory_detail.html @@ -154,7 +154,7 @@ data-node-type="{{ node_type }}">Edit Inventory Details {% if not user|has_group:"inventory only" %} - {% if inventory_item.part.coefficient_name_events.exists %} + {% if inventory_item.part.coefficient_name_events.exists and inventory_item.part.part_type.ccc_toggle %} Add Calibration Coefficients {% endif %} - {% if not inventory_item.constant_default_events.exists and part_has_consts %} + {% if not inventory_item.constant_default_events.exists and part_has_consts and inventory_item.part.part_type.ccc_toggle %} Add Constant Defaults {% endif %} - {% if part_has_consts and inventory_item.build.current_deployment %} + {% if part_has_consts and inventory_item.build.current_deployment and inventory_item.part.part_type.ccc_toggle %} Add Constants {% endif %} - {% if part_has_configs and inventory_item.build.current_deployment %} + {% if part_has_configs and inventory_item.build.current_deployment and inventory_item.part.part_type.ccc_toggle %} Part Specs - {% if inventory_item.calibration_events.exists %} + {% if inventory_item.calibration_events.exists and inventory_item.part.part_type.ccc_toggle %} {% endif %} - {% if inventory_item.constant_default_events.exists %} + {% if inventory_item.constant_default_events.exists and inventory_item.part.part_type.ccc_toggle %} {% endif %} - {% if inventory_item.config_events.exists %} + {% if inventory_item.config_events.exists and inventory_item.part.part_type.ccc_toggle %} diff --git a/roundabout/templates/parts/ajax_part_detail.html b/roundabout/templates/parts/ajax_part_detail.html index ed48e0088..498b5608a 100644 --- a/roundabout/templates/parts/ajax_part_detail.html +++ b/roundabout/templates/parts/ajax_part_detail.html @@ -288,16 +288,12 @@

Custom Fields

}); }); - let partType = '{{part_template.part_type}}'; - if (partType == 'Instrument') { + let ccc_toggle = '{{part_template.part_type.ccc_toggle}}'; + if (ccc_toggle == 'True') { $('#calibrations-section').show(); - } else { - $('#calibrations-section').hide(); - } - - if (partType == 'Instrument' || partType == 'Electrical') { $('#configuration_constant-section').show(); } else { + $('#calibrations-section').hide(); $('#configuration_constant-section').hide(); } diff --git a/roundabout/templates/parts/ajax_part_form.html b/roundabout/templates/parts/ajax_part_form.html index a2558e11f..667a1d44b 100644 --- a/roundabout/templates/parts/ajax_part_form.html +++ b/roundabout/templates/parts/ajax_part_form.html @@ -214,8 +214,8 @@
Revision Documentation
}) - const part_type = '{{part_template.part_type}}' - if (part_type == 'Instrument') { + const ccc_toggle = '{{part_template.part_type.ccc_toggle}}' + if (ccc_toggle == 'True') { $('#div_id_cal_dec_places').show(); } else { $('#div_id_cal_dec_places').hide(); @@ -223,13 +223,22 @@
Revision Documentation
// On-change of dropdown selection, obtain part type name, show/hide max-cal attribute $('#id_part_type').change(function() { - let part_type = $(this).find('option:selected').text(); - if (part_type == ' Instrument') { - $('#div_id_cal_dec_places').show(); - } else { - $('#div_id_cal_dec_places').hide(); - } - }) + let part_type_id = $(this).find('option:selected').attr('value'); + let url = "{% url 'parts:check_ccc_enabled' %}" + $.ajax({ + url: url, + data: { + "part_type_id": part_type_id, + }, + success: function (data) { + if ( data.ccc_toggle ) { + $('#div_id_cal_dec_places').show(); + } else { + $('#div_id_cal_dec_places').hide(); + } + } + }); + }); }); diff --git a/roundabout/templates/parts/part_type_list.html b/roundabout/templates/parts/part_type_list.html index 6389efd5a..e35a83aee 100644 --- a/roundabout/templates/parts/part_type_list.html +++ b/roundabout/templates/parts/part_type_list.html @@ -36,6 +36,7 @@

Part Types

+ @@ -47,6 +48,7 @@

Part Types

{% else %} {% endif %} +
Name/descriptionConfigs, Constants, and Calibrations Enabled  
{{ part_type.name }}{{ part_type.ccc_toggle }} Edit Delete diff --git a/roundabout/userdefinedfields/api/serializers.py b/roundabout/userdefinedfields/api/serializers.py new file mode 100644 index 000000000..f70d76cf6 --- /dev/null +++ b/roundabout/userdefinedfields/api/serializers.py @@ -0,0 +1,45 @@ +""" +# 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 rest_framework import serializers +from rest_flex_fields import FlexFieldsModelSerializer + +from ..models import * +from roundabout.parts.api.serializers import PartSerializer +from roundabout.inventory.api.serializers import InventorySerializer + + +class FieldSerializer(FlexFieldsModelSerializer): + class Meta: + model = Field + fields = '__all__' + + +class FieldValueSerializer(FlexFieldsModelSerializer): + class Meta: + model = FieldValue + fields = '__all__' + + expandable_fields = { + 'field': FieldSerializer, + 'part': PartSerializer, + 'inventory': (InventorySerializer, {'many': True}) + } diff --git a/roundabout/assemblies/api/urls.py b/roundabout/userdefinedfields/api/views.py similarity index 61% rename from roundabout/assemblies/api/urls.py rename to roundabout/userdefinedfields/api/views.py index 149dbd28a..5ac140f3d 100644 --- a/roundabout/assemblies/api/urls.py +++ b/roundabout/userdefinedfields/api/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 @@ -19,14 +19,20 @@ # If not, see . """ -from django.urls import path, include -from rest_framework.routers import DefaultRouter, SimpleRouter -from .views import AssemblyViewSet +from rest_framework import generics, filters, viewsets +from rest_framework.permissions import IsAuthenticated -# Create a router and register our viewsets with it. -router = SimpleRouter() -router.register(r'assemblies', AssemblyViewSet, 'assemblies' ) +from ..models import * +from .serializers import FieldSerializer, FieldValueSerializer -urlpatterns = [ - path('', include(router.urls) ), -] + +class FieldViewSet(viewsets.ModelViewSet): + queryset = Field.objects.all() + serializer_class = FieldSerializer + permission_classes = (IsAuthenticated,) + + +class FieldValueViewSet(viewsets.ModelViewSet): + queryset = FieldValue.objects.all() + serializer_class = FieldValueSerializer + permission_classes = (IsAuthenticated,) diff --git a/roundabout/userdefinedfields/migrations/0006_auto_20200820_1325.py b/roundabout/userdefinedfields/migrations/0006_auto_20200820_1325.py new file mode 100644 index 000000000..ecb4ed8d5 --- /dev/null +++ b/roundabout/userdefinedfields/migrations/0006_auto_20200820_1325.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.13 on 2020-08-20 13:25 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('userdefinedfields', '0005_auto_20200517_1814_squashed_0006_auto_20200517_1822'), + ] + + operations = [ + migrations.AddField( + model_name='field', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='field', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/roundabout/userdefinedfields/migrations/0007_fieldvalue_updated_at.py b/roundabout/userdefinedfields/migrations/0007_fieldvalue_updated_at.py new file mode 100644 index 000000000..327ef49e4 --- /dev/null +++ b/roundabout/userdefinedfields/migrations/0007_fieldvalue_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-08-20 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('userdefinedfields', '0006_auto_20200820_1325'), + ] + + operations = [ + migrations.AddField( + model_name='fieldvalue', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/roundabout/userdefinedfields/models.py b/roundabout/userdefinedfields/models.py index 48267add3..d62b0b967 100644 --- a/roundabout/userdefinedfields/models.py +++ b/roundabout/userdefinedfields/models.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 dateutil import parser from django.db import models +from django.utils import timezone from django.contrib.postgres.fields import JSONField # Create your models here. @@ -40,6 +41,8 @@ class Field(models.Model): field_default_value = models.CharField(max_length=255, null=False, blank=True) choice_field_options = JSONField(null=True, blank=True) global_for_part_types = models.ManyToManyField('parts.PartType', blank=True, related_name='custom_fields') + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ('field_name',) @@ -57,6 +60,7 @@ class FieldValue(models.Model): part = models.ForeignKey('parts.Part', related_name='fieldvalues', on_delete=models.CASCADE, null=True) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) user = models.ForeignKey('users.User', related_name='fieldvalues', on_delete=models.SET_NULL, null=True, blank=False) is_current = models.BooleanField(default=False) diff --git a/roundabout/users/migrations/0002_auto_20191022_1510.py b/roundabout/users/migrations/0002_auto_20191022_1510.py index ba0519bc6..50bcb53b0 100644 --- a/roundabout/users/migrations/0002_auto_20191022_1510.py +++ b/roundabout/users/migrations/0002_auto_20191022_1510.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 @@ -26,21 +26,27 @@ from django.apps import apps from django.contrib.auth import get_user_model from django.contrib.auth.management import create_permissions +from django.contrib.auth.hashers import make_password -User = get_user_model() -Group = apps.get_model('auth','Group') env = environ.Env() def generate_superuser(apps, schema_editor): + User = apps.get_model('users', 'User') + Group = apps.get_model('auth','Group') + DJANGO_SU_NAME = env('DJANGO_SU_NAME') DJANGO_SU_EMAIL = env('DJANGO_SU_EMAIL') - DJANGO_SU_PASSWORD = env('DJANGO_SU_PASSWORD') + DJANGO_SU_PASSWORD = make_password(env('DJANGO_SU_PASSWORD')) - superuser = User.objects.create_superuser( + superuser = User.objects.create( username=DJANGO_SU_NAME, email=DJANGO_SU_EMAIL, - password=DJANGO_SU_PASSWORD) + password=DJANGO_SU_PASSWORD, + is_superuser=True, + is_staff=True, + is_active=True, + ) superuser.save() diff --git a/roundabout/users/migrations/0004_user_is_infield.py b/roundabout/users/migrations/0004_user_is_infield.py new file mode 100644 index 000000000..dc5358ce8 --- /dev/null +++ b/roundabout/users/migrations/0004_user_is_infield.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-07-31 19:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_auto_20191023_1506'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_infield', + field=models.BooleanField(default=False), + ), + ] diff --git a/roundabout/users/migrations/0005_auto_20200819_1355.py b/roundabout/users/migrations/0005_auto_20200819_1355.py new file mode 100644 index 000000000..6d70672f5 --- /dev/null +++ b/roundabout/users/migrations/0005_auto_20200819_1355.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.13 on 2020-08-19 13:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_user_is_infield'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ['username']}, + ), + ] diff --git a/roundabout/users/models.py b/roundabout/users/models.py index ae9caff0a..361aa1f63 100644 --- a/roundabout/users/models.py +++ b/roundabout/users/models.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 @@ -18,18 +18,20 @@ # along with ooicgsn-roundabout in the COPYING.md file at the project root. # If not, see . """ - +from django.db import models from django.contrib.auth.models import AbstractUser -from django.db.models import CharField from django.urls import reverse from django.utils.translation import ugettext_lazy as _ class User(AbstractUser): - # First Name and Last Name do not cover name patterns # around the globe. - name = CharField(_("Name of User"), blank=True, max_length=255) + name = models.CharField(_("Name of User"), blank=True, max_length=255) + is_infield = models.BooleanField(default=False) + + class Meta: + ordering = ['username'] def __str__(self): return self.username