diff --git a/Dockerfile b/Dockerfile index 31930def2..e71836745 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,17 @@ RUN mkdir /nemo WORKDIR /nemo ENV DJANGO_SETTINGS_MODULE "settings" ENV PYTHONPATH "/nemo/" + +# Gunicorn config options +ENV GUNICORN_WORKER_CLASS "" +ENV GUNICORN_WORKER_COUNT "" +ENV GUNICORN_THREAD_COUNT "" +ENV GUNICORN_KEEPALIVE_SECONDS "" +ENV GUNICORN_CAPTURE_OUTPUT "" + +# NEMO extra python packages +ENV NEMO_EXTRA_PIP_PACKAGES "" + COPY gunicorn_configuration.py /etc/ EXPOSE 8000/tcp diff --git a/NEMO/actions.py b/NEMO/actions.py index 16064edc2..177b97238 100644 --- a/NEMO/actions.py +++ b/NEMO/actions.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.utils.safestring import mark_safe +from NEMO.mixins import BillableItemMixin from NEMO.models import Area, Configuration, Interlock, InterlockCard, Tool, User from NEMO.typing import QuerySetType from NEMO.utilities import new_model_copy @@ -176,3 +177,10 @@ def duplicate_configuration(model_admin, request, queryset: QuerySetType[Configu messages.error( request, f"{original_name} could not be duplicated because of the following error: {str(error)}" ) + + +@admin.action(description="Waive selected charges") +def waive_selected_charges(model_admin, request, queryset: QuerySetType[BillableItemMixin]): + for charge in queryset: + charge.waive(request.user) + messages.success(request, f"{model_admin.model.__name__} #{charge.id} has been successfully waived") diff --git a/NEMO/admin.py b/NEMO/admin.py index 41f676275..74a5e619d 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -29,6 +29,7 @@ rebuild_area_tree, synchronize_with_tool_usage, unlock_selected_interlocks, + waive_selected_charges, ) from NEMO.forms import BuddyRequestForm, RecurringConsumableChargeForm, UserPreferencesForm from NEMO.mixins import ModelAdminRedirectMixin, ObjPermissionAdminMixin @@ -475,7 +476,18 @@ def save_model(self, request, obj: Area, form, change): @register(TrainingSession) class TrainingSessionAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.ModelAdmin): - list_display = ("id", "trainer", "trainee", "tool", "project", "type", "date", "duration", "qualified") + list_display = ( + "id", + "trainer", + "trainee", + "tool", + "project", + "type", + "date", + "duration", + "qualified", + "usage_event", + ) list_filter = ( "qualified", "date", @@ -486,7 +498,8 @@ class TrainingSessionAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, adm ("trainee", admin.RelatedOnlyFieldListFilter), ) date_hierarchy = "date" - autocomplete_fields = ["trainer", "trainee", "tool", "project", "validated_by"] + autocomplete_fields = ["trainer", "trainee", "tool", "project", "validated_by", "waived_by"] + actions = [waive_selected_charges] def formfield_for_foreignkey(self, db_field, request, **kwargs): """We only want staff user and tool superusers to be possible trainers""" @@ -504,7 +517,8 @@ class StaffChargeAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.M ("staff_member", admin.RelatedOnlyFieldListFilter), ) date_hierarchy = "start" - autocomplete_fields = ["staff_member", "customer", "project", "validated_by"] + autocomplete_fields = ["staff_member", "customer", "project", "validated_by", "waived_by"] + actions = [waive_selected_charges] @register(AreaAccessRecord) @@ -512,7 +526,8 @@ class AreaAccessRecordAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, ad list_display = ("id", "customer", "area", "project", "start", "end") list_filter = (("area", TreeRelatedFieldListFilter), "start") date_hierarchy = "start" - autocomplete_fields = ["customer", "project", "validated_by"] + autocomplete_fields = ["customer", "project", "validated_by", "waived_by"] + actions = [waive_selected_charges] @register(Configuration) @@ -590,10 +605,15 @@ class ProjectDocumentsInline(DocumentModelAdmin): @register(Project) class ProjectAdmin(admin.ModelAdmin): - list_display = ("name", "id", "get_application_identifier", "account", "active", "start_date") + list_display = ("name", "id", "get_application_identifier", "account", "active", "get_managers", "start_date") filter_horizontal = ("only_allow_tools",) search_fields = ("name", "application_identifier", "account__name") - list_filter = ("active", ("account", admin.RelatedOnlyFieldListFilter), "start_date") + list_filter = ( + "active", + ("account", admin.RelatedOnlyFieldListFilter), + "start_date", + ("manager_set", admin.RelatedOnlyFieldListFilter), + ) inlines = [ProjectDocumentsInline] form = ProjectAdminForm autocomplete_fields = ["account"] @@ -602,6 +622,10 @@ class ProjectAdmin(admin.ModelAdmin): def get_application_identifier(self, project: Project): return project.application_identifier + @display(description="PIs", ordering="manager_set") + def get_managers(self, project: Project): + return mark_safe("
".join([pi.get_name() for pi in project.manager_set.all()])) + def save_model(self, request, obj, form, change): """ Audit project creation and modification. Also save any project membership changes explicitly. @@ -667,7 +691,8 @@ class ReservationAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.M ) date_hierarchy = "start" inlines = [ConfigurationOptionInline] - autocomplete_fields = ["user", "creator", "tool", "project", "cancelled_by", "validated_by"] + autocomplete_fields = ["user", "creator", "tool", "project", "cancelled_by", "validated_by", "waived_by"] + actions = [waive_selected_charges] class ReservationQuestionsForm(forms.ModelForm): @@ -718,11 +743,14 @@ class ReservationQuestionsAdmin(admin.ModelAdmin): form = ReservationQuestionsForm filter_horizontal = ("only_for_tools", "only_for_areas", "only_for_projects") readonly_fields = ("questions_preview",) + list_filter = ["enabled", "tool_reservations", "area_reservations"] + list_display = ["name", "enabled", "tool_reservations", "area_reservations"] fieldsets = ( ( None, { "fields": ( + "enabled", "name", "questions", "questions_preview", @@ -743,9 +771,10 @@ def questions_preview(self, obj): @register(UsageEvent) class UsageEventAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.ModelAdmin): list_display = ("id", "tool", "user", "operator", "project", "start", "end", "duration", "remote_work") - list_filter = ("remote_work", "start", "end", ("tool", admin.RelatedOnlyFieldListFilter)) + list_filter = ("remote_work", "training", "start", "end", ("tool", admin.RelatedOnlyFieldListFilter)) date_hierarchy = "start" - autocomplete_fields = ["tool", "user", "operator", "project", "validated_by"] + autocomplete_fields = ["tool", "user", "operator", "project", "validated_by", "waived_by"] + actions = [waive_selected_charges] @register(Consumable) @@ -777,7 +806,8 @@ class ConsumableWithdrawAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, list_display = ("id", "customer", "merchant", "consumable", "quantity", "project", "date") list_filter = ("date", ("consumable", admin.RelatedOnlyFieldListFilter)) date_hierarchy = "date" - autocomplete_fields = ["customer", "merchant", "consumable", "project", "validated_by"] + autocomplete_fields = ["customer", "merchant", "consumable", "project", "validated_by", "waived_by"] + actions = [waive_selected_charges] @register(RecurringConsumableCharge) @@ -798,6 +828,14 @@ class Meta: widgets = {"password": forms.PasswordInput(render_value=True)} fields = "__all__" + def clean_extra_args(self): + extra_args = self.cleaned_data["extra_args"] + try: + return json.dumps(json.loads(extra_args), indent=4) + except: + pass + return extra_args + def clean(self): if any(self.errors): return @@ -1667,11 +1705,21 @@ class ToolUsageCounterAdmin(admin.ModelAdmin): "tool_usage_question", "value", "warning_threshold", + "default_value", + "staff_members_can_reset", + "qualified_users_can_reset", + "superusers_can_reset", "last_reset", "last_reset_by", "is_active", ) - list_filter = (("tool", admin.RelatedOnlyFieldListFilter), "last_reset") + list_filter = ( + "staff_members_can_reset", + "qualified_users_can_reset", + "superusers_can_reset", + ("tool", admin.RelatedOnlyFieldListFilter), + "last_reset", + ) readonly_fields = ("warning_threshold_reached",) form = ToolUsageCounterAdminForm autocomplete_fields = ["tool", "last_reset_by"] @@ -1723,6 +1771,7 @@ class AdjustmentRequestAdmin(admin.ModelAdmin): ) date_hierarchy = "last_updated" actions = [adjustment_requests_export_csv, adjustment_requests_mark_as_applied] + readonly_fields = ["creation_time"] @admin.display(description="Diff") def get_time_difference(self, adjustment_request: AdjustmentRequest): @@ -1866,7 +1915,10 @@ class EmailLogAdmin(admin.ModelAdmin): list_display = ["id", "category", "sender", "to", "subject", "when", "ok"] list_filter = ["category", "ok"] search_fields = ["subject", "content", "to"] - readonly_fields = ("content_preview",) + readonly_fields = ( + "when", + "content_preview", + ) date_hierarchy = "when" def content_preview(self, obj): diff --git a/NEMO/apps/area_access/apps.py b/NEMO/apps/area_access/apps.py new file mode 100644 index 000000000..7e2ff9057 --- /dev/null +++ b/NEMO/apps/area_access/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class AreaAccessConfig(AppConfig): + name = "NEMO.apps.area_access" + + def ready(self): + """ + This code will be run when Django starts. + """ + pass diff --git a/NEMO/apps/area_access/templates/area_access/base.html b/NEMO/apps/area_access/templates/area_access/base.html index 61dce0a4f..866043262 100644 --- a/NEMO/apps/area_access/templates/area_access/base.html +++ b/NEMO/apps/area_access/templates/area_access/base.html @@ -71,7 +71,7 @@

- {% include 'base/footer.html' %} + {% include 'base/footer.html' with no_links=True %} diff --git a/NEMO/apps/kiosk/templates/kiosk/choices.html b/NEMO/apps/kiosk/templates/kiosk/choices.html index ddd54bfb0..a24289d1f 100644 --- a/NEMO/apps/kiosk/templates/kiosk/choices.html +++ b/NEMO/apps/kiosk/templates/kiosk/choices.html @@ -1,3 +1,4 @@ +{% load custom_tags_and_filters %}
@@ -14,7 +15,7 @@ {% endfor %} {% endif %} {% if usage_events %} -

Current {{ facility_name }} usage

+

Current {{ facility_name }} use

{% for u in usage_events %}
@@ -31,14 +32,16 @@

Current {{ facility_name }} usage

Find a tool by category

{% for category in categories %} -
-
-
- {{ category }} + {% if category not in unqualified_categories or customer.is_staff or "tool"|customization:"kiosk_only_show_qualified_tools" != "enabled" %} +
+
+
+ {{ category }} +
-
+ {% endif %} {% endfor %}
{% if upcoming_reservations %} diff --git a/NEMO/apps/kiosk/templates/kiosk/kiosk.html b/NEMO/apps/kiosk/templates/kiosk/kiosk.html index 52e7d0be9..b9aa43061 100644 --- a/NEMO/apps/kiosk/templates/kiosk/kiosk.html +++ b/NEMO/apps/kiosk/templates/kiosk/kiosk.html @@ -92,7 +92,7 @@

- {% include 'base/footer.html' %} + {% include 'base/footer.html' with no_links=True %} diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html b/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html index ad055baa9..526296319 100644 --- a/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html +++ b/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html @@ -24,11 +24,11 @@

Create a new reservation

When would you like to reserve the {{ tool }}?

+ placeholder="Choose a start date">
When would you like to reserve the {{ tool }}? class="form-control" placeholder="Choose a start time">
+
+ +
When would you like to reserve the {{ tool }}? {% for item in tool_reservation_times %} unavailable_times.push([{{ item.start|date:"U" }},{{ item.end|date:"U" }}]); {% endfor %} - let date_picker = $('#date').pickadate({format: "dddd, mmmm d", formatSubmit: "yyyy-mm-dd", firstDay: 1, hiddenName: true, onSet: refresh_times}); - let start_time_picker = $('#start').pickatime({interval: 15, formatSubmit: "H:i", hiddenName: true, formatLabel: format_label}); - let end_time_picker = $('#end').pickatime({interval: 15, formatSubmit: "H:i", hiddenName: true, formatLabel: format_label}); + let start_date_picker = $('#start_date').pickadate({format: "{{ pickadate_date_format }}", formatSubmit: "yyyy-mm-dd", firstDay: 1, hiddenName: true, onSet: function(event) { + var date = start_date_picker.pickadate('picker').get('select', '{{ pickadate_date_format }}'); + end_date_picker.pickadate('picker').set('select', date); + refresh_times; + } + }); + let end_date_picker = $('#end_date').pickadate({format: "{{ pickadate_date_format }}", formatSubmit: "yyyy-mm-dd", firstDay: 1, hiddenName: true, onSet: refresh_times}); + let start_time_picker = $('#start').pickatime({interval: 15, format: "{{ pickadate_time_format }}", formatSubmit: "H:i", hiddenName: true, formatLabel: format_labels(true)}); + let end_time_picker = $('#end').pickatime({interval: 15, format: "{{ pickadate_time_format }}", formatSubmit: "H:i", hiddenName: true, formatLabel: format_labels(false)}); // set initial date - if ('{{ date|default_if_none:'' }}') { - date_picker.pickadate('picker').set('select', '{{ date }}', {format: 'yyyy-mm-dd'}) + if ('{{ start_date|default_if_none:'' }}') { + start_date_picker.pickadate('picker').set('select', '{{ start_date }}', {format: '{{ pickadate_date_format }}'}) } function refresh_times() { start_time_picker.pickatime('picker').render(); end_time_picker.pickatime('picker').render(); } - function format_label(time) { - if (date_picker.pickadate('picker').get('select') && unavailable_times.length > 0) { - let date_selected = date_picker.pickadate('picker').get('select').pick; // selected date in milliseconds - let time_selected = time.pick * 60 * 1000; // time in milliseconds - let date_time_selected = (date_selected + time_selected)/1000; // back to seconds to compare with python timestamp - for (let i=0 ; i < unavailable_times.length; i++) { - let times = unavailable_times[i]; - let start = times[0]; - let end = times[1]; - if (date_time_selected >= start && date_time_selected < end) { - return 'h:i A !alre!ad!y re!serve!d'; + function format_labels(is_start){ + return function format_label(time) { + let date_picker = is_start ? start_date_picker : end_date_picker; + if (date_picker.pickadate('picker').get('select') && unavailable_times.length > 0) { + let date_selected = date_picker.pickadate('picker').get('select').pick; // selected date in milliseconds + let time_selected = time.pick * 60 * 1000; // time in milliseconds + let date_time_selected = (date_selected + time_selected)/1000; // back to seconds to compare with python timestamp + for (let i=0 ; i < unavailable_times.length; i++) { + let times = unavailable_times[i]; + let start = times[0]; + let end = times[1]; + if (date_time_selected >= start && date_time_selected < end) { + return '{{ pickadate_time_format }} !alre!ad!y re!serve!d'; + } } } - } - return "h:i A"; - } + return '{{ pickadate_time_format }}'; + } + } revert(120) diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py index 507b34583..0b755a5e9 100644 --- a/NEMO/apps/kiosk/views.py +++ b/NEMO/apps/kiosk/views.py @@ -3,6 +3,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import ValidationError from django.db.models import Q from django.shortcuts import redirect, render from django.utils import timezone @@ -92,6 +93,13 @@ def do_enable_tool(request, tool_id): except RequiredUnansweredQuestionsException as e: dictionary = {"message": str(e), "delay": 10} return render(request, "kiosk/acknowledgement.html", dictionary) + + # Validate usage event + try: + new_usage_event.full_clean() + except ValidationError as e: + return render(request, "kiosk/acknowledgement.html", {"message": str(e)}) + new_usage_event.save() # Remove wait list entry if it exists @@ -100,11 +108,10 @@ def do_enable_tool(request, tool_id): wait_list_entry.update(deleted=True, date_exited=timezone.now()) try: - dynamic_form.charge_for_consumables(new_usage_event, new_usage_event.pre_run_data, request) + dynamic_form.process_run_data(new_usage_event, new_usage_event.pre_run_data, request) except Exception as e: dictionary = {"message": str(e), "delay": 10} return render(request, "kiosk/acknowledgement.html", dictionary) - dynamic_form.update_tool_counters(new_usage_event.pre_run_data, tool.id) dictionary = {"message": "You can now use the {}".format(tool), "badge_number": customer.badge_number} return render(request, "kiosk/acknowledgement.html", dictionary) @@ -151,8 +158,8 @@ def do_disable_tool(request, tool_id): try: current_usage_event.run_data = dynamic_form.extract(request) except RequiredUnansweredQuestionsException as e: - if customer.is_staff and customer != current_usage_event.operator and current_usage_event.user != customer: - # if a staff is forcing somebody off the tool and there are required questions, send an email and proceed + if customer != current_usage_event.operator and current_usage_event.user != customer: + # if someone else is forcing somebody off the tool and there are required questions, send an email and proceed current_usage_event.run_data = e.run_data email_managers_required_questions_disable_tool(current_usage_event.operator, customer, tool, e.questions) else: @@ -160,11 +167,10 @@ def do_disable_tool(request, tool_id): return render(request, "kiosk/acknowledgement.html", dictionary) try: - dynamic_form.charge_for_consumables(current_usage_event, current_usage_event.run_data, request) + dynamic_form.process_run_data(current_usage_event, current_usage_event.run_data, request) except Exception as e: dictionary = {"message": str(e), "delay": 10} return render(request, "kiosk/acknowledgement.html", dictionary) - dynamic_form.update_tool_counters(current_usage_event.run_data, tool.id) current_usage_event.save() dictionary = { @@ -255,11 +261,14 @@ def reserve_tool(request): """ Create a reservation for a user. """ try: - date = parse_date(request.POST["date"]) - start = localize(datetime.combine(date, parse_time(request.POST["start"]))) - end = localize(datetime.combine(date, parse_time(request.POST["end"]))) + start_date = parse_date(request.POST["start_date"]) + end_date = parse_date(request.POST["end_date"]) + start = localize(datetime.combine(start_date, parse_time(request.POST["start"]))) + end = localize(datetime.combine(end_date, parse_time(request.POST["end"]))) except: - dictionary["message"] = "Please enter a valid date, start time, and end time for the reservation." + dictionary["message"] = ( + "Please enter a valid start date, start time, end date and end time for the reservation." + ) return render(request, "kiosk/error.html", dictionary) # Create the new reservation: reservation = Reservation() diff --git a/NEMO/apps/sensors/admin.py b/NEMO/apps/sensors/admin.py index fb20095b4..5b50cbef5 100644 --- a/NEMO/apps/sensors/admin.py +++ b/NEMO/apps/sensors/admin.py @@ -1,4 +1,4 @@ -from copy import deepcopy +import json from django import forms from django.contrib import admin, messages @@ -136,6 +136,14 @@ class Meta: model = Sensor fields = "__all__" + def clean_extra_args(self): + extra_args = self.cleaned_data["extra_args"] + try: + return json.dumps(json.loads(extra_args), indent=4) + except: + pass + return extra_args + def clean(self): if any(self.errors): return @@ -211,8 +219,8 @@ class SensorAdmin(admin.ModelAdmin): "read_address", "number_of_values", "get_read_frequency", - "get_last_read", - "get_last_read_at", + "last_read", + "last_value", ) list_filter = ( "visible", @@ -222,21 +230,12 @@ class SensorAdmin(admin.ModelAdmin): ) actions = [duplicate_sensor_configuration, read_selected_sensors, hide_selected_sensors, show_selected_sensors] autocomplete_fields = ["sensor_card", "interlock_card"] + readonly_fields = ["last_read", "last_value"] @display(boolean=True, ordering="sensor_card__enabled", description="Card Enabled") def get_card_enabled(self, obj: Sensor): return obj.card.enabled - @display(description="Last read") - def get_last_read(self, obj: Sensor): - last_data_point = obj.last_data_point() - return last_data_point.value if last_data_point else "" - - @display(description="Last read at") - def get_last_read_at(self, obj: Sensor): - last_data_point = obj.last_data_point() - return last_data_point.created_date if last_data_point else "" - @display(ordering="read_frequency", description="Read frequency") def get_read_frequency(self, obj: Sensor): return obj.read_frequency if obj.read_frequency != 0 else display_for_value(False, "", boolean=True) diff --git a/NEMO/apps/sensors/apps.py b/NEMO/apps/sensors/apps.py new file mode 100644 index 000000000..78483c597 --- /dev/null +++ b/NEMO/apps/sensors/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class SensorsConfig(AppConfig): + name = "NEMO.apps.sensors" + label = "sensors" + + def ready(self): + """ + This code will be run when Django starts. + """ + pass diff --git a/NEMO/apps/sensors/migrations/0002_alter_sensor_data_label_alter_sensor_data_prefix_and_more.py b/NEMO/apps/sensors/migrations/0002_alter_sensor_data_label_alter_sensor_data_prefix_and_more.py new file mode 100644 index 000000000..807166744 --- /dev/null +++ b/NEMO/apps/sensors/migrations/0002_alter_sensor_data_label_alter_sensor_data_prefix_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.15 on 2024-10-04 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensors", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="sensor", + name="data_label", + field=models.CharField(blank=True, help_text="Label for graph and table data", max_length=255, null=True), + ), + migrations.AlterField( + model_name="sensor", + name="data_prefix", + field=models.CharField(blank=True, help_text="Prefix for sensor data values", max_length=255, null=True), + ), + migrations.AlterField( + model_name="sensor", + name="data_suffix", + field=models.CharField(blank=True, help_text="Suffix for sensor data values", max_length=255, null=True), + ), + migrations.AlterField( + model_name="sensor", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="sensorcard", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="sensorcard", + name="server", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="sensorcardcategory", + name="name", + field=models.CharField(help_text="The name for this sensor card category", max_length=255), + ), + migrations.AlterField( + model_name="sensorcategory", + name="name", + field=models.CharField(help_text="The name for this sensor category", max_length=255), + ), + ] diff --git a/NEMO/apps/sensors/migrations/0003_alter_sensor_options_sensor_last_read_and_more.py b/NEMO/apps/sensors/migrations/0003_alter_sensor_options_sensor_last_read_and_more.py new file mode 100644 index 000000000..eceae602e --- /dev/null +++ b/NEMO/apps/sensors/migrations/0003_alter_sensor_options_sensor_last_read_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.15 on 2024-11-11 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensors", "0002_alter_sensor_data_label_alter_sensor_data_prefix_and_more"), + ] + + def populate_sensor_last_value(apps, schema_editor): + Sensor = apps.get_model("sensors", "Sensor") + SensorData = apps.get_model("sensors", "SensorData") + for sensor in Sensor.objects.all(): + try: + last_data_point = SensorData.objects.filter(sensor=sensor).latest("created_date") + if last_data_point: + sensor.last_value = last_data_point.value + sensor.last_read = last_data_point.created_date + sensor.save(update_fields=["last_value", "last_read"]) + except: + pass + + def populate_sensor_last_value_reverse(apps, schema_editor): + pass + + operations = [ + migrations.AlterModelOptions( + name="sensor", + options={"ordering": ["name"]}, + ), + migrations.AddField( + model_name="sensor", + name="last_read", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="sensor", + name="last_value", + field=models.FloatField(blank=True, null=True), + ), + migrations.AlterField( + model_name="sensordata", + name="created_date", + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + migrations.RunPython(populate_sensor_last_value, populate_sensor_last_value_reverse), + ] diff --git a/NEMO/apps/sensors/migrations/0004_sensorcard_extra_args.py b/NEMO/apps/sensors/migrations/0004_sensorcard_extra_args.py new file mode 100644 index 000000000..e8ed52e67 --- /dev/null +++ b/NEMO/apps/sensors/migrations/0004_sensorcard_extra_args.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2024-11-13 01:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensors", "0003_alter_sensor_options_sensor_last_read_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="sensorcard", + name="extra_args", + field=models.TextField( + blank=True, + help_text="Json formatted extra arguments to pass to the sensor card implementation.", + null=True, + ), + ), + ] diff --git a/NEMO/apps/sensors/models.py b/NEMO/apps/sensors/models.py index aacd8f841..44b85c69c 100644 --- a/NEMO/apps/sensors/models.py +++ b/NEMO/apps/sensors/models.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import json import random from logging import getLogger from typing import List, Optional @@ -11,8 +12,10 @@ from django.db import models from django.utils import timezone from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from NEMO.apps.sensors.customizations import SensorCustomization +from NEMO.constants import CHAR_FIELD_MEDIUM_LENGTH, CHAR_FIELD_SMALL_LENGTH from NEMO.decorators import postpone from NEMO.evaluators import evaluate_boolean_expression from NEMO.fields import MultiEmailField @@ -24,8 +27,10 @@ class SensorCardCategory(BaseModel): - name = models.CharField(max_length=200, help_text="The name for this sensor card category") - key = models.CharField(max_length=100, help_text="The key to identify this sensor card category by in sensors.py") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name for this sensor card category") + key = models.CharField( + max_length=CHAR_FIELD_SMALL_LENGTH, help_text="The key to identify this sensor card category by in sensors.py" + ) class Meta: verbose_name_plural = "Sensor card categories" @@ -36,14 +41,24 @@ def __str__(self): class SensorCard(BaseModel): - name = models.CharField(max_length=200) - server = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) + server = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) port = models.PositiveIntegerField() category = models.ForeignKey(SensorCardCategory, on_delete=models.CASCADE) - username = models.CharField(max_length=100, blank=True, null=True) - password = models.CharField(max_length=100, blank=True, null=True) + username = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, blank=True, null=True) + password = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, blank=True, null=True) + extra_args = models.TextField( + null=True, blank=True, help_text=_("Json formatted extra arguments to pass to the sensor card implementation.") + ) enabled = models.BooleanField(blank=False, null=False, default=True) + @property + def extra_args_dict(self): + try: + return json.loads(self.extra_args) + except: + return {} + class Meta: ordering = ["name"] @@ -53,7 +68,7 @@ def __str__(self): class SensorCategory(BaseModel): - name = models.CharField(max_length=200, help_text="The name for this sensor category") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name for this sensor category") parent = models.ForeignKey( "SensorCategory", related_name="children", null=True, blank=True, on_delete=models.SET_NULL ) @@ -95,16 +110,22 @@ class Meta: class Sensor(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) visible = models.BooleanField( default=True, help_text="Specifies whether this sensor is visible in the sensor dashboard" ) sensor_card = models.ForeignKey(SensorCard, blank=True, null=True, on_delete=models.CASCADE) interlock_card = models.ForeignKey(InterlockCard, blank=True, null=True, on_delete=models.CASCADE) sensor_category = models.ForeignKey(SensorCategory, blank=True, null=True, on_delete=models.SET_NULL) - data_label = models.CharField(blank=True, null=True, max_length=200, help_text="Label for graph and table data") - data_prefix = models.CharField(blank=True, null=True, max_length=100, help_text="Prefix for sensor data values") - data_suffix = models.CharField(blank=True, null=True, max_length=100, help_text="Suffix for sensor data values") + data_label = models.CharField( + blank=True, null=True, max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="Label for graph and table data" + ) + data_prefix = models.CharField( + blank=True, null=True, max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="Prefix for sensor data values" + ) + data_suffix = models.CharField( + blank=True, null=True, max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="Suffix for sensor data values" + ) unit_id = models.PositiveIntegerField(null=True, blank=True) read_address = models.PositiveIntegerField(null=True, blank=True) number_of_values = models.PositiveIntegerField(null=True, blank=True, validators=[MinValueValidator(1)]) @@ -120,6 +141,8 @@ class Sensor(BaseModel): validators=[MaxValueValidator(1440), MinValueValidator(0)], help_text="Enter the read frequency in minutes. Every 2 hours = 120, etc. Max value is 1440 min (24hrs). Use 0 to disable sensor data read.", ) + last_read = models.DateTimeField(null=True, blank=True) + last_value = models.FloatField(null=True, blank=True) @property def card(self): @@ -134,8 +157,8 @@ def read_data(self, raise_exception=False): return sensors.get(self.card.category, raise_exception).read_values(self, raise_exception) - def last_data_point(self): - return SensorData.objects.filter(sensor=self).latest("created_date") + def last_value_display(self): + return display_sensor_value(self, self.last_value) def clean(self): from NEMO.apps.sensors import sensors @@ -166,14 +189,17 @@ def alert_triggered(self) -> bool: def __str__(self): return self.name + class Meta: + ordering = ["name"] + class SensorData(BaseModel): sensor = models.ForeignKey(Sensor, on_delete=models.CASCADE) - created_date = models.DateTimeField(auto_now_add=True) + created_date = models.DateTimeField(auto_now_add=True, db_index=True) value = models.FloatField() def display_value(self): - return f"{self.sensor.data_prefix + ' ' if self.sensor.data_prefix else ''}{self.value}{' ' + self.sensor.data_suffix if self.sensor.data_suffix else ''}" + return display_sensor_value(self.sensor, self.value) class Meta: verbose_name_plural = "Sensor data" @@ -354,3 +380,9 @@ def get_alert_description(time, reset: bool, condition: str, no_data: bool, valu if trigger_reason: alert_description += f" because {trigger_reason}." return alert_description + + +def display_sensor_value(sensor: Sensor, value: float) -> str: + if not value: + return "" + return f"{sensor.data_prefix + ' ' if sensor.data_prefix else ''}{value}{' ' + sensor.data_suffix if sensor.data_suffix else ''}" diff --git a/NEMO/apps/sensors/sensors.py b/NEMO/apps/sensors/sensors.py index d14f66d2f..376eef116 100644 --- a/NEMO/apps/sensors/sensors.py +++ b/NEMO/apps/sensors/sensors.py @@ -5,6 +5,8 @@ from unittest import mock from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from pymodbus.bit_read_message import ReadCoilsRequest, ReadCoilsResponse from pymodbus.client import ModbusTcpClient @@ -78,7 +80,13 @@ def read_values(self, sensor: Sensor_model, raise_exception=False): continue data_value = self.evaluate_sensor(sensor, registers=registers) if data_value is not None: - data = SensorData.objects.create(sensor=sensor, value=data_value) + now = timezone.now() + # Saving the data point in both sensor data and sensor + with transaction.atomic(): + data = SensorData.objects.create(sensor=sensor, value=data_value, created_date=now) + sensor.last_read = now + sensor.last_value = data_value + sensor.save(update_fields=["last_read", "last_value"]) process_alerts(sensor, data) return data except Exception as error: @@ -166,7 +174,8 @@ def clean_sensor(self, sensor_form: SensorAdminForm): raise ValidationError({"formula": str(e)}) def do_read_values(self, sensor: Sensor_model) -> List: - client = ModbusTcpClient(sensor.card.server, port=sensor.card.port) + timeout = sensor.card.extra_args_dict.get("timeout", 3) + client = ModbusTcpClient(sensor.card.server, port=sensor.card.port, timeout=timeout) try: valid_connection = client.connect() if not valid_connection: diff --git a/NEMO/apps/sensors/templates/sensors/sensors.html b/NEMO/apps/sensors/templates/sensors/sensors.html index 4b0db43ba..9ce5c9fc6 100644 --- a/NEMO/apps/sensors/templates/sensors/sensors.html +++ b/NEMO/apps/sensors/templates/sensors/sensors.html @@ -36,9 +36,9 @@ {{ sensor.name }}

- {{ sensor.last_data_point.created_date|date:"SHORT_DATETIME_FORMAT" }} + {{ sensor.last_read|date:"SHORT_DATETIME_FORMAT" }}
- {{ sensor.last_data_point.display_value }} + {{ sensor.last_value_display }} {% endfor %} {% endif %} diff --git a/NEMO/constants.py b/NEMO/constants.py new file mode 100644 index 000000000..bf3fa675f --- /dev/null +++ b/NEMO/constants.py @@ -0,0 +1,18 @@ +# Maximum length for additional information of reservations +ADDITIONAL_INFORMATION_MAXIMUM_LENGTH = 3000 + +# Maximum length for feedback emails +FEEDBACK_MAXIMUM_LENGTH = 5000 + +# Multiple useful lengths for Char fields (previous DB limitation is not valid nowadays) +CHAR_FIELD_SMALL_LENGTH = 100 +CHAR_FIELD_MEDIUM_LENGTH = 255 +CHAR_FIELD_LARGE_LENGTH = 1024 +# Kept for backward compatibility +CHAR_FIELD_MAXIMUM_LENGTH = CHAR_FIELD_MEDIUM_LENGTH + +# Name of the parameter to indicate which view to redirect to +NEXT_PARAMETER_NAME = "next" + +# Name of the media folder under which only staff can see files +MEDIA_PROTECTED = "protected" diff --git a/NEMO/context_processors.py b/NEMO/context_processors.py index 18f3e5f34..9897cfc77 100644 --- a/NEMO/context_processors.py +++ b/NEMO/context_processors.py @@ -1,5 +1,11 @@ from NEMO.models import Area, Notification, PhysicalAccessLevel, Tool, User -from NEMO.utilities import date_input_js_format, datetime_input_js_format, time_input_js_format +from NEMO.utilities import ( + date_input_js_format, + datetime_input_js_format, + time_input_js_format, + pickadate_date_format, + pickadate_time_format, +) from NEMO.views.customization import CustomizationBase from NEMO.views.notifications import get_notification_counts @@ -84,6 +90,8 @@ def base_context(request): "time_input_js_format": time_input_js_format, "date_input_js_format": date_input_js_format, "datetime_input_js_format": datetime_input_js_format, + "pickadate_date_format": pickadate_date_format, + "pickadate_time_format": pickadate_time_format, "no_header": request.session.get("no_header", False), "safety_menu_item": customization_values.get("safety_main_menu") == "enabled", "calendar_page_title": customization_values.get("calendar_page_title"), diff --git a/NEMO/decorators.py b/NEMO/decorators.py index fe23c6ddf..761260723 100644 --- a/NEMO/decorators.py +++ b/NEMO/decorators.py @@ -3,7 +3,7 @@ import sys from functools import wraps from logging import getLogger -from threading import Lock, RLock, Thread +from threading import Lock, Thread from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import user_passes_test @@ -12,6 +12,8 @@ decorators_logger = getLogger(__name__) +locks = {} + def disable_session_expiry_refresh(f): """ @@ -53,25 +55,21 @@ def decorator(*arguments, **named_arguments): # For example, @synchronized('tool_id') on a do_this(tool_id) function will only prevent do_this from being called # at the same time with the same tool_id. If do_this is called twice with different tool_id, it won't be locked def synchronized(method_argument=""): - def inner(function): - def decorator(*args, **kwargs): + def decorator(function): + @wraps(function) + def wrapper(*args, **kwargs): func_args = inspect.signature(function).bind(*args, **kwargs).arguments attribute_value = slugify_underscore(str(func_args.get(method_argument, ""))) - lock_name = "__" + function.__name__ + "_lock_" + attribute_value + "__" - lock: RLock = vars(function).get(lock_name, None) - if lock is None: - meta_lock = vars(decorator).setdefault("_synchronized_meta_lock", Lock()) - with meta_lock: - lock = vars(function).get(lock_name, None) - if lock is None: - lock = RLock() - setattr(function, lock_name, lock) + lock_name = slugify_underscore( + f"{function.__module__.replace('.', '_')}_{function.__qualname__}_{attribute_value}" + ) + lock = locks.setdefault(lock_name, Lock()) with lock: return function(*args, **kwargs) - return decorator + return wrapper - return inner + return decorator # Use this decorator annotation to register your own customizations which will be shown in the customization page diff --git a/NEMO/fields.py b/NEMO/fields.py index 4065ff989..941bd5447 100644 --- a/NEMO/fields.py +++ b/NEMO/fields.py @@ -1,6 +1,5 @@ -from distutils.util import strtobool - from django import forms +from django.contrib.admin.widgets import AutocompleteMixin from django.core import validators from django.core.exceptions import ValidationError from django.core.validators import EmailValidator @@ -9,6 +8,8 @@ from django.db.models.lookups import BuiltinLookup from django.utils.translation import gettext_lazy as _ +from NEMO.utilities import strtobool + DEFAULT_SEPARATOR = "," @@ -31,6 +32,27 @@ def as_sql(self, compiler, connection): return "%s <> ''" % sql, params +# This widget can be used with ChoiceField as or with CharField (choices needs to be used in attrs) +class DatalistWidget(forms.TextInput): + + def __init__(self, attrs=None): + if attrs is not None: + attrs = attrs.copy() + self.choices = attrs.pop("choices", []) + super().__init__(attrs) + + def render(self, name, value, attrs=None, renderer=None): + if attrs is None: + attrs = {} + attrs["list"] = f"{name}_datalist" + options = self.choices + datalist = f'' + for option_value, option_label in options: + datalist += f'' + datalist += "" + return super().render(name, value, attrs, renderer) + datalist + + class MultiEmailWidget(forms.TextInput): is_hidden = False separator = DEFAULT_SEPARATOR @@ -122,3 +144,28 @@ def to_python(self, value): def value_to_string(self, obj): value = self.value_from_object(obj) return self.get_prep_value(value) + + +# Django admin special widget to allow select2 autocomplete for choice fields +class AdminAutocompleteSelectWidget(forms.Select): + def __init__(self, choices=(), attrs=None): + super().__init__(attrs) + self.choices = choices + + def render(self, name, value, attrs=None, renderer=None): + select_html = super().render(name, value, attrs, renderer) + select2_script = f""" + + """ + return select_html + select2_script + + @property + def media(self): + # Reuse the AutocompleteMixin's media files + return AutocompleteMixin(None, None).media diff --git a/NEMO/forms.py b/NEMO/forms.py index 8082b21ec..b2d26067e 100644 --- a/NEMO/forms.py +++ b/NEMO/forms.py @@ -508,6 +508,31 @@ class Meta: model = AdjustmentRequest exclude = ["creation_time", "creator", "last_updated", "last_updated_by", "status", "reviewer", "deleted"] + def clean(self) -> dict: + cleaned_data = super().clean() + edit = bool(self.instance.pk) + item_type = cleaned_data.get("item_type") + item_id = cleaned_data.get("item_id") + if item_type and item_id and not edit: + item = item_type.get_object_for_this_type(pk=item_id) + new_start = cleaned_data.get("new_start") + new_end = cleaned_data.get("new_end") + # If the dates/quantities are not changed, remove them + # We are comparing formatted dates so we have the correct precision (otherwise user input might not have seconds/milliseconds and they would not be equal) + if ( + new_start + and new_end + and format_datetime(new_start) == format_datetime(item.start) + and format_datetime(new_end) == format_datetime(item.end) + ): + cleaned_data["new_start"] = None + cleaned_data["new_end"] = None + # also remove quantity if not changed + new_quantity = cleaned_data.get("new_quantity") + if new_quantity and new_quantity == item.quantity: + cleaned_data["new_quantity"] = None + return cleaned_data + class StaffAbsenceForm(ModelForm): class Meta: diff --git a/NEMO/interlocks.py b/NEMO/interlocks.py index c552cbadd..e81d3cff8 100644 --- a/NEMO/interlocks.py +++ b/NEMO/interlocks.py @@ -189,7 +189,8 @@ def _send_command(self, interlock: Interlock_model, command_type: Interlock_mode # Create a TCP socket to send the interlock command. sock = socket.socket() try: - sock.settimeout(3.0) # Set the send/receive timeout to be 3 seconds. + timeout = interlock.card.extra_args_dict.get("timeout", 3.0) + sock.settimeout(timeout) # Set the send/receive timeout to be 3 seconds. server_address = (interlock.card.server, interlock.card.port) sock.connect(server_address) sock.send(command_message) @@ -359,7 +360,10 @@ def _send_command(self, interlock: Interlock_model, command_type: Interlock_mode # Backward compatibility, no bank means bank 1 bank = interlock.unit_id if interlock.unit_id is not None else 1 try: - with socket.create_connection((interlock.card.server, interlock.card.port), 10) as relay_socket: + timeout = interlock.card.extra_args_dict.get("timeout", 10) + with socket.create_connection( + (interlock.card.server, interlock.card.port), timeout=timeout + ) as relay_socket: if command_type == Interlock_model.State.LOCKED: # turn the interlock channel off off_command = (99 + interlock.channel) if interlock.channel != 0 else 129 @@ -416,7 +420,8 @@ def set_relay_state(cls, interlock: Interlock_model, state: {0, 1}) -> Interlock url = f"{interlock.card.server}:{interlock.card.port}/{state_xml_name}?{cls.state_parameter_template.format(interlock.channel or '')}={state}" if not url.startswith("http") and not url.startswith("https"): url = "http://" + url - response = requests.get(url, auth=auth) + timeout = interlock.card.extra_args_dict.get("timeout", 3) + response = requests.get(url, auth=auth, timeout=timeout) response_error = cls.check_response_error(response) if not response_error: break @@ -486,7 +491,8 @@ def _send_command(self, interlock: Interlock_model, command_type: Interlock_mode @classmethod def set_relay_state(cls, interlock: Interlock_model, state: {0, 1}) -> Interlock_model.State: coil = interlock.channel - client = ModbusTcpClient(interlock.card.server, port=interlock.card.port) + timeout = interlock.card.extra_args_dict.get("timeout", 3) + client = ModbusTcpClient(interlock.card.server, port=interlock.card.port, timeout=timeout) try: valid_connection = client.connect() if not valid_connection: diff --git a/NEMO/log.py b/NEMO/log.py new file mode 100644 index 000000000..f5c1e75db --- /dev/null +++ b/NEMO/log.py @@ -0,0 +1,32 @@ +from django.conf import settings +from django.core.cache import cache +from django.utils.log import AdminEmailHandler + + +# Admin email override to limit the number of emails being sent when errors occur +# The default cache implementation is not shared across processes (but is thread-safe) +# If you are using gunicorn with multiple workers, each of the worker will work with its own cache +class ThrottledAdminEmailHandler(AdminEmailHandler): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.period_in_seconds = getattr(settings, "LOGGING_ERROR_EMAIL_PERIOD_SECONDS", 60) + self.max_emails = getattr(settings, "LOGGING_ERROR_EMAIL_MAX_EMAILS", 1) + self.cache_key = getattr(settings, "LOGGING_ERROR_EMAIL_CACHE_KEY", "error_email_admins_counter") + + def increment_counter(self): + try: + cache.incr(self.cache_key) + except ValueError: + cache.set(self.cache_key, 1, self.period_in_seconds) + return cache.get(self.cache_key) + + def emit(self, record): + try: + counter = self.increment_counter() + except Exception: + pass + else: + if counter > self.max_emails: + return + super().emit(record) diff --git a/NEMO/middleware.py b/NEMO/middleware.py index a571283b4..9ddfb8096 100644 --- a/NEMO/middleware.py +++ b/NEMO/middleware.py @@ -103,7 +103,7 @@ def __call__(self, request): class ImpersonateMiddleware(MiddlewareMixin): def process_request(self, request): - if request.user.has_perm("NEMO.can_impersonate_user") and "impersonate_id" in request.session: + if request.user.has_perm("NEMO.can_impersonate_users") and "impersonate_id" in request.session: request.user = User.objects.get(pk=request.session["impersonate_id"]) diff --git a/NEMO/migrations/0081_oracle_django_4_rename.py b/NEMO/migrations/0081_oracle_django_4_rename.py index 375e35343..38946fed6 100644 --- a/NEMO/migrations/0081_oracle_django_4_rename.py +++ b/NEMO/migrations/0081_oracle_django_4_rename.py @@ -18,9 +18,8 @@ def check_nemo_6_0_0_oracle_long_names(apps, schema_editor): table_renames = [] column_renames = [] - if not getattr(connection, "vendor", "") == "oracle": - print("This is only needed for ORACLE databases") - else: + if getattr(connection, "vendor", "") == "oracle": + print("We have an ORACLE db, we need to fix tables for django 4.0") with connection.cursor() as cursor: table_list = connection.introspection.get_table_list(cursor) @@ -67,9 +66,8 @@ def check_nemo_6_0_0_oracle_long_names_reverse(apps, schema_editor): table_renames = [] column_renames = [] - if not getattr(connection, "vendor", "") == "oracle": - print("This is only needed for ORACLE databases") - else: + if getattr(connection, "vendor", "") == "oracle": + print("We have an ORACLE db, we need to fix tables for django 4.0") with connection.cursor() as cursor: table_list = connection.introspection.get_table_list(cursor) diff --git a/NEMO/migrations/0087_version_6_1_0.py b/NEMO/migrations/0087_version_6_1_0.py new file mode 100644 index 000000000..d5452c0c5 --- /dev/null +++ b/NEMO/migrations/0087_version_6_1_0.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.11 on 2024-06-11 02:00 + +from django.db import migrations + +from NEMO.migrations_utils import create_news_for_version + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0086_adjustmentrequest_new_quantity"), + ] + + def new_version_news(apps, schema_editor): + create_news_for_version(apps, "6.1.0", "") + + operations = [ + migrations.RunPython(new_version_news), + ] diff --git a/NEMO/migrations/0088_alter_configurationoption_options.py b/NEMO/migrations/0088_alter_configurationoption_options.py new file mode 100644 index 000000000..7f9658d88 --- /dev/null +++ b/NEMO/migrations/0088_alter_configurationoption_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.14 on 2024-07-27 22:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0087_version_6_1_0"), + ] + + operations = [ + migrations.AlterModelOptions( + name="configurationoption", + options={"ordering": ["configuration__display_order"]}, + ), + ] diff --git a/NEMO/migrations/0089_door_farewell_message.py b/NEMO/migrations/0089_door_farewell_message.py new file mode 100644 index 000000000..3e0d504e5 --- /dev/null +++ b/NEMO/migrations/0089_door_farewell_message.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.11 on 2024-08-27 13:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0088_alter_configurationoption_options"), + ] + + operations = [ + migrations.AddField( + model_name="door", + name="farewell_message", + field=models.TextField( + blank=True, + help_text="The farewell message will be displayed on the tablet logout page. You can use HTML and JavaScript.", + null=True, + ), + ), + ] diff --git a/NEMO/migrations/0090_toolusagecounter_counter_direction_and_more.py b/NEMO/migrations/0090_toolusagecounter_counter_direction_and_more.py new file mode 100644 index 000000000..62800ce8f --- /dev/null +++ b/NEMO/migrations/0090_toolusagecounter_counter_direction_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.15 on 2024-09-27 21:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0089_door_farewell_message"), + ] + + operations = [ + migrations.AddField( + model_name="toolusagecounter", + name="counter_direction", + field=models.IntegerField(choices=[(1, "Increment"), (-1, "Decrement")], default=1), + ), + migrations.AddField( + model_name="toolusagecounter", + name="default_value", + field=models.FloatField(default=0, help_text="The default value to reset this counter to"), + preserve_default=False, + ), + migrations.AddField( + model_name="toolusagecounter", + name="email_facility_managers_when_reset", + field=models.BooleanField( + default=True, help_text="Check this box to email facility managers when this counter is reset" + ), + ), + migrations.AddField( + model_name="toolusagecounter", + name="qualified_users_can_reset", + field=models.BooleanField( + default=False, help_text="Check this box to allow qualified users to reset this counter" + ), + ), + migrations.AddField( + model_name="toolusagecounter", + name="staff_members_can_reset", + field=models.BooleanField(default=True, help_text="Check this box to allow staff to reset this counter"), + ), + migrations.AddField( + model_name="toolusagecounter", + name="superusers_can_reset", + field=models.BooleanField( + default=False, help_text="Check this box to allow tool superusers to reset this counter" + ), + ), + migrations.AlterField( + model_name="toolusagecounter", + name="value", + field=models.FloatField(help_text="The current value of this counter"), + ), + ] diff --git a/NEMO/migrations/0091_adjustmentrequest_waive_charges.py b/NEMO/migrations/0091_adjustmentrequest_waive_charges.py new file mode 100644 index 000000000..e73de7af4 --- /dev/null +++ b/NEMO/migrations/0091_adjustmentrequest_waive_charges.py @@ -0,0 +1,146 @@ +# Generated by Django 4.2.15 on 2024-09-29 19:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0090_toolusagecounter_counter_direction_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="adjustmentrequest", + name="waive", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="areaaccessrecord", + name="waived", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="areaaccessrecord", + name="waived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="area_access_waived_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="areaaccessrecord", + name="waived_on", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="consumablewithdraw", + name="waived", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="consumablewithdraw", + name="waived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="consumable_withdrawal_waived_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="consumablewithdraw", + name="waived_on", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="reservation", + name="waived", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="reservation", + name="waived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="reservation_waived_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="reservation", + name="waived_on", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="staffcharge", + name="waived", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="staffcharge", + name="waived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="staff_charge_waived_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="staffcharge", + name="waived_on", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="trainingsession", + name="waived", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="trainingsession", + name="waived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="training_waived_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="trainingsession", + name="waived_on", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="usageevent", + name="waived", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="usageevent", + name="waived_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="usage_event_waived_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="usageevent", + name="waived_on", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/NEMO/migrations/0092_alter_accounttype_name_alter_alert_category_and_more.py b/NEMO/migrations/0092_alter_accounttype_name_alter_alert_category_and_more.py new file mode 100644 index 000000000..b254a6d5a --- /dev/null +++ b/NEMO/migrations/0092_alter_accounttype_name_alter_alert_category_and_more.py @@ -0,0 +1,372 @@ +# Generated by Django 4.2.15 on 2024-10-04 03:41 + +import re + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0091_adjustmentrequest_waive_charges"), + ] + + operations = [ + migrations.AlterField( + model_name="accounttype", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="alert", + name="category", + field=models.CharField(blank=True, help_text="A category/type for this alert.", max_length=255), + ), + migrations.AlterField( + model_name="alert", + name="title", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name="alertcategory", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="area", + name="category", + field=models.CharField( + blank=True, + db_column="category", + help_text='Create sub-categories using slashes. For example "Category 1/Sub-category 1".', + max_length=1024, + null=True, + ), + ), + migrations.AlterField( + model_name="area", + name="name", + field=models.CharField( + help_text="What is the name of this area? The name will be displayed on the tablet login and logout pages.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="badgereader", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="chemical", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="chemical", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="chemicalhazard", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="configuration", + name="configurable_item_name", + field=models.CharField( + blank=True, + help_text="The name of the tool part being configured. This text is displayed as a label on the tool control page. Leave this field blank if there is only one configuration slot.", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="configuration", + name="name", + field=models.CharField( + help_text="The name of this overall configuration. This text is displayed as a label on the tool control page.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="contactinformationcategory", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="emaillog", + name="subject", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="interlockcardcategory", + name="name", + field=models.CharField(help_text="The name for this interlock category", max_length=255), + ), + migrations.AlterField( + model_name="landingpagechoice", + name="name", + field=models.CharField( + help_text="The textual name that will be displayed underneath the image", max_length=100 + ), + ), + migrations.AlterField( + model_name="landingpagechoice", + name="url", + field=models.CharField( + help_text="The URL that the choice leads to when clicked. Relative paths such as /calendar/ are used when linking within the site. Use fully qualified URL paths such as https://www.google.com/ to link to external sites.", + verbose_name="URL", + max_length=255, + ), + ), + migrations.AlterField( + model_name="news", + name="title", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="onboardingphase", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="project", + name="name", + field=models.CharField(max_length=1024, unique=True), + ), + migrations.AlterField( + model_name="projectdiscipline", + name="name", + field=models.CharField(help_text="The name of the discipline", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="projectdocuments", + name="name", + field=models.CharField( + blank=True, help_text="The optional name to display for this document", max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="projectdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="recurringconsumablecharge", + name="name", + field=models.CharField(help_text="The name/identifier for this recurring charge.", max_length=255), + ), + migrations.AlterField( + model_name="reservation", + name="title", + field=models.TextField( + blank=True, + default="", + help_text="Shows a custom title for this reservation on the calendar. Leave this field blank to display the reservation's user name as the title (which is the default behaviour).", + max_length=255, + ), + ), + migrations.AlterField( + model_name="resource", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="resourcecategory", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="safetycategory", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="safetyissue", + name="location", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="safetyitem", + name="name", + field=models.CharField(help_text="The safety item name.", max_length=255), + ), + migrations.AlterField( + model_name="safetyitemdocuments", + name="name", + field=models.CharField( + blank=True, help_text="The optional name to display for this document", max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="safetyitemdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="safetytraining", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="scheduledoutage", + name="category", + field=models.CharField( + blank=True, + help_text="A categorical reason for why this outage is scheduled. Useful for trend analytics.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="scheduledoutage", + name="reminder_days", + field=models.CharField( + blank=True, + help_text="The number of days to send a reminder before a scheduled outage. A comma-separated list can be used for multiple reminders.", + max_length=255, + null=True, + validators=[ + django.core.validators.RegexValidator( + re.compile("^\\d+(?:,\\d+)*\\Z"), + code="invalid", + message="Enter only digits separated by commas.", + ) + ], + ), + ), + migrations.AlterField( + model_name="scheduledoutagecategory", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="staffavailabilitycategory", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="staffknowledgebasecategory", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="staffknowledgebaseitem", + name="name", + field=models.CharField(help_text="The item name.", max_length=255), + ), + migrations.AlterField( + model_name="staffknowledgebaseitemdocuments", + name="name", + field=models.CharField( + blank=True, help_text="The optional name to display for this document", max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="staffknowledgebaseitemdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="taskhistory", + name="status", + field=models.CharField(help_text="A text description of the task's status", max_length=255), + ), + migrations.AlterField( + model_name="taskstatus", + name="name", + field=models.CharField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name="tool", + name="_category", + field=models.CharField( + blank=True, + db_column="category", + help_text='Create sub-categories using slashes. For example "Category 1/Sub-category 1".', + max_length=1024, + null=True, + ), + ), + migrations.AlterField( + model_name="tooldocuments", + name="name", + field=models.CharField( + blank=True, help_text="The optional name to display for this document", max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="tooldocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="toolqualificationgroup", + name="name", + field=models.CharField(help_text="The name of this tool group", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="toolusagecounter", + name="name", + field=models.CharField(help_text="The name of this counter", max_length=255), + ), + migrations.AlterField( + model_name="toolusagecounter", + name="tool_usage_question", + field=models.CharField( + help_text="The name of the tool's post usage question which should be used to increment this counter", + max_length=255, + ), + ), + migrations.AlterField( + model_name="userdocuments", + name="name", + field=models.CharField( + blank=True, help_text="The optional name to display for this document", max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="userdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="userknowledgebasecategory", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + migrations.AlterField( + model_name="userknowledgebaseitem", + name="name", + field=models.CharField(help_text="The item name.", max_length=255), + ), + migrations.AlterField( + model_name="userknowledgebaseitemdocuments", + name="name", + field=models.CharField( + blank=True, help_text="The optional name to display for this document", max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="userknowledgebaseitemdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="userpreferences", + name="recurring_charges_reminder_days", + field=models.CharField( + blank=True, + default="60,7", + help_text="The number of days to send a reminder before a recurring charge is due. A comma-separated list can be used for multiple reminders.", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="usertype", + name="name", + field=models.CharField(help_text="The unique name for this item", max_length=255, unique=True), + ), + ] diff --git a/NEMO/migrations/0093_trainingsession_usage_event_usageevent_training.py b/NEMO/migrations/0093_trainingsession_usage_event_usageevent_training.py new file mode 100644 index 000000000..50be7c882 --- /dev/null +++ b/NEMO/migrations/0093_trainingsession_usage_event_usageevent_training.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2024-11-01 22:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0092_alter_accounttype_name_alter_alert_category_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="trainingsession", + name="usage_event", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="NEMO.usageevent" + ), + ), + migrations.AddField( + model_name="usageevent", + name="training", + field=models.BooleanField(default=False), + ), + ] diff --git a/NEMO/migrations/0094_interlockcard_extra_args.py b/NEMO/migrations/0094_interlockcard_extra_args.py new file mode 100644 index 000000000..9c5a72e45 --- /dev/null +++ b/NEMO/migrations/0094_interlockcard_extra_args.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2024-11-13 01:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0093_trainingsession_usage_event_usageevent_training"), + ] + + operations = [ + migrations.AddField( + model_name="interlockcard", + name="extra_args", + field=models.TextField( + blank=True, + help_text="Json formatted extra arguments to pass to the interlock card implementation.", + null=True, + ), + ), + ] diff --git a/NEMO/migrations/0095_reservationquestions_enabled.py b/NEMO/migrations/0095_reservationquestions_enabled.py new file mode 100644 index 000000000..3d7071107 --- /dev/null +++ b/NEMO/migrations/0095_reservationquestions_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-12-06 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0094_interlockcard_extra_args"), + ] + + operations = [ + migrations.AddField( + model_name="reservationquestions", + name="enabled", + field=models.BooleanField(default=True), + ), + ] diff --git a/NEMO/mixins.py b/NEMO/mixins.py index 6fb124427..ae485bc71 100644 --- a/NEMO/mixins.py +++ b/NEMO/mixins.py @@ -10,8 +10,8 @@ from django.shortcuts import redirect from django.utils import timezone +from NEMO.constants import NEXT_PARAMETER_NAME from NEMO.utilities import RecurrenceFrequency, beginning_of_the_day, format_datetime, get_recurring_rule -from NEMO.views.constants import NEXT_PARAMETER_NAME if TYPE_CHECKING: from NEMO.models import User @@ -91,6 +91,19 @@ def get_item(self) -> str: elif self.get_real_type() == BillableItemMixin.MISSED_RESERVATION: return f"{self.tool or self.area} missed reservation" + def waive(self, user: User): + if hasattr(self, "waived"): + self.waived = True + self.waived_by = user + self.waived_on = timezone.now() + self.save(update_fields=["waived", "waived_by", "waived_on"]) + + def validate(self, user: User): + if hasattr(self, "validated"): + self.validated = True + self.validated_by = user + self.save(update_fields=["validated", "validated_by"]) + def get_start(self) -> Optional[datetime.datetime]: if self.get_real_type() in [ BillableItemMixin.AREA_ACCESS, @@ -112,26 +125,26 @@ def get_end(self) -> Optional[datetime.datetime]: return self.date def can_be_adjusted(self, user: User): - # determine if a charge can be adjusted - from NEMO.views.customization import UserRequestsCustomization + # determine if the given user can make an adjustment request for this charge + from NEMO.views.customization import AdjustmentRequestsCustomization from NEMO.views.usage import get_managed_projects pi_projects = get_managed_projects(user) - time_limit = UserRequestsCustomization.get_date_limit() + time_limit = AdjustmentRequestsCustomization.get_date_limit() time_limit_condition = not time_limit or time_limit <= self.get_end() user_project_condition = self.get_customer() == user or self.project in pi_projects operator_is_staff = self.get_operator() == user and user.is_staff if self.get_real_type() == BillableItemMixin.AREA_ACCESS: - access_enabled = UserRequestsCustomization.get_bool("adjustment_requests_area_access_enabled") - remote_enabled = UserRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") + access_enabled = AdjustmentRequestsCustomization.get_bool("adjustment_requests_area_access_enabled") + remote_enabled = AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") if self.staff_charge: return remote_enabled and time_limit_condition and operator_is_staff else: return access_enabled and user_project_condition and time_limit_condition elif self.get_real_type() == BillableItemMixin.TOOL_USAGE: - remote_enabled = UserRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") - usage_enabled = UserRequestsCustomization.get_bool("adjustment_requests_tool_usage_enabled") + remote_enabled = AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") + usage_enabled = AdjustmentRequestsCustomization.get_bool("adjustment_requests_tool_usage_enabled") if self.remote_work: return remote_enabled and time_limit_condition and operator_is_staff else: @@ -139,18 +152,31 @@ def can_be_adjusted(self, user: User): usage_enabled and time_limit_condition and user_project_condition - and self.get_customer() == self.get_operator() + and (self.get_customer() == self.get_operator() or not self.remote_work) + ) or ( + remote_enabled + and self.get_operator() == user + and user.is_staff + and self.get_operator() != self.get_customer() ) elif self.get_real_type() == BillableItemMixin.REMOTE_WORK: - remote_enabled = UserRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") + remote_enabled = AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") return remote_enabled and time_limit_condition and operator_is_staff elif self.get_real_type() == BillableItemMixin.TRAINING: return f"{self.get_type_display()} training" elif self.get_real_type() == BillableItemMixin.CONSUMABLE: - withdrawal_enabled = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_enabled") - self_check = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_self_checkout") - staff_check = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_staff_checkout") - usage_event = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_usage_event") + withdrawal_enabled = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_consumable_withdrawal_enabled" + ) + self_check = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_consumable_withdrawal_self_checkout" + ) + staff_check = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_consumable_withdrawal_staff_checkout" + ) + usage_event = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_consumable_withdrawal_usage_event" + ) type_condition = True if not self_check: consumable_self_checked = ( @@ -168,9 +194,44 @@ def can_be_adjusted(self, user: User): type_condition = type_condition and not self.usage_event return withdrawal_enabled and time_limit_condition and type_condition and user_project_condition elif self.get_real_type() == BillableItemMixin.MISSED_RESERVATION: - missed_resa_enabled = UserRequestsCustomization.get_bool("adjustment_requests_missed_reservation_enabled") + missed_resa_enabled = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_missed_reservation_enabled" + ) return missed_resa_enabled and time_limit_condition and user_project_condition + def can_be_waived(self): + from NEMO.views.customization import AdjustmentRequestsCustomization + from NEMO.models import AreaAccessRecord, ConsumableWithdraw, Reservation, UsageEvent + + return ( + isinstance(self, AreaAccessRecord) + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_area_access_enabled") + or isinstance(self, UsageEvent) + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_tool_usage_enabled") + or isinstance(self, ConsumableWithdraw) + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_consumable_withdrawal_enabled") + or isinstance(self, Reservation) + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_missed_reservation_enabled") + ) + + def can_times_be_changed(item): + from NEMO.views.customization import AdjustmentRequestsCustomization + from NEMO.models import ConsumableWithdraw, Reservation + + can_change_reservation_times = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_missed_reservation_times" + ) + return ( + item + and not isinstance(item, ConsumableWithdraw) + and (not isinstance(item, Reservation) or can_change_reservation_times) + ) + + def can_quantity_be_changed(self): + from NEMO.models import ConsumableWithdraw + + return isinstance(self, ConsumableWithdraw) + def get_operator_action(self) -> str: if self.get_real_type() == BillableItemMixin.AREA_ACCESS: return "entered " diff --git a/NEMO/models.py b/NEMO/models.py index d3ca6b641..aa5a240d9 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import json import os import sys from datetime import timedelta @@ -9,7 +10,7 @@ from json import loads from logging import getLogger from re import match -from typing import List, Optional, Set, Union +from typing import Dict, List, Optional, Set, Union from django.conf import settings from django.contrib.auth.models import BaseUserManager, Group, Permission, PermissionsMixin @@ -32,6 +33,13 @@ from mptt.models import MPTTModel from NEMO import fields +from NEMO.constants import ( + ADDITIONAL_INFORMATION_MAXIMUM_LENGTH, + CHAR_FIELD_LARGE_LENGTH, + CHAR_FIELD_MEDIUM_LENGTH, + CHAR_FIELD_SMALL_LENGTH, + MEDIA_PROTECTED, +) from NEMO.mixins import BillableItemMixin, CalendarDisplayMixin, ConfigurationMixin, RecurrenceMixin from NEMO.typing import QuerySetType from NEMO.utilities import ( @@ -44,6 +52,7 @@ format_daterange, format_datetime, get_chemical_document_filename, + get_duration_with_off_schedule, get_full_url, get_hazard_logo_filename, get_model_instance, @@ -55,7 +64,6 @@ supported_embedded_extensions, ) from NEMO.validators import color_hex_list_validator, color_hex_validator -from NEMO.views.constants import ADDITIONAL_INFORMATION_MAXIMUM_LENGTH, CHAR_FIELD_MAXIMUM_LENGTH, MEDIA_PROTECTED from NEMO.widgets.configuration_editor import ConfigurationEditor models_logger = getLogger(__name__) @@ -131,7 +139,7 @@ def natural_key(self): class BaseCategory(SerializationByNameModel): - name = models.CharField(max_length=200, unique=True, help_text="The unique name for this item") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, unique=True, help_text="The unique name for this item") display_order = models.IntegerField( help_text="The display order is used to sort these items. The lowest value category is displayed first." ) @@ -146,15 +154,18 @@ def __str__(self): class BaseDocumentModel(BaseModel): document = models.FileField( - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, null=True, blank=True, upload_to=document_filename_upload, verbose_name="Document", ) - url = models.CharField(null=True, blank=True, max_length=200, verbose_name="URL") + url = models.URLField(null=True, blank=True, verbose_name="URL") name = models.CharField( - null=True, blank=True, max_length=200, help_text="The optional name to display for this document" + null=True, + blank=True, + max_length=CHAR_FIELD_MEDIUM_LENGTH, + help_text="The optional name to display for this document", ) display_order = models.IntegerField( default=1, @@ -322,7 +333,7 @@ class UserPreferences(BaseModel): ) staff_status_view = models.CharField( "staff_status_view", - max_length=100, + max_length=CHAR_FIELD_SMALL_LENGTH, default="day", choices=[("day", "Day"), ("week", "Week"), ("month", "Month")], help_text="Preferred view for staff status page", @@ -389,7 +400,7 @@ class UserPreferences(BaseModel): null=True, blank=True, default="60,7", - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The number of days to send a reminder before a recurring charge is due. A comma-separated list can be used for multiple reminders.", ) create_reservation_confirmation_override = models.BooleanField( @@ -441,7 +452,7 @@ class UserType(BaseCategory): class ProjectDiscipline(BaseCategory): - name = models.CharField(max_length=200, unique=True, help_text="The name of the discipline") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, unique=True, help_text="The name of the discipline") class PhysicalAccessLevel(BaseModel): @@ -455,7 +466,7 @@ class Schedule(object): (WEEKENDS, "Weekends"), ) - name = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) area = TreeForeignKey("Area", on_delete=models.CASCADE) schedule = models.IntegerField(choices=Schedule.Choices) weekdays_start_time = models.TimeField( @@ -625,7 +636,7 @@ class Meta: class Closure(BaseModel): name = models.CharField( - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name of this closure, that will be displayed as the policy problem and alert (if applicable).", ) alert_days_before = models.PositiveIntegerField( @@ -726,13 +737,15 @@ class OnboardingPhase(BaseCategory): class User(BaseModel, PermissionsMixin): # Personal information: - username = models.CharField(max_length=100, unique=True) - first_name = models.CharField(max_length=100) - last_name = models.CharField(max_length=100) + username = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, unique=True) + first_name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) + last_name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) email = models.EmailField(verbose_name="email address") type = models.ForeignKey(UserType, null=True, blank=True, on_delete=models.SET_NULL) domain = models.CharField( - max_length=100, blank=True, help_text="The Active Directory domain that the account resides on" + max_length=CHAR_FIELD_SMALL_LENGTH, + blank=True, + help_text="The Active Directory domain that the account resides on", ) onboarding_phases = models.ManyToManyField(OnboardingPhase, blank=True) safety_trainings = models.ManyToManyField(SafetyTraining, blank=True) @@ -1120,7 +1133,7 @@ class OperationMode(object): HYBRID = 2 Choices = ((REGULAR, "Regular"), (WAIT_LIST, "Wait List"), (HYBRID, "Hybrid")) - name = models.CharField(max_length=100, unique=True) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, unique=True) parent_tool = models.ForeignKey( "Tool", related_name="tool_children_set", @@ -1133,7 +1146,9 @@ class OperationMode(object): _description = models.TextField( db_column="description", null=True, blank=True, help_text="HTML syntax could be used" ) - _serial = models.CharField(db_column="serial", null=True, blank=True, max_length=100, help_text="Serial Number") + _serial = models.CharField( + db_column="serial", null=True, blank=True, max_length=CHAR_FIELD_SMALL_LENGTH, help_text="Serial Number" + ) _image = models.ImageField( db_column="image", upload_to=get_tool_image_filename, @@ -1151,7 +1166,7 @@ class OperationMode(object): db_column="category", null=True, blank=True, - max_length=1000, + max_length=CHAR_FIELD_LARGE_LENGTH, help_text='Create sub-categories using slashes. For example "Category 1/Sub-category 1".', ) _operational = models.BooleanField( @@ -1191,8 +1206,10 @@ class OperationMode(object): help_text="Users who can approve/deny adjustment requests for this tool. Defaults to facility managers if left blank.", ) # Extra info - _location = models.CharField(db_column="location", null=True, blank=True, max_length=100) - _phone_number = models.CharField(db_column="phone_number", null=True, blank=True, max_length=100) + _location = models.CharField(db_column="location", null=True, blank=True, max_length=CHAR_FIELD_SMALL_LENGTH) + _phone_number = models.CharField( + db_column="phone_number", null=True, blank=True, max_length=CHAR_FIELD_SMALL_LENGTH + ) _notification_email_address = models.EmailField( db_column="notification_email_address", blank=True, @@ -1231,7 +1248,7 @@ class OperationMode(object): ) _grant_badge_reader_access_upon_qualification = models.CharField( db_column="grant_badge_reader_access_upon_qualification", - max_length=100, + max_length=CHAR_FIELD_SMALL_LENGTH, null=True, blank=True, help_text="Badge reader access is granted to the user upon qualification for this tool.", @@ -2009,7 +2026,7 @@ class Meta(BaseDocumentModel.Meta): class ToolQualificationGroup(SerializationByNameModel): - name = models.CharField(max_length=200, unique=True, help_text="The name of this tool group") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, unique=True, help_text="The name of this tool group") tools = models.ManyToManyField(Tool, blank=False) def __str__(self): @@ -2028,7 +2045,7 @@ class Meta: class Configuration(BaseModel, ConfigurationMixin): name = models.CharField( - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name of this overall configuration. This text is displayed as a label on the tool control page.", ) tool = models.ForeignKey( @@ -2037,7 +2054,7 @@ class Configuration(BaseModel, ConfigurationMixin): configurable_item_name = models.CharField( blank=True, null=True, - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name of the tool part being configured. This text is displayed as a label on the tool control page. Leave this field blank if there is only one configuration slot.", ) advance_notice_limit = models.PositiveIntegerField( @@ -2066,7 +2083,10 @@ class Configuration(BaseModel, ConfigurationMixin): validators=[color_hex_list_validator], ) absence_string = models.CharField( - max_length=100, blank=True, null=True, help_text="The text that appears to indicate absence of a choice." + max_length=CHAR_FIELD_SMALL_LENGTH, + blank=True, + null=True, + help_text="The text that appears to indicate absence of a choice.", ) maintainers = models.ManyToManyField( User, blank=True, help_text="Select the users that are allowed to change this configuration." @@ -2128,7 +2148,7 @@ def __str__(self): class ConfigurationOption(BaseModel, ConfigurationMixin): - name = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) configuration = models.ForeignKey( Configuration, null=True, @@ -2145,7 +2165,7 @@ class ConfigurationOption(BaseModel, ConfigurationMixin): current_setting = models.CharField( null=True, blank=True, - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The current value for this configuration option", ) available_settings = models.TextField( @@ -2160,7 +2180,10 @@ class ConfigurationOption(BaseModel, ConfigurationMixin): validators=[color_hex_list_validator], ) absence_string = models.CharField( - max_length=100, blank=True, null=True, help_text="The text that appears to indicate absence of a choice." + max_length=CHAR_FIELD_SMALL_LENGTH, + blank=True, + null=True, + help_text="The text that appears to indicate absence of a choice.", ) def get_color(self): @@ -2181,6 +2204,9 @@ def __str__(self): selected = f", current value: {self.current_setting}" if self.current_setting else "" return f"{self.name}, options: {self.available_settings_as_list()}{selected}" + class Meta: + ordering = ["configuration__display_order"] + class TrainingSession(BaseModel, BillableItemMixin): class Type(object): @@ -2198,10 +2224,26 @@ class Type(object): qualified = models.BooleanField( default=False, help_text="Indicates that after this training session the user was qualified to use the tool." ) + usage_event = models.ForeignKey( + "UsageEvent", + null=True, + blank=True, + on_delete=models.CASCADE, + ) validated = models.BooleanField(default=False) validated_by = models.ForeignKey( User, null=True, blank=True, related_name="training_validated_set", on_delete=models.CASCADE ) + waived = models.BooleanField(default=False) + waived_on = models.DateTimeField(null=True, blank=True) + waived_by = models.ForeignKey( + User, null=True, blank=True, related_name="training_waived_set", on_delete=models.CASCADE + ) + + def clean(self): + errors = validate_waive_information(self) + if errors: + raise ValidationError(errors) class Meta: ordering = ["-date"] @@ -2221,6 +2263,16 @@ class StaffCharge(BaseModel, CalendarDisplayMixin, BillableItemMixin): validated_by = models.ForeignKey( User, null=True, blank=True, related_name="staff_charge_validated_set", on_delete=models.CASCADE ) + waived = models.BooleanField(default=False) + waived_on = models.DateTimeField(null=True, blank=True) + waived_by = models.ForeignKey( + User, null=True, blank=True, related_name="staff_charge_waived_set", on_delete=models.CASCADE + ) + + def clean(self): + errors = validate_waive_information(self) + if errors: + raise ValidationError(errors) class Meta: ordering = ["-start"] @@ -2231,7 +2283,7 @@ def __str__(self): class Area(MPTTModel): name = models.CharField( - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="What is the name of this area? The name will be displayed on the tablet login and logout pages.", ) parent_area = TreeForeignKey( @@ -2246,7 +2298,7 @@ class Area(MPTTModel): db_column="category", null=True, blank=True, - max_length=1000, + max_length=CHAR_FIELD_LARGE_LENGTH, help_text='Create sub-categories using slashes. For example "Category 1/Sub-category 1".', ) abuse_email: List[str] = fields.MultiEmailField( @@ -2504,6 +2556,16 @@ class AreaAccessRecord(BaseModel, CalendarDisplayMixin, BillableItemMixin): validated_by = models.ForeignKey( User, null=True, blank=True, related_name="area_access_validated_set", on_delete=models.CASCADE ) + waived = models.BooleanField(default=False) + waived_on = models.DateTimeField(null=True, blank=True) + waived_by = models.ForeignKey( + User, null=True, blank=True, related_name="area_access_waived_set", on_delete=models.CASCADE + ) + + def clean(self): + errors = validate_waive_information(self) + if errors: + raise ValidationError(errors) class Meta: indexes = [ @@ -2513,12 +2575,22 @@ class Meta: def __str__(self): return str(self.id) + def validate_unique(self, exclude=None): + super().validate_unique(exclude) + already_logged_in = ( + AreaAccessRecord.objects.filter(customer_id=self.customer_id, area_id=self.area_id, end=None) + .exclude(id=self.id) + .exists() + ) + if self.area and self.customer and not self.end and already_logged_in: + raise ValidationError(_("You are already logged in to this area")) + class ConfigurationHistory(BaseModel): configuration = models.ForeignKey(Configuration, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) modification_time = models.DateTimeField(default=timezone.now) - item_name = models.CharField(null=True, blank=False, max_length=CHAR_FIELD_MAXIMUM_LENGTH) + item_name = models.CharField(null=True, blank=False, max_length=CHAR_FIELD_MEDIUM_LENGTH) slot = models.PositiveIntegerField() setting = models.TextField() @@ -2535,7 +2607,7 @@ class AccountType(BaseCategory): class Account(SerializationByNameModel): - name = models.CharField(max_length=100, unique=True) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, unique=True) type = models.ForeignKey(AccountType, null=True, blank=True, on_delete=models.SET_NULL) start_date = models.DateField(null=True, blank=True) active = models.BooleanField( @@ -2560,8 +2632,8 @@ def __str__(self): class Project(SerializationByNameModel): - name = models.CharField(max_length=100, unique=True) - application_identifier = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_LARGE_LENGTH, unique=True) + application_identifier = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) start_date = models.DateField(null=True, blank=True) account = models.ForeignKey( Account, @@ -2587,9 +2659,11 @@ class Meta: ordering = ["name"] def display_with_pis(self): + from NEMO.templatetags.custom_tags_and_filters import project_selection_display + pis = ", ".join([pi.get_name() for pi in self.manager_set.all()]) pis = f" (PI{'s' if self.manager_set.count() > 1 else ''}: {pis})" if pis else "" - return f"{self.name}{pis}" + return f"{project_selection_display(self)}{pis}" def display_with_status(self): return f"{'[INACTIVE] ' if not self.active else ''}{self.name}" @@ -2674,7 +2748,7 @@ class Reservation(BaseModel, CalendarDisplayMixin, BillableItemMixin): title = models.TextField( default="", blank=True, - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="Shows a custom title for this reservation on the calendar. Leave this field blank to display the reservation's user name as the title (which is the default behaviour).", ) question_data = models.TextField(null=True, blank=True) @@ -2682,6 +2756,11 @@ class Reservation(BaseModel, CalendarDisplayMixin, BillableItemMixin): validated_by = models.ForeignKey( User, null=True, blank=True, related_name="reservation_validated_set", on_delete=models.CASCADE ) + waived = models.BooleanField(default=False) + waived_on = models.DateTimeField(null=True, blank=True) + waived_by = models.ForeignKey( + User, null=True, blank=True, related_name="reservation_waived_set", on_delete=models.CASCADE + ) @property def reservation_item(self) -> Union[Tool, Area]: @@ -2713,6 +2792,21 @@ def reservation_item_filter(self): def duration(self): return self.end - self.start + def duration_for_policy(self): + # This method returns the duration that counts for policy checks. + # i.e. reservation duration minus any time when the policy is off + item = self.reservation_item + if item and isinstance(item, (Tool, Area)): + return get_duration_with_off_schedule( + self.start, + self.end, + item.policy_off_weekend, + item.policy_off_between_times, + item.policy_off_start_time, + item.policy_off_end_time, + ) + return self.duration() + def has_not_ended(self): return False if self.end < timezone.now() else True @@ -2772,6 +2866,11 @@ def copy(self, new_start: datetime = None, new_end: datetime = None): new_reservation._deferred_related_models = deferred_related_models return new_reservation + def clean(self): + errors = validate_waive_information(self) + if errors: + raise ValidationError(errors) + class Meta: ordering = ["-start"] @@ -2780,7 +2879,8 @@ def __str__(self): class ReservationQuestions(BaseModel): - name = models.CharField(max_length=100, help_text="The name of this ") + enabled = models.BooleanField(default=True) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, help_text="The name of this ") questions = models.TextField( help_text="Upon making a reservation, the user will be asked these questions. This field will only accept JSON format" ) @@ -2822,21 +2922,54 @@ class UsageEvent(BaseModel, CalendarDisplayMixin, BillableItemMixin): User, null=True, blank=True, related_name="usage_event_validated_set", on_delete=models.CASCADE ) remote_work = models.BooleanField(default=False) + training = models.BooleanField(default=False) pre_run_data = models.TextField(null=True, blank=True) run_data = models.TextField(null=True, blank=True) + waived = models.BooleanField(default=False) + waived_on = models.DateTimeField(null=True, blank=True) + waived_by = models.ForeignKey( + User, null=True, blank=True, related_name="usage_event_waived_set", on_delete=models.CASCADE + ) + + def clean(self): + errors = validate_waive_information(self) + if errors: + raise ValidationError(errors) + + def self_usage(self): + return self.user == self.operator def duration(self): return calculate_duration(self.start, self.end, "In progress") + def duration_minutes(self) -> int: + return int(self.duration().total_seconds() // 60) + + def pre_run_data_json(self): + return loads(self.pre_run_data) if self.pre_run_data else None + + def post_run_data_json(self): + return loads(self.run_data) if self.run_data else None + class Meta: ordering = ["-start"] def __str__(self): return str(self.id) + def validate_unique(self, exclude=None): + super().validate_unique(exclude) + tool_already_in_use = ( + UsageEvent.objects.filter(tool_id__in=self.tool.get_family_tool_ids(), end=None) + .exclude(id=self.id) + .exists() + ) + if self.tool and not self.end and tool_already_in_use: + raise ValidationError(_("This tool is already in use")) + class Consumable(BaseModel): - name = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) category = models.ForeignKey("ConsumableCategory", blank=True, null=True, on_delete=models.CASCADE) quantity = models.IntegerField(help_text="The number of items currently in stock.") reusable = models.BooleanField( @@ -2903,7 +3036,7 @@ def check_consumable_quantity_threshold(sender, instance: Consumable, **kwargs): class ConsumableCategory(BaseModel): - name = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) class Meta: ordering = ["name"] @@ -2945,6 +3078,11 @@ class ConsumableWithdraw(BaseModel, BillableItemMixin): validated_by = models.ForeignKey( User, null=True, blank=True, related_name="consumable_withdrawal_validated_set", on_delete=models.CASCADE ) + waived = models.BooleanField(default=False) + waived_on = models.DateTimeField(null=True, blank=True) + waived_by = models.ForeignKey( + User, null=True, blank=True, related_name="consumable_withdrawal_waived_set", on_delete=models.CASCADE + ) class Meta: ordering = ["-date"] @@ -2986,6 +3124,7 @@ def clean(self): policy.check_billing_to_project(self.project, self.customer, self.consumable, self) except ProjectChargeException as e: errors["project"] = e.msg + errors.update(validate_waive_information(self)) if errors: raise ValidationError(errors) @@ -2994,7 +3133,9 @@ def __str__(self): class RecurringConsumableCharge(BaseModel, RecurrenceMixin): - name = models.CharField(max_length=200, help_text="The name/identifier for this recurring charge.") + name = models.CharField( + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name/identifier for this recurring charge." + ) customer = models.ForeignKey( User, null=True, @@ -3151,17 +3292,29 @@ def __str__(self): class InterlockCard(BaseModel): - name = models.CharField(max_length=100, blank=True, null=True) - server = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, blank=True, null=True) + server = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) port = models.PositiveIntegerField() number = models.PositiveIntegerField(blank=True, null=True) even_port = models.PositiveIntegerField(blank=True, null=True) odd_port = models.PositiveIntegerField(blank=True, null=True) category = models.ForeignKey("InterlockCardCategory", blank=False, null=False, on_delete=models.CASCADE, default=1) - username = models.CharField(max_length=100, blank=True, null=True) - password = models.CharField(max_length=100, blank=True, null=True) + username = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, blank=True, null=True) + password = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, blank=True, null=True) + extra_args = models.TextField( + null=True, + blank=True, + help_text=_("Json formatted extra arguments to pass to the interlock card implementation."), + ) enabled = models.BooleanField(blank=False, null=False, default=True) + @property + def extra_args_dict(self): + try: + return json.loads(self.extra_args) + except: + return {} + class Meta: ordering = ["server", "number"] @@ -3185,7 +3338,7 @@ class State(object): (LOCKED, "Locked"), ) - name = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MAXIMUM_LENGTH) + name = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH) card = models.ForeignKey(InterlockCard, on_delete=models.CASCADE) channel = models.PositiveIntegerField(blank=True, null=True, verbose_name="Channel/Relay/Coil") unit_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="Multiplier/Unit id/Bank") @@ -3211,6 +3364,7 @@ def __str__(self): category = self.card.category if self.card else None channel_name = interlocks.get(category, raise_exception=False).channel_name + unit_id_name = interlocks.get(category, raise_exception=False).unit_id_name display_name = "" if self.name: display_name += f"{self.name}" @@ -3218,12 +3372,18 @@ def __str__(self): if self.name: display_name += ", " display_name += f"{channel_name} " + str(self.channel) + if self.unit_id: + if self.name or self.channel: + display_name += ", " + display_name += f"{unit_id_name} " + str(self.unit_id) return str(self.card) + (f", {display_name}" if display_name else "") class InterlockCardCategory(BaseModel): - name = models.CharField(max_length=200, help_text="The name for this interlock category") - key = models.CharField(max_length=100, help_text="The key to identify this interlock category by in interlocks.py") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name for this interlock category") + key = models.CharField( + max_length=CHAR_FIELD_SMALL_LENGTH, help_text="The key to identify this interlock category by in interlocks.py" + ) class Meta: verbose_name_plural = "Interlock card categories" @@ -3385,7 +3545,7 @@ class Stage(object): (COMPLETION, "Completion"), ) - name = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) stage = models.IntegerField(choices=Stage.Choices) class Meta: @@ -3397,7 +3557,7 @@ def __str__(self): class TaskStatus(SerializationByNameModel): - name = models.CharField(max_length=200, unique=True) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, unique=True) notify_primary_tool_owner = models.BooleanField( default=False, help_text="Notify the primary tool owner when a task transitions to this status" ) @@ -3429,7 +3589,7 @@ class TaskHistory(BaseModel): related_name="history", on_delete=models.CASCADE, ) - status = models.CharField(max_length=200, help_text="A text description of the task's status") + status = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="A text description of the task's status") time = models.DateTimeField(auto_now_add=True, help_text="The date and time when the task status was changed") user = models.ForeignKey(User, help_text="The user that changed the task to this status", on_delete=models.CASCADE) @@ -3466,7 +3626,7 @@ def __str__(self): class ResourceCategory(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) def __str__(self): return str(self.name) @@ -3477,7 +3637,7 @@ class Meta: class Resource(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) category = models.ForeignKey(ResourceCategory, blank=True, null=True, on_delete=models.SET_NULL) available = models.BooleanField(default=True, help_text="Indicates whether the resource is available to be used.") fully_dependent_tools = models.ManyToManyField( @@ -3621,12 +3781,17 @@ def calculate_duration(start, end, unfinished_reason): class Door(BaseModel): - name = models.CharField(max_length=100) + name = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH) welcome_message = models.TextField( null=True, blank=True, help_text="The welcome message will be displayed on the tablet login page. You can use HTML and JavaScript.", ) + farewell_message = models.TextField( + null=True, + blank=True, + help_text="The farewell message will be displayed on the tablet logout page. You can use HTML and JavaScript.", + ) areas = TreeManyToManyField(Area, related_name="doors", blank=False) interlock = models.OneToOneField(Interlock, null=True, blank=True, on_delete=models.PROTECT) @@ -3645,7 +3810,7 @@ class SafetyIssue(BaseModel): reporter = models.ForeignKey( User, blank=True, null=True, related_name="reported_safety_issues", on_delete=models.SET_NULL ) - location = models.CharField(null=True, blank=True, max_length=200) + location = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH) creation_time = models.DateTimeField(auto_now_add=True) visible = models.BooleanField( default=True, @@ -3678,7 +3843,7 @@ class Meta(BaseCategory.Meta): class SafetyItem(BaseModel): - name = models.CharField(max_length=200, help_text="The safety item name.") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The safety item name.") description = models.TextField( null=True, blank=True, help_text="The description for this safety item. HTML can be used." ) @@ -3710,7 +3875,7 @@ class Meta(BaseDocumentModel.Meta): class AlertCategory(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) class Meta: ordering = ["name"] @@ -3721,8 +3886,10 @@ def __str__(self): class Alert(BaseModel): - title = models.CharField(blank=True, max_length=150) - category = models.CharField(blank=True, max_length=200, help_text="A category/type for this alert.") + title = models.CharField(blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH) + category = models.CharField( + blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="A category/type for this alert." + ) contents = models.TextField() creation_time = models.DateTimeField(default=timezone.now) creator = models.ForeignKey(User, null=True, blank=True, related_name="+", on_delete=models.SET_NULL) @@ -3765,8 +3932,8 @@ class Meta(BaseCategory.Meta): class ContactInformation(BaseModel): - name = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH) - title = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, blank=True, null=True) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) + title = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, blank=True, null=True) image = models.ImageField( blank=True, help_text="Portraits are resized to 266 pixels high and 200 pixels wide. Crop portraits to these dimensions before uploading for optimal bandwidth usage", @@ -3774,7 +3941,7 @@ class ContactInformation(BaseModel): category = models.ForeignKey(ContactInformationCategory, on_delete=models.CASCADE) email = models.EmailField(blank=True) office_phone = models.CharField(max_length=40, blank=True) - office_location = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, blank=True) + office_location = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, blank=True) mobile_phone = models.CharField(max_length=40, blank=True) mobile_phone_is_sms_capable = models.BooleanField( default=True, @@ -3821,7 +3988,7 @@ class Types: user = models.ForeignKey(User, related_name="notifications", on_delete=models.CASCADE) expiration = models.DateTimeField() - notification_type = models.CharField(max_length=100, choices=Types.Choices) + notification_type = models.CharField(max_length=CHAR_FIELD_SMALL_LENGTH, choices=Types.Choices) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey("content_type", "object_id") @@ -3831,9 +3998,11 @@ class LandingPageChoice(BaseModel): image = models.ImageField( help_text="An image that symbolizes the choice. It is automatically resized to 128x128 pixels when displayed, so set the image to this size before uploading to optimize bandwidth usage and landing page load time" ) - name = models.CharField(max_length=40, help_text="The textual name that will be displayed underneath the image") + name = models.CharField( + max_length=CHAR_FIELD_SMALL_LENGTH, help_text="The textual name that will be displayed underneath the image" + ) url = models.CharField( - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, verbose_name="URL", help_text="The URL that the choice leads to when clicked. Relative paths such as /calendar/ are used when linking within the site. Use fully qualified URL paths such as https://www.google.com/ to link to external sites.", ) @@ -3862,7 +4031,7 @@ class LandingPageChoice(BaseModel): help_text="Hides this choice from staff and technicians. When checked, only normal users, facility managers and super-users can see the choice", ) notifications = models.CharField( - max_length=100, + max_length=CHAR_FIELD_SMALL_LENGTH, blank=True, null=True, choices=Notification.Types.Choices, @@ -3877,7 +4046,7 @@ def __str__(self): class Customization(BaseModel): - name = models.CharField(primary_key=True, max_length=100) + name = models.CharField(primary_key=True, max_length=CHAR_FIELD_SMALL_LENGTH) value = models.TextField() class Meta: @@ -3888,7 +4057,7 @@ def __str__(self): class ScheduledOutageCategory(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) class Meta: ordering = ["name"] @@ -3902,14 +4071,16 @@ class ScheduledOutage(BaseModel): start = models.DateTimeField() end = models.DateTimeField() creator = models.ForeignKey(User, on_delete=models.CASCADE) - title = models.CharField(max_length=100, help_text="A brief description to quickly inform users about the outage") + title = models.CharField( + max_length=CHAR_FIELD_SMALL_LENGTH, help_text="A brief description to quickly inform users about the outage" + ) details = models.TextField( blank=True, help_text="A detailed description of why there is a scheduled outage, and what users can expect during the outage", ) category = models.CharField( blank=True, - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="A categorical reason for why this outage is scheduled. Useful for trend analytics.", ) tool = models.ForeignKey(Tool, blank=True, null=True, on_delete=models.CASCADE) @@ -3918,7 +4089,7 @@ class ScheduledOutage(BaseModel): reminder_days = models.CharField( null=True, blank=True, - max_length=200, + max_length=CHAR_FIELD_MEDIUM_LENGTH, validators=[validate_comma_separated_integer_list], help_text="The number of days to send a reminder before a scheduled outage. A comma-separated list can be used for multiple reminders.", ) @@ -3985,7 +4156,7 @@ def __str__(self): class News(BaseModel): - title = models.CharField(max_length=200) + title = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) pinned = models.BooleanField( default=False, help_text="Check this box to keep this story at the top of the news feed" ) @@ -4011,7 +4182,7 @@ class Meta: class BadgeReader(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) send_key = models.CharField( max_length=20, help_text="The name of the key which submits the badge number ('F2', 'Shift', 'Meta', 'Enter', 'a' etc.)", @@ -4036,43 +4207,103 @@ def default(): class ToolUsageCounter(BaseModel): - name = models.CharField(max_length=200, help_text="The name of this counter") + class CounterDirection(object): + INCREMENT = +1 + DECREMENT = -1 + Choices = ( + (INCREMENT, _("Increment")), + (DECREMENT, _("Decrement")), + ) + + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text=_("The name of this counter")) description = models.TextField( - null=True, blank=True, help_text="The counter description to be displayed next to it on the tool control page" + null=True, + blank=True, + help_text=_("The counter description to be displayed next to it on the tool control page"), ) - value = models.FloatField(default=0, help_text="The current value of this counter") - tool = models.ForeignKey(Tool, help_text="The tool this counter is for.", on_delete=models.CASCADE) + value = models.FloatField(help_text=_("The current value of this counter")) + default_value = models.FloatField(help_text=_("The default value to reset this counter to")) + counter_direction = models.IntegerField(default=CounterDirection.INCREMENT, choices=CounterDirection.Choices) + tool = models.ForeignKey(Tool, help_text=_("The tool this counter is for."), on_delete=models.CASCADE) tool_usage_question = models.CharField( - max_length=200, - help_text="The name of the tool's post usage question which should be used to increment this counter", + max_length=CHAR_FIELD_MEDIUM_LENGTH, + help_text=_("The name of the tool's post usage question which should be used to increment this counter"), + ) + staff_members_can_reset = models.BooleanField( + default=True, help_text=_("Check this box to allow staff to reset this counter") + ) + superusers_can_reset = models.BooleanField( + default=False, help_text=_("Check this box to allow tool superusers to reset this counter") + ) + qualified_users_can_reset = models.BooleanField( + default=False, help_text=_("Check this box to allow qualified users to reset this counter") + ) + last_reset_value = models.FloatField( + null=True, blank=True, help_text=_("The last value before the counter was reset") + ) + last_reset = models.DateTimeField( + null=True, blank=True, help_text=_("The date and time this counter was last reset") ) - last_reset_value = models.FloatField(null=True, blank=True, help_text="The last value before the counter was reset") - last_reset = models.DateTimeField(null=True, blank=True, help_text="The date and time this counter was last reset") last_reset_by = models.ForeignKey( - User, null=True, blank=True, help_text="The user who last reset this counter", on_delete=models.SET_NULL + User, null=True, blank=True, help_text=_("The user who last reset this counter"), on_delete=models.SET_NULL + ) + email_facility_managers_when_reset = models.BooleanField( + default=True, help_text=_("Check this box to email facility managers when this counter is reset") ) warning_threshold = models.FloatField( null=True, blank=True, - help_text="When set in combination with the email address, a warning email will be sent when the counter reaches this value.", + help_text=_( + "When set in combination with the email address, a warning email will be sent when the counter reaches this value." + ), ) warning_email = fields.MultiEmailField( - null=True, blank=True, help_text="The address to send the warning email to. A comma-separated list can be used." + null=True, + blank=True, + help_text=_("The address to send the warning email to. A comma-separated list can be used."), ) warning_threshold_reached = models.BooleanField(default=False) - is_active = models.BooleanField(default=True, help_text="The state of the counter") + is_active = models.BooleanField(default=True, help_text=_("The state of the counter")) def value_color(self): color = None if self.warning_threshold: - if self.value < self.warning_threshold: + effective_value = self.counter_direction * self.value + effective_warning_threshold = self.counter_direction * self.warning_threshold + if effective_value < effective_warning_threshold: color = "success" - elif self.value == self.warning_threshold: + elif effective_value == effective_warning_threshold: color = "warning" - elif self.value > self.warning_threshold: + elif effective_value > effective_warning_threshold: color = "danger" return bootstrap_primary_color(color) + def reset_permitted_users(self) -> QuerySetType[User]: + user_filter = Q(is_facility_manager=True) | Q(is_superuser=True) + if self.staff_members_can_reset: + user_filter |= Q(is_staff=True) + if self.superusers_can_reset: + user_filter |= Q(superuser_for_tools__in=[self.tool]) + if self.qualified_users_can_reset: + user_filter |= Q(id__in=Qualification.objects.filter(tool=self.tool).values_list("user_id", flat=True)) + return User.objects.filter(Q(is_active=True) & user_filter).distinct() + + def clean(self): + errors = {} + if self.warning_threshold: + effective_warning_threshold = self.counter_direction * self.warning_threshold + effective_default_value = self.counter_direction * self.default_value + if effective_default_value > effective_warning_threshold: + errors.update( + { + "warning_threshold": _( + f"The warning threshold ({self.warning_threshold}) needs to be {'higher' if self.counter_direction > 0 else 'lower'} than the default value ({self.default_value})" + ) + } + ) + if errors: + raise ValidationError(errors) + def __str__(self): return str(self.name) @@ -4085,17 +4316,19 @@ class Meta: def check_tool_usage_counter_threshold(sender, instance: ToolUsageCounter, **kwargs): try: if instance.warning_threshold: + effective_warning_threshold = instance.counter_direction * instance.warning_threshold + effective_value = instance.counter_direction * instance.value if ( instance.is_active and not instance.warning_threshold_reached - and instance.value >= instance.warning_threshold + and effective_value >= effective_warning_threshold ): - # value is over threshold. set flag and send email + # value is under/over threshold. set flag and send email instance.warning_threshold_reached = True from NEMO.views.tool_control import send_tool_usage_counter_email send_tool_usage_counter_email(instance) - if instance.warning_threshold_reached and instance.value < instance.warning_threshold: + if instance.warning_threshold_reached and effective_value < effective_warning_threshold: # it has been reset. reset flag instance.warning_threshold_reached = False except Exception as e: @@ -4161,6 +4394,7 @@ class AdjustmentRequest(BaseModel): new_start = models.DateTimeField(null=True, blank=True) new_end = models.DateTimeField(null=True, blank=True) new_quantity = models.PositiveIntegerField(null=True, blank=True) + waive = models.BooleanField(default=False) status = models.IntegerField(choices=RequestStatus.choices_without_expired(), default=RequestStatus.PENDING) reviewer = models.ForeignKey( "User", null=True, blank=True, related_name="adjustment_requests_reviewed", on_delete=models.CASCADE @@ -4202,7 +4436,6 @@ def get_new_end(self) -> Optional[datetime]: def get_quantity_difference(self) -> int: if self.item and self.new_quantity is not None: return self.new_quantity - self.item.quantity - return 0 def get_time_difference(self) -> str: if self.item and self.new_start and self.new_end: @@ -4217,10 +4450,18 @@ def get_time_difference(self) -> str: ) def get_difference(self): - return self.get_time_difference() or self.get_quantity_difference() + if self.waive: + return "Waived" if self.item and self.item.waived else "Waive requested" + else: + return (self.get_time_difference() or self.get_quantity_difference()) if self.item else "" def adjustable_charge(self): - return self.has_changed_time() or isinstance(self.item, Reservation) or self.get_quantity_difference() + return ( + self.waive + or self.has_changed_time() + or isinstance(self.item, Reservation) + or self.get_quantity_difference() + ) def has_changed_time(self) -> bool: """Returns whether the original charge is editable, i.e. if it has a changed start or end""" @@ -4236,8 +4477,9 @@ def creator_and_reply_users(self) -> List[User]: def reviewers(self) -> QuerySetType[User]: # Create the list of users to notify/show request to. If the adjustment request has a tool/area and their # list of reviewers is empty, send/show to all facility managers - tool: Tool = getattr(self.item, "tool", None) if self.item else None - area: Area = getattr(self.item, "area", None) if self.item else None + item = get_model_instance(self.item_type, self.item_id) + tool: Tool = getattr(item, "tool", None) if item else None + area: Area = getattr(item, "area", None) if item else None facility_managers = User.objects.filter(is_active=True, is_facility_manager=True) if tool: tool_reviewers = tool._adjustment_request_reviewers.filter(is_active=True) @@ -4249,7 +4491,12 @@ def reviewers(self) -> QuerySetType[User]: def apply_adjustment(self, user): if self.status == RequestStatus.APPROVED: - if self.has_changed_time(): + if self.waive: + self.item.waive(user) + self.applied = True + self.applied_by = user + self.save() + elif self.has_changed_time(): new_start = self.get_new_start() new_end = self.get_new_end() if new_start: @@ -4266,11 +4513,9 @@ def apply_adjustment(self, user): self.applied = True self.applied_by = user elif isinstance(self.item, Reservation): - self.item.missed = False - self.item.save() - self.applied = True - self.applied_by = user - self.save() + # in this case the times have not been changed so we are essentially waiving the charge + self.waive = True + self.apply_adjustment(user) def delete(self, using=None, keep_parents=False): adjustment_id = self.id @@ -4284,6 +4529,14 @@ def delete(self, using=None, keep_parents=False): ], ).delete() + def save(self, *args, **kwargs): + # We are removing new start, new end and new quantity just in case + if self.waive: + self.new_end = None + self.new_start = None + self.new_quantity = None + super().save(*args, **kwargs) + def clean(self): if not self.description: raise ValidationError({"description": _("This field is required.")}) @@ -4298,15 +4551,6 @@ def clean(self): raise ValidationError({NON_FIELD_ERRORS: _("There is already an adjustment request for this charge")}) if self.new_start and self.new_end and self.new_start > self.new_end: raise ValidationError({"new_end": _("The end must be later than the start")}) - if ( - self.new_start - and format_datetime(self.new_start) == format_datetime(item.start) - and self.new_end - and format_datetime(self.new_end) == format_datetime(item.end) - ) or (self.new_quantity is not None and self.new_quantity == item.quantity): - raise ValidationError( - {NON_FIELD_ERRORS: _("You must change at least one attribute (dates or quantity)")} - ) class Meta: ordering = ["-creation_time"] @@ -4325,9 +4569,9 @@ class Meta: class StaffAbsenceType(BaseModel): - name = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, help_text="The name of this absence type.") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The name of this absence type.") description = models.CharField( - max_length=CHAR_FIELD_MAXIMUM_LENGTH, help_text="The description for this absence type." + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The description for this absence type." ) def __str__(self): @@ -4435,10 +4679,10 @@ class ChemicalHazard(BaseCategory): class Chemical(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH) hazards = models.ManyToManyField(ChemicalHazard, blank=True, help_text="Select the hazards for this chemical.") document = models.FileField(null=True, blank=True, upload_to=get_chemical_document_filename, max_length=500) - url = models.CharField(null=True, blank=True, max_length=200, verbose_name="URL") + url = models.URLField(null=True, blank=True, verbose_name="URL") keywords = models.TextField(null=True, blank=True) class Meta: @@ -4511,7 +4755,7 @@ class Meta(BaseCategory.Meta): class StaffKnowledgeBaseItem(BaseModel): - name = models.CharField(max_length=200, help_text="The item name.") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The item name.") description = models.TextField(null=True, blank=True, help_text="The description for this item. HTML can be used.") category = models.ForeignKey( StaffKnowledgeBaseCategory, @@ -4550,7 +4794,7 @@ class Meta(BaseCategory.Meta): class UserKnowledgeBaseItem(BaseModel): - name = models.CharField(max_length=200, help_text="The item name.") + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text="The item name.") description = models.TextField(null=True, blank=True, help_text="The description for this item. HTML can be used.") category = models.ForeignKey( UserKnowledgeBaseCategory, @@ -4585,9 +4829,9 @@ class Meta(BaseDocumentModel.Meta): class ToolCredentials(BaseModel): tool = models.ForeignKey(Tool, on_delete=models.CASCADE) - username = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MAXIMUM_LENGTH) - password = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MAXIMUM_LENGTH) - comments = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MAXIMUM_LENGTH) + username = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH) + password = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH) + comments = models.CharField(null=True, blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH) authorized_staff = models.ManyToManyField( User, blank=True, @@ -4605,7 +4849,7 @@ class EmailLog(BaseModel): when = models.DateTimeField(null=False, auto_now_add=True) sender = models.EmailField(null=False, blank=False) to = models.TextField(null=False, blank=False) - subject = models.CharField(null=False, max_length=254) + subject = models.CharField(null=False, max_length=CHAR_FIELD_MEDIUM_LENGTH) content = models.TextField(null=False) ok = models.BooleanField(null=False, default=True) attachments = models.TextField(null=True) @@ -4614,6 +4858,16 @@ class Meta: ordering = ["-when"] +def validate_waive_information(item: [BillableItemMixin]) -> Dict: + errors = {} + if item.waived: + if not item.waived_by: + errors["waived_by"] = _("This field is required") + if not item.waived_on: + errors["waived_on"] = _("This field is required") + return errors + + def record_remote_many_to_many_changes_and_save(request, obj, form, change, many_to_many_field, save_function_pointer): """ TODO: This should be done through pre/post save diff --git a/NEMO/plugins/__init__.py b/NEMO/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/NEMO/plugins/utils.py b/NEMO/plugins/utils.py new file mode 100644 index 000000000..b43ecafd7 --- /dev/null +++ b/NEMO/plugins/utils.py @@ -0,0 +1,76 @@ +import importlib.metadata +from logging import getLogger +from typing import List, Optional, Union, Tuple + +from django.core.exceptions import FieldDoesNotExist +from django.db.models import Field +from django.http import HttpResponse +from django.shortcuts import render +from packaging.requirements import Requirement +from packaging.version import InvalidVersion, Version + +utils_logger = getLogger(__name__) + + +# Useful function to render and combine 2 separate django templates +def render_combine_responses(request, original_response: HttpResponse, template_name, context): + """Combines contents of an original http response with a new one""" + additional_content = render(request, template_name, context) + original_response.content += additional_content.content + return original_response + + +# Checks that at least one requirement in the list is satisfied and returns the first one +def is_any_requirement_satisfied( + requirements: List[Union[Requirement, str]], prereleases=False +) -> Optional[Requirement]: + for requirement in requirements: + try: + if isinstance(requirement, str): + requirement = Requirement(requirement) + dist_version = importlib.metadata.version(requirement.name) + if check_version_satisfies(dist_version, requirement, prereleases): + return requirement + except (importlib.metadata.PackageNotFoundError, InvalidVersion): + continue + return None + + +def check_version_satisfies(installed_version, requirement, prereleases=False) -> bool: + """Check if the installed version satisfies the requirement.""" + installed_version = Version(installed_version) + for specifier in requirement.specifier: + if not specifier.contains(installed_version, prereleases=prereleases): + return False + return True + + +def get_extra_requires(app_name, extra_name: str) -> List[Requirement]: + requirements: List[Requirement] = [] + distribution = importlib.metadata.distribution(app_name) + requires = distribution.requires or [] + for req in requires: + requirement = Requirement(req) + if requirement.marker and requirement.marker.evaluate({"extra": extra_name}): + requirements.append(requirement) + return requirements + + +# Use this function in apps config ready function to add new types of notifications to NEMO +def add_dynamic_notification_types(notification_types: List[Tuple[str, str]]): + from NEMO.models import Notification, LandingPageChoice + + add_dynamic_notifications_to_field(Notification._meta.get_field("notification_type"), notification_types) + add_dynamic_notifications_to_field(LandingPageChoice._meta.get_field("notifications"), notification_types) + + +def add_dynamic_notifications_to_field(field: Field, notification_types: List[Tuple[str, str]]): + try: + original_choices = list(field.choices) + + for notification_type in notification_types: + if notification_type not in original_choices: + original_choices.append(notification_type) + field.choices = original_choices + except FieldDoesNotExist: + utils_logger.exception("Error adding dynamic notifications: {}".format(notification_types)) diff --git a/NEMO/policy.py b/NEMO/policy.py index d94f879fe..f5b80d1f0 100644 --- a/NEMO/policy.py +++ b/NEMO/policy.py @@ -38,10 +38,12 @@ ) from NEMO.utilities import ( EmailCategory, + beginning_of_the_day, distinct_qs_value_list, format_daterange, format_datetime, get_class_from_settings, + get_local_date_times_for_item_policy_times, render_email_template, send_mail, ) @@ -603,23 +605,41 @@ def should_enforce_reservation_policy(self, reservation: Reservation) -> bool: should_enforce = True item = reservation.reservation_item - start_time = timezone.localtime(reservation.start) - end_time = timezone.localtime(reservation.end) - if item.policy_off_weekend and start_time.weekday() >= 5 and end_time.weekday() >= 5: + start_time = reservation.start.astimezone() + end_time = reservation.end.astimezone() + if ( + item.policy_off_weekend + and start_time.weekday() >= 5 + and end_time.weekday() >= 5 + and reservation.duration() <= timedelta(days=2) + ): should_enforce = False if item.policy_off_between_times and item.policy_off_start_time and item.policy_off_end_time: if item.policy_off_start_time <= item.policy_off_end_time: """Range is similar to 6am-6pm""" + policy_duration = datetime.combine(datetime.today(), item.policy_off_start_time) - datetime.combine( + datetime.today(), item.policy_off_end_time + ) + duration_ok = reservation.duration() <= policy_duration if ( - item.policy_off_start_time <= start_time.time() <= item.policy_off_end_time + duration_ok + and item.policy_off_start_time <= start_time.time() <= item.policy_off_end_time and item.policy_off_start_time <= end_time.time() <= item.policy_off_end_time ): should_enforce = False else: """Range is similar to 6pm-6am""" + policy_duration = datetime.combine( + datetime.today() + timedelta(days=1), item.policy_off_end_time + ) - datetime.combine(datetime.today(), item.policy_off_start_time) + duration_ok = reservation.duration() <= policy_duration if ( - item.policy_off_start_time <= start_time.time() or start_time.time() <= item.policy_off_end_time - ) and (item.policy_off_start_time <= end_time.time() or end_time.time() <= item.policy_off_end_time): + duration_ok + and ( + item.policy_off_start_time <= start_time.time() or start_time.time() <= item.policy_off_end_time + ) + and (item.policy_off_start_time <= end_time.time() or end_time.time() <= item.policy_off_end_time) + ): should_enforce = False return should_enforce @@ -630,29 +650,35 @@ def check_reservation_policy_for_item( cancelled_reservation: Optional[Reservation], ) -> List[str]: item_policy_problems = [] - # Calculate the duration of the reservation: - duration = new_reservation.end - new_reservation.start + # This method checks reservation policy for reservations that are either outside of policy off time + # Or that overlap with some off time # The reservation must be at least as long as the minimum block time for this item. # Staff may break this rule. # An explicit policy override allows this rule to be broken. + # Policy off: whether part of the reservation is during off time or not, we are using the real reservation time item = new_reservation.reservation_item item_type = new_reservation.reservation_item_type if item.minimum_usage_block_time: + # use real duration regardless of policy + duration = new_reservation.duration() minimum_block_time = timedelta(minutes=item.minimum_usage_block_time) if duration < minimum_block_time: item_policy_problems.append( - f"Your reservation has a duration of {str(int(duration.total_seconds() / 60))} minutes. This {item_type.value} requires a minimum reservation duration of {str(int(minimum_block_time.total_seconds() / 60))} minutes." + f"Your reservation has a duration of {str(int(duration.total_seconds() / 60))} minutes. This {item_type.value} requires a minimum reservation duration of {str(item.minimum_usage_block_time)} minutes." ) # The reservation may not exceed the maximum block time for this tool. # Staff may break this rule. # An explicit policy override allows this rule to be broken. + # Policy off: we are always using the duration without counting policy off time if item.maximum_usage_block_time: + # use duration without counting policy off time + policy_duration = new_reservation.duration_for_policy() maximum_block_time = timedelta(minutes=item.maximum_usage_block_time) - if duration > maximum_block_time: + if policy_duration > maximum_block_time: item_policy_problems.append( - f"Your reservation has a duration of {str(int(duration.total_seconds() / 60))} minutes. Reservations for this {item_type.value} may not exceed {str(int(maximum_block_time.total_seconds() / 60))} minutes." + f"Your reservation has a duration of {str(int(policy_duration.total_seconds() / 60))} minutes. Reservations for this {item_type.value} may not exceed {str(item.maximum_usage_block_time)} minutes." ) user = new_reservation.user @@ -660,13 +686,26 @@ def check_reservation_policy_for_item( # If there is a limit on number of reservations per user per day then verify that the user has not exceeded it. # Staff may break this rule. # An explicit policy override allows this rule to be broken. + # Policy off: only exclude reservations that start and end inside the policy off time + # Reservations that overlap at any point should count + # Weekends are fine since it's all day and the should_enforce_policy method should return False if item.maximum_reservations_per_day: - start_of_day = new_reservation.start - start_of_day = start_of_day.replace(hour=0, minute=0, second=0, microsecond=0) + start_of_day = beginning_of_the_day(new_reservation.start.astimezone()) end_of_day = start_of_day + timedelta(days=1) reservations_for_that_day = Reservation.objects.filter( cancelled=False, shortened=False, start__gte=start_of_day, end__lte=end_of_day, user=user ) + if item.policy_off_between_times: + if item.policy_off_start_time < item.policy_off_end_time: + reservations_for_that_day = reservations_for_that_day.exclude( + start__gte=datetime.combine(start_of_day.date(), item.policy_off_start_time), + end__lte=datetime.combine(start_of_day.date(), item.policy_off_end_time), + ) + else: + reservations_for_that_day = reservations_for_that_day.exclude( + start__gte=datetime.combine(start_of_day.date(), item.policy_off_start_time), + end__lte=datetime.combine(end_of_day.date(), item.policy_off_end_time), + ) reservations_for_that_day = reservations_for_that_day.filter(**new_reservation.reservation_item_filter) # Exclude any reservation that is being cancelled. if cancelled_reservation and cancelled_reservation.id: @@ -684,52 +723,93 @@ def check_reservation_policy_for_item( # A minimum amount of time between reservations for the same user & same tool can be enforced. # Staff may break this rule. # An explicit policy override allows this rule to be broken. + # Policy off: exclude reservations during policy off time and weekends if item.minimum_time_between_reservations: buffer_time = timedelta(minutes=item.minimum_time_between_reservations) - must_end_before = new_reservation.start - buffer_time - too_close = Reservation.objects.filter( - cancelled=False, shortened=False, user=user, end__gt=must_end_before, start__lt=new_reservation.start - ) - too_close = too_close.filter(**new_reservation.reservation_item_filter) - if cancelled_reservation and cancelled_reservation.id: - too_close = too_close.exclude(id=cancelled_reservation.id) - if too_close.exists(): - if user == user_creating_reservation: - item_policy_problems.append( - f"Separate reservations for this {item_type.value} that belong to you must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation ends too close to another reservation." + must_end_before = (new_reservation.start - buffer_time).astimezone() + # For weekends, we can just check that must_end_before is not within the policy off time + skip_minimum_check = False + if item.policy_off_weekend: + if must_end_before.weekday() in [5, 6]: + skip_minimum_check = True + if not skip_minimum_check: + too_close = Reservation.objects.filter( + cancelled=False, + shortened=False, + user=user, + end__gt=must_end_before, + start__lt=new_reservation.start, + ) + too_close = too_close.filter(**new_reservation.reservation_item_filter) + if item.policy_off_between_times: + policy_start_today, policy_end_today = get_local_date_times_for_item_policy_times( + must_end_before, item.policy_off_start_time, item.policy_off_end_time ) - else: - item_policy_problems.append( - f"Separate reservations for this {item_type.value} that belong to {str(user)} must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation ends too close to another reservation." + policy_start_yesterday, policy_end_yesterday = get_local_date_times_for_item_policy_times( + must_end_before - timedelta(days=1), item.policy_off_start_time, item.policy_off_end_time ) - must_start_after = new_reservation.end + buffer_time - too_close = Reservation.objects.filter( - cancelled=False, shortened=False, user=user, start__lt=must_start_after, end__gt=new_reservation.start - ) - too_close = too_close.filter(**new_reservation.reservation_item_filter) - if cancelled_reservation and cancelled_reservation.id: - too_close = too_close.exclude(id=cancelled_reservation.id) - if too_close.exists(): - if user == user_creating_reservation: - item_policy_problems.append( - f"Separate reservations for this {item_type.value} that belong to you must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation begins too close to another reservation." + too_close = too_close.exclude(start__gte=policy_start_today, end__lte=policy_end_today).exclude( + start__gte=policy_start_yesterday, end__lte=policy_end_yesterday ) - else: - item_policy_problems.append( - f"Separate reservations for this {item_type.value} that belong to {str(user)} must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation begins too close to another reservation." + if cancelled_reservation and cancelled_reservation.id: + too_close = too_close.exclude(id=cancelled_reservation.id) + if too_close.exists(): + if user == user_creating_reservation: + item_policy_problems.append( + f"Separate reservations for this {item_type.value} that belong to you must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation begins too close to another reservation." + ) + else: + item_policy_problems.append( + f"Separate reservations for this {item_type.value} that belong to {str(user)} must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation begins too close to another reservation." + ) + must_start_after = (new_reservation.end + buffer_time).astimezone() + # For weekends, we can just check that must_start_after is not within the policy off time + skip_minimum_check = False + if item.policy_off_weekend and must_start_after.weekday() in [5, 6]: + skip_minimum_check = True + if not skip_minimum_check: + too_close = Reservation.objects.filter( + cancelled=False, + shortened=False, + user=user, + start__lt=must_start_after, + end__gt=new_reservation.start, + ) + too_close = too_close.filter(**new_reservation.reservation_item_filter) + if item.policy_off_between_times: + policy_start_today, policy_end_today = get_local_date_times_for_item_policy_times( + must_start_after, item.policy_off_start_time, item.policy_off_end_time + ) + policy_start_tomorrow, policy_end_tomorrow = get_local_date_times_for_item_policy_times( + must_start_after + timedelta(days=1), item.policy_off_start_time, item.policy_off_end_time + ) + too_close = too_close.exclude(start__gte=policy_start_today, end__lte=policy_end_today).exclude( + start__gte=policy_start_tomorrow, end__lte=policy_end_tomorrow ) + if cancelled_reservation and cancelled_reservation.id: + too_close = too_close.exclude(id=cancelled_reservation.id) + if too_close.exists(): + if user == user_creating_reservation: + item_policy_problems.append( + f"Separate reservations for this {item_type.value} that belong to you must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation ends too close to another reservation." + ) + else: + item_policy_problems.append( + f"Separate reservations for this {item_type.value} that belong to {str(user)} must be at least {str(item.minimum_time_between_reservations)} minutes apart from each other. The proposed reservation ends too close to another reservation." + ) # Check that the user is not exceeding the maximum amount of time they may reserve in the future. # Staff may break this rule. # An explicit policy override allows this rule to be broken. + # Policy off: use policy duration if item.maximum_future_reservation_time: reservations_after_now = Reservation.objects.filter(cancelled=False, user=user, start__gte=timezone.now()) reservations_after_now = reservations_after_now.filter(**new_reservation.reservation_item_filter) if cancelled_reservation and cancelled_reservation.id: reservations_after_now = reservations_after_now.exclude(id=cancelled_reservation.id) - amount_reserved_in_the_future = new_reservation.duration() + amount_reserved_in_the_future = new_reservation.duration_for_policy() for r in reservations_after_now: - amount_reserved_in_the_future += r.duration() + amount_reserved_in_the_future += r.duration_for_policy() if amount_reserved_in_the_future.total_seconds() / 60 > item.maximum_future_reservation_time: if user == user_creating_reservation: item_policy_problems.append( diff --git a/NEMO/serializers.py b/NEMO/serializers.py index 8dffb8097..44bd808cb 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType from django.core import validators +from django.utils.translation import gettext_lazy as _ from rest_flex_fields.serializers import FlexFieldsSerializerMixin from rest_framework import serializers from rest_framework.fields import ( @@ -17,6 +18,8 @@ from rest_framework.relations import PrimaryKeyRelatedField from rest_framework.utils import model_meta +from NEMO.constants import CHAR_FIELD_LARGE_LENGTH, CHAR_FIELD_MEDIUM_LENGTH +from NEMO.fields import DEFAULT_SEPARATOR, MultiEmailField from NEMO.models import ( Account, AccountType, @@ -51,12 +54,35 @@ TrainingSession, UsageEvent, User, + UserDocuments, ) -from NEMO.views.constants import CHAR_FIELD_MAXIMUM_LENGTH + + +class MultiEmailSerializerField(serializers.CharField): + def __init__(self, separator=DEFAULT_SEPARATOR, **kwargs): + self.email_validator = validators.EmailValidator( + message=_("Enter a valid email address or a list separated by {}").format(separator) + ) + self.separator = separator + kwargs.setdefault("max_length", 2000) + super().__init__(**kwargs) + + def to_internal_value(self, data): + emails = data.split(self.separator) + for email in emails: + email = email.strip() + self.email_validator(email) + return emails + + def to_representation(self, value): + return ",".join(value) # Overriding validate to call model full_clean class ModelSerializer(serializers.ModelSerializer): + serializer_field_mapping = serializers.ModelSerializer.serializer_field_mapping.copy() + serializer_field_mapping[MultiEmailField] = MultiEmailSerializerField + def validate(self, attrs): attributes_data = dict(attrs) ModelClass = self.Meta.model @@ -110,7 +136,7 @@ def full_clean(self, instance, exclude=None, validate_unique=True): instance.full_clean(exclude, validate_unique) -class AlertCategorySerializer(serializers.ModelSerializer): +class AlertCategorySerializer(ModelSerializer): class Meta: model = AlertCategory fields = "__all__" @@ -127,6 +153,8 @@ class Meta: class UserSerializer(FlexFieldsSerializerMixin, ModelSerializer): + user_documents = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + class Meta: model = User exclude = ["preferences"] @@ -134,6 +162,7 @@ class Meta: "projects": ("NEMO.serializers.ProjectSerializer", {"many": True}), "managed_projects": ("NEMO.serializers.ProjectSerializer", {"many": True}), "groups": ("NEMO.serializers.GroupSerializer", {"many": True}), + "user_documents": ("NEMO.serializers.UserDocumentSerializer", {"many": True}), "user_permissions": ("NEMO.serializers.PermissionSerializer", {"many": True}), } @@ -152,6 +181,17 @@ class Meta: fields = "__all__" +class UserDocumentSerializer(FlexFieldsSerializerMixin, ModelSerializer): + user = PrimaryKeyRelatedField(queryset=User.objects.all()) + + class Meta: + model = UserDocuments + fields = "__all__" + expandable_fields = { + "user": "NEMO.serializers.UserSerializer", + } + + class ProjectSerializer(FlexFieldsSerializerMixin, ModelSerializer): principal_investigators = PrimaryKeyRelatedField( source="manager_set", many=True, queryset=User.objects.all(), allow_null=True, required=False @@ -253,6 +293,8 @@ class Meta: "project": "NEMO.serializers.ProjectSerializer", "descendant": "NEMO.serializers.ReservationSerializer", "configuration_options": ("NEMO.serializers.ConfigurationOptionSerializer", {"many": True}), + "validated_by": "NEMO.serializers.UserSerializer", + "waived_by": "NEMO.serializers.UserSerializer", } @@ -265,6 +307,8 @@ class Meta: "operator": "NEMO.serializers.UserSerializer", "tool": "NEMO.serializers.ToolSerializer", "project": "NEMO.serializers.ProjectSerializer", + "validated_by": "NEMO.serializers.UserSerializer", + "waived_by": "NEMO.serializers.UserSerializer", } @@ -277,6 +321,8 @@ class Meta: "area": "NEMO.serializers.AreaSerializer", "project": "NEMO.serializers.ProjectSerializer", "staff_charge": "NEMO.serializers.StaffChargeSerializer", + "validated_by": "NEMO.serializers.UserSerializer", + "waived_by": "NEMO.serializers.UserSerializer", } @@ -322,6 +368,8 @@ class Meta: "trainee": "NEMO.serializers.UserSerializer", "tool": "NEMO.serializers.ToolSerializer", "project": "NEMO.serializers.ProjectSerializer", + "validated_by": "NEMO.serializers.UserSerializer", + "waived_by": "NEMO.serializers.UserSerializer", } @@ -333,6 +381,8 @@ class Meta: "customer": "NEMO.serializers.UserSerializer", "staff_member": "NEMO.serializers.UserSerializer", "project": "NEMO.serializers.ProjectSerializer", + "validated_by": "NEMO.serializers.UserSerializer", + "waived_by": "NEMO.serializers.UserSerializer", } @@ -371,6 +421,8 @@ class Meta: "merchant": "NEMO.serializers.UserSerializer", "consumable": "NEMO.serializers.ConsumableSerializer", "project": "NEMO.serializers.ProjectSerializer", + "validated_by": "NEMO.serializers.UserSerializer", + "waived_by": "NEMO.serializers.UserSerializer", } @@ -500,20 +552,25 @@ class BillableItemSerializer(serializers.Serializer): type = ChoiceField( ["missed_reservation", "tool_usage", "area_access", "consumable", "staff_charge", "training_session"] ) - name = CharField(max_length=200, read_only=True) + name = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) item_id = IntegerField(read_only=True) details = CharField(max_length=500, read_only=True) - account = CharField(max_length=200, read_only=True) + account = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) account_id = IntegerField(read_only=True) - project = CharField(max_length=200, read_only=True) + project = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) project_id = IntegerField(read_only=True) - application = CharField(max_length=200, read_only=True) - user = CharField(max_length=255, read_only=True) - username = CharField(max_length=200, read_only=True) + application = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) + user = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) + username = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) user_id = IntegerField(read_only=True) start = DateTimeField(read_only=True) end = DateTimeField(read_only=True) quantity = DecimalField(read_only=True, decimal_places=2, max_digits=8) + validated = BooleanField(read_only=True) + validated_by = CharField(read_only=True, source="validated_by.username", allow_null=True) + waived = BooleanField(read_only=True) + waived_by = CharField(read_only=True, source="waived_by.username", allow_null=True) + waived_on = DateTimeField(read_only=True) def update(self, instance, validated_data): pass @@ -527,37 +584,37 @@ class Meta: class ToolStatusSerializer(serializers.Serializer): id = IntegerField(read_only=True) - name = CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, read_only=True) - category = CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, read_only=True) + name = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) + category = CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True) in_use = BooleanField(read_only=True) visible = BooleanField(read_only=True) operational = BooleanField(read_only=True) problematic = BooleanField(read_only=True) - problem_descriptions = CharField(default=None, max_length=1000, read_only=True) + problem_descriptions = CharField(default=None, max_length=CHAR_FIELD_LARGE_LENGTH, read_only=True) customer_id = IntegerField(default=None, source="get_current_usage_event.user.id", read_only=True) customer_name = CharField( default=None, source="get_current_usage_event.user.get_name", - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True, ) customer_username = CharField( default=None, source="get_current_usage_event.user.username", - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True, ) operator_id = IntegerField(default=None, source="get_current_usage_event.operator.id", read_only=True) operator_name = CharField( default=None, source="get_current_usage_event.operator.get_name", - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True, ) operator_username = CharField( default=None, source="get_current_usage_event.operator.username", - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, read_only=True, ) current_usage_id = IntegerField(default=None, source="get_current_usage_event.id", read_only=True) diff --git a/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css b/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css index da3a5bc54..63e4d3f61 100644 --- a/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css +++ b/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css @@ -184,4 +184,163 @@ div.dynamic_form_preview:empty .dynamic_form_preview input:invalid, .dynamic_form_preview select:invalid, .dynamic_form_preview textarea:invalid { border-color: #dc3545!important; + border-width: 2px!important; + background: #ffebeb!important; +} + +.dynamic_form_preview input:invalid::placeholder +{ + color: #505050!important; +} + +.dynamic_form_preview button +{ + border-image-width: 1; + border-width: 1px; +} + +.required-question-star +{ + color:red; + font-size: 20px; + font-weight: bold; + line-height: .75; +} + +.col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-xs-1, .col-xs-10, .col-xs-11, .col-xs-12, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9 +{ + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} + +.col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9 +{ + float: left; +} + +.btn-group-vertical>.btn-group:after, .btn-group-vertical>.btn-group:before, .btn-toolbar:after, .btn-toolbar:before, .clearfix:after, .clearfix:before, .container-fluid:after, .container-fluid:before, .container:after, .container:before, .dl-horizontal dd:after, .dl-horizontal dd:before, .form-horizontal .form-group:after, .form-horizontal .form-group:before, .modal-footer:after, .modal-footer:before, .nav:after, .nav:before, .navbar-collapse:after, .navbar-collapse:before, .navbar-header:after, .navbar-header:before, .navbar:after, .navbar:before, .pager:after, .pager:before, .panel-body:after, .panel-body:before, .row:after, .row:before +{ + display: table; + content: " "; +} + +.btn-group-vertical>.btn-group:after, .btn-toolbar:after, .clearfix:after, .container-fluid:after, .container:after, .dl-horizontal dd:after, .form-horizontal .form-group:after, .modal-footer:after, .nav:after, .navbar-collapse:after, .navbar-header:after, .navbar:after, .pager:after, .panel-body:after, .row:after +{ + clear: both; +} + +.row +{ + margin-right: -15px; + margin-left: -15px; +} + +@media (min-width: 992px) +{ + .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 + { + float: left; + } + .col-md-12 + { + width: 100%; + } + .col-md-11 + { + width: 91.66666667%; + } + .col-md-10 + { + width: 83.33333333%; + } + .col-md-9 + { + width: 75%; + } + .col-md-8 + { + width: 66.66666667%; + } + .col-md-7 + { + width: 58.33333333%; + } + .col-md-6 + { + width: 50%; + } + .col-md-5 + { + width: 41.66666667%; + } + .col-md-4 + { + width: 33.33333333%; + } + .col-md-3 + { + width: 25%; + } + .col-md-2 + { + width: 16.66666667%; + } + .col-md-1 + { + width: 8.33333333%; + } + .col-md-offset-12 + { + margin-left: 100%; + } + .col-md-offset-11 + { + margin-left: 91.66666667%; + } + .col-md-offset-10 + { + margin-left: 83.33333333%; + } + .col-md-offset-9 + { + margin-left: 75%; + } + .col-md-offset-8 + { + margin-left: 66.66666667%; + } + .col-md-offset-7 + { + margin-left: 58.33333333%; + } + .col-md-offset-6 + { + margin-left: 50%; + } + .col-md-offset-5 + { + margin-left: 41.66666667%; + } + .col-md-offset-4 + { + margin-left: 33.33333333%; + } + .col-md-offset-3 + { + margin-left: 25%; + } + .col-md-offset-2 + { + margin-left: 16.66666667%; + } + .col-md-offset-1 + { + margin-left: 8.33333333%; + } + .col-md-offset-0 + { + margin-left: 0; + } } \ No newline at end of file diff --git a/NEMO/static/nemo.css b/NEMO/static/nemo.css index 1667c6096..0b67dd79d 100644 --- a/NEMO/static/nemo.css +++ b/NEMO/static/nemo.css @@ -1045,10 +1045,24 @@ li.area-tree-node a, li.area-tree-node a:hover, li.area-tree-node a:focus } /* Invalid input for post usage/reservation questions and additional event parameters */ -#tool_control input:invalid, #tool_control select:invalid, #tool_control textarea:invalid, -#additional_event_parameters input:invalid, #additional_event_parameters select:invalid, #additional_event_parameters textarea:invalid +.dynamic_form input:invalid { border-color: #dc3545!important; + border-width: 2px; + background: #ffebeb; +} + +.dynamic_form input:invalid::placeholder +{ + color: #505050 ; +} + +.required-question-star +{ + color:red; + font-size: 20px; + font-weight: bold; + line-height: .75; } /* Make form-control look like plain text */ diff --git a/NEMO/static/nemo.js b/NEMO/static/nemo.js index c608ca327..2c4f01d0b 100644 --- a/NEMO/static/nemo.js +++ b/NEMO/static/nemo.js @@ -553,7 +553,8 @@ function add_to_list(list_selector, on_click, id, text, removal_title, input_nam let addition = '
'; if (!readonly) { - addition += '' + + // use parwehtml to make sure there are no html tags in the title + addition += '' + '' + ' '; } diff --git a/NEMO/templates/base/footer.html b/NEMO/templates/base/footer.html index 7ddfbe33a..c63a21fcf 100644 --- a/NEMO/templates/base/footer.html +++ b/NEMO/templates/base/footer.html @@ -8,6 +8,9 @@ {% else %}   {% endif %} - - Developed by CNST, NIST + - + {% if not no_links %}{% endif %} + Developed by CNST, NIST + {% if not no_links %}{% endif %}
diff --git a/NEMO/templates/base/navbar_base.html b/NEMO/templates/base/navbar_base.html index 0502fa7d8..499c8677f 100644 --- a/NEMO/templates/base/navbar_base.html +++ b/NEMO/templates/base/navbar_base.html @@ -17,9 +17,11 @@ {% endif %} + {% if next_reservation and "tool"|customization:"tool_control_show_next_reservation_user" %} + {% now "Y-m-d" as today %} + The next reservation is + {% if today == next_reservation.start|date:"Y-m-d" %} + at {{ next_reservation.start|date:"TIME_FORMAT" }} + {% else %} + on {{ next_reservation.start }} + {% endif %} + for {{ next_reservation.user }} + {% endif %}
{% if tool_rate %}
@@ -133,11 +153,11 @@

{{ counter.name }}: {{ counter.value|floatformat:-2 }}
- {% if user.is_staff %} + {% if user in counter.reset_permitted_users %}
Reset + onclick="return confirm('You are about to reset the {{ counter.name }} counter to {{ counter.default_value }}.\nAre you sure?')">Reset
{% endif %}
{{ counter.description|linebreaksbr }}
@@ -412,12 +432,20 @@

{{ o.creator }} scheduled this outage from {{ o.start }} until {{ o.end }}.< {% if user.is_staff or user.is_service_personnel and tool in user.qualifications.all %}

What would you like to do?

+
{% if user.is_staff %} + {% if "training"|customization:"training_show_self_option_in_tool_control" == "enabled" %} +
+ + {% endif %}
{% if "remote_work"|customization:"remote_work_ask_explicitly" %} + {% if "training"|customization:"training_show_behalf_option_in_tool_control" == "enabled" %} +
+ + {% endif %}
+ {% elif user in tool.superusers.all and "training"|customization:"training_show_self_option_in_tool_control" == "enabled" %} + {# When user is not staff/service personnel but is a tool superuser and training option is enabled #} +

What would you like to do?

+ +
+ + {% if "training"|customization:"training_show_self_option_in_tool_control" == "enabled" %} +
+ + {% endif %} +
+
{% elif tool.operational and not tool.required_resource_is_unavailable and not tool.delayed_logoff_in_progress and not tool.scheduled_outage_in_progress %} {% include 'tool_control/get_projects.html' with active_projects=user.active_projects user_id=user.id %} {% endif %} @@ -551,7 +604,7 @@

What would you like to do?

{% endfor %} {% endif %} - {% if tool.description or tool.image or tool.tooldocuments_set.all and user_can_see_documents %} + {% if tool.description or tool.image or "tool"|customization:"tool_control_documents_in_separate_tab" != "enabled" and tool.tooldocuments_set.all and user_can_see_documents %}
{% if tool.description %} @@ -568,7 +621,7 @@

What would you like to do?

max-height:300px">
{% endif %} - {% if tool.tooldocuments_set.all and user_can_see_documents %} + {% if "tool"|customization:"tool_control_documents_in_separate_tab" != "enabled" and tool.tooldocuments_set.all and user_can_see_documents %}
Documents:
    @@ -955,6 +1008,19 @@

    Task & comment history for this tool

    {# Spacer #}
+ {% if "tool"|customization:"tool_control_documents_in_separate_tab" == "enabled" and tool.tooldocuments_set.all and user_can_see_documents %} +
+
Documents:
+ +
{# Spacer #}
+
+ {% endif %}
+ {% include 'training/training_recording.html' %} {% endblock %} diff --git a/NEMO/templates/training/training_entry.html b/NEMO/templates/training/training_entry.html index 0c5b1369c..2804a3ddf 100644 --- a/NEMO/templates/training/training_entry.html +++ b/NEMO/templates/training/training_entry.html @@ -4,105 +4,107 @@ style="display: flex; flex-flow: row wrap; padding: 15px 0"> -
-
-
-
- {% with date_allowed="training"|customization:"training_allow_date" %} - -
- - + {% with date_allowed="training"|customization:"training_allow_date" %} + {% with label_class=date_allowed|yesno:"col-sm-2 col-md-1,col-sm-2 col-md-1" input_class=date_allowed|yesno:"col-sm-4 col-md-3,col-sm-4 col-md-3" %} +
+
- -
- - - -
- -
- {% if date_allowed %} - -
+
+ +
+ id="user_textbox__{{ entry_number }}" + name="chosen_user__{{ entry_number }}" + data-row="{{ entry_number }}"> +
- {% endif %} - -
-
- -
min
+ +
+ + +
-
- -
- -
- -
-
- + +
+ {% if date_allowed %} + +
+ +
+ {% endif %} + +
+
+ +
min
+
+
+ +
+ +
+ +
+
+ +
{% endwith %} -
+ {% endwith %}
diff --git a/NEMO/templates/training/training_recording.html b/NEMO/templates/training/training_recording.html new file mode 100644 index 000000000..30c9302fa --- /dev/null +++ b/NEMO/templates/training/training_recording.html @@ -0,0 +1,176 @@ +{% load custom_tags_and_filters %} + +

Use this form to record training sessions.

+

+ You can add participants to a training session by clicking the 'Add another participant' link. + Remove a participant or blank row by clicking the circled X on that row. +

+

+ When a user has successfully completed a training session you have the option to check the "Qualify" box, which qualifies the user for that tool. +

+
+
+ {% csrf_token %} + + {# Rows are inserted here upon load & when "Add another participant" is clicked. #} + {% include 'training/training_entry.html' with entry_number=0 %} + + {# Note: onclick need to stay blank so it will trigger the onsumbit of this form #} + {% button id="record_training_sessions" type="save" value="Record training sessions" onclick="" %} +
+ diff --git a/NEMO/templates/usage/adjustment_request_button.html b/NEMO/templates/usage/adjustment_request_button.html index 93490af0f..8f1055223 100644 --- a/NEMO/templates/usage/adjustment_request_button.html +++ b/NEMO/templates/usage/adjustment_request_button.html @@ -1,5 +1,5 @@ {% load custom_tags_and_filters %} -{% if "requests"|customization:"adjustment_requests_enabled" %} +{% if "adjustment_requests"|customization:"adjustment_requests_enabled" %} {% with content_type=charge|content_type %} {% url 'create_adjustment_request' content_type.id charge.id as create_adjustment_request %} {% if not existing_adjustments or content_type.id not in existing_adjustments or charge.id not in existing_adjustments|get_item:content_type.id %} diff --git a/NEMO/templates/usage/usage.html b/NEMO/templates/usage/usage.html index c34fe622f..66b36a54b 100644 --- a/NEMO/templates/usage/usage.html +++ b/NEMO/templates/usage/usage.html @@ -18,7 +18,7 @@

Adjustment activities are not included in the usage information. - {% if "requests"|customization:"adjustment_requests_enabled" and adjustment_time_limit %} + {% if "adjustment_requests"|customization:"adjustment_requests_enabled" and adjustment_time_limit %}
Adjustment requests can be made for charges ending after {{ adjustment_time_limit.date }}. {% endif %} diff --git a/NEMO/templates/usage/usage_base.html b/NEMO/templates/usage/usage_base.html index 443905d85..856854f4f 100644 --- a/NEMO/templates/usage/usage_base.html +++ b/NEMO/templates/usage/usage_base.html @@ -16,7 +16,7 @@

between {{ start_date|date }} and {{ end_date|date }}

- This page presents a monthly report of your {{ facility_name }} usage + This page presents a monthly report of your {{ facility_name }} use {% if billing_service %}and billing information{% endif %} . Approved adjustments are {% if not billing_service %}not{% endif %} diff --git a/NEMO/templates/users/create_or_modify_user.html b/NEMO/templates/users/create_or_modify_user.html index c5b5189da..d3a24bde5 100644 --- a/NEMO/templates/users/create_or_modify_user.html +++ b/NEMO/templates/users/create_or_modify_user.html @@ -555,7 +555,7 @@ $('#add_a_tool_qualification').autocomplete('tools', on_search_selection, {{ tools|json_search_base }}); {% if form.instance.id %} {# Only look for projects and qualifications if this is an existing user (and not a new user). #} {% for project in form.instance.projects.all %} - add_project('{{ project.display_with_pis }}', {{ project.id }}); + add_project('{{ project.display_with_pis|safe }}', {{ project.id }}); {% endfor %} {% for tool in form.instance.qualifications.all %} add_tool_qualification('{{ tool.name }}', {{ tool.id }}); diff --git a/NEMO/templatetags/custom_tags_and_filters.py b/NEMO/templatetags/custom_tags_and_filters.py index 73b4803ba..1c83afdcd 100644 --- a/NEMO/templatetags/custom_tags_and_filters.py +++ b/NEMO/templatetags/custom_tags_and_filters.py @@ -1,4 +1,5 @@ import datetime +from collections.abc import Mapping, Sequence from datetime import timedelta from importlib.metadata import PackageNotFoundError, version from urllib.parse import quote @@ -14,10 +15,10 @@ from django.utils.html import escape, escapejs, format_html from django.utils.safestring import mark_safe +from NEMO.constants import NEXT_PARAMETER_NAME from NEMO.mixins import BillableItemMixin from NEMO.models import User from NEMO.utilities import get_full_url -from NEMO.views.constants import NEXT_PARAMETER_NAME from NEMO.views.customization import CustomizationBase, ProjectsAccountsCustomization register = template.Library() @@ -125,8 +126,14 @@ def res_question_tbody(dictionary): @register.filter -def get_item(dictionary, key): - return dictionary.get(key) +def get_item(dict_or_array, key): + if isinstance(dict_or_array, Mapping): + return dict_or_array.get(key) + elif isinstance(dict_or_array, Sequence): + try: + return dict_or_array[key] + except IndexError: + pass @register.simple_tag diff --git a/NEMO/tests/test_area_access.py b/NEMO/tests/test_area_access.py index 88ad3f565..a2e3db7b3 100644 --- a/NEMO/tests/test_area_access.py +++ b/NEMO/tests/test_area_access.py @@ -1,6 +1,7 @@ import datetime from django.contrib.auth.models import Permission +from django.core.exceptions import ValidationError from django.core.management import call_command from django.test import TestCase from django.urls import reverse @@ -20,6 +21,7 @@ User, ) from NEMO.tests.test_utilities import ( + create_user_and_project, login_as_staff, login_as_user, login_as_user_with_permissions, @@ -551,6 +553,13 @@ def test_staff_login_to_area(self): ).exists() ) + def test_area_already_logged_in(self): + user, project = create_user_and_project() + record = AreaAccessRecord(customer=user, project=project, area=area, start=timezone.now()) + record.save() + record_2 = AreaAccessRecord(customer=user, project=project, area=area, start=timezone.now()) + self.assertRaises(ValidationError, record_2.full_clean) + class DoorInterlockTestCase(TestCase): door: Door = None diff --git a/NEMO/tests/test_calendar/test_area_reservation.py b/NEMO/tests/test_calendar/test_area_reservation.py index dc52085e0..4b9de2c96 100644 --- a/NEMO/tests/test_calendar/test_area_reservation.py +++ b/NEMO/tests/test_calendar/test_area_reservation.py @@ -243,7 +243,7 @@ def test_reservation_policy_problems(self): self.assertEqual(response.status_code, 200) self.assertContains( response, - "Separate reservations for this area that belong to you must be at least 120 minutes apart from each other. The proposed reservation ends too close to another reservation.", + "Separate reservations for this area that belong to you must be at least 120 minutes apart from each other. The proposed reservation begins too close to another reservation.", ) data = self.get_reservation_data(start + timedelta(minutes=30), start + timedelta(minutes=90), area) diff --git a/NEMO/tests/test_calendar/test_tool_reservation.py b/NEMO/tests/test_calendar/test_tool_reservation.py index a0dca4fd8..9a05339d4 100644 --- a/NEMO/tests/test_calendar/test_tool_reservation.py +++ b/NEMO/tests/test_calendar/test_tool_reservation.py @@ -233,7 +233,7 @@ def test_reservation_policy_problems(self): self.assertEqual(response.status_code, 200) self.assertContains( response, - "Separate reservations for this tool that belong to you must be at least 120 minutes apart from each other. The proposed reservation ends too close to another reservation.", + "Separate reservations for this tool that belong to you must be at least 120 minutes apart from each other. The proposed reservation begins too close to another reservation.", ) data = self.get_reservation_data(start + timedelta(minutes=30), start + timedelta(minutes=90), tool) @@ -241,11 +241,11 @@ def test_reservation_policy_problems(self): self.assertEqual(response.status_code, 200) self.assertContains( response, - "Separate reservations for this tool that belong to you must be at least 120 minutes apart from each other. The proposed reservation begins too close to another reservation.", + "Separate reservations for this tool that belong to you must be at least 120 minutes apart from each other. The proposed reservation ends too close to another reservation.", ) self.assertContains( response, - "Separate reservations for this tool that belong to you must be at least 120 minutes apart from each other. The proposed reservation ends too close to another reservation.", + "Separate reservations for this tool that belong to you must be at least 120 minutes apart from each other. The proposed reservation begins too close to another reservation.", ) tool.maximum_future_reservation_time = 90 diff --git a/NEMO/tests/test_dynamic_form.py b/NEMO/tests/test_dynamic_form.py index be6f5b6b3..fd5796335 100644 --- a/NEMO/tests/test_dynamic_form.py +++ b/NEMO/tests/test_dynamic_form.py @@ -4,10 +4,78 @@ from django.test import TestCase from NEMO.utilities import EmptyHttpRequest -from NEMO.widgets.dynamic_form import DynamicForm +from NEMO.widgets.dynamic_form import DynamicForm, PostUsageGroupQuestion class TestDynamicForm(TestCase): + + def test_question_with_initial_data(self): + # question and initial data + initial_data = {"test": {"type": "number", "user_input": "2"}} + data = [ + {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250}, + ] + data_with_default = [ + {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250, "default_value": "1"}, + ] + + # question with initial and no default => initial + dynamic_form = DynamicForm(json.dumps(data), initial_data=initial_data) + dynamic_form.validate("tool_usage_group_question", 1) + question = [question for question in dynamic_form.questions if question.name == "test"][0] + self.assertEqual(question.get_default_value(), initial_data["test"]["user_input"]) + + # question with no initial data at all => None + dynamic_form = DynamicForm(json.dumps(data), initial_data=None) + question = [question for question in dynamic_form.questions if question.name == "test"][0] + self.assertEqual(question.get_default_value(), None) + + # question with no initial data but default in question => default + dynamic_form = DynamicForm(json.dumps(data_with_default)) + question = [question for question in dynamic_form.questions if question.name == "test"][0] + self.assertEqual(question.get_default_value(), data_with_default[0]["default_value"]) + + # question with initial data and default in question => initial + dynamic_form = DynamicForm(json.dumps(data_with_default)) + question = [question for question in dynamic_form.questions if question.name == "test"][0] + self.assertEqual(question.get_default_value(), data_with_default[0]["default_value"]) + + # question with group questions + group_user_input = { + "test_group": { + "type": "group", + "user_input": { + "0": { + "test": "2", + }, + "1": { + "test": "3", + }, + }, + } + } + group_data = [ + { + "name": "test_group", + "type": "group", + "title": "This is a test group", + "max_number": 2, + "questions": [ + {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250}, + ], + } + ] + dynamic_form = DynamicForm(json.dumps(group_data), initial_data=group_user_input) + question: PostUsageGroupQuestion = [ + question for question in dynamic_form.questions if question.name == "test_group" + ][0] + for index, data in enumerate(list(group_user_input["test_group"]["user_input"].values())): + question.load_sub_questions(index, data) + sub_question = [question for question in question.sub_questions if question.initial_name == "test"][0] + self.assertEqual( + sub_question.get_default_value(), group_user_input["test_group"]["user_input"][str(index)]["test"] + ) + def test_formula_number_field(self): data = [ {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250}, diff --git a/NEMO/tests/test_formats.py b/NEMO/tests/test_formats.py index de3f02fa4..16144ea79 100644 --- a/NEMO/tests/test_formats.py +++ b/NEMO/tests/test_formats.py @@ -1,21 +1,25 @@ -import datetime +from datetime import date, datetime, time, timedelta + +try: + import zoneinfo +except ImportError: + from backports import zoneinfo -import pytz from django.conf import settings from django.test import TestCase from django.utils import timezone from django.utils.formats import date_format, time_format from django.utils.timezone import make_aware -from NEMO.utilities import format_daterange, format_datetime +from NEMO.utilities import format_daterange, format_datetime, get_duration_with_off_schedule class FormatTestCase(TestCase): def test_format_daterange(self): self.assertTrue(format_datetime()) - today = datetime.datetime.today() - self.assertEqual(format_datetime(datetime.datetime.today()), date_format(today, "DATETIME_FORMAT")) - now = datetime.datetime.now() + today = datetime.today() + self.assertEqual(format_datetime(datetime.today()), date_format(today, "DATETIME_FORMAT")) + now = datetime.now() self.assertEqual(format_datetime(now), date_format(today, "DATETIME_FORMAT")) now_tz = timezone.now() self.assertTrue(format_datetime(now_tz)) @@ -23,38 +27,38 @@ def test_format_daterange(self): self.assertEqual(format_datetime(now_tz.date()), date_format(now_tz)) dt_format = "m/d/Y @ g:i A" - start = datetime.datetime(2022, 2, 11, 5, 0, 0) - end = start + datetime.timedelta(days=2) + start = datetime(2022, 2, 11, 5, 0, 0) + end = start + timedelta(days=2) self.assertEqual( format_daterange(start, end, dt_format=dt_format), f"from 02/11/2022 @ 5:00 AM to 02/13/2022 @ 5:00 AM" ) - start_same_day = datetime.datetime(2022, 2, 11, 5, 0, 0) - end_same_day = start_same_day + datetime.timedelta(hours=2) + start_same_day = datetime(2022, 2, 11, 5, 0, 0) + end_same_day = start_same_day + timedelta(hours=2) self.assertEqual( format_daterange(start_same_day, end_same_day, dt_format=dt_format), f"02/11/2022 from 5:00 AM to 7:00 AM" ) - start_midnight = datetime.datetime(2022, 2, 11, 0, 0, 0) - end_midnight = datetime.datetime(2022, 2, 11, 23, 59, 0) + start_midnight = datetime(2022, 2, 11, 0, 0, 0) + end_midnight = datetime(2022, 2, 11, 23, 59, 0) self.assertEqual( format_daterange(start_midnight, end_midnight, dt_format=dt_format), f"02/11/2022 from 12:00 AM to 11:59 PM" ) t_format = "g:i A" - start_time = datetime.time(8, 0, 0) - end_time = datetime.time(14, 0, 0) + start_time = time(8, 0, 0) + end_time = time(14, 0, 0) self.assertEqual(format_daterange(start_time, end_time, t_format=t_format), f"from 8:00 AM to 2:00 PM") d_format = "m/d/Y" - start_date = datetime.date(2022, 2, 11) - end_date = datetime.date(2022, 2, 11) + start_date = date(2022, 2, 11) + end_date = date(2022, 2, 11) self.assertEqual(format_daterange(start_date, end_date, d_format=d_format), f"from 02/11/2022 to 02/11/2022") - tz = pytz.timezone("US/Pacific") - self.assertEqual(settings.TIME_ZONE, "America/New_York") - start_tz = make_aware(datetime.datetime(2022, 2, 11, 5, 0, 0), tz) # 5AM Pacific => 8AM Eastern - end_tz = start_tz + datetime.timedelta(days=2) + tz = zoneinfo.ZoneInfo("US/Pacific") + self.assertEqual(settings.TIME_ZONE, "US/Eastern") + start_tz = make_aware(datetime(2022, 2, 11, 5, 0, 0), tz) # 5AM Pacific => 8AM Eastern + end_tz = start_tz + timedelta(days=2) self.assertNotEqual( format_daterange(start_tz, end_tz, dt_format=dt_format), f"from 02/11/2022 @ 5:00 AM to 02/13/2022 @ 5:00 AM", @@ -64,11 +68,201 @@ def test_format_daterange(self): f"from 02/11/2022 @ 8:00 AM to 02/13/2022 @ 8:00 AM", ) - start_midnight_tz = make_aware(datetime.datetime(2022, 2, 11, 0, 0, 0), tz) # midnight Pacific -> 3AM Eastern - end_midnight_tz = make_aware( - datetime.datetime(2022, 2, 11, 23, 59, 0), tz - ) # 11:59 PM Pacific -> 2:59AM Eastern + start_midnight_tz = make_aware(datetime(2022, 2, 11, 0, 0, 0), tz) # midnight Pacific -> 3AM Eastern + end_midnight_tz = make_aware(datetime(2022, 2, 11, 23, 59, 0), tz) # 11:59 PM Pacific -> 2:59AM Eastern self.assertEqual( format_daterange(start_midnight_tz, end_midnight_tz, dt_format=dt_format), f"from 02/11/2022 @ 3:00 AM to 02/12/2022 @ 2:59 AM", ) + + def test_duration_with_off_schedule_weekends(self): + # 6-10 am local starting on a Tuesday (Sep 17 2024 is a Tuesday) + start = timezone.now().replace( + year=2024, + month=9, + day=17, + hour=6, + minute=0, + second=0, + microsecond=0, + tzinfo=timezone.get_current_timezone(), + ) + end = start + timedelta(hours=4) + timedelta(days=7) + duration = end - start + + # no time off + self.assertEqual(duration, get_duration_with_off_schedule(start, end, False, False, None, None)) + # time off whole weekend + self.assertEqual( + duration - timedelta(days=2), get_duration_with_off_schedule(start, end, True, False, None, None) + ) + + # time off overlapping beginning, end on Saturday + end = start + timedelta(days=4) + timedelta(hours=4) + duration = end - start + # only time off will be Saturday from 0am to 10am + self.assertEqual( + duration - timedelta(hours=10), get_duration_with_off_schedule(start, end, True, False, None, None) + ) + + # time off overlapping end, start previous Sunday, end on Tuesday + end = start + start = start - timedelta(days=2) + duration = end - start + # only time off will be Sunday from 6am to midnight + self.assertEqual( + duration - timedelta(hours=18), get_duration_with_off_schedule(start, end, True, False, None, None) + ) + + def test_duration_with_off_schedule_weekdays_normal(self): + # 6-10 am local on a Tuesday (Sep 17 2024 is a Tuesday) + start = timezone.now().replace( + year=2024, + month=9, + day=17, + hour=6, + minute=0, + second=0, + microsecond=0, + tzinfo=timezone.get_current_timezone(), + ) + end = start + timedelta(hours=4) + duration = end - start + # no time off + self.assertEqual(duration, get_duration_with_off_schedule(start, end, False, False, None, None)) + # time off in the middle + self.assertEqual( + duration - timedelta(minutes=45), + get_duration_with_off_schedule( + start, end, False, True, time(hour=7, minute=0, second=0), time(hour=7, minute=45, second=0) + ), + ) + # time off overlapping beginning + self.assertEqual( + duration - timedelta(minutes=15), + get_duration_with_off_schedule( + start, end, False, True, time(hour=5, minute=0, second=0), time(hour=6, minute=15, second=0) + ), + ) + # time off overlapping end + self.assertEqual( + duration - timedelta(minutes=15), + get_duration_with_off_schedule( + start, end, False, True, time(hour=9, minute=45, second=0), time(hour=17, minute=15, second=0) + ), + ) + # time off before window + self.assertEqual( + duration, + get_duration_with_off_schedule( + start, end, False, True, time(hour=3, minute=45, second=0), time(hour=5, minute=15, second=0) + ), + ) + # time off after window + self.assertEqual( + duration, + get_duration_with_off_schedule( + start, end, False, True, time(hour=13, minute=45, second=0), time(hour=15, minute=15, second=0) + ), + ) + + # test with multiple days + # 6am to 10am the next day + end = start + timedelta(days=1, hours=4) + duration = end - start + # no time off + self.assertEqual(duration, get_duration_with_off_schedule(start, end, False, False, None, None)) + # time off in the middle + self.assertEqual( + duration - timedelta(minutes=90), + get_duration_with_off_schedule( + start, end, False, True, time(hour=7, minute=00, second=0), time(hour=7, minute=45, second=0) + ), + ) + # time off overlapping beginning (15 first day + 75 second) + self.assertEqual( + duration - timedelta(minutes=90), + get_duration_with_off_schedule( + start, end, False, True, time(hour=5, minute=0, second=0), time(hour=6, minute=15, second=0) + ), + ) + # time off overlapping end (150 + 15 second) + self.assertEqual( + duration - timedelta(minutes=165), + get_duration_with_off_schedule( + start, end, False, True, time(hour=9, minute=45, second=0), time(hour=12, minute=15, second=0) + ), + ) + + def test_duration_with_off_schedule_weekdays_reverse(self): + # 6pm-10 am local on a Tuesday (Sep 17 2024 is a Tuesday) + start = timezone.now().replace( + year=2024, + month=9, + day=17, + hour=18, + minute=0, + second=0, + microsecond=0, + tzinfo=timezone.get_current_timezone(), + ) + end = start + timedelta(hours=16) + duration = end - start + # no time off + self.assertEqual(duration, get_duration_with_off_schedule(start, end, False, False, None, None)) + # time off in the middle + self.assertEqual( + duration - timedelta(hours=4), + get_duration_with_off_schedule( + start, end, False, True, time(hour=22, minute=0, second=0), time(hour=2, minute=0, second=0) + ), + ) + # time off overlapping beginning + self.assertEqual( + duration - timedelta(hours=8), + get_duration_with_off_schedule( + start, end, False, True, time(hour=17, minute=0, second=0), time(hour=2, minute=0, second=0) + ), + ) + # time off overlapping end + self.assertEqual( + duration - timedelta(hours=12), + get_duration_with_off_schedule( + start, end, False, True, time(hour=22, minute=00, second=0), time(hour=12, minute=00, second=0) + ), + ) + # time off before/after window + self.assertEqual( + duration, + get_duration_with_off_schedule( + start, end, False, True, time(hour=13, minute=45, second=0), time(hour=15, minute=15, second=0) + ), + ) + + # test with multiple days + # 6pm to 10am the following day + end = start + timedelta(days=1, hours=16) + duration = end - start + # no time off + self.assertEqual(duration, get_duration_with_off_schedule(start, end, False, False, None, None)) + # time off in the middle + self.assertEqual( + duration - timedelta(hours=8), + get_duration_with_off_schedule( + start, end, False, True, time(hour=22, minute=0, second=0), time(hour=2, minute=0, second=0) + ), + ) + # time off overlapping beginning (6 first + 2 + 7 second + 2 third) + self.assertEqual( + duration - timedelta(hours=17), + get_duration_with_off_schedule( + start, end, False, True, time(hour=17, minute=0, second=0), time(hour=2, minute=0, second=0) + ), + ) + # time off overlapping end (2 first + 12 + 2 second + 10 third) + self.assertEqual( + duration - timedelta(hours=26), + get_duration_with_off_schedule( + start, end, False, True, time(hour=22, minute=00, second=0), time(hour=12, minute=00, second=0) + ), + ) diff --git a/NEMO/tests/test_requests/test_adjustment_requests.py b/NEMO/tests/test_requests/test_adjustment_requests.py index ee7d0b2c4..5c5ac70b8 100644 --- a/NEMO/tests/test_requests/test_adjustment_requests.py +++ b/NEMO/tests/test_requests/test_adjustment_requests.py @@ -1,6 +1,5 @@ from datetime import timedelta -from django.core.exceptions import NON_FIELD_ERRORS from django.test import TestCase from django.urls import reverse from django.utils import timezone @@ -21,15 +20,15 @@ login_as_user, validate_model_error, ) -from NEMO.views.customization import UserRequestsCustomization +from NEMO.views.customization import AdjustmentRequestsCustomization class AdjustmentRequestTestCase(TestCase): def setUp(self) -> None: - UserRequestsCustomization.set("adjustment_requests_enabled", "enabled") + AdjustmentRequestsCustomization.set("adjustment_requests_enabled", "enabled") def test_enable_adjustment_requests(self): - UserRequestsCustomization.set("adjustment_requests_enabled", "") + AdjustmentRequestsCustomization.set("adjustment_requests_enabled", "") login_as_user(self.client) response = self.client.get(reverse("adjustment_requests")) self.assertContains(response, "not enabled", status_code=400) @@ -58,7 +57,6 @@ def test_create_request(self): adjustment_request.item = usage_event adjustment_request.new_start = usage_event.start adjustment_request.new_end = usage_event.end - validate_model_error(self, adjustment_request, [NON_FIELD_ERRORS]) adjustment_request.new_start = usage_event.start - timedelta(minutes=5) adjustment_request.full_clean() adjustment_request.save() diff --git a/NEMO/tests/test_settings.py b/NEMO/tests/test_settings.py index c40bc6dd8..5a6bcbce6 100644 --- a/NEMO/tests/test_settings.py +++ b/NEMO/tests/test_settings.py @@ -94,7 +94,7 @@ EMAIL_FILE_PATH = "./email_logs" -TIME_ZONE = "America/New_York" +TIME_ZONE = "US/Eastern" DATABASES = { "default": { diff --git a/NEMO/tests/test_tool.py b/NEMO/tests/test_tool.py index 172345209..34153b427 100644 --- a/NEMO/tests/test_tool.py +++ b/NEMO/tests/test_tool.py @@ -1,6 +1,7 @@ from datetime import timedelta from typing import Optional +from django.core.exceptions import ValidationError from django.test import TestCase from django.urls import reverse from django.utils import timezone @@ -16,6 +17,7 @@ PhysicalAccessLevel, Project, Tool, + UsageEvent, User, ) from NEMO.tests.test_utilities import create_user_and_project, login_as @@ -209,3 +211,10 @@ def test_tool_in_use(self): self.assertTrue(alternate_tool.in_use()) # make sure both return the same usage event self.assertEqual(tool.get_current_usage_event(), alternate_tool.get_current_usage_event()) + + def test_tool_already_in_use(self): + user, project = create_user_and_project() + usage = UsageEvent(user=user, operator=user, project=project, tool=tool, start=timezone.now()) + usage.save() + usage_2 = UsageEvent(user=user, operator=user, project=project, tool=tool, start=timezone.now()) + self.assertRaises(ValidationError, usage_2.full_clean) diff --git a/NEMO/tests/test_urls.py b/NEMO/tests/test_urls.py index 5e697c5ae..a89577673 100644 --- a/NEMO/tests/test_urls.py +++ b/NEMO/tests/test_urls.py @@ -16,7 +16,11 @@ from NEMO.models import User from NEMO.tests.test_utilities import login_as, login_as_staff, login_as_user, login_as_user_with_permissions from NEMO.utilities import get_full_url -from NEMO.views.customization import ApplicationCustomization, EmailsCustomization, UserRequestsCustomization +from NEMO.views.customization import ( + AdjustmentRequestsCustomization, + ApplicationCustomization, + EmailsCustomization, +) url_test_logger = getLogger(__name__) @@ -137,6 +141,7 @@ "knowledge_base_categories": {"kwargs": {"kind": "user"}}, "knowledge_base_all_in_one": {"kwargs": {"kind": "user"}}, "view_user": {"login_id": 1}, + "enable_tool": {"login_id": 1, "kwargs": {"tool_id": 3, "user_id": 1, "project_id": 1, "staff_charge": "false"}}, } urls_to_skip = [ @@ -174,7 +179,7 @@ def setUpTestData(cls): EmailsCustomization.set("user_office_email_address", "email@example.org") EmailsCustomization.set("safety_email_address", "email@example.org") EmailsCustomization.set("abuse_email_address", "email@example.org") - UserRequestsCustomization.set("adjustment_requests_enabled", "enabled") + AdjustmentRequestsCustomization.set("adjustment_requests_enabled", "enabled") def test_get_full_url(self): request = RequestFactory().get("/") @@ -221,7 +226,7 @@ def test_more_calendar_urls(self): "event_feed", { "get": { - "event_type": f"{facility_name.lower()} usage", + "event_type": f"{facility_name.lower()} use", "start": start.strftime("%Y-%m-%d"), "end": end_one_day.strftime("%Y-%m-%d"), "item_type": "tool", @@ -234,7 +239,7 @@ def test_more_calendar_urls(self): "event_feed", { "get": { - "event_type": f"{facility_name.lower()} usage", + "event_type": f"{facility_name.lower()} use", "start": start.strftime("%Y-%m-%d"), "end": end_one_day.strftime("%Y-%m-%d"), "personal_schedule": "yes", @@ -246,7 +251,7 @@ def test_more_calendar_urls(self): "event_feed", { "get": { - "event_type": f"{facility_name.lower()} usage", + "event_type": f"{facility_name.lower()} use", "start": start.strftime("%Y-%m-%d"), "end": end_one_day.strftime("%Y-%m-%d"), "all_tools": "yes", @@ -258,7 +263,7 @@ def test_more_calendar_urls(self): "event_feed", { "get": { - "event_type": f"{facility_name.lower()} usage", + "event_type": f"{facility_name.lower()} use", "start": start.strftime("%Y-%m-%d"), "end": end_one_day.strftime("%Y-%m-%d"), "all_areas": "yes", @@ -270,7 +275,7 @@ def test_more_calendar_urls(self): "event_feed", { "get": { - "event_type": f"{facility_name.lower()} usage", + "event_type": f"{facility_name.lower()} use", "start": start.strftime("%Y-%m-%d"), "end": end_one_day.strftime("%Y-%m-%d"), "all_areastools": "yes", diff --git a/NEMO/tests/test_users.py b/NEMO/tests/test_users.py index 9f009d033..85512ee6a 100644 --- a/NEMO/tests/test_users.py +++ b/NEMO/tests/test_users.py @@ -1,3 +1,4 @@ +from django.contrib.auth.models import Permission from django.test import TestCase from django.urls import reverse @@ -22,3 +23,45 @@ def test_view_user_profile(self): login_as_staff(self.client) response = self.client.get(reverse("view_user", args=[customer.id])) self.assertEqual(response.status_code, 400) + + def test_impersonate_user(self): + admin = create_user_and_project(is_staff=True)[0] + admin.is_superuser = True + admin.save() + manager = create_user_and_project(is_staff=True)[0] + manager.is_facility_manager = True + manager.user_permissions.add(Permission.objects.get(codename="can_impersonate_users")) + manager.save() + staff = create_user_and_project(is_staff=True)[0] + staff.user_permissions.add(Permission.objects.get(codename="can_impersonate_users")) + staff.save() + user = create_user_and_project()[0] + user_2 = create_user_and_project()[0] + # 1 admin can impersonate anyone + login_as(self.client, admin) + response = self.client.post(reverse("impersonate"), data={"user_id": manager.id}) + self.assertEqual(response.status_code, 302) + # 2 facility manager can impersonate anyone except admins + login_as(self.client, manager) + response = self.client.post(reverse("impersonate"), data={"user_id": manager.id}) + self.assertEqual(response.status_code, 302) + response = self.client.post(reverse("impersonate"), data={"user_id": admin.id}) + self.assertEqual(response.status_code, 403) + # 3 staff can impersonate only regular users + login_as(self.client, staff) + response = self.client.post(reverse("impersonate"), data={"user_id": admin.id}) + self.assertEqual(response.status_code, 403) + response = self.client.post(reverse("impersonate"), data={"user_id": manager.id}) + self.assertEqual(response.status_code, 403) + response = self.client.post(reverse("impersonate"), data={"user_id": staff.id}) + self.assertEqual(response.status_code, 403) + response = self.client.post(reverse("impersonate"), data={"user_id": user.id}) + self.assertEqual(response.status_code, 302) + # 4 user cannot impersonate anyone unless he has permission + login_as(self.client, user) + response = self.client.post(reverse("impersonate"), data={"user_id": user_2.id}) + self.assertEqual(response.status_code, 403) + user.user_permissions.add(Permission.objects.get(codename="can_impersonate_users")) + user.save() + response = self.client.post(reverse("impersonate"), data={"user_id": user_2.id}) + self.assertEqual(response.status_code, 302) diff --git a/NEMO/urls.py b/NEMO/urls.py index e2f08ab4a..12409dda8 100644 --- a/NEMO/urls.py +++ b/NEMO/urls.py @@ -15,6 +15,7 @@ from django.views.static import serve from rest_framework import routers +from NEMO.constants import MEDIA_PROTECTED from NEMO.decorators import any_staff_required from NEMO.models import ReservationItemType from NEMO.views import ( @@ -62,7 +63,6 @@ user_requests, users, ) -from NEMO.views.constants import MEDIA_PROTECTED logger = logging.getLogger(__name__) @@ -110,6 +110,7 @@ def sort_urls(url_path): router.register(r"training_sessions", api.TrainingSessionViewSet) router.register(r"usage_events", api.UsageEventViewSet) router.register(r"users", api.UserViewSet) +router.register(r"user_documents", api.UserDocumentsViewSet) router.registry.sort(key=sort_urls) reservation_item_types = f'(?P{"|".join(ReservationItemType.values())})' @@ -123,16 +124,22 @@ def sort_urls(url_path): app_name = app.name if app_name != "NEMO" and app_name.startswith("NEMO"): try: - mod = import_module("%s.urls" % app_name) - except ModuleNotFoundError: - logger.warning(f"no urls found for NEMO plugin: {app_name}") - pass - except Exception as e: - logger.exception(f"could not import urls for NEMO plugin: {app_name}") - pass - else: - urlpatterns += [path("", include("%s.urls" % app_name))] - logger.debug(f"automatically including urls for plugin: {app_name}") + plugin_urls = "%s.urls" % app_name + try: + mod = import_module(plugin_urls) + except ModuleNotFoundError as e: + if e.name == plugin_urls: + logger.debug(f"no urls found for NEMO plugin: {app_name}") + else: + raise + else: + urlpatterns += [path("", include(plugin_urls))] + logger.debug(f"automatically including urls for plugin: {app_name}") + except Exception: + if getattr(app, "critical", False): + raise + else: + logger.exception(f"Failure when loading URLs for app: {app_name}") # The order matters for some tests to run properly urlpatterns += [ diff --git a/NEMO/utilities.py b/NEMO/utilities.py index 09a23124b..55da03a2f 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -1,18 +1,19 @@ +from __future__ import annotations + import csv import importlib import os from calendar import monthrange from copy import deepcopy -from datetime import date, datetime, time +from datetime import date, datetime, time, timedelta, timezone from email import encoders from email.mime.base import MIMEBase from enum import Enum from io import BytesIO, StringIO from logging import getLogger -from typing import Dict, List, Optional, Sequence, Set, Tuple, Union +from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union from urllib.parse import urljoin -import pytz from PIL import Image from dateutil import rrule from dateutil.parser import parse @@ -26,15 +27,22 @@ from django.core.mail import EmailMessage from django.db.models import QuerySet from django.http import HttpRequest, HttpResponse, QueryDict -from django.shortcuts import render +from django.shortcuts import resolve_url from django.template import Template from django.template.context import make_context from django.urls import NoReverseMatch, reverse -from django.utils import timezone +from django.utils import timezone as django_timezone from django.utils.formats import date_format, get_format, time_format from django.utils.html import format_html from django.utils.text import slugify -from django.utils.timezone import is_naive, localtime, make_aware + +# For backwards compatibility +import NEMO.plugins.utils + +render_combine_responses = NEMO.plugins.utils.render_combine_responses + +if TYPE_CHECKING: + from NEMO.models import User utilities_logger = getLogger(__name__) @@ -66,6 +74,24 @@ "%%": "%", } +py_to_pick_date_formats = { + "%A": "dddd", + "%a": "ddd", + "%B": "mmmm", + "%b": "mmm", + "%d": "dd", + "%H": "HH", + "%I": "hh", + "%M": "i", + "%m": "mm", + "%p": "A", + "%X": "HH:i", + "%x": "mm/dd/yyyy", + "%Y": "yyyy", + "%y": "yy", + "%%": "%", +} + # Convert a python format string to javascript format string def convert_py_format_to_js(string_format: str) -> str: @@ -74,13 +100,37 @@ def convert_py_format_to_js(string_format: str) -> str: return string_format +def convert_py_format_to_pickadate(string_format: str) -> str: + string_format = ( + string_format.replace("%w", "") + .replace("%s", "") + .replace("%f", "") + .replace("%:z", "") + .replace("%z", "") + .replace("%Z", "") + .replace("%j", "") + .replace(":%S", "") + .replace("%S", "") + .replace("%U", "") + .replace("%W", "") + .replace("%c", "") + .replace("%G", "") + .replace("%u", "") + .replace("%V", "") + ) + for py, pick in py_to_pick_date_formats.items(): + string_format = pick.join(string_format.split(py)) + return string_format + + time_input_format = get_format("TIME_INPUT_FORMATS")[0] date_input_format = get_format("DATE_INPUT_FORMATS")[0] datetime_input_format = get_format("DATETIME_INPUT_FORMATS")[0] time_input_js_format = convert_py_format_to_js(time_input_format) date_input_js_format = convert_py_format_to_js(date_input_format) datetime_input_js_format = convert_py_format_to_js(datetime_input_format) - +pickadate_date_format = getattr(settings, "PICKADATE_DATE_FORMAT", convert_py_format_to_pickadate(date_input_format)) +pickadate_time_format = getattr(settings, "PICKADATE_TIME_FORMAT", convert_py_format_to_pickadate(time_input_format)) supported_embedded_video_extensions = [".mp4", ".ogv", ".webm", ".3gp"] supported_embedded_pdf_extensions = [".pdf"] @@ -241,7 +291,7 @@ def parse_parameter_string( def month_list(since=datetime(year=2013, month=11, day=1)): - month_count = (timezone.now().year - since.year) * 12 + (timezone.now().month - since.month) + 1 + month_count = (django_timezone.now().year - since.year) * 12 + (django_timezone.now().month - since.month) + 1 result = list(rrule.rrule(rrule.MONTHLY, dtstart=since, count=month_count)) result = localize(result) result.reverse() @@ -252,7 +302,7 @@ def get_month_timeframe(date_str: str = None): if date_str: start = parse(date_str) else: - start = timezone.now() + start = django_timezone.now() first_of_the_month = localize(datetime(start.year, start.month, 1)) last_of_the_month = localize( datetime(start.year, start.month, monthrange(start.year, start.month)[1], 23, 59, 59, 999999) @@ -354,7 +404,7 @@ def format_daterange( def format_datetime(universal_time=None, df=None, as_current_timezone=True, use_l10n=None) -> str: - this_time = universal_time if universal_time else timezone.now() if as_current_timezone else datetime.now() + this_time = universal_time if universal_time else django_timezone.now() if as_current_timezone else datetime.now() local_time = as_timezone(this_time) if as_current_timezone else this_time if isinstance(local_time, time): return time_format(local_time, df or "TIME_FORMAT", use_l10n) @@ -370,7 +420,7 @@ def export_format_datetime( This function returns a formatted date/time for export files. Default returns date + time format, with underscores """ - this_time = date_time if date_time else timezone.now() if as_current_timezone else datetime.now() + this_time = date_time if date_time else django_timezone.now() if as_current_timezone else datetime.now() export_date_format = getattr(settings, "EXPORT_DATE_FORMAT", "m_d_Y").replace("-", "_") export_time_format = getattr(settings, "EXPORT_TIME_FORMAT", "h_i_s").replace("-", "_") if not underscore: @@ -386,20 +436,20 @@ def export_format_datetime( def as_timezone(dt): - naive = type(dt) == date or is_naive(dt) - return timezone.localtime(dt) if not naive else dt + naive = type(dt) == date or django_timezone.is_naive(dt) + return django_timezone.localtime(dt) if not naive else dt def localize(dt, tz=None): - tz = tz or timezone.get_current_timezone() + tz = tz or django_timezone.get_current_timezone() if isinstance(dt, list): - return [make_aware(d, tz) for d in dt] + return [django_timezone.make_aware(d, tz) for d in dt] else: - return make_aware(dt, tz) + return django_timezone.make_aware(dt, tz) def naive_local_current_datetime(): - return localtime(timezone.now()).replace(tzinfo=None) + return django_timezone.localtime(django_timezone.now()).replace(tzinfo=None) def beginning_of_the_day(t: datetime, in_local_timezone=True) -> datetime: @@ -590,14 +640,6 @@ def distinct_qs_value_list(qs: QuerySet, field_name: str) -> Set: return set(list(qs.values_list(field_name, flat=True))) -# Useful function to render and combine 2 separate django templates -def render_combine_responses(request, original_response: HttpResponse, template_name, context): - """Combines contents of an original http response with a new one""" - additional_content = render(request, template_name, context) - original_response.content += additional_content.content - return original_response - - def render_email_template(template, dictionary: dict, request=None): """Use Django's templating engine to render the email template If we don't have a request, create a empty one so context_processors (messages, customizations etc.) can be used @@ -725,8 +767,8 @@ def create_ics( event_name, start: datetime, end: datetime, - user, - organizer=None, + user: User, + organizer: User = None, cancelled: bool = False, description: str = None, ) -> MIMEBase: @@ -743,8 +785,8 @@ def create_ics( sequence = "SEQUENCE:2\n" if cancelled else "SEQUENCE:0\n" priority = "PRIORITY:5\n" if cancelled else "PRIORITY:0\n" now = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") - start = start.astimezone(pytz.utc).strftime("%Y%m%dT%H%M%SZ") - end = end.astimezone(pytz.utc).strftime("%Y%m%dT%H%M%SZ") + start = start.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + end = end.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") lines = [ "BEGIN:VCALENDAR\n", "VERSION:2.0\n", @@ -780,6 +822,111 @@ def new_model_copy(instance): return new_instance -def slugify_underscore(name: str): +def slugify_underscore(name: Any): # Slugify and replaces dashes by underscores return slugify(name).replace("-", "_") + + +def strtobool(val): + """Convert a string representation of truth to true (1) or false (0). + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return 1 + elif val in ("n", "no", "f", "false", "off", "0"): + return 0 + else: + raise ValueError("invalid truth value %r" % (val,)) + + +# This method will subtract weekend time if applicable and weekdays time between weekday_start_time_off and weekday_end_time_off +def get_duration_with_off_schedule( + start: datetime, + end: datetime, + weekend_off: bool, + policy_off_times: bool, + weekday_start_time_off: time, + weekday_end_time_off: time, +) -> timedelta: + duration = end - start + local_start, local_end = start.astimezone(), end.astimezone() + current_date = beginning_of_the_day(local_start) + while current_date <= local_end: + begin = beginning_of_the_day(current_date) + midnight = beginning_of_the_day(current_date + timedelta(days=1)) + current_start, current_end = max(begin, local_start), min(midnight, local_end) + if current_date.weekday() in [5, 6] and weekend_off: + # If it's a weekend day and weekends are off, subtract the days' duration + duration = duration - (current_end - current_start) + elif ( + current_date.weekday() not in [5, 6] + and policy_off_times + and weekday_start_time_off + and weekday_end_time_off + ): + # we have time offs during weekdays + if weekday_start_time_off < weekday_end_time_off != midnight: + duration = duration - find_overlapping_duration( + current_date, current_start, current_end, weekday_start_time_off, weekday_end_time_off + ) + else: + # reverse time off with overnight. i.e. 6pm -> 6am + # we are just splitting into 2 and running same algorithm + duration = duration - find_overlapping_duration( + current_date, current_start, current_end, weekday_start_time_off, time.min + ) + duration = duration - find_overlapping_duration( + current_date, current_start, current_end, time.min, weekday_end_time_off + ) + current_date += timedelta(days=1) + + return duration + + +# given a date, a start and end time and a start time of day and end time of day +# return the overlapping duration +def find_overlapping_duration( + current_date: datetime, + current_start: datetime, + current_end: datetime, + weekday_start_time_off: time, + weekday_end_time_off: time, +) -> timedelta: + current_start_time_off, current_end_time_off = get_local_date_times_for_item_policy_times( + current_date, weekday_start_time_off, weekday_end_time_off + ) + # double-check the start or end time off is actually included in the date range + if current_start <= current_start_time_off < current_end or current_start < current_end_time_off <= current_end: + return min(current_end, current_end_time_off) - max(current_start, current_start_time_off) + return timedelta(0) + + +# This method return datetime objects for start and end date of a policy off range +# i.e. given Fri, Sep 20 and policy off 6pm -> 9pm it will return (Fri Sep 20 @ 6pm, Fri Sep 20 @ 9pm) +# if the policy is overnight (6pm -> 6am) it will return (Fri Sep 20 @ 6pm, Sat Sep 21 @ 6am) +def get_local_date_times_for_item_policy_times( + current_date: datetime, weekday_start_time_off: time, weekday_end_time_off: time +) -> (datetime, datetime): + # Convert to local time since we will be using .date() + current_date = current_date.astimezone() + current_start_time_off = datetime.combine(current_date.date(), weekday_start_time_off, tzinfo=current_date.tzinfo) + if weekday_end_time_off < weekday_start_time_off: + # If the end is before the start, add a day so it counts as overnight + current_end_time_off = datetime.combine( + (current_date + timedelta(days=1)).date(), weekday_end_time_off, tzinfo=current_date.tzinfo + ) + else: + current_end_time_off = datetime.combine(current_date.date(), weekday_end_time_off, tzinfo=current_date.tzinfo) + return current_start_time_off, current_end_time_off + + +def response_js_redirect(to, query_string=None, *args, **kwargs): + return HttpResponse( + f"", + content_type="text/javascript", + status=202, + ) diff --git a/NEMO/views/adjustment_requests.py b/NEMO/views/adjustment_requests.py index 97a05b0ee..5f9f1dbe0 100644 --- a/NEMO/views/adjustment_requests.py +++ b/NEMO/views/adjustment_requests.py @@ -38,7 +38,7 @@ render_email_template, send_mail, ) -from NEMO.views.customization import EmailsCustomization, UserRequestsCustomization, get_media_file_contents +from NEMO.views.customization import AdjustmentRequestsCustomization, EmailsCustomization, get_media_file_contents from NEMO.views.notifications import ( create_adjustment_request_notification, create_request_message_notification, @@ -50,11 +50,11 @@ @login_required @require_GET def adjustment_requests(request): - if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_enabled"): return HttpResponseBadRequest("Adjustment requests are not enabled") user: User = request.user - max_requests = quiet_int(UserRequestsCustomization.get("adjustment_requests_display_max"), None) + max_requests = quiet_int(AdjustmentRequestsCustomization.get("adjustment_requests_display_max"), None) adj_requests = AdjustmentRequest.objects.filter(deleted=False) my_requests = adj_requests.filter(creator=user) @@ -75,7 +75,7 @@ def adjustment_requests(request): "pending_adjustment_requests": adj_requests.filter(status=RequestStatus.PENDING), "approved_adjustment_requests": adj_requests.filter(status=RequestStatus.APPROVED)[:max_requests], "denied_adjustment_requests": adj_requests.filter(status=RequestStatus.DENIED)[:max_requests], - "adjustment_requests_description": UserRequestsCustomization.get("adjustment_requests_description"), + "adjustment_requests_description": AdjustmentRequestsCustomization.get("adjustment_requests_description"), "request_notifications": get_notifications(request.user, Notification.Types.ADJUSTMENT_REQUEST, delete=False), "reply_notifications": get_notifications(request.user, Notification.Types.ADJUSTMENT_REQUEST_REPLY), "user_is_reviewer": user_is_reviewer, @@ -91,7 +91,7 @@ def adjustment_requests(request): @login_required @require_http_methods(["GET", "POST"]) def create_adjustment_request(request, request_id=None, item_type_id=None, item_id=None): - if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_enabled"): return HttpResponseBadRequest("Adjustment requests are not enabled") user: User = request.user @@ -107,27 +107,36 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ except ContentType.DoesNotExist: pass - # Show times if not missed reservation or if missed reservation but customization is set to show times anyway - change_times_allowed = can_change_times(adjustment_request.item) - # Show quantity field if we have a consumable - change_quantity_allowed = isinstance(adjustment_request.item, ConsumableWithdraw) + edit = bool(adjustment_request.id) + initial_data = {"creator": adjustment_request.creator if edit else user} + # set those initial properties on the form if we just changed the item + item_changed = bool(item_type_id) + if item_changed and adjustment_request.item and adjustment_request.item.can_times_be_changed(): + initial_data["new_start"] = adjustment_request.item.start + initial_data["new_end"] = adjustment_request.item.end + if item_changed and adjustment_request.item and adjustment_request.item.can_quantity_be_changed(): + initial_data["new_quantity"] = adjustment_request.item.quantity + + form = AdjustmentRequestForm( + request.POST or None, + instance=adjustment_request, + initial=initial_data, + ) - # only change the times if we are provided with a charge and it's allowed - if item_type_id and adjustment_request.item and change_times_allowed: - adjustment_request.new_start = adjustment_request.item.start - adjustment_request.new_end = adjustment_request.item.end - if item_type_id and adjustment_request.item and change_quantity_allowed: - adjustment_request.new_quantity = adjustment_request.item.quantity + item_type = form.data.get("item_type") if form.is_bound else None + item_id = form.data.get("item_id") if form.is_bound else None - dictionary = { - "change_times_allowed": change_times_allowed, - "change_quantity_allowed": change_quantity_allowed, - "eligible_items": adjustment_eligible_items(user, adjustment_request.item), - } + # item from the form always has priority + item = ( + ContentType.objects.get_for_id(item_type).get_object_for_this_type(pk=item_id) + if item_type and item_id + else adjustment_request.item + ) + + dictionary = {"item": item, "eligible_items": adjustment_eligible_items(user, current_item=item), "form": form} if request.method == "POST": # some extra validation needs to be done here because it depends on the user - edit = bool(adjustment_request.id) errors = [] if edit: if adjustment_request.deleted: @@ -137,12 +146,6 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ if adjustment_request.creator != user and user not in adjustment_request.reviewers(): errors.append("You are not allowed to edit this request.") - form = AdjustmentRequestForm( - request.POST, - instance=adjustment_request, - initial={"creator": adjustment_request.creator if edit else user}, - ) - # add errors to the form for better display for error in errors: form.add_error(None, error) @@ -161,7 +164,7 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ actual_decision = next(iter(decision)) if actual_decision.startswith("approve_"): adjustment_request.status = RequestStatus.APPROVED - if actual_decision == "approve_apply_request" and UserRequestsCustomization.get_bool( + if actual_decision == "approve_apply_request" and AdjustmentRequestsCustomization.get_bool( "adjustment_requests_apply_button" ): adjust_charge = True @@ -172,7 +175,7 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ form.instance.last_updated_by = user new_adjustment_request = form.save() - # We only apply it here in case something goes wrong before when saving it + # We only apply it here in case something goes wrong when saving it if adjust_charge: new_adjustment_request.apply_adjustment(user) @@ -186,26 +189,13 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ delete_notification(Notification.Types.ADJUSTMENT_REQUEST, adjustment_request.id, reviewers) send_request_received_email(request, new_adjustment_request, edit, reviewers) return redirect("user_requests", "adjustment") - else: - item_type = form.cleaned_data.get("item_type") - item_id = form.cleaned_data.get("item_id") - if item_type and item_id: - dictionary["change_times_allowed"] = can_change_times(item_type.get_object_for_this_type(pk=item_id)) - dictionary["change_quantity_allowed"] = isinstance( - item_type.get_object_for_this_type(pk=item_id), ConsumableWithdraw - ) - dictionary["form"] = form - return render(request, "requests/adjustment_requests/adjustment_request.html", dictionary) - else: - form = AdjustmentRequestForm(instance=adjustment_request) - dictionary["form"] = form - return render(request, "requests/adjustment_requests/adjustment_request.html", dictionary) + return render(request, "requests/adjustment_requests/adjustment_request.html", dictionary) @login_required @require_POST def adjustment_request_reply(request, request_id): - if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_enabled"): return HttpResponseBadRequest("Adjustment requests are not enabled") adjustment_request = get_object_or_404(AdjustmentRequest, id=request_id) @@ -233,7 +223,7 @@ def adjustment_request_reply(request, request_id): @login_required @require_GET def delete_adjustment_request(request, request_id): - if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_enabled"): return HttpResponseBadRequest("Adjustment requests are not enabled") adjustment_request = get_object_or_404(AdjustmentRequest, id=request_id) @@ -253,7 +243,7 @@ def delete_adjustment_request(request, request_id): @require_GET def mark_adjustment_as_applied(request, request_id): user: User = request.user - if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_enabled"): return HttpResponseBadRequest("Adjustment requests are not enabled") adjustment_request = get_object_or_404(AdjustmentRequest, id=request_id) @@ -274,9 +264,9 @@ def mark_adjustment_as_applied(request, request_id): @login_required @require_GET def apply_adjustment(request, request_id): - if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_enabled"): return HttpResponseBadRequest("Adjustment requests are not enabled") - if not UserRequestsCustomization.get_bool("adjustment_requests_apply_button"): + if not AdjustmentRequestsCustomization.get_bool("adjustment_requests_apply_button"): return HttpResponseBadRequest("Applying adjustments is not allowed") adjustment_request = get_object_or_404(AdjustmentRequest, id=request_id) @@ -311,7 +301,7 @@ def send_request_received_email(request, adjustment_request: AdjustmentRequest, "template_color": bootstrap_primary_color(color_type), "adjustment_request": adjustment_request, "status": status, - "adjustment_requests_url": absolute_url, + "adjustment_request_url": absolute_url, "manager_note": adjustment_request.manager_note if status == "denied" else None, "user_office": False, } @@ -374,44 +364,41 @@ def email_interested_parties(reply: RequestMessage, reply_url): ) -def can_change_times(item): - can_change_reservation_times = UserRequestsCustomization.get_bool("adjustment_requests_missed_reservation_times") - return ( - item - and not isinstance(item, ConsumableWithdraw) - and (not isinstance(item, Reservation) or can_change_reservation_times) - ) - - def adjustment_eligible_items(user: User, current_item=None) -> List[BillableItemMixin]: - item_number = UserRequestsCustomization.get_int("adjustment_requests_charges_display_number") - date_limit = UserRequestsCustomization.get_date_limit() + item_number = AdjustmentRequestsCustomization.get_int("adjustment_requests_charges_display_number") + date_limit = AdjustmentRequestsCustomization.get_date_limit() end_filter = {"end__gte": date_limit} if date_limit else {} items: List[BillableItemMixin] = [] - if UserRequestsCustomization.get_bool("adjustment_requests_missed_reservation_enabled"): + if AdjustmentRequestsCustomization.get_bool("adjustment_requests_missed_reservation_enabled"): items.extend( Reservation.objects.filter(user=user, missed=True).filter(**end_filter).order_by("-end")[:item_number] ) - if UserRequestsCustomization.get_bool("adjustment_requests_tool_usage_enabled"): + if AdjustmentRequestsCustomization.get_bool("adjustment_requests_tool_usage_enabled"): + # also add non-remote work on behalf of user items.extend( - UsageEvent.objects.filter(user=user, operator=user, end__isnull=False) + UsageEvent.objects.filter(end__isnull=False) + .filter(Q(user=user, operator=user) | Q(user=user, remote_work=False)) .filter(**end_filter) .order_by("-end")[:item_number] ) - if UserRequestsCustomization.get_bool("adjustment_requests_area_access_enabled"): + if AdjustmentRequestsCustomization.get_bool("adjustment_requests_area_access_enabled"): items.extend( AreaAccessRecord.objects.filter(customer=user, end__isnull=False, staff_charge__isnull=True) .filter(**end_filter) .order_by("-end")[:item_number] ) - if UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_enabled"): + if AdjustmentRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_enabled"): date_filter = {"date__gte": date_limit} if date_limit else {} consumable_withdrawals = ( ConsumableWithdraw.objects.filter(customer=user).filter(**date_filter).order_by("-date") ) - self_checkout = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_self_checkout") - staff_checkout = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_staff_checkout") - usage_event = UserRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_usage_event") + self_checkout = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_consumable_withdrawal_self_checkout" + ) + staff_checkout = AdjustmentRequestsCustomization.get_bool( + "adjustment_requests_consumable_withdrawal_staff_checkout" + ) + usage_event = AdjustmentRequestsCustomization.get_bool("adjustment_requests_consumable_withdrawal_usage_event") type_filter = Q() if not self_checkout: type_filter = type_filter & ~Q( @@ -425,10 +412,11 @@ def adjustment_eligible_items(user: User, current_item=None) -> List[BillableIte type_filter = type_filter & ~Q(usage_event__isnull=False) consumable_withdrawals = consumable_withdrawals.filter(type_filter) items.extend(consumable_withdrawals[:item_number]) - if user.is_staff and UserRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled"): - # Add all remote charges for staff to request for adjustment + if user.is_staff and AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled"): + # Add all charges where staff is the operator and remove the ones where user is the operator items.extend( - UsageEvent.objects.filter(remote_work=True, operator=user, end__isnull=False) + UsageEvent.objects.filter(operator=user, end__isnull=False) + .exclude(user=F("operator")) .filter(**end_filter) .order_by("-end")[:item_number] ) @@ -469,6 +457,7 @@ def adjustments_csv_export(request_list: List[AdjustmentRequest]) -> HttpRespons table_result.add_header(("new_start", "New start")) table_result.add_header(("new_end", "New end")) table_result.add_header(("difference", "Difference")) + table_result.add_header(("waived", "Waive requested")) table_result.add_header(("reviewer", "Reviewer")) table_result.add_header(("applied", "Applied")) table_result.add_header(("applied_by", "Applied by")) @@ -485,6 +474,7 @@ def adjustments_csv_export(request_list: List[AdjustmentRequest]) -> HttpRespons "new_end": req.new_end, "new_quantity": req.new_quantity, "difference": req.get_time_difference() or req.get_quantity_difference(), + "waived": req.waive, "reviewer": req.reviewer, "applied": req.applied, "applied_by": req.applied_by, diff --git a/NEMO/views/api.py b/NEMO/views/api.py index 91dac8f84..ab55f67f2 100644 --- a/NEMO/views/api.py +++ b/NEMO/views/api.py @@ -42,6 +42,7 @@ TrainingSession, UsageEvent, User, + UserDocuments, ) from NEMO.rest_pagination import NEMOPageNumberPagination from NEMO.serializers import ( @@ -81,6 +82,7 @@ ToolStatusSerializer, TrainingSessionSerializer, UsageEventSerializer, + UserDocumentSerializer, UserSerializer, ) from NEMO.typing import QuerySetType @@ -213,6 +215,20 @@ class UserViewSet(ModelViewSet): } +class UserDocumentsViewSet(ModelViewSet): + filename = "user_documents" + queryset = UserDocuments.objects.all() + serializer_class = UserDocumentSerializer + filterset_fields = { + "id": key_filters, + "user": key_filters, + "name": string_filters, + "url": string_filters, + "display_order": number_filters, + "uploaded_at": datetime_filters, + } + + class ProjectDisciplineViewSet(ModelViewSet): filename = "project_disciplines" queryset = ProjectDiscipline.objects.all() @@ -383,11 +399,14 @@ class ReservationViewSet(ModelViewSet): "tool": key_filters, "area_id": key_filters, "area": key_filters, + "question_data": string_filters, "cancelled": boolean_filters, "missed": boolean_filters, "validated": boolean_filters, "validated_by": key_filters, - "question_data": string_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -407,8 +426,12 @@ class UsageEventViewSet(ModelViewSet): "operator": key_filters, "tool_id": key_filters, "tool": key_filters, + "training": boolean_filters, "validated": boolean_filters, "validated_by": key_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -430,6 +453,9 @@ class AreaAccessRecordViewSet(ModelViewSet): "staff_charge": key_filters, "validated": boolean_filters, "validated_by": key_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -492,9 +518,12 @@ class StaffChargeViewSet(ModelViewSet): "project": key_filters, "start": datetime_filters, "end": datetime_filters, + "note": string_filters, "validated": boolean_filters, "validated_by": key_filters, - "note": string_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -512,12 +541,17 @@ class TrainingSessionViewSet(ModelViewSet): "tool": key_filters, "project_id": key_filters, "project": key_filters, + "usage_event_id": key_filters, + "usage_event": key_filters, "duration": number_filters, "type": number_filters, "date": datetime_filters, "qualified": boolean_filters, "validated": boolean_filters, "validated_by": key_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -562,6 +596,9 @@ class ConsumableWithdrawViewSet(ModelViewSet): "date": datetime_filters, "validated": boolean_filters, "validated_by": key_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -569,6 +606,7 @@ class ContentTypeViewSet(XLSXFileMixin, viewsets.ReadOnlyModelViewSet): filename = "content_types" queryset = ContentType.objects.all() serializer_class = ContentTypeSerializer + pagination_class = None filterset_fields = { "id": key_filters, "app_label": string_filters, @@ -629,8 +667,8 @@ class PhysicalAccessLevelViewSet(ModelViewSet): "name": string_filters, "area": key_filters, "schedule": number_filters, - "weekdays_start_time": datetime_filters, - "weekdays_end_time": datetime_filters, + "weekdays_start_time": time_filters, + "weekdays_end_time": time_filters, "allow_staff_access": boolean_filters, "allow_user_request": boolean_filters, } @@ -727,6 +765,7 @@ class PermissionViewSet(XLSXFileMixin, viewsets.ReadOnlyModelViewSet): filename = "permissions" queryset = Permission.objects.all() serializer_class = PermissionSerializer + pagination_class = None filterset_fields = { "name": string_filters, "codename": string_filters, diff --git a/NEMO/views/api_billing.py b/NEMO/views/api_billing.py index 25dfd5664..5deb6e030 100644 --- a/NEMO/views/api_billing.py +++ b/NEMO/views/api_billing.py @@ -59,6 +59,11 @@ def __init__(self, item_type: str, project: Project, user: User): self.name: Optional[str] = None self.details: Optional[str] = "" self.item_id: Optional[int] = None + self.validated: bool = False + self.validated_by: Optional[User] = None + self.waived: bool = False + self.waived_by: Optional[User] = None + self.waived_on: Optional[datetime] = None if project: self.account: Optional[str] = project.account.name self.account_id: Optional[int] = project.account.id @@ -217,6 +222,11 @@ def billable_items_usage_events(usage_events: QuerySetType[UsageEvent]) -> List[ item.start = usage_event.start item.end = usage_event.end item.quantity = get_minutes_between_dates(usage_event.start, usage_event.end) + item.validated = usage_event.validated + item.validated_by = usage_event.validated_by + item.waived = usage_event.waived + item.waived_on = usage_event.waived_on + item.waived_by = usage_event.waived_by billable_items.append(item) return billable_items @@ -235,6 +245,11 @@ def billable_items_area_access_records(area_access_records: QuerySetType[AreaAcc item.start = area_access_record.start item.end = area_access_record.end item.quantity = get_minutes_between_dates(area_access_record.start, area_access_record.end) + item.validated = area_access_record.validated + item.validated_by = area_access_record.validated_by + item.waived = area_access_record.waived + item.waived_on = area_access_record.waived_on + item.waived_by = area_access_record.waived_by billable_items.append(item) return billable_items @@ -248,6 +263,11 @@ def billable_items_consumable_withdrawals(withdrawals: QuerySetType[ConsumableWi item.start = consumable_withdrawal.date item.end = consumable_withdrawal.date item.quantity = consumable_withdrawal.quantity + item.validated = consumable_withdrawal.validated + item.validated_by = consumable_withdrawal.validated_by + item.waived = consumable_withdrawal.waived + item.waived_on = consumable_withdrawal.waived_on + item.waived_by = consumable_withdrawal.waived_by billable_items.append(item) return billable_items @@ -261,6 +281,11 @@ def billable_items_missed_reservations(missed_reservations: QuerySetType[Reserva item.start = missed_reservation.start item.end = missed_reservation.end item.quantity = 1 + item.validated = missed_reservation.validated + item.validated_by = missed_reservation.validated_by + item.waived = missed_reservation.waived + item.waived_on = missed_reservation.waived_on + item.waived_by = missed_reservation.waived_by billable_items.append(item) return billable_items @@ -275,6 +300,11 @@ def billable_items_staff_charges(staff_charges: QuerySetType[StaffCharge]) -> Li item.start = staff_charge.start item.end = staff_charge.end item.quantity = get_minutes_between_dates(staff_charge.start, staff_charge.end) + item.validated = staff_charge.validated + item.validated_by = staff_charge.validated_by + item.waived = staff_charge.waived + item.waived_on = staff_charge.waived_on + item.waived_by = staff_charge.waived_by billable_items.append(item) return billable_items @@ -289,6 +319,11 @@ def billable_items_training_sessions(training_sessions: QuerySetType[TrainingSes item.start = training_session.date item.end = training_session.date item.quantity = training_session.duration + item.validated = training_session.validated + item.validated_by = training_session.validated_by + item.waived = training_session.waived + item.waived_on = training_session.waived_on + item.waived_by = training_session.waived_by billable_items.append(item) return billable_items diff --git a/NEMO/views/area_access.py b/NEMO/views/area_access.py index 4995479a2..a4e2fe235 100644 --- a/NEMO/views/area_access.py +++ b/NEMO/views/area_access.py @@ -3,6 +3,7 @@ from logging import getLogger from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError from django.db.models import F from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render @@ -193,6 +194,11 @@ def new_area_access_record(request): if record.project not in record.customer.active_projects(): dictionary["error_message"] = "{} is not authorized to bill that project.".format(record.customer) return render(request, "area_access/new_area_access_record.html", dictionary) + try: + record.full_clean() + except ValidationError as e: + dictionary["error_message"] = str(e) + return render(request, "area_access/new_area_access_record.html", dictionary) record.save() dictionary["success"] = "{} is now logged in to the {}.".format(record.customer, record.area.name) return render(request, "area_access/new_area_access_record.html", dictionary) @@ -258,6 +264,10 @@ def change_project(request, new_project=None): record.area = area record.customer = request.user record.project = new_project + try: + record.full_clean() + except ValidationError as e: + return render(request, "area_access/change_project.html", {"error": str(e)}) record.save() return redirect(reverse("landing")) @@ -375,6 +385,9 @@ def self_log_in(request, load_areas=True): f"You do not have a current reservation for the {error.area.name}. Please make a reservation before trying to access this area." ) return render(request, "area_access/self_login.html", dictionary) + except ValidationError as e: + dictionary["area_error_message"] = str(e) + return render(request, "area_access/self_login.html", dictionary) except Exception as error: area_access_logger.exception(error) dictionary["area_error_message"] = "unexpected error" @@ -418,7 +431,10 @@ def occupancy(request): @synchronized("user") def log_in_user_to_area(area, user, project): - return AreaAccessRecord.objects.create(area=area, customer=user, project=project) + area_access_record = AreaAccessRecord(area=area, customer=user, project=project) + area_access_record.full_clean() + area_access_record.save() + return area_access_record @synchronized("user") diff --git a/NEMO/views/authentication.py b/NEMO/views/authentication.py index 0d7e0d305..f7ba680d7 100644 --- a/NEMO/views/authentication.py +++ b/NEMO/views/authentication.py @@ -295,11 +295,36 @@ def impersonate(request): return redirect(reverse("landing")) if not request.user.has_perm("NEMO.can_impersonate_users"): return HttpResponseForbidden() + user: User = request.user if request.method == "POST": user_id = request.POST["user_id"] + impersonated_user: User = User.objects.get(pk=user_id) + # check roles to make sure regular users cannot impersonate admins or facility managers + if user.is_superuser: + pass + elif user.is_facility_manager: + # Facility managers can impersonate anyone except admins + if impersonated_user.is_superuser: + return HttpResponseForbidden("You cannot impersonate an administrator") + else: + # Anyone else (staff non admin, non facility manager and regular users) can only impersonate regular users + if impersonated_user.is_any_part_of_staff: + return HttpResponseForbidden("You cannot only impersonate regular users") request.session["impersonate_id"] = int(user_id) - request.session["impersonated_user"] = str(User.objects.get(pk=user_id)) + request.session["impersonated_user"] = str(impersonated_user) return redirect(reverse("landing")) else: users = User.objects.filter(is_active=True) + if user.is_superuser: + pass + elif user.is_facility_manager: + users = users.exclude(is_superuser=True) + else: + users = ( + users.exclude(is_facility_manager=True) + .exclude(is_superuser=True) + .exclude(is_accounting_officer=True) + .exclude(is_staff=True) + .exclude(is_user_office=True) + ) return render(request, "impersonate.html", {"users": users}) diff --git a/NEMO/views/calendar.py b/NEMO/views/calendar.py index a826b19d6..46bef5eea 100644 --- a/NEMO/views/calendar.py +++ b/NEMO/views/calendar.py @@ -15,6 +15,7 @@ from django.utils.timezone import make_aware from django.views.decorators.http import require_GET, require_POST +from NEMO.constants import ADDITIONAL_INFORMATION_MAXIMUM_LENGTH from NEMO.decorators import disable_session_expiry_refresh, postpone, staff_member_required, synchronized from NEMO.exceptions import ProjectChargeException, RequiredUnansweredQuestionsException from NEMO.forms import ScheduledOutageForm, save_scheduled_outage @@ -50,11 +51,11 @@ render_email_template, send_mail, ) -from NEMO.views.constants import ADDITIONAL_INFORMATION_MAXIMUM_LENGTH from NEMO.views.customization import ( ApplicationCustomization, CalendarCustomization, EmailsCustomization, + ToolCustomization, get_media_file_contents, ) from NEMO.widgets.dynamic_form import DynamicForm, render_group_questions @@ -175,7 +176,20 @@ def event_feed(request): facility_name = ApplicationCustomization.get("facility_name") if event_type == "reservations": return reservation_event_feed(request, start, end) - elif event_type == f"{facility_name.lower()} usage": + if event_type == "reservations and use": + # We need to remove the json array brackets from the original responses (last [ of reservations and first ] of usage) + reservation_feed = reservation_event_feed(request, start, end) + reservation_feed_content = reservation_feed.content + position = reservation_feed_content.rfind("]".encode()) + if position != -1: + reservation_feed_content = ( + reservation_feed_content[:position] + "".encode() + reservation_feed_content[position + 1 :] + ) + reservation_feed.content = reservation_feed_content + usage_event_feed(request, start, end).content.replace( + "[".encode(), "".encode(), 1 + ) + return reservation_feed + elif event_type == f"{facility_name.lower()} use": return usage_event_feed(request, start, end) # Only staff may request a specific user's history... elif event_type == "specific user" and request.user.is_staff: @@ -955,7 +969,7 @@ def reservation_group_question(request, reservation_question_id, group_name): def get_and_combine_reservation_questions( item_type: ReservationItemType, item_id: int, project: Project = None ) -> List[ReservationQuestions]: - reservation_questions = ReservationQuestions.objects.all() + reservation_questions = ReservationQuestions.objects.filter(enabled=True) if item_type == ReservationItemType.TOOL: reservation_questions = reservation_questions.filter(tool_reservations=True) reservation_questions = reservation_questions.filter(Q(only_for_tools=None) | Q(only_for_tools__in=[item_id])) @@ -1053,7 +1067,7 @@ def cancel_the_reservation( if reservation.area: recipients.extend(reservation.area.reservation_email_list()) if reservation.user.get_preferences().attach_cancelled_reservation: - event_name = f"{reservation.reservation_item.name} Reservation" + event_name = reservation.title or f"{reservation.reservation_item.name} Reservation" attachment = create_ics( reservation.id, event_name, reservation.start, reservation.end, reservation.user, cancelled=True ) @@ -1095,7 +1109,7 @@ def send_user_created_reservation_notification(reservation: Reservation): user_office_email = EmailsCustomization.get("user_office_email_address") # We don't need to check for existence of reservation_created_user_email because we are attaching the ics reservation and sending the email regardless (message will be blank) if user_office_email: - event_name = f"{reservation.reservation_item.name} Reservation" + event_name = reservation.title or f"{reservation.reservation_item.name} Reservation" attachment = create_ics(reservation.id, event_name, reservation.start, reservation.end, reservation.user) send_mail( subject=subject, content=message, from_email=user_office_email, to=recipients, attachments=[attachment] @@ -1122,7 +1136,7 @@ def send_user_cancelled_reservation_notification(reservation: Reservation): user_office_email = EmailsCustomization.get("user_office_email_address") # We don't need to check for existence of reservation_cancelled_user_email because we are attaching the ics reservation and sending the email regardless (message will be blank) if user_office_email: - event_name = f"{reservation.reservation_item.name} Reservation" + event_name = reservation.title or f"{reservation.reservation_item.name} Reservation" attachment = create_ics( reservation.id, event_name, reservation.start, reservation.end, reservation.user, cancelled=True ) @@ -1177,11 +1191,13 @@ def send_tool_free_time_notification( formatted_time = f"{freed_time:0.0f}" link = get_full_url(reverse("calendar"), request) user_ids = distinct_qs_value_list(tool_notifications, "user") + include_username = ToolCustomization.get_bool("tool_freed_time_notification_include_username") for user in User.objects.in_bulk(user_ids).values(): if user != cancelled_reservation.user: + include_username_message = f" by {user.username}" if include_username else "" subject = f"[{tool.name}] {formatted_time} minutes freed starting {formatted_start}" message = f"Dear {user.first_name},
\n" - message += f"The following time slot has been freed for the {tool.name}:

\n\n" + message += f"The following time slot has been freed for the {tool.name}{include_username_message}:

\n\n" message += f"Start: {formatted_start}
\n" message += f"End: {format_datetime(start_time + timedelta(minutes=freed_time))}
\n" message += f"Duration: {formatted_time} minutes

\n\n" diff --git a/NEMO/views/constants.py b/NEMO/views/constants.py index 5621ca9ac..e5a08b29c 100644 --- a/NEMO/views/constants.py +++ b/NEMO/views/constants.py @@ -1,14 +1,2 @@ -# Maximum length for additional information of reservations -ADDITIONAL_INFORMATION_MAXIMUM_LENGTH = 3000 - -# Maximum length for feedback emails -FEEDBACK_MAXIMUM_LENGTH = 5000 - -# Maximum length for Char fields to work with every db type -CHAR_FIELD_MAXIMUM_LENGTH = 255 - -# Name of the parameter to indicate which view to redirect to -NEXT_PARAMETER_NAME = "next" - -# Name of the media folder under which only staff can see files -MEDIA_PROTECTED = "protected" +# Kept for backward compatibility. remove in future version of NEMO +from NEMO.constants import * diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py index 23284b47b..363b750ad 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -360,7 +360,6 @@ class InterlockCustomization(CustomizationBase): @customization(key="requests", title="User requests") class UserRequestsCustomization(CustomizationBase): - frequencies = [RecurrenceFrequency.DAILY, RecurrenceFrequency.WEEKLY, RecurrenceFrequency.MONTHLY] variables = { "buddy_requests_title": "Buddy requests board", "buddy_board_description": "", @@ -368,6 +367,22 @@ class UserRequestsCustomization(CustomizationBase): "access_requests_description": "", "access_requests_minimum_users": "2", "access_requests_display_max": "", + "weekend_access_notification_emails": "", + "weekend_access_notification_cutoff_hour": "", + "weekend_access_notification_cutoff_day": "", + } + + def validate(self, name, value): + if name == "weekend_access_notification_emails": + recipients = tuple([e for e in value.split(",") if e]) + for email in recipients: + validate_email(email) + + +@customization(key="adjustment_requests", title="Adjustment requests") +class AdjustmentRequestsCustomization(CustomizationBase): + frequencies = [RecurrenceFrequency.DAILY, RecurrenceFrequency.WEEKLY, RecurrenceFrequency.MONTHLY] + variables = { "adjustment_requests_enabled": "", "adjustment_requests_tool_usage_enabled": "enabled", "adjustment_requests_area_access_enabled": "enabled", @@ -378,6 +393,10 @@ class UserRequestsCustomization(CustomizationBase): "adjustment_requests_consumable_withdrawal_self_checkout": "enabled", "adjustment_requests_consumable_withdrawal_staff_checkout": "enabled", "adjustment_requests_consumable_withdrawal_usage_event": "enabled", + "adjustment_requests_waive_tool_usage_enabled": "", + "adjustment_requests_waive_area_access_enabled": "", + "adjustment_requests_waive_consumable_withdrawal_enabled": "", + "adjustment_requests_waive_missed_reservation_enabled": "", "adjustment_requests_title": "Adjustment requests", "adjustment_requests_description": "", "adjustment_requests_charges_display_number": "10", @@ -386,9 +405,6 @@ class UserRequestsCustomization(CustomizationBase): "adjustment_requests_time_limit_frequency": RecurrenceFrequency.WEEKLY.index, "adjustment_requests_edit_charge_button": "", "adjustment_requests_apply_button": "", - "weekend_access_notification_emails": "", - "weekend_access_notification_cutoff_hour": "", - "weekend_access_notification_cutoff_day": "", } @classmethod @@ -430,10 +446,6 @@ def context(self) -> Dict: return context_dict def validate(self, name, value): - if name == "weekend_access_notification_emails": - recipients = tuple([e for e in value.split(",") if e]) - for email in recipients: - validate_email(email) if value and name == "adjustment_requests_time_limit_frequency": try: if RecurrenceFrequency(int(value)) not in self.frequencies: @@ -486,12 +498,14 @@ class ToolCustomization(CustomizationBase): "tool_task_updates_superusers": "", "tool_task_updates_allow_regular_user_preferences": "", "tool_control_hide_data_history_users": "", + "tool_control_documents_in_separate_tab": "", "tool_control_configuration_setting_template": "{{ current_setting }}", "tool_control_broadcast_upcoming_reservation": "", "tool_control_show_task_details": "", "tool_control_show_qualified_users_to_all": "", "tool_control_show_documents_only_qualified_users": "", "tool_control_show_tool_credentials": "enabled", + "tool_control_show_next_reservation_user": "", "tool_qualification_reminder_days": "", "tool_qualification_expiration_days": "", "tool_qualification_expiration_never_used_days": "", @@ -499,10 +513,13 @@ class ToolCustomization(CustomizationBase): "tool_problem_max_image_size_pixels": "750", "tool_problem_send_to_all_qualified_users": "", "tool_problem_allow_regular_user_preferences": "", + "tool_problem_safety_hazard_automatic_shutdown": "", "tool_configuration_near_future_days": "1", "tool_reservation_policy_superusers_bypass": "", "tool_wait_list_spot_expiration": "15", "tool_wait_list_reservation_buffer": "15", + "tool_freed_time_notification_include_username": "", + "kiosk_only_show_qualified_tools": "", } def validate(self, name, value): @@ -561,7 +578,13 @@ class RemoteWorkCustomization(CustomizationBase): @customization(key="training", title="Training") class TrainingCustomization(CustomizationBase): - variables = {"training_only_type": "", "training_allow_date": "", "training_included_hidden_tools": ""} + variables = { + "training_only_type": "", + "training_allow_date": "", + "training_included_hidden_tools": "", + "training_show_self_option_in_tool_control": "", + "training_show_behalf_option_in_tool_control": "", + } def context(self) -> Dict: dictionary = super().context() @@ -625,6 +648,7 @@ class TemplatesCustomization(CustomizationBase): ("weekend_access_email", ".html"), ("recurring_charges_reminder_email", ".html"), ("wait_list_notification_email", ".html"), + ("tool_required_unanswered_questions_email", ".html"), ] diff --git a/NEMO/views/email.py b/NEMO/views/email.py index 354f1789d..902e9079f 100644 --- a/NEMO/views/email.py +++ b/NEMO/views/email.py @@ -222,7 +222,7 @@ def send_broadcast_email(request): ) form = EmailBroadcastForm(request.POST) if not form.is_valid(): - return render(request, "email/compose_email.html", {"form": form}) + return render(request, "email/compose_email.html", {"form": form, "error": form.errors.as_text()}) # Check if the user is allowed to broadcast email error = check_user_allowed(request.user, form.cleaned_data["audience"], form.cleaned_data["selection"]) if error: diff --git a/NEMO/views/feedback.py b/NEMO/views/feedback.py index a4edb4ace..d1f4403f6 100644 --- a/NEMO/views/feedback.py +++ b/NEMO/views/feedback.py @@ -2,8 +2,8 @@ from django.shortcuts import render from django.views.decorators.http import require_http_methods +from NEMO.constants import FEEDBACK_MAXIMUM_LENGTH from NEMO.utilities import EmailCategory, parse_parameter_string, render_email_template, send_mail -from NEMO.views.constants import FEEDBACK_MAXIMUM_LENGTH from NEMO.views.customization import EmailsCustomization, get_media_file_contents diff --git a/NEMO/views/landing.py b/NEMO/views/landing.py index e65a99066..1489fac36 100644 --- a/NEMO/views/landing.py +++ b/NEMO/views/landing.py @@ -63,6 +63,7 @@ def landing(request): "landing_page_choices": landing_page_choices, "self_log_in": able_to_self_log_in_to_area(request.user), "self_log_out": able_to_self_log_out_of_area(request.user), + "script_name": settings.FORCE_SCRIPT_NAME, } return render(request, "landing.html", dictionary) diff --git a/NEMO/views/remote_work.py b/NEMO/views/remote_work.py index ae4d76081..91286b704 100644 --- a/NEMO/views/remote_work.py +++ b/NEMO/views/remote_work.py @@ -1,4 +1,5 @@ from django.contrib import messages +from django.core.exceptions import ValidationError from django.db.models import Q from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import get_object_or_404, redirect, render @@ -260,6 +261,10 @@ def begin_staff_area_charge(request): return HttpResponseBadRequest(e.msg) except: return HttpResponseBadRequest("Invalid area") + try: + record.full_clean() + except ValidationError as e: + return HttpResponseBadRequest(str(e)) # No errors, save it record.save() return redirect(reverse("staff_charges")) diff --git a/NEMO/views/status_dashboard.py b/NEMO/views/status_dashboard.py index fc0c5e1be..7676712fb 100644 --- a/NEMO/views/status_dashboard.py +++ b/NEMO/views/status_dashboard.py @@ -355,7 +355,7 @@ def create_tool_summary(tooltip_info=False): tool_summary = merge(tools, tasks, unavailable_resources, usage_events, scheduled_outages, tooltip_info) tool_summary = list(tool_summary.values()) tool_sort = StatusDashboardCustomization.get("dashboard_tool_sort") - max_date_aware = (datetime.max - timedelta(days=1)).astimezone() + max_date_aware = datetime.max.replace(tzinfo=timezone.get_default_timezone()) if tool_sort == "name": tool_summary.sort(key=lambda x: x["name"].lower()) elif tool_sort == "time_desc": diff --git a/NEMO/views/tasks.py b/NEMO/views/tasks.py index 360ff5dfc..32822a481 100644 --- a/NEMO/views/tasks.py +++ b/NEMO/views/tasks.py @@ -1,5 +1,5 @@ from logging import getLogger -from typing import List, Set +from typing import List, Set, Tuple from django.conf import settings from django.contrib.auth.decorators import login_required @@ -87,7 +87,11 @@ def create(request): def save_task(request, task: Task, user: User, task_images: List[TaskImages] = None): task.save() - if task.force_shutdown: + if ( + task.force_shutdown + or task.safety_hazard + and ToolCustomization.get_bool("tool_problem_safety_hazard_automatic_shutdown") + ): # Shut down the tool. task.tool.operational = False task.tool.save() @@ -136,12 +140,11 @@ def send_new_task_emails(request, task: Task, user, task_images: List[TaskImages + (" shutdown" if task.force_shutdown else " problem") ) message = render_email_template(message, dictionary, request) - recipients = get_task_email_recipients(task, new=True) + tos, bcc = get_task_email_recipients(task, new=True) if ToolCustomization.get_bool("tool_problem_send_to_all_qualified_users"): - recipients = set(recipients) for qualified_user in task.tool.user_set.all(): if qualified_user.is_active: - recipients.update( + bcc.extend( [ email for email in qualified_user.get_emails( @@ -153,7 +156,8 @@ def send_new_task_emails(request, task: Task, user, task_images: List[TaskImages subject=subject, content=message, from_email=user.email, - to=recipients, + to=tos, + bcc=bcc, attachments=attachments, email_category=EmailCategory.TASKS, ) @@ -227,7 +231,7 @@ def cancel(request, task_id): def send_task_updated_email(task, url, task_images: List[TaskImages] = None): try: - recipients = get_task_email_recipients(task) + tos, bcc = get_task_email_recipients(task) attachments = None if task_images: attachments = [ @@ -264,7 +268,8 @@ def send_task_updated_email(task, url, task_images: List[TaskImages] = None): subject=f"{task.tool} task {task_status}", content=message, from_email=task_user.email, - to=recipients, + to=tos, + bcc=bcc, attachments=attachments, email_category=EmailCategory.TASKS, ) @@ -401,9 +406,10 @@ def save_task_images(request, task: Task) -> List[TaskImages]: return task_images -def get_task_email_recipients(task: Task, new=False) -> List[str]: +def get_task_email_recipients(task: Task, new=False) -> Tuple[List[str], List[str]]: # Add all recipients, starting with primary owner recipient_users: Set[User] = {task.tool.primary_owner} + bcc_users: Set[User] = set() # Add backup owners recipient_users.update(task.tool.backup_owners.all()) if ToolCustomization.get_bool("tool_task_updates_superusers"): @@ -430,12 +436,13 @@ def get_task_email_recipients(task: Task, new=False) -> List[str]: and ToolCustomization.get_bool("tool_task_updates_allow_regular_user_preferences") ) if send_email_to_regular_user: - recipient_users.update( + bcc_users.update( User.objects.filter(is_active=True).filter(Q(preferences__tool_task_notifications__in=[task.tool])) ) - recipients = [ + tos = [ email for user in recipient_users for email in user.get_emails(user.get_preferences().email_send_task_updates) ] + bcc = [email for user in bcc_users for email in user.get_emails(user.get_preferences().email_send_task_updates)] if task.tool.notification_email_address: - recipients.append(task.tool.notification_email_address) - return recipients + tos.append(task.tool.notification_email_address) + return tos, bcc diff --git a/NEMO/views/timed_services.py b/NEMO/views/timed_services.py index 0ebef423f..0dbcf3e18 100644 --- a/NEMO/views/timed_services.py +++ b/NEMO/views/timed_services.py @@ -385,7 +385,7 @@ def email_out_of_time_reservation_notification(request): def send_email_out_of_time_reservation_notification(request=None): """ - Out of time reservation notification for areas is when a user is still logged in an area but his reservation expired. + Out of time reservation notification for areas is when a user is still logged in an area but either their reservation expired or they are outside of their permitted access hours. """ # Exit early if the out of time reservation email template has not been customized for the organization yet. # This feature only sends emails, so if the template is not defined there nothing to do. @@ -394,7 +394,8 @@ def send_email_out_of_time_reservation_notification(request=None): "The out of time reservation email template has not been customized for your organization yet. Please visit the customization page to upload a template, then out of time email notifications can be sent." ) - out_of_time_user_area = [] + out_of_time_user_reservations = [] + trigger_time = timezone.now().replace(second=0, microsecond=0) # Round down to the nearest minute. # Find all logged users access_records: List[AreaAccessRecord] = ( @@ -409,31 +410,36 @@ def send_email_out_of_time_reservation_notification(request=None): if customer.is_staff or customer.is_service_personnel: continue - if area.requires_reservation: - # Calculate the timestamp of how late a user can be logged in after a reservation ended. - threshold = ( - timezone.now() - if not area.logout_grace_period - else timezone.now() - timedelta(minutes=area.logout_grace_period) - ) - threshold = datetime.replace(threshold, second=0, microsecond=0) # Round down to the nearest minute. - ending_reservations = Reservation.objects.filter( - cancelled=False, - missed=False, - shortened=False, - area=area, - user=customer, - start__lte=timezone.now(), - end=threshold, - ) - # find out if a reservation is starting right at the same time (in case of back to back reservations, in which case customer is good) - starting_reservations = Reservation.objects.filter( - cancelled=False, missed=False, shortened=False, area=area, user=customer, start=threshold - ) - if ending_reservations.exists() and not starting_reservations.exists(): - out_of_time_user_area.append(ending_reservations[0]) + threshold = ( + trigger_time if not area.logout_grace_period else trigger_time - timedelta(minutes=area.logout_grace_period) + ) + physical_access = PhysicalAccessLevel.objects.filter(user=customer, area=area).first() + # Check first if allowed schedule has just expired + if ( + physical_access + and physical_access.accessible_at(threshold - timedelta(minutes=1)) + and not physical_access.accessible_at(threshold) + ): + out_of_time_user_reservations.append(Reservation(user=customer, area=area, end=threshold)) + else: + if area.requires_reservation: + ending_reservations = Reservation.objects.filter( + cancelled=False, + missed=False, + shortened=False, + area=area, + user=customer, + start__lte=timezone.now(), + end=threshold, + ) + # find out if a reservation is starting right at the same time (in case of back to back reservations, in which case customer is good) + starting_reservations = Reservation.objects.filter( + cancelled=False, missed=False, shortened=False, area=area, user=customer, start=threshold + ) + if ending_reservations.exists() and not starting_reservations.exists(): + out_of_time_user_reservations.append(ending_reservations[0]) - for reservation in out_of_time_user_area: + for reservation in out_of_time_user_reservations: send_out_of_time_reservation_notification(reservation, request) return HttpResponse() @@ -443,7 +449,8 @@ def send_out_of_time_reservation_notification(reservation: Reservation, request= message = get_media_file_contents("out_of_time_reservation_email.html") user_office_email = EmailsCustomization.get("user_office_email_address") if message and user_office_email: - subject = "Out of time in the " + str(reservation.area.name) + name = str(reservation.area.name) + subject = "Out of time for the " + name if reservation.start else "Out of allowed schedule for the " + name message = render_email_template(message, {"reservation": reservation}, request) recipients = reservation.user.get_emails( reservation.user.get_preferences().email_send_reservation_ending_reminders diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 000fb16ec..f214a69e6 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError from django.db.models import Q from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -46,12 +47,12 @@ get_email_from_settings, quiet_int, render_email_template, + response_js_redirect, send_mail, ) from NEMO.views.area_access import able_to_self_log_out_of_area from NEMO.views.calendar import shorten_reservation from NEMO.views.customization import ( - ApplicationCustomization, CalendarCustomization, EmailsCustomization, InterlockCustomization, @@ -166,6 +167,18 @@ def tool_status(request, tool_id): except Reservation.DoesNotExist: pass + dictionary["next_reservation"] = ( + Reservation.objects.filter( + start__gt=timezone.now(), + cancelled=False, + missed=False, + shortened=False, + tool=tool, + ) + .order_by("start") + .first() + ) + # Staff need the user list to be able to qualify users for the tool. if user.is_staff: dictionary["users"] = User.objects.filter(is_active=True) @@ -241,15 +254,17 @@ def usage_data_history(request, tool_id): table_pre_run_data = BasicDisplayTable() table_pre_run_data.add_header(("user", "User")) + table_pre_run_data.add_header(("operator", "Operator")) if show_project_info: table_pre_run_data.add_header(("project", "Project")) - table_pre_run_data.add_header(("date", "Date")) + table_pre_run_data.add_header(("date", "Start date")) table_run_data = BasicDisplayTable() table_run_data.add_header(("user", "User")) + table_run_data.add_header(("operator", "Operator")) if show_project_info: table_run_data.add_header(("project", "Project")) - table_run_data.add_header(("date", "Date")) + table_run_data.add_header(("date", "End date")) for usage_event in pre_usage_events: if usage_event.pre_run_data: @@ -378,6 +393,7 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge): user = get_object_or_404(User, id=user_id) project = get_object_or_404(Project, id=project_id) staff_charge = staff_charge == "true" + is_training = request.POST.get("training", "false") == "true" bypass_interlock = request.POST.get("bypass", "False") == "True" # Figure out if the tool usage is part of remote work # 1: Staff charge means it's always remote work @@ -405,6 +421,12 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge): except RequiredUnansweredQuestionsException as e: return HttpResponseBadRequest(str(e)) + # Validate usage event + try: + new_usage_event.full_clean() + except ValidationError as e: + return HttpResponseBadRequest(str(e)) + # All policy checks passed so enable the tool for the user. if tool.interlock and not tool.interlock.unlock(): if bypass_interlock and interlock_bypass_allowed(user): @@ -435,10 +457,16 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge): area_access.staff_charge = new_staff_charge area_access.customer = new_staff_charge.customer area_access.project = new_staff_charge.project + try: + area_access.full_clean() + except ValidationError as e: + return HttpResponseBadRequest(str(e)) area_access.save() # Now we can safely save the usage event new_usage_event.remote_work = remote_work + if (user.is_staff or user in tool.superusers.all()) and not remote_work and is_training: + new_usage_event.training = True new_usage_event.save() # Remove wait list entry if it exists @@ -447,10 +475,9 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge): wait_list_entry.update(deleted=True, date_exited=timezone.now()) try: - dynamic_form.charge_for_consumables(new_usage_event, new_usage_event.pre_run_data, request) + dynamic_form.process_run_data(new_usage_event, new_usage_event.pre_run_data, request) except Exception as e: return HttpResponseBadRequest(str(e)) - dynamic_form.update_tool_counters(new_usage_event.pre_run_data, tool.id) return response @@ -495,22 +522,17 @@ def disable_tool(request, tool_id): try: current_usage_event.run_data = dynamic_form.extract(request) except RequiredUnansweredQuestionsException as e: - if ( - (user.is_staff or user.is_user_office) - and user != current_usage_event.operator - and current_usage_event.user != user - ): - # if a staff is forcing somebody off the tool and there are required questions, send an email and proceed + if user != current_usage_event.operator and current_usage_event.user != user: + # if someone else is forcing somebody off the tool and there are required questions, send an email and proceed current_usage_event.run_data = e.run_data email_managers_required_questions_disable_tool(current_usage_event.operator, user, tool, e.questions) else: return HttpResponseBadRequest(str(e)) try: - dynamic_form.charge_for_consumables(current_usage_event, current_usage_event.run_data, request) + dynamic_form.process_run_data(current_usage_event, current_usage_event.run_data, request) except Exception as e: return HttpResponseBadRequest(str(e)) - dynamic_form.update_tool_counters(current_usage_event.run_data, tool.id) current_usage_event.save() if user.charging_staff_time(): @@ -525,6 +547,9 @@ def disable_tool(request, tool_id): if area_record and tool.ask_to_leave_area_when_done_using and able_to_self_log_out_of_area(user): return render(request, "tool_control/logout_user.html", {"area": area_record.area, "tool": tool}) + if current_usage_event.training: + return response_js_redirect("training", query_string=f"usage_event_id={current_usage_event.id}") + return HttpResponse() @@ -581,13 +606,13 @@ def do_exit_wait_list(entry, time): @login_required @require_GET def past_comments_and_tasks(request): - user: User = request.user - start, end = extract_optional_beginning_and_end_times(request.GET) - search = request.GET.get("search") - if not start and not end and not search: - return HttpResponseBadRequest("Please enter a search keyword, start date or end date.") - tool_id = request.GET.get("tool_id") try: + user: User = request.user + start, end = extract_optional_beginning_and_end_times(request.GET) + search = request.GET.get("search") + if not start and not end and not search: + return HttpResponseBadRequest("Please enter a search keyword, start date or end date.") + tool_id = request.GET.get("tool_id") tasks = Task.objects.filter(tool_id=tool_id) comments = Comment.objects.filter(tool_id=tool_id) if not user.is_staff: @@ -674,12 +699,14 @@ def tool_usage_group_question(request, tool_id, group_name): ) -@staff_member_required +@login_required @require_GET def reset_tool_counter(request, counter_id): counter = get_object_or_404(ToolUsageCounter, id=counter_id) + if request.user not in counter.reset_permitted_users(): + return redirect("landing") counter.last_reset_value = counter.value - counter.value = 0 + counter.value = counter.default_value counter.last_reset = datetime.now() counter.last_reset_by = request.user counter.save() @@ -687,28 +714,29 @@ def reset_tool_counter(request, counter_id): # Save a comment about the counter being reset. comment = Comment() comment.tool = counter.tool - comment.content = f"The {counter.name} counter was reset to 0. Its last value was {counter.last_reset_value}." + comment.content = f"The {counter.name} counter was reset to {counter.default_value}. Its last value was {counter.last_reset_value}." comment.author = request.user comment.expiration_date = timezone.now() comment.save() - # Email Lab Managers about the counter being reset. - facility_managers = [ - email - for manager in User.objects.filter(is_active=True, is_facility_manager=True) - for email in manager.get_emails(manager.get_preferences().email_send_task_updates) - ] - if facility_managers: - message = f"""The {counter.name} counter for the {counter.tool.name} was reset to 0 on {formats.localize(counter.last_reset)} by {counter.last_reset_by}. - -Its last value was {counter.last_reset_value}.""" - send_mail( - subject=f"{counter.tool.name} counter reset", - content=message, - from_email=get_email_from_settings(), - to=facility_managers, - email_category=EmailCategory.SYSTEM, - ) + if counter.email_facility_managers_when_reset: + # Email Lab Managers about the counter being reset. + facility_managers = [ + email + for manager in User.objects.filter(is_active=True, is_facility_manager=True) + for email in manager.get_emails(manager.get_preferences().email_send_task_updates) + ] + if facility_managers: + message = f"""The {counter.name} counter for the {counter.tool.name} was reset to {counter.default_value} on {formats.localize(counter.last_reset)} by {counter.last_reset_by}. + + Its last value was {counter.last_reset_value}.""" + send_mail( + subject=f"{counter.tool.name} counter reset", + content=message, + from_email=get_email_from_settings(), + to=facility_managers, + email_category=EmailCategory.SYSTEM, + ) return redirect("tool_control") @@ -731,38 +759,28 @@ def email_managers_required_questions_disable_tool( ): user_office_email = EmailsCustomization.get("user_office_email_address") abuse_email_address = EmailsCustomization.get("abuse_email_address") - cc_users: List[User] = [staff_member, tool.primary_owner] - # Add facility managers as CC based on their tool notification preferences if any - cc_users.extend( - User.objects.filter(is_active=True, is_facility_manager=True).filter( - Q(preferences__tool_task_notifications__isnull=True) | Q(preferences__tool_task_notifications__in=[tool]) + message = get_media_file_contents("tool_required_unanswered_questions_email.html") + if message: + cc_users: List[User] = [staff_member, tool.primary_owner] + # Add facility managers as CC based on their tool notification preferences if any + cc_users.extend( + User.objects.filter(is_active=True, is_facility_manager=True).filter( + Q(preferences__tool_task_notifications__isnull=True) + | Q(preferences__tool_task_notifications__in=[tool]) + ) + ) + ccs = [email for user in cc_users for email in user.get_emails(EmailNotificationType.BOTH_EMAILS)] + ccs.append(abuse_email_address) + rendered_message = render_email_template(message, {"user": tool_user, "tool": tool, "questions": questions}) + tos = tool_user.get_emails(EmailNotificationType.BOTH_EMAILS) + send_mail( + subject=f"Unanswered post‑usage questions after logoff from the {tool.name}", + content=rendered_message, + from_email=user_office_email, + to=tos, + cc=ccs, + email_category=EmailCategory.ABUSE, ) - ) - facility_name = ApplicationCustomization.get("facility_name") - ccs = [email for user in cc_users for email in user.get_emails(EmailNotificationType.BOTH_EMAILS)] - ccs.append(abuse_email_address) - display_questions = "".join( - [linebreaksbr(mark_safe(question.render_as_text())) + "

" for question in questions] - ) - message = f""" -Dear {tool_user.get_name()},
-You have been logged off by staff from the {tool} that requires answers to the following post-usage questions:
-
-{display_questions} -
-Regards,
-
-{facility_name} Management
-""" - tos = tool_user.get_emails(EmailNotificationType.BOTH_EMAILS) - send_mail( - subject=f"Unanswered post‑usage questions after logoff from the {tool.name}", - content=message, - from_email=user_office_email, - to=tos, - cc=ccs, - email_category=EmailCategory.ABUSE, - ) def send_tool_usage_counter_email(counter: ToolUsageCounter): @@ -792,6 +810,7 @@ def format_usage_data( try: user_data = f"{usage_event.user.first_name} {usage_event.user.last_name}" + operator_data = f"{usage_event.operator.first_name} {usage_event.operator.last_name}" run_data: Dict = loads(usage_run_data) for question_key, question in run_data.items(): if "user_input" in question: @@ -810,6 +829,7 @@ def format_usage_data( group_usage_data[name] = user_input if group_usage_data: group_usage_data["user"] = user_data + group_usage_data["operator"] = operator_data group_usage_data["date"] = date_data if show_project_info: group_usage_data["project"] = usage_event.project.name @@ -819,6 +839,7 @@ def format_usage_data( usage_data[question_key] = question["user_input"] if usage_data: usage_data["user"] = user_data + usage_data["operator"] = operator_data usage_data["date"] = date_data if show_project_info: usage_data["project"] = usage_event.project.name diff --git a/NEMO/views/training.py b/NEMO/views/training.py index 7dd723a33..727bc6cd7 100644 --- a/NEMO/views/training.py +++ b/NEMO/views/training.py @@ -13,7 +13,7 @@ from NEMO.decorators import staff_member_or_tool_superuser_required from NEMO.exceptions import ProjectChargeException -from NEMO.models import MembershipHistory, Project, Tool, ToolQualificationGroup, TrainingSession, User +from NEMO.models import MembershipHistory, Project, Tool, ToolQualificationGroup, TrainingSession, UsageEvent, User from NEMO.policy import policy_class as policy from NEMO.utilities import datetime_input_format from NEMO.views.customization import TrainingCustomization @@ -26,6 +26,10 @@ @require_GET def training(request): """Present a web page to allow staff or tool superusers to charge training and qualify users on particular tools.""" + return render(request, "training/training.html", get_training_dictionary(request)) + + +def get_training_dictionary(request): user: User = request.user users = User.objects.filter(is_active=True).exclude(id=user.id) tools = Tool.objects.filter( @@ -44,27 +48,33 @@ def training(request): if training_only_type is not None: # only keep the one type training_types = [training_type for training_type in training_types if training_type[0] == training_only_type] - return render( - request, - "training/training.html", - { - "users": users, - "tools": list(tools), - "tool_groups": list(tool_groups), - "charge_types": training_types, - }, - ) + usage_event = None + if request.GET.get("usage_event_id", None): + usage_event = UsageEvent.objects.filter(id=request.GET.get("usage_event_id", None) or None).first() + dictionary = { + "users": users, + "tools": list(tools), + "tool_groups": list(tool_groups), + "charge_types": training_types, + "duration": usage_event.duration_minutes() if usage_event else None, + "date": usage_event.end if usage_event else None, + "usage_event": usage_event, + } + return dictionary @staff_member_or_tool_superuser_required @require_GET def training_entry(request): entry_number = int(request.GET["entry_number"]) - return render( - request, - "training/training_entry.html", - {"entry_number": entry_number, "charge_types": TrainingSession.Type.Choices}, - ) + dictionary = get_training_dictionary(request) + dictionary["entry_number"] = entry_number + # Pass in any eligible data to the training entry for prefilling + eligible_data = ["duration", "charge_type_id"] + for key, value in request.GET.items(): + if key in eligible_data: + dictionary[key] = value + return render(request, "training/training_entry.html", dictionary) def is_valid_field(field): @@ -81,6 +91,7 @@ def charge_training(request): date_allowed = TrainingCustomization.get_bool("training_allow_date") try: charges = {} + usage_event_id = request.POST.get("usage_event_id", None) or None for key, value in request.POST.items(): if is_valid_field(key): attribute, separator, index = key.partition("__") @@ -88,6 +99,7 @@ def charge_training(request): if index not in charges: charges[index] = TrainingSession() charges[index].trainer = trainer + charges[index].usage_event_id = usage_event_id if attribute == "chosen_user": charges[index].trainee = User.objects.get(id=to_int_or_negative(value)) if attribute == "chosen_tool": @@ -144,7 +156,7 @@ def charge_training(request): dictionary = { "title": "Success!", "content": "Training charges were successfully saved.", - "redirect": reverse("landing"), + "redirect": reverse("training"), } return render(request, "display_success_and_redirect.html", dictionary) diff --git a/NEMO/views/usage.py b/NEMO/views/usage.py index 587e3fb47..5f5f20db9 100644 --- a/NEMO/views/usage.py +++ b/NEMO/views/usage.py @@ -40,7 +40,7 @@ billable_items_training_sessions, billable_items_usage_events, ) -from NEMO.views.customization import ProjectsAccountsCustomization, UserRequestsCustomization +from NEMO.views.customization import AdjustmentRequestsCustomization, ProjectsAccountsCustomization logger = getLogger(__name__) @@ -104,7 +104,7 @@ def date_parameters_dictionary(request, default_function: Callable = get_month_t "identifier": identifier, "tab_url": get_url_for_other_tab(request) if get_billing_service().get("available", False) else "", "billing_service": get_billing_service().get("available", False), - "adjustment_time_limit": UserRequestsCustomization.get_date_limit(), + "adjustment_time_limit": AdjustmentRequestsCustomization.get_date_limit(), "existing_adjustments": existing_adjustments, } return dictionary, start_date, end_date, kind, identifier diff --git a/NEMO/views/user_requests.py b/NEMO/views/user_requests.py index 892a7588f..62249b10a 100644 --- a/NEMO/views/user_requests.py +++ b/NEMO/views/user_requests.py @@ -3,7 +3,7 @@ from django.views.decorators.http import require_GET from NEMO.models import Area, PhysicalAccessLevel, User -from NEMO.views.customization import UserRequestsCustomization +from NEMO.views.customization import AdjustmentRequestsCustomization, UserRequestsCustomization @login_required @@ -17,7 +17,7 @@ def user_requests(request, tab: str = None): ) buddy_requests_title = UserRequestsCustomization.get("buddy_requests_title") access_requests_title = UserRequestsCustomization.get("access_requests_title") - adjustment_requests_title = UserRequestsCustomization.get("adjustment_requests_title") + adjustment_requests_title = AdjustmentRequestsCustomization.get("adjustment_requests_title") dictionary = { "tab": active_tab, "buddy_requests_title": buddy_requests_title, diff --git a/NEMO/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index 85fefaa50..42f98f00b 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -5,19 +5,21 @@ import sys from collections import Counter from copy import copy -from distutils.util import strtobool from json import dumps, loads from logging import getLogger -from typing import Any, Callable, Dict, List, Optional, Type +from typing import Any, Callable, Dict, List, Optional, Type, Union +from django.contrib import messages +from django.core.exceptions import ValidationError from django.http import QueryDict from django.urls import NoReverseMatch, reverse from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from NEMO.evaluators import evaluate_expression, get_expression_variables from NEMO.exceptions import RequiredUnansweredQuestionsException -from NEMO.models import Consumable, ToolUsageCounter -from NEMO.utilities import EmptyHttpRequest, quiet_int, slugify_underscore +from NEMO.models import Consumable, Task, ToolUsageCounter, UsageEvent +from NEMO.utilities import EmptyHttpRequest, quiet_int, slugify_underscore, strtobool from NEMO.views.consumables import make_withdrawal dynamic_form_logger = getLogger(__name__) @@ -29,11 +31,12 @@ class PostUsageQuestion: question_type = "Question" - required_span = '*' + required_span = '*' - def __init__(self, properties: Dict, index: int = None): + def __init__(self, properties: Dict, index: int = None, initial_data=None): self.properties = properties self.name = self._init_property("name") + self.initial_name = self.name self.title = self._init_property("title") self.title_html = self._init_property("title_html") self.help = self._init_property("help") @@ -51,12 +54,15 @@ def __init__(self, properties: Dict, index: int = None): self.rows = self._init_property("rows") self.consumable = self._init_property("consumable") self.consumable_id = self._init_property("consumable_id") - self.required = self._init_property("required", True) + self.required = self._init_property("required", boolean=True) # For backwards compatibility keep default choice self.default_value = self._init_property("default_value") or self._init_property("default_choice") self.choices = self._init_property("choices") self.labels = self._init_property("labels") self.formula = self._init_property("formula") + self.options = self._init_property("options") + self.form_row = self._init_property("form_row") + self.form_cell = self._init_property("form_cell") self.group_add_button_name = self._init_property("group_add_button_name") or "Add" self.index = index if index and not isinstance(self, PostUsageGroupQuestion): @@ -65,6 +71,11 @@ def __init__(self, properties: Dict, index: int = None): self.form_name = f"df_{slugify_underscore(self.name)}" self.all_questions: List[PostUsageQuestion] = [] self.is_sub_question: bool = False + self.initial_data = initial_data + + def get_default_value(self): + # Initial data always has precedence over the default value from the question definition + return self.initial_data if self.initial_data is not None else self.default_value def _init_property(self, prop: str, boolean: bool = False) -> Any: if boolean: @@ -88,8 +99,8 @@ def validate(self): self.validate_property_exists("title") self.validate_property_exists("type") - def render(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: - return self.render_element(virtual_inputs, group_question_url, group_item_id) + self.render_script( + def render(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: + return self.render_element(virtual_inputs, group_question_url, group_item_id, extra_class) + self.render_script( virtual_inputs, group_question_url, group_item_id ) @@ -100,7 +111,7 @@ def render_as_text(self) -> str: result += " (possible choices: " + "|".join(self.choices) + ")" return result - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: return "" def render_script(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: @@ -130,11 +141,15 @@ def validate_labels_and_choices(self): raise Exception("When using labels you need one for each choice") @staticmethod - def load_questions(questions: Optional[List[Dict]], index: int = None): + def load_questions(questions: Optional[List[Dict]], index: int = None, initial_data: Union[Dict, str] = None): + user_inputs = get_submitted_user_inputs(initial_data) questions_to_load = questions or [] post_usage_questions: List[PostUsageQuestion] = [] for question in questions_to_load: - post_usage_questions.append(question_types.get(question["type"], PostUsageQuestion)(question, index)) + question_initial_data = user_inputs[question["name"]] if question["name"] in user_inputs else None + post_usage_questions.append( + question_types.get(question["type"], PostUsageQuestion)(question, index, question_initial_data) + ) return post_usage_questions @@ -145,14 +160,14 @@ def validate(self): super().validate() self.validate_labels_and_choices() - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: title = self.title_html or self.title - result = f'

{title}{self.required_span if self.required else ""}
' + result = f'
{title}{self.required_span if self.required else ""}
' for index, choice in enumerate(self.choices): label = self.labels[index] if self.labels else choice result += '
' required = "required" if self.required else "" - is_default_choice = "checked" if self.default_value and self.default_value == choice else "" + is_default_choice = "checked" if self.get_default_value() and self.get_default_value() == choice else "" result += f'' result += "
" result += "
" @@ -166,15 +181,15 @@ def validate(self): super().validate() self.validate_labels_and_choices() - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: title = self.title_html or self.title - result = f'
{title}{self.required_span if self.required else ""}
' + result = f'
{title}{self.required_span if self.required else ""}
' result += f'' for index, choice in enumerate(self.choices): label = self.labels[index] if self.labels else choice result += '
' required = f"""onclick="checkbox_required('{self.form_name}')" """ if self.required else "" - is_default_choice = "checked" if self.default_value and self.default_value == choice else "" + is_default_choice = "checked" if self.get_default_value() and self.get_default_value() == choice else "" result += f'' result += "
" result += "
" @@ -204,14 +219,13 @@ class PostUsageDropdownQuestion(PostUsageQuestion): def validate(self): super().validate() - self.validate_property_exists("max-width") self.validate_labels_and_choices() - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: title = self.title_html or self.title - max_width = f"max-width:{self.max_width}px" - result = f'
{title}{self.required_span if self.required else ""}
' + result = f'
{title}{self.required_span if self.required else ""}
' required = "required" if self.required else "" + max_width = f"max-width:{self.max_width}px" if self.max_width else "" result += ( f'" if self.help: @@ -232,14 +246,10 @@ def render_element(self, virtual_inputs: bool, group_question_url: str, group_it class PostUsageTextFieldQuestion(PostUsageQuestion): question_type = "Question of type text" - def validate(self): - super().validate() - self.validate_property_exists("max-width") - - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: title = self.title_html or self.title - max_width = f"max-width:{self.max_width}px" - result = '
' + max_width = f"max-width:{self.max_width}px" if self.max_width else "" + result = f'
' result += f'' input_group_required = True if self.prefix or self.suffix else False if input_group_required: @@ -249,7 +259,7 @@ def render_element(self, virtual_inputs: bool, group_question_url: str, group_it required = "required" if self.required else "" pattern = f'pattern="{self.pattern}"' if self.pattern else "" placeholder = f'placeholder="{self.placeholder}"' if self.placeholder else "" - default_value = f'value="{self.default_value}"' if self.default_value else "" + default_value = f'value="{self.get_default_value()}"' if self.get_default_value() else "" result += self.render_input(required, pattern, placeholder, default_value) if self.suffix: result += f'{self.suffix}' @@ -262,7 +272,8 @@ def render_element(self, virtual_inputs: bool, group_question_url: str, group_it def render_input(self, required: str, pattern: str, placeholder: str, default_value: str) -> str: maxlength = f'maxlength="{self.maxlength}"' if self.maxlength else "" - return f'' + max_width = f"max-width:{self.max_width}px" if self.max_width else "" + return f'' def render_script(self, virtual_inputs: bool, group_question_url: str, item_id: int) -> str: if virtual_inputs: @@ -284,7 +295,7 @@ class PostUsageTextAreaFieldQuestion(PostUsageTextFieldQuestion): def render_input(self, required: str, pattern: str, placeholder: str, default_value: str) -> str: rows = f'rows="{str(self.rows)}"' if self.rows else "" - return f'' + return f'' class PostUsageNumberFieldQuestion(PostUsageTextFieldQuestion): @@ -348,7 +359,7 @@ def all_questions_by_name(self): def all_questions_by_form_name(self): return {question.form_name: question for question in self.all_questions} - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: return f'' def validate(self): @@ -437,44 +448,85 @@ def extract_for_formula(self, request, index=None) -> Any: return evaluate_expression(formula, **extracted_form_values) if all_valid else None +class PostUsageRadioReportProblemQuestion(PostUsageRadioQuestion): + question_type = "Question of type radio report problem" + + def __init__(self, properties: Dict, index: int = None, initial_data=None): + properties["choices"] = ["true", "false"] + if not properties.get("labels"): + properties["labels"] = ["Yes", "No"] + super().__init__(properties, index, initial_data) + + def validate(self): + super().validate() + self.validate_property_exists("options") + task = Task(**self.options) + try: + if not task.problem_description: + raise ValidationError({"problem_description": _("This field is required")}) + task.full_clean(exclude=["tool", "creator", "urgency", "safety_hazard", "force_shutdown"]) + except ValidationError as e: + raise Exception(f"{self.question_type} options are invalid: {e.message_dict}") + + class PostUsageGroupQuestion(PostUsageQuestion): question_type = "Question of type group" - def __init__(self, properties: Dict, index: int = None): - super().__init__(properties, index) + def __init__(self, properties: Dict, index: int = None, initial_data=None): + super().__init__(properties, index, initial_data) self.max_number = self._init_property("max_number") # we need a safe group name to use in js function and variable names self.group_name = slugify_underscore(self.name) - self.sub_questions: List[PostUsageQuestion] = PostUsageQuestion.load_questions( - self._init_property("questions"), index - ) + self.load_sub_questions(index) for sub_question in self.sub_questions: sub_question.is_sub_question = True + def load_sub_questions(self, index, initial_data=None): + self.sub_questions = PostUsageQuestion.load_questions(self._init_property("questions"), index) + if initial_data: + for sub_question in self.sub_questions: + sub_question.initial_data = initial_data.get(sub_question.initial_name, None) + def validate(self): super().validate() self.validate_property_exists("questions") self.validate_property_exists("max_number") for sub_question in self.sub_questions: + if isinstance(sub_question, PostUsageRadioReportProblemQuestion): + raise Exception(f"{sub_question.question_type} cannot be used inside a group question") sub_question.validate() - def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: + def get_initial_data_for_subquestion(self, sub_question_name): + if self.initial_data and self.index is not None and self.index < len(self.initial_data): + index_data = self.initial_data[self.index] or {} + return index_data.get(sub_question_name, None) + + def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int, extra_class="") -> str: title = self.title_html or self.title - result = f'
{title}
' + result = f'
' + result += f'
{title}
' result += f'
' - result += self.render_group_question(virtual_inputs, group_question_url, group_item_id) - result += "
" + # It's a bit more complicated here, we need to render multiple groups if we have initial data + # So we change the index, reload sub questions and render each of them + if self.initial_data: + for index, data in enumerate(self.initial_data): + self.load_sub_questions(index, data) + result += self.render_group_question(virtual_inputs, group_question_url, group_item_id) + result += "
" + else: + result += self.render_group_question(virtual_inputs, group_question_url, group_item_id) + result += "
" result += "
" result += '
' result += f'' result += "
" + result += "
" return result def render_group_question(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str: result = "" result += f'
' - for sub_question in self.sub_questions: - result += sub_question.render(virtual_inputs, group_question_url, group_item_id) + result += render_grid_questions(self.sub_questions, group_question_url, group_item_id, virtual_inputs) if self.index: result += f'' return result @@ -483,7 +535,7 @@ def render_script(self, virtual_inputs: bool, group_question_url: str, item_id: return f""" " - for question in self.questions: - result += question.render(virtual_inputs, group_question_url, group_item_id) + result += f'
{render_grid_questions(self.questions, group_question_url, group_item_id, virtual_inputs)}
' return mark_safe(result) def validate(self, group_question_url: str, group_item_id: int): @@ -639,15 +692,20 @@ def filter_questions(self, function: Callable[[PostUsageQuestion], bool]) -> Lis results.append(sub_question) return results - def charge_for_consumables(self, usage_event, run_data: str, request=None): - customer = usage_event.user - merchant = usage_event.operator - project = usage_event.project + def process_run_data(self, usage_event: UsageEvent, run_data: str, request=None): try: run_data_json = loads(run_data) except Exception as error: dynamic_form_logger.debug(error) return + self._charge_for_consumables(usage_event, run_data_json, request) + self._update_tool_counters(usage_event, run_data_json) + self._report_problems(usage_event, run_data_json, request) + + def _charge_for_consumables(self, usage_event, run_data_json: Dict, request=None): + customer = usage_event.user + merchant = usage_event.operator + project = usage_event.project for question in self.questions: input_data = run_data_json[question.name] if question.name in run_data_json else None withdraw_consumable_for_question(question, input_data, customer, merchant, project, usage_event, request) @@ -657,41 +715,60 @@ def charge_for_consumables(self, usage_event, run_data: str, request=None): sub_question, input_data, customer, merchant, project, usage_event, request ) - def update_tool_counters(self, run_data: str, tool_id: int): - # This function increments all counters associated with the given tool - try: - run_data_json = loads(run_data) - except Exception as error: - dynamic_form_logger.debug(error) - return - active_counters = ToolUsageCounter.objects.filter(is_active=True, tool_id=tool_id) + def _update_tool_counters(self, usage_event: UsageEvent, run_data_json: Dict): + # This function increments/decrements all counters associated with the given tool + active_counters = ToolUsageCounter.objects.filter(is_active=True, tool_id=usage_event.tool_id) for counter in active_counters: additional_value = 0 for question in self.questions: input_data = run_data_json[question.name] if question.name in run_data_json else None - additional_value += get_counter_increment_for_question( - question, input_data, counter.tool_usage_question - ) + additional_value += get_counter_value_for_question(question, input_data, counter.tool_usage_question) if isinstance(question, PostUsageGroupQuestion): for sub_question in question.sub_questions: - additional_value += get_counter_increment_for_question( + additional_value += get_counter_value_for_question( sub_question, input_data, counter.tool_usage_question ) if additional_value: - counter.value += additional_value + counter.value += counter.counter_direction * additional_value counter.save() + def _report_problems(self, usage_event: UsageEvent, run_data_json: Dict, request): + for question in self.questions: + input_data = run_data_json[question.name] if question.name in run_data_json else None + if isinstance(question, PostUsageRadioReportProblemQuestion): + if "user_input" in input_data and input_data["user_input"] == "true": + from NEMO.views.tasks import save_task + + task = Task(**question.options) + if task.force_shutdown is None: + task.force_shutdown = False + if task.safety_hazard is None: + task.safety_hazard = False + task.creator = usage_event.operator + task.tool = usage_event.tool + if task.urgency is None: + task.urgency = ( + Task.Urgency.HIGH if task.safety_hazard or task.force_shutdown else Task.Urgency.NORMAL + ) + save_task(request, task, usage_event.operator) + message = f"A problem report was automatically send to staff{' and the tool was shutdown' if task.force_shutdown else ''}" + messages.success(request, message, extra_tags="data-speed=9000") -def get_submitted_user_inputs(user_data: str) -> Dict: - """Takes the user data as a string and returns a dictionary of inputs or a list of inputs for group fields""" + +def get_submitted_user_inputs(user_data: Union[str, dict]) -> Dict: + """Takes the user data as a string or dict and returns a dictionary of inputs or a list of inputs for group fields""" user_input = {} - user_data_json = loads(user_data) - for field_name, data in user_data_json.items(): - if "user_input" in data: - if data["type"] != "group": - user_input[field_name] = data["user_input"] - else: - user_input[field_name] = data["user_input"].values() + try: + user_data_json = loads(user_data) if isinstance(user_data, str) and user_data else user_data + if user_data_json: + for field_name, data in user_data_json.items(): + if "user_input" in data: + if data["type"] != "group": + user_input[field_name] = data["user_input"] + else: + user_input[field_name] = list(data["user_input"].values()) + except Exception as e: + dynamic_form_logger.exception(e) return user_input @@ -705,6 +782,25 @@ def render_group_questions(request, questions, group_question_url, group_item_id return "" +def render_grid_questions(questions, group_question_url: str, group_item_id: int, virtual_inputs: bool): + # only use the grid if we have "form_row" defined for at least one item + use_grid = max([q.form_row for q in questions if q.form_row], default=0) + result = "" + for row in sort_question_for_grid(questions): + if row: + extra_class = "" + max_cells = len([q for q in row if not isinstance(q, PostUsageFormulaQuestion)]) + if use_grid and max_cells: + cell_width = 12 // max_cells + result += '
' + extra_class = f"col-md-{cell_width or 12}" + for question in row: + result += question.render(virtual_inputs, group_question_url, group_item_id, extra_class) + if use_grid and max_cells: + result += "
" + return result + + def validate_consumable_for_question(question: PostUsageQuestion): if question.has_consumable: if not isinstance(question, (PostUsageNumberFieldQuestion, PostUsageFormulaQuestion)): @@ -747,7 +843,7 @@ def withdraw_consumable_for_question(question, input_data, customer, merchant, p ) -def get_counter_increment_for_question(question, input_data, counter_question): +def get_counter_value_for_question(question, input_data, counter_question): additional_value = 0 if isinstance(question, (PostUsageNumberFieldQuestion, PostUsageFloatFieldQuestion)): if question.name == counter_question and "user_input" in input_data and input_data["user_input"]: @@ -794,6 +890,27 @@ def admin_render_dynamic_form_preview(dynamic_form_json: str, group_url: str, it ) +def sort_question_for_grid(questions: List[PostUsageQuestion]) -> List[List[PostUsageQuestion]]: + rows: List[List[PostUsageQuestion]] = [] + + # Sorting the list of questions by row, None last + row_cells = sorted(questions, key=lambda x: (x.form_row is None, x.form_row)) + + # Extracting the unique rows + unique_rows = sorted(set(q.form_row for q in row_cells if q.form_row is not None)) + # Adding row None at the end + unique_rows.append(None) + + for row in unique_rows: + # Extracting cells for the current row and sorting them + cells_in_row = sorted( + [q for q in row_cells if q.form_row == row], key=lambda x: (x.form_cell is None, x.form_cell) + ) + rows.append(cells_in_row) + + return rows + + def match_group_index(form_name: str) -> Optional[re.Pattern]: # This will match form_name or any combination of form_name_1, form_name_2 etc. return re.compile("^" + form_name + "(_(\d+))?$") @@ -805,6 +922,7 @@ def match_group_index(form_name: str) -> Optional[re.Pattern]: "textbox": PostUsageTextFieldQuestion, "textarea": PostUsageTextAreaFieldQuestion, "radio": PostUsageRadioQuestion, + "radio_report_problem": PostUsageRadioReportProblemQuestion, "checkbox": PostUsageCheckboxQuestion, "dropdown": PostUsageDropdownQuestion, "formula": PostUsageFormulaQuestion, diff --git a/README.md b/README.md index 0956d006b..322b24733 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Code style: black](https://img.shields.io/badge/python%20style-black-000000.svg)](https://github.com/psf/black) [![Code style: djlint](https://img.shields.io/badge/html%20style-djlint-black.svg)](https://www.djlint.com) -[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/NEMO?label=python)](https://www.python.org/downloads/release/python-380/) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/NEMO?label=python)](https://www.python.org/downloads/release/python-390/) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/nanofab/nemo?label=NEMO%20docker%20version)](https://hub.docker.com/r/nanofab/nemo) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/usnistgov/nemo?label=NEMO%20github%20version)](https://github.com/usnistgov/NEMO/releases) [![PyPI](https://img.shields.io/pypi/v/nemo?label=NEMO%20pypi%20version)](https://pypi.org/project/NEMO/) diff --git a/gunicorn_configuration.py b/gunicorn_configuration.py index 0c7c6a905..46c281923 100644 --- a/gunicorn_configuration.py +++ b/gunicorn_configuration.py @@ -1,14 +1,15 @@ # This is the default Gunicorn configuration file for the NEMO Docker image. # It is mounted inside the image at /etc/gunicorn_configuration.py, and you can override it by # mounting your own configuration file to this location or providing command line arguments to Gunicorn. - +import os from multiprocessing import cpu_count bind = "0.0.0.0:8000" -worker_class = "gthread" +preload_app = True +worker_class = os.getenv("GUNICORN_WORKER_CLASS", "gthread") or "gthread" # The following value was decided based on the Gunicorn documentation and configuration example: # http://docs.gunicorn.org/en/stable/configure.html#configuration-file -workers = min(cpu_count() * 2 + 1, 9) -threads = 8 -keepalive = 300 -capture_output = True +workers = int(os.getenv("GUNICORN_WORKER_COUNT", min(cpu_count() * 2 + 1, 9)) or min(cpu_count() * 2 + 1, 9)) +threads = int(os.getenv("GUNICORN_THREAD_COUNT", "8") or "8") +keepalive = int(os.getenv("GUNICORN_KEEPALIVE_SECONDS", "300") or "300") +capture_output = (os.getenv("GUNICORN_CAPTURE_OUTPUT", "True") or "True") == "True" diff --git a/resources/emails/adjustment_request_notification_email.html b/resources/emails/adjustment_request_notification_email.html index 0c60569b6..20335b510 100644 --- a/resources/emails/adjustment_request_notification_email.html +++ b/resources/emails/adjustment_request_notification_email.html @@ -54,17 +54,22 @@

ADJUSTMENT REQUEST {{ st
Charge: {{ adjustment_request.item.get_display }}
- Start time: {{ adjustment_request.item.start|date:"SHORT_DATETIME_FORMAT" }} - {% if adjustment_request.get_new_start %} - -> {{ adjustment_request.get_new_start|date:"SHORT_DATETIME_FORMAT" }} + {% if not adjustment_request.waive %} + Start time: {{ adjustment_request.item.start|date:"SHORT_DATETIME_FORMAT" }} + {% if adjustment_request.get_new_start %} + -> {{ adjustment_request.get_new_start|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} +
+ End time: {{ adjustment_request.item.end|date:"SHORT_DATETIME_FORMAT" }} + {% if adjustment_request.get_new_end %} + -> {{ adjustment_request.get_new_end|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} +
+ Difference: {{ adjustment_request.get_time_difference|default_if_none:"" }} + {% else %} + Waive: I request that this charge be waived {% endif %}
- End time: {{ adjustment_request.item.end|date:"SHORT_DATETIME_FORMAT" }} - {% if adjustment_request.get_new_end %} - -> {{ adjustment_request.get_new_end|date:"SHORT_DATETIME_FORMAT" }} - {% endif %} -
- Difference: {{ adjustment_request.get_time_difference|default_if_none:"" }} {% endif %} {% if adjustment_request.description %}
diff --git a/resources/emails/out_of_time_reservation_email.html b/resources/emails/out_of_time_reservation_email.html index 55e9291e2..bf208c005 100644 --- a/resources/emails/out_of_time_reservation_email.html +++ b/resources/emails/out_of_time_reservation_email.html @@ -11,18 +11,38 @@ font-family: 'Avenir Next', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'"> -

OUT OF TIME IN THE {{ reservation.area }}

+

+ OUT OF + {% if reservation.start %} + TIME + {% else %} + ALLOWED SCHEDULE + {% endif %} + IN THE {{ reservation.area }} +

Dear {{ reservation.user.first_name }},

-

Your reservation of the {{ reservation.area }} in the facility ended at {{ reservation.end }}.

+

+ Your + {% if reservation.start %} + reservation of the + {% else %} + allowed schedule for the + {% endif %} + {{ reservation.area }} in the {{ facility_name }} ended at {{ reservation.end }}. +

However, our records show that you are still logged in the {{ reservation.area }}.

If you simply forgot to log out, please contact a staff member


- Please remember that the facility is a shared resource, and overstaying a reservation may inhibit the productivity of other facility users. + {% if reservation.start %} + Please remember that the {{ facility_name }} is a shared resource, and overstaying a reservation may inhibit the productivity of other facility users. + {% else %} + Please remember that users are not allowed to overstay in areas outside their allowed schedule. + {% endif %}

diff --git a/resources/emails/tool_required_unanswered_questions_email.html b/resources/emails/tool_required_unanswered_questions_email.html new file mode 100644 index 000000000..1bc9bf8fe --- /dev/null +++ b/resources/emails/tool_required_unanswered_questions_email.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + +
+

REQUIRED UNANSWERED QUESTIONS

+
+

Dear {{ user.get_name }},

+

You have been logged off from the {{ tool }} that requires answers to the following post-usage questions:

+ {% for question in questions %} + {{ question.render_as_text|safe|linebreaksbr }} +
+
+ {% endfor %} +

Regards,

+

+

{{ facility_name }} Management

+
+ + + diff --git a/resources/fixtures/splash_pad.json b/resources/fixtures/splash_pad.json index 1ffb4f0cb..00b978888 100644 --- a/resources/fixtures/splash_pad.json +++ b/resources/fixtures/splash_pad.json @@ -8746,6 +8746,7 @@ "name": "Total gold used", "description": "", "value": 203, + "default_value": 0, "tool": ["Unaxis ICP Etcher"], "tool_usage_question": "gold_used", "last_reset_value": null, diff --git a/resources/settings.py b/resources/settings.py index 02af55e57..ac032dcc0 100644 --- a/resources/settings.py +++ b/resources/settings.py @@ -78,6 +78,15 @@ DATE_INPUT_FORMATS = ["%m/%d/%Y", *global_settings.DATE_INPUT_FORMATS] TIME_INPUT_FORMATS = ["%I:%M:%S %p", *global_settings.TIME_INPUT_FORMATS] +# -------------------- Pick date and time formats -------------------- +# Those formats are optional in most cases and only used on kiosk or mobile views, when picking up date/time separately. +# If not defined, a conversion from DATE_INPUT_FORMATS and TIME_INPUT_FORMATS will be attempted. +# See allowed date formats at https://amsul.ca/pickadate.js/date/#formatting-rules +# See allowed time formats at https://amsul.ca/pickadate.js/time/#formatting-rules +# PICKADATE_DATE_FORMAT = "mm/dd/yyyy" +# PICKADATE_TIME_FORMAT = "HH:i A" + + # -------------------- Internationalization and localization -------------------- # A boolean that specifies whether Django’s translation system should be enabled. # This provides an easy way to turn it off, for performance. diff --git a/setup.py b/setup.py index 06a124ee8..5ce406609 100644 --- a/setup.py +++ b/setup.py @@ -2,8 +2,8 @@ setup( name="NEMO", - version="6.0.3", - python_requires=">=3.8, <4", + version="6.1.0.dev", + python_requires=">=3.9, <4", packages=find_namespace_packages(exclude=["resources", "resources.*", "build", "build.*"]), include_package_data=True, url="https://github.com/usnistgov/NEMO", @@ -20,12 +20,12 @@ "License :: Public Domain", "Natural Language :: English", "Operating System :: OS Independent", - "Framework :: Django :: 3.2", - "Programming Language :: Python :: 3.8", + "Framework :: Django :: 4.2", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], install_requires=[ "cryptography==42.0.8", @@ -33,15 +33,15 @@ "django-auditlog==3.0.0", "django-filter==23.5", "django-mptt==0.14.0", - "djangorestframework==3.15.1", + "djangorestframework==3.15.2", "drf-excel==2.4.0", "drf-flex-fields==1.0.2", "ldap3==2.9.1", - "Pillow==10.3.0", + "Pillow==11.0.0", "pymodbus==3.3.2", "python-dateutil==2.9.0", - "pytz==2024.1", "requests==2.32.3", + "packaging==24.0.0", ], extras_require={"dev-tools": ["pre-commit", "djlint", "black"]}, entry_points={ diff --git a/start_NEMO_in_Docker.sh b/start_NEMO_in_Docker.sh index 285b59027..e644d4878 100755 --- a/start_NEMO_in_Docker.sh +++ b/start_NEMO_in_Docker.sh @@ -3,6 +3,13 @@ # Exit if any of following commands fails set -e +if [[ -n "$NEMO_EXTRA_PIP_PACKAGES" ]]; then + echo "Installing additional Python packages: $NEMO_EXTRA_PIP_PACKAGES" + python3 -m pip install $NEMO_EXTRA_PIP_PACKAGES +else + echo "No additional Python packages to install." +fi + # Collect static files django-admin collectstatic --no-input --clear